用 Vue 一段时间了,一直有个问题没搞清楚:为什么改 this.someObj.newProp = 'xxx' 不触发视图更新,但改 this.someArr.push() 就会更新?为什么要用 this.$set?
带着这些问题去翻了 Vue 2 的源码,弄懂了响应式系统的基本原理,写篇记录。
defineProperty 劫持
Vue 2 响应式的核心是 Object.defineProperty,它可以重定义对象某个属性的 getter 和 setter:
1 | let value = 'hello' |
Vue 在初始化 data 的时候,会对 data 里每个属性都做这个操作,把 get/set 替换成自己的版本。
Dep 和 Watcher:依赖收集
光有 setter 知道数据变了还不够,还要知道”哪些视图依赖了这个数据”,这样才能精确通知到位。
Vue 里用 Dep(依赖管理器) 和 Watcher(观察者) 来实现:
- 每个响应式属性有一个对应的
Dep实例,维护一个 Watcher 列表 - 每个组件有一个
Watcher,负责执行 render 函数并在执行过程中”订阅”用到的数据
依赖收集发生在 getter 里:
1 | // 伪代码,简化版 |
当组件渲染时,Vue 先设置 Dep.target = 当前组件的Watcher,然后执行 render 函数。render 函数读取 this.xxx 触发 getter,getter 里 dep.depend() 把这个 Watcher 记进去。render 执行完,Dep.target = null。
之后 this.xxx = newVal 触发 setter,setter 调 dep.notify(),通知所有订阅了这个 dep 的 Watcher 去重新 render。
为什么新增属性不响应
现在问题就很清楚了。Object.defineProperty 是针对已存在的属性做拦截。初始化时 Vue 遍历 data 里的每个属性,对每个属性调用 defineReactive。
如果你在初始化之后往对象上加新属性:
1 | // data() { return { user: { name: '张三' } } } |
Vue 根本不知道 age 被赋值了,自然不会更新视图。
this.$set(this.user, 'age', 25) 的源码大概是这样:
1 | function set(target, key, val) { |
$set 本质上就是:补调 defineReactive,再手动触发一次更新通知。
数组为什么可以
数组也是对象,理论上也可以对每个 index 做 defineProperty,但 Vue 没这么做——主要原因是性能,数组可能有几千个元素,每个都 defineProperty 太重了。
Vue 的做法是拦截数组的变异方法(mutating methods):
1 | const arrayProto = Array.prototype |
所以 this.list.push(item) 能触发更新——push 方法被替换成了包含 dep.notify() 的版本。
但 this.list[2] = newItem 不能触发——直接下标赋值,绕过了拦截的方法,Vue 检测不到。同理 this.list.length = 0 也不行,要用 this.list.splice(0) 才行。
Vue 3 为什么换成 Proxy
Object.defineProperty 的核心局限:
- 只能拦截已有属性,新增/删除属性检测不到
- 数组下标赋值检测不到
- 对嵌套对象要递归地全部 defineProperty,初始化时有开销
Proxy 是在对象层面做拦截,而不是属性层面:
1 | const handler = { |
新增属性、删除属性、数组下标赋值,都能被 set/deleteProperty 拦截到,Vue 2 那些限制全部消失了。另外 Vue 3 是懒代理(访问到嵌套对象时才 Proxy),而 Vue 2 是初始化就递归处理所有嵌套,所以 Vue 3 初始化更快。
理解了这些之后,Vue 的很多”奇怪行为”就都说得通了。
评论加载中…