Vue 2 双向绑定原理读源码笔记:一个非 this.$set 不可的 bug,逼我看懂了 Object.defineProperty、Observer、Dep 和 Watcher

formData 的值明明变了,输入框却死活不更新,这种 bug 特别适合把人一路逼到 Vue 2 的响应式底层。因为业务代码层面你能检查的都检查过了:取值没错、绑定没错、事件也没错,偏偏视图就是不动。

这次动态表单的问题就是这样。不同商品分类有不同的扩展属性,字段列表从接口下发,前端拿到配置后循环着往 formData 里初始化字段:

1fields.forEach(field => {
2  this.formData[field.key] = ''
3})

然后按配置渲染出一排输入框,v-model 绑到 formData[field.key] 上。结果是:输入框能渲染出来,打字却没反应。敲键盘,光标闪,字上不去,控制台里 formData 的值甚至真的变了——就是视图不动。

最后修它只改了一行,难的是弄明白为什么这一行有效:

1this.$set(this.formData, field.key, '')

好了。字能打了,视图跟着动了。但我盯着这行代码很不舒服:$set= 到底差在哪?为什么赋值这么天经地义的操作在 Vue 里会失灵?文档只告诉我"Vue 无法检测属性的添加",没告诉我为什么无法检测。周五晚上我把 Vue 的仓库 clone 下来,切到我们线上在用的 v2.6.10,对着 src/core/observer 目录读了一个周末。这篇是我的读源码笔记——顺着"这个 bug 为什么会发生"这条线,把响应式这套东西从头到尾捋一遍。

笔记一:一切的地基是 Object.defineProperty

observer 目录里所有的魔法,最后都落到一个原生 API 上。先脱离 Vue 做个最小实验:

1var obj = {}
2var value = 'Tom'
3
4Object.defineProperty(obj, 'name', {
5  get: function() {
6    console.log('get name')
7    return value
8  },
9  set: function(newValue) {
10    console.log('set name')
11    value = newValue
12  }
13})
14
15console.log(obj.name)
16obj.name = 'Jerry'

通过 getter 和 setter,可以拦截属性的读取和设置。你以为你在做一次平平无奇的赋值,实际上执行了一个函数——Vue 2 响应式的全部戏法,就建立在"读和写都会被我知道"这个能力上。

这里有个读源码之前我从没想过的点:Object.defineProperty逐个属性劫持的。Vue 初始化时要递归遍历 data 里的每一个属性,给每个都装上 getter/setter。属性层级越深、对象越大,初始化成本越高。所以 data 别一上来就塞一个几百个字段的大对象,尤其是那种从后端直接拿来当 state 用的庞大响应体;能不响应式的部分(比如纯展示、不会变的常量配置)可以 Object.freeze 掉,Vue 检测到冻结对象就不去劫持了,省一笔开销——这招我周一就用在了商品发布页那份巨大的分类配置上。

笔记二:五十行写一个能跑的假 Vue

源码里 defineReactive 这个函数名出现频率极高。剥掉细节,它做的事可以浓缩成这样:

1function defineReactive(obj, key, value) {
2  Object.defineProperty(obj, key, {
3    get: function() {
4      return value
5    },
6    set: function(newValue) {
7      if (newValue === value) return
8
9      value = newValue
10      updateView()
11    }
12  })
13}
14
15function observe(obj) {
16  Object.keys(obj).forEach(key => {
17    defineReactive(obj, key, obj[key])
18  })
19}

使用:

1var data = {
2  name: 'Tom'
3}
4
5observe(data)
6
7data.name = 'Jerry'

这个版本简化到近乎粗暴,但已经能说明骨架:数据变化时,setter 里可以通知视图更新。

对照真源码,observe 其实更讲究:它会给被观察的对象挂一个 __ob__ 属性,指向一个 Observer 实例,作为"这个对象已经被响应式处理过"的标记,避免重复劫持;Observerwalk 方法再对每个 key 调 defineReactive,遇到值还是对象就继续递归 observe。你在控制台打印任何 Vue 组件的 data,展开后看到的那个 __ob__,就是它。我以前一直当它是控制台的噪音,读完源码才知道那是响应式系统的"到此一游"。

