☰
Vue 2.x源码解析:从new Vue到DOM更新的数据驱动渲染链路
2026/10/14 1:39:15 网站建设 项目流程

数据驱动、new Vue 发生的过程、Vue 实例挂载的实现、render、Virtual DOM、createElement、update,这一串概念单看每一个都不算难,难的是把它们串成一条完整的线。我这两年带团队做 Vue 源码共读,发现大多数人卡住的地方不是某个函数看不懂,而是不知道这些概念之间谁先谁后、谁调用谁。这篇文章就沿着 Vue 2.x 源码的执行顺序,从new Vue这行代码开始,一直走到真实 DOM 被更新,把整条渲染链路完整拆开。

先回答一个很多人都会问的问题:读 Vue 源码到底有什么用?答案很实际——当你理解了这条数据驱动链路后,日常开发里很多"玄学"就变成了"必然"。比如为什么beforeCreate里拿不到 data,为什么子组件的mounted会先于父组件触发,为什么列表渲染用 index 作 key 会出问题,为什么有时候改了数据视图没反应。这些问题的答案全部藏在这条链路里。

1. 数据驱动这条主线:七个主题如何串成一条渲染链路

1.1 一次渲染到底经过几个阶段

先用一个最简单的例子把场景立起来。页面上有一行代码:

<div id="app"><p>{{msg}}</p></div>

然后执行:

new Vue({ el: '#app', data: { msg: 'hello' } })

页面出现了 "hello"。这个结果大家都很熟悉,但背后发生的事分成三个阶段。

第一个阶段是初始化。new Vue内部会做选项合并、建立响应式数据、初始化事件和生命周期相关状态。这个阶段结束后,你的 data 已经被改造成了带 getter/setter 的响应式对象,只是还没有渲染出任何 DOM。

第二个阶段是挂载。因为传了el,Vue 会调用$mount,进入渲染流程。这里要说明一点:如果没有传el,初始化阶段就不会主动渲染,实例会一直处于"待命"状态,直到你手动调用$mount。

第三个阶段是渲染与更新。render函数被执行,产出 Virtual DOM(也就是 vnode 树),再通过patch把 vnode 树映射成真实 DOM。之后当msg变成'world'时,setter 会通知渲染 watcher,重新执行一遍渲染流程,生成新的 vnode 树,再和旧树对比,只更新有差异的 DOM 节点。

这就是 Vue 文档里反复强调的"数据驱动":视图不是被手动改出来的,而是数据变化自动触发重渲染。关键点在中间隔着一层 vnode,不是"数据变了直接改 DOM",而是"数据变了 → 重新生成 vnode → diff → 精准更新 DOM"。

1.2 先记住这条调用链,再开始读源码

读 Vue 源码最忌讳从头看到尾,因为core目录下的文件互相引用,顺序阅读很容易迷路。我建议先把下面这条调用链写下来,贴在编辑器旁边,后面每一节都会对应到这条链上的一个环节:

new Vue(options) └ _init ├ 选项合并,生成 vm.$options ├ initLifecycle / initEvents / initRender ├ beforeCreate ├ initInjections ├ initState(props → methods → data → computed → watch) ├ initProvide ├ created └ 如果存在 options.el,则执行 $mount └ mountComponent ├ new Watcher(updateComponent) // 渲染 watcher └ updateComponent = _update(_render()) ├ _render() → 执行 render 函数 → createElement → VNode 树 └ _update() → patch → 真实 DOM

接下来第 2 节讲_init里的初始化顺序,第 3 节讲$mount和mountComponent,第 4 节讲_render与createElement如何生成 vnode,第 5 节讲_update里 patch 的 diff 策略。整篇文章就是沿着这条链往下走,每到一个节点就展开讲细节。

2. new Vue 时发生了什么:_init 的调用顺序藏着哪些因果关系

2.1 构造函数只是壳,真正的逻辑在 _init

Vue 的构造函数简单得让人意外,核心就一句话:

function Vue(options) { if (!(this instanceof Vue)) { warn('Vue is a constructor and should be called with the `new` keyword') } this._init(options) }

真正的逻辑全部在_init里。简化后的初始化流程大概是这个样子:

Vue.prototype._init = function(options) { const vm = this vm.$options = mergeOptions(resolveConstructorOptions(vm), options, vm) initLifecycle(vm) initEvents(vm) initRender(vm) callHook(vm, 'beforeCreate') initInjections(vm) initState(vm) initProvide(vm) callHook(vm, 'created') if (vm.$options.el) { vm.$mount(vm.$options.el) } }

