ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Vue 2 核心响应式与渲染原理全景拆解

2026/8/5 19:11:25 拓冰建站 浏览量
Vue 2 核心响应式与渲染原理全景拆解

Vue 2 核心响应式与渲染原理全景拆解

本文聚焦于Vue 2的底层架构设计,梳理从数据劫持、依赖收集、异步队列更新,到生命周期衔接与this.$nextTick机制的全流程。

文章目录

  • Vue 2 核心响应式与渲染原理全景拆解
    • 1. 响应式基石:`Object.defineProperty` 与 Getter / Setter
      • Getter 与 Setter 的职责划分
      • 为什么组件中的 `data` 必须是一个函数?
      • 特殊场景补充:数组响应式与 `$set`
    • 2. 依赖收集核心:`Dep` 响应式通讯录
      • 简化版的实现模型
    • 3. 视图异步更新机制(Async Queue)
      • 核心步骤与去重逻辑
    • 4. `Watcher` 说明
      • 什么是 `Watcher`?
      • 每一个属性都有 `Watcher` 吗?
      • Vue 2 中一共有哪几类 `Watcher`?
      • Watcher 的全局唯一 `id`
      • `id` 在异步队列去重中的运用
    • 5. `this.$nextTick` 的底层实现与 FIFO 顺序
      • 并没有“反复横跳”
      • 完整运行流
    • 6. 生命周期的完整衔接

1. 响应式基石:Object.defineProperty与 Getter / Setter

Vue 2 在初始化时,利用Object.definePropertydata中定义的普通属性,改写为具有拦截能力的访问器属性(Accessor Properties)

Getter 与 Setter 的职责划分

  • getter(取值拦截器):在读取数据属性(如模板渲染或计算属性取值)时触发。

  • 核心任务:触发依赖收集(Dep.depend()),记录当前是哪个Watcher在读取该数据。

  • setter(赋值拦截器):在给数据重新赋值(如this.count = 2)时触发。

  • 核心任务:触发派发更新(Dep.notify()),通知所有依赖该属性的Watcher数据发生了改变。

为什么组件中的data必须是一个函数?

在 Vue 组件定义中,data必须写成返回对象的函数

// 正确写法data(){return{count:0}}
  • 根本原因(引用隔离):组件本质上是一个可复用的 VueClass 实例。如果data是一个纯粹的对象,多个组件实例就会共享同一块内存地址。修改其中一个组件的data,会直接污染其他实例。
  • Vue 的处理机制:每一个组件在实例化时,Vue 会执行data()函数,生成一份全新的独立对象,然后再对其进行响应式劫持。

使用函数能够返回全新独立对象的根本原因在于:
函数的闭包/作用域机制结合对象的字面量创建,使得每次函数运行都会在内存堆中申请一块独立的新空间。
这相当于工厂的流水线模具——函数是“模具”,每次调用函数就是“压制一次”,生产出来的都是物理上互相独立的新产品。

特殊场景补充:数组响应式与$set

  1. 数组的重写拦截:出于性能开销考虑,Vue 2 没有对数组的每一个索引去定义getter/setter。而是拦截重写了数组的 7 个变异原型方法(push,pop,shift,unshift,splice,sort,reverse)。在调用这些方法时,除了执行原生操作,还会手动调用数组本身持有的dep.notify()派发更新。
  2. 新增/删除属性与this.$set由于Object.defineProperty无法侦测动态添加或删除的属性,Vue 2 提供了this.$set(target, key, value),其底层会调用defineReactive补建响应式拦截,并显式触发target.__ob__.dep.notify()通知视图更新。

2. 依赖收集核心:Dep响应式通讯录

Dep(Dependence)在 Vue 2 中不是一个单纯的函数,而是一个类(Class)。它的主要作用是作为依赖管理器,连接响应式属性与视图观察者(Watcher)。

  • 每个响应式属性都有一个专属的Dep实例(保存在defineReactive的闭包作用域中)。
  • 核心结构与方法:
  • subs数组:存储所有依赖当前属性的Watcher实例。
  • depend():在getter中调用,将当前的Watcher加进subs列表。
  • notify():在setter中调用,遍历subs数组,依次调用每个Watcher.update()

概括来说:data里每一个嵌套对象的每一个属性,在底层都有自己独立的一套gettersetter以及存放在闭包里的Dep实例,负责精细化控制属于它自己的update通知。

简化版的实现模型

