第3章:Vue初始化底层原理大揭秘

22 阅读41分钟

第3章:Vue初始化底层原理大揭秘

一、Vue初始化流水线介绍

image.png  

二、角色解构——Observer

问:乐乐和Observer 的源码介绍:

答:

1、 Observer (观察员)的构造函数:Observer 类

l   源码位置:src/core/observer/index.js

l   诞生时机: _init 流水线的第4步,(initState -> initData -> observe(data) )。

l   底层动作: new Observer(value)。

l   真实职责: 它是一个纯数据的劫持者。它的核心动作是调用 walk 方法,递归遍历当时已存在的属性data对象,为每一个属性通过 Object.defineProperty 劫持并改写成 getter/setter。对于深层对象,它会递归执行同样的劫持流程;对于数组,它会拦截并重写 7 个变异方法,并对新增元素进行响应式化处理。

l   关键局限: 它的管线铺设是一次性的静态施工。它只负责处理初始化时刻的已知状态,让它们变得“可观测”。它不负责任何视图渲染,也不主动收集依赖。一旦初始劫持完成,Observer 便退出了底层车间——此后如果大脑在运转中动态新增了图纸之外的属性,Observer 不会再自动追踪和劫持,新增属性将处于无管线的裸露状态,只有通过系统强制调用 $set,才能手动补装劫持管线并触发通知。


2、 乐乐(Render Watcher)的构造函数: Watcher 类

l   源码位置: src/core/observer/watcher.js

l   诞生时机: _init 流水线的最后一步(mounted 或首次渲染 触发 new Watcher(vm, updateComponent))。

l   底层动作: new Watcher(vm, updateComponent, { render: true })。

l   真实职责: 它是一个调度执行者与依赖收集者,且具有严格的感知边界与粗暴的执行机制。它的核心运作分为两步:****

1)        收集期: 实例化时,它会立刻执行 updateComponent(驱动视图进行首次渲染)。在渲染过程中,它会去读取那些被 Observer 提前劫持过的 data 属性,从而触发 getter,将自己的依赖列表(deps)填满。但这种收集是被动的且受限的——它只收集初始渲染时触碰到的浅层管线,对于 Observer 未铺设管线的新增属性,或未触碰到的深层嵌套属性,它一无所知,也不会追踪。

2)        响应期: 收集完毕后,只要已记录的依赖数据发生变化,触发 setter,就会通知乐乐。乐乐收到通知后,会毫无差别地执行全量重新渲染(再次执行 updateComponent 生成全新 vnode 并 diff),而不具备局部微调的能力。

l   关键局限: 它的感知范围被严格锁定在“Observer 初始施工且自身首次触碰过的图纸”内。管线外的新增属性、未触碰的深层属性变化,对它而言是彻底的盲区;同时,它的驱动方式是粗暴的“一刀切”,哪怕只是极细微的数据变动,也会触发整台机器的全量重启级渲染。

用了 Vue.set(或 vm.set(或 vm.set),乐乐就能监控到新属性的变化了。

但这里有一个非常关键的源码细节需要厘清:$set 并不是让乐乐去“主动重新收集”了新属性,而是系统在底层强行“补装管线”后,手动给她发了一封急件。

1)         强制施工: 如果是新增属性,$set 会强行调用 new Observer(),为这个原本裸露的新属性补装 getter/setter 管线,让它变得“可观测”。

2)         强制派发通知: 由于新属性是在 Observer 初始施工之后才加的,乐乐在首次渲染时根本没触碰过它,所以乐乐的依赖列表里依然没有这个新属性的记录。那乐乐怎么知道它变了?$set 在改写完新属性的值后,会绕过正常的 setter 流程,直接手动调用 dep.notify()——相当于底层车间直接拉响了属于这个新管线的最高级别警报。

3)         强制唤醒: dep.notify() 一响,作为调度员的乐乐(Render Watcher)就会收到通知,哪怕她之前根本没把这条管线记在自己的小本本上,她也会被强制唤醒,进而去执行那套粗暴的全量重新渲染(updateComponent)。


 

问:所以Observer 和 Watcher 是有一个先后顺序的?

答:没错。

这是Vue2 响应式系统最核心的架构精髓——时序依赖(Timing Dependency)。

在_init这条流水线上,Observer 和 Watcher 的诞生顺序不仅是先后的,而且是强依赖的、不可颠倒的铁律。如果顺序错乱,整个响应系统就会当场瘫痪。

我们可以从源码的微观执行顺序来拆解这个“先铺路,后接表”的绝对逻辑:

1. 先有 Observer:预埋空白警铃

在 _init 的较早阶段(initState -> initData),第四只手臂执行 new Observer(data)。

  • 底层动作: Observer 递归遍历初始 data 上的已有属性,用 Object.defineProperty 将它们改写为 getter/setter。****
  • 核心伏笔: 在改写 getter 时,源码会在每个属性的闭包内部悄悄实例化一个属于自己的信号收集器:const dep = new Dep()。****
  • 状态: 这就相当于 Observer 在底层车间的每一条管线节点上,都预先安装了一个空白警铃。此时警铃虽然挂在那里,但没有任何人的联系方式(依赖列表为空),因为此时还没有任何监控者诞生。

2. 后有 Watcher:触碰管线,登记身份

在 _init 的最后阶段(mounted 首次渲染),最后一只手臂执行 new Watcher(vm, updateComponent, ..., true)。

  • 底层动作: 乐乐(Render Watcher)实例化时,会立刻执行 updateComponent 进行首次渲染。在渲染过程中,她必然会去读取那些已被 Observer 劫持的 data 属性,从而触发 getter。****
  • 核心闭包: 源码中 getter 的内部有一句关键逻辑:if (Dep.target) dep.depend()。这里的 Dep.target 就是当前正在执行的乐乐本人。dep.depend() 的作用,就是把乐乐的联系方式强制塞进刚才那个空白警铃(dep)的依赖列表里。
  • 状态:只有当乐乐亲自触碰了管线,触发了 getter,她才真正在底层的警铃上留下了自己的名字。从此,管线一有波动,警铃就能精准呼叫到乐乐。

如果顺序颠倒:响应式的彻底死亡

假设最后一只手臂抢在第四只手臂之前行动,即先 new Watcher,后 new Observer

1.       乐乐先诞生,立刻执行 updateComponent 读取 data。