这段代码里每一行都对应一个明确职责。initLifecycle初始化$parent、$children、$root、$refs这些实例属性;initEvents处理父组件传入的事件监听器;initRender定义vm._c和vm.$createElement,同时给$attrs和$listeners做响应式代理。

注意两个 hook 的位置:beforeCreate在initState之前触发,所以此时访问this.msg得到的是undefined;created在initState之后触发,此时 data、computed、methods 都已经可用,但 DOM 还没挂载。很多初学者分不清这两个钩子的区别,其实就是initState的调用时机决定的。

还有个顺序细节很容易被忽略:initInjections在initState前面,initProvide在initState后面。这样设计是有道理的——data 初始化时可能用到注入的值,而 provide 的值又可能依赖 data。如果你把这两个顺序调换,就会遇到"data 里拿不到 inject,provide 里拿不到 data"的尴尬。

2.2 initState 的选择:props、methods、data、computed、watch 为什么按这个顺序

initState内部的初始化顺序是固定的:

export function initState(vm) { vm._watchers = [] const opts = vm.$options if (opts.props) initProps(vm, opts.props) if (opts.methods) initMethods(vm, opts.methods) if (opts.data) initData(vm) if (opts.computed) initComputed(vm, opts.computed) if (opts.watch && opts.watch !== nativeWatch) initWatch(vm, opts.watch) }

这个顺序不是随便写的,每一步都依赖前面已经存在的东西。

props 最先初始化,因为 data 里完全可能引用 props 的值。比如你写:

data() { return { total: this.base + this.extra } }

这里的base、extra如果来自 props,那么 props 必须在 data 之前准备好,否则this.base就是undefined。

methods 紧随其后,原因是 data 初始化时也可能调用 methods 里的函数。一个很常见的写法是:

data() { return { list: this.buildList() } }

如果 methods 还没初始化,buildList就不存在,这一行直接报错。

data 排第三,因为它要被observe变成响应式对象,这是后面 computed 和 watch 的基础。computed 显然要依赖 data,所以放第四。watch 放最后,因为它既要监听 data 的变化,也可能监听 computed 的变化,必须等前面都就绪。

我用一个生活化的类比帮团队理解:先把外部传进来的料接住(props),把菜刀备好(methods),再处理食材(data),然后开火烹饪(computed),最后安排一个人盯着锅(watch)。顺序乱了,后续步骤必然出问题。

另外注意一个实现细节:initData会把vm._data上的属性代理到vm本身上,这就是为什么你能直接写this.msg而不是this._data.msg。代理是用Object.defineProperty实现的,这一步同时为后续的 getter/setter 拦截打了基础。

2.3 mergeOptions 与 $options:el 的存在决定了要不要挂载

_init里的第一个动作是生成vm.$options,它调用了mergeOptions。这个合并经历里有很多策略:生命周期钩子会合并成数组(同名的都保留,按顺序执行),props、methods、computed这类对象是后者覆盖前者,data在组件场景下是函数,需要递归合并。

Vue 的全局 mixin 能影响所有组件,原理就是往构造函数的options里预先塞入一份配置,等到每个组件实例化时合并进去。所以你在 mixin 里定义的created钩子和组件自己的created钩子都会执行,顺序是先 mixin 后组件本身。

还有一点要注意:_init的最后一行判断的是vm.$options.el是否存在。如果你new Vue({ data: {...} })不带 el,初始化就到此结束,不会执行$mount。这给后续手动挂载留了空间,但也制造了一个常见困惑——很多人以为new Vue一定会渲染,实际上没有 el 就什么都没有。

3. 实例挂载的实现:从 $mount 到 mountComponent 的调用链

3.1 运行时版和完整版 $mount 的差异,以及"找不到 render"的坑

Vue 2.x 的发布包分成两个版本:完整版(vue.js)和运行时版(vue.runtime.js)。两者的$mount实现不一样,这个差异是很多"运行时警告"的根源。

完整版的$mount可以简化理解为:

const mount = Vue.prototype.$mount Vue.prototype.$mount = function(el, hydrating) { const options = this.$options if (!options.render) { let template = options.template if (!template && el) { template = getOuterHTML(el) } if (template) { const { render } = compileToFunctions(template, options) options.render = render } } return mount.call(this, el, hydrating) }