笔记三:Dep 和 Watcher——谁在用我,我改了要通知谁

真实框架不能每次数据变化都更新整个页面。它需要知道哪些地方用到了这个数据。源码里这套账本由两个类记:

  • getter 负责收集依赖
  • setter 负责通知依赖更新
1class Dep {
2  constructor() {
3    this.subs = []
4  }
5
6  depend(watcher) {
7    this.subs.push(watcher)
8  }
9
10  notify() {
11    this.subs.forEach(watcher => watcher.update())
12  }
13}

Watcher 可以理解为一个观察者:

1class Watcher {
2  update() {
3    console.log('update view')
4  }
5}

把 Dep 和 defineReactive 串起来才完整。每个响应式属性身上挂一个自己的 Dep 实例,getter 里把「当前正在求值的 watcher」收进去,setter 里通知这个 Dep 下所有 watcher:

1function defineReactive(obj, key, value) {
2  const dep = new Dep()
3
4  Object.defineProperty(obj, key, {
5    get() {
6      if (Dep.target) {        // 当前有 watcher 在求值
7        dep.depend(Dep.target)
8      }
9      return value
10    },
11    set(newValue) {
12      if (newValue === value) return
13      value = newValue
14      dep.notify()             // 通知这个属性的所有依赖
15    }
16  })
17}

Dep.target 是个全局变量,指向「此刻正在读数据的那个 watcher」。Watcher 求值前把自己挂到 Dep.target,触发 getter 就被收集,求值完再置空。这就是依赖收集的关键机制:靠一次「主动读取」把数据和用它的地方关联起来。

1let uid = 0
2class Watcher {
3  constructor(vm, expOrFn, cb) {
4    this.vm = vm
5    this.getter = expOrFn
6    this.cb = cb
7    this.value = this.get()
8  }
9  get() {
10    Dep.target = this          // 把自己挂上去
11    const value = this.getter.call(this.vm, this.vm)
12    Dep.target = null          // 用完置空,避免误收集
13    return value
14  }
15  update() {
16    const oldValue = this.value
17    this.value = this.get()
18    this.cb.call(this.vm, this.value, oldValue)
19  }
20}

读到这一段时我在纸上画了张图才彻底想通:为什么"只是读一下数据"就能建立依赖关系?关键就在 Dep.target 这个全局指针——组件渲染本质上是一次求值,求值过程会把模板里用到的每个数据都"读"一遍,每次读都撞进对应属性的 getter,于是渲染 watcher 就被这些属性各自的 Dep 记了名。改数据时按名单通知,一个不多一个不少。手撸一遍这个迷你版,比背十遍"数据劫持加发布订阅"都管用。

顺带记一笔更新时机:更新是异步批量的。setter 通知 watcher 后,Vue 不会立刻同步更新 DOM,而是把 watcher 推进一个队列去重,在下一个 tick 统一刷新。所以你连续改十次同一个数据,视图也只更新一次。这也是 this.$nextTick 存在的原因:改完数据想拿到更新后的 DOM,得等下一个 tick。我之前在表单里"改完数据立刻量 DOM 高度拿到旧值"的困惑,读到 scheduler.js 这一段时一并解开了。

笔记四:回到案发现场——为什么新增属性不响应

铺垫完,我的 bug 终于能在源码层面讲清楚了。

1var vm = new Vue({
2  data: {
3    user: {
4      name: 'Tom'
5    }
6  }
7})
8
9vm.user.age = 18

age 是后面新增的。defineReactive 只在初始化时跑,它给 name 装了 getter/setter,但它不可能未卜先知地给一个还不存在的 age 装——**Object.defineProperty 只能拦截已经定义过的 key,对"往对象上新加一个 key"这个动作本身毫无感知。**所以 vm.user.age = 18 就是一次普通得不能再普通的赋值,没有 setter、没有 Dep、没有通知,视图自然纹丝不动。