2.       但此时 Observer 还没进场,data 的属性还是原生的、未被劫持的普通数据。读取它们时,不会触发任何 getter

3.       既然没有触发 getter,就绝不会执行 dep.depend()。乐乐走过了一遍底层车间,却没有在任何警铃上留下名字。

4.       随后,Observer 姗姗来迟,开始铺设管线,为属性安装警铃(new Dep())。但它安装的全部是空白警铃,因为乐乐早就走过了,没人登记过联系方式。

5.       最终结果:数据变化时,setter 触发,警铃拉响(dep.notify()),但因为依赖列表是空的,通知发不出去。乐乐坐在引擎舱里,永远收不到信号,视图永远不会再更新。响应式系统彻底失效。

源码铁律的小说映射

这正是 _init 写在地基里的真相:顺序不可颠倒,因为依赖收集的本质是“Watcher 主动去触发 Observer 留下的陷阱”。

  • Observer 必须先在黑暗的底层车间里默默完工,把每一根反映莱莉内心的管线都接上空白警铃(new Dep()),等待未来的某个人来登记。
  • 然后 Watcher 才能在上层舱室里诞生,当她第一次启动引擎,探出手去抚摸那些管线时(触发 getter),她才真正赋予了那些警铃意义——把自己的灵魂(Dep.target)与底层的管线绑定(dep.depend())。
  • 没有底层的静默铺设(Observer),上层的初次触碰就是徒劳(getter 不存在);没有上层的初次唤醒(Watcher),底层的精心铺设就是死物(Dep 为空)。先有可观测的土壤,才能长出会感知的生命。

 

问:Dep 是啥?

答:

Dep 是Vue响应式系统中的依赖收集器(可以理解为文中的感应片的登记本),它的核心作用是管理和通知数据的依赖订阅者(即Watcher)。

具体功能如下:

1)         收集依赖: 通过depend()方法,在数据被读取(触发get)时,将当前全局的订阅者Dep.target 登记到自身的 subs数组中。

2)         通知更新: 通过notify() 方法,在数据被修改(触发setter)时,遍历subs数组,通知所有登记过的Watcher 调用器 update 方法进行视图或计算更新。

3)         全局标记: 静态属性Dep.target 用于标记当前正在初始化/求值的 Watcher,确保依赖收集的准确性。

【问】:Dep.target = null; // 全局标记:当前正在出生的Watcher   到底是啥意思?

【答】:声明一个全局的“占位符”,用来登记当前正在执行的依赖收集的Watcher(观察者),并且初始化状态下没有任何Watcher 在工作,所以设为null。

 

结合上下文,可以从以下几个方面理解:

1)         为什么需要全局标记:在Vue响应式系统中,一个数据属性可能被多个Watcher依赖(比如多个组件都用到了同一个数据)。当数据被读取触发 get 函数时,数据需要知道“此刻到底是哪个Watcher在读我?”,才可能把这个Watcher登记到自己的subs 数组中。Dep.target 就是用来暂存这个“当前读者”的全局变量。

2)         “正在出生的Watcher”是什么意思呢? 当一个 Watcher 刚被创建(出生)时,它会立刻去读取它关心的数据,以此来触发数据的get 函数完成依赖登记。在这个读取的过程中,Watcher 会把自己赋值给 Dep.target(例如 Dep.target = this), 告诉数据“我正在读你,快把我接下来”。这就是“正在出生”的状态。

3)         为什么初始值是null? 因为依赖收集是按需、瞬时的。只有在Watcher初始化求值得那一小段时间里,Dep.target 才有值。求值完成后,立即必须把Dep.target重置为null,清空全局标志。如果不清空,后续任何无关的代码偶尔读取了这个数据,都会被错误地登记subs, 导致依赖混乱。

简而言之:  Dep.target 就像医院挂号窗口的“当前就诊患者名”显示屏。初始没人挂号(null);某个 Watcher 挂号时,屏幕显示它的名字(赋值为该 Watcher);挂号完毕,屏幕立刻清空(恢复为 null),防止下一个人被误当成前一个患者。


问: 后续任何无关的代码偶尔读取了这个数据,都会被错误地登记subs, 导致依赖混乱——这句话怎么理解?

【答】:

我们可以用一个生活例子和代码场景来理解:

1、   生活类比:医院挂号

Dep.target 就像医院大厅的 当前就诊患者名牌。

subs 就像医生的患者登记本。

depend() : 就像医生的操作:“看一眼名牌,把上面的人写进登记本”。

正常流程:张三(Watcher)来看病,大厅名牌显示“张三“(Dep.target = ‘张三’)。张三进入病房看病,医生看到名牌上的就是张三,把张三登记到本子上。看完病医生把张三的名字清空了(Dep.target = null)。

如果不擦掉(依赖混乱): 张三看完病走了,名牌还写着“张三“,此时,李四(无关代码)只是路过顺便看了一眼诊室,护士一看名牌还写着“张三”,又把“张三”记了一遍。结果下次医生通知患者时,张三会被错误地通知多次,甚至本不该被通知的路人也混进了登记本。

2. 代码场景:Vue 组件渲染 vs 普通事件

假设有一个响应式数据 obj.name:

需要依赖的场景(真正的 Watcher):  Vue 渲染组件时,会生成一个渲染 Watcher。它读取 obj.name 来画页面,此时 Dep.target 被设为这个 Watcher。get 触发,这个 Watcher 被正确登记进 subs,以后 obj.name 变了,页面就会更新。

无关代码的场景(不需要依赖):  你在代码里随便写了个普通的按钮点击事件:

javascript

button.addEventListener('click', () => {

    console.log(obj.name); *//* *只是顺手读一下数据,打印而已*

})

在这个回调执行时,如果你没有清空 Dep.target,而它刚好还停留在之前的某个 Watcher 上。那么 obj.name 的 get 被触发时,defineReactive 里的 dep.depend() 就会把那个旧的 Watcher 再错误地 push 一次进 subs。

3. 导致的后果

这就是所谓的“依赖混乱”。后果包括:

  • 多余更新:数据改变时,notify() 会通知 subs 里的所有 Watcher 去执行 update()。如果里面混进了不需要更新的无关读取者,就会导致无意义的重复渲染或计算。
  • 性能浪费:本来只需更新1次,因为依赖被错误重复收集,可能更新了10次。
  • 逻辑错乱:闭包里的 dep 实例本应只收集真正关心它的 Watcher,现在却变成了“谁读过我就记谁”,这完全违背了响应式系统“精准更新”的设计初衷。

所以,在 get 函数中完成依赖收集后,Vue 的真实源码会立刻执行 Dep.target = null,把“名牌”擦得干干净净,确保下一次任何普通的、无关的数据读取,都会因为 if(Dep.target) 判断为 null 而跳过 depend(),不会污染 subs 登记册。


问:

此时,李四(无关代码)只是路过顺便看了一眼诊室,护士一看名牌还写着“张三”,又把“张三”记了一遍。结果下次医生通知患者时,张三会被错误地通知多次,甚至本不该被通知的路人也混进了登记本。——如果按照正常世界的看病流程,我理解,如果写着张三不应该是占用了看病的机会,别人进不去吗。什么叫李四看了一眼,把张三又记了一遍呢?

答:

你的直觉非常敏锐!你的疑惑来自于把**“名牌(Dep.target)”“诊室(get函数)”**混淆了。让我顺着你的思路,把这个类比修正一下,你就能瞬间明白了。

关键纠偏:诊室是永远开放的,不会因为名牌写了人就锁门。

在 JavaScript 的世界里,数据的 get 函数(诊室)是永远敞开的。任何人、任何代码,随时都可以来读数据(进诊室看一眼)。名牌(Dep.target)并不会起到“锁门”或“占用排号”的作用,它只是一个被动的“公告牌”。

所以,真实的场景是这样的:

1. 正确的流程(Vue 真实源码的做法)

  • 张三(Watcher)来看病:  张三进门时,把自己的名字写在了门口的名牌上(Dep.target = 张三)。
  • 医生登记:  医生看到名牌上写着“张三”,就把张三记在了预约本(subs)里。
  • 张三看完离开:   【关键动作】张三离开时,顺手把名牌擦掉了(Dep.target = null)。
  • 李四(无关代码)路过看一眼:  此时名牌是空的。李四进诊室随便看了看就走了。医生看了一眼名牌,发现是空的(if(Dep.target) 判断为 null),于是不理会李四,也不做任何登记
  • 结果:  预约本上只有真正的张三,依赖精准,没有混乱。

2. 错误的流程(如果不擦名牌,也就是不把 Dep.target 重置为 null)

  • 张三(Watcher)来看病:  张三进门,把名字写在名牌上(Dep.target = 张三)。
  • 医生登记:  医生看到名牌,把张三记在预约本(subs)里。
  • 张三看完离开:   【关键动作缺失】张三忘了擦名牌!  名牌上依然写着“张三”。
  • 李四(无关代码)路过看一眼:  李四推门进去,随便看了一眼数据(触发了 get)。医生听到有人进门,习惯性地看了一眼名牌——名牌上还写着“张三”! 医生不知道进门的是路过的李四,她只认名牌,于是又把“张三”在预约本上记了一遍
  • 结果:  预约本上出现了两个“张三”。下次医生(数据改变)通知时,张三会被通知两次,做了两次无意义的更新。这就是所谓的“张三被错误地通知多次”。
  •  

【问】:为什么会“把路人也混进登记本”?

【答】:既然名牌上写的是张三,那护士记下的也是张三,路人李四怎么混进去的?

这取决于路人的身份。假设路过的不只是李四,而是另一个组件的 Watcher(王五)

  • 王五本来只是想在用户点击按钮时,随便读取一下这个数据打印到控制台,他完全不需要在这个数据变化时重新执行自己
  • 但王五进门读数据时,看到名牌上还残留着“张三”的名字。
  • 护士一看名牌,就把“张三”又记了一遍。但如果此时王五进门前,不小心把自己名字写上了名牌(比如另一个计算属性正在求值),然后没有擦掉就走了,接着一个毫不相关的路人甲进门,护士就会把**“王五”**也记进张三的登记本!
  • 这样,路人甲触发的读取,反而让王五被强塞进了张三的依赖列表。数据一变,张三和王五都会被迫更新,这就是“本不该被通知的路人也混进了登记本”,导致系统做大量无用的渲染,这就是依赖混乱

 场景一:点击事件里的普通逻辑

假设你有一个购物车,总价 totalPrice 是响应式数据。


*//* *这是一个真正的 Watcher(张三):渲染组件时读取了 totalPrice*

*//* *组件模板:<div>总价:{{ totalPrice }}</div>*

 

*//* *这是一个普通的业务代码(李四):用户点击结算按钮*

methods: {

  checkout() {

    *//* *这里读取了 totalPrice,只是为了打印日志,或者传给后端接口*

    console.log('当前总价是:', this.totalPrice);

    api.submitOrder(this.totalPrice);

  }

}

当用户点击按钮触发 checkout 时,代码执行到了 this.totalPrice,触发了数据的 get 函数。这就是“李四看了一眼”。


问:

李四进诊室随便看了看就走了。医生看了一眼名牌,发现是空的(if(Dep.target) 判断为 null),于是不理会李四,也不做任何登记。加入李四也是一个计算属性,但是它知识打印了数据。那么它会在初始化的时候去做依赖收集吗?

答:

会的,绝对会!

只要它是 computed(计算属性),在初始化时 Vue 就一定会为它创建一个专属的 Watcher,并立刻执行它的函数去求值,这就意味着它一定会走进诊室(触发 get)。

但是,这里有一个非常反直觉、却又极其精妙的 Vue 设计机制:即使李四走进了诊室,保安也不会给它登记!它的 subs 登记册依然是空的!

我们来一步步还原这个过程:

1. 李四的求值流程(它有名牌)

因为李四是一个计算属性,Vue 在初始化时会这么做:

1.       创建李四的 Watcher。

2.       把李四的名牌挂上:Dep.target = 李四。

3.       执行李四的函数:console.log(this.someData)。

4.       读到了 this.someData,触发了数据的 get 函数,走进了诊室。

2. 保安的检查(为什么不给它登记?)

李四进了诊室,保安看了一眼名牌,上面写着“李四”(Dep.target 有值,不为 null)。按理说,保安应该执行 dep.depend() 把李四记下来的。

但 Vue 的真实源码里,depend() 并不是直接 push,而是这样写的:


depend() {

    if (Dep.target) {

        Dep.target.addDep(this); *//* *注意!**是让 Watcher 自己来决定要不要登记!***\*\*\*\*

    }

}

这就相当于:保安看到名牌上有名字,不会直接往本子上写,而是对着名牌喊:“哎,名牌上的这位,你要在我这本子上签字吗?”

3. 李四的抉择(Watcher 的 addDep 逻辑)

李四(计算属性 Watcher)听到保安的呼唤,它内部有一套自己的审查逻辑。它会问自己一个问题: “我真的需要在这个数据上依赖吗?”

Vue 为了优化性能,给计算属性 Watcher 设计了一个机制:只有当别人依赖了李四的结果时,李四才会去依赖它内部读取的数据。

在初始化阶段,如果没有任何组件渲染(张三)或其他计算属性引用了李四,李四就是一个**“没人要的孤儿”**。它的内部有一个标记叫 this.dirty,并且它知道自己此时没有任何“上游订阅者”。

所以,当保安问李四要不要签字时,李四内心的逻辑是:

  • “我虽然打印了数据,但我是个孤儿,没人关心我的结果变没变。既然没人关心我,我也就不需要关心这个数据变没变。”
  • 于是,李四对保安说: “不签,我不登记。”

4. 结果:依赖收集的惰性(Lazy)

这就是 Vue 计算属性最核心的性能优化——惰性求值与惰性收集

  • 如果李四只是孤零零地打印数据:初始化时,它执行了一遍,算出了结果,但没有在数据的 subs 里留下任何痕迹。当 someData 未来发生变化时,根本不会通知李四,李四也不会重新计算,完美节省了性能。
  • 如果后来张三(渲染 Watcher)用到了李四:比如模板里写了 
    {{ liSiResult }}
    。这时候张三求值时读到了李四,李四发现“有客户(张三)需要我了!”,此时李四才会再次跑去保安那里,主动把自己登记在 someData 的 subs 里

总结

你的直觉是对的:只要是计算属性,初始化时一定会去读数据(进诊室) 。 但 Vue 的精妙之处在于:进诊室不代表就要办住院手续(收集依赖) 。保安只认名牌(Dep.target),但 Watcher 有自己的脑子,它会判断“只有当我的结果被别人需要时,我才去办住院”。如果只是自己偷偷打印看看,它办了住院也是浪费医疗资源,所以它选择不登记!


问:但是你不是说会在初始化的时候算一次吗?

答:

你说得非常对!这正是 Vue 计算属性设计中最容易让人产生错觉的“魔术”时刻。

我之前说“初始化时一定会执行它的函数去求值”,这句话在 Vue 的早期版本(Vue 1.x/2.x 的某些实现)中是成立的。但在你目前学习的 Vue 2 主流源码中,这句话其实是一个简化的说法,真相比这更“懒”!

为了解开这个疑惑,我们必须揭开计算属性 Watcher 内部最核心的两个机制:lazy(懒加载)  和 dirty (脏数据标记)

1. 初始化时,它真的“算了一次”吗?

答案是:根本没有!它只是做好了准备,但绝对没有立刻去算。

在 Vue 初始化时,当遇到一个 computed(比如李四),Vue 会这样做:

1.       创建一个专属的 Watcher 给李四。

2.       给这个 Watcher 打上一个标签:this.lazy = true(意思是:我是一个懒汉,除非有人逼我,我绝不主动干活)。

3.       给这个 Watcher 设定一个状态:this.dirty = true(意思是:我现在的缓存值是脏的/无效的,下次如果有人问我结果,我必须重新算一次)。

4.       【关键】立刻停止!绝不执行李四的函数!

所以,在初始化的那一刻,李四的函数 console.log(this.someData) 根本没有被执行,自然也就没有走进诊室,也没有触发任何 get,更没有收集任何依赖!

2. 那它什么时候才会去算?(第一次求值的时机)

既然初始化时没算,那它总得有个时机去算吧?这个时机就是:当渲染 Watcher(张三)需要用到李四的结果时。

假设你的模板里有这么一段:


<div>{{ liSiResult }}</div>

初始化的最后一步,Vue 开始渲染页面(张三出场):

1.       张三挂上名牌:Dep.target = 张三。

2.       张三执行渲染函数,读到了模板里的 liSiResult。

3.       为了拿到 liSiResult 的值,张三去访问了李四这个计算属性。

4.       李四被访问了!它内部的逻辑开始运转:


*//* *计算属性被读取时,会触发 Watcher 的 evaluate 方法*

evaluate() {

    if (this.dirty) { *//* *检查自己是不是脏的*

        this.value = this.get(); *//* *只有脏的时候,才真正去执行函数求值!*

        this.dirty = false;      *//* *洗干净了,下次再读我,就直接给缓存值,不再算了*

    }

    return this.value;

}

3. 完整的闭环:第一次求值 + 依赖收集

现在我们把所有角色串起来,看看真正的流程是怎样的:

1.       张三挂名牌:Dep.target = 张三。

2.       张三读李四:触发李四的 evaluate()。

3.       李四发现自己脏:this.dirty = true,于是李四决定亲自去执行一次函数求值。

4.       李四执行函数:此时,李四会把自己的名牌临时盖在张三的名牌上面(pushTarget(李四)),此时全局 Dep.target = 李四。

5.       李四进诊室:执行 console.log(this.someData),触发数据的 get。

6.       保安看名牌:保安看到名牌上写着“李四”,于是把李四记在了 someData 的 subs 登记册上。

7.       李四求值完毕:把结果返回给张三,同时把自己洗干净(this.dirty = false),并且摘掉自己的名牌(popTarget()),恢复成张三的名牌:Dep.target = 张三。

8.       张三读李四的结果:张三拿到了李四给的值,继续渲染页面。

总结:你之前的疑惑点在哪里?

你以为 Vue 在初始化 computed 时,就会立刻不管不顾地执行一遍函数(进诊室)。 但实际上,Vue 是极其“抠门”的:如果一个计算属性的结果最终没有被页面用到,Vue 在初始化时连它的函数都懒得执行一丝一毫!

只有当渲染 Watcher(张三)真正去读取这个计算属性时,它才会被逼着去执行第一次求值,也就是在这个“被逼着求值”的过程中,它才走进了诊室,完成了依赖收集。

这就是 Vue 计算属性的惰性求值没有消费者,就不生产;没有页面渲染依赖,就不收集数据依赖!


