前置知识
1. Object.defineProperty,可以看这篇文章
2. 观察者模式,可以看我的笔记
双向绑定
双向绑定,一方面是指视图改变数据,比如 input 中输入内容,data 中的数据同步变化,这个简单,可以通过监听 input 事件实现
另一方面是指数据改变视图,比如还是以刚刚例子来说,data 中的数据改变,页面上 input 的内容就要同步变化,这个比较复杂,下面看看怎么实现吧
实现概览
实现双向绑定,要做以下几件事
- Observer:劫持属性的 getter 和 setter,当数据变动时,通知 Watcher 执行视图更新。
- proxy:代理属性,使得不需要通过 this.data 读取,而是直接通过 this 读取
- Dep:观察者系统,用于订阅与通知执行 Watcher
- Watcher:Watcher 包含一个更新视图的 Update 方法,当 Observer 监听到数据改变时就会通知 Watcher 执行 update 方法更新视图
- Compile:扫描模版,初始化视图,为绑定 v-model 的元素监听 input 事件,为双向绑定的属性订阅 Watcher
整体流程大概是:Observer 用于劫持属性的 getter 和 setter,Compile 会从模版中找出双向绑定的属性,为属性实例化 Watcher,Watcher 会触发该属性的 getter,从而让属性使用 Dep 将自己收集起来,当属性的值改变时,会触发setter,通知收集好的 watcher 执行 update 方法更新视图,有点绕先大概了解流程,再往下看代码会更好理解。
从Vue构造函数开始
网上很多文章都是一步步实现上面说到的方法或者类的,也就是从分散到整体的,要看到最后才能把整个流程串通起来,我从Vue构造函数开始,从整体拆开来分析,应该会更好理解
首先实现下Vue构造函数,看看实例化Vue时要做哪些事情
function Vue(options) {
// vue的data是一个工厂函数,需要执行下取得其返回的对象
this.data = options.data();
// 挂载的dom节点
const dom = document.querySelector(options.el);
// 代理属性,使得可以直接通过this访问this.data中的属性
for (let key of Object.keys(this.data)) {
this.proxy(key);
}
// 劫持data中的属性,订阅watcher,以及监听到数据变化时通知订阅者更新
observe(this.data); // 解析dom,初始化视图,为双向绑定的属性监听input,并且生成watcher实例
new Compile(dom, this); // 找出双向绑定的属性做处理
}
proxy实现
首先要看的是 proxy 这部分,平时在写 vue 时,我们直接使用 this 就能读取到 data 中的数据,而不需要通过 this.data 去读取,为什么可以这样呢?其实是因为 Vue 给 data 中的属性做了代理
循环data中每个属性,调用proxy方法
for (let key of Object.keys(this.data)) {
this.proxy(key);
}
proxy 方法通过 defineProperty 劫持 this 的 getter 和 setter,使得读取 this[key] 时,实际上会访问 this.data[key],同理,设置 this[key] 时,实际上会设置 this.data[key]
// 在Vue原型上定义proxy方法
Vue.prototype.proxy = function (key){
Object.defineProperty(this, key, {
configurable: false,
enumerable: true,
// 读取this[key]时,实际上会读取 this.data[key]
get () {
return this.data[key];
},
// 设置this[key]时,实际上会设置 this.data[key]
set (newVal) {
this.data[key] = newVal;
}
});
}
观察者系统Dep
订阅器其实是观察者系统,包含两个方法
1. addSub:添加订阅者
2. notify:通知订阅者执行 update 方法更新视图
function Dep() {
this.subs = [];
}
Dep.prototype = {
addSub: function (sub) {
this.subs.push(sub);
},
notify: function () {
this.subs.forEach(function (sub) {
sub.update(); // 执行watcher的更新视图的方法。
});
}
}
Watcher构造函数
下面再实现下 Watcher 类,包含三个参数
- vm:传入vue实例,也就是this
- exp:属性的key
- cb:操作dom,更新视图的回调函数
每个双向绑定的属性(也就是v-model或者双花括号中的属性)都会创建一个 Watcher 实例,Watcher 的作用是为属性提供一个 update 方法,用于更新视图
function Watcher(vm, exp, cb) {
this.cb = cb; // 一个更新视图的方法
this.vm = vm;
this.exp = exp;
// 绑定自己到 Dep.target
Dep.target = this;
// 此处读取一下自己的值,从而触发 getter,订阅自己(Dep.target)
this.value = this.vm[this.exp];
// 释放Dep.target
Dep.target = null;
}
Watcher.prototype = {
update () {
let newValue = this.vm[this.exp];
let oldValue = this.value;
if (newValue !== oldValue) {
this.cb.call(this.vm, newValue, oldValue)
}
}
}
看看上面代码的注释,这里会先把 this 绑定到 Dep.target,也就是把 watcher 绑定到 Dep.target 上了,然后再读取一下属性,目的是触发该属性的 getter,在 getter 中会将 Dep.target 收集起来,由于 Dep.target 其实就是 watcher ,所以这里其实是把 watcher 手机起来了,具体看下下面劫持 data 中属性的 observe 方法
observe实现
刚刚已经实现了 Dep 和 Watcher,现在可以在 observe 中使用了,observe 的目的劫持属性gettter 和 setter ,在 getter 中收集 watcher ,当数据发生改变时,在 setter 中通知 watcher 更新视图
function observe(data) {
if (typeof data !== 'object') return;
for (let key of Object.keys(data)) {
defineReactive(data, key, data[key]);
}
}
function defineReactive(data, key, val) {
observe(val);
let dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
/*
在getter中订阅watcher,在实例化该属性的watcher时,
会把watcher绑定到Dep的静态属性target上,然后读取一下该属性,
从而进入getter这里执行这个订阅操作。
*/
if (Dep.target) {
dep.addSub(Dep.target);
}
return val;
},
set(newval) {
val = newval;
// 触发观察者,从而执行watcher的update方法,更新视图
dep.notify();
}
})
}
可以看到在 getter 中会订阅 Dep.target 也就是订阅该属性的 watcher,在 setter 中会执行dep.notify 通知 watcher 更新视图
Compile实现
observe、Dep 、Watcher 已经实现了,现在要通过 Compile 类把这几个串通起来
Compile 会循环扫描模版节点,找到双向绑定的属性,也就是 v-model 绑定的属性 或者 {{}} 中的属性,然后完成初始化视图,监听input事件,为属性订阅watcher这几个操作
function Compile (el, vm) {
this.el = el;
this.vm = vm;
this.compileElement(el);
}
Compile.prototype = {
compileElement (el) {
let childs = el.childNodes;
Array.from(childs).forEach(node => {
let reg = /\{\{(.*)\}\}/;
let text = node.textContent;
if (this.isElementNode(node)) // 元素节点
this.compile(node)
else if (this.isTextNode(node) && reg.test(text)) { // 文本节点
this.compileText(node, reg.exec(text)[1]);
}
if (node.childNodes && node.childNodes.length) {
this.compileElement(node);
}
})
},
compile (node) {
let nodeAttr = node.attributes;
Array.from(nodeAttr).forEach(attr => {
if (this.isDirective(attr.nodeName)) { // v-model属性
node.value = this.vm[attr.nodeValue]; // 初始化
// 绑定input事件,达到视图更新数据目的
node.addEventListener('input', () => {
this.vm[attr.nodeValue] = node.value;
})
new Watcher(this.vm, attr.nodeValue, val => {
node.value = val;
})
}
})
},
compileText (node, exp) {
node.textContent = this.vm[exp]; // 初始化
new Watcher(this.vm, exp, val => {
node.textContent = val;
});
},
isElementNode (node) {
return node.nodeType === 1;
},
isTextNode (node) {
return node.nodeType === 3;
},
isDirective (attr) {
return attr === 'v-model';
}
}
可以看到,对于元素节点,compile 会判断该节点是否有绑定 v-model 这个属性,有的话初始化 node.value,然后监听 input 事件,实现视图改变时数据随之改变,再为该属性实例化 watcher, watcher 的功能上面已经说过了,watcher 会读取一下属性,从而进入属性的 getter 中收集自己,当数据改变时,会进入 setter 中,setter 中会通知 watcher 更新视图
对于文本节点,compile 会用正则匹配 {{}} 中的属性,初始化 node.textContent,然后再为该属性实例化 watcher
至此双向绑定已经整体实现了,代码已提交到 github上
总结
对于视图改变数据:Vue 会通过 compiler 扫描 template,为绑定了 v-model 的元素监听 input 事件,当触发 input 时,改变 data
对于数据改变视图:首先 Vue 会劫持所有属性的 getter 和 setter,Vue 会通过 compiler 扫描 template ,为绑定到 v-model 的属性或者 {{}} 双花括号中的属性实例化一个 watcher ,watcher 会读取一下属性,从而触发 getter ,在 getter 中将自己收集到观察者系统 Dep 中,然后当数据改变时,会进入到属性的 setter 中,setter 中会通知 Dep 中收集起来的 watcher 更新视图。