前提

在阅读本篇文章之前,你应该对 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)
  }
})

我们加上上述代码,是不是就能得到一些信息?

image-20250904132145755

但是这够吗?很明显不够啊,因为我们这里的调用是写死的,未来我们知道有多少函数会用到这个属性吗?不太可能的。而且将来每有一个项目,就写一个 defineProperty,这也太麻烦了。

试写方案

我们试着写一个通用 js 来解决上述问题,将其命名为 auv.js 吧,我们就不写 Vue 了,我们功力还没那么深厚。

上面我们增加的代码只观察了 name 属性,这也不太通用,项目中只观察一个属性的情况太少了,我们写一个观察全部属性的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
 * 观察某个对象的全部属性
 * @param obj
 */
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
/**
 * 观察某个对象的全部属性
 * @param obj
 */
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
// 将html内部js抽离为 demo1.js 并更新 demo1.js 如下
observer(goods) // 设置观察对象
window.__auv = showName // 设置全局变量收集对象
showName()
window.__auv = null

// 更新 auv.js 如下
/**
 * 观察某个对象的全部属性
 * @param obj
 */
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();
        }
      }
    })
  }
}

此时页面表现:

PixPin_2025-09-04_14-22-41

现在还有点不太完美,每次都要写下面三句:

1
2
3
window.__auv = showName
showName()
window.__auv = null

完全可以在 auv.js 中写成函数:

1
2
3
4
5
6
// auv.js
const easyRun = (fn) => {
  window.__auv = fn;
  fn();
  window.__auv = null;
}

那么 demo1.js 中那三句话就可以写成一句话:

1
easyRun(showName)

我们可以加一个 input 将输入的值去更改商品名字,方便展示效果:

1
<input type="text" oninput="goods.name = this.value"/>

页面效果:

PixPin_2025-09-04_14-31-03

当然我们现在的这份代码肯定有很多 bug,Vue 的代码比我们的这个版本健全得多。

至此,相信你已经对 Vue 的数据响应式有更深刻的认识。