Vue.js组件系统架构深度解析

一个普通对象是怎么变成功能完备的组件实例的:实例化与初始化流程、父子组件之间如何建立联系、插槽(含作用域插槽)如何分发,以及动态组件和异步组件的实现。

位置
第 10 篇 / 共 12 篇
预计
23 分钟

<child /> 渲染出来的不是子组件,是一个占位 VNode

父组件的 render 函数跑完,子组件实例还不存在。这一步产出的只是一个做了标记的普通 VNode,实例要等 patch 走到它身上才创建。下面这段在完整版 Vue 2 的控制台里可以直接跑:

浏览器控制台 · 完整版 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-child
console.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}` : ''}`cidVue.extend 发的自增整数, 下一节会讲它从哪来。

msgdata.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.extendsrc/core/global-api/extend.js):

Vue 2 · Vue.extend(节选)
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 上。四个钩子对应组件一生的四个时刻:

Vue 2 · componentVNodeHooks(src/core/vdom/create-component.js)
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 是这么攒的:

Vue 2 · createComponentInstanceForVnode(节选)
const options = {
_isComponent: true, // 这个标记让 _init 走快路
_parentVnode: vnode, // 占位 VNode,之后就是子实例的 $vnode
parent // activeInstance,也就是当前正在 patch 的那个实例
};
return new vnode.componentOptions.Ctor(options);

parent 传的是 activeInstance —— 一个模块级变量,指向当前正在执行 patch 的实例。父子关系不是靠 「谁写在谁的模板里」推出来的,是靠 patch 的执行栈现场记下来的。这也解释了为什么 $parentbeforeCreate 里就能拿到:它是构造参数,比一切初始化都早。

prepatch 是数据流的关键:父组件重新渲染时,同位置的子组件走的是 prepatch,不是 init。 实例不重建,只把新的 propsDatalisteners 灌进去。改一个 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._initsrc/core/instance/init.js)的主体只有几行,顺序是全部信息:

Vue 2 · _init 的主体
if (options && options._isComponent) {
initInternalComponent(vm, options); // 组件走这条:不做动态选项合并,只搬字段
} else {
vm.$options = mergeOptions(resolveConstructorOptions(vm.constructor), options || {}, vm);
}
initLifecycle(vm); // $parent / $root / $children / $refs
initEvents(vm); // _events,并把父组件传下来的 listeners 注册上
initRender(vm); // $slots / $scopedSlots / $attrs / $listeners / _c
callHook(vm, 'beforeCreate');
initInjections(vm); // 源码注释:resolve injections before data/props
initState(vm); // props → methods → data → computed → watch
initProvide(vm); // 源码注释:resolve provide after data/props
callHook(vm, 'created');

两个钩子夹着的正是 initState,于是能画出这张表:

在这个钩子里 已经有了 还没有
beforeCreate $parent$root$options$slots$attrs propsdatacomputedmethodsinject
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):

Vue 2 · callHook
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 把它们改成了 beforeUnmountunmounted。 这不是加了个新别名 —— 老名字在 Vue 3 里直接不生效,写了也不会报错,钩子就是不执行, 只有迁移用的 @vue/compat 构建才认,而且会打一条弃用提示。其余的钩子名没变; beforeCreatecreated 在组合式 API 里没有对应的 onXxx,因为 setup() 本身就在这两者之间执行。

props 在父组件那边就被挑走,改不动是因为 setter 上挂了一句警告

extractPropsFromVNodeDatasrc/core/vdom/helpers/extract-props.js)在父组件渲染时执行, 拿子组件构造器上的 props 声明去 data.attrsdata.props 里对。key 试两次:先原样, 再 hyphenate 成短横线形式。所以模板里写 user-name、声明里写 userName 能对上, 反过来 userName 写在 DOM 内模板里会被浏览器抹成小写,那就对不上了。

挑走的进 propsData,剩下的原地留在 attrs 里 —— $attrs 就是这么来的。 子组件把 v-bind="$attrs" 转手往下传,这是包装第三方组件的常规做法。

子实例这边的 initPropssrc/core/instance/state.js):

Vue 2 · initProps(节选)
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 变化时走的是 prepatchupdateChildComponent,核心也是一行:

Vue 2 · updateChildComponent 的 props 部分
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 2 · $on 与 $emit(节选)
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 只在初始化时取一次值,之后不会再看提供者一眼

initProvideinitInjections 隔着 initState,一前一后。resolveInject 沿着 $parent 链 往上找 _provided,找到就把值取下来。关键在取下来之后:

Vue 2 · initInjections
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,和后代身上这份拷贝没有任何关系。实测:

provide 的值被重新赋值之后(实测输出)
改之前: <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 里同样不是自动响应的,官方文档的说法是传 refcomputed 下去。

插槽是函数,2.6 之后连普通插槽也是