// 全局属性,用于临时指向正在挂载/运行的 WatcherDep.target=null;classDep{constructor(){this.subs=[];// 订阅者列表 (Watcher 集合)}// 依赖收集depend(){if(Dep.target){this.subs.push(Dep.target);// 双向收集:Watcher 内部也会记录当前的 Dep 实例Dep.target.addDep(this);}}// 派发更新notify(){// 拷贝一份订阅者数组进行遍历constsubs=this.subs.slice();for(leti=0;i<subs.length;i++){subs[i].update();}}}// 属性数据劫持functiondefineReactive(obj,key,val){constdep=newDep();// 为每一个属性在闭包中绑定一个独立的 Dep 实例Object.defineProperty(obj,key,{enumerable:true,configurable:true,get(){if(Dep.target){dep.depend();// getter 触发,收集依赖}returnval;},set(newVal){if(newVal===val)return;val=newVal;dep.notify();// setter 触发,派发更新}});}

3. 视图异步更新机制(Async Queue)

当你连续修改多个数据属性时(如this.a = 1; this.b = 2;),Vue 2绝不会立即去重新渲染页面,而是将更新推入一个异步更新队列进行批量处理。

核心步骤与去重逻辑

  1. 去重入队(queueWatcher):
    数据改动触发setter→ \to调用dep.notify()→ \to触发watcher.update()Watcher收到更新通知后,先通过专属的id检查自己是否已在队列中。如果在,则忽略;如果不在,则推入全局的queue数组。
  2. 合并触发:
    即便在单次事件循环中修改了 100 个变量,由于同一个组件对应的是同一个渲染 Watcher,更新队列里始终只有这个 Watcher 的单一记录。
  3. 在微任务中清空队列:
    Vue 内部调用nextTick(flushSchedulerQueue)注册一个微任务。待主线程同步代码全部执行完毕后,事件循环进入微任务阶段,批量遍历queue,只执行一次Render 与 Diff 过程。
