Vue.js组件系统架构深度解析
一个普通对象是怎么变成功能完备的组件实例的:实例化与初始化流程、父子组件之间如何建立联系、插槽(含作用域插槽)如何分发,以及动态组件和异步组件的实现。
<child /> 渲染出来的不是子组件,是一个占位 VNode
父组件的 render 函数跑完,子组件实例还不存在。这一步产出的只是一个做了标记的普通 VNode,实例要等 patch 走到它身上才创建。下面这段在完整版 Vue 2 的控制台里可以直接跑:
const Child = { props: ['msg'], template: '<p>{{ msg }}</p>' };
const vm = new Vue({ components: { Child }, template: '<div><child msg="hi" @pick="onPick" /></div>', methods: { onPick() {} }}).$mount('#app');
const cv = vm._vnode.children[0]; // 父组件渲染出来的那个子组件节点console.log(cv.tag); // vue-component-1-childconsole.log(Object.keys(cv.data.hook)); // ['init', 'prepatch', 'insert', 'destroy']console.log(cv.componentOptions.propsData); // { msg: 'hi' }console.log(cv.data.attrs); // {}console.log(cv.data.on); // undefined五行输出,把组件系统的入口说完了。
tag 不是 child,是 vue-component-1-child。这串字符是 createComponent
拼的,模板写死在 src/core/vdom/create-component.js 里:
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`。cid 是 Vue.extend 发的自增整数,
下一节会讲它从哪来。
msg 从 data.attrs 里消失了,出现在 componentOptions.propsData 里。属性归不归 props 管,
在父组件这一侧就分好了,子组件拿到的 propsData 已经是挑干净的。没被挑走的那些留在 attrs 里,
就是后来的 $attrs。
@pick 也不在 data.on 里了。源码在这里做了一次交换:data.on 被存进 componentOptions.listeners,
然后 data.on = data.nativeOn。组件标签上的 @pick 从来不是 DOM 监听器,它最后会变成子组件实例上的
一次 $on;只有加了 .native 的那些才留在 data.on 上,当真正的 DOM 事件处理。
data.hook 上那四个函数是整篇文章的主线。patch 认得它们,走到这个 VNode 时按时机回调,
组件的创建、更新、插入、销毁全挂在这四个名字上。
graph TB A[组件定义] --> B[组件注册] B --> C[组件实例化] C --> D[生命周期初始化] D --> E[数据响应式处理] E --> F[模板编译] F --> G[渲染函数生成] G --> H[VNode创建] H --> I[DOM挂载]
J[父组件] --> K[Props传递] K --> L[子组件] L --> M[Events发射] M --> J
N[插槽定义] --> O[内容分发] O --> P[作用域处理] P --> Q[渲染输出]这张图是粗线条的地图,底下两条支线是父子之间的来回。接下来按图上的顺序走:先看注册那一步把普通对象 变成了什么,再看四个钩子怎么把实例造出来。
Vue.extend 用原型链造子类,顺手把构造器缓存在了组件定义上
components: { Child } 里的 Child 是个普通对象,它得先变成构造器。干这件事的是
Vue.extend(src/core/global-api/extend.js):
Vue.cid = 0;let cid = 1;
Vue.extend = function (extendOptions) { const Super = this; const SuperId = Super.cid; const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {}); if (cachedCtors[SuperId]) { return cachedCtors[SuperId]; // 同一个定义对象、同一个基类,只造一次 }
const Sub = function VueComponent(options) { this._init(options); }; Sub.prototype = Object.create(Super.prototype); Sub.prototype.constructor = Sub; Sub.cid = cid++; Sub.options = mergeOptions(Super.options, extendOptions); Sub['super'] = Super;
// 省略:Sub.extend / Sub.mixin / Sub.use 与 component / directive / filter 的转授
// props 和 computed 的代理定义在原型上,而不是每个实例上 if (Sub.options.props) initProps(Sub); if (Sub.options.computed) initComputed(Sub);
cachedCtors[SuperId] = Sub; return Sub;};三件事值得单说。
继承是原型链,不是 class Sub extends Vue。 Sub 是个普通函数,函数体只有一句 this._init(options);
父子关系靠 Object.create(Super.prototype) 接上。所以组件实例上能调 $emit、$mount 这些方法,
是因为它们挂在 Vue.prototype 上,被原型链一路继承下来了。
缓存挂在组件定义对象上,不在 Vue 那边。 cachedCtors 就是 extendOptions._Ctor,
一个写进你那个组件对象里的隐藏字段,key 是基类的 cid。这个设计的用处是同一个组件对象在多处注册也只造一个
构造器;副作用是你的组件定义对象被写了一个属性,深拷贝它会连这份缓存一起拷走。
prop 的访问器定义在原型上。 initProps(Sub) 在 Vue.extend 的时候就把 props 里每个 key
代理到 Sub.prototype,指向 _props。上一节那个例子里,this.msg 的 getter 是原型上的属性,
实例自己身上没有 —— 一个组件渲染一千次,这个 Object.defineProperty 只跑过一次。
Vue.component('my-comp', {...}) 做的事很薄:把定义对象交给 this.options._base.extend(...),
再塞进 Vue.options.components。因为是塞进 Vue.options,所有组件通过 mergeOptions 都能查到它,
这就是「全局注册」的全部含义。局部注册的 components: { Child } 落在自己的 $options.components 上,
查不到才往原型链上找。
Vue 3 换掉的是「全局」这两个字:注册方法在应用实例上,app.component('my-comp', {...}),
注册表跟着 app 走。同一个页面上挂两个 app,各自的组件互不可见 —— Vue 2 那种全局注册污染全局的问题,
是这么解决的。
四个钩子挂在 VNode 上,patch 走到哪个才创建哪个实例
componentVNodeHooks 是这套系统的中枢,installComponentHooks 把它合并到每个组件 VNode 的
data.hook 上。四个钩子对应组件一生的四个时刻:
const componentVNodeHooks = { init(vnode, hydrating) { if (vnode.componentInstance && !vnode.componentInstance._isDestroyed && vnode.data.keepAlive) { // 被 keep-alive 缓存过的,当作一次更新处理,不重新创建 componentVNodeHooks.prepatch(vnode, vnode); } else { const child = vnode.componentInstance = createComponentInstanceForVnode(vnode, activeInstance); child.$mount(hydrating ? vnode.elm : undefined, hydrating); } },
prepatch(oldVnode, vnode) { const options = vnode.componentOptions; const child = vnode.componentInstance = oldVnode.componentInstance; updateChildComponent( child, options.propsData, options.listeners, vnode, options.children ); },
insert(vnode) { const { context, componentInstance } = vnode; if (!componentInstance._isMounted) { componentInstance._isMounted = true; callHook(componentInstance, 'mounted'); } // 省略:keep-alive 的 activated 走另一条路,见后面那一节 },
destroy(vnode) { const { componentInstance } = vnode; if (!componentInstance._isDestroyed) { if (!vnode.data.keepAlive) componentInstance.$destroy(); else deactivateChildComponent(componentInstance, true); } }};init 里那两行是「普通对象变成组件实例」的确切位置。createComponentInstanceForVnode 只干一件事 ——
new vnode.componentOptions.Ctor(options),而 options 是这么攒的:
const options = { _isComponent: true, // 这个标记让 _init 走快路 _parentVnode: vnode, // 占位 VNode,之后就是子实例的 $vnode parent // activeInstance,也就是当前正在 patch 的那个实例};return new vnode.componentOptions.Ctor(options);parent 传的是 activeInstance —— 一个模块级变量,指向当前正在执行 patch 的实例。父子关系不是靠
「谁写在谁的模板里」推出来的,是靠 patch 的执行栈现场记下来的。这也解释了为什么 $parent 在
beforeCreate 里就能拿到:它是构造参数,比一切初始化都早。
prepatch 是数据流的关键:父组件重新渲染时,同位置的子组件走的是 prepatch,不是 init。
实例不重建,只把新的 propsData 和 listeners 灌进去。改一个 prop 不会让子组件的 created 再跑一遍,
原因就在这里。
insert 里那句 callHook(componentInstance, 'mounted') 决定了一个经常被记反的顺序。patch 是深度优先,
子节点先插入、后收集 insert 队列,所以钩子的实际顺序是:
parent created -> child created -> child mounted -> parent mounted父组件的 created 最早,mounted 最晚。想在父组件里读子组件渲染出来的 DOM 尺寸,
mounted 是安全的;反过来,在子组件的 mounted 里读父组件的 $el,那时父组件还没挂完。
_init 的调用顺序决定了每个钩子里能拿到什么
Vue.prototype._init(src/core/instance/init.js)的主体只有几行,顺序是全部信息:
if (options && options._isComponent) { initInternalComponent(vm, options); // 组件走这条:不做动态选项合并,只搬字段} else { vm.$options = mergeOptions(resolveConstructorOptions(vm.constructor), options || {}, vm);}initLifecycle(vm); // $parent / $root / $children / $refsinitEvents(vm); // _events,并把父组件传下来的 listeners 注册上initRender(vm); // $slots / $scopedSlots / $attrs / $listeners / _ccallHook(vm, 'beforeCreate');initInjections(vm); // 源码注释:resolve injections before data/propsinitState(vm); // props → methods → data → computed → watchinitProvide(vm); // 源码注释:resolve provide after data/propscallHook(vm, 'created');两个钩子夹着的正是 initState,于是能画出这张表:
| 在这个钩子里 | 已经有了 | 还没有 |
|---|---|---|
beforeCreate |
$parent、$root、$options、$slots、$attrs |
props、data、computed、methods、inject |
created |
上面全部,加 props / data / computed / methods / inject / provide |
$el,DOM 还没有 |
mounted |
$el,以及全部子组件的 $el |
—— |
initInternalComponent 那条分支值得单看:组件实例不走 mergeOptions,因为选项合并要遍历策略表,
是热路径上明显的开销。它改成 Object.create(Ctor.options) 起一个原型链,再把 propsData、
_parentListeners、_renderChildren、_componentTag 几个字段直接搬过去。一个列表渲染一千个组件,
省下的就是一千次选项合并。
callHook 本身也有一处容易忽略的设计(src/core/instance/lifecycle.js):
export function callHook(vm, hook) { pushTarget(); // 关掉依赖收集 const handlers = vm.$options[hook]; if (handlers) { for (let i = 0; i < handlers.length; i++) { invokeWithErrorHandling(handlers[i], vm, null, vm, `${hook} hook`); } } if (vm._hasHookEvent) vm.$emit('hook:' + hook); popTarget();}pushTarget() 不带参数,等于把 Dep.target 顶成 undefined。在生命周期钩子里读多少响应式数据都不会
被收集成依赖 —— 源码里那行注释直接写着 #7573 disable dep collection when invoking lifecycle hooks。
少了这一句,mounted 里随手读一个 this.list.length 就会把当前 watcher 绑上去。
_hasHookEvent 是个开关:只有真的有人 $on('hook:mounted'),才付得起每个钩子多一次 $emit 的成本。
这个标记在 $on 里用正则 /^hook:/ 一测就置上了。
sequenceDiagram participant U as 用户代码 participant V as Vue实例 participant D as DOM
U->>V: new Vue() V->>V: beforeCreate V->>V: 初始化注入和响应式 V->>V: created V->>V: 编译模板 V->>V: beforeMount V->>D: 创建DOM节点 V->>V: mounted
Note over V,D: 数据变化时 V->>V: beforeUpdate V->>D: 更新DOM V->>V: updated
Note over V,D: 销毁时 V->>V: beforeDestroy V->>D: 移除DOM和事件 V->>V: destroyed图上最后那两个名字是 Vue 2 的。Vue 3 把它们改成了 beforeUnmount 和 unmounted。
这不是加了个新别名 —— 老名字在 Vue 3 里直接不生效,写了也不会报错,钩子就是不执行,
只有迁移用的 @vue/compat 构建才认,而且会打一条弃用提示。其余的钩子名没变;
beforeCreate 和 created 在组合式 API 里没有对应的 onXxx,因为 setup() 本身就在这两者之间执行。
props 在父组件那边就被挑走,改不动是因为 setter 上挂了一句警告
extractPropsFromVNodeData(src/core/vdom/helpers/extract-props.js)在父组件渲染时执行,
拿子组件构造器上的 props 声明去 data.attrs 和 data.props 里对。key 试两次:先原样,
再 hyphenate 成短横线形式。所以模板里写 user-name、声明里写 userName 能对上,
反过来 userName 写在 DOM 内模板里会被浏览器抹成小写,那就对不上了。
挑走的进 propsData,剩下的原地留在 attrs 里 —— $attrs 就是这么来的。
子组件把 v-bind="$attrs" 转手往下传,这是包装第三方组件的常规做法。
子实例这边的 initProps(src/core/instance/state.js):
const props = vm._props = {};const keys = vm.$options._propKeys = [];if (!isRoot) toggleObserving(false); // 非根实例:不把 prop 的值再观测一遍
for (const key in propsOptions) { keys.push(key); const value = validateProp(key, propsOptions, propsData, vm);
if (process.env.NODE_ENV !== 'production') { defineReactive(props, key, value, () => { if (!isRoot && !isUpdatingChildComponent) { warn(`Avoid mutating a prop directly since the value will be ` + `overwritten whenever the parent component re-renders. ` + `Prop being mutated: "${key}"`, vm); } }); } else { defineReactive(props, key, value); } // 静态 props 在 Vue.extend 时已经代理到原型上了,这里只补实例化时新增的 if (!(key in vm)) proxy(vm, `_props`, key);}toggleObserving(true);toggleObserving(false) 是一个全局开关,关掉之后 observe() 直接返回,不会给这个值造 Observer。
父组件传下来的对象本来就已经是响应式的,子组件没必要再走一遍。这也解释了那条让很多人困惑的规则:
直接给 prop 赋值(this.msg = 'x')会触发上面那个 customSetter 报警;
改 prop 里面的字段(this.user.name = 'x')一声不吭,因为那个对象的响应式来自父组件的 data,
和这个 setter 无关。前者会在父组件下次渲染时被覆盖掉,后者是真的改到了父组件的数据 —— 都不该写,
但只有一个有警告。
props 变化时走的是 prepatch → updateChildComponent,核心也是一行:
if (propsData && vm.$options.props) { toggleObserving(false); const props = vm._props; for (let i = 0; i < (vm.$options._propKeys || []).length; i++) { const key = vm.$options._propKeys[i]; props[key] = validateProp(key, vm.$options.props, propsData, vm); } toggleObserving(true); vm.$options.propsData = propsData;}props[key] = ... 写的是响应式对象,setter 通知的是子组件自己的渲染 watcher。
父组件传新值 → 子组件重渲染,这条链就是一次普通的响应式派发,中间没有任何组件专属的机制。
反向的那条路,Vue 2 靠约定:子组件 $emit('update:title', v),父组件写 :title.sync="x",
.sync 是编译期展开成 @update:title 的语法糖。Vue 3 删掉了 .sync,改成 v-model:title,
同一件事换了个写法,事件名还是 update:title。组件上不带参数的 v-model,
Vue 2 默认对应 value 属性和 input 事件(可以用 model 选项改),
Vue 3 默认对应 modelValue 属性和 update:modelValue 事件,而且一个组件上可以挂多个。
$listeners 在 Vue 3 里没有了,父组件传下来的监听器合并进了 $attrs(键名形如 onClick)。
Vue 2 里写 v-bind="$attrs" v-on="$listeners" 的透传写法,Vue 3 只剩 v-bind="$attrs" 一句。
$emit 只是遍历一个数组,它不冒泡
组件的事件系统和 DOM 事件没有任何关系,实现是 src/core/instance/events.js 里几十行:
Vue.prototype.$on = function (event, fn) { const vm = this; // 省略:event 是数组时逐个递归 (vm._events[event] || (vm._events[event] = [])).push(fn); if (hookRE.test(event)) vm._hasHookEvent = true; // hookRE = /^hook:/ return vm;};
Vue.prototype.$emit = function (event) { const vm = this; // 省略:开发环境下 event 名大小写不匹配时的提示 let cbs = vm._events[event]; if (cbs) { cbs = cbs.length > 1 ? toArray(cbs) : cbs; const args = toArray(arguments, 1); for (let i = 0, l = cbs.length; i < l; i++) { invokeWithErrorHandling(cbs[i], vm, args, vm, `event handler for "${event}"`); } } return vm;};cbs.length > 1 ? toArray(cbs) : cbs 那行是防一种具体的事故:监听器在自己的回调里 $off 掉自己,
数组当场变短,正在跑的 for 循环会跳过后面一个回调。复制一份再遍历就没这个问题。$once 正是这么用的。
_events 是每个实例私有的一张表,$emit 只查自己这张表。所以子组件 $emit 出去的事件,
爷爷组件收不到 —— 没有冒泡,没有捕获。父组件之所以能收到,是因为 initEvents 在初始化时把
$options._parentListeners(也就是占位 VNode 上那份 componentOptions.listeners)
挨个 $on 到了子实例上。上一节控制台里那个 @pick,最后就躺在子实例的 _events.pick 里。
代价是跨层通信没有现成的路。Vue 2 的常见做法是拿一个空 Vue 实例当事件总线,
问题在于总线不知道谁还活着,组件销毁时忘了 $off 就是一处内存泄漏,而且事件从哪来、被谁听着,
全靠字符串串起来,翻代码翻不出调用链。Vue 3 把 $on / $off / $once 从实例上删掉了,
$emit 留着 —— 明确地不再支持事件总线这种用法,跨层的事交给 provide/inject 或者状态库。
provide 只在初始化时取一次值,之后不会再看提供者一眼
initProvide 和 initInjections 隔着 initState,一前一后。resolveInject 沿着 $parent 链
往上找 _provided,找到就把值取下来。关键在取下来之后:
const result = resolveInject(vm.$options.inject, vm);if (result) { toggleObserving(false); Object.keys(result).forEach(key => { // 开发环境下还会带一个「不要直接改注入的值」的 customSetter defineReactive(vm, key, result[key]); }); toggleObserving(true);}defineReactive(vm, key, result[key]) 把值拷贝到了后代实例上。提供者后来重新赋值
this.theme = 'dark',改的是提供者自己的 data,和后代身上这份拷贝没有任何关系。实测:
改之前: <div><i>light</i></div>把 this.theme 改成 dark 之后: <div><i>light</i></div>toggleObserving(false) 还说明了另一半:注入进来的值不会被重新观测。所以想让 provide/inject 跟着变,
得让传下去的东西本身是响应式的 —— 传一个对象然后改它的字段,或者传一个读取函数,
后代用 computed 包一层。原文里那种 provide() { return { theme: () => this.theme } } 的写法,
绕的就是这个限制。Vue 3 里同样不是自动响应的,官方文档的说法是传 ref 或 computed 下去。
插槽是函数,2.6 之后连普通插槽也是
插槽内容是在父组件的作用域里编译出来的,作为 children 挂在占位 VNode 上传给子组件。
initRender 调 resolveSlots(src/core/instance/render-helpers/resolve-slots.js)把这堆 children
按 data.slot 分组,分不出名字的全进 default。
子组件模板里的 <slot> 编译成 _t(...),也就是 renderSlot:
export function renderSlot(name, fallbackRender, props, bindObject) { const scopedSlotFn = this.$scopedSlots[name]; let nodes; if (scopedSlotFn) { nodes = scopedSlotFn(props) || (isFunction(fallbackRender) ? fallbackRender() : fallbackRender); } else { nodes = this.$slots[name] || (isFunction(fallbackRender) ? fallbackRender() : fallbackRender); } // 省略:props.slot 存在时再包一层 <template slot="..."> return nodes;}先查 $scopedSlots,查不到才查 $slots。2.6 之后第一条分支几乎总是命中,因为
normalizeScopedSlots(src/core/vdom/helpers/normalize-scoped-slots.js)会把普通插槽也代理成函数,
挂到 $scopedSlots 上 —— 源码里那段循环的注释就是 expose normal slots on scopedSlots。
两种插槽从此在子组件这一侧长得一样,区别只剩「调用时传不传参数」。
这次统一是 2.6 的事,也是 slot-scope 被 v-slot 取代的那一版。v-slot 只能写在
<template> 和组件标签上,位置固定,编译器一眼能看出插槽边界;slot-scope 可以写在任意元素上,
分析起来麻烦得多。2.6 起 slot 和 slot-scope 就标了弃用,Vue 3 里它们不再有任何含义,
编译器当普通属性放过去,只剩 v-slot(简写 #)。
编译产物这边,Vue 2 用 _u(resolveScopedSlots,在
src/core/instance/render-helpers/resolve-scoped-slots.js)把每个插槽包成 { key, fn } 收进一个对象,
并盖一个 $stable 标记:插槽名是静态的就是 stable。Vue 3 的同一段模板编译成一个插槽对象,
每个函数被 _withCtx 包住,末尾跟一个 _: 1 /* STABLE */:
return (_openBlock(), _createBlock(_component_Comp, null, { one: _withCtx(({ foo }) => [_toDisplayString(foo), _toDisplayString(_ctx.bar)]), two: _withCtx(({ bar }) => [_toDisplayString(_ctx.foo), _toDisplayString(bar)]), _: 1 /* STABLE */}))_: 1 就是 $stable 的后代,同一个优化换成了一个数字标记。_withCtx 干的事是记住定义这个插槽时的
渲染实例,等函数真被调用时切回去 —— 插槽内容写在父组件里,却在子组件的 render 过程中执行,
不切回去的话,插槽里用到的组件名和指令名会跑到子组件的注册表里去找,找不到。
Vue 2 用 child.context === context 那个判断解决同一个问题,Vue 3 换成了在调用时切换上下文。
$stable 不是个可有可无的标记,它连着一处真实的开销。updateChildComponent 里有这么一段判断:
let needsForceUpdate = !!( renderChildren || // 这次传了插槽内容 vm.$options._renderChildren || // 上次传了插槽内容 hasDynamicScopedSlot // 作用域插槽不 stable);// ...if (needsForceUpdate) { vm.$slots = resolveSlots(renderChildren, parentVnode.context); vm.$forceUpdate();}只要你给一个组件传了插槽内容,父组件每次重渲染,这个子组件就跟着强制重渲染一次, 哪怕插槽里是一段写死的文本、哪怕它的 props 一个都没变。实测:
模板:<slotted>常量文本</slotted><plain :x="1"/><span>{{ tick }}</span>改 tick 之后 updated 钩子的调用者: ["slotted updated"]plain 的 props 没变,跳过了;slotted 什么都没变,还是重渲染了。原因是插槽内容是父组件渲染出来的
VNode,父组件一重渲染它就是一批新对象,Vue 没有便宜的办法判断它们等不等价,只能保守地重来一遍。
这是一条实打实的代价:性能敏感的地方,插槽比 props 贵。
keep-alive 不渲染任何东西,它的 render 只做一次查表
keep-alive 的整个实现在 src/core/components/keep-alive.js,不到一百行,
关键是选项里的 abstract: true。回到 createComponent:抽象组件的 data 会被整个丢掉,只留 slot。
所以它不产生 DOM、不出现在 $parent 链上,$el 拿到的是被它包住的那个组件的元素。
render() { const slot = this.$slots.default; const vnode = getFirstComponentChild(slot); // 只认第一个组件子节点 const componentOptions = vnode && vnode.componentOptions; if (componentOptions) { const name = getComponentName(componentOptions); const { include, exclude } = this; if ((include && (!name || !matches(include, name))) || (exclude && name && matches(exclude, name))) { return vnode; // 不缓存,原样返回 }
const { cache, keys } = this; const key = vnode.key == null ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '') : vnode.key;
if (cache[key]) { vnode.componentInstance = cache[key].componentInstance; // 把旧实例接上 remove(keys, key); keys.push(key); // 命中就挪到队尾 } else { this.vnodeToCache = vnode; // 先记下来,这一轮还不能写缓存 this.keyToCache = key; }
vnode.data.keepAlive = true; } return vnode || (slot && slot[0]);}未命中时 render 并没有真的写缓存,只是把 vnode 和 key 记在实例上。真正写进去的是 cacheVNode(),
由 mounted 和 updated 两个钩子调用:
cacheVNode() { const { cache, keys, vnodeToCache, keyToCache } = this; if (vnodeToCache) { const { tag, componentInstance, componentOptions } = vnodeToCache; cache[keyToCache] = { name: getComponentName(componentOptions), tag, componentInstance }; keys.push(keyToCache); if (this.max && keys.length > parseInt(this.max)) { pruneCacheEntry(cache, keys[0], keys, this._vnode); // 超了就淘汰队首 } this.vnodeToCache = null; }}推迟这一步的原因,正是第一节那个结论:缓存里要存的 componentInstance,render 的时候还不存在 ——
它得等 patch 里的 init 钩子跑完才被挂到 vnode 上。所以缓存只能等到组件真的挂载完再写。
keys 数组是 LRU 队列:命中的 key 移到队尾,超过 max 时删掉队首那个。
(早期 2.x 的写法是在 render 里直接 cache[key] = vnode,靠 vnode 对象后来被就地改写来拿到实例。
翻旧文章看到那个版本,不用怀疑自己手上的源码。)
include 和 exclude 的判断是两个独立的排除条件用 || 连起来的,不是二选一。
两个都写时,一个组件必须既在 include 里、又不在 exclude 里,才会被缓存 —— exclude 优先。
另外这里比对的是组件的 name 选项:没写 name 的组件,include 永远匹配不上它。
缓存 key 的默认值是 cid + '::' + tag 而不是光一个 cid,源码注释说明了原因(#3269):
同一个组件定义可以在不同地方注册成不同的局部名字,光靠 cid 会把它们当成同一个来缓存。
真正让缓存生效的是前面那两个钩子。vnode.data.keepAlive = true 之后,init 发现实例还在,
走 prepatch 而不是新建;destroy 发现 keepAlive 为真,调 deactivateChildComponent 而不是
$destroy。activated / deactivated 这两个钩子只有被 keep-alive 包着的组件才会收到,
就是从这两条分支里发出来的。Vue 3 里它是 <KeepAlive> 组件,这两个钩子的名字没变。
异步组件先返回一个注释节点,加载完再逼父组件重渲染一次
异步组件在 createComponent 里从一个判断开始:isUndef(Ctor.cid)。构造器都有 cid,
没有 cid 说明拿到的是个工厂函数,转交给 resolveAsyncComponent
(src/core/vdom/helpers/resolve-async-component.js)。第一次调用时组件还没下载完,
它返回 undefined,createComponent 于是 return createAsyncPlaceholder(...) —— 一个注释节点。
页面上那个 <!----> 就是等待中的异步组件。
组件下载完之后,怎么让已经渲染过的地方重来一遍?答案朴素得有点意外:
const owners = factory.owners = [owner]; // owner 是 currentRenderingInstance
const forceRender = (renderCompleted) => { for (let i = 0; i < owners.length; i++) owners[i].$forceUpdate(); // 省略:renderCompleted 时清空 owners 与两个定时器};
const resolve = once(res => { factory.resolved = ensureCtor(res, baseCtor); if (!sync) forceRender(true);});
// 高级用法:factory() 返回 { component, loading, error, delay, timeout }if (isDef(res.loading)) { timerLoading = setTimeout(() => { if (isUndef(factory.resolved) && isUndef(factory.error)) { factory.loading = true; forceRender(false); // 只为了切到 loading 组件,再渲染一次 } }, res.delay || 200);}factory.owners 收集的是所有用到这个异步组件的实例 —— 同一个组件在页面上出现五次,owners 里就有五个,
下载只发生一次,加载完对五个各调一次 $forceUpdate()。resolved 缓存在工厂函数自己身上,
第二次渲染时第一个 if 就直接返回了,不会再下载。
delay 默认 200 毫秒,意思是「200 毫秒内加载完就别闪 loading 了」—— 一个避免闪烁的默认值,
不是加载延迟。timeout 到点则走 reject,切到 error 组件。这两个定时器加上 once() 包裹的
resolve / reject,就是「高级异步组件」的全部实现:没有额外的状态机,也没有组件级的
loading 状态,只有「改一个标记,然后逼着用它的人重渲染」。
Vue 3 把这套包装成了 defineAsyncComponent({ loader, loadingComponent, errorComponent, delay, timeout }),
选项一一对应,多了 suspensible 用来和 <Suspense> 配合。写法上的区别是 Vue 3 要显式调这个函数,
不再靠「工厂函数没有 cid」来推断。
复用逻辑:mixin 合并选项,组合式函数只是返回值
graph TB A[组件设计模式] --> B[容器组件模式] A --> C[高阶组件模式] A --> D[插槽模式] A --> E[混入模式]
B --> B1[业务逻辑] B --> B2[数据管理]
C --> C1[功能增强] C --> C2[逻辑复用]
D --> D1[内容分发] D --> D2[布局组合]
E --> E1[选项合并] E --> E2[生命周期扩展]图上四条里,插槽和混入是框架直接支持的,另外两条是用法。混入这条已经在前面出现过好几次了:
Vue.extend 里的 mergeOptions,_init 里的 mergeOptions,走的是同一张策略表
(src/core/util/options.js)。不同选项的合并规则不一样:生命周期钩子拼成数组,mixin 的排在前面;
methods / components / computed 用同名覆盖,组件自己的赢;data 递归合并对象。
$options.mounted 是个数组而不是函数,callHook 里那个 for 循环也就有了解释。
混入的代价是它把来源抹掉了。组件里出现一个 this.formatTime,你得翻遍所有 mixin 才知道它从哪来;
两个 mixin 都定义了 loading,后合并的悄悄赢,没有任何提示。Vue 3 的组合式函数换掉的正是这一点 ——
它就是个普通函数,返回什么你在调用处写什么,const { formatTime } = useTime(),来源写在脸上,
重名了自己改一个名字。Vue 2.7 把这套 API backport 回去了,2.7 以下则要装 @vue/composition-api。
函数式组件是另一条老路子。Vue 2 里写 functional: true,组件没有实例、没有响应式数据、没有生命周期,
render(h, context) 从 context 里拿 props 和 children,省掉的正是那三样东西的开销。
到了 Vue 3,functional 选项被删掉了,函数式组件就是一个接收 (props, context) 返回 vnode 的普通函数。
性能这条理由也没了:迁移指南写得很直接,有状态组件的开销已经降到「差别可以忽略」的程度,
建议就用普通组件。函数式组件在 Vue 3 里剩下的用处是写那种一次性的、只做一层包装的小组件。
回到最开始那个占位 VNode:它的 data.hook.init 造实例,prepatch 灌新 props,insert 发 mounted,
destroy 收场。组件系统所有看起来很不一样的能力 —— 插槽、keep-alive、异步加载 ——
最后都落在这四个钩子的某一条分支上。真正把这些 VNode 一个个比出来、决定哪个走 init、哪个走
prepatch 的,是 patch 里的那套 diff。
这篇是 Vue.js 内部机制深度解析的第 10 篇。前一篇是 Vue.js 异步更新与 nextTick 机制深度解析(下篇),后一篇是 Vue.js 状态管理模式:构建可扩展的应用架构。