前言
我们用 Vue2 开发时可能经常会苦恼:为什么有时候明明 修改了对象上的某个属性值 却没有触发响应式更新,特别是当对象嵌套层级比较多时更加是一头雾水。当遇到这种情况的时候很可能会造成的一个现象就是 $set方法的滥用。现在,我们通过手写 Vue2 源码的方式来理解 Vue2 的响应式系统,从根本上解决这个问题。
正文
准备工作:声明 data 对象和渲染函数
js
//页面结构
<h1></h1>
// 初始化 data 对象
const data = {
message: 'Hello World!',
obj: {
foo: 'aaa',
bar: 'bbb'
},
num: 123,
list: ['A', 'B', { c: 'C' }]
}
//渲染函数
function render() {
document.querySelector('h1').textContent = JSON.stringify(data)
}
render() // 先渲染一遍
最简单的响应式
响应式的核心是通过 Object.defineProperty 方法重写 data 对象上属性的存取函数来监听属性的读写,并在读写时执行相应的逻辑(更新视图、收集依赖等...)
js
// 遍历 data 对象的所有属性,并重新定义属性的存值函数和取值函数
const keys = Object.keys(data)
for(let i = 0; i < keys.length; i++) {
const key = keys[i]
let val = data[key]
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
// 属性的取值函数
get() {
return val
},
// 属性的存值函数
set(newVal) {
if(val === newVal) return
val = newVal
// 当修改属性值时触发渲染函数
render()
}
})
}
思考:
- 因为只能对 data 对象上已有的属性进行重写,所以当新增或删除属性时无法通过
set
函数触发视图更新 - 只遍历了 data 对象上第一层属性,当修改嵌套对象的属性值时无法触发视图更新,如:
data.obj.foo = 'AAA'
就无法触发
通过递归的方式层层遍历改善响应式
js
// 递归的话就必须有函数,所以要对上面的逻辑进行封装
function observe(data) {
// 判断 data 为对象或数组
if(Object.prototype.toString.call(data) === '[object Object]' || Array.isArray(data)) {
const keys = Object.keys(data)
for(let i = 0; i < keys.length; i++) {
const key = keys[i]
let val = data[key]
// 递归调用observe
observe(val)
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
// val 变量被闭包保存了
return val
},
set(newVal) {
if(val === newVal) return
val = newVal
// 将得到的新值也要进行响应式监听
observe(val)
render()
}
})
}
}
}
思考 (重要):
- 这种方式无论嵌套对象的层级有多深,都能够将所有子对象的属性进行响应式监听,但是!!!当你通过
data.obj.xx = xx
这种方式为 data.obj 添加原本不存在的属性时,对这个xx
属性的读写是无法触发响应式的!!!!所以你应该先把数据处理好后再赋值给 data 上已存在的属性 例子:
js
const data = {
a: 'some value'
}
observe(data)
let obj = {
x: 'x',
outer: {
y: 'y',
inner: {
z: 'z'
}
}
}
data.a = obj // data.a.x、 data.outer.y、data.outer.inner.z 都能触发响应式
data.a = { food: 'pizza', drink: 'milk tea' } // data.a.food、data.a.drink 都能触发响应式
data.a.location = 'cinema' // 无法触发响应式,因为 data.a 上原本不存在 location属性
// 没有通过 Object.defineProperty 监听 location 的读写
- 这种递归监听的方式虽然无论属性值是对象还是数组都进行了观测,但假如数组中包含成千上万个元素,为每一个数组下标都添加
get
和set
方法对于性能来说是承担不起的,所以数组需要进行额外的响应式处理。 - 数组类型的数据通过
push、pop
等方法改变成员时无法触发响应式,所以数组需要额外的响应式监听处理
数组的响应式观测
为了更贴近 Vue2 的源码,我们需要对上面的逻辑进行进一步封装,并对数组的响应式监听做特殊处理
js
function observe(value) {
if (Object.prototype.toString.call(value) === '[object Object]' || Array.isArray(value)) {
// 引入 Observer 类来帮我们处理响应式
return new Observer(value)
}
}
// observer 类
class Observer {
constructor(value) {
// 先将 Observer 实例定义到 value 上,后面数组触发响应式时会用到
Object.defineProperty(value, '__ob__', {
value: this,
enumerable: false, // 设置属性不可遍历,这样 Object.keys、in 循环就读取不到该属性
configurable: true,
writable: true
})
if (Array.isArray(value)) {
// 通过替换原型链来改写数组的 push、pop 等方法
value.__proto__ = arrayMethods // arrayMethods 详细定义在下方代码中
this.observeArray(value)
} else {
const keys = Object.keys(value)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
defineReactive(value, key)
}
}
}
// 对数组的元素进行响应式监听
observeArray(value) {
for (let i = 0; i < value.length; i++) {
observe(value[i])
}
}
}
// 重写对象属性的存取函数
function defineReactive(obj, key) {
let val = obj[key]
observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
return val
},
set(newVal) {
if (val === newVal) return
val = newVal
observe(val)
render()
}
})
}
const arrayProto = Array.prototype
// 让 arrayMethods 继承数组的方法
const arrayMethods = Object.create(arrayProto)
// 要改写的方法列表
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
methodsToPatch.forEach(method => {
const origin = arrayProto[method]
arrayMethods[method] = function (...args) {
const ob = this.__ob__
const result = origin.apply(this, args)
// 被插入的数据
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break;
case 'splice':
inserted = args.slice(2)
break
}
// 为数组中所有新增的元素监听响应式
if (inserted) ob.observeArray(inserted)
// 触发响应式
render()
return result
}
})
思考 (重要)
- 现在通过调用数组方法
push
、pop
改变数组成员可以触发响应式,但是通过下标直接修改数组元素还是不能触发响应式,如:data.list[0] = 'abc'
- 假如数组元素是对象,对元素上原本不存在的属性值进行读写不能触发响应式。我们应该先将数组中的每个元素格式化成我们想要的数据结构后,再赋值给响应式对象上的属性。例如:
js
const data = {
list: []
}
observe(data)
data.list = [{id: 1, title: 'first'}, {id: 2, title: 'second'}]
data.list[0].id += 2 //可以触发响应式
data.list[1].content = '222' // 无法触发响应式
// 假如我们从接口获取到数组 list,并想要为 list 中每个元素添加上 disabled 属性值时
// 错误做法
const res = [{id: 1, title: 'first'}, {id: 2, title: 'second'}]
data.list = res
data.list.forEach(item => item.disabled = false) // 修改 disabled 无法触发响应式
// 正确做法
const res = [{id: 1, title: 'first'}, {id: 2, title: 'second'}]
res.forEach(item => item.disabled = false)
data.list = res // 修改 disabled 可以触发响应式
总结
我觉得对于我们这种 Vue2 框架使用者来说,最重要的就是理解它的响应式系统。如果把 Vue2 比作一辆汽车,那么响应式系统就是它的发动机,只要我们以正确的方式踩下油门(触发响应式),那么框架就会帮助我们做好一切工作(如更新视图等等...)。 如果能够理解以上所有的代码 (当然,源码中的响应式逻辑复杂得多,我这里保留了其中我觉得最核心的部分),在开发时通过正确的方式改写实例的 data 对象,基本也就不会再遇到修改数据无法触发响应式的问题了。