写在前面
Vue3发布已有 9 个月,相比Vue2确实做了太多优化,于是想着重新再仔细全面地研究一下Vue2源码,然后对比Vue3做个整理,方便以后复习查阅。
so,今天就从 Vue2 开始吧!
预准备
-
项目地址:github.com/vuejs/vue
-
环境需要
- 2.1. 全局安装 rollup
- 2.2. 修改 dev 脚本,
package.json
"dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:webfull-dev",
-
开始调试
-
3.1. 打包脚本: npm run dev
这里会看到多个版本的 vue 文件,- runtime:运行时,不包含编译器
- common:cjs 规范,用于webpack1环境
- esm:ES 模块,用于webpack2+环境
- umd:兼容 cjs 和 amd,用于浏览器
需要注意的是,平时我们是用vue-cli基于webpack环境,webpack借助vue-loader就提前完成了编译工作,因此不需要
vue
的编译器模块,所以使用的都是runtime.esm
版本 -
3.2. 页面使用
<script src="../../dist/vue.js"></script>
-
-
文件结构关键部分
- src
- compiler 编译器相关
- core 核心代码
- components 通用组件,如
keep-alive
- global-api 全局 api,如
$set
、$delete
- instance 构造函数等
- observer 响应式相关
- util
- vdom 虚拟 dom
- components 通用组件,如
- src
初始化流程
研究源码的第一步就是从初始化入手,这个阶段的内容大多都很抽象,我们并不知道将来有什么具体作用,所以也是最容易劝退的一个环节。可以先在脑海中留个印象,后续很多流程都会回到这里的某个方法继续深究,在这个过程中不断加深记忆。
所谓初始化,就是Vue从无到有的过程,先后经历定义 Vue 的全局属性及方法、创建实例、定义实例的属性及方法、执行数据响应式、挂载 dom
从源码探究流程
首先找到入口文件夹,/src/platforms/web/
,可以看到多个入口文件
- entry-compiler.js
- entry-runtime-with-compiler.js
- entry-runtime.js
- entry-server-basic-renderer.js
- entry-server-renderer.js
从entry-runtime-with-compiler.js
进入,可以获得最全面的内容,包括运行时和编译器两大部分
注入编译器:entry-runtime-with-compiler.js /src/platforms/web/
-
作用
- 为Vue实例上的
$mount
方法注入编译器,该编译器的作用是将 template 转成 render 函数
- 为Vue实例上的
-
核心源码
if (!options.render) { // 先经过 多根/dom和字符串情况 的处理,变成单根的字符串形式 // ... if (template) { const { render, staticRenderFns } = compileToFunctions( template, { outputSourceRange: process.env.NODE_ENV !== 'production', shouldDecodeNewlines, shouldDecodeNewlinesForHref, delimiters: options.delimiters, comments: options.comments, }, this ); options.render = render; } } return mount.call(this, el, hydrating);
template
有两种情况,字符串
或dom选择器
,但最终都会处理成字符串,如果template
是多根元素,经过compileToFunctions
处理只保留第一个节点,这也是template
必须要用单根的原因这一步的关键是获得
render
函数,后续的组件渲染和更新都会用到这个方法。通常在webpack环境中并不需要注入编译器这一步,因为webpack在编译阶段借助vue-loader将单文件中的template
转成render
函数,从而减少生产环境下Vue文件的体积和编译的时间。
注入 web 运行时: /src/platforms/web/runtime/index.js
所谓 web 运行时,其实就是注入 web 平台 特有的方法,因为还要考虑Weex,所以单独分出了这个模块
-
作用:
- 为实例定义patch方法,用于 组件更新
- 为实例上的
$mount
方法额外扩展mountComponent
方法,用于将组件渲染到浏览器
-
核心源码
Vue.prototype.__patch__ = inBrowser ? patch : noop; Vue.prototype.$mount = function (el?: string | Element, hydrating?: boolean): Component { el = el && inBrowser ? query(el) : undefined; return mountComponent(this, el, hydrating); };
为实例添加的这两个方法和 挂载 息息相关,在后续的初始化与更新流程会用到。
定义全局 API:initGlobalAPI /src/core/index.js
-
作用:初始化 Vue 的静态方法,
- Vue.util 中的方法(
mergeOptions
,defineReactive
) Vue.observe
Vue.use
Vue.mixin
Vue.extend
Vue.component/directive/filter
- Vue.util 中的方法(
-
核心源码
Vue.set = set; Vue.delete = del; Vue.nextTick = nextTick; initUse(Vue); // 实现Vue.use函数 initMixin(Vue); // 实现Vue.mixin函数 initExtend(Vue); // 实现Vue.extend函数 initAssetRegisters(Vue); // 注册实现Vue.component/directive/filter
这一步是注入全局方法,通常在开发项目的入口文件用的会很多,比如挂载全局组件、添加指令、使用插件等。
定义实例相关: src/core/instance/index.js
-
作用
-
- 定义Vue构造器,
-
- 为Vue实例注入
API
- 为Vue实例注入
-
-
核心源码
function Vue(options) { this._init(options); } initMixin(Vue); // 初始化 this._init 方法 // 其他实例属性和方法由下面这些方法混入 stateMixin(Vue); eventsMixin(Vue); lifecycleMixin(Vue); renderMixin(Vue);
我们熟知的实例方法基本都来自这里
-
stateMixin 定义
$data
、$props
、$set
、$delete
、$watch
-
eventsMixin 定义
$on
、emit
、off
、once
-
lifecycleMixin 定义
_update
、$forceUpdate
、$destory
-
renderMixin 定义
$nextTick
、_render
为实例注入方法,基本平时工作用都有所涉及,开发项目用的最多的实例 方法都来自这里
-
实例的初始化:this._init src/core/instance/init.js
-
作用
- 调用
mergeOptions
合并Vue.options
和new Vue
传入的参数,赋值给vm.$options
- 伴随着生命周期的执行,为实例添加属性、添加事件监听(组件通信)、初始化渲染相关、执行数据响应式等等,最后调用
$mount
将组件渲染到页面
- 调用
-
核心源码
vm.$options = mergeOptions(resolveConstructorOptions(vm.constructor), options || {}, vm); initLifecycle(vm); // 实例属性:$parent,$root,$children,$refs initEvents(vm); // 监听_parentListeners initRender(vm); // 插槽解析,$slots,$scopeSlots, $createElement() callHook(vm, 'beforeCreate'); // 接下来都是和组件状态相关的数据操作 // inject/provide initInjections(vm); // 注入祖辈传递下来的数据 initState(vm); // 数据响应式:props,methods,data,computed,watch initProvide(vm); // 提供给后代,用来隔代传递参数 callHook(vm, 'created'); // 如果设置了 el,则自动执行$mount() if (vm.$options.el) { vm.$mount(vm.$options.el); }
我们熟知的实例方法基本都来自这里
-
initLifecycle 定义
vm.$parent
、vm.$root
、vm.$refs
、vm.$children
-
initEvents 定义
vm._events
、updateComponentListeners(vm.$listeners)
-
initRender 定义
vm._c
、vm.$createElement
-
initInjections 依次执行
resolveInject
、defineReactive
-
initState 定义
initProps
、initMethods
、initData
、initComputed
、initWatch
-
initProvide 定义
vm._provide
同样也是定义实例上的属性及方法,与上一步不同的是,上个过程定义的api大多是平时工作写业务用的,这个过程提供的方法基本都为后续的源码服务,另外
initLifecycle
中定义的属性平时写组件库的小伙伴可能很熟悉。
-
渲染:$mount 与 mountComponent /src/core/instance/lifecycle.js
$mount
在初始化时分别被扩展了编译器方法和运行时方法,核心在与运行时为其扩展了mountComponent
,这是组件挂载的入口🚩
-
作用:组件初始化和更新最重要的流程之一,为
updateComponent
赋值更新函数,创建Watcher -
核心源码
if (process.env.NODE_ENV !== 'production' && config.performance && mark) { // ... } else { updateComponent = () => { vm._update(vm._render(), hydrating); }; } // we set this to vm._watcher inside the watcher's constructor // since the watcher's initial patch may call $forceUpdate (e.g. inside child // component's mounted hook), which relies on vm._watcher being already defined new Watcher( vm, updateComponent, noop, { before() { if (vm._isMounted && !vm._isDestroyed) { callHook(vm, 'beforeUpdate'); } }, }, true /* isRenderWatcher */ );
在 Vue 中,所有的渲染都由 Watcher(render Watcher) 完成,包括初始化渲染和组件更新,所以这一步结束后,可以在页面上看到真实 dom 的样子。
流程梳理
- 首先初始化全局的静态方法,components、filter、directive。set、delete 等,
- 然后定义Vue 实例的方法,
- 接着执行
init
方法进行实例的初始化,伴随着生命周期的进行执行初始化属性、事件监听、数据响应式,最后调用$mount
将组件挂载到页面上
总结与思考
-
为什么
$mount
要经过扩展?为了方便跨平台开发,因为Vue2新增了Weex,所以这一步是向平台注入特有的方法
-
在
mergeOptions
中发现有个监听事件绑定的操作用于组件通信时,其通信机制是怎样的?
当前组件在mergeOptions
时有一个属性parentListener
用来存放父组件通过 props 绑定的事件,组件会通过$on
将注册到自身,在使用时直接$emit
触发即可 -
生命周期的名称及应用:
-
2.1. 分类列举
- 初始化阶段:beforeCreate、created、beforeMount、mounted
- 更新阶段:beforeUpdate、updated
- 销毁阶段:beforeDestroy、destroyed
-
2.2. 应用:
- created 时,所有数据准备就绪,适合做数据获取、赋值等数据操作
- mounted 时,$el 已生成,可以获取 dom;子组件也已挂载,可以访问它们
- updated 时,数值变化已作用于 dom,可以获取 dom 最新状态
- destroyed 时,组件实例已销毁,适合取消定时器等操作
-
数据响应式
预先准备
响应式原理
借助 Object.defineReactive,可以对一个对象的某个key
进行 get 和 set 的拦截,get 时进行 依赖收集,set 时 触发依赖
但Object.defineReactive
有两个缺陷
- 无法监听动态添加的属性
- 无法监听数组变化
三个概念 及 发布订阅模式
(ps:这里只做原理简析,下文会具体提到详细的 dep 和 watcher 互相引用 等细节问题。)
- Observer
发布者:每个对象(包含子对象)有一个 Observer 实例,内部存在一个 dep,用于管理多个Watcher,当数据改变时,通过 dep 通知 Watcher 进行更新 - Dep
发布订阅中心:内部管理多个Watcher - Watcher
订阅者:执行组件的初始化和更新方法
流程一览
官网的流程图
初始化时进行数据响应式操作和创建组件 Watcher,
- 前者为对象的每个
key
进行getter
/setter
拦截,并创建dep
, - 而组件 Watcher负责组件的渲染和更新。
组件 Watcher在创建时会执行一次组件的render
函数,从而间接触发相关key
的getter
方法,将Watcher收集到key
的dep
中,
当我们更改key
的值时会触发key
的setter
方法,通过key
的dep
通知Watcher进行更新。
从源码探究流程
还记得吗,初始化执行了_init
方法,其中有一个函数initState
,这便是数据响应式的入口
initState /src/core/instance/state.js
-
作用
- 初始化
props
、methods
、data
、computed
和watch
,并进行 响应式处理
- 初始化
-
核心源码
const opts = vm.$options; // 1.props if (opts.props) initProps(vm, opts.props); // 2.methods if (opts.methods) initMethods(vm, opts.methods); // 3.data if (opts.data) { initData(vm); } else { observe((vm._data = {}), true /* asRootData */); } if (opts.computed) initComputed(vm, opts.computed); if (opts.watch && opts.watch !== nativeWatch) { initWatch(vm, opts.watch); }
可以看到,这里响应式处理的对象是
_data
,因此_data
将来是一个响应式对象,很多Vue 组件都借助了这个特点来获取响应式内容,比如Vuex这里需要特别关注
initData
方法,其核心功能是对我们平时写的data
进行数据响应化处理function initData(vm: Component) { const keys = Object.keys(data); let i = keys.length; while (i--) { const key = keys[i]; proxy(vm, '_data', key); // 将 响应式数据 代理到 this 上面 } // 执行数据响应化 observe(data, true /* asRootData */); }
我们之所以可以直接在组件内部通过
this
使用data
中的属性,是因为这里做了一个proxy(vm, '_data', key)
的操作,proxy
并没有多复杂,只是把_data
的操作直接交给vm
处理function proxy(target: Object, sourceKey: string, key: string) { sharedPropertyDefinition.get = function proxyGetter() { return this[sourceKey][key]; }; sharedPropertyDefinition.set = function proxySetter(val) { this[sourceKey][key] = val; }; Object.defineProperty(target, key, sharedPropertyDefinition); }
观察的入口:observe /src/core/observer/index.js
-
作用
- 不重复地 为 数组 和 (除 VNode 以外的)对象 创建 Observer实例
-
源码
export function observe(value: any, asRootData: ?boolean): Observer | void { if (!isObject(value) || value instanceof VNode) { return; } let ob: Observer | void; if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) { ob = value.__ob__; } else if ( shouldObserve && !isServerRendering() && (Array.isArray(value) || isPlainObject(value)) && Object.isExtensible(value) && !value._isVue ) { ob = new Observer(value); } if (asRootData && ob) { ob.vmCount++; } return ob; }
(ps:开发环境下会对
props
,methods
校验,避免命名冲突)这里会有个细节,根据对象是否包含
__ob__
选择是否复用 Observer ,而__ob__
是哪来的呢,其实是new Observer
时操作的
Observer /src/core/observer/index.js
-
作用
- 为 对象/数组 创建 Observer 实例,并挂载到对象的
__ob__
属性上, - 创建 dep,用于数组的响应式和
Vue.set
时使用
- 为 对象/数组 创建 Observer 实例,并挂载到对象的
-
核心源码
class Observer { constructor(value: any) { this.dep = new Dep(); // 指定ob实例 def(value, '__ob__', this); if (Array.isArray(value)) { // 覆盖原型 if (hasProto) { protoAugment(value, arrayMethods); } else { copyAugment(value, arrayMethods, arrayKeys); } // 观察数组 this.observeArray(value); } else { // 观察对象 this.walk(value); } } walk(obj: Object) { const keys = Object.keys(obj); for (let i = 0; i < keys.length; i++) { defineReactive(obj, keys[i]); } } observeArray(items: Array<any>) { for (let i = 0, l = items.length; i < l; i++) { observe(items[i]); } } }
因为数组元素无法直接被
Object.defineProperty
拦截,后面会单独处理。但数组元素可能是对象,因此需要观察里面的元素。
观察对象:defineReactive /src/core/observer/index.js
-
作用:
- 通过
Object.defineProperty
为对象的key进行拦截, - 为对象的
key
创建dep,用于key
发生变化时通知更新
- 通过
-
核心源码
function defineReactive(obj: Object, key: string, val: any, customSetter?: ?Function) { const dep = new Dep(); let childOb = observe(val); Object.defineProperty(obj, key, { get: function reactiveGetter() { const value = getter ? getter.call(obj) : val; if (Dep.target) { dep.depend(); if (childOb) { childOb.dep.depend(); if (Array.isArray(value)) { dependArray(value); } } } return value; }, set: function reactiveSetter(newVal) { const value = getter ? getter.call(obj) : val; /* eslint-disable no-self-compare */ if (newVal === value || (newVal !== newVal && value !== value)) { return; } // #7981: for accessor properties without setter // ... val = newVal; childOb = observe(newVal); dep.notify(); }, }); }
getter 负责为 自己和子元素添加依赖,setter 负责两件事
- 内容有变化时通知 dep 更新 watcher
- 观察新设置的值(新设置的值可能也是个对象)
或许你会有个小疑问,执行
setter
时为什么只改形参val
呢?其实这是JavaScript 的 闭包 特性,我的理解是,闭包为当前函数提供了一个作用域,每次
setter
被触发都会从当前作用域下取出变量val
,getter
时返回这个val
,所以我们每次操作都值都是当前作用域下的val
。
观察数组:方法覆盖 /src/core/observer/array.js
-
作用
- 数组有 7 个可以改变内部元素的方法,对这 7 个方法扩展额外的功能
-
- 观察新添加的元素,实现数组内部元素数据响应式
-
- 取出数组身上的
__ob__
,让他的dep
通知watcher
更新视图,实现数组响应式
- 取出数组身上的
-
- 数组有 7 个可以改变内部元素的方法,对这 7 个方法扩展额外的功能
-
核心源码
// 获取数组原型 const arrayProto = Array.prototype; // 克隆一份 export const arrayMethods = Object.create(arrayProto); // 7个变更方法需要覆盖 const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']; /** * Intercept mutating methods and emit events */ methodsToPatch.forEach(function (method) { // cache original method // 保存原始方法 const original = arrayProto[method]; // 覆盖之 def(arrayMethods, method, function mutator(...args) { // 1.执行默认方法 const result = original.apply(this, args); // 2.变更通知 const ob = this.__ob__; // 可能会有新元素加入 let inserted; switch (method) { case 'push': case 'unshift': inserted = args; break; case 'splice': inserted = args.slice(2); break; } // 对新加入的元素做响应式 if (inserted) ob.observeArray(inserted); // notify change // ob内部有一个dep,让它去通知更新 ob.dep.notify(); return result; }); });
还记得吗,在创建
Observer
实例时特意给对象添加了一个dep,这里可以通过dep调用notify
方法通知更新,以此实现数组的数据响应式。
Dep /src/core/observer/dep.js
-
作用:
- 每个实例管理一组 Watcher 实例,可以通知更新
- 添加Watcher 实例到自己
- 通知Watcher 实例添加或删除自己(互相添加或删除)
-
核心源码
class Dep { constructor() { this.id = uid++; this.subs = []; } // 用于和watcher建立连接 addSub(sub: Watcher) { this.subs.push(sub); } // 用于和watcher取消引用 removeSub(sub: Watcher) { remove(this.subs, sub); } // 用于添加watcher到自己 depend() { if (Dep.target) { Dep.target.addDep(this); } } // 用于通知watcher更新 notify() { // stabilize the subscriber list first const subs = this.subs.slice(); for (let i = 0, l = subs.length; i < l; i++) { subs[i].update(); } } }
Dep 和 Watcher 相互引用,互相添加是为了处理
Vue.set
,互相删除是为了处理Vue.delete
。
Watcher /src/core/observer/watcher.js
-
作用:
- 分为 render Watcher 和 user Watcher,
- user Watcher用于
watch
和computed
, - render Watcher用于组件初始化和更新,执行粒度是
render
整个组件 - 实例存在于对象观察者的dep中
- 和dep互相添加或删除
-
核心源码
class Watcher { constructor( vm: Component, expOrFn: string | Function, cb: Function, options?: ?Object, isRenderWatcher?: boolean ) { this.vm = vm; // 用于 vm.forceUpdate if (isRenderWatcher) { vm._watcher = this; } vm._watchers.push(this); // options if (options) { this.deep = !!options.deep; this.user = !!options.user; this.lazy = !!options.lazy; this.sync = !!options.sync; this.before = options.before; } else { this.deep = this.user = this.lazy = this.sync = false; } this.cb = cb; this.id = ++uid; // uid for batching this.active = true; this.dirty = this.lazy; // for lazy watchers this.deps = []; this.newDeps = []; this.depIds = new Set(); this.newDepIds = new Set(); this.expression = process.env.NODE_ENV !== 'production' ? expOrFn.toString() : ''; // parse expression for getter // 初始化 的时候参数2如果是一个函数,则直接赋值给getter if (typeof expOrFn === 'function') { this.getter = expOrFn; } else { this.getter = parsePath(expOrFn); if (!this.getter) { this.getter = noop; } } this.value = this.lazy ? undefined : this.get(); } // 执行更新,重新收集依赖(初始化与更新都会再次执行这里) get() { pushTarget(this); let value; const vm = this.vm; try { value = this.getter.call(vm, vm); } catch (e) { } finally { // 深度监听 if (this.deep) { traverse(value); } // 当前Watcher赋值给Dep.target,用于重新收集依赖 popTarget(); this.cleanupDeps(); } return value; } // 添加watcher到subs addDep(dep: Dep) { const id = dep.id; // 相互添加引用 if (!this.newDepIds.has(id)) { // watcher添加dep this.newDepIds.add(id); this.newDeps.push(dep); if (!this.depIds.has(id)) { // dep添加watcher dep.addSub(this); } } } // 清除依赖,更新和初始化并不会实际执行,因为newDepIds中没有内容 cleanupDeps() { let i = this.deps.length; while (i--) { const dep = this.deps[i]; if (!this.newDepIds.has(dep.id)) { dep.removeSub(this); } } let tmp = this.depIds; this.depIds = this.newDepIds; this.newDepIds = tmp; this.newDepIds.clear(); tmp = this.deps; this.deps = this.newDeps; this.newDeps = tmp; this.newDeps.length = 0; } // 组件更新、computed、watch update() { /* istanbul ignore else */ // computed if (this.lazy) { this.dirty = true; } else if (this.sync) { this.run(); } else { // 异步更新 watcher入队 queueWatcher(this); } } // 同步执行的watcher,async:true run() { if (this.active) { // 如果是组件级别watcher,只走下面get const value = this.get(); if ( value !== this.value || // Deep watchers and watchers on Object/Arrays should fire even // when the value is the same, because the value may // have mutated. isObject(value) || this.deep ) { // set new value const oldValue = this.value; this.value = value; if (this.user) { try { this.cb.call(this.vm, value, oldValue); } catch (e) {} } else { this.cb.call(this.vm, value, oldValue); } } } } // 不立即触发的watcher,immediate:false evaluate() { this.value = this.get(); this.dirty = false; } // 和 watcher 互相引用 depend() { let i = this.deps.length; while (i--) { this.deps[i].depend(); } } // 取消监听,和 watcher 互相删除 引用,$watch 时使用 teardown() { if (this.active) { // remove self from vm's watcher list // this is a somewhat expensive operation so we skip it // if the vm is being destroyed. if (!this.vm._isBeingDestroyed) { remove(this.vm._watchers, this); } let i = this.deps.length; while (i--) { this.deps[i].removeSub(this); } this.active = false; } } }
即使只选取了 Watcher 的核心源码,但内容依然很多,主要包括重新收集依赖和
computed
、watch
、$watch
这些过程,忽略这些情况的话,其实Watcher的核心作用只有初始化和更新。值得注意的是
get
方法,最终的渲染和更新都会走到这里,并且里面有一个popTarget
方法,这是实现Vue.set
的关键。
Vue.set
说了这么多遍Vue.set
,是不是以为实现起来会很复杂,相反核心源码只有两行,其实流程大部分都在Watcher中实现了。
function set(target: Array<any> | Object, key: any, val: any): any {
defineReactive(ob.value, key, val);
ob.dep.notify();
}
调用Vue.set
后,首先为key
创建dep
,然后取出对象身上的__ob__
通知更新,更新时来到Watcher,从update
到get
,最终先执行popTarget
,将当前的render Watcher赋值给Dep.target
,然后调用组件的render
函数,间接触发key
的getter
方法,完成收集依赖并更新视图。
流程梳理
- Vue初始化时调用
this._init
,其中initState
方法用于初始化响应式数据 - 首先将
_data
代理到实例上,方便开发者通过this
调用,然后对_data
进行响应式处理,为每个被观察的对象创建观察者实例,并添加到__ob__
属性上 - 每个key拥有一个dep,
getter
时收集依赖(watcher),setter
时通知依赖(watcher)更新 - 每个对象也拥有一个dep,用于数组更新和实现
Vue.set
- 对于数组采用方法覆盖,7 个方法在执行时扩展一个额外的操作,观察新增的元素,然后让数组的
__ob__
通知watcher进行更新
总结与思考
-
dep和watcher 的关系为什么设计成多对多?
- 首先要明白的概念是,watcher包含render Watcher和user watcher,
- 其次,一个key拥有一个dep,
- 一个key可能通过props绑定给多个组件,这就有多个render Watcher
- 如果在组件中使用了
computed
、watch
,这就又添加了多个user Watcher - 到这里,dep和watcher是一对多
- Vue2 很重要的一点是,render Watcher的更新粒度是整个组件,对于一个组件,通常有多个可以触发更新的
key
,又因为一个key有一个dep,所以这种情况下dep和watcher是多对一的关系 - 综合上面两种情况,dep和watcher被设计成多对多的关系是最合适的
-
为什么需要
Vue.set
,其使用场景需要注意什么?- 存在的意义:因为
Object.defineProperty
无法动态监听,当增加key
时需要手动设置成响应式。 - 注意:添加 key 的这个对象必须是响应式 🚩,因为
Vue.set
关键的一步是取出对象身上的dep触发更新完成收集依赖,如果对象不是响应式数据就不存在dep,因此无法完成依赖收集
- 存在的意义:因为
-
综合数据响应式原理,感觉最复杂的部分在于处理 数组 和 新增 key 的情况,大量逻辑都在Watcher中,导致Watcher的源码读起来很麻烦,这也是后来Vue3着重优化的一部分。后续专门整理下Vue3的变化以作对比 🚩
批量异步更新
上个模块着重整理了Watcher,他负责组件的初始化渲染和更新,更新阶段为了更高效率地工作,Vue采用了批量异步更新策略
首先考虑为何要批量呢?数据响应式让我们可以通过更改数据触发视图自动更新,但如果一个函数中多次更改了数据呢,多次触发更新会很损耗性能,所以Vue将更新设置成了“批量”,即在一次同步任务中的所有更改,只会触发一次更新,既然更新和同步任务划分了界限,那么更新自然而然就被放到了异步中处理。
回顾Watcher的update
函数源码
// 组件更新 computed watch
update() {
/* istanbul ignore else */
// computed
if (this.lazy) {
this.dirty = true;
} else if (this.sync) {
this.run();
} else {
// 异步更新 watcher入队
queueWatcher(this);
}
}
所以入口是queueWatcher
方法
预先准备
-
浏览器中的 事件循环模型
-
Tick 是一个微任务单元
从源码探究流程
更新的入口:queueWatcher /src/core/observer/scheduler.js
-
作用
- 不重复地向queue中添加watcher
- 一次更新流程中只调用一次
nextTick(flushSchedulerQueue)
-
核心源码
function queueWatcher(watcher: Watcher) { const id = watcher.id; if (has[id] == null) { has[id] = true; if (!flushing) { queue.push(watcher); } else { // if already flushing, splice the watcher based on its id // if already past its id, it will be run next immediately. // ... } // queue the flush if (!waiting) { waiting = true; nextTick(flushSchedulerQueue); } } }
flushSchedulerQueue
用于批量执行queue
中的任务,用waiting
加锁的意义在于,nextTick
可能会开启异步任务,因此只尝试开启一次。flushSchedulerQueue
更新结束后会重置waiting
为false
,用于下一次更新使用。
管理队列:nextTick /src/core/util/next-tick.js
-
作用
- 将
flushSchedulerQueue
添加到callbacks
- 尝试开启(一次同步任务只开启一次) 异步任务
- 将
-
核心源码
function nextTick(cb, ctx) { // 存入callbacks数组 callbacks.push(function () { // 错误处理 if (cb) { try { cb.call(ctx); } catch (e) { handleError(e, ctx, 'nextTick'); } } }); if (!pending) { pending = true; // 启动异步任务 timerFunc(); } }
timerFunc
是基于平台的真正的异步函数,在初始化时定义,一旦调用会直接在真正的任务栈中添加异步任务,所以用pending
加锁的意义是为了保证只添加一个异步任务。或许你也会疑问,上一步不是加锁了吗,这里两个状态表示的意义不同,上面
waiting
表示已经添加任务后,进入等待阶段,后面再有watdher要更新只往队列加,但是不能再尝试向队列加执行任务了,除非用户触发vm.$nextTick
;而这里的pending
表示异步更新即将执行,请不要催促。所以不可以用一个哦~
真正的异步函数:timerFunc /src/core/util/next-tick.js
- 作用
- 这是基于平台的,真正执行的异步任务,根据浏览器兼容性选择支持的异步函数
- 核心源码
这里会根据浏览器的兼容性选择最适合的异步任务,优先级为:promise > MutationObserver > setImmediate(虽然是宏任务,但优于 setTimeout) > setTimeoutif (typeof Promise !== 'undefined' && isNative(Promise)) { const p = Promise.resolve(); timerFunc = () => { p.then(flushCallbacks); // In problematic UIWebViews, Promise.then doesn't completely break, but // it can get stuck in a weird state where callbacks are pushed into the // microtask queue but the queue isn't being flushed, until the browser // needs to do some other work, e.g. handle a timer. Therefore we can // "force" the microtask queue to be flushed by adding an empty timer. if (isIOS) setTimeout(noop); }; isUsingMicroTask = true; } // else if...
流程梳理
在一次同步任务中,当执行setter
时,会取出dep执行notify
从而触发queueWatcher
,
queueWatcher
不重复地向queue
中添加watcher,然后加锁执行nextTick
,
nextTick
会向微任务队列中添加一个flushCallbacks
(即flushSchedulerQueue
)。
在 js 任务栈 中的情况大致如下:
setter
一旦被触发,微任务队列就推入 方法flushCallbacks
,整个过程只存在一个- 若当前同步任务没有结束,如果用户执行
vm.$nextTick
,只会向callbacks
中加任务,不会再产生新的flushCallbacks
- 如果用户手动执行了微任务,则向浏览器的微任务队列中推入一个微任务,在
flushCallbacks
后面 - 若同步任务执行完毕,浏览器自动从微任务队列中取出
flushCallbacks
和 用户产生的微任务 一次性执行
思考与总结
-
queue
、callbacks
、flushCallbacks
、flushSchedulerQueue
的关系flushCallbacks
,是真正执行的的异步任务,作用是刷新callbacks
callbacks
中存放的是flushSchedulerQueueflushSchedulerQueue
:刷新queue
的函数queue
中存放的是watcher
他们之间是这样的关系:
callbacks = [flushSchedulerQueue: () => while queue:[watcher1,watcher2,watcher3], $nextTick]
-
善用
$nextTick
在代码中的位置
如果我们的业务需要在更新时先获取到旧的 dom 内容,然后再进行新的 dom 操作。或许这时候可以不滥用data
,毕竟响应式数据有开销嘛,可以在 修改 data 之前执行$nextTick
,like this,receiveNotice() { this.$nextTick(() => { console.log(this.refs.map) // 更新之前的dom }) this.updateMap(); // 该方法触发dom更新 console.log(this.refs.map) // 最新的dom }
VNode与patch
由于render Watcher的更新粒度是整个组件,为了避免全量更新而引入了虚拟 dom。其本质是用js 对象来描述dom 结构的一种数据结构。虚拟 dom不仅避免让我们直接操作dom,还可以抽象出dom 的实现层来做跨平台开发。
从源码探究流程
初始化流程的最终阶段
Vue 初始化最后一步是调用$mount
,执行mountComponent
。
可记否,mountComponent
核心源码如下:
function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
) {
updateComponent = () => {
vm._update(vm._render(), hydrating);
};
new Watcher(vm, updateComponent, ...)
}
Watcher 实例化时将传入的第二个参数(updateComponent
)赋值给get
并执行一次,真正的渲染函数就在_render
和_update
中
_render
/src/core/instance/render.js
-
作用
- 产生虚拟 dom
-
核心源码
Vue.prototype._render = function (): VNode { const vm: Component = this; const { render } = vm.$options; // render self let vnode; try { vnode = render.call(vm._renderProxy, vm.$createElement); } catch (e) { handleError(e, vm, `render`); } // 必须是单根节点 if (Array.isArray(vnode) && vnode.length === 1) { vnode = vnode[0]; } return vnode; };
(上文)初始化阶段
/src/platforms/web/entry-runtime-with-compiler.js
为$mount
扩展来render
方法,render
函数(转成别名函数之前)形式如下render(h) { return h("div", { attrs: { id: 'demo' } }, "Hello World") }
render
函数接收的参数h
就是上面源码中传入的vm.$createElement
,是经过标准化处理的高阶函数,
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
createElement
核心代码如下function createElement( context: Component, tag: any, data: any, children: any ): VNode | Array<VNode> { if (Array.isArray(data) || isPrimitive(data)) { children = data; data = undefined; } return _createElement(context, tag, data, children); } function _createElement( context: Component, tag?: string | Class<Component> | Function | Object, data?: VNodeData, children?: any ): VNode | Array<VNode> { // vnode生成 let vnode, ns; if (typeof tag === 'string') { let Ctor; ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag); // 浏览器节点 if (config.isReservedTag(tag)) { vnode = new VNode( config.parsePlatformTagName(tag), data, children, undefined, undefined, context ); } else if ( (!data || !data.pre) && isDef((Ctor = resolveAsset(context.$options, 'components', tag))) ) { // component 自定义组件的情况 // 上面的代码获取了自定义组件的构造函数 vnode = createComponent(Ctor, data, context, children, tag); } else { // 浏览器节点 vnode = new VNode(tag, data, children, undefined, undefined, context); } } else { // component 自定义组件的情况 // direct component options / constructor vnode = createComponent(tag, data, context, children); } if (Array.isArray(vnode)) { return vnode; } else if (isDef(vnode)) { if (isDef(ns)) applyNS(vnode, ns); if (isDef(data)) registerDeepBindings(data); return vnode; } else { return createEmptyVNode(); } }
如果是浏览器标签,就直接通过
new VNode
创建虚拟 dom,如果是自定义组件,则需要获取到组件的构造函数(Ctor),通过createComponent
获取组件的虚拟 dom总之最终都会产生虚拟 dom
_update
/src/core/instance/lifecycle.js
-
作用
- 保存最新的虚拟 dom,用于下次更新时取出来使用
- 调用
__patch__
最终在页面上渲染 真实 dom
-
核心源码
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { const prevVnode = vm._vnode; vm._vnode = vnode; // Vue.prototype.__patch__ is injected in entry points // based on the rendering backend used. if (!prevVnode) { // initial render vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */); } else { // updates vm.$el = vm.__patch__(prevVnode, vnode); } };
可以看到,首次渲染 和 更新都执行
__patch__
,区别为:- 首次渲染时传入的旧节点是真实 dom
- 更新时传入的新旧节点都是虚拟 dom
__patch__
: 首次渲染 和 更新 /src/core/vdom/patch.js
真正的patch
函数来自工厂函数 createPatchFunction
-
作用
- 首次渲染时,调用
createElm
创建一整棵树,添加到旧节点的兄弟节点位置,然后删除旧节点 - 更新时调用
patchVnode
- 首次渲染时,调用
-
核心源码
function createPatchFunction(backend) { const { modules, nodeOps } = backend; return function patch(oldVnode, vnode, hydrating, removeOnly) { if (isUndef(oldVnode)) { } else { const isRealElement = isDef(oldVnode.nodeType); if (!isRealElement && sameVnode(oldVnode, vnode)) { // patch existing root node // 更新逻辑 patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly); } else { // 初始化逻辑 if (isRealElement) { // either not server-rendered, or hydration failed. // create an empty node and replace it oldVnode = emptyNodeAt(oldVnode); } // 真实dom const oldElm = oldVnode.elm; const parentElm = nodeOps.parentNode(oldElm); // 创建一整棵树 createElm( vnode, insertedVnodeQueue, // extremely rare edge case: do not insert if old element is in a // leaving transition. Only happens when combining transition + // keep-alive + HOCs. (#4590) oldElm._leaveCb ? null : parentElm, nodeOps.nextSibling(oldElm) ); // 此时界面上新旧dom都存在 // 删除旧节点 if (isDef(parentElm)) { removeVnodes([oldVnode], 0, 0); } else if (isDef(oldVnode.tag)) { invokeDestroyHook(oldVnode); } } } invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch); return vnode.elm; }; }
patchVnode:diff 发生的地方 /src/core/vdom/patch.js
-
作用
diff
新旧节点,渲染最终的真实 dom到页面
-
核心源码
function patchVnode(oldVnode, vnode, insertedVnodeQueue, ownerArray, index, removeOnly) { if (oldVnode === vnode) { return; } // 获取两节点孩子 const oldCh = oldVnode.children; const ch = vnode.children; // 属性更新,没有diff,全部更新 ps:vue3静态标记都优化主要在这部分 if (isDef(data) && isPatchable(vnode)) { for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode); if (isDef((i = data.hook)) && isDef((i = i.update))) i(oldVnode, vnode); } // text children if (isUndef(vnode.text)) { // 新旧节点都有孩子 if (isDef(oldCh) && isDef(ch)) { if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly); } else if (isDef(ch)) { // 新节点有孩子,创建 if (process.env.NODE_ENV !== 'production') { checkDuplicateKeys(ch); } if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, ''); addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue); } else if (isDef(oldCh)) { // 删除 removeVnodes(oldCh, 0, oldCh.length - 1); } else if (isDef(oldVnode.text)) { // 清空文本 nodeOps.setTextContent(elm, ''); } } else if (oldVnode.text !== vnode.text) { // 文本更新 nodeOps.setTextContent(elm, vnode.text); } }
- 比较策略:同层比较,深度优先(类似先序遍历)
- 更新顺序:
- 处理属性:全量 diff
- 处理 文本 和 children(二选一)
- 新旧节点 都有 children,
updateChildren
- 旧节点 没有 children,则先清空 旧节点 的文本,然后新增 children
- 新节点 没有 children,移除所有 children
- 新旧节点 都没有 children,则文本替换
- 新旧节点 都有 children,
最复杂的情况在
updateChildren
updateChildren /src/core/vdom/patch.js
-
作用
- diff 子节点
-
核心代码:
function sameVnode(a, b) { return ( a.key === b.key && ((a.tag === b.tag && a.isComment === b.isComment && isDef(a.data) === isDef(b.data) && sameInputType(a, b)) || (isTrue(a.isAsyncPlaceholder) && a.asyncFactory === b.asyncFactory && isUndef(b.asyncFactory.error))) ); } function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { // 4个游标 let oldStartIdx = 0; let newStartIdx = 0; let oldEndIdx = oldCh.length - 1; let oldStartVnode = oldCh[0]; let oldEndVnode = oldCh[oldEndIdx]; let newEndIdx = newCh.length - 1; let newStartVnode = newCh[0]; let newEndVnode = newCh[newEndIdx]; // 搜索相同节点时使用 let oldKeyToIdx, idxInOld, vnodeToMove, refElm; // removeOnly is a special flag used only by <transition-group> // to ensure removed elements stay in correct relative positions // during leaving transitions const canMove = !removeOnly; if (process.env.NODE_ENV !== 'production') { checkDuplicateKeys(newCh); } // 循环条件时游标不重叠 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 调整游标:游标移动可能造成对应节点为空 if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left } else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx]; // 4个else if 是首尾查找 } else if (sameVnode(oldStartVnode, newStartVnode)) { // 头头 patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx); oldStartVnode = oldCh[++oldStartIdx]; newStartVnode = newCh[++newStartIdx]; } else if (sameVnode(oldEndVnode, newEndVnode)) { // 尾尾 patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx); oldEndVnode = oldCh[--oldEndIdx]; newEndVnode = newCh[--newEndIdx]; } else if (sameVnode(oldStartVnode, newEndVnode)) { // 头尾 // Vnode moved right patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx); canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)); oldStartVnode = oldCh[++oldStartIdx]; newEndVnode = newCh[--newEndIdx]; } else if (sameVnode(oldEndVnode, newStartVnode)) { // 尾头 // Vnode moved left patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx); canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm); oldEndVnode = oldCh[--oldEndIdx]; newStartVnode = newCh[++newStartIdx]; } else { // 乱序diff // 核心就是从新数组取第一个,然后从旧数组查找,代码太多了先不贴了 } } // 循环结束,开始扫尾工作 批量创建或删除 if (oldStartIdx > oldEndIdx) { refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm; addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue); } else if (newStartIdx > newEndIdx) { removeVnodes(oldCh, oldStartIdx, oldEndIdx); } }
步骤如下:
-
优先按照 web 场景 中常见的列表变化进行尝试,
常见的场景有- 列表头部添加/删除 dom,列表尾部添加/删除 dom,
- 列表排序,升序降序的改变,即首尾互换
-
若是乱序情况,则按如下处理:
- 在新旧节点的头与尾共创建 4 个游标,
- 不移动dom的操作:新旧节点 头头对比或尾尾对比
若满足sameVnode
,则patchVnode
,然后游标运动 - 可能移动dom的操作:头尾对比,尾头对比
- 乱序:两个数组对比,找到节点更新,然后移动到新位置
- 扫尾工作:随着循环,四个游标两两靠近,如果最后没有重叠,说明新旧节点数量有变化,所以需要批量添加或批量删除
有点难理解对吗,这里举个例子吧(默认每个元素都有key),或许会好些
const oldChildren = [a, b, c, d]; const newChildren = [a, e, f, b, c, d]; // 1.新旧节点队首元素相同,但没有变化,于是头游标向后运动,比较新旧节点的第二个元素 const oldChildren = [b, c, d]; const newChildren = [e, f, b, c, d]; // 2.新旧节点的队首元素不同,队尾元素相同,但没有变化,于是尾游标向前运动 const oldChildren = [b, c]; const newChildren = [e, f, b, c]; // 3.新旧节点的队首元素不同,队尾元素相同,但没有变化,于是尾游标继续向前运动 const oldChildren = [b]; const newChildren = [e, f, b]; // 4.旧节点没有元素了,进行扫尾工作,新节点但头游标和尾游标之间存在元素,批量增加[e, f] const oldChildren = []; const newChildren = [e, f];
-
流程梳理
- 首先在
patch
中宏观把控,首次渲染时,调用createElm
创建一整棵树,更新时调用patchVnode
patchVnode
的策略是:同层比较,深度优先,先更新属性,然后处理textContent和children
- 处理
children
会优先比较新旧节点的头头、尾尾、头尾、尾头,最后处理乱序情况,等一切都处理结束,再进行收尾工作,即批量增或批量删
思考与总结
1. 为什么 patch
不是直接使用,而是通过一个工厂函数createPatchFunction
返回?
为平台注入特有的 节点操作 和 属性操作 方法
// 接收平台特殊操作,返回平台patch
export function createPatchFunction(backend) {
let i, j;
const cbs = {};
const { modules, nodeOps } = backend;
return function patch(...) {
// ...
};
}
2. 为什么不推荐用 index
作 key
?
两个原因:
sameVnode
在判断时,即使dom没有变化也会判定为发生变化,因此所有 dom 都要重新更新一次- 列表重排序的场景可能会出现问题
比如:
当列表中出现删除场景时,因为sameType
的策略是首先比较key
,被删除节点后面的dom
,由于key(index)
也发生了改变,就会被判定为dom
发生了改变,首先造成的影响就是diff
流程变复杂,如果列表并没有太复杂,造成不了太多性能的损耗,但是继续思考,如果那些没有改变的dom
,很不幸操作了一些非响应式引起的变化,比如改变style
,或通过css
弹出了Popover
,那么当前更新时就会覆盖这些非响应式变化,让用户体验不好,或者误以为产生了 bug
3. 虚拟 dom 适合一切场景吗?
虚拟 dom 不适合频繁 diff
的场景,比如游戏,游戏画面需要频繁渲染,此时如果使用虚拟 dom,cpu 会持续占用内存造成卡顿,因此不适合所有场景
4. 我在 debug 时,VNode
的children
中有个VNode
的tag
为什么是undefined
?
编写 template
时的空格或换行符在编译器中会被解析成tag
为undefined
的VNode
组件化原理
组件有两种声明方式
- 局部:在组件的
components
中声明即可局部使用 - 全局:通过
Vue.component
注册
预先准备
方法 Vue.component
的来源
初始化 定义全局 API 时调用了initAssetRegisters
,该方法用于注册三个全局方法:Vue.component
、Vue.filter
、Vue.directive
其中Vue.component
的核心代码:
Vue[type] = function (id, definition) {
if (type === 'component' && isPlainObject(definition)) {
definition.name = definition.name || id;
// 调用 Vue.extend 转换成 VueComponent,将来使用时 new VueComponent 即可
definition = this.options._base.extend(definition);
}
};
Vue.extend
返回的是 VueComponent,使用时通过 new VueComponent 即可获取组件实例
Vue.component
最终将自定义组件添加到Vue.options
中,Vue.options
是全局的components
,默认只有 KeepAlive、Transition、TransitionGroup 三个组件
render
函数对于 自定义组件 和 浏览器标签 有何区别?
template
到 编译后的render
变化如下
<template>
<div id="demo">
<h1>Vue组件化机制</h1>
<comp></comp>
</div>
</template>
(function anonymous() {
with (this) {
return _c(
'div',
{ attrs: { id: 'demo' } },
[_c('h1', [_v('Vue组件化机制')]), _v(' '), _c('comp')],
1
);
}
});
可以看到对于自定义组件和 host 组件都采用了同样的处理方法:即createElement(tag)
的方式,由此可见,答案在createElement
中(这里的_c
就是createElement
的柯里化处理)
_c
_v
为何物?
在初始化的instance/renderhelpers
中为实例提供了方法别名
renderList
:v-for
_v
创建文本
_s
格式化
等
然后在initRender
中给实例声明一些方法:createElement
、_c
vm._c = (...) => createElement(...)
等
从源码探究流程
上个模块VNode中已经整理,在获取虚拟 dom时最终会调用createElement
,createElement
处理两种情况
- 如果是 浏览器标签,则
new VNode(...)
- 如果时 Vue 组件,则
createComponent(...)
所以现在我们去看createComponent
即可
createComponent /src/core/vdom/create-component.js
-
作用:返回组件的 虚拟 dom
-
核心源码
export function createComponent( Ctor: Class<Component> | Function | Object | void, data: ?VNodeData, context: Component, children: ?Array<VNode>, tag?: string ) { let asyncFactory; if (isUndef(Ctor.cid)) { asyncFactory = Ctor; Ctor = resolveAsyncComponent(asyncFactory, baseCtor); if (Ctor === undefined) { // 异步组件的占位符 return createAsyncPlaceholder(asyncFactory, data, context, children, tag); } } // 组件身上有双向绑定,要额外声明 事件类型 和 属性名称 if (isDef(data.model)) { transformModel(Ctor.options, data); } // 分离原生事件和自定义事件 const listeners = data.on; // replace with listeners with .native modifier // so it gets processed during parent component patch. data.on = data.nativeOn; // 安装自定义组件的钩子 installComponentHooks(data); // 返回 虚拟dom const name = Ctor.options.name || tag; const vnode = new VNode( `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`, data, undefined, undefined, undefined, context, { Ctor, propsData, listeners, tag, children }, asyncFactory ); return vnode; }
还记得
_render
函数吗,用于获得虚拟 dom,初始化和更新都会调用这个方法,
_update
做了两件重要的事情- 保存一份虚拟 dom 存到
_vnode
中,下次直接取出来使用 - 调用
__patch__
,初始化执行createElm
,更新执行patchVnode
因为初始化阶段已经得到虚拟 dom了,
patchVnode
只做diff,因此组件虚拟 dom转真实 dom的关键在createElm
中 - 保存一份虚拟 dom 存到
createElm /src/core/vdom/patch.js
-
作用
-
- 如果是浏览器标签,则创建真实 dom 树
-
- 如果是自定义组件,则调用
createComponent
- 如果是自定义组件,则调用
-
- 最终都是:虚拟 dom 转真实 dom
-
-
核心源码
function createElm(vnode, insertedVnodeQueue, parentElm, refElm, nested, ownerArray, index) { // 自定义组件 if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) { return; } // 浏览器标签 const data = vnode.data; const children = vnode.children; const tag = vnode.tag; if (isDef(tag)) { vnode.elm = vnode.ns ? nodeOps.createElementNS(vnode.ns, tag) : nodeOps.createElement(tag, vnode); /* istanbul ignore if */ if (__WEEX__) { } else { createChildren(vnode, children, insertedVnodeQueue); if (isDef(data)) { invokeCreateHooks(vnode, insertedVnodeQueue); } insert(parentElm, vnode.elm, refElm); } } else if (isTrue(vnode.isComment)) { vnode.elm = nodeOps.createComment(vnode.text); insert(parentElm, vnode.elm, refElm); } else { vnode.elm = nodeOps.createTextNode(vnode.text); insert(parentElm, vnode.elm, refElm); } }
到这里发现又回到了
createComponent
,一想到之前也有遇到类似的情景:$mount
函数也有过函数覆盖的情况,于是看了一下文件路径,发现接下来要找的createComponent
在src/core/vdom/patch.js
- createComponent() - src/core/vdom/create-component.js 组件 vnode 创建
- createComponent() - src/core/vdom/patch.js 创建组件实例并挂载,vnode 转换为 dom
createComponent src/core/vdom/patch.js
-
作用:将组件的虚拟 dom转换成真实 dom
-
核心源码
function createComponent(vnode, insertedVnodeQueue, parentElm, refElm) { let i = vnode.data; if (isDef(i)) { const isReactivated = isDef(vnode.componentInstance) && i.keepAlive; // 前面安装的钩子在hook中,只有自定义组件有init函数,执行init函数后调用组件的$mount if (isDef((i = i.hook)) && isDef((i = i.init))) { i(vnode, false /* hydrating */); } // after calling the init hook, if the vnode is a child component // it should've created a child instance and mounted it. the child // component also has set the placeholder vnode's elm. // in that case we can just return the element and be done. if (isDef(vnode.componentInstance)) { initComponent(vnode, insertedVnodeQueue); insert(parentElm, vnode.elm, refElm); if (isTrue(isReactivated)) { reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm); } return true; } } }
这里执行了钩子函数,于是我们去前面寻找安装的钩子的地方
组件的钩子函数 installComponentHooks /src/core/vdom/create-component.js
- 作用:安装钩子,注意是安装,不是执行
- 核心源码
function installComponentHooks(data: VNodeData) { const hooks = data.hook || (data.hook = {}); // 合并用户和默认的管理钩子 for (let i = 0; i < hooksToMerge.length; i++) { const key = hooksToMerge[i]; const existing = hooks[key]; const toMerge = componentVNodeHooks[key]; if (existing !== toMerge && !(existing && existing._merged)) { hooks[key] = existing ? mergeHook(toMerge, existing) : toMerge; } } }
hooksToMerge
包含 4 个钩子:init
、prepatch
、insert
、destory
(ps:keepAlive 组件的实现原理关键在init
部分:不需要重新创建组件,放在缓存中)
const child = (vnode.componentInstance = createComponentInstanceForVnode(vnode, activeInstance));
child.$mount(hydrating ? vnode.elm : undefined, hydrating);
init
是组件的初始化,可以看到执行了$mount
方法,因此自定义组件和host 组件在渲染阶段的区别主要是,根组件执行$mount
=> patch
=> createElm
向下递归,如果遇到host 组件,直接 createElement
(web 平台的情况),
若遇到自定义组件,则调用createComponent
,最终会执行组件的 钩子 函数:init
方法
整体流程梳理
- 定义:
Vue.component
=>通过Vue.extend获取VueComponent
=>添加到Vue.options.components中
- 初始化:
vm._update(vm.render())
=>createElement
=>createComponent
=>__patch__
=>createElm
=>createComponent
=> 执行组件的钩子函数init
- 更新:递归到组件时执行组件的钩子函数
思考与总结
-
组件化的本质是什么?
对组件化的本质,我的理解是产生虚拟 dom。因为不管是浏览器标签还是自定义组件,最终都会走向render
。 -
我看到
createComponent
对事件的监听做了单独做了处理,父子组件通信时绑定的事件如何处理的?
父子组件通过事件通信时,事件的绑定和触发都发生在子组件身上。 -
我看到用于
createComponent
中,处理钩子函数时专门对KeepAlive
做了处理,其实现原理是什么?
执行init
如果发现是KeepAlive
组件,则尝试从缓存中取,并且由于钩子函数的存在,可以做很好的动效处理。 -
全局组件和局部组件在实现原理上有何区别?
初始化Vue 组件时会调用mergeOptions
,将Vue.options.components
中的 全局组件 合并到 Vue 组件 的components
属性中,以此达到全局使用的目的。 -
存在父子关系时,生命周期执行顺序?
在整理patch得到的结论:create/destory 自上而下(深度优先),mount(从下向上)
父组件 beforeCreated ->父组件 created ->父组件 beforeMounted ->子组件 beforeCreated ->子组件 created ->子组件 beforeMounted ->子组件 mounted -> 父组件 mounted。 -
为什么说尽量少地声明全局组件?
由Vue 组件化的原理可以看到,通过Vue.component
声明的全局组件会先执行Vue.extends
创建出VueComponent,然后存放在Vue.options.components
中,并且初始化创建Vue 组件时再通过mergeOptions
注入到Vue 组件的components
选项中,因此,如果全局组件过多会占用太多资源和事件,导致首屏加载不流畅或白屏时间过长的问题。 -
组件拆分粒度的问题
在Vue2中,render Watcher的更新粒度是整个组件,所以当组件拆分不合理可能会导致一个组件有大量的虚拟 dom,这时候在diff
时会变慢,
其实反观Vue1,render Watcher的更新粒度是一个节点,可以精准更新,因此不需要虚拟 dom,这是最理想化的更新。但是由于太多Watcher占用了内存而无法开发大型项目,到了Vue2被摒弃了。快速 diff和内存占用总要有所取舍,所以还得具体场景具体分析。
带给我的收获与思考
-
大量设计模式的使用
- 发布订阅模式:数据响应式
- 工厂模式:
createPatchFunction
-
闭包
- 解析组件模板: 使用了闭包作为缓存,为了重复解析
cached
:使用闭包缓存函数createPatchFunction
: 把很多更新用的函数作为闭包defineReactive
:闭包作用域内的变量val
-
方法覆盖(扩展)
数组响应式、
$mount
方法跨平台 -
精巧的工具方法
诸如类型校验、代理、密闭对象、冻结对象、检查是否是原始值、extend、只执行一次的函数等等,内容太多,看来要单独整理一篇文章了。
-
微任务的妙用
异步更新策略借助的就是浏览器的事件循环,同步任务执行完毕后会刷新微任务队列。 让我想到工作中有这么一个场景,当 websocket 推送数据后,页面关联的图表会重新
render
,每个图表的render
都相对耗时,同步执行会导致每次循环都等图表渲染结束才进行下一次循环,造成页面暂时的卡顿。于是我们将render
放到微任务中处理,等循环的同步任务结束后会自动执行微任务队列,实现了页面优化。 -
和react旧diff的不同
Vue2的diff与react Fiber之前的diff还是很像的,区别是Vue2的diff过程带有一点点智能,表现为会优先处理 web 场景常见的情况,即向列表头部添加元素、向列表尾部添加元素,列表的倒叙排列、升序排列
- PS:在读源码时发现一个
initProxy
方法,里面使用了es6的proxy
,也就是现在Vue3着重优化数据响应式的方案,但该方法只在开发环境下使用了一次,莫非当时就有了proxy代替Object.defineProperty的想法啦?