撸一个vue的双向绑定
1、前言
说起双向绑定可能大家都会说:Vue内部通过Object.defineProperty方法属性拦截的方式,把data对象里每个数据的读写转化成getter/setter,当数据变化时通知视图更新。虽然一句话把大概原理概括了,但是其内部的实现方式还是值得深究的,本文就以从简入繁的形式给大家撸一遍,让大家了解双向绑定的技术细节。
2、来一个简单的版本
让我们的数据变得可观测,实现原理不难,利用Object.defineProperty重新定义对象的属性描述符。
/**
* 把一个对象的每一项都转化成可观测对象
* @param { Object } obj 对象
*/
function observable(obj) {
if (!obj || typeof obj !== 'object') {
return;
}
let keys = Object.keys(obj);
keys.forEach((key) => {
defineReactive(obj, key, obj[key])
})
return obj;
}
/**
* 使一个对象转化成可观测对象
* @param { Object } obj 对象
* @param { String } key 对象的key
* @param { Any } val 对象的某个key的值
*/
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
get() {
console.log(`${key}属性被读取了`);
return val;
},
set(newVal) {
console.log(`${key}属性被修改了`);
val = newVal;
}
})
}
let car = observable({
'brand': 'BMW',
'price': 3000
})
//测试
console.log(car.brand);
3、一步一步实现一个观察者模式的双向绑定
先给一张思维导向图吧(图盗的,链接:https://www.cnblogs.com/libin-1/p/6893712.html),本文章不涉及Compile部分。
这张图我就不解释,我们先跟着一步一步的把代码撸出来,再回头来看这张图,问题不大。
建议在读之前一定要了解观察者模式和发布订阅模式以及其区别,一篇简单的文章总结了一下两种模式的区别(链接:https://www.cnblogs.com/chenlei987/p/10504956.html),Vue的双向绑定使用的就是观察者模式,其中Dep对象就是观察者的目标对象,而Watcher就是观察者,然后等待Dep对象的通知更新的,其中update方法是由watcher自己管理的,并非如发布订阅模式由目标对象去管理,在观察者模式中,目标对象管理的订阅者列表应该是Watcher本身,而不是事件/订阅主题。
3.1、声明一个Vue类,并将data里面的数据代理到Vue实例上面。
var Vue = (function (){
class Vue{
constructor (options = {}){
//简化处理
this.$options = options;
let data = (this._data =
typeof this.$options.data == 'function' ?
this.$options.data()
:
this.$options.data);
Object.keys(data).forEach(key =>{ this._proxy(key) });
// 监听数据
//observe(data);
}
_proxy (key){
//用this这个对象去代理 this._data这个对象里面的key
Object.defineProperty(this, key, {
configurable: true,
enumerable: true,
set: (val) => {
this._data[key] = val
},
get: () =>{
return this._data[key]
}
})
}
}
return Vue;
}
let VM = new Vue({
data (){
return {
a: 1,
arr: [1,2,3,4,5,6]
}
},
});
//说明 _proxy代理成功了
console.log(VM.a);
VM.a = 2;
console.log(VM.a);
3.2、让data里面的数据变得可观测,开启observe之旅
注:下面我所说的"data里面"就是指vue实例的data属性。
上面代码Vue类的constructor里面我注释了一行代码,下面我取消注释,并且开始定义observe函数
// 监听数据
observe(data);
在定义observe方法之前,首先明白我们observe要做什么?
实参是data数据,我们要遍历整个data数据的key,为data数据的每一个key都用Object.defineProperty去重新定义它的 getter和setter函数,从而使其可观测。
class Observer{
constructor (value){
this.value = value;
this.walk(value);
}
// 遍历属性值并监听
walk(value) {
Object.keys(value).forEach(key => this.convert(key, value[key]));
}
// 执行监听的具体方法
convert(key, val) {
defineReactive(this.value, key, val);
}
}
function defineReactive(obj, key, val) {
const dep = new Dep();
// 给当前属性的值添加监听
let chlidOb = observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: () => {
//do something
// if (Dep.target) {
// dep.depend();
//}
return val;
},
set: newVal => {
if (val === newVal) return;
val = newVal;
//do something
// 对新值进行监听
//chlidOb = observe(newVal);
// 通知所有订阅者,数值被改变了
//dep.notify();
},
});
}
function observe(value) {
// 当值不存在,或者不是复杂数据类型时,不再需要继续深入监听
if (!value || typeof value !== 'object') {
return;
}
return new Observer(value);
}
看到在get和set函数里面的do something了吗,可以理解为在data里面的每个key的设置和获取都被我们截取到了,在每个key的设置和获取时我们可以干些事情了。比如更数据对应的DOM。
要做什么呢?
get函数: 从思维图图1可以看出需要把当前的Watcher添加进Dep对象,等待数据更新,调用回调。
set函数: 数据更新,Dep对象通知所有订阅的watcher更新,调用回调,更新视图。
3.3、Watcher
先声明一个Watcher类,用于添加进Dep对象并通知更新视图使用。
let uid = 0;
class Watcher {
constructor(vm, expOrFn, cb) {
// 设置id,用于区分新Watcher和只改变属性值后新产生的Watcher
this.id = uid++;
this.vm = vm; // 被订阅的数据一定来自于当前Vue实例
this.cb = cb; // 当数据更新时想要做的事情
this.expOrFn = expOrFn; // 被订阅的数据
this.val = this.get(); // 维护更新之前的数据
}
// 对外暴露的接口,用于在订阅的数据被更新时,由订阅者管理员(Dep)调用
update() {
this.run();
}
addDep(dep) {
// 如果在depIds的hash中没有当前的id,可以判断是新Watcher,因此可以添加到dep的数组中储存
// 此判断是避免同id的Watcher被多次储存
//这里要是不限制重复,你会发现在响应的过程中,Dep实例下的subs会成倍的增加watcher。多输入几个字浏览器就卡死了。
if (!dep.depIds.hasOwnProperty(this.id)) {
dep.addSubs(this);
dep.depIds[this.id] = dep;
}
}
run() {
const val = this.get();
if (val !== this.val) {
this.val = val;
this.cb.call(this.vm, val);
}
}
get() {
// 当前订阅者(Watcher)读取被订阅数据的最新更新后的值时,通知订阅者管理员收集当前订阅者
Dep.target = this;
//注意:在这里获取该属性 从而就触发了defineProperty的get方法,该watcher已经进入Dep的subs队列了
const val = this.vm._data[this.expOrFn];
//初始化执行一遍回调
this.cb.call(this.vm, val);
// 置空,用于下一个Watcher使用
Dep.target = null;
return val;
}
}
上面代码我们先从constructor看起,接受三个参数,vm当前的vue实例,expOrFn实例化时该watcher实例所 代表/处理 的"data里面"(‘data里面’上面有解释,这里提醒一下)的哪个值,cb,回调函数,也就是当数据更新后需要做什么(自然是更新DOM咯)。
然后在constructor里面还调用了 this.get()。详细看一下get函数的定义,两行代码需要注意:
// 当前订阅者(Watcher)读取被订阅数据的最新更新后的值时,通知订阅者管理员收集当前订阅者
Dep.target = this;
//注意:在这里获取该属性 从而就触发了defineProperty的get方法,该watcher已经进入Dep的subs队列了
const val = this.vm._data[this.expOrFn];
Dep.target = this;
确定了当前的活动的watcher实例,Dep.target
我们可以认为它是一个全局变量,用于存放当前活动的watcher实例。
const val = this.vm._data[this.expOrFn];
获取数据,这句话其实就已经触发了其自身的getter方法(这点要注意,不然你连流程都理解不通)。
进入了getter方法,也就把当前活动的实例的watcher添加进dep对象等待更新。
添加进Dep对象后,置空,用于下一个Watcher使用 Dep.target = null;
3.4、Dep
一直在说dep对象,我们一定要知道dep对象就是观察者模式里面的目标对象,用于存放watcher和负责通知更新的。
下面来定义一个Dep对象,放到class Watcher前面。 注意Dep的作用范围.
class Dep{
constructor (){
this.depIds = {}; // hash储存订阅者的id,避免重复的订阅者
//订阅者列表 watcher实例列表
this.subs = [];
}
depend (){
Dep.target.addDep(this);//相当于调用this.addSubs 将 watcher实例添加进订阅列表 等待通知更新
//本来按照我们的理解,在denpend里面是需要将watcher添加进 Dep对象, 等待通知更新的,所以应该调用 this.addSubs(Dep.target)
//但是由于需要解耦 所以 先调用 watcher的addDep 在addDep中调用Dep实例的addSubs
//简化理解就是 将 watcher实例添加进订阅列表 等待通知更新
}
addSubs (sub) {
//这里的sub肯定是watcher实例
this.subs.push(sub);
}
notify (){
//监听到值的变化,通知所有订阅者watcher更新
this.subs.forEach((sub) =>{
sub.update();
});
}
}
Dep.target = null;//存储当前活动的watcher
再改改defineReactive,把注释打开
function defineReactive(obj, key, val) {
const dep = new Dep();
// 给当前属性的值添加监听
let chlidOb = observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: () => {
// 如果Dep类存在target属性,将其添加到dep实例的subs数组中
// target指向一个Watcher实例,每个Watcher都是一个订阅者
// Watcher实例在实例化过程中,会读取data中的某个属性,从而触发当前get方法
if (Dep.target) {
dep.depend();
}
return val;
},
set: newVal => {
if (val === newVal) return;
val = newVal;
// 对新值进行监听
chlidOb = observe(newVal);
// 通知所有订阅者,数值被改变了
dep.notify();
},
});
}
然后起一个watcher来监听
3.5、让数据响应起来
先给Vue暴露一个方法 $watcher 可以调用实例化Watcher。
class Vue{
constructor (options = {}){
//简化处理
this.$options = options;
let data = (this._data =
typeof this.$options.data == 'function' ?
this.$options.data()
:
this.$options.data);
Object.keys(data).forEach(key =>{ this._proxy(key) });
// 监听数据
observe(data);
}
// 对外暴露调用订阅者的接口,内部主要在指令中使用订阅者
$watch(expOrFn, cb) {
//property需要监听的属性 cb在监听到更新后的回调
new Watcher(this, expOrFn, cb);
}
_proxy (key){
//用this这个对象去代理 this._data这个对象里面的key
Object.defineProperty(this, key, {
configurable: true,
enumerable: true,
set: (val) => {
this._data[key] = val
},
get: () =>{
return this._data[key]
}
})
}
}
3.6、测试: 声明一个实例
html部分
<h3>Vue双向绑定</h3>
<input type="text" id="input">
<p id="react"></p>
<h3>Vue数组双向绑定</h3>
<input type="text" id="arr-input">
<p id="arr-reat"></p>
let reactElement = document.querySelector("#react");
let input = document.getElementById('input');
input.addEventListener('keyup', function (e) {
VM.a = e.target.value;
});
VM.$watch('a', val => reactElement.innerHTML = val); //监听属性 a 当a发生改变时
//数组的响应并不能实现
let arrReactElement = document.querySelector("#arr-reat");
let arrInput = document.getElementById('arr-input');
arrInput.addEventListener('keyup', function (e) {
VM.arr.push(e.target.value);
console.log(VM.arr);
});
VM.$watch('arr', val => arrReactElement.innerHTML = val); //监听属性 a 当a并没有发生改变时
VM.$watch就可以实例化一个watcher,从而去劫持data里面某个属性的改变,在改变时调用回调函数。
数组的改变并没有实现。上面的代码见https://gitee.com/cchennlleii/MyTest/blob/master/vueReactive/vue-reactive%E7%AE%80%E5%8D%95%E5%AE%9E%E7%8E%B0.html
4、对数组的支持
在说这个之前我们先去看一看vue官网对于数组更新检测的说明,链接:https://cn.vuejs.org/v2/guide/list.html#数组更新检测
总的来说,对于数组支持更新的只是数组原型上的方法,对于vm.items[index] = newValue是不支持的。
其实Object.defineProperty对于数组都是不支持的,根据消息vue3.0用的proxy对于数组得到了完美的支持,但是兼容性不怎么样。
既然vue实现了对数组原型方法的支持,那么我们也来让我们的例子对数组方法也支持吧。
原理不难,vue对于所有的数组原型方法都写了一层hack,让其支持更新。那么下面我们就一步一步来实现。
4.1、准备一套数组原型方法的hack
/**
* Define a expOrFn.
*/
function def(obj, key, val, enumerable) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
});
}
//数组改变的监听
var arrayProto = Array.prototype;
var arrayMethods = Object.create(arrayProto);
var methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
];
/**
* Intercept mutating methods and emit events
*/
methodsToPatch.forEach(function (method) {
// cache original method
var original = arrayProto[method];
def(arrayMethods, method, function mutator() {
var args = [], len = arguments.length;
while (len--) args[len] = arguments[len];
var result = original.apply(this, args);
var ob = this.__ob__;
var inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args;
break
case 'splice':
inserted = args.slice(2);
break
}
if (inserted) { ob.observeArray(inserted); }
// notify change
ob.dep.notify(); //调用该数组下的 __ob__.dep 详细可见class Observer的constructor里的注释
return result
});
});
上面代码准备了一个arrayMethods的对象,它继承自Array.prototype,并且对methodsToPatch里面的方法进行了改写,后面我们会把arrayMethods这个对象挂到"data里面"每个数组下,让该数组调用数组原生方法,比如[].push其实调用的是arrayMethods里面被改写的方法,从而在该数组改变时获取到该数组的更新。
下面开始挂载arrayMethods对象,在挂载我之前我们看到有一个this.__ob__属性,这里的this指向要观测的数组。这个__ob__就是前面的observe对象,并且每个observe下面还有一个dep对象。下面我们来理清楚这层关系。
class Observer{
constructor (value){
this.value = value;
//下面两行代码虽然很简单,但是我们需要从这里理清楚关系
//假如 有数据如 {a: [1,2,3], b: 1}, 然后调用oberve(vm.a),vm当前vue实例
//会自动挂载 __ob__ 和 __ob__.dep
// 那么对数组a进行oberserve的对象就是a.__ob__, 它所对应的dep对象就是 a.__ob__.dep
//详细使用可以在对数组的方法进行hack的时候 使用到
def(value, '__ob__', this);//让被监听的数据都带上一个不可枚举的属性 __ob__ 代表observe对象
this.dep = new Dep();//首先每个oberserve实例下有一个dep对象
//在这里处理数组
if (Array.isArray(value)){
//调用数组的hack方法, 让数组也能被监听 arrayMethods
var arrayKeys = Object.getOwnPropertyNames(arrayMethods);
for (var i = 0, l = arrayKeys.length; i < l; i++) {
var key = arrayKeys[i];
def(value, key, arrayMethods[key]);
}
}
else{
//对象 遍历key 添加监听
this.walk(value);
}
}
//Observer的其他方法
//...
}
上面代码首先给每个值挂载__ob__属性(不可枚举),然后给每个Obeserve对象挂载Dep对象。然后根据value的类型,如果是数组就会挂载arrayMethods方法。
现在我们来理清数组在哪里依赖收集,在哪里通知更新的。
在对数组hack的方法里面(上上一段代码)有一段ob.dep.notify(); 这里通知更新,所以依赖收集也一定要收集到value.ob.dep对象里面,两个dep对象应该是相同的,下面我们来看看依赖收集写在哪里的。
function defineReactive(obj, key, val) {
const dep = new Dep();
// 给当前属性的值添加监听
let childOb = observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: () => {
// 如果Dep类存在target属性,将其添加到dep实例的subs数组中
// target指向一个Watcher实例,每个Watcher都是一个订阅者
// Watcher实例在实例化过程中,会读取data中的某个属性,从而触发当前get方法
if (Dep.target) {
dep.depend();
if (childOb) {
childOb.dep.depend();
if (Array.isArray(val)) {
dependArray(val);
}
}
}
return val;
},
set: newVal => {
if (val === newVal) return;
val = newVal;
// 对新值进行监听
childOb = observe(newVal);
// 通知所有订阅者,数值被改变了
dep.notify();
},
});
}
function dependArray(value) {
for (var e = (void 0), i = 0, l = value.length; i < l; i++) {
e = value[i];
e && e.__ob__ && e.__ob__.dep.depend();
if (Array.isArray(e)) {
dependArray(e);
}
}
}
数组虽然在Object.defineProperty里面set方法无法响应,但是get方法是没有问题的,所以在数组get的时候,判断val如果是array,会调用value.ob.dep.depend进行依赖收集。与上面依赖通知使用了同意个dep对象,也就是挂载在自身的__ob__.dep。
写到这里我们就完全实现对数组原生方法的支持了。
下面看一下效果 代码地址:https://gitee.com/cchennlleii/MyTest/blob/master/vueReactive/vue-reactive对数组的支持.html
4.2测试代码
html部分
<h3>Vue双向绑定</h3>
<input type="text" id="input">
<p id="react"></p>
<h3>Vue数组双向绑定</h3>
<input type="text" id="arr-input">
<p id="arr-reat"></p>
<h3>Vue对nextTick实现</h3>
<button id="addBtn">加100000次</button>
<p id="react-tick"></p>
let reactElement = document.querySelector("#react");
let input = document.getElementById('input');
input.addEventListener('keyup', function (e) {
VM.a = e.target.value;
});
VM.$watch('a', val => reactElement.innerHTML = val); //监听属性 a 当a发生改变时
//数组的响应并能实现
let arrReactElement = document.querySelector("#arr-reat");
let arrInput = document.getElementById('arr-input');
arrInput.addEventListener('keyup', function (e) {
VM.arr.push(e.target.value);
console.log(VM.arr);
});
VM.$watch('arr', val => arrReactElement.innerHTML = val); //监听属性 a 当a发生改变时
let reactTick = document.querySelector("#react-tick");
VM.$watch('tickData', val => {
console.log(val);
reactTick.innerHTML = val;
}); //监听属性 a 当a发生改变时
document.querySelector('#addBtn').addEventListener('click', function () {
for (let i = 0; i < 100000; i++) {
VM.tickData = i;
}
}, false)
效果:
5、对nextTick的支持
vue官网对nextTick的解释:
nextTick如果自己实现就是在下一个envet loop执行,不在本次同步任务中执行。
自己实现一个简单的:
//nextTick的实现
let callbacks = [];
let pending = false;
function nextTick(cb) {
callbacks.push(cb);
if (!pending) {
pending = true;
setTimeout(flushCallbacks, 0);
}
}
function flushCallbacks() {
pending = false;
const copies = callbacks.slice(0);
callbacks.length = 0;
for (let i = 0; i < copies.length; i++) {
copies[i]();
}
}
简单理解: 在本次event loop中收集cb(任务),放到下一个event loop去执行。 关于不知道event loop的可以参考这篇文章:https://www.cnblogs.com/chenlei987/p/10479433.html,我总结的很简单。我参考的http://www.ruanyifeng.com/blog/2014/10/event-loop.html。
在理解event loop的同时也需要同时了解 microtask和macrotask的区别。
好了言归正传,在vue的'data里面'某个属性发生了改变,并被观测到后,调用了watcher.update,并不会立即调用watcher.run去更新视图,它会经过nextTick之后再更新视图,说起来有点牵强。
还是第四部=步的代码,没有实现对nextTick的优化。
代码:
<h3>Vue双向绑定</h3>
<input type="text" id="input">
<p id="react"></p>
<h3>Vue数组双向绑定</h3>
<input type="text" id="arr-input">
<p id="arr-reat"></p>
<h3>Vue对nextTick实现</h3>
<button id="addBtn">加1000次</button>
<p id="react-tick"></p>
let reactTick = document.querySelector("#react-tick");
VM.$watch('tickData', val => {
console.log(val);
reactTick.innerHTML = val;
}); //监听属性 a 当a发生改变时
document.querySelector('#addBtn').addEventListener('click', function () {
for (let i = 0; i < 1000; i++) {
VM.tickData = i;
}
}, false)
效果是这样的:
现在的效果是VM.tickData加1000次,那么cb(回调)就会调用1000次,这样是非常影响性能的,我们想要的效果是无论VM.tickData在本次event loop加多少次,都不会触发回调,只需要在VM.tickData加完之后,触发一次最终的cb(回调)就ok了。
下面我们就来实现这种优化,代码不多。
//nextTick的实现
let callbacks = [];
let pending = false;
function nextTick(cb) {
callbacks.push(cb);
if (!pending) {
pending = true;
setTimeout(flushCallbacks, 0);
}
}
function flushCallbacks() {
pending = false;
const copies = callbacks.slice(0);
callbacks.length = 0;
for (let i = 0; i < copies.length; i++) {
copies[i]();
}
}
let has = {};
let queue = [];
let waiting = false;
function queueWatcher(watcher) {
const id = watcher.id;
if (has[id] == null) {
has[id] = true;
queue.push(watcher);
if (!waiting) {
waiting = true;
nextTick(flushSchedulerQueue);
}
}
}
function flushSchedulerQueue() {
let watcher, id;
for (index = 0; index < queue.length; index++) {
watcher = queue[index];
id = watcher.id;
has[id] = null;
watcher.run();
}
waiting = false;
}
然后更改Watcher里面的update方法,并不直接调用watcher.run,而是经过queueWatcher控制
update() {
queueWatcher(this);
// this.run();
}
代码地址:https://gitee.com/cchennlleii/MyTest/blob/master/vueReactive/vue-reactive对nextTck的支持.html
6、总结
如果面试官问我关于双向绑定的问题,从这三个方面去回答,Object.definproperty,观察者模式,nextTick,当然,你需要把这三个点联系起来去描述,相信我你把上面的看懂了,联系起来完全没问题的,你是最棒的!
7、本文参考:
https://codepen.io/xiaomuzhu/pen/jxBRgj/
https://www.jianshu.com/p/2df6dcddb0d7