1. 从一次组件渲染异常说起:为什么要啃 createComponent
很多人第一次翻 Vue 源码,都是从new Vue()一路跟到$mount,然后卡在createElement里那一堆判断上。我当初也是这么过来的,真正让我下决心把createComponent这段逻辑彻底搞明白的,是一次线上组件的渲染异常:一个看起来平平无奇的函数式组件,在某些父级更新场景下,渲染出来的内容跟预期完全对不上,控制台也不报错,就是"静默地渲染错了"。排查了大半天,最后发现问题出在createComponent对组件配置对象的识别和Ctor缓存上。
这件事让我意识到,createComponent不是一个可以跳过的"中间函数",它是 Vue 组件化体系里承上启下的关键节点。往上,它接住createElement传下来的tag、data、children;往下,它决定这个vnode到底是普通元素还是组件,要不要走extend构造、要不要装钩子函数、children怎么变成slot。你平时写的<my-comp :list="list" @change="onChange">,最终能不能正确渲染、能不能正确更新,很大程度就取决于这个函数里的几个判断分支。
这篇内容适合两类人:一类是已经能熟练写 Vue 业务、想往源码层走一步的中级开发者;另一类是正在准备深入理解虚拟 DOM 与组件化机制、需要把"组件是怎么被创建成 vnode"这件事讲清楚的人。我会尽量用"从业者拆代码"的方式来讲,不堆术语,把每个判断背后的动机讲透,同时把我在实际调试中踩过的坑一并放进来。核心关键词就三个:createComponent、组件 vnode、构造器缓存,这三个词贯穿全文。
需要提前说明的是,本文基于 Vue 2.x 的运行时加编译器版本(也就是带compiler的完整构建)来展开,源码路径集中在src/core/vdom/create-component.js和src/core/vdom/create-element.js这两个文件。如果你用的是只含运行时的构建,模板编译那部分会由构建工具提前处理,但createComponent的运行时逻辑是一致的,不影响理解。
2. createComponent 在 createElement 里的调用时机与前置判断
2.1 createElement 的完整分支链路
要理解createComponent,得先看它是被谁、在什么条件下调用的。createElement的签名大致是createElement(context, tag, data, children, normalizationType, alwaysNormalize),它内部先做参数归一化,然后进入核心判断。这个判断顺序非常关键,因为它决定了"什么情况下才会走到组件创建"。
核心逻辑可以简化成这样一段伪代码:
// 简化后的 createElement 核心分支 if (typeof tag === 'string') { let Ctor ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag) if (config.isReservedTag(tag)) { // 平台保留标签,如 div、span vnode = new VNode(config.parsePlatformTagName(tag), data, children, undefined, undefined, context) } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) { // 关键分支:在组件选项里找到了这个 tag 对应的组件 vnode = createComponent(Ctor, data, context, children, tag) } else { // 未知标签,当作普通元素处理 vnode = new VNode(tag, data, children, undefined, undefined, context) } } else { // tag 本身就是组件构造器或选项对象 vnode = createComponent(tag, data, context, children) }这里有两个入口会调用createComponent:一个是tag是字符串、且能在当前实例的components选项里解析到对应组件时;另一个是tag直接就是组件构造器或选项对象(比如手写render函数时h(MyComponent))。这两个入口最终都汇到同一个函数,但传参形态不同,前者传的是解析出来的Ctor,后者传的就是原始tag。
我特别想强调isReservedTag这个判断的位置。它排在组件解析之前,意味着如果你给组件起了个跟 HTML 保留标签同名的名字,比如叫div或者span,那它永远不会被当成组件处理,而是直接走保留标签分支。这个坑我在早期项目里真踩过——有人把组件命名为header,结果渲染出来是个原生<header>元素,样式全乱,查了半天才发现是命名冲突。所以组件命名一定要避开保留标签,这是硬性约束。
2.2 resolveAsset 是怎么把字符串变成构造器的
上面那段代码里,resolveAsset(context.$options, 'components', tag)这一步是字符串 tag 能变成组件的前提。它的逻辑不复杂,但有几个细节值得说。
resolveAsset会先看$options.components里有没有以tag为键的项,如果没有,会尝试驼峰化和首字母大写两种变体。这就是为什么你写<my-component>和<MyComponent>都能解析到同一个组件——Vue 在解析时做了大小写和连字符的兼容处理。具体来说,它会依次尝试tag、camelize(tag)、capitalize(camelize(tag))三种形式。
// resolveAsset 的核心查找逻辑(简化) function resolveAsset(options, type, id, warnMissing) { const assets = options[type] if (hasOwn(assets, id)) return assets[id] const camelizedId = camelize(id) if (hasOwn(assets, camelizedId)) return assets[camelizedId] const PascalCaseId = capitalize(camelizedId) if (hasOwn(assets, PascalCaseId)) return assets[PascalCaseId] // 找不到时回退到原型链上的 assets const res = assets[id] || assets[camelizedId] || assets[PascalCaseId] return res }这里有个容易忽略的点:resolveAsset找到的Ctor可能是一个普通选项对象(你写components: { MyComp: { template: '...' } }),也可能是一个已经Vue.extend过的构造器。createComponent内部要能同时处理这两种形态,这就是它第一件事要做baseCtor.extend的原因。理解这一点,后面看Ctor缓存就不会觉得突兀。
2.3 为什么组件解析要放在保留标签判断之后
这个顺序不是随便定的。保留标签判断在前,是为了性能——绝大多数vnode都是普通 DOM 元素,先做一次快速的isReservedTag判断,能避免对每个元素都去components选项里查一遍。如果反过来,每个div、span都要走一次resolveAsset,在大列表渲染场景下开销会明显上升。
另一个原因是语义正确性。HTML 规范里保留标签有明确的语义,Vue 必须保证这些标签始终按原生元素处理,不能被用户自定义组件"劫持"。把保留标签判断前置,等于给原生标签加了一道保护。这个设计思路其实在很多框架里都能看到:先处理高频且语义固定的情况,再处理低频且需要查找的情况。
3. 组件 vnode 的构造:Ctor 缓存、钩子安装与 children 处理
3.1 baseCtor.extend 与构造器缓存机制
进入createComponent之后,第一段核心逻辑就是处理Ctor:
// createComponent 中处理 Ctor 的部分(简化) let baseCtor = context.$options._base if (isObject(Ctor)) { Ctor = baseCtor.extend(Ctor) } // 缓存处理 if (typeof Ctor === 'function') { if (!Ctor.cid) { // 没有 cid 说明是未缓存的构造器 if (options) { Ctor = baseCtor.extend(Ctor) } else { return createEmptyVNode() } } }baseCtor就是全局的Vue构造函数,context.$options._base在initGlobalAPI阶段被挂上去。baseCtor.extend做的事情,本质上是创建一个继承自Vue的子类,把传入的选项对象合并进去,并给这个子类分配一个唯一的cid(component id)。
为什么要缓存?因为每次渲染都可能重新执行createComponent,如果每次都extend一次,就会产生大量重复的构造器,既浪费内存,又会让后续的patch无法通过cid判断两个组件是否同类型。Vue 的做法是在extend内部做缓存:同一个选项对象,多次extend返回同一个构造器。
// Vue.extend 内部的缓存逻辑(简化) 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.cid = cid++ Sub.options = mergeOptions(Super.options, extendOptions) // 缓存 cachedCtors[SuperId] = Sub return Sub }缓存键是SuperId,也就是父构造器的cid。这意味着同一个选项对象,被不同的父类extend时会得到不同的子类,这是合理的——因为选项合并的结果依赖父类。这个细节在写高阶组件或者动态继承场景时很重要,我见过有人手动改_Ctor缓存导致组件行为错乱的案例,根源就是没理解缓存键的含义。
3.2 installComponentHooks:组件 vnode 的钩子从哪来
组件vnode和普通元素vnode最大的区别之一,是它的data上挂了一组组件特有的钩子:init、prepatch、insert、destroy。这些钩子不是用户写的,而是createComponent通过installComponentHooks装上去的。
// 组件钩子定义(简化) const componentVNodeHooks = { init(vnode, hydrating) { if (vnode.componentInstance && !vnode.componentInstance._isDestroyed && vnode.data.keepAlive) { // keep-alive 场景下的复用 const mountedNode = vnode componentVNodeHooks.prepatch(mountedNode, mountedNode) } 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, options.tag) }, insert(vnode) { const { context, componentInstance } = vnode if (!componentInstance._isMounted) { componentInstance._isMounted = true callHook(componentInstance, 'mounted') } // ... }, destroy(vnode) { const { componentInstance } = vnode if (!componentInstance._isDestroyed) { if (!vnode.data.keepAlive) { componentInstance.$destroy() } else { deactivateChildComponent(componentInstance, true) } } } }installComponentHooks会把这些钩子合并到data.hook上。注意它是"合并"而不是"覆盖",因为用户可能在data里也写了hook(比如某些指令或自定义渲染场景)。合并策略是:如果用户已经定义了同名钩子,就把组件钩子包一层,先执行用户的再执行组件的,或者反过来,具体取决于钩子类型。
这里有个实操经验:如果你在写函数式组件或者手动操作vnode时,发现组件的mounted钩子没触发,八成是insert钩子被覆盖或者没装上。我调试过一个场景,某第三方库在data.hook里塞了自己的insert,结果把组件的insert覆盖了,导致mounted永远不执行。解决办法是用mergeHook的方式合并,而不是直接赋值。
3.3 children 到 slot 的转换逻辑
组件vnode的children处理和普通元素完全不同。普通元素的children就是子vnode数组,而组件的children会被当作插槽内容,存到componentOptions.children里,而不是vnode.children。
// createComponent 中处理 children 的部分(简化) if (isDef(children)) { // 归一化 children if (!isDef(data.slot)) { children = normalizeChildren(children) } // 提取作用域插槽 if (isDef(data.scopedSlots)) { // ... } } const vnode = new VNode( `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`, data, undefined, undefined, undefined, context, { Ctor, propsData, listeners, tag, children }, asyncFactory )注意new VNode的第二个参数之后,children位置传的是undefined,真正的children被放进了最后一个参数componentOptions对象里。这就是为什么你在调试组件vnode时,vnode.children是空的,而vnode.componentOptions.children才有内容。这个设计是为了区分"元素子节点"和"插槽内容"两种语义。
data.slot的存在与否会影响归一化方式。如果指定了slot名,说明这是具名插槽内容,归一化时不会做额外的扁平化处理;如果没有指定,走normalizeChildren,会把嵌套数组拍平、把文本转成文本vnode。这个细节在写渲染函数时经常被忽略,导致插槽内容渲染异常。
4. 从源码回到实战:几个高频踩坑场景的排查链路
4.1 组件不渲染但无报错:从 resolveAsset 开始查
这是最常见的一类问题。现象是:模板里写了组件标签,页面什么都不显示,控制台也没有警告。排查链路我一般这么走。
第一步,确认组件是否被正确注册。检查components选项里键名和模板里用的标签名是否匹配。注意大小写和连字符的转换规则:模板里写<my-comp>,选项里写MyComp或myComp都能解析,但写mycomp(全小写无连字符)在某些情况下会解析失败,因为camelize('mycomp')还是mycomp,跟MyComp对不上。
第二步,确认标签名没有跟保留标签冲突。前面说过,isReservedTag判断在前,如果组件叫header、footer、main这类 HTML5 语义标签,会被当成原生元素。解决办法是加前缀,比如app-header。
第三步,确认resolveAsset的查找路径。如果组件是在某个子组件里注册的,而使用它的地方在更外层,那context.$options.components里是找不到的。Vue 的组件解析是沿着当前渲染上下文找的,不是全局查找。这种情况要么把组件注册到全局,要么在使用它的组件里局部注册。
第四步,如果以上都没问题,就要看是不是render函数里手动h调用时传错了参数。比如h('my-comp')传的是字符串,会走resolveAsset;而h(MyComp)传的是构造器,直接走createComponent。如果传了个未定义的变量,tag是undefined,会走到createEmptyVNode,什么都不渲染。
4.2 组件更新时 props 不生效:prepatch 与 updateChildComponent
组件首次渲染正常,但父组件更新后,子组件的props没跟着变。这类问题通常出在prepatch钩子和updateChildComponent上。
prepatch在组件vnode被patch时触发,它拿到新旧两个vnode,从旧vnode上取componentInstance,然后调用updateChildComponent更新propsData、listeners、children等。如果props没更新,可能的原因有几个。
一是props传递时用了非响应式的值。比如:list="someNonReactiveArray",父组件里这个数组不是响应式的,父组件重新渲染时vnode上的propsData没变,updateChildComponent里做props对比时认为没变化,就跳过了更新。解决办法是确保传给props的数据是响应式的,或者用:key强制重建组件。
二是props的default或validator里有副作用,导致propsData计算异常。我遇到过一个案例,default是个函数返回对象,但函数里读了外部可变状态,导致每次计算出的默认值不同,props对比逻辑混乱。props的default函数应该是纯函数,不依赖外部可变状态。
三是updateChildComponent里的props更新被shouldUpdateComponent拦截了。Vue 在patch前会判断组件是否需要更新,如果props、listeners、children都没变,且没有forceUpdate,就会跳过整个子组件更新。这个优化本身没问题,但如果你的组件依赖了props之外的隐式状态,就可能出现"该更新没更新"的情况。
4.3 函数式组件与普通组件在 createComponent 里的差异
函数式组件(functional: true)在createComponent里走的分支跟普通组件不同。普通组件会创建Ctor、装钩子、生成带componentOptions的vnode;函数式组件则直接调用render函数,把结果作为vnode返回,不创建组件实例。
// 函数式组件的处理分支(简化) if (isTrue(Ctor.options.functional)) { return createFunctionalComponent(Ctor, propsData, data, context, children) }这个分支在createComponent里排在Ctor处理之后、钩子安装之前。函数式组件没有componentInstance,所以init、prepatch这些钩子对它没意义。它的props是通过propsData直接传给render函数的第二个参数context的。
这里有个坑:函数式组件里拿不到this,所有数据都从context参数取。context.props是props,context.children是插槽内容,context.slots()返回插槽对象,context.data是vnode的data。如果你在函数式组件里写了this.xxx,会直接报错或者拿到undefined。我见过有人把普通组件改成函数式组件后忘了改this引用,排查了半天。
另一个差异是函数式组件没有生命周期钩子,created、mounted都不会触发。如果你需要在这些时机做事,函数式组件不是好选择。它的优势是渲染开销小、没有实例化成本,适合纯展示、无状态的场景,比如列表项、图标、简单布局组件。
4.4 异步组件在 createComponent 里的特殊处理
异步组件(工厂函数形式)在createComponent里也有专门分支。当Ctor是一个函数且没有cid时,Vue 会把它当作异步组件工厂处理,创建一个占位vnode,等工厂resolve后再替换。
// 异步组件处理(简化) if (isUndef(Ctor.cid)) { asyncFactory = Ctor Ctor = resolveAsyncComponent(asyncFactory, baseCtor) if (Ctor === undefined) { // 返回占位注释节点 return createAsyncPlaceholder(asyncFactory, data, context, children, tag) } }resolveAsyncComponent会调用工厂函数,工厂函数返回一个 Promise 或者接受resolve/reject回调。在resolve之前,渲染的是占位节点;resolve之后,会触发forceRender,重新执行createComponent,这次Ctor已经有cid了,走正常组件创建流程。
异步组件的坑主要集中在loading和error状态的处理上。如果你配置了loading组件,它在resolve之前显示;如果resolve失败且配置了error组件,显示错误组件。但要注意,loading和error组件本身不能是异步的,否则会陷入无限等待。另外,异步组件的resolve是带缓存的,同一个工厂函数多次调用只会resolve一次,这个缓存在resolveAsyncComponent内部维护。
5. 手写一个简化版 createComponent:把逻辑跑通一遍
5.1 最小可运行实现
光看源码容易"以为自己懂了",我习惯的做法是手写一个简化版,把核心逻辑跑一遍。下面这个实现去掉了平台差异、异步组件、函数式组件等分支,只保留最核心的"字符串 tag 解析 + Ctor 缓存 + 钩子安装 + vnode 构造"。
// 简化版 createComponent,仅用于理解核心流程 let cid = 0 function Vue(options) { this._init(options) } 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 = Object.assign({}, Super.options, extendOptions) cachedCtors[SuperId] = Sub return Sub } Vue.cid = 0 Vue.options = {} function createComponent(Ctor, data, context, children, tag) { const baseCtor = context.$options._base if (typeof Ctor === 'object') { Ctor = baseCtor.extend(Ctor) } if (typeof Ctor !== 'function') { return createEmptyVNode() } data = data || {} // 安装组件钩子 installComponentHooks(data) const name = Ctor.options.name || tag const vnode = { tag: `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`, data, children: undefined, context, componentOptions: { Ctor, propsData: extractPropsFromVNodeData(data, Ctor), listeners: data.on, tag, children } } return vnode } function installComponentHooks(data) { const hooks = data.hook || (data.hook = {}) for (const key in componentVNodeHooks) { const existing = hooks[key] const ours = componentVNodeHooks[key] hooks[key] = existing ? mergeHook(ours, existing) : ours } } function mergeHook(ours, existing) { return function mergedHook() { ours.apply(null, arguments) existing.apply(null, arguments) } } function createEmptyVNode() { return { tag: undefined, data: undefined, children: undefined, text: '' } }这段代码跑起来,你会发现几个关键点:Ctor缓存确实生效了,同一个选项对象多次createComponent返回同一个构造器;钩子合并逻辑保证了用户钩子和组件钩子都能执行;componentOptions里存了Ctor、propsData、listeners、children,这些正是后续patch和updateChildComponent需要的。
5.2 用这个简化版验证几个边界情况
写完简化版,我一般会拿几个边界情况去验证,看看行为是否符合预期。
第一个边界:Ctor是选项对象且没有name。这时候vnode.tag会是vue-component-${cid},没有后缀。这个tag主要用于调试和patch时的类型判断,不影响渲染。
第二个边界:data.hook里已经有init钩子。合并后,用户init先执行,组件init后执行。如果用户init里改了vnode.componentInstance,组件init会覆盖它。这个顺序在大多数场景下没问题,但如果用户想在组件实例创建后做点什么,应该用insert钩子而不是init。
第三个边界:children是嵌套数组。normalizeChildren会把它拍平,但拍平只做一层还是递归做,取决于normalizationType。createComponent里传的是alwaysNormalize还是simpleNormalizeChildren,会影响最终children的结构。这个细节在写渲染函数时要注意,嵌套太深可能导致插槽内容顺序错乱。
第四个边界:propsData提取。extractPropsFromVNodeData会从data.attrs和data.props里按Ctor.options.props的键名提取。如果props里定义了camelCase的键,而模板里用的是kebab-case,提取时会做转换。这个转换逻辑跟resolveAsset的大小写处理类似,但方向相反。
5.3 从简化版反推源码里的设计取舍
跑完简化版,再回头看源码,很多设计取舍就清楚了。比如为什么Ctor缓存要放在extend里而不是createComponent里?因为extend是更底层的 API,用户可能直接调用Vue.extend,缓存放在这里能覆盖所有调用路径。如果放在createComponent里,用户手动extend的构造器就不会被缓存。
再比如为什么钩子要合并而不是覆盖?因为data.hook是用户可写的,如果直接覆盖,用户写的钩子就丢了。合并虽然多了一层函数调用,但保证了扩展性。这个思路在 Vue 源码里很常见:凡是用户可能介入的地方,都留了合并或回退的余地。
还有为什么children要放进componentOptions而不是vnode.children?因为组件vnode的children语义跟元素vnode不同。元素vnode的children是子节点,组件vnode的children是插槽内容,两者在patch时的处理逻辑完全不同。分开存放,patch时就能根据vnode类型走不同分支,避免混淆。
6. 组件 vnode 的 tag 命名规则与调试技巧
6.1 vue-component-cid-name 的生成逻辑
组件vnode的tag不是用户写的标签名,而是vue-component-${Ctor.cid}${name ?-${name}: ''}这个格式。这个设计有几个实际用途。
第一,调试时能快速识别组件类型。你在 Vue Devtools 或者打印vnode时,看到vue-component-5-MyList,就知道这是cid为 5、名字叫MyList的组件。cid是全局递增的,同一个组件构造器在所有地方cid都一样,方便追踪。
第二,patch时用于类型判断。两个vnode是否同类型,会先比较tag。如果tag不同,直接判定为不同节点,走销毁重建流程。组件vnode的tag包含cid,所以不同组件构造器的vnode一定不同tag,不会误判为同类型。
第三,name后缀来自Ctor.options.name或传入的tag。如果组件选项里没写name,就用模板里的标签名。这个name在keep-alive的include/exclude匹配、递归组件自引用等场景会用到。我建议给每个组件都显式写name,不要依赖自动推断,因为自动推断在压缩混淆后可能失效。
6.2 用 Devtools 和断点定位组件创建问题
调试组件创建问题,我常用的两个手段:Vue Devtools 和源码断点。
Devtools 里能看到组件树,每个组件的name、props、data都列得清清楚楚。如果某个组件没出现在组件树里,说明它根本没被创建成组件vnode,问题出在resolveAsset或isReservedTag阶段。如果出现了但props不对,问题出在extractPropsFromVNodeData或updateChildComponent。
源码断点我一般打在createComponent入口和installComponentHooks里。在createComponent入口看传入的Ctor、data、children是否符合预期;在installComponentHooks里看data.hook合并前后的状态。如果Ctor是undefined或者不是函数,说明resolveAsset没找到组件;如果data.hook里init被覆盖了,说明钩子合并出了问题。
还有一个技巧:在createComponent里临时加console.log,打印Ctor.cid和Ctor.options.name。这样能快速确认组件是否被正确解析、cid是否稳定。如果同一个组件每次渲染cid都变,说明Ctor缓存没生效,每次都在extend新构造器,这会导致组件频繁销毁重建,性能很差。
6.3 组件 name 与 keep-alive 的匹配陷阱
keep-alive的include/exclude是按组件name匹配的。如果组件没写name,keep-alive会尝试用vnode的tag里的name部分,也就是Ctor.options.name || tag。但tag在压缩后可能变成短变量名,导致匹配失败。
我遇到过一个案例:组件在开发环境keep-alive正常工作,打包上线后缓存失效。排查发现组件没写name,开发环境tag是完整的组件名,压缩后变成了t之类的短名,keep-alive的include匹配不上。解决办法很简单:给组件显式写name,且name用字符串字面量,不要用变量,这样压缩工具不会改它。
另一个陷阱是递归组件。递归组件通过name自引用,如果name没写或者写错,递归会失败。递归组件的name必须是静态字符串,且在组件选项里显式声明。我见过有人用name: this.someVar,结果this在选项求值时还不是组件实例,拿到undefined,递归直接崩。
7. 组件化链路里 createComponent 的上下游关系
7.1 上游:createElement 的参数归一化
createComponent拿到的children是经过createElement归一化过的。归一化分两种:simpleNormalizeChildren和normalizeChildren。前者只做一层拍平,用于编译器生成的渲染函数(编译器已经保证了children结构);后者做递归拍平,用于用户手写的渲染函数。
这个区分是为了性能。编译器生成的children结构是可控的,不需要递归处理;用户手写的可能嵌套任意深度,必须递归拍平。createComponent里对children的处理依赖这个归一化结果,如果归一化没做好,插槽内容可能顺序错乱或者丢失。
data的归一化也值得说。createElement会把data里的class、style、attrs、on等做标准化处理,比如把class的字符串形式转成对象形式,把on的事件名做大小写处理。createComponent拿到的data已经是标准化后的,所以extractPropsFromVNodeData能直接按props键名从data.attrs和data.props里取值。
7.2 下游:patch 如何消费组件 vnode
createComponent产出的组件vnode,最终会被patch消费。patch遇到组件vnode时,会走createElm里的组件分支,调用createComponent时装的init钩子。
init钩子做两件事:创建组件实例、挂载。创建实例用createComponentInstanceForVnode,它从vnode.componentOptions里取Ctor、propsData、listeners、children,构造出组件实例。挂载用child.$mount,把组件渲染成真实 DOM,替换掉占位节点。
更新时,patch会调用prepatch钩子,进而调用updateChildComponent。updateChildComponent会更新props、listeners、children,并触发子组件的重新渲染。如果props变化触发了子组件render,子组件会生成新的vnode树,再走一遍patch。
销毁时,patch调用destroy钩子,触发组件实例的$destroy,清理事件监听、watcher、子组件等。keep-alive场景下,destroy不会真正销毁,而是调用deactivateChildComponent,把组件实例缓存起来,下次复用。
7.3 一个完整的组件渲染时序
把上下游串起来,一个组件从模板到渲染的完整时序大致是这样:
- 模板编译成渲染函数,渲染函数里调用
_c('my-comp', data, children)。 _c就是createElement,它归一化参数,判断my-comp不是保留标签,调用resolveAsset找到Ctor。createComponent被调用,Ctor如果是选项对象就extend成构造器,装钩子,生成组件vnode。patch遇到组件vnode,调用init钩子,创建组件实例并挂载。- 组件实例的
$mount触发组件自身的渲染,生成子vnode树,递归patch。 - 父组件更新时,
prepatch钩子触发updateChildComponent,更新props等,触发子组件重新渲染。 - 组件销毁时,
destroy钩子触发$destroy,清理资源。
这个时序里,createComponent是第 3 步,承上启下。理解它在整个链路里的位置,比孤立地看它的代码更有价值。
8. 我在实际项目中总结的几条经验
第一条,组件命名一定要加业务前缀,避开保留标签。我现在的习惯是统一用app-或biz-开头,既避免冲突,又方便在 Devtools 里按前缀过滤。这个习惯是从一次header命名冲突的线上问题后养成的,代价不小。
第二条,给每个组件显式写name,且用字符串字面量。这能保证keep-alive匹配、递归组件自引用、Devtools 显示都正常,压缩后也不受影响。我见过太多因为没写name导致的诡异问题,加上这一行成本极低,收益很高。
第三条,函数式组件不要滥用。它确实省内存、渲染快,但没有生命周期、没有this、调试信息少。我一般只在纯展示、高频渲染的场景用,比如长列表的每一项。有状态、有交互的组件,老老实实用普通组件。
第四条,异步组件的loading和error组件要提前测。这两个组件在resolve前后切换,容易出现闪烁或者状态残留。我一般会在loading组件上加个最小显示时间,避免加载太快导致闪烁;error组件要能重试,给用户一个恢复的入口。
第五条,调试组件创建问题时,先确认Ctor是否被正确解析,再看钩子是否装上,最后看children和propsData是否符合预期。这个顺序能覆盖 90% 的组件创建问题,比盲目打断点高效得多。
第六条,不要手动改_Ctor缓存。这个缓存是extend内部维护的,手动改会破坏缓存一致性,导致组件行为错乱。如果确实需要动态继承,用Vue.extend正常调用,让缓存机制自己工作。
第七条,理解componentOptions和vnode.children的区别。调试时看到vnode.children是空的不要慌,组件vnode的插槽内容在componentOptions.children里。这个区分在写自定义渲染逻辑时特别重要,搞混了会导致插槽内容处理错误。
这些经验都是我在实际项目里踩坑踩出来的,每一条背后都有具体的排查过程。源码读一遍可能记不住,但结合这些坑去理解,印象会深很多。createComponent这个函数本身代码量不大,但它牵扯到的组件化机制、缓存策略、钩子系统、插槽处理,每一个都值得单独深挖。把这一节吃透,后面看patch、updateChildComponent、keep-alive都会顺很多。