我的动态表单 bug 一模一样:formData 在 data 里声明时是个空对象,接口配置回来后 this.formData[field.key] = '' 加的这些 key 全是"黑户",没一个被劫持过。输入框 v-model 改的是黑户的值,改了也没人知道。

Vue.set(组件里 this.$set 是它的别名)凭什么行?我专门去看了 src/core/observer/index.js 里的 set 函数,核心就三步:把新属性用 defineReactive 补办"户口"(装上 getter/setter),从目标对象的 __ob__.dep 上手动 notify() 一次,然后返回值。所以它不是什么魔法,就是把初始化时漏掉的劫持流程手动补跑了一遍,再补发一次更新通知。文档里那句"Vue 无法检测属性的添加",翻译成源码语言就是这么直白。

要批量加多个字段,别一个个 $set,那样性能差,直接整体替换一个新对象更干脆:

1this.formData = { ...this.formData, ...newFields }

整体赋值会触发 formData 这个属性自己的 setter,新对象会被重新 observe 一遍,所有字段都是响应式的。我的动态表单最后就是这么改的——比循环 $set 干净得多。

删属性同理,delete obj.key 不会触发更新,得用 Vue.deletethis.$delete,它内部也是删完手动 notify 一把。

笔记五:数组是另一套戏法

对象讲完了,数组是响应式系统里的"特区"。直接改数组索引:

1vm.list[0] = newValue

可能不会触发更新。直接改长度:

1vm.list.length = 0

也可能不响应。推荐:

1Vue.set(vm.list, 0, newValue)

或者使用会被 Vue 包装过的方法:

1vm.list.push(item)
2vm.list.splice(index, 1, newValue)

"包装过的方法"具体怎么回事,源码里 array.js 一共就几十行,是整个 observer 目录里最好读的文件。Vue 重写了数组原型上 7 个会改变原数组的方法:pushpopshiftunshiftsplicesortreverse。做法是造一个继承自 Array.prototype 的拦截原型,把这 7 个方法包一层,在原方法执行后手动触发 dep.notify(),再把响应式数组的原型指向这个拦截原型:

1const arrayProto = Array.prototype
2const arrayMethods = Object.create(arrayProto)
3
4;['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => {
5  const original = arrayProto[method]
6  Object.defineProperty(arrayMethods, method, {
7    value(...args) {
8      const result = original.apply(this, args)
9      // 对新增的元素做 observe,再通知更新
10      // ...这里拿到该数组的 __ob__.dep.notify()
11      return result
12    }
13  })
14})

arr[index] = xarr.length = 0 不在这 7 个方法里,自然拦不到。这就是为什么改索引、改长度不响应——不是 Vue 偷懒,是 defineProperty 给每个数组下标都装 getter/setter 既不现实也太贵(数组可能几万项,还会频繁增删),于是退而求其次只拦方法。splice 既能改索引又能改长度,所以官方推荐用它兜底。

读到这我顺手翻了下我们项目里的表格代码,果然有一处 this.tableData[index] = newRow 靠着"碰巧后面还有别的赋值触发了更新"苟活着——当场改成了 splice读源码最实在的回报,就是能认出项目里那些"碰巧能跑"的定时炸弹。

笔记六:把两个 API 的能力边界跑给自己看

"defineProperty 只能拦已有 key、Proxy 能拦全部"这句话,我在控制台跑了一段对照,比任何文章都直观。先看 defineProperty

1const obj = {};
2Object.defineProperty(obj, 'name', {
3  get() { console.log('get name'); return this._name; },
4  set(v) { console.log('set name =', v); this._name = v; },
5});
6
7obj.name = 'Jerry'; // 打印 set name = Jerry —— 已有 key,拦得到
8obj.age = 18;       // 什么都不打印 —— 新增 key,没装过 getter/setter,拦不到