它的逻辑是:如果实例没有render函数,就尝试从template选项拿模板;连template都没有,就取el对应的 DOM 元素的outerHTML作为模板,然后用编译器compileToFunctions把模板编译成render函数。编译完成后,再调用运行时版真正的$mount。

运行时版的$mount就不做任何编译,直接进入mountComponent。所以当你把new Vue({ template: '<div>{{msg}}</div>' })写在一个引用了运行时版的工程里,会得到一条警告:说你既没有 render 函数,也没有可用的编译器。CLI 脚手架默认引入的就是运行时版,因为.vue单文件组件在构建阶段已经把模板编译成 render 了,运行时不需要再带编译器。

如果你的确有运行时编译需求,可以开启runtimeCompiler配置。但这会引入完整版编译器,包体积会变大,我个人的建议是能不开就不开,能用.vue或手写 render 就别依赖运行时编译。

3.2 mountComponent 的核心:渲染 watcher 与 updateComponent

$mount最终会走到mountComponent,它的核心逻辑简化出来是这样:

export function mountComponent(vm, el, hydrating) { vm.$el = el if (!vm.$options.render) { vm.$options.render = createEmptyVNode if (process.env.NODE_ENV !== 'production') { warn('render 函数不存在,模板编译失败时会走到这个分支') } } callHook(vm, 'beforeMount') let updateComponent = () => { vm._update(vm._render(), hydrating) } new Watcher(vm, updateComponent, noop, { before() { if (vm._isMounted && !vm._isDestroyed) { callHook(vm, 'beforeUpdate') } } }, true) if (vm.$vnode == null) { vm._isMounted = true callHook(vm, 'mounted') } return vm }

几个关键点要拆开看。

updateComponent是"渲染 + 更新"的统一入口。首次执行它,完成首屏渲染;之后每次响应式数据变化,触发依赖,最终也会重新执行它。这个设计非常巧妙:不管首次还是更新,走的都是同一条路。

new Watcher(vm, updateComponent, noop, ..., true)创建的就是渲染 watcher。第五个参数true表示这是一个渲染 watcher,会被存在vm._watcher上,和用户通过watch选项创建的 watcher 区分开。Watcher在实例化时会立即求值一次,所以这行代码执行完,首屏 DOM 就已经渲染出来了。

beforeMount在 watcher 创建之前调用,所以你在beforeMount里还看不到渲染出来的 DOM。mounted则分两种情况:根实例在mountComponent末尾直接调用;子组件的mounted不是在这里触发的,而是在 patch 阶段通过insertedVnodeQueue队列触发,后面细讲。

3.3 beforeMount、mounted 的触发时机,以及父子组件顺序

理解生命周期钩子触发时机,不能只记结论,要回到底层位置。

beforeMount发生在渲染 watcher 创建之前。对于父组件来说,它的beforeMount先触发,然后才轮到子组件初始化渲染,因此顺序是父组件的beforeMount在前,子组件的beforeMount在后。

子组件的mounted钩子不是由mountComponent直接触发的。当 patch 过程创建了一个组件 vnode 对应的真实节点时,会把组件实例的 mounted 回调推进insertedVnodeQueue,最后在invokeInsertHook里统一执行。而根实例的mounted在mountComponent的末尾,且要求vm.$vnode == null(说明是根节点)才会调用。

所以实际观察到的执行顺序是:父beforeMount→ 子beforeMount→ 子mounted→ 父mounted。这个顺序经常在业务里被利用,比如父组件要在所有子组件挂载完成后才能做一些初始化操作,那就放父的mounted里。

还有一个挂载细节值得记住:vm.$el在mountComponent中被赋值为传入的 DOM 元素,但首屏渲染完成后,vm.$el会被替换成 vnode 渲染出的真实 DOM。这就是为什么很多人发现"挂载容器自己消失了"——#app这个元素被渲染出来的新节点替换掉,原来的容器不再存在于文档中。

4. render 与 Virtual DOM:createElement 是如何生成 vnode 的

4.1 模板编译出来的 render 函数长什么样

很多人对 render 函数有陌生感,其实它没那么神秘。来看一个模板:

<div id="app"><p>{{msg}}</p></div>

经过模板编译,变成的 render 函数大致是这个样子:

with(this) { return _c('div', { attrs: { id: 'app' } }, [ _c('p', [_v(_s(msg))]) ]) }

这里的_c是编译模板专用的 createElement,对应vm._c;_v是创建文本 vnode 的createTextVNode;_s是把值转成字符串的toString。手写 render 时你通常用的是h参数,它对应的是vm.$createElement,和_c的区别在于参数规范化策略不同,后面会说到。

