接下来重点来看Vue的数据响应系统。我看很多文章在讲数据响应的时候先用一个简单的例子介绍了数据双向绑定的思路,然后再看源码。这里也借鉴了这种方式,感觉这样的确更有利于理解。
数据双向绑定的思路
1. 对象
先来看元素是对象的情况。假设我们有一个对象和一个监测方法:
const data = {
a: 1
};
/**
* exp[String, Function]: 被观测的字段
* fn[Function]: 被观测对象改变后执行的方法
*/
function watch (exp, fn) {
}
我们可以调用watch方法,当a的值改变后打印一句话:
watch('a', () => {
console.log('a 改变了')
})
要实现这个功能,我们首先要能知道属性a被修改了。这时候就需要使用Object.defineProperty函数把属性a变成访问器属性:
Object.defineProperty(data, 'a', {
set () {
console.log('设置了 a')
},
get () {
console.log('读取了 a')
}
})
这样当我们修改a的值:data.a = 2时,就会打印出设置了 a, 当我们获取a的值时:data.a, 就会打印出读取了 a.
在属性的读取和设置中我们已经能够进行拦截并做一些操作了。可是在属性修改时我们并不想总打印设置了 a这句话,而是有一个监听方法watch,不同的属性有不同的操作,对同一个属性也可能监听多次。
这就需要一个容器,把对同一个属性的监听依赖收集起来,在属性改变时再取出依次触发。既然是在属性改变时触发依赖,我们就可以放在setter里面,在getter中收集依赖。这里我们先不考虑依赖被重复收集等一些情况
const dep = [];
Object.defineProperty(data, 'a', {
set () {
dep.forEach(fn => fn());
},
get () {
dep.push(fn);
}
})
我们定义了容器dep, 在读取a属性时触发get函数把依赖存入dep中;在设置a属性时触发set函数把容器内的依赖挨个执行。
那fn从何而来呢?再看一些我们的监测函数watch
watch('a', () => {
console.log('a 改变了')
})
该函数有两个参数,第一个是被观测的字段,第二个是被观测字段的值改变后需要触发的操作。其实第二个参数就是我们要收集的依赖fn。
const data = {
a: 1
};
const dep = [];
Object.defineProperty(data, 'a', {
set () {
dep.forEach(fn => fn());
},
get () {
// Target就是该变量的依赖函数
dep.push(Target);
}
})
let Target = null;
function watch (exp, fn) {
// 将fn赋值给Target
Target = fn;
// 读取属性,触发get函数,收集依赖
data[exp];
}
现在仅能够观测a一个属性,为了能够观测对象data上面的所有属性,我们将定义访问器属性的那段代码封装一下:
function walk () {
for (let key in data) {
const dep = [];
const val = data[key];
Object.defineProperty(data, key, {
set (newVal) {
if (newVal === val) return;
val = newVal;
dep.forEach(fn => fn());
},
get () {
// Target就是该变量的依赖函数
dep.push(Target);
return val;
}
})
}
}
用for循环遍历data上的所有属性,对每一个属性都用Object.defineProperty改为访问器属性。
现在监测data里面基本类型值的属性没问题了,如果data的属性值又是一个对象呢:
data: {
a: {
aa: 1
}
}
我们再来改一下我们的walk函数,当val还是一个对象时,递归调用walk:
function walk (data) {
for (let key in data) {
const dep = [];
const val = data[key];
// 如果val是对象,递归调用walk,将其属性转为访问器属性
if (Object.prototype.toString.call(val) === '[object Object]') {
walk(val);
}
Object.defineProperty(data, key, {
set (newVal) {
if (newVal === val) return;
val = newVal;
dep.forEach(fn => fn());
},
get () {
// Target就是该变量的依赖函数
dep.push(Target);
return val;
}
})
}
}
添加了一段判断逻辑,如果某个属性的属性值仍然是对象,就递归调用walk函数。
虽然经过上面的改造,data.a.aa是访问器属性了,但下面但代码仍然不能运行:
watch('a.aa', () => {
console.log('修改了 a.b')
})
这是为什么呢?再看我们的watch函数:
function watch (exp, fn) {
// 将fn赋值给Target
Target = fn;
// 读取属性,触发get函数,收集依赖
data[exp];
}
在读取属性的时候是data[exp],放到这里就是data[a.aa],这自然是不对的。正确的读取方式应该是data[a][aa]. 我们需要对watch函数做改造:
function watch (exp, fn) {
// 将fn赋值给Target
Target = fn;
let obj = data;
if (/\./.test(exp)) {
const path = exp.split('.');
path.forEach(p => obj = obj[p])
return;
}
data[exp];
}
这里增加了一个判断逻辑,当监测的字段中包含.时,就执行if语句块的内容。首先使用split函数将字符串转换为数组:a.aa => [a, aa]. 然后使用循环读取到嵌套的属性值,并且return结束。
Vue中提供了$watch实例方法来观测表达式,对复杂的表达式用函数取代:
// 函数
vm.$watch(
function () {
// 表达式 `this.a + this.b` 每次得出一个不同的结果时
// 处理函数都会被调用。
// 这就像监听一个未被定义的计算属性
return this.a + this.b
},
function (newVal, oldVal) {
// 做点什么
}
)
当第一个函数执行时,就会触发this.a、this.b的get拦截器,从而收集依赖。
我们的watch函数第一个参数是函数时watch函数要做些什么改变呢?要想能够收集依赖,就得读取属性触发get函数。当第一个参数是函数时怎么读取属性呢?函数内是有读取属性的,所以只要执行一下函数就行了。
function watch (exp, fn) {
// 将fn赋值给Target
Target = fn;
// 如果 exp 是函数,直接执行该函数
if (typeof exp === 'function') {
exp()
return
}
let obj = data;
if (/\./.test(exp)) {
const path = exp.split('.');
path.forEach(p => obj = obj[p])
return;
}
data[exp];
}
对象的处理暂且就到这里,具体的我们在源码中去看。
2. 数组
数组有几个变异方法会改变数组本身:push pop shift unshift splice sort reverse, 那怎么才能知道何时调用了这些变异方法呢?我们可以在保证原来方法功能不变的前提下对方法进行扩展。可是如何扩展呢?
数组实例的方法都来自于数组构造函数的原型, 数组实例的__proto__属性指向数组构造函数的原型,即:arr.__proto__ === Array.prototype, 我们可以定义一个对象,它的原型指向Array.prototype,然后在这个对象中重新定义与变异方法重名的函数,然后让实例的__proto__指向该对象,这样调用变异方法的时候,就会先调用重定义的方法。
const mutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
// 创建以Array.prototype为原型的对象
const arrayMethods = Object.create(Array.prototype);
// 缓存Array.prototype
const originMethods = Array.prototype;
mutationMethods.forEach(method => {
arrayMethods[method] = function (...args) {
// 调用原来的方法获取结果
const result = originMethods[method].apply(this, args);
console.log(`重定义了${method}方法`)
return result;
}
})
我们来测试一下:
const arr = [];
arr.__proto__ = arrayMethods;
arr.push(1);
可以看到在控制台打印出了重定义了push方法这句话。
先大概有个印象,接下来我们来看源码吧。
实例对象代理访问data
在initState方法中,有这样一段代码:
const opts = vm.$options
...
if (opts.data) {
initData(vm)
} else {
observe(vm._data = {}, true /* asRootData */)
}
opts就是vm.$options,如果opts.data存在,就执行initData方法,否则执行observe方法,并给vm._data赋值空对象。我们就从initData