constqueue=[];lethas={};letwaiting=false;functionqueueWatcher(watcher){constid=watcher.id;if(has[id]==null){has[id]=true;queue.push(watcher);// 存入 Watcherif(!waiting){waiting=true;// 在当前宏任务的同步代码结束后,由微任务统一刷新队列nextTick(flushSchedulerQueue);}}}functionflushSchedulerQueue(){for(leti=0;i<queue.length;i++){constwatcher=queue[i];has[watcher.id]=null;watcher.run();// 真正开始重新渲染与 DOM 挂载}queue.length=0;waiting=false;}

4.Watcher说明

什么是Watcher

Watcher(观察者 / 订阅者)在 Vue 2 中是一个类(Class)
如果说Dep是“通讯录/发布者”,那么Watcher就是真正干活的“订阅者/执行者”

当数据发生改变,Dep.notify()喊了一声“数据变了!”,收到通知并真正去跑代码计算、去更新 DOM 节点的,就是Watcher

每一个属性都有Watcher吗?

答案是:没有!绝不是每个属性都有 Watcher。

  • Dep是“属性级别”的:每一个属性在闭包中都有一个专属的Dep实例(1个属性 = 1个 Dep)。
  • Watcher是“表达式/组件级别”的:Watcher只有在需要监听变化并执行回调/渲染的地方才会创建。

Vue 2 中一共有哪几类Watcher

在 Vue 2 组件运行期间,一共只有以下3 种Watcher

  1. 渲染 Watcher(Render Watcher):
  • 数量:每个组件实例只有一个。
  • 作用:负责整个组件视图的重新渲染(执行vm._update(vm._render()))。
  • 属性与 Watcher 的关系:假设模板里用了 10 个数据属性(a,b,c…),这 10 个属性对应的 10 个Dep里,存的都是同一个渲染 Watcher
  1. 用户 Watcher(User Watcher /watch选项):
  • 数量:你写了几个watch属性,就有几个。
  • 作用:执行你在watch: { foo(val) { ... } }中写的自定义回调函数。
  1. 计算属性 Watcher(Computed Watcher):
  • 数量:你写了几个computed,就有几个。
  • 作用:内部维护一个dirty标志位,用来实现计算属性的缓存机制

Watcher 的全局唯一id

每一个Watcher实例都有一个全局唯一的id

id是实现“异步更新队列去重(防抖)”的核心关键!在 Vue 2 内部,有一个自增的全局计数器:

letuid=0;// 全局自增 ID 计数器classWatcher{constructor(vm,expOrFn,cb,options){this.id=++uid;// 每一个 Watcher 诞生时,都会拿到一个递增且唯一的 idthis.deps=[];this.depIds=newSet();// ...}addDep(dep){if(!this.depIds.has(dep.id)){this.depIds.add(dep.id);this.deps.push(dep);// 双向收集:Watcher 记录属性的 Dep,用于组件销毁和清除无效依赖}}}

id在异步队列去重中的运用

当你写了如下代码:

this.firstName='张';this.lastName='三';
  1. firstName改变,触发它的Dep,通知渲染 Watcher(假设id: 1
  2. Vue 执行queueWatcher(watcher)
if(has[watcher.id]==null){// 检查 has[1] 是否存在has[watcher.id]=true;// 标记 has[1] = truequeue.push(watcher);// 把 id: 1 的渲染 Watcher 推入队列}
  1. lastName改变,触发它的Dep通知同一个渲染 Watcher(id: 1
  2. Vue 再次执行queueWatcher(watcher)
if(has[1]==null){...}// 发现 has[1] 已经是 true 了!直接跳过!

正是因为每个Watcher有唯一的id,Vue 才能用极其高效的O(1)时间复杂度过滤掉重复的更新通知。

5.this.$nextTick的底层实现与 FIFO 顺序

this.$nextTick(cb)能够准确拿到最新 DOM 的核心原因在于:微任务队列的“先来后到(FIFO,先进先出)”执行顺序

并没有“反复横跳”

在底层,Vue 维护了一个统一的回调函数队列callbacks

constcallbacks=[];letpending=false;functionnextTick(cb){callbacks.push(cb);// 入队if(!pending){pending=true;// 开启锁定,保证当前 Tick 内只开启一次微任务注册// 使用微任务 API(如 Promise.then)在主线程空闲时派发Promise.resolve().then(flushCallbacks);}}functionflushCallbacks(){pending=false;// 解锁constcopies=callbacks.slice(0);callbacks.length=0;for(leti=0;i<copies.length;i++){copies[i]();}}

完整运行流

假设有如下代码:

this.message='Hello World';// ① 修改数据this.$nextTick(()=>{// ② 手动挂载回调console.log(this.$el.textContent);});
  1. 执行 ① 时:触发setter,Vue 内部自动调用nextTick(flushSchedulerQueue)
  • 此时callbacks队列为:[ flushSchedulerQueue ]
  1. 执行 ② 时:手动调用$nextTick,你的回调函数被推入队列。
  • 此时callbacks队列为:[ flushSchedulerQueue, userCallback ]
  1. 主线程同步代码结束,开启微任务:
  • 执行callbacks[0]flushSchedulerQueue):Vue 重新渲染并把新节点直接同步写到了内存 DOM 节点上。
  • 执行callbacks[1]userCallback):在此回调里读取 DOM,由于前一步内存 DOM 已经被更新,因此可以直接读取到最新结果。

6. 生命周期的完整衔接

响应式系统与异步更新机制并非孤立运行,而是贯穿在 Vue 组件的各个生命周期钩子中:

[ new Vue() 实例创建 ] │ ▼ beforeCreate 钩子 │ ┌─────────┴─────────┐ │ 初始化 Inject/ │ │ Data/Props/Method │ --> 针对 data 递归调用 defineReactive, │ 转换 Getter/Setter │ 挂载对应的 Dep 实例 └─────────┬─────────┘ ▼ created 钩子 --> 此时数据已具备响应式,但还没挂载 DOM │ beforeMount 钩子 │ ┌─────────┴─────────┐ │ 创建 Render Watcher│ --> 首次执行 Render,触发属性的 getter, │ 并执行首次渲染过程 │ 把 Render Watcher 收集进 Dep 的 subs 数组中 └─────────┬─────────┘ ▼ mounted 钩子 --> 真实 DOM 首次渲染完成 │ ┌─────────┴─────────┐ │ 数据改变 (Setter)│ --> 触发 dep.notify(),将 Watcher 推入异步更新队列 (queueWatcher) └─────────┬─────────┘ ▼ beforeUpdate 钩子 --> 异步队列准备刷新视图前触发 │ ┌─────────┴─────────┐ │ 执行 Watcher.run() │ --> 生成新 VNode,通过 Diff 算法对比,映射并更新真实 DOM └─────────┬─────────┘ ▼ updated 钩子 --> 视图更新完成 │ beforeDestroy 钩子 --> 销毁前,移除监听器、子组件及 Watcher │ destroyed 钩子

🚀 感谢阅读!想了解更多?

📖 我的博客网站 | 记录思考,分享干货
🏡 我的个人主页 | 关于我、开源项目