obj.name = 'Jerry' 会打印 set name = Jerry,而 obj.age = 18 一声不响——因为 age 是后加的,初始化时没给它定义 setter。Vue 2 的"新增属性不响应"和"数组下标赋值不响应",本质都是这一条。

换成 Proxy,代理的是整个对象,新增 key、改数组下标都逃不掉:

1const arr = new Proxy(['a'], {
2  set(target, key, value) {
3    console.log('set', key, '=', value);
4    return Reflect.set(target, key, value);
5  },
6});
7
8arr[0] = 'x';   // 打印 set 0 = x   —— 下标赋值,Proxy 拦得到
9arr.push('b');  // 打印 set 1 = b, set length = 2 —— push 也是底层在写下标和 length

arr[0] = 'x' 打印 set 0 = x,连 push 内部对下标和 length 的写入都会逐条触发 set。官方早就预告过,下一个大版本的响应式会换成 Proxy 重写——真到那天,Vue.set/$set 这类补丁应该就能退休了,深层对象也可以做成"读到才递归"的惰性劫持,初始化更快。不过 Proxy 没法 polyfill、不兼容 IE11,而我们的中后台还背着 IE 的验收指标,所以短期内我跟 Vue 2 和 $set 还得继续处下去。看得懂新版本为什么好,和现在就能用上,是两回事。

笔记七:v-model 不是魔法

既然在读表单相关的源码,顺路把 v-model 也拆了。输入框:

1<input v-model="name">

可以理解为:

1<input :value="name" @input="name = $event.target.value">

所谓双向绑定,就是数据到视图(上一路的 Observer/Dep/Watcher),加上视图事件再改数据(一个平平无奇的事件监听)。两条单行道,不是一条双行道。

理解这一点后,自定义组件的 v-model 也好理解:

1<my-input :value="name" @input="name = $event" />

也就是说自定义组件想支持 v-model,默认得接收一个 value prop、并在变化时 $emit('input', 新值)

1export default {
2  props: ['value'],
3  methods: {
4    onChange(e) {
5      this.$emit('input', e.target.value)
6    }
7  }
8}

如果是 checkbox、单选这类语义,默认的 value/input 不合适,可以用 model 选项改掉:

1export default {
2  model: { prop: 'checked', event: 'change' },
3  props: ['checked'],
4}

注意 prop 是单向的,组件内部不能直接改 this.value,得通过 emit 让父级去改,再流回来。这也是 Vue「单向数据流」的体现,所谓双向只是「下行绑定 + 上行事件」的语法糖,本质上数据流向始终是清晰单向的。这点想明白了,很多「子组件改了 prop 报警告」的困惑也就解开了——正好商品发布页在封装一批表单组件,这段理解马上就要用上。

合上源码之后

周一站会我把这个 bug 的来龙去脉当成小分享讲了五分钟,从 formData[field.key] = '' 讲到 defineReactive__ob__。讲完实习生问:"所以以后动态加字段,要么 $set,要么整体换对象?"我说对,但更重要的是你现在知道为什么只有这两条路——因为劫持发生在初始化,黑户 key 没有 setter,而 $set 是补办户口,整体替换是重新落户。

把这个周末的笔记收拢成三句话:

Vue 2 响应式绕不开 Object.defineProperty、依赖收集和派发更新。getter 里 Dep.target 收集、setter 里 notify、Watcher 求值前后挂载和清空 target,这条链路能自己讲顺,才算真懂而不是背过。

新增属性、数组索引、Vue.set、v-model,这些"注意事项"没有一条是规则,全是 defineProperty 能力边界投下的影子。

项目里遇到"改了数据视图不更新",多数时候问题不在 Vue,而在数据压根没被做成响应式。以前这句话我是从文档里抄来的,现在能顺着 defineReactiveDepWatcher 这条链路自己讲一遍原因——同一句结论,从"文档说的"变成"我推导出来的",往后再遇到类似的怪现象,判断的起点也会不一样:先想数据有没有被劫持到,而不是先怀疑框架本身出了问题。