
天天写 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 的构造函数会立即执行一次getget里调用updateComponent于是完成首次渲染。首次渲染过程中模板里用到的响应式数据会被读取触发它们的依赖收集把当前渲染 Watcher 记录到这些数据的订阅列表里。此后任何数据变化通知发布时都会找到这个渲染 Watcher触发它重新执行updateComponent。这也是响应式系统和渲染流程真正接轨的位置。3.4 挂载完成后的钩子与性能提示mountComponent里还会设置vm._isMounted true并触发mounted钩子。注意这个触发是异步的实际上它是同步在首次渲染完成后调用但如果你在mounted里操作 DOM拿到的已经是渲染好的结构。真正异步的是beforeMount和mounted的执行时机会因为子组件不同而变化父组件mounted要在所有子组件mounted之后触发。源码里用insertedVnodeQueue收集插入的子组件节点等整棵组件树插入完毕再按顺序触发保证“子先父后”。这块看起来小但直接影响你在mounted里能不能安全访问子组件实例。4. render 与 Virtual DOM4.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 classcontainerspanHello/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. createElementvnode 的制造工厂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调用patchVue.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的作用被放得很大——如果没有 keydiff 只能依靠位置对比元素是数组时容易错误复用有 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 串起来。断点可以多打几轮每轮只看一条分支。源码这东西读一遍记不住很正常但每读一遍你对“框架到底替我做了什么”的理解就会扎实一分。