如果你在工程里引入了vue-template-compiler,可以写个小脚本把模板编译出来看看输出。我建议每个人都做一次这个实验,因为它能直观地告诉你:模板和 render 函数本质上是同一件事的两种表达,模板还要经过编译这一步,render 函数则直接绕过编译。

等价的手写 render 写法是:

new Vue({ render(h) { return h('div', { attrs: { id: 'app' } }, [h('p', this.msg)]) } })

理解了这个等价关系,你就明白为什么 Vue 文档说"模板最终会被编译成 render 函数"。模板是给人写的,render 函数是给运行时跑的。

4.2 createElement 的参数重载与 children 规范化

直接看createElement源码的人,经常被它的参数列表搞得一头雾水。它的签名是:

export function createElement(context, tag, data, children, normalizationType, alwaysNormalize) { if (Array.isArray(data) || isPrimitive(data)) { normalizationType = children children = data data = undefined } if (isTrue(alwaysNormalize)) { normalizationType = ALWAYS_NORMALIZE } return _createElement(context, tag, data, children, normalizationType) }

注意那个重载判断:如果你写h('div', 'hello'),第二个参数是一个原始字符串,它会被重新当作children,data变成undefined。如果你写h('div', [h('span')]),第二个参数是数组,同样被当成children。只有你写h('div', { attrs: {...} }, children)的时候,才是 data 在第二个参数、children 在第三个参数的标准用法。

这个"弹参数"的设计是为了让手写 render 更顺手:大多数简单节点不需要传 data,直接传 children 就行。

真正复杂的部分是 children 的规范化。normalizationType有 0 和 1 两种:

normalizeChildren(类型 0)会递归处理嵌套数组,并把字符串、数字这类原始值转成文本 vnode。它是给手写 render 的$createElement用的,因为人写代码时很容易写出h('div', 'text')、h('div', ['a', h('b')])这种不规整的输入。

simpleNormalizeChildren(类型 1)只把二维数组拍平一层。它是给编译模板的_c用的,因为模板编译产物已经是规范的 vnode 数组,只需要处理函数式组件可能返回数组这种情况。

为什么规范化这么重要?因为 patch 阶段遍历 children 时,默认假设它是一个规范的 vnode 数组。如果里面有原始字符串或者深层嵌套数组,diff 过程会出错,或者出现"文本节点没有被当作节点处理"的诡异表现。规范化相当于在入口处把各种"违章输入"统一修整成标准格式。

4.3 VNode 结构拆解:普通元素和组件怎么区分

VNode 类本身字段很多,但核心的就这几个。

字段含义
tag标签名;如果是组件,则存储组件名称
data属性、事件、指令等,patch 时由对应模块消费
children子 vnode 数组
text文本节点的内容
elm对应的真实 DOM,patch 过程会回填
key来自 data.key,diff 复用判断的关键
componentOptions组件 vnode 特有:Ctor、propsData、listeners、children
componentInstance组件实例,patch 阶段创建后回填
parent父 vnode
isComment / isStatic / isRootInsert / isCloned各种标记位

普通元素和组件 vnode 的创建路径不同。_createElement在拿到一个字符串类型的 tag 时,会先判断它是不是 HTML 保留标签或 SVG 标签;如果不是保留标签,就去components注册表里找有没有同名的组件构造器;找到就进入createComponent创建组件 vnode,找不到就当作一个普通未知标签处理。

createComponent做的事情比较多:解析出组件构造器Ctor,验证它是否具备渲染能力,然后处理 props、事件监听器、插槽内容,把这些打包进componentOptions,最后返回一个带着componentOptions的 vnode。

这里有一个常踩的坑:组件注册名拼写错误时,resolveAsset找不到组件,Vue 不会报错,而是把它当成普通 HTML 标签渲染。所以你可能会看到页面上莫名出现一个不认识的自定义标签,却没有收到任何提示。理解了这个查找逻辑,以后遇到"组件没渲染"的问题,第一反应就应该是去检查注册名和模板里的标签名是否完全一致。

对渲染引擎来说,组件和原生元素没有本质区别,都是 vnode 树上的一种节点,只是组件 vnode 在 patch 阶段多走一步:实例化子组件。这就是"一切皆 vnode"的心智模型,掌握了它,很多渲染层的行为都能预测。

5. update 的执行链路:_update 里 patch 的 diff 策略与 key 的作用

