前提
在阅读本篇文章之前,你应该对 JS 的属性描述符有所了解,如果没有,欢迎你阅读我的这篇文章《JS 属性描述符》
场景
假设有以下的 HTML 代码描述一个页面:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| <!DOCTYPE html> <html lang="en"> <head> <title>Demo 1</title> <meta charset="UTF-8"> </head> <body> <div class="container"> <p id="name"></p> <p id="time"></p> </div> <script> const goods = { name: "苹果", price: 5.5, purchaseTime: "2025-01-01", }
const showName = () => { const nameDom = document.getElementById("name") nameDom.innerHTML = `商品名称:${goods.name}` } const showPurchaseTime = () => { const timeDom = document.getElementById("time") const now = new Date() const purchaseDate = new Date(goods.purchaseTime) const diffTime = Math.abs(now - purchaseDate) timeDom.innerHTML = `商品存放距今已有:${Math.floor(diffTime / (1000 * 60 * 60 * 24))}天` }
showName() showPurchaseTime() </script> </body> </html>
|
就这样一个简单的页面,将来要改动 goods 的信息的时候,比如更改商品名称,在调用两个函数的后面加上:
1 2 3
| showName() showPurchaseTime() goods.name = "梨"
|
很显然,页面数据是不会随之更新的,除非在 goos.name = "梨" 的下一行再次调用 showName() 函数。
换句话说,就是在更改了属性之后,我们应该调用依赖于该属性的函数。这样才能保证页面和数据是统一的。
有的朋友可能会说,那还不是你函数写的有问题,你写成下面这样不就好了?
1 2 3 4
| const showName = (name) => { const nameDom = document.getElementById("name") nameDom.innerHTML = `商品名称:${name}` }
|
这样不就可以在更改商品名字的时候更新页面了吗?
是的,确实可以。但是这会给你增加额外的心智负担。每有一次更新数据需求,你都要尝试找到这个对象的这个属性是否有对应的更新函数,这您受得了吗?
请大家忘记 Vue 的存在,让我们穿越回尤雨溪还没有写出 Vue 的时候,站在他的角度来想想,这个问题应该如何解决。
思考解决方案
回忆上述困境,它真的好解决吗?
即使我们能够想出一种办法,能够在程序中每一次更改属性的时候自动加上调用函数的代码,但是这样就够了吗?
很明显是不够的,因为就算我们把代码写完,属性依然可以被更改。用户还可以直接在 F12 的开发者工具中更改,这您受得了嘛?
这就是说,我们根本无法知道属性到底在哪里被更改了…… 吗?
回忆之前的属性描述符,get() 和 set() 是不是能够帮我们得到一些信息呢?
1 2 3 4 5 6 7 8 9 10 11 12
| let internalVal = goods.name Object.defineProperty(goods, "name", { get: () => { console.log("发生了获取商品名称行为,当前名称为:" + internalVal) return internalVal }, set: (val) => { internalVal = val showName() console.log("发生了修改商品名称的行为,新名称为:" + val) } })
|
我们加上上述代码,是不是就能得到一些信息?

但是这够吗?很明显不够啊,因为我们这里的调用是写死的,未来我们知道有多少函数会用到这个属性吗?不太可能的。而且将来每有一个项目,就写一个 defineProperty,这也太麻烦了。
试写方案
我们试着写一个通用 js 来解决上述问题,将其命名为 auv.js 吧,我们就不写 Vue 了,我们功力还没那么深厚。
上面我们增加的代码只观察了 name 属性,这也不太通用,项目中只观察一个属性的情况太少了,我们写一个观察全部属性的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
const observer = (obj) => { for (let key in obj) { let internalVal = obj[key]; Object.defineProperty(obj, key, { get() { return internalVal; }, set(val) { internalVal = val; } }) } }
|
问题就来了,写成通用的之后,我们根本不知道该如何触发依赖(某个使用到被关注的属性的函数)更新。前面好歹还能手写 showName() 触发更新,现在根本不知道。
现在我们就需要想想,是不是我们对依赖的来源根本不知道?本质就是:谁在用我们的属性?
但是这个问题正好 get() 能解决啊,只要有函数在用到我们关注的属性,那么 get() 就一定能知道谁在用。这就是说:get() 需要记录到底谁在用我;而 set() 需要运行那个用我的函数。
这个在 Vue 中有专门的名词:
- 依赖收集:记录哪个函数在用我
- 派发更新:运行用我的那个函数
我们更新一下 observer:
思考一下,为什么要用 new Set() 而不是普通数组?
点我查看原因
用 new Set() 而不是普通数组,相信你能想到是去重。还有一个原因就是,我们根本不知道依赖使用到了几次属性,如果使用了多次,那么普通数组就会记录多个同样的函数,这大大降低了效率。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
const observer = (obj) => { for (let key in obj) { let internalVal = obj[key]; let fns = new Set(); Object.defineProperty(obj, key, { get() { fns.add(exampleFn) return internalVal; }, set(val) { internalVal = val; for (let fn of fns) { fn(); } } }) } }
|
现在兜兜转转又回到了之前的问题,我们到底如何记录 exampleFn 呢?到底谁在用我的属性?
Vue 用了一个极其巧妙的方式解决了这个问题:定义一个全局变量,帮你运行想要运行的函数。
比如,在我们这个简单场景下,可以定义一个 window.__auv 来收集要运行的函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| observer(goods) window.__auv = showName showName() window.__auv = null
const observer = (obj) => { for (let key in obj) { let internalVal = obj[key]; let fns = new Set(); Object.defineProperty(obj, key, { get() { if (window.__auv) { fns.add(window.__auv) } return internalVal; }, set(val) { internalVal = val; for (let fn of fns) { fn(); } } }) } }
|
此时页面表现:

现在还有点不太完美,每次都要写下面三句:
1 2 3
| window.__auv = showName showName() window.__auv = null
|
完全可以在 auv.js 中写成函数:
1 2 3 4 5 6
| const easyRun = (fn) => { window.__auv = fn; fn(); window.__auv = null; }
|
那么 demo1.js 中那三句话就可以写成一句话:
我们可以加一个 input 将输入的值去更改商品名字,方便展示效果:
1
| <input type="text" oninput="goods.name = this.value"/>
|
页面效果:

当然我们现在的这份代码肯定有很多 bug,Vue 的代码比我们的这个版本健全得多。
至此,相信你已经对 Vue 的数据响应式有更深刻的认识。