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 实例,作为"这个对象已经被响应式处理过"的标记,避免重复劫持;Observer 的 walk 方法再对每个 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.delete 或 this.$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 个会改变原数组的方法:push、pop、shift、unshift、splice、sort、reverse。做法是造一个继承自 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] = x 和 arr.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,而在数据压根没被做成响应式。以前这句话我是从文档里抄来的,现在能顺着 defineReactive、Dep、Watcher 这条链路自己讲一遍原因——同一句结论,从"文档说的"变成"我推导出来的",往后再遇到类似的怪现象,判断的起点也会不一样:先想数据有没有被劫持到,而不是先怀疑框架本身出了问题。