☰
Vue 2.x源码解析:从new Vue到DOM渲染的完整链路
2026/10/14 1:26:50 网站建设 项目流程

天天写 Vue 的你,有没有想过new Vue({...})之后,浏览器页面上的那些 DOM 到底是怎么“长”出来的?我指的不是模板语法怎么变成页面——那层你肯定熟——而是从入口构造函数到最终真实 DOM 出现在文档流里的完整链路:数据驱动、new Vue 的初始化顺序、实例挂载、render 函数的执行、Virtual DOM 的构建,再到 createElement 和 update 的落地。

这篇文章我想按源码的真实执行顺序,把这六个环节一次讲透。内容主要基于 Vue 2.x 的内部实现,适合已经能熟练写业务、想进一步理解框架原理的开发者。看完之后你至少会有三个收获:知道 new Vue 之后每一步在干嘛、理解 render 和 Virtual DOM 为什么是 Vue 性能与跨平台的基石、遇到页面不更新的 bug 时能直接从机制层面定位而不是靠瞎猜。

1. 数据驱动:一切从状态开始

1.1 数据驱动到底是什么意思

很多人刚接触 Vue 时,听到“数据驱动”这四个字只觉得是个概念,实际写了两三年业务也不一定能说清它和“命令式 DOM 操作”的本质区别。用最简单的例子说:传统方式写页面,你脑子里始终要维护“当前 UI 状态”和“真实 DOM 状态”两份数据,每次用户操作都手动同步一次;而数据驱动只维护一份数据。

Vue 做法是:你声明状态,框架负责让 DOM 跟着状态走。状态变了,DOM 自动变——不是靠你的命令,而是靠框架内部一套“状态变更 -> 触发更新 -> 重新渲染”的机制。这套机制背后由三个核心模块支撑:响应式系统、Virtual DOM 渲染器、组件系统。三者各自独立又互相配合,源码里你能清楚看到它们的分工:响应式负责“感知变化”,渲染器负责“绘制页面”,组件负责“组织代码”。

1.2 数据驱动对开发方式的影响

理解数据驱动不只是在面试时背概念。我见过很多项目里到处是document.getElementById或大量手工拼接 DOM 字符串,维护成本极高。数据驱动让开发者从“操作 DOM”转向“维护状态”,代码可预测性大幅提升。

但有得必有失。数据驱动也带来了几个隐含前提:第一,框架必须能在数据变化时精确知道“哪个组件依赖了这份数据”,否则只能全量重绘;第二,渲染过程必须足够高效,不能每次变化都销毁重建整个页面——这两个问题正是 Vue 引入依赖收集和 Virtual DOM 的原因。你可以把 Virtual DOM 看成“渲染的中间层”,它让 Vue 在数据变化后先计算出最小变更集合,再批量应用到真实 DOM,而不是无脑刷新。

2. new Vue 发生了什么

2.1 构造函数的真实入口

很多初学者以为new Vue(options)里全是魔法,实际上框架作者设计得非常克制。源码中构造函数的逻辑很短:

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

真正干活的是this._init(options)。这个函数在initMixin里被挂到原型上,负责整个实例的初始化流程。用 Chrome 调试器跟一遍_init的全过程,你会发现它像一条流水线:生命周期、事件、渲染函数、注入、状态、provide。每一站都有明确的职责和顺序。

2.2 _init 的初始化流水线

_init的核心流程可以简化为这样:

Vue.prototype._init = function (options) { const vm = this vm.$options = mergeOptions( resolveConstructorOptions(vm.constructor), 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) } }

注意几个容易被忽略的细节。mergeOptions这一步会把全局的mixins、components、directives等选项合并进实例的$options,这是后面判断组件、指令时的查表基础。initLifecycle不是初始化数据,而是给vm挂上$parent、$root、$children、$refs这些父子关系字段。initEvents负责处理父组件传入的事件监听器,initRender则把$createElement和_c绑定到实例上,同时给$attrs、$listeners做响应式代理。

2.3 为什么初始化顺序必须是“props -> methods -> data -> computed -> watch”

initState内部还有一层顺序,这是新手读源码容易忽视的地方。正确的顺序是:props、methods、data、computed、watch。为什么这个顺序不能乱?

因为每个初始化阶段都依赖前面已经准备好的内容。data里的字段可能依赖props初始值来做默认值判断;computed可能引用data做计算;watch又会监听props、data、computed上的字段,所以它必须最后初始化。如果先初始化computed而它依赖的data还没建立,依赖关系就会漏掉,页面更新时就无法触发正确的重渲染。

