对于VUE,最显著的特点之一就是其数据双向绑定而带来的奇妙开发体验。经由vue源码中的某些操作,使得工程师在项目开发过程中,无需操作Dom,逻辑层对数据的改变便会自动反馈在视图层;反过来,v-model的使用也会使得用户在视图层上的修改映射到真实数据上。
vue官方文档中有一目---”深入响应式原理“,专门阐述了这一特性的实现机制,然而篇幅有限,有些具体点的阐述对初学者来讲还是不是很友好。前一段时间自己专门去找了一些源码相关的内容去学习,详细了解了一下这一过程。此篇文章将会梳理总结一下自己的学习成果。完整代码见:https://github.com/cyanl77/mvvm
下面进入正题。(持续更新)
1 概述
1.1 数据变化监听
”深入响应式原理“第一小节叫做”如何追踪变化“,它想要探讨的问题和此部分一致,即javascript本身是如何监听到一个数据的变化的,了解这一点是理解”响应式“机制的第一步。
实现这一功能的是Object.defineProperty。该方法本身的目的在于定义或修改一个对象的现有属性,该方法第三个参数属性描述符可通过一对函数getter和setter来定义一个属性的存取特性,它们分别在该属性被读取或重新赋值的时候被调用。现在可明确,js即是通过定义待观测属性的getter和setter来达到监测其变化,进而响应变化的目的。
到此,可以写出如下实现响应式系统的雏形,假设我们要监测一个对象中属性,当其发生改变时,自动在控制台输出:
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get () {
//do something
return value;
},
set (newValue) {
if(value !== newValue) {
value = newValue;
//do something
}
}
})
1.2 vue中的数据依赖收集
vue构建的视图中,可能有多处依赖于data中的同一属性,当逻辑层的值发生变化,理应对视图层的每一个值进行相应的改变。上一小节的响应式雏形代码中只对在setter中承担了相应过程中的部分功能。而getter则完成了另一部分的功能,即收集视图层的数据依赖。这里的依赖准确来说应该叫做一个观察者,它负责监测一对相互关联的数据和引用该数据的视图,并维护着新数据渲染的方法。
vue响应式系统实现原理到此已经大致清晰:为data中所有属性绑定其存取属性getter和setter,其中,getter用来收集,setter用来更新。每当视图层对数据进行读取,则调用getter,将对应依赖收集起来;每当逻辑层改变该数据,则调用setter函数,依次更新收集到的所有依赖。
下面再重新审视官网文档上的这个原理图就要清楚的多,其中“touch”的过程就是渲染视图时读数据触发getter的过程,而“wathcer”就是上文说的观察者,它具体是怎样的实现将在之后的小节中进行具体说明。
2 关键数据结构
2.1 订阅者Dep
订阅者Dep本质是一个类,其功能简单说就是一个收集管理处。我们知道,对于vue组件实例data中的某一数据,可能被视图层多处依赖,每一处依赖,就有一个对应的观察者watcher来负责执行视图的变化更新。所以为了在数据变化时更新到所有的视图层数据,对于每一个数据,我们都需要维护这样一个数据结构Dep来收集所有引用该数据的watcher,以使得数据变化时,它能一一通知收集到的watcher去执行对应的更新函数。dep与watcher的关系如下图所示:
具体来说,订阅者对象实例承担了以下工作:
- 收集watcher。
- 存储watcher。
- 数据更新时,循环通知所有watcher更新对应视图。
这里值得提及一下Dep实例收集观察者的过程,源码中采取了巧妙的方式使得一个watcher一旦被实例化,便自己将自己加入对应的dep中。其具体过程如下:
1). Dep类自身定义了静态变量target,指向新new出的watcher。
2).watcher在构造函数中会为了保存当前值(以便待观察数据被赋予新值时进行比较)而读取数据。
3).触发该数据的getter,而每个数据的getter中会调用对应dep的收集函数将target所指向的watcher实例存储起来。
4).解除target指向直到有新的watcher被实例化出来。
基于以上所述,可封装如下订阅者对象:
let depId = 0;
class Dep {
constructor() {
this.id = depId++;
//存储watcher
this.subs = [];
}
//添加watcher
addSub(watcher) {
this.subs.push(watcher);
}
depend(){
Dep.target.addDep(this);
}
//数据变化,通知所有观察者更新对应视图
notify() {
this.subs.forEach(watcher =>{
//依赖更新视图
watcher.update();
})
}
}
Dep.target = null;
数据绑定存取属性的过程也进一步封装为一个函数,并补充完整其getter的内容,这里每个带观测数据和每个dep实例是一一对应的关系:
function defineReactive (obj,key,value) {
const dep = new Dep();
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get () {
if(Dep.target){
dep.depend();
}
return value;
},
set (newValue) {
if(value !== newValue) {
value = newValue;
dep.notify();
}
}
})
}
function observer(obj) {
Object.keys(obj).forEach((key) => {
defineReactive(obj,key,obj[key]);
if(typeof obj[key] === 'object') {
observer(obj[key]);
}
})
}
2.2 观察者watcher
观察者watcher,其本质为一个对象,我们在组件实例中定义的watch的成员就是在为数据绑定一个个的watcher,视图部分有可能是dom中一个元素属性或文本节点,不同形式的视图层其更新方式有所不同。在响应式的环节中,每个观察者存有对应视图的更新方法。
由上节我们知道,当一个数据在逻辑层发生改变,会首先通知给watcher的收集管理处Dep,在由Dep一一传达收集的watcher,此时每个watcher调用对应的更新方法去更新视图。具体来说,watcher在这一过程中做了以下工作:
- 首次实例化,将自己注册在订阅者Dep中。
- 解析待观察的表达式,在data中获取对应的新值,存储旧值。
- 比较新旧值,当新旧值不同,调用更新方法更新视图。
基于以上所述, watcher类定义的框架大致如下:
class Watcher {
constructor (vm,expOrFunc,cb) {
//vm vue实例
this.vm = vm;
// 被观察的属性变量名称
this.exp = expOrFunc;
this.getter = function(vm, exp){
return vm.$data[exp];
};
this.id = watcherId++;
//属性赋新值后调用回调
this.cb = cb;
this.deps = [];
this.value = this.get(); //获取老值
}
get(){
Dep.target = this;
let value = this.getter(this.vm,this.exp);
//配合getter中Dep.target非空判断防止相同watcher二次加入,读后需解绑
Dep.target = null;
return value;
}
//注册
addDep(dep){
if(this.deps.indexOf(dep.id) === -1){
this.deps.push(dep.id);
dep.addSub(this);
}
}
//对外暴露的方法
update(){
let value = this.get(); //新值
if(this.value !== value) {
const oldValue = this.value;
this.value = value;
this.cb.call(this.vm,value,oldValue);
}
}
}
2.3 执行
不考虑各种各样的边界情况,到这里我们关键数据结构已经构建完全,可以进行实例化并简单的模拟响应式数据。由于代码中未加入html模板编译的过程,这里仅用js定义watch的形式来产生一个watcher观察数据, 回调函数在控制台打印更新后的数据。具体代码如下:
class Vue {
constructor(options){
this._data = options.data;
observer(this._data);
if(options.watch) {
Object.keys(this._data).forEach((key)=>{
const watcher = new Watcher(this,key,options.watch[key]);
})
}
}
}
let o = new Vue({
data: {
a: 10,
b: 'hhhh'
},
watch: {
'a': function (newValue) {
console.log("update a:"+newValue)
},
'b': function (newValue) {
console.log("update b:"+newValue)
}
}
})
在vue实例构建的时候,会调用observer函数对data对象中的每个属性进行响应式化,即定义他们的getter和setter并初始化每个属性对应的dep实例。同时根据配置属性watch来生成一个针对属性a的watcher,每当这个数据发生变化时,将调用回调函数更新视图(这里只是控制台输出)。说明起见,每个响应式属性在setter中执行完属性收集,将打印一下对应的dep.subs。代码执行,控制台打印如下:
控制台输出两个watcher的数组,由于数据a,b各自仅拥有一个观察者watcher,因此每个数组长度均为1,id分别为0和1。属性deps解释一下,该属性维护了其已注册了的订阅者实例dep的id,一旦watcher的注册函数addDep被调用,其首先会从属性deps中查看其在这个dep中是否已被注册过,如果是,则不重新注册。
当改变某个响应式属性,会在赋值时在控制台打印新值:
在控制台改变一下数据a的值,触发了a所绑定的setter,从而让a的订阅者去
通知其subs中所有的watcher调用update方法去更新视图,最终调用了传给watcher的回调函数,在控制台打印“update a:70”。这里还会打印了dep.subs是因为在真正更新视图前,需要调用get函数去读取一下新值,所以又触发了一次setter。由于我们做了防止watcher重复注册的判断,故打印出的dep.subs中依然只有id为0的一个watcher。
(其实我也疑惑为什么不在setter中直接传值newValue不就无需触发getter了嘛,还有为什么watcher加入dep的行为不直接在dep中push了还兜那么大圈子... 也许是这样的写法解耦的比较彻底...)
然而,还有一个问题值得思考,vue中我们观察的很可能是个对象,比如a.name、a.name.first这样,当对象内部的值发生改变,视图依然可以发生改变。做到这样的深度观察,即需要为对象内部的值也定义好其setter及getter,实现方法不难,无非是递归,这里用Array的reduce方法来改变一下watcher中读取数据的方法getter:
this.getter = function(vm, exp){
let exprArr = exp.split('.');
let value = exprArr.reduce((prev,next) => {
return prev[next];
}, vm.$data)
return value;
};
将a的值改为一个数据再执行下上面的过程: