响应式原理 —— 数据变了,视图怎么知道?
一个困惑先来思考一个看起来很简单的问题。下面这两行代码之间发生了什么letmessage你好// ... 某个时刻 ...message再见// ← 这一行执行之后// ↑// 如果页面上显示了 message// 框架是怎么知道它变了、并且更新页面的在纯 JavaScript 里给一个变量赋值不会有任何通知。没有事件触发没有回调调用什么都没有。变量就是悄悄地从你好变成了再见。但如果 Vue 能在我改数据的时候自动更新视图——它一定是监听了数据的变化。问题是JS 没有提供变量被修改时触发回调的功能。那 Vue 是怎么做到的一段生活比喻快递柜 vs 家门口想象两种收快递的方式。方式一快递柜快递员把包裹放进快递柜发个短信给你。你被动等待通知收到短信后去取。能被通知的前提是快递柜有你的手机号。方式二家门口快递员直接把包裹扔你门口。没人通知你你要主动去门口看看有没有新包裹。纯 JS 变量就像方式二——改了就是改了没有任何通知机制。Vue 做的事情就像给每个变量都装了一个快递柜系统——在变量被访问和修改的时候拦截下来执行额外的逻辑。JavaScript 的拦截能力Object.defineProperty —— Vue 2 的方案JavaScript 有一个不太常用但非常强大的 APIObject.defineProperty。它的作用是让你自定义读取属性和设置属性时发生什么。letobj{}// 定义一个内部变量来存储真实值letinternalValue你好Object.defineProperty(obj,message,{get(){console.log( 有人在读 message)returninternalValue},set(newValue){console.log(✏️ 有人在改 message从,internalValue,变成,newValue)internalValuenewValue// 这里就是通知视图更新的好时机}})obj.message// 控制台 有人在读 message 返回 你好obj.message再见// 控制台✏️ 有人在改 message从 你好 变成 再见看到魔法了吗obj.message 再见这行看起来普普通通的赋值语句实际上触发了一个函数调用。这就是 Vue 2 响应式系统的核心基石。Vue 遍历data里的每一个属性用Object.defineProperty把它们变成能被拦截的属性。Proxy —— Vue 3 的方案Vue 3 用了更现代的Proxyletobj{message:你好}letproxynewProxy(obj,{get(target,key){console.log( 有人在读,key)returntarget[key]},set(target,key,value){console.log(✏️ 有人在改,key,从,target[key],变成,value)target[key]valuereturntrue}})proxy.message// 有人在读 messageproxy.message再见// ✏️ 有人在改 message 从 你好 变成 再见Proxy 的优势我们会后续文章详细对比。现在先用Object.defineProperty来理解核心思想它更直观。动手实现一个迷你响应式系统版本 1能检测读写/** * 把普通对象变成响应式对象 * 遍历每个属性用 defineProperty 拦截 get/set */functiondefineReactive(obj,key){letvalueobj[key]Object.defineProperty(obj,key,{get(){console.log(get${key}:${value})returnvalue},set(newValue){if(newValuevalue)return// 值没变不用更新console.log(set${key}:${value}→${newValue})valuenewValue// TODO: 这里要通知视图更新}})}functionobserve(obj){if(typeofobj!object||objnull)returnObject.keys(obj).forEach(keydefineReactive(obj,key))}// ─── 测试 ───letdata{name:小明,age:18}observe(data)data.name// 控制台get name: 小明data.name小红// 控制台set name: 小明 → 小红data.age20// 控制台set age: 18 → 20现在data的每个属性都变成了可监听的属性。但还差关键一步set 的时候通知谁版本 2加上更新视图的回调假设视图是一个简单的渲染函数functionrender(){document.getElementById(app).textContentdata.name}每次data.name变化时我们希望自动调用render()。最朴素的想法在 set 里直接调用 render。functiondefineReactive(obj,key){letvalueobj[key]Object.defineProperty(obj,key,{get(){returnvalue},set(newValue){if(newValuevalue)returnvaluenewValuerender()// ← 简单粗暴}})}这就实现了一个最简单的响应式系统但问题是每个属性的 set 都会调用 render如果data有 100 个属性改任何一个都触发render太浪费了。render 函数从哪来硬编码在defineReactive里完全不灵活。render 函数里读了哪些属性如果 render 只用了name改age就不应该触发 render。我们需要一个更聪明的方案render或任何依赖数据的函数在执行的时候声明自己用了哪些数据。然后当那些数据变化时只通知声明了依赖的函数。核心设计依赖收集这引出了 Vue 响应式系统最精妙的设计——依赖收集。用一个比喻来理解想象一个图书馆 图书馆响应式数据 ├── 《name》 ← 每一本书是一个响应式属性 ├── 《age》 └── 《message》 读者们依赖数据的函数/组件 ├── 读者A读《name》和《age》 ← render 函数 A ├── 读者B只读《message》 ← computed B └── 读者C读《name》和《message》 ← watch C 借阅登记表每个属性维护的依赖列表 《name》 → [读者A, 读者C] ← name 变了要通知 A 和 C 《age》 → [读者A] ← age 变了只通知 A 《message》→ [读者B, 读者C] ← message 变了通知 B 和 C流程是这样的1. 借书时getter → 依赖收集读者 A 走进图书馆拿起《name》看。图书管理员在登记表上记录“《name》→ 读者 A 借阅过”。读者 A 又拿起《age》。管理员记录“《age》→ 读者 A 借阅过”。2. 修书时setter → 派发更新有人修改了《name》这本书的内容。管理员翻开登记表看到读者 A 和读者 C 都借阅过于是通知他们“你们之前看的那本《name》内容变了如果有需要可以来重新看一眼”。读者 B 呢他没借过《name》所以不被打扰。3. 核心洞察依赖收集的本质是在 getter 中记录谁在用我在 setter 中通知用我的人该更新了。代码实现Dep WatcherVue 用两个类来实现这个模式DepDependency依赖管理器每个响应式属性都有自己的 Dep负责维护一个谁依赖我的列表并在值变化时通知它们。Watcher观察者每个依赖数据的函数比如组件的 render、computed、watch都是一个 Watcher。Watcher 负责执行函数并在执行过程中把自己注册到用到的属性的 Dep 中。Dep依赖管理器classDep{constructor(){this.subs[]// subscribers — 订阅者列表每个订阅者都是一个 Watcher}// 添加订阅者depend(){if(Dep.target){// Dep.target 指向当前正在执行的 Watcherthis.subs.push(Dep.target)}}// 通知所有订阅者数据变了你们该更新了notify(){this.subs.forEach(watcherwatcher.update())}}// 全局的当前 Watcher标记// 类似于图书馆的当前在馆读者Dep.targetnullWatcher观察者classWatcher{constructor(getter,callback){this.gettergetter// 一个函数执行时会读取响应式数据this.callbackcallback// getter 返回值变了之后要调的回调this.valuethis.get()// 首次执行收集依赖}get(){// 把自己设为当前活跃的 WatcherDep.targetthis// 执行 getter过程中会触发响应式属性的 getter// 那些 getter 里会调用 dep.depend()把这个 Watcher 收集进去letvaluethis.getter()// 收集完毕清空标记Dep.targetnullreturnvalue}update(){// 被 Dep 通知你依赖的数据变了letnewValuethis.get()// 重新执行 getter获取新值this.callback(newValue)// 通知外部}}改进 defineReactive接入 DepfunctiondefineReactive(obj,key){letvalueobj[key]letdepnewDep()// ← 每个属性有自己的 DepObject.defineProperty(obj,key,{get(){// 如果有 Watcher 正在收集依赖把自己注册进去if(Dep.target){dep.depend()}returnvalue},set(newValue){if(newValuevalue)returnvaluenewValue// 通知所有依赖这个属性的 Watcherdep.notify()}})}现在完整流程走一遍// 1. 创建响应式数据letdata{name:小明,age:18}observe(data)// 2. 创建一个 Watcher它依赖 data.namenewWatcher(()data.name同学,// getter读了 data.name(newValue)console.log(视图更新:,newValue)// callback)// 执行过程// → new Watcher() 调用 this.get()// → 设置 Dep.target 这个 watcher// → 执行 getter: data.name ← 触发 name 的 getter// → name 的 getter 里 dep.depend()// → dep.subs.push(这个watcher) ← 依赖收集完毕// → Dep.target null// 3. 修改数据data.name小红// 执行过程// → name 的 setter 触发 dep.notify()// → dep.subs.forEach(w w.update())// → watcher.update() 重新执行 getter拿到新值 小红同学// → callback(小红同学) ← 视图更新画一张完整的图new Watcher(getter, callback) │ ▼ ┌─────────────────────┐ │ Dep.target this │ ← 现在是 Watcher#1 在执行 └──────────┬──────────┘ │ ▼ ┌─────────────────────┐ │ 执行 getter() │ │ getter 读取 data.name │ └──────────┬──────────┘ │ ▼ ┌─────────────────────┐ │ data.name 的 getter │ │ if (Dep.target) │ ← 有人正在收集依赖 │ dep.depend() │ ← 把 Watcher#1 加入 subs └──────────┬──────────┘ │ ▼ ┌─────────────────────┐ │ Dep.target null │ ← 收集完毕恢复 └─────────────────────┘ ══════════ 时间流逝 ══════════ data.name 新值 │ ▼ ┌─────────────────────┐ │ data.name 的 setter │ │ dep.notify() │ └──────────┬──────────┘ │ ▼ ┌─────────────────────┐ │ Watcher#1.update() │ │ 重新执行 getter │ │ 调用 callback │ └─────────────────────┘还有两个问题要解决问题 1嵌套对象letdata{user:{name:小明,age:18}}observe(data)只拦截了data.user但如果有人直接修改data.user.nameuser的 getter 会触发但name没有自己的 defineProperty。解决递归 observefunctiondefineReactive(obj,key){letvalueobj[key]letdepnewDep()observe(value)// ← 递归如果 value 是对象也给它的属性加上拦截Object.defineProperty(obj,key,{get(){if(Dep.target)dep.depend()returnvalue},set(newValue){if(newValuevalue)returnvaluenewValueobserve(newValue)// ← 新值也可能是对象也要递归dep.notify()}})}问题 2数组Object.defineProperty可以拦截arr[0] xxx但你很少那样改数组push、pop、splice等方法不会被set拦截Vue 2 的解决方案重写数组的 7 个变异方法constarrayProtoArray.prototypeconstarrayMethodsObject.create(arrayProto)constmethodsToPatch[push,pop,shift,unshift,splice,sort,reverse]methodsToPatch.forEach(method{arrayMethods[method]function(...args){// 先执行原始方法constresultarrayProto[method].apply(this,args)// 再通知依赖更新this.__ob__.dep.notify()returnresult}})Vue 3 的 Proxy 直接解决了这个问题——Proxy 可以拦截数组方法的调用。总结响应式系统的核心思想用一句话概括在 getter 中收集依赖谁在用我在 setter 中通知更新用我的人该更新了。实现它只需要三个类Observer遍历对象把属性变成响应式Dep管理依赖列表通知更新Watcher代表一个依赖数据的执行单元被通知后重新执行这篇文章我们实现了 Vue 响应式系统的骨架。下一讲我们会更深入地看 Watcher 的细节多个 Watcher 之间怎么协作嵌套的 Watcher 怎么处理Watcher 的更新怎么和视图关联