另外initInjections和initProvide的位置也有讲究:provide在initState之前?不,源码里provide是后初始化的,但inject要比state先初始化,因为子组件的数据(data/computed)里可能引用注入的值。这个设计一致性很强:高层依赖先建立,低层依赖后建立,全部初始化完成后再触发created钩子。

2.4 钩子时机对业务代码的影响

理解流水线顺序,你就能解释很多日常现象。比如beforeCreate里访问不到data和computed,因为initState还没执行;created里能访问数据和事件,但拿不到 DOM,因为挂载发生在初始化完成后、由vm.$mount触发。我见过不少人问“为什么我在 created 里操作 DOM 没反应”,其实就是因为挂载还没开始。

如果你需要根据数据是否准备完成来决定做不做某些逻辑,正确的做法是看生命周期:beforeCreate适合初始化非响应式变量或插件常量,created适合请求接口或做数据加工,mounted之后才有完好的 DOM 结构可操作。这些结论不是背出来的,源码的初始化顺序决定了它们。

3. 实例挂载的实现

3.1 $mount 的入口与平台差异

_init的最后一行如果检测到el选项,会自动调vm.$mount(vm.$options.el)。如果你手动调vm.$mount('#app'),也是同一个入口。这里有个很多初学者不知道的点:$mount在完整版(带编译器)和运行时版里有不同的实现。

运行时版直接调用mountComponent;完整版会先做一次“编译前升级”——缓存原型上的原始$mount,再覆盖$mount方法,在新方法里先尝试把template或el编译成render函数,然后再调用缓存的原始$mount走 mountComponent。也就是说:

const mount = Vue.prototype.$mount Vue.prototype.$mount = function (el, hydrating) { // 完整版:这里先做 template -> render 的编译 return mount.call(this, el, hydrating) }

这个设计很取巧:既能复用核心逻辑,又能按需注入编译能力。

3.2 编译得到 render 函数

如果你平时用完整版开发,template字符串会经过compileToFunctions变成render函数。这个编译过程包含三个关键步骤:解析 HTML 模板成 AST(抽象语法树)、对 AST 做静态标记和优化、根据 AST 生成代码字符串,最后用new Function包装成 render 函数。

静态标记是性能优化的关键。编译器会识别节点中不依赖响应式数据的部分,给它们打上static标记,后续更新时可以直接跳过这些子树的 diff,这在长列表场景下收益明显。如果你用.vue单文件组件配合打包器,这个编译会提前在构建阶段完成,运行时的$mount就不需要做编译了,这也解释了为什么打包后体积能更小、启动更快。

3.3 mountComponent 的核心机制

进入mountComponent才是挂载的本体。它做了几件事:创建updateComponent渲染函数、实例化一个渲染 Watcher、在合适的时机触发生命周期钩子。核心代码大致长这样:

updateComponent = () => { vm._update(vm._render(), hydrating) } new Watcher(vm, updateComponent, noop, { before() { if (vm._isMounted && !vm._isDestroyed) { callHook(vm, 'beforeUpdate') } } }, true /* isRenderWatcher */)

这里有个关键点:组件初次挂载的 DOM 生成,不是一次性同步代码从头跑到尾,而是由“渲染 Watcher”驱动的求值过程。Watcher 的构造函数会立即执行一次get,get里调用updateComponent,于是完成首次渲染。

首次渲染过程中,模板里用到的响应式数据会被读取,触发它们的依赖收集,把当前渲染 Watcher 记录到这些数据的订阅列表里。此后任何数据变化,通知发布时都会找到这个渲染 Watcher,触发它重新执行updateComponent。这也是响应式系统和渲染流程真正接轨的位置。

3.4 挂载完成后的钩子与性能提示

mountComponent里还会设置vm._isMounted = true并触发mounted钩子。注意这个触发是异步的?实际上它是同步在首次渲染完成后调用,但如果你在mounted里操作 DOM,拿到的已经是渲染好的结构。

真正异步的是beforeMount和mounted的执行时机会因为子组件不同而变化:父组件mounted要在所有子组件mounted之后触发。源码里用insertedVnodeQueue收集插入的子组件节点,等整棵组件树插入完毕再按顺序触发,保证“子先父后”。

这块看起来小,但直接影响你在mounted里能不能安全访问子组件实例。

4. render 与 Virtual DOM

4.1 render 函数的产物不是 DOM

很多人以为render函数直接返回 DOM 元素,其实它返回的是 Virtual DOM 节点(vnode)。什么是 vnode?它是对真实 DOM 的轻量描述,本质上是一个普通的 JavaScript 对象,记录了标签名、属性、子节点、文本、所属组件实例等信息。

为什么中间要隔一层 vnode?因为直接操作浏览器 DOM 有三个痛点:性能开销大、跨平台难、无法表达组件级抽象。vnode 就像是施工图纸上的符号,描述“这里应该有什么”,而不直接动手“砌墙”。真正“砌墙”的动作发生在后面的patch阶段。

4.2 手写 render 与模板编译的区别

平时你写模板,编译器会生成一个 render 函数。手写 render 时,通常用h函数或this.$createElement来构建 vnode。比如:

render(h) { return h('div', { class: 'container' }, [ h('span', 'Hello') ]) }

模板编译生成的 render 又是什么样?把<div class="container"><span>Hello</span></div>编译后,大致是:

_c('div', { staticClass: "container" }, [ _c('span', [_v("Hello")]) ])

_c和$createElement都指向 vnode 创建函数,区别在于_c是编译内部使用的,传入的 children 已经经过了归一化处理;手写$createElement时 children 可能是嵌套数组或其他边界情况,需要额外的normalizeChildren处理。这也是源码里它们被分成两个实例属性的原因。

4.3 vnode 的数据结构

源码里 VNode 类的字段非常多,核心字段包括:tag(标签名)、data(属性/事件/样式等)、children(子 vnode 数组)、text(文本节点内容)、elm(对应的真实 DOM 节点,diff 后赋值)、context(所属组件实例)、key(用于 diff 的优化键)、componentOptions(组件 vnode 的选项)、componentInstance(组件实例,组件 vnode 被渲染后赋值)。

理解 vnode 最关键的一点是:elm初始为空,只有经过createElm真正创建 DOM 后才有值。所以你可以把 vnode 看成“描述”,把 DOM 看成“成品”,两者通过elm字段互相引用。调试组件渲染时,打印 vnode 和打印$el看到的信息完全不同,这正常,别以为出 bug 了。

4.4 Virtual DOM 的价值量化

Virtual DOM 最直接的收益是性能。每次数据变化,Vue 都在内存中构建新的 vnode 树,与旧的 vnode 树做 diff,得到差异集合后最小化更新真实 DOM。真实 DOM 的增删改查是昂贵的,重排、重绘都很费资源,而纯 JavaScript 对象的对比成本低很多。用生活类比就是:你要改一面墙上的十块砖,命令式做法是直接一块块换;vnode diff 是先画一张图标记哪几块要换,再精准动手。

跨平台也是 vnode 带来的红利。由于 render 输出的是平台无关的结构描述,同一套组件代码可以跑在浏览器、服务端渲染甚至原生应用中。框架只需要针对不同平台实现相应的 createElement 和 patch 逻辑即可。没有 vnode,跨平台复用的成本要高得多。

5. createElement:vnode 的制造工厂

5.1 入口重载与参数判断

vnode 的创建统一走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) }

第一段if处理的是调用方式重载。Vue 允许你跳过data直接传 children,比如h('div', [h('span')]),此时数组会落在data参数上,函数需要把它纠正到children。这种“参数左移”的兼容方式在源码里很常见,也是手写 render 时容易踩坑的地方——如果你传了class和children顺序颠倒,页面可能渲染出完全不一样的结构。

5.2 _createElement 的分支逻辑

真正的_createElement里有几个显眼的判断。第一是防止把响应式对象直接当作 data 传入,源码会在data上检测__ob__并报警告;第二是处理文本节点,如果tag是字符串,就直接创建 VNode;第三是组件节点识别:

if (typeof tag === 'string') { let Ctor 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))) { vnode = createComponent(Ctor, data, context, children, tag) } else { vnode = new VNode(tag, data, children, undefined, undefined, context) } } else { vnode = createComponent(tag, data, context, children) }

resolveAsset会在当前组件的components注册表中查找同名组件。如果注册表里没有,就当作普通 HTML 标签创建。这也是“组件名拼写错误不会直接报错、只会渲染成未知元素”的原因,排查时可以从这里入手。

5.3 children 的归一化

children参数可能是数组、嵌套数组、文本字符串、甚至单个 vnode。_createElement里会调用normalizeChildren来统一格式。这里的归一化分两种级别:simpleNormalizeChildren只做浅层拍平,而normalizeChildren会递归处理嵌套数组并把原始值转成文本 vnode。

