从零实现一个双向绑定

面试问了好几次vue的双向绑定。。。我思来想去,单向绑定是拦截器加上发布订阅者模式实现的,双向绑定不就是额外绑定一个input事件?

在文章开头,先声明这篇文章是深度参考剖析Vue实现原理 - 如何实现双向绑定mvvm的产物。并且这只是一个demo,极度精简,不会考虑太多只是为了实现双向绑定。

本文代码

其他的先不说,先放张大纲图

根据这张图我们可以看出,vue实现双向绑定的流程。

  1. 先通过观察者Observer劫持监听data所有属性,getter添加订阅者,setter通知订阅者。
  2. Compile初始化dom,解析指令,将dom中与vm实例有联系的节点更新函数,作为订阅者,存入依赖管理器Dep中。
  3. 当vm实例的数据发生变化时,观察者将变化通知给依赖管理器。
  4. 依赖管理器调用与该变化相关观察者的更新函数。
  5. 当dom中的input或textarea发生变化时,通过监听input事件,将input,textarea标签的value赋值到vm实例上。

接下来我们详细讲下每个过程。

Dep

首先我们先来完成一个依赖管理器。根据我们的需求,我们需要这个依赖管理器可以添加订阅者,和通知订阅者更新
由于我们是通过拦截每个属性的getter来添加订阅者,因此为了能确定属性的dep应该添加那个订阅者,我们还需要单独维护一个target变量,用来确定订阅者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// js/Dep.js
// 每一个dep需要一个唯一编号
let uid = 0;
export class Dep {
constructor() {
this.id = uid ++;
// 用来存放订阅者
this.subs = [];
}

addSub(watcher) {
this.subs.push(watcher);
}

notify() {
this.subs.forEach(function(watcher) {
watcher.update();
})
}
}

Dep.target = null;

Watcher

现在我们来编写一个订阅者类,根据需求我们需要将订阅者添加到依赖管理器的功能,和依赖管理器通知变化后的更新功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// js/Watcher.js
import { Dep } from './Dep.js'
export class Watcher {
constructor(vm, expFn) {
this.expFn = expFn;
this.vm = vm;
this.depIds = new Set();
this.get();
}

addDep(dep) {
if (!this.depIds.has(dep.id)) {
this.depIds.add(dep.id);
dep.addSub(this);
}
}

update() {
this.expFn.call(this.vm);
}

get() {
Dep.target = this;
this.expFn.call(this.vm);
Dep.target = null;
}
}

Observer

接下来我们完成一个观察者Observer,根据需求,我们需要为每一个属性添加一个依赖收集器和拦截器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// js/Observer.js
import { Dep } from './Dep.js'

class Observer {
constructor(obj) {
Object.keys(obj).forEach(key => {
defineReactive(obj, key);
})
}
}

export function observe(obj) {
if (typeof obj === 'object') {
new Observer(obj);
}
}

export function defineReactive(obj, key) {
let val = obj[key];
const dep = new Dep();
Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
Dep.target.addDep(dep);
}
return val;
},
set(v) {
if (val !== v) {
val = v;
dep.notify();
}
}
})
}

Compile

接下来我们实现一个解析模版指令的函数,它的作用是解析类似`v-model`,`{{}}`,等模版指令(这里我们只做`{{}}`,`v-model`的解析。)同时将设置了`v-model`的input和textarea标签绑定上input事件,实现双向绑定。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// js/Compile.js
import { Watcher } from './Watcher.js'
export function compile(vm, root) {
traverse(root, node => {
if (isTextNode(node)) {
const text = node.textContent;
let match = text.match(/\{\{.+\}\}/g)
if (!match) {
return ;
}
match = match.map(name => {
return name.replace(/\{|\}/g, '').trim();
});
match.forEach(item => {
if (vm[item]) {
const re = new RegExp(`\\{\\{ {0,}${item} {0,}\\}\\}`, 'g');
const origin = node.textContent;
new Watcher(vm, () => {
node.textContent = origin.replace(re, vm[item]);

})
}
})

} else if (isElementNode(node)) {
Object.keys(compileUtil).forEach(key => {
const name = node.getAttribute(`v-${key}`);
if (name) {
compileUtil[key](vm, node, name);
}
})
}
})

}

function traverse(root, callback) {
if (!root) {
return;
}
callback(root);
if (root.childNodes) {
Array.from(root.childNodes).forEach(node => {
traverse(node, callback);
})
}
}

function isTextNode(node) {
if (node.nodeType === 3) {
return true;
}
}

function isElementNode(node) {
return node.nodeType == 1;
}

const compileUtil = {
model(vm, ele, name) {
if (ele.tagName === 'INPUT' || ele.tagName === 'TEXTAREA') {
ele.addEventListener('input', e => {
vm[name] = ele.value;
})
new Watcher(vm, () => {
ele.value = vm[name];
})
}
}
}

入口类(vm)

在写一个入口类,将Watcher Dep Observer Compile封装起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// js/index.js
import { observe } from './Observer.js'
import { compile } from './Compile.js'
class Vue {
constructor(config) {
if (!config) {
return null;
}
const { el, data } = config;
this.$el = document.querySelector(el);
if (data) {
this.$data = { ...data };
observe(this.$data);
Object.keys(this.$data).forEach(key => {
proxy(this.$data, key, this);
})

}
compile(this, this.$el);
}
}
/**
*
* @param { object } obj - 被代理的对象
* @param { string } key - 被代理对象的key
* @param { Vue } goalObj - 代理目标对象
* 访问goalObj[key]相当于访问obj[key]
*/
function proxy(obj, key, goalObj) {
const descriptor = Object.getOwnPropertyDescriptor(obj, key);
Object.defineProperty(goalObj, key, {
get() {
if (descriptor.get) {
return descriptor.get.call(obj);
} else {
return descriptor.value;
}
},
set(v) {
if (descriptor.set) {
descriptor.set.call(obj, v);
}
}
})
}

最后测试一下吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>vue2双向绑定demo测试</title>
</head>
<body>
<div id="App">
<input type="text" v-model="text">
{{ text }}
</div>
</body>
<script type="module" src="./dist.js"></script>
</html>
1
2
3
4
5
6
7
8
9
// dist.js
import Vue from './js/index.js'

new Vue({
el: '#App',
data: {
text: 'HelloWorld',
}
})