问:所有如果初始化的时,如果页面需要渲染才会计算是吗?

答:是的,完全正确!

这就是 Vue 计算属性最核心的设计哲学:按需计算(惰性求值)

我们可以把初始化阶段分成两个明确的步骤来看:

1.       步骤一:Vue 解析组件配置(数据初始化阶段)

o   Vue 扫描到了 computed 里的李四。

o   Vue 只是给李四造了一个专属的 Watcher,并且打上了 lazy = true(懒惰模式)和 dirty = true(需要重新计算)的标签。

o   到此为止,Vue 绝不会去执行李四的函数!  此时没有任何数据被读取,没有任何 get 被触发,更没有任何依赖被收集。

2.       步骤二:Vue 渲染页面(挂载阶段)

o   Vue 开始执行渲染函数(张三出场),要把组件画到网页上。

o   张三在画页面的过程中,遇到了模板里的 {{ liSiResult }}。

o   张三不得不去访问李四,想要拿李四的计算结果来画图。

o   就在这一刻!因为页面渲染需要用到李四,李四的函数才被真正执行!  它去读了 this.someData,触发了 get,完成了依赖收集。

反证法:如果页面不需要渲染呢?

假设你写了一个组件,里面定义了十几个计算属性,但是你的模板(template)里非常简洁,只用了其中一个。

那么在初始化并渲染这个组件时:

  • 被模板用到的那个计算属性:因为页面要画它,它被迫求值,成功收集了依赖。
  • 另外十几个没被模板用到的计算属性:因为页面上根本没有它们的位置,没有任何人去读它们,它们的函数连一次都不会执行!闭包里的代码一行都没跑,更别提去触发数据的 get 了。

总结

你总结得非常精准:计算属性的第一次求值和依赖收集,是被“页面渲染”这个动作倒逼出来的。

只有当渲染 Watcher(张三)或者另一个被依赖的计算属性去读取它时,它才会从“懒惰状态”苏醒,去执行计算、去收集依赖。如果没人读它,它就永远是一段沉睡的代码,不会产生任何性能开销!


问:那么如果在计算属性中去请求接口呢。会执行吗?

答:

如果在计算属性中去请求接口,在初始化阶段(页面渲染前)是不会执行的。

原因正是我们刚才确认的:计算属性是惰性求值的,只有当页面渲染需要用到它的结果时,它才会执行内部的函数。  如果页面上没有用到这个计算属性,接口请求根本不会发出去。


但是,在 Vue 中,绝对不建议在计算属性里请求接口!  这是一个典型的反模式(Bad Practice),会带来严重的问题:

1. 最大的灾难:无限死循环触发接口

计算属性的核心职责是:根据现有的响应式数据,派生出一个新值。它必须是纯函数。

如果你在计算属性里请求接口,通常会写成这样:


computed: {

  userList() {

    *//* *请求接口*

    api.getUserList().then(res => {

      *//* *把结果赋值给 data 里的响应式数据!*

      this.users = res.data;

    })

    return this.users;

  }

}

来看看这会引发什么噩梦:

1.       页面渲染,读取 userList,触发计算属性的函数。

2.       函数发起接口请求。

3.       接口返回数据,执行 this.users = res.data。

4.       致命一击:修改了 this.users,触发了数据的 set!

5.       set 触发 notify,通知所有依赖 this.users 的 Watcher 更新。

6.       渲染 Watcher(张三)收到通知,重新渲染页面。

7.       重新渲染时,又读到了 userList,计算属性再次执行!

8.       又发起了一次接口请求……

9.       又修改了 this.users……

10.    无限循环!接口会被疯狂调用,直到浏览器崩溃或服务器宕机。

2. 计算属性不支持异步

计算属性的设计要求是同步返回一个值。 接口请求是异步的,在计算属性的函数执行完时,接口还没返回结果,它只能先返回一个 undefined 或者旧值。等接口回来后,你只能去修改别的 data,这就又回到了上面说的死循环问题。

正确的做法:请求接口应该放在哪里?

既然计算属性不能请求接口,那应该在哪请求呢?根据时机不同,有两个标准位置:

时机一:初始化时就要请求数据 -> 放在 created 或 mounted 生命周期

生命周期钩子不是 Watcher,它们在初始化时一定会执行,且不会产生依赖收集的副作用。


created() {

  *//* *初始化时主动请求,绝不依赖渲染倒逼*

  api.getUserList().then(res => {

    this.users = res.data;

  })

},

computed: {

  *//* *计算属性只负责根据请求回来的数据,做纯粹的同步计算*

  formattedUsers() {

    return this.users.map(u => u.name + '先生');

  }

}

时机二:依赖某个条件变化才请求数据 -> 放在 watch(侦听器)

如果你需要当某个数据变化时去请求接口(比如搜索关键词变了,重新请求搜索结果),必须用 watch,而不是 computed。 watch 专门用于处理副作用(异步请求、DOM操作等)。


data() {

  return { keyword: '' }

},

watch: {

  *//* *当 keyword 变化时,去请求接口*

  keyword(newVal) {

    api.search(newVal).then(res => {

      this.searchResult = res.data;

    })

  }

}

总结

  • 会不会执行? :如果页面渲染依赖它,就会执行;如果不依赖,就不执行。
  • 能不能这么写? :绝对不能!计算属性只做同步的数学计算或字符串拼接,绝不能包含异步请求或修改 data 的副作用。请求接口请认准 created/mounted 或 watch!

 

问:  这个用户点击应该是已经初始化完成了吧。

答:

完全正确!  你的理解非常精准。

当用户点击按钮触发 checkout 时,页面早就渲染完毕,Vue 的初始化阶段已经彻底结束了。

这就回到了我们之前讨论的核心机制:时序隔离

我们来梳理一下完整的时间线,你就明白了为什么 checkout(李四)绝对不会污染依赖收集:

1. 初始化阶段(依赖收集的“营业时间”)

  • Vue 创建计算属性 Watcher(王五)和渲染 Watcher(张三)。
  • 张三渲染页面,读取了 totalPrice。
  • 此时 Dep.target = 张三,保安把张三登记进了 totalPrice 的 subs 登记册。
  • 张三渲染完毕,执行 popTarget(),把名牌摘下:Dep.target = null。
  • 页面画好了,初始化流程正式关门下班。此时,所有的依赖关系都已经牢牢地记在登记册上了。