插槽内容是在父组件的作用域里编译出来的,作为 children 挂在占位 VNode 上传给子组件。 initRenderresolveSlotssrc/core/instance/render-helpers/resolve-slots.js)把这堆 children 按 data.slot 分组,分不出名字的全进 default

子组件模板里的 <slot> 编译成 _t(...),也就是 renderSlot

Vue 2 · renderSlot(src/core/instance/render-helpers/render-slot.js)
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 之后第一条分支几乎总是命中,因为 normalizeScopedSlotssrc/core/vdom/helpers/normalize-scoped-slots.js)会把普通插槽也代理成函数, 挂到 $scopedSlots 上 —— 源码里那段循环的注释就是 expose normal slots on scopedSlots。 两种插槽从此在子组件这一侧长得一样,区别只剩「调用时传不传参数」。

这次统一是 2.6 的事,也是 slot-scopev-slot 取代的那一版。v-slot 只能写在 <template> 和组件标签上,位置固定,编译器一眼能看出插槽边界;slot-scope 可以写在任意元素上, 分析起来麻烦得多。2.6 起 slotslot-scope 就标了弃用,Vue 3 里它们不再有任何含义, 编译器当普通属性放过去,只剩 v-slot(简写 #)。

编译产物这边,Vue 2 用 _uresolveScopedSlots,在 src/core/instance/render-helpers/resolve-scoped-slots.js)把每个插槽包成 { key, fn } 收进一个对象, 并盖一个 $stable 标记:插槽名是静态的就是 stable。Vue 3 的同一段模板编译成一个插槽对象, 每个函数被 _withCtx 包住,末尾跟一个 _: 1 /* STABLE */

Vue 3 · 带具名插槽的编译产物(节选)
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 里有这么一段判断:

Vue 2 · 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 拿到的是被它包住的那个组件的元素。

Vue 2 · keep-alive 的 render
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(), 由 mountedupdated 两个钩子调用:

Vue 2 · keep-alive 的 cacheVNode
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 对象后来被就地改写来拿到实例。 翻旧文章看到那个版本,不用怀疑自己手上的源码。)

includeexclude 的判断是两个独立的排除条件用 || 连起来的,不是二选一。 两个都写时,一个组件必须既在 include 里、又不在 exclude 里,才会被缓存 —— exclude 优先。 另外这里比对的是组件的 name 选项:没写 name 的组件,include 永远匹配不上它。

缓存 key 的默认值是 cid + '::' + tag 而不是光一个 cid,源码注释说明了原因(#3269): 同一个组件定义可以在不同地方注册成不同的局部名字,光靠 cid 会把它们当成同一个来缓存。

真正让缓存生效的是前面那两个钩子。vnode.data.keepAlive = true 之后,init 发现实例还在, 走 prepatch 而不是新建;destroy 发现 keepAlive 为真,调 deactivateChildComponent 而不是 $destroyactivated / deactivated 这两个钩子只有被 keep-alive 包着的组件才会收到, 就是从这两条分支里发出来的。Vue 3 里它是 <KeepAlive> 组件,这两个钩子的名字没变。

异步组件先返回一个注释节点,加载完再逼父组件重渲染一次

异步组件在 createComponent 里从一个判断开始:isUndef(Ctor.cid)。构造器都有 cid, 没有 cid 说明拿到的是个工厂函数,转交给 resolveAsyncComponentsrc/core/vdom/helpers/resolve-async-component.js)。第一次调用时组件还没下载完, 它返回 undefinedcreateComponent 于是 return createAsyncPlaceholder(...) —— 一个注释节点。 页面上那个 <!----> 就是等待中的异步组件。

组件下载完之后,怎么让已经渲染过的地方重来一遍?答案朴素得有点意外:

Vue 2 · resolveAsyncComponent(节选)
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 里拿 propschildren,省掉的正是那三样东西的开销。 到了 Vue 3,functional 选项被删掉了,函数式组件就是一个接收 (props, context) 返回 vnode 的普通函数。 性能这条理由也没了:迁移指南写得很直接,有状态组件的开销已经降到「差别可以忽略」的程度, 建议就用普通组件。函数式组件在 Vue 3 里剩下的用处是写那种一次性的、只做一层包装的小组件。

回到最开始那个占位 VNode:它的 data.hook.init 造实例,prepatch 灌新 props,insertmounteddestroy 收场。组件系统所有看起来很不一样的能力 —— 插槽、keep-alive、异步加载 —— 最后都落在这四个钩子的某一条分支上。真正把这些 VNode 一个个比出来、决定哪个走 init、哪个走 prepatch 的,是 patch 里的那套 diff。

这篇是 Vue.js 内部机制深度解析的第 10 篇。前一篇是 Vue.js 异步更新与 nextTick 机制深度解析(下篇),后一篇是 Vue.js 状态管理模式:构建可扩展的应用架构