尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue原理核心解析:响应式系统、虚拟DOM与异步更新

Vue原理核心解析:响应式系统、虚拟DOM与异步更新 做前端的人几乎都绕不开“Vue原理”这几个字。不管是面试前临时抱佛脚还是日常排查bug找到怀疑人生最后都会回到同一个问题Vue到底是怎么让数据和页面联动起来的我在带团队的时候发现一个规律能把原理讲清楚的人写代码的稳定性和排查问题的速度通常比只会背API的人高一截。所以这篇就把Vue原理里最核心的东西串一遍尽量用大白话和能跑的代码说话不搞玄学。这篇内容适合三种人看准备面试、正在用Vue但不太清楚内部机制、以及想阅读源码但是不知道从哪下手的。我会重点讲响应式系统、编译渲染链路、虚拟DOM与diff、异步更新与nextTick、computed和watch的差异这几个大块后面再补一点自定义v-model和依赖注入这类高频扩展点。每个部分我都会给出可验证的代码片段不是光讲概念。1. 先把Vue的“根”讲明白从数据变化到页面更新1.1 一句话原理与Vue2/Vue3的底层差异Vue核心原理用一句话说就是数据驱动视图。当你把数据改了依赖这份数据的视图会自动更新不需要你手动去操作DOM。这句话听着简单但里面藏了三个问题数据变化怎么被知道哪些视图依赖了这份数据变化之后更新谁这三个问题合起来就是整个响应式系统。先看底层机制差异。Vue2用的是Object.defineProperty给数据对象的每个属性重写getter和setterVue3则换成ES6的Proxy直接代理整个对象。这个替换是本质性的Object.defineProperty需要对每个属性逐一拦截新增属性和删除属性不会触发setter所以Vue2里才有Vue.set和Vue.delete这两个弥补方案。Proxy可以拦截get、set、deleteProperty、has、ownKeys等13种操作新增属性、删除属性都能被感知到。性能上Vue2初始化时递归遍历所有属性层级越深越慢Vue3是懒代理访问到深层对象时才递归代理初始化开销小很多。但要注意Proxy也有代价兼容性不如defineProperty老。如果项目需要支持IE11Vue3的Proxy方案直接用不了这也是很多老项目一直停在Vue2的原因。1.2 依赖收集谁在监听谁谁来通知谁响应式系统里面有两个核心角色Dep和Watcher。我用追星来打比方数据是明星视图是粉丝Dep是明星的粉丝群Watcher是群里的粉丝代表。明星发微博数据变化粉丝群发通知代表收到消息后去更新街边的广告牌视图。具体流程是这样的读取数据属性时触发getter当前正在运行的Watcher会被记录到这个属性的Dep里这叫“依赖收集”。修改数据属性时触发setterDep通知它收集到的所有Watcher执行更新这叫“派发更新”。Vue2的源码里面每个响应式属性都会持有一个Dep实例里面维护一个subs数组。Vue3的Dep类做了升级用Set存储effect还支持在组件实例上挂载。逻辑模型是一样的。关键点在于依赖收集必须发生在“读取”时。如果一个数据从来没有被任何渲染函数或者computed读取过那它变化时不会通知任何人因为Dep里根本没有Watcher。这也是为什么Vue3的响应式调试工具track和trigger两个函数会单独暴露出来方便理解。1.3 手写一个mini版reactive与effect为了不把原理停留在嘴上我写一个最简版本。这个版本不完整但能跑通“数据变化 - 自动执行副作用函数”的核心链路。// 保存当前正在执行的 effect let activeEffect null // Dep管理依赖的集合 class Dep { constructor() { this.effects new Set() } depend() { if (activeEffect) { this.effects.add(activeEffect) } } notify() { this.effects.forEach((effect) effect()) } } // 用 WeakMap 保存 对象 - 属性 - Dep const targetMap new WeakMap() function getDep(target, key) { let depsMap targetMap.get(target) if (!depsMap) { depsMap new Map() targetMap.set(target, depsMap) } let dep depsMap.get(key) if (!dep) { dep new Dep() depsMap.set(key, dep) } return dep } // 简易 reactive export function reactive(target) { return new Proxy(target, { get(target, key, receiver) { const dep getDep(target, key) dep.depend() return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { const dep getDep(target, key) const result Reflect.set(target, key, value, receiver) dep.notify() return result }, }) } // 简易 effect export function effect(fn) { const wrappedEffect () { activeEffect wrappedEffect fn() activeEffect null } wrappedEffect() return wrappedEffect }上面这段代码可以直接跑你会发现一个很有意思的现象模板里的数据为什么要先被读取一次然后才会在后续变化时更新。因为get是建立依赖的唯一入口如果代码里根本没人读这个属性那dep.depend()就永远不会执行后续的dep.notify()自然也找不到要通知的对象。1.4 为什么Vue3要换掉Object.definePropertyVue2的响应式除了无法侦测新增/删除属性对数组也有特殊情况。它拦截了数组的push、pop、shift、unshift、splice、sort、reverse这7个方法靠重写原型链实现。一旦数组通过下标直接修改元素比如arr[0] 1也不会触发更新。我用一个真实事故说明这个坑有多隐蔽之前有个项目接口返回一个列表后前端直接往数组中间插了一条数据用的是this.list[5] obj这种方式。在Vue2里页面纹丝不动排查半天才发现是响应式问题。当时只能用splice重写或者Vue.set兜底。Vue3用Proxy之后这些问题从根上消失了targetMap用WeakMap存储对象被垃圾回收时不会内存泄漏。Reflect.get(target, key, receiver)可以正确返回原始值和绑定this上下文避免某些边缘场景下取值错误。深层嵌套对象在get时懒代理访问到哪一层就代理到哪一层。需要提醒的是Proxy代理后的对象判断相等性和类型时可能出现意外。比如reactive对象 ! 原始对象在做事件监听或者第三方库传参时要特别注意传的是代理对象而不是原始对象。2. 模板不会魔法编译与渲染流程拆解2.1 模板编译三阶段parse、transform、codegenVue推荐我们用template写页面但浏览器不认识div{{ name }}/div这种带插值语法的模板。运行时必须要有一段把模板「翻译」成渲染函数的逻辑这段逻辑就是编译器。Vue2的编译器大致分三步parse用正则把模板解析成AST抽象语法树。optimize遍历AST标记静态节点这类节点在后续更新时可以直接跳过。generate根据AST生成可执行的render函数字符串最后套上with(this)执行。Vue3的编译器改成了更模块化的结构parse阶段类似生成带位置的AST节点。transform阶段会进行各种转换包括识别指令、插值表达式、缓存事件处理函数等。codegen阶段生成render函数字符串并附带补丁标记patchFlag。为什么说模板不是魔法因为它的最终产物就是一段普通JavaScript函数。你可以直接在控制台打印一个组件实例的render属性能看到它的真实模样。比如div{{ name }}/div编译产物大致是_ctx.name完整的render函数会返回一个虚拟节点调用。使用模板的好处是编译器可以帮你做静态优化手写render函数灵活性更高但容易丢掉优化信息。2.2 render函数返回的到底是什么虚拟节点render函数执行之后返回的是一个虚拟节点VNode。VNode本质上就是一个普通对象描述了真实DOM应该长什么样。一个简化版的Vue3 VNode大概长这样{ type: div, props: { id: app }, children: [hello], key: null, shapeFlag: 2, // 表示这是一个元素节点 patchFlag: 0, // 动态内容标记 el: null // 真实DOM引用 }虚拟DOM的意义不是让代码变酷而是让框架可以在JavaScript层面描述UI结构并通过对比新旧VNode来计算最小更新范围。如果没有VNode每次数据变化都直接操作真实DOM手动维护性和性能都不可控。2.3 挂载与更新从根组件开始的完整链路创建一个应用的时候Vue会经历这么一条链路调用createApp(App)创建应用实例。内部对组件的setup或options里的data、computed、watch做响应式处理。执行组件的render函数得到根VNode。patch方法把VNode转换成真实DOM并插入容器。当响应式数据变化时组件渲染的Watcher被触发重新执行render得到新VNode。新旧VNode做diff找到差异并更新到真实DOM上。注意第5步不是整个页面重新渲染而是组件级别的重新渲染。Vue的响应式系统粒度其实到组件模板内局部更新依赖VNode diff来精简。这也解释了为什么大型项目要把组件拆细组件越小数据变化时重新渲染的范围越小。如果你把一整个页面都写在一个巨型组件里任意一个数据变化整页的render都会执行diff代价随之变大。2.4 静态优化与为什么要给合适的分工Vue3编译器的进步在于静态优化做得很细。比如模板里写死的静态节点会被提升到渲染函数外部只创建一次监听的事件如果表达式不变也会被缓存带有动态绑定的节点会标上patchFlagdiff时直接针对性处理。这些优化对开发者是透明的意味着写template通常比手写render有更好的性能空间。反过来如果你需要高级抽象比如动态生成组件类型或者设计一套配置驱动的表单渲染器手写render和h函数反而是更合适的选择。我的个人经验是默认写template只有当你需要动态创建组件或者做复杂封装时才使用render函数。不要为了炫技放弃编译器优化。3. 虚拟DOM与diffVue的“性能账本”3.1 虚拟DOM真的比直接操作DOM快吗这个问题几乎每次面试都会遇到而且答案和大众直觉相反。直接操作DOM在简单场景下常常比虚拟DOM更快因为虚拟DOM多了一次创建VNode和diff的过程。那Vue为什么还要用它首先虚拟DOM提供的是可维护性你只需要声明“现在UI应该长什么样”框架帮你算出中间差异。其次当UI复杂度上升手写DOM操作很难保证性能而diff可以在框架层面做批量优化。说白了虚拟DOM是“省人力”和“兜底性能”不是“极致性能”。这也是为什么很多面试官喜欢追问虚拟DOM的必要性。你只要答出“跨平台能力”和“最小化DOM操作”两层意思就到位了。VNode不仅渲染到浏览器还能渲染到小程序、Canvas、原生App等本质上是一个抽象中间层。3.2 diff同层比较与patch过程Vue的diff算法有一个铁律同层比较不跨层级移动。真实DOM里很少有子节点整体移动跨层级的场景跨层级比较的算法复杂度又太高所以直接放弃。patch的过程可以简化成这样判断sameVnode如果类型和key都一致说明可以复用节点进入patchVnode。patchVnode里更新节点上的props、事件、class、style等。如果有子节点继续递归diff子节点。如果节点完全不一致直接创建新节点替换旧节点。如果只更新文本内容整个diff最理想的情况是“只改文本内容”文本节点是最便宜的更新。这也是为什么建议把动态变化的部分隔离到尽可能小的组件里。3.3 key为什么不能随便用index列表diff底层逻辑列表渲染时Vue会对比新旧子节点的key。它的目标不是把所有节点重新创建而是尽量复用。如果key给错了复用逻辑就会出错。我用一个实际场景说明列表渲染一组输入框每行有个checkbox数据是从接口加载的。一开始我图省事key直接写成index。后来列表做了倒序页面显示时checkbox状态全乱了因为Vue复用了DOM节点但状态被带了回去。这种bug很难排查第一反应是数据问题最后才定位到key头上。index作为key在列表头部插入时会导致所有后续节点都被判定为“需要更新”因为它们对应的数据和之前都不一样Vue只能逐个更新造成不必要开销甚至会引发组件状态错乱。正解是用唯一的业务ID作为key。3.4 源码里的优化策略与双端diffVue2的diff在处理子节点时做了双端比较分别从头尾两边向中间收缩减少移动次数。Vue3在此基础上更激进会在diff之前先处理前置和后置的相同节点然后针对中间不可复用的部分建立key到index的映射表用最长递增子序列计算最少的移动次数。这套算法在面试中不一定会让手写但理解了能让你更清楚为什么key如此重要。如果没有keyVue只能靠索引去对应节点一旦顺序变化能复用的节点也被当作不同的节点重渲染代价最大。日常开发中记住一条v-for里尽量不要同时使用v-if。因为v-if的优先级在Vue2中高于v-for有些写法会产生临时变量且很难维护在Vue3中v-if优先级低于v-for会产生额外的循环判断。两个指令一起用代码可读性和性能都不讨好不如用计算属性先过滤再迭代。4. 异步更新与nextTick别被“立即生效”骗了4.1 数据批量更新Watcher队列为什么是异步的你有没有遇到过这种代码改了一个响应式数据然后立刻读取DOM里对应的内容结果拿到的还是旧值。原因很简单Vue不会每次数据变化都同步更新DOM而是把更新动作放进一个异步队列等本轮事件循环快结束时统一执行。这么设计的最直接原因是性能。假设一个方法里连续改了10次同一个数据如果每次都同步更新DOM浏览器会重复执行10次渲染管线。Vue把Watcher加入队列同一个Watcher在队列里只会保留一个最终只做一次更新这就是批量更新。更新队列执行时不仅会运行Watcher还会在队列刷新后执行flushSchedulerQueue。nextTick就是在这个机制下暴露给用户的回调时机。4.2 nextTick原理与事件循环的关系nextTick本质上是一个微任务调度的封装。Vue3的实现很简单核心逻辑就是把回调丢进一个callbacks数组通过Promise.resolve().then异步刷新。搞清楚它跟宏任务/微任务的关系很重要。浏览器事件循环里当前宏任务执行完会清空微任务队列再进行渲染。Vue的批量更新和nextTick都放在微任务里所以它们能赶在浏览器重绘之前把所有DOM更新做完。这也是为什么在setTimeout里访问DOM有时候能拿到更新后的值但时机不稳定。如果你在nextTick里修改数据回调会被推入新的队列而不是立即执行。这个行为也解释了为什么在nextTick里疯狂改数据不会导致同步更新还是会走异步批量。4.3 手写一个简单nextTick为了彻底理解我写一个极简版本原理和Vue3内部非常接近const callbacks [] let pending false function flushCallbacks() { pending false const copies callbacks.slice() callbacks.length 0 for (let i 0; i copies.length; i) { copies[i]() } } export function nextTick(cb) { callbacks.push(() { if (cb) { try { cb() } catch (e) { console.error(e) } } }) if (!pending) { pending true Promise.resolve().then(flushCallbacks) } // 返回Promise使调用方可以 await return Promise.resolve() }这个版本的精髓在于pending标记。它的作用是确保同一轮微任务里不管调用多少次nextTick最终只执行一次flushCallbacks。所有回调都被收集到同一个数组里在微任务里依次执行。4.4 实际项目中的时序问题排查实际操作中最常见的问题是需要在数据更新后拿到最新DOM的尺寸、位置等信息。比如弹窗打开后要获取内容高度如果不放在nextTick里拿到的往往是旧值。我的建议是需要读取更新后DOM状态时用await nextTick()而不是setTimeout时序更稳定。做滚动加载优化时注意在列表数据变化后下一帧可能还没更新布局要用requestAnimationFrame配合不要只依赖nextTick。在Vue3的script setup里nextTick是直接从vue导出的用起来和在options API里一样。有一个调试技巧分享给你当怀疑是批量更新导致渲染不及时在nextTick回调里打点对比渲染时间。如果发现渲染时间确实异常长原因大概率不在数据更新而在diff或者渲染本身。这时就需要查是不是有组件层级过深、列表key不规范、或者模板里写了过重的计算逻辑。5. computed与watch原理不同千万别混用5.1 computed的实现惰性求值与缓存机制computed计算属性之所以比methods里的函数更适合派生数据核心优势在于缓存。它内部维护了一个dirty标记初始值为true。首次访问computed时执行计算函数拿到结果后把dirty设成false之后只要依赖的响应式数据没有变化再次访问时直接返回缓存结果不会重复计算。当依赖的响应式数据变化时Vue不是立刻重新计算结果而是把dirty重新置为true。下一次有人访问该computed时才会去重新计算。这种设计叫惰性求值和Vue3的effect配合得很好。用一段伪代码辅助理解class ComputedRefImpl { constructor(getter) { this._getter getter this._value undefined this._dirty true this.dep new Dep() } get value() { if (this._dirty) { this._value this._getter() this._dirty false } this.dep.depend() return this._value } }5.2 watch的实现用户Watcher与回调触发watch和computed走的是另一条路。它创建一个用户Watcher当侦测的数据源变化时执行回调用于处理异步、开销较大或者需要显式指挥的操作。watch的deep选项会深度遍历对象的每一个属性分别把用户Watcher收集进依赖里。所以deep监听的开销不小数据量特别大的对象要慎重使用。immediate选项会在创建Watcher时立刻执行一次回调而不是等数据第一次变化才执行这在初始化时需要基于数据做一些准备操作时非常有用。5.3 使用场景与面试高频问答我经常遇到有人把computed和watch用反。举几个正例用computed做数据的过滤、格式化、求和、组合。用watch监听路由参数变化重新请求接口。用watch监听表单里某个字段变化做防抖搜索请求。用computed渲染动态类名拼接。反例是在computed里发请求在computed里修改其他响应式数据。computed要求是纯函数有副作用会使结果不可预测调试起来很痛苦。面试有个经典问题computed和watch都能监听数据变化为什么不直接用watch实现所有功能答案核心是缓存和惰性计算。computed适合多读少改的派生状态watch适合频繁变化需要主动响应的行为。6. 扩展自定义v-model、依赖注入与常见追问6.1 自定义v-model的实现原理v-model在自定义组件上其实是语法糖它绑定一个prop同时监听一个事件并更新这个prop。Vue2默认是valueinputVue3改成modelValueupdate:modelValue并且model选项被改用参数形式。一个自定义输入框组件在Vue3里可以这样写template input :modelValuemodelValue update:modelValue$emit(update:modelValue, $event) / /template script setup defineProps([modelValue]) defineEmits([update:modelValue]) /script这样父组件就能这样用MyInput v-modelsearchText /真正的价值在于可扩展性。你可以给同一个组件定义多个v-model参数一个用于文本一个用于选中状态互不干扰。这个模式让组件API更清晰底层原理就是props和emit的配合没有魔法。6.2 provide/inject能跨层级通信但得知道它的边界provide/inject解决了深层组件透传props麻烦的问题。父组件通过provide提供数据任意层级的子组件通过inject拿到数据中间组件不需要显式传递任何props。需要特别注意的是provide/inject本身不是响应式的除非你提供的是ref或者reactive对象。如果你在父组件里provide了一个普通数组后续修改这个数组子组件里的inject不会自动更新。这是很多开发者的盲区拿它当全局共享状态用结果数据变了页面没反应。如果确实需要响应式共享正确做法是提供一个ref比如const count ref(0) provide(count, count)子组件通过inject(count)拿到的就是这个ref对象访问.value时才有响应式联动。6.3 几个容易被追问的原点还有一个高频追问点是“函数式组件和普通组件的区别”。函数式组件没有自己的状态和生命周期可以理解为纯函数接收props返回VNode渲染开销小适合做纯展示、无副作用的组件。到了Vue3函数式组件的写法也变了但思想还是那些无状态、无实例。另外“keep-alive的原理”也常被拿出来考。它本身是一个抽象组件会缓存组件的VNode和子树在切换时命中缓存直接复用实例不重新执行创建过程。这也解释了为什么保持滚定位置的组件里activated和deactivated两个生命周期钩子比mounted和unmounted更常用。写到这里其实已经把Vue原理里最关键的几条主线都过了一遍。我个人的经验是学原理不要只看源码一定要动手改一改、拆一拆。你可以把文章里的mini reactive改造成支持ref和computed的版本也可以直接在Vue项目里打印编译后的render函数看产物。把原理落到自己能写出来的程度才算真正掌握。这套方法我带过不少人实践过效果比单纯刷面试题扎实得多。
返回列表