2. 用户交互阶段(依赖收集的“下班时间”)

  • 用户看着完整的页面,过了几分钟,点击了“结算”按钮。
  • 触发了 checkout 方法,执行到了 this.totalPrice。
  • 触发了 totalPrice 的 get 函数。
  • 但是此时,因为初始化早就结束了,名牌钩子上是空的:Dep.target = null
  • 保安看了一眼名牌:if(Dep.target),发现是空的,根本不理会,不做任何登记
  • get 函数直接返回 val(当前的总价数值),给 checkout 去打印或者传给后端。

总结

正是因为用户点击事件发生在初始化之后,而 Vue 在初始化结束时又严格保证了 Dep.target 被清空(恢复为 null),所以才形成了一个完美的闭环:

  • 初始化时(营业) :挂名牌,保安认真登记。
  • 初始化后(下班) :名牌摘掉,保安只管返回数据,谁来读都不登记。

所以,你说的“用户点击应该是已经初始化完成了”,这就是防止 checkout 这种普通业务代码污染依赖的根本原因(时序原因) !哪怕 Vue 没有那些复杂的栈管理(pushTarget/popTarget),仅仅靠这个时间差,也能保证普通事件里的读取不会产生错误的依赖收集。


问:不登记的意思是,不再接收新的依赖采集者是吗?

答:完全正确!你的理解一语中的!

“不登记”的意思正是:在这个时刻(初始化结束后,普通代码读取数据),感应片不再接收任何新的依赖订阅者(Watcher)加入它的 subs 登记册。

我们可以把响应式数据的 subs 登记册想象成一个VIP会员群

  • 初始化阶段(营业时间) :VIP群大门敞开。张三(渲染 Watcher)、王五(计算属性 Watcher)进来读取数据,保安给他们发 VIP卡,把他们加进群(subs.push)。这就是接收新的依赖收集者
  • 初始化结束后(下班时间) :VIP群大门关闭。李四(普通事件代码)来读取数据,他只是前台来办事的,保安拒绝给他发 VIP卡,不把他拉进群(因为 Dep.target 是 null,跳过了 depend)。这就是不再接收新的依赖收集者

为什么不再接收?因为没必要!

响应式系统的目的是:“数据变了,通知关心它的人”。李四只是在用户点击那一瞬间关心了一下数据(拿去打印),他并不打算在这个数据未来发生变化时,再重新执行一遍自己的代码。如果他不需要未来的通知,把他拉进 VIP群就是浪费内存,还会导致无意义的更新。

但是,有两点需要特别注意(补充完善你的理解):

1.       大门关闭,不是永远关闭 如果未来某个时刻,组件需要重新渲染,此时张三(渲染 Watcher)又要执行了,Dep.target 又会变成张三。大门会临时重新打开,允许张三再次核对一下自己的 VIP身份(Vue 内部有去重机制,不会重复拉人),确保自己还在群里。

2.       不接收新订阅者,不代表老订阅者退群 李四不被登记,只是他自己没进群。但之前已经进群的张三和王五,依然牢牢留在 subs 登记册里。未来任何时候 totalPrice 发生改变,触发 set 执行 dep.notify(),保安依然会在群里喊:“数据变了!”,张三和王五依然会收到通知并去更新页面或重新计算。

总结

你说的非常精准:不登记 = 不再接收新的依赖收集者。 Vue 通过 if(Dep.target) 这个门卫,严格把控了“入群资格”,确保只有真正需要长期追踪数据变化的 Watcher 才能被收录进 subs,而那些临时路过、只用一次数据的普通代码,则被挡在门外,保证了依赖系统的纯粹和高效!


问:初始化虽然结束了,但是get 一直都会触发的是吗?

答:

对!完全正确!你抓住了响应式系统最本质的运行规律!

初始化只是 Vue “贴感应片”(设置 Object.defineProperty)和“第一次点名登记”(收集依赖)的过程。但感应片一旦贴上,它就永久生效了,直到这个组件被销毁。

所以,get 和 set 这两个函数,就像是在数据属性上安装的常驻感应器

  • 不管是什么时候(初始化时、或者初始化完了5分钟之后、甚至1小时之后)。
  • 不管是什么代码(渲染 Watcher、普通的事件回调、甚至第三方插件)。
  • 只要代码执行到了 this.totalPrice,就一定会踩到这个感应器,100% 会触发 get 函数

同理,只要代码执行到了 this.totalPrice = 100,就一定会触发 set 函数。

触发 get 和 收集依赖 是两码事

这正是很多人容易混淆的地方。我们把这两件事拆开看:

1.       触发 get(踩感应器) :这是物理层面的,只要读取就必定触发,永远存在,没有时间限制

2.       收集依赖 dep.depend()(保安登记) :这是业务层面的,取决于进门前有没有挂名牌(Dep.target 是否为 null)。

看看你上下文里的源码:


get() {

    if(Dep.target) dep.depend(); *//* *这一句是有条件的!*

    return val;                  *//* *这一句是绝对执行的!*

}

整个生命周期的运行图景是这样的:

  • 初始化阶段(渲染) : 张三读数据 → 触发 get → Dep.target 是张三 → 保安登记张三 → 返回 val 给张三画页面。
  • 初始化结束后(用户点击按钮) : 李四读数据 → 触发 get → Dep.target 是 null → 保安无视李四,跳过登记 → 返回 val 给李四打印日志。
  • 未来的某次更新(数据变了,页面重新渲染) : 张三再次读数据 → 触发 get → Dep.target 又变成了张三 → 保安核对名单(其实不用重复登记,Vue有去重机制) → 返回新的 val 给张三重新画页面。

总结

初始化的结束,只是意味着**“第一轮依赖收集完成了,名牌摘掉了”**,绝不意味着感应器被拆除了。

Object.defineProperty 的劫持是永久存在的,get 无论何时何地都会被触发。只是因为初始化后 Dep.target 被清空成了 null,后续无数次的普通 get 触发,都变成了“只拿数据,不登记”的纯路过行为,从而保证了依赖收集不被污染!


问:所以登记发生在初始化的阶段,也就是页面渲染出来之前?

答:

对,绝大部分的“首次登记”发生在初始化阶段,也就是页面第一次渲染出来之前。