为什么要有这个区分?因为模板编译生成的 children 结构相对规整,浅层处理就够;但手写 render 时你可能会写h('div', ['a', h('span'), [h('i')]])这种混乱嵌套,只有深度归一化才能保证 patch 阶段遍历 children 时不炸栈。这个细节很典型地体现了“编译场景友好 + 手写场景健壮”的设计取舍。

5.4 组件 vnode 的建立

当 tag 命中组件时,走createComponent。这一步会提取构造器、安装组件钩子(init、prepatch、insert、destroy)、生成componentOptions,最后创建 vnode 时标记vnode.componentOptions = componentOptions。

组件 vnode 和普通元素 vnode 在后续 patch 中走的是完全不同的流程:普通 vnode 直接创建 DOM 元素,组件 vnode 则要实例化子组件、执行子组件的 render 和 mount。可以说组件 vnode 是一个“延迟执行容器”,父组件渲染时只是把它挂到树上,等到插入阶段才真正触发子组件的完整生命周期。

6. update 流程:patch 与真实 DOM 同步

6.1 从 _update 到 patch

每当数据变化,渲染 Watcher 会重新执行updateComponent,内部调用vm._update(vm._render(), hydrating)。_update的核心逻辑是取vm._vnode(旧 vnode)和最新 vnode,调用patch:

Vue.prototype._update = function (vnode, hydrating) { const prevVnode = vm._vnode vm._vnode = vnode if (!prevVnode) { // 首次渲染:创建真实 DOM 并替换 el vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false) } else { // 更新:diff 新旧 vnode vm.$el = vm.__patch__(prevVnode, vnode) } }

首次渲染和更新渲染走的是同一个 patch 函数,但参数不同——首次渲染时旧节点传的是vm.$el(一个真实 DOM),更新时旧节点传的是旧 vnode。patch 函数里有一行专门做判断:

if (isUndef(oldVnode)) { // 空节点直接创建 } else if (isRealElement(oldVnode)) { // 首次挂载路径 oldVnode = emptyNodeAt(oldVnode) }

emptyNodeAt会把真实#app元素包装成一个空的旧 vnode,后续逻辑就可以统一按 vnode 对比处理。

6.2 patch 的首次渲染过程

首次渲染时,patch 的核心是createElm。它会根据 vnode 的 tag 调用document.createElement或createElementNS,给元素设置属性、样式、事件,把子 vnode 递归创建并插入当前元素,最后把整个 DOM 树挂到vnode.elm上。组件 vnode 会在createComponent命中时调用它的 init 钩子,触发子组件实例化和挂载。

确保插槽、指令、ref 等模块都在createElm阶段被调用,顺序有点长但设计得很清晰:先创建根节点、再插入子节点、再执行指令绑定和 ref 赋值。读过这段源码后你会理解,为什么某些在 DOM 插入后才需要执行的逻辑要从mounted汇总触发——因为createElm在完成整个树创建后,才轮到insert钩子和生命周期回调。

6.3 更新渲染与双端 diff

更新渲染走patchVnode和updateChildren。patchVnode的职责是:如果新旧节点完全相同(sameVnode 为 true),就不做替换,而是逐层更新属性;如果文本内容变化,直接改textContent;如果子节点变化,调用updateChildren。

updateChildren是 diff 的核心,源码用四个指针在旧子节点数组和新子节点数组两端同时收缩比较,优先处理头/尾的相同节点,做最小移动和复用。这里key的作用被放得很大——如果没有 key,diff 只能依靠位置对比,元素是数组时容易错误复用;有 key 后可以通过映射表快速找到可复用的节点,减少 DOM 操作。

我建议你亲手在updateChildren里打断点,跟一次列表平移的 diff 过程。你会看到明明只是把数组里第一项挪到最后,Vue 并不会销毁重建整个列表,而是只移除一个节点、在末尾插入一个节点。这个观察才能真正理解 diff 的价值,光背概念很容易忘。

6.4 异步更新队列与 nextTick

渲染 Watcher 每次触发执行更新时,并不是同步立刻 patch,而是被推进一个队列里排队。源码里queueWatcher会按 watcher id 去重,并把 flush 动作交给nextTick。也就是说:

export function queueWatcher(watcher) { const id = watcher.id if (has[id] == null) { has[id] = true queue.push(watcher) nextTick(flushSchedulerQueue) } }

nextTick的实现做了能力降级:优先用Promise,其次MutationObserver,再退到setImmediate,最后setTimeout(0)。这个降级很好理解——不同运行环境对微任务和宏任务的支持不同,Vue 要保证一把事件循环里多次数据修改只触发一次渲染。

这也是为什么你在业务代码里连续修改三次 data,页面不会渲染三次,而是统一在微任务阶段渲染一次。理解这个机制后,this.$nextTick的用法就顺理成章了:它就是把回调放到渲染 flush 之后执行,你确实能拿到更新后的 DOM。

7. 常见问题与排查技巧实录

7.1 数据变了但页面没更新

这大概是源码读者最先能诊断的一类问题。Vue 2 的响应式是基于Object.defineProperty的,写在 data 里的属性会被转成 getter/setter。但数组下标赋值和对象新增属性不走 setter,所以不会触发依赖通知。

数组情况:arr[0] = 1不生效,需要用vm.$set(arr, 0, 1)或者arr.splice(0, 1, 1)。对象情况:obj.newKey = 1不生效,需要用vm.$set(obj, 'newKey', 1)。为什么$set能生效?源码里$set会调用defineReactive给目标属性补上 getter/setter,并手动触发dep.notify()。知道这个原理后,你还会顺手明白:为什么数据初始化时应该预先把所有用到的字段都声明出来——不仅是为了代码整洁,更是为了让响应式系统“提前铺好路”。

7.2 v-for 里 key 为什么不能省略

不写 key 时,diff 会退化到“先比较位置、再看内容”的策略。列表头尾插入或删除时,很容易出现节点被错误复用的情况,尤其当子元素包含表单或组件状态时,你会看到输入框内容错位、组件生命周期异常等诡异现象。

源码里 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 是第一个判断条件,没有 key 就等于全部 undefined,能复用的概率全压在 tag 和类型上,结果自然不可靠。所以列表中务必给每一项唯一稳定的 key,千万别用数组下标当 key——排序或插入后下标会变,diff 又白干了。

7.3 手写 render 时 createElement 容易踩的坑

手写 render 没有模板编译器的宽容,最容易出现这几个问题:一是把class写成字符串而不是对象形式,导致样式不生效;二是事件监听器必须放进on属性里:h('button', { on: { click: handler } });三是children里直接放了原始字符串和 vnode 混合的深层数组,忘记归一化导致渲染异常;四是在data里塞了响应式对象或多余字段,每次 patch 更新时这些字段都会被遍历和处理,白白增加开销。

我自己的习惯是:手写 render 时尽量保持 children 结构扁平,能拆子组件就拆子组件,既保证归一化效率,也让 vnode 树更清晰。排错时优先打印一段console.log(vnode),看看 tag、data、children 是否符合预期。

7.4 源码阅读的断点与调试技巧

如果你想亲自跟踪这条链路,我建议按这个顺序打断点:在_init入口看初始化流水线,在mountComponent里看 Watcher 首次求值,在_render里看 render 函数执行完后的 vnode 结构,在_update里对比新旧 vnode,在patch函数里查看首次渲染的分支走向,再到updateChildren里观察 diff 移动过程。

调试响应式更新时,最直接的办法是在渲染 Watcher 的 get 方法里打断点,或者直接观察dep.subs数组里有哪些 watcher。只要你理解了“读数据触发 getter -> 把当前 watcher 放进 subs”,再遇到“为什么这个属性一变整个页面都重渲”这类问题,都能拽出依赖关系来回答,而不是靠玄学。

写在最后的一段私货

源码啃到这儿,最大的收获不是背下了多少函数名,而是建立了一种排障直觉。以前遇到页面不更新,我只能上网搜“Vue 数据变化 DOM 不更新怎么办”,现在我会直接在脑内投影这条链路:改了数据 -> 有没有触发 setter -> 渲染 Watcher 有没有入队 -> nextTick 有没有 flush -> diff 有没有正确复用节点。每一步都有对应的源码位置可以验证,问题基本一眼定位。

如果你也是刚开始啃这块,我的建议是别贪多,按这篇文章的顺序一步一步跟:先看数据驱动的大局,再追_init流水线,然后盯住 mountComponent,最后把 render、createElement、patch 串起来。断点可以多打几轮,每轮只看一条分支。源码这东西,读一遍记不住很正常,但每读一遍,你对“框架到底替我做了什么”的理解就会扎实一分。

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

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

立即咨询