5.1 _update 的两个分支:首次渲染和更新渲染

mountComponent里的updateComponent会调用_update,它的实现简化为:

Vue.prototype._update = function(vnode, hydrating) { const vm = this const prevVnode = vm._vnode vm._vnode = vnode if (!prevVnode) { // 首次渲染:oldVnode 是真实 DOM 元素 vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false) } else { // 后续更新:oldVnode 是上一次渲染的 vnode 树 vm.$el = vm.__patch__(prevVnode, vnode) } }

首次渲染时,vm._vnode还不存在,所以 patch 的 oldVnode 参数是vm.$el——一个真实 DOM 元素,就是你的#app。patch 内部会通过emptyNodeAt把这个真实元素包装成一个"空 vnode",然后再把它和新 vnode 树对比。

更新渲染时,oldVnode 是上一次生成的 vnode 树。两条路径最终都会走到同一个 patch 核心,只是 oldVnode 形态不同。

__patch__本身是一个平台相关的注入方法,Web 端和 Weex 端传入的 nodeOps 和 modules 不一样。Web 端的 patch 里,modules 包括 attrs、class、style、event、domProps 这些模块,各自负责 vnode data 里对应字段的更新。这也是 Vue 源码模块化设计的一个亮点:核心 diff 逻辑与平台无关,平台差异被隔离在 modules 里。

5.2 patch 的同层 diff 策略与 four-pointer 算法

patch 的入口逻辑先判断两个节点是否值得复用,判断函数是sameVnode:

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) }

key 和 tag 是核心判断标准。如果新旧节点满足sameVnode,就进入patchVnode,做属性更新、子节点 diff、文本更新;如果不满足,就直接创建新节点、删除旧节点,不再尝试部分复用。

子节点的 diff 就是著名的四指针算法。updateChildren维护 oldStart、oldEnd、newStart、newEnd 四个索引,从数组两端向中间收敛。每次循环优先尝试四种匹配:头头比较、尾尾比较、旧头对新尾、旧尾对新头。命中就用patchVnode递归处理;四种都没命中,就用 key 建立索引去旧 children 里查找可复用的节点。

这个算法有几个值得琢磨的点。第一,它只在同一层级比较,不跨层查找。Vue 不会把一个 vnode 从旧树的某个父节点下直接"移到"另一个父节点下;如果层级变了,结果是旧节点被删除、新节点被创建。第二,算法是近似线性复杂度的,对常规列表更新非常高效。第三,它依赖 key 来提供"跨位置复用"的能力,没有 key 时只能靠位置硬对。

为什么不做跨层 diff?真实业务里把整个子树从 A 父节点迁移到 B 父节点的场景极其罕见,为了这种少见需求去增加算法复杂度不划算。Vue 选择的是"做 99% 场景的高效匹配,剩下 1% 退化为重建"。理解这个工程权衡,比记住算法本身更重要。

对开发者的启示是:保持组件树的结构稳定。如果你在v-if里频繁切换包裹层级,或者在v-for外层套不同的父标签,diff 很可能退化成销毁重建,直接拖累性能。

5.3 key 到底影响了什么,index 为什么不是好 key

因为sameVnode会把 key 作为首要判断条件,所以 key 的本质作用是给 diff 提供"跨位置复用"的依据。列表项的顺序变了,只要 key 不变,Vue 就能认出这是同一个节点,从而复用它的 DOM 和组件实例。

用 index 作 key 的经典问题,我用一个大家都遇到过的场景说明。渲染一个可编辑列表:

<li v-for="(item, index) in list" :key="index"> <input v-model="item.value" /> </li>

在列表头部插入一条数据后,原来第一行的输入框内容会"跟着 index 走",导致每个输入框里的值和对应的数据项错位。因为 index 从 0 开始逐个更新,Vue 认为 index 为 0 的节点还是原来那个节点,只是内容变了,DOM 被原地复用,输入框状态就乱了。

正确做法是给每条数据一个业务上稳定的唯一 id,用id作为 key。还有一个容易被忽视的坑:key 不要用随机数。每次渲染都生成新的随机值,等于告诉 Vue "所有节点都变了",diff 的复用机制完全失效,性能反而更差。

某些场景下没有 key,Vue 会尝试按 index 原地复用,并给出"请为 v-for 提供 key"的警告。这个警告不是小题大做,它背后就是 diff 复用机制在提醒你:不给 key,跨位置复用就无法实现。

6. 读源码会踩的坑和排错思路:从调用栈反推渲染链路