我们来把这个时间线卡得再精准一点。Vue 的初始化流程中,渲染出页面的那一步叫 mount(挂载)。

整个流程是这样的:

1.       各种初始化:处理 data、methods、computed 等(此时只是贴感应片,还没登记)。

2.       挂载阶段(mount):Vue 开始执行渲染函数,要把组件画到页面上。

3.       渲染 Watcher(张三)出场:张三挂上名牌 Dep.target = 张三,开始读取模板里需要的数据来生成 DOM。

4.       触发 get,完成登记:张三读到 totalPrice,触发 get,保安把张三登记进册子。

5.       页面渲染完毕:DOM 节点真正出现在屏幕上,用户看到了页面。张三摘掉名牌 Dep.target = null。

所以,用户看到页面的那一瞬间,依赖登记的工作已经全部做完了。 你的理解完全正确。


⚠️ 但是,有一个重要的细节补充:登记并不是“一锤子买卖”

虽然首次登记发生在页面出来之前,但 Vue 的响应式系统是活的。在页面渲染出来之后(初始化结束后),依然会发生新的登记。

这主要出现在以下两种场景:

场景一:条件渲染(v-if)带来的新依赖 假设你的模板是这样的:

html

VIP专属总价:{{ vipPrice }}
普通总价:{{ normalPrice }}
  • 初始化时,如果 isVIP 是 false,VIP 的那个 div 根本不会渲染,所以 vipPrice 没有被读取,也没有被登记。
  • 过了5分钟,用户点击了某个按钮,isVIP 变成了 true。
  • 此时,页面需要重新渲染 VIP 区域。渲染 Watcher(张三)再次出场,挂上名牌,去读取 vipPrice。
  • 就在这初始化结束之后的5分钟,保安把张三登记进了 vipPrice 的册子里! 这是一个全新的登记。

场景二:计算属性的懒计算 我们之前讨论过,如果一个计算属性(李四)在初始化时没有被页面用到,它就不会求值,不会登记。 如果后来某个交互导致模板里用到了李四,李四才被迫求值,这时候也会发生初始化之后的新登记。

总结

你的结论非常到位:核心的首次登记,确实发生在页面渲染出来之前。

但更严谨的说法是:只要有 Watcher(渲染或计算)因为任何原因重新求值,并且读取了之前没读过的新数据,就会在那个时刻发生新的登记,无论这个时刻是不是在初始化阶段。

这就是 Vue 响应式的动态之美——依赖关系是跟着代码的实际执行路径动态生长的!


如果不清空 Dep.target 会怎样?  假设在这之前,正好有一个计算属性(王五)在求值,把 Dep.target 设成了自己,但没来得及清空。此时 checkout 里的 this.totalPrice 触发了 get,系统一看 Dep.target 有值,就会把“王五”也强行塞进 totalPrice 的依赖册里!以后只要 totalPrice 变了,王五就会被通知更新,但这完全没必要,因为王五根本不依赖 totalPrice。

场景二:非 Watcher 的生命周期钩子


created() {

  *//* *只是拿初始值去发个请求,并不关心这个数据后续变化*

  this.fetchData(this.currentId);

}

在 created 里读取 this.currentId,也是典型的“李四看了一眼”。这只是普通的 JS 取值,根本不需要收集依赖。

场景三:Vue 的工具函数

Vue 内部也有大量普通的读取逻辑,比如:


*//* *判断两个 prop 是否相等*

if (newProp === oldProp) { ... }

这里读了一下 oldProp,也是“看了一眼”,不需要登记。


核心本质:为什么必须区分“张三”和“李四”?

响应式系统的设计初衷是:数据变了,用到这个数据的视图或计算才要更新

  • 张三(Watcher) :是真正需要长期关注数据的人。比如模板渲染、计算属性。他们出生时,会主动把自己的名片挂在 Dep.target 上,然后去读数据,告诉数据:“我依赖你,你变了必须通知我!”读完后,立刻摘掉名片(Dep.target = null)。
  • 李四(普通代码) :只是临时路过,用一下数据。比如事件回调、发请求。他们执行时,Dep.target 应该是空的(null)。他们读数据时,因为没名片,数据就不会理他们。

如果系统不通过 Dep.target = null 来清场,那就相当于:  不管是谁,不管他是长期订阅(张三),还是临时路过查个字典(李四),只要他碰了一下这本书,这本书就把他记成终身订阅者。结果就是,你的数据只要稍微一变,一大堆根本不需要更新的函数、组件都被强行唤醒,导致页面疯狂重渲染,性能直接崩溃。

所以,Dep.target = null 就是在声明: “现在不是收集依赖的专门时段,任何人来读数据,都只是普通路过,千万别把他们记进订阅名单!”

image.png


问:哪些情况会触发组件渲染?

答:

这是一个非常核心且实用的问题!理解了什么会触发渲染,你就能掌控 Vue 应用的性能。

在 Vue 2 中,触发组件重新渲染(让渲染 Watcher 张三重新执行)的情况,可以归纳为以下几大类:

1. 响应式数据的变化(最常见、最核心)

这是 Vue 响应式系统的本职工作。只要你在代码里修改了组件 data 或 props 里的响应式数据,触发了数据的 set 函数,就会派发更新 dep.notify(),通知张三重新画页面。

·                  直接赋值:this.count = 10

·                  数组变异方法:this.list.push('新项')、this.list.splice(0, 1)(Vue 重写了这些方法以便触发更新)

·                  对象新增属性:this.$set(this.user, 'age', 25)(直接 this.user.age = 25 是不触发更新的,因为新增属性没有感应片)

2. 计算属性依赖的数据变化

如果你在模板里用了一个计算属性:

html

{{ formattedPrice }}

虽然你没有直接修改 formattedPrice,但你修改了它内部依赖的 this.price:


this.price = 100;

price 变了,通知计算属性 Watcher(王五)重新计算。王五算出了新值,它再通知依赖它的渲染 Watcher(张三)重新渲染页面。

3. 强制手动触发(逃生舱)

当某些极其特殊的情况,你明明改了数据,但 Vue 没检测到(比如你用了非变异的数组方法,或者改了非响应式对象),你可以强行按刷新按钮:

·                  this.$forceUpdate() :强行让当前组件的渲染 Watcher 重新执行一次。这就像是对保安说:“不管登记册上的人有没有变,你现在立刻把张三叫来重画一遍!”(不到万不得已,不要用这个,说明你的响应式设计有漏洞)。

4. 父组件重新渲染(连带影响)

这是新手最容易踩坑导致性能浪费的地方! 当父组件重新渲染时,即使传给子组件的 props 一点都没变,子组件也会跟着重新渲染!

原因:Vue 在父组件重新渲染时,会重新解析模板,发现 ,就会再次创建子组件的 VNode(虚拟DOM)。这会导致子组件的渲染 Watcher 被触发。

案例


*<!--* *父组件模板 -->*

<div>

  <p>父组件计数:{{ parentCount }}</p>

  *<!--* *这个子组件没接收任何会变的 props -->*

  <static-child></static-child>

</div>

当你执行 this.parentCount++ 时,父组件重新渲染。虽然 static-child 跟 parentCount 毫无关系,但它也会被连带重新渲染一遍。

解决办法:给不想被连带渲染的子组件加上 v-once 指令,或者把它提取出来,通过 provide/inject 避开父组件的渲染范围,或者在子组件内部做好优化。


****一个常见的误区:watch 会触发渲染吗?

很多人以为写了一个 watch,当数据变了,watch 执行了,就会触发页面渲染。 错!watch 本身绝对不会触发渲染。

watch 只是帮你盯着数据,数据变了它执行一段逻辑(比如发个请求、打个日志)。页面是否渲染,只取决于模板(render函数)里是否读了这个数据。  如果模板没读这个数据,哪怕你写了 watch 且它疯狂执行,页面也不会重新渲染哪怕一丝一毫。


💡 ****一个极其重要的性能知识点:渲染 ≠ 更新 DOM

最后,必须要澄清一个概念:渲染 Watcher 重新执行(触发渲染),并不意味着浏览器里的真实 DOM 一定会被替换!

Vue 非常聪明,它内部有一层虚拟 DOM (VNode) Diff 算法作为缓冲:

1.                 渲染 Watcher 执行,生成一棵新的虚拟 DOM 树

2.                 Vue 把新树和旧虚拟 DOM 树进行对比(Diff)。

3.                 只有找到差异的地方,Vue 才会去操作真实 DOM,更新那一点点变化。

结论:即使父组件的重新渲染连带触发了子组件的渲染,只要子组件接收的 props 没变,子组件内部状态没变,Diff 算法对比后发现“一模一样”,Vue 就会放弃操作真实 DOM,什么都不改。这在大多数情况下保证了性能不会太差,但生成虚拟 DOM 和 Diff 对比的过程本身也是消耗 JS 计算性能的,所以还是要尽量减少不必要的渲染触发。


问:初始化包括几个生命周期钩子函数?

答:

在 Vue 中,当我们说“初始化阶段”(也就是页面渲染出来之前),主要涵盖了以下几个核心的生命周期钩子,它们按严格的顺序依次执行:

1. beforeCreate (刚怀孕,啥也没有)

·                  在干啥:Vue 刚开始创建组件实例,此时只初始化了最底层的空壳(如事件系统)。

·                  状态:data、methods、computed 全都还没挂载到 this 上。

·                  你能干啥:几乎啥也干不了,访问 this.xxx 都是 undefined。一般很少用这个钩子。

2. created (灵魂出生,但还没有肉体)

·                  在干啥:Vue 已经把 data、methods、computed、watch 等都初始化好了,挂到了 this 上。**感应片(defineReactive)已经贴完了!**数据已经变成响应式的了。

·                  状态:组件实例完全可用,但还没有 DOM 元素(this.$el 是 undefined)。

·                  你能干啥

o          最常用的初始化钩子!

o          发起异步请求(调接口) :因为可以访问 this,可以在拿到数据后赋值给 this.xxx。

o          初始化非响应式的全局变量

o          注意:此时渲染 Watcher 还没出生,模板还没被解析,所以依赖收集还没真正开始。

3. beforeMount (准备画图,还没往墙上贴)

·                  在干啥:Vue 已经把模板(template)编译成了虚拟 DOM(render 函数),正准备把它转化成真实的 DOM 挂载到页面上。

·                  状态:虚拟 DOM 已经生成,但页面上还看不到组件(this.$el 还是旧的或者不存在)。

·                  你能干啥:很少使用,可以在最后时刻修改数据,但这不会触发额外的渲染(因为马上就要渲染了)。

4. mounted (画完贴墙,页面可见!)

·                  在干啥:Vue 已经把虚拟 DOM 变成了真实的 DOM,并插入了页面文档中。渲染 Watcher(张三)此时才真正出场,执行了第一次渲染,完成了依赖收集!

·                  状态:组件彻底在页面上显示了,this.$el 可用。

·                  你能干啥

o          操作真实 DOM:比如初始化第三方 UI 库(如 Echarts、Swiper),因为它们需要真实的 DOM 节点才能渲染。

o          获取 DOM 尺寸/位置:因为此时元素已经在页面上,有真实的宽高了。

o          注意:如果你在 mounted 里修改了 data,会触发第二次渲染(修补更新)。


💡 ****关键知识点:created vs mounted

面试和实际开发中最常考的区别就是这俩:

对比维度createdmounted
是否有 DOM❌ 没有 (this.$el 为空)✅ 有 (this.$el 可用)
数据是否响应式✅ 已贴感应片✅ 已贴感应片
依赖是否收集❌ 还没渲染,没收集✅ 渲染完毕,已收集
请求接口推荐度⭐⭐⭐⭐⭐ (越早请求越好)⭐⭐⭐ (如果不需要操作DOM,不推荐在这请求)

最佳实践请求接口尽量放在 created 里!  因为网络请求是不依赖 DOM 的,早点发请求,数据早点回来,等 mounted 渲染时,可能数据已经拿到了,页面直接带数据渲染,用户看到的白屏时间更短!

生命周期流程图速记:

new Vue() ➡️ beforeCreate ➡️ 初始化数据/方法 ➡️ created ➡️ 编译模板 ➡️ beforeMount ➡️ 创建真实DOM并挂载 ➡️ mounted ➡️ (页面出来啦!) ➡️ ...后续更新/销毁...

所以,我们之前讨论的所有“首次依赖收集”的动作,都是在 beforeMount 到 mounted 这两步之间悄悄完成的!

 


未完待续---