6.1 选对版本和调试环境,读码速度翻倍

读 Vue 2.x 源码,我建议以 2.6 系列为准。这个版本的结构比 3.x 简单清晰,响应式(defineProperty + Watcher + Dep)和 vdom(createElement + patch)都在同一个包里,适合首次完整通读。3.x 把响应式换成了 Proxy,编译链路也拆成了独立模块,等 2.x 这条线读完再对照着读,会轻松很多。

调试环境方面,不要一上来就用脚手架项目打断点,干扰因素太多。我的做法是建立一个最小 HTML 页面,直接引用开发构建的 vue.js(未压缩版本),然后写一段最简单的渲染代码。在_render、_update、createElm这几个关键函数上打断点,刷新页面就能看到完整的首屏渲染调用栈。

更实用的一个习惯是:从一次具体的数据变化倒着看。在浏览器控制台执行app.msg = 'world',然后在dep.notify上打断点,顺着调用栈往上翻,你会看到notify→watcher.update→queueWatcher→flushSchedulerQueue→updateComponent→_render→_update→patch的完整链路。把这条链走顺,前面所有章节的内容全部串起来了。

调试时注意确认自己加载的是开发构建且 sourcemap 正确。如果断点跳进压缩后的代码,体验会非常糟糕,有时候不是你找不到函数,而是调试对象选错了。

6.2 从源码反推工程实践:哪些优化真正有用

理解了渲染链路后,很多性能优化的"术"就变成"道"了。

先讲依赖收集的粒度。render 函数执行期间,所有被访问的响应式属性都会把当前渲染 watcher 收集到自己的依赖列表里。所以 render 函数里尽量不要做大范围对象遍历,每次属性访问都会增加一个依赖;一旦对象内部任意属性变化,就可能触发整个组件重渲染。如果你需要在模板里展示一个经过复杂计算的派生数据,优先用 computed。computed 自带缓存,且把依赖集中在一个小的计算范围内,比在模板里直接访问深层数据要可控得多。

再说静态节点跳过。模板编译阶段会标记静态子树,把完全不含动态绑定的部分提取到staticRenderFns里。更新时,Vue 对静态 vnode 直接复用,不会重新渲染,所以像v-once这类指令确实能减少 patch 开销。但说实话,如果你的页面本身更新频率很低,这些优化的收益非常有限,不必为了"优化"而优化,先把数据结构和组件拆分搞对才是大头。

最后说函数式组件。Vue 2.6 中函数式组件虽然没有实例,但 vnode 本身还是要创建的,省掉的只是实例初始化的开销。如果你的列表项组件有几十个 props、复杂的模板和生命周期,把它改成函数式组件确实能省一部分内存;但如果只是为了少写一个data()而硬改成函数式,收益并不明确。大规模列表优化优先考虑数据分区、虚拟滚动、局部渲染,而不是无脑把所有组件都改成函数式。

6.3 三个最常见的理解误区

第一个误区:"new Vue 就立刻渲染了"。实际上只有$options.el存在,_init末尾才会自动调用$mount;否则实例一直处于未挂载状态,vm.$el是undefined。测试环境里写new Vue({ data })却访问不到$el,原因就在这里。想在初始化后手动控制渲染时机,就等数据准备好再调用$mount。

第二个误区:"数据变了,整棵 vnode 树重建了"。重新渲染确实会生成一棵新的 vnode 树,但 patch 会尽可能复用相同 tag 和 key 的节点,只更新有差异的部分;静态节点更是直接跳过。所谓"重建"发生在 vnode 层面,真实 DOM 的增删改是被 diff 精确控制的最小集。

第三个误区:"patch 就是字符串 diff"。"字符串 diff"是模板字符串替换的思路,vnode 的 diff 是完全不一样的东西:它通过 tag、key 判断同一性,通过属性模块更新差异,通过 children 递归处理子节点。这也是为什么尽量保持组件树结构稳定很重要——结构稳定,diff 才能充分发挥复用的优势,性能才会稳定。

最后再分享一个我自己的读码习惯。源码分析这件事,最关键的不是背下函数名,而是理解"数据变化到底是怎么变成 DOM 变化的"这条主线。我每次拿到一个新知识点,都会回到那个最小 HTML 页面,把对应的断点打上,亲手走一遍调用栈。走通一次,比翻十遍注释都管用。这条主线通了,后面你再去看 3.x、去看 Nuxt 的渲染层,都会发现它们的内核和你已经掌握的东西高度相似。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询