数据双向绑定的探究和实现

45次阅读

共计 9255 个字符,预计需要花费 24 分钟才能阅读完成。

data-binding
简单版 vue 的双向绑定实现
成果图

vue 实现
上诉成果用 vue 来实现其实很简单,我们先创建一个文件夹 v0,在 v0 文件夹内见一个 index.html:
<!DOCTYPE html>
<html lang=”en”>
<head>
<meta charset=”UTF-8″>
<title>data-binding</title>
</head>
<style>
#app {
text-align: center;
}
</style>
<body>
<div id=”app”>
<h2>{{title}}</h2>
<input v-model=”name”>
<h1>{{name}}</h1>
<button v-on:click=”clickMe”>click me!</button>
</div>
</body>
<!– vue 源码 cdn 地址 https://cn.vuejs.org/v2/guide/ –>
<script src=”https://cdn.jsdelivr.net/npm/vue”></script>
<script type=”text/javascript”>

new Vue({
el: ‘#app’,
data: {
title: ‘hello world’,
name: ‘yujiyang’
},
methods: {
clickMe: function () {
this.title = ‘hello world’;
}
},
mounted: function () {
window.setTimeout(() => {
this.title = ‘ 你好 ’;
}, 1000);
}
});

</script>
</html>
那么 vue 是怎么实现的呢?

数据双向绑定
{{}}双括号变量解析
v-model 和事件指令解析

现在从简到难一步一步来实现这个 ’vue’。ps:由于本文只是为了学习和分享,所以只是简单实现下原理,并没有考虑太多情况和设计,如果大家有什么建议,欢迎提出来。
vue 数据双向绑定原理
看过 vue 的源码的同学都知道,vue 是通过数据劫持结合发布者 - 订阅者模式的方式来实现的~
首先来看下 vue 是如何进行数据劫持的?
new Vue({
el: ‘#app’,
data: {
message: {
name:’yjy’,
}
},
created: function () {
console.log(this.message);
}
});
结果:

我们可以看到属性 name 有两个相对应的 get 和 set 方法,为什么会多出这两个方法呢?因为 vue 是通过 Object.defineProperty()来实现数据劫持的。
Object.defineProperty()是用来做什么的?它可以来控制一个对象属性的一些特有操作,比如读写权、是否可以枚举,这里我们主要先来研究下它对应的两个描述属性 get 和 set,如果还不熟悉其用法,请点击这里阅读更多用法。
举个例子:
var People = {
name: ‘jiyang’
};
console.log(People.name); // yjy
如果我们想要在执行 console.log(People.name)的同时,直接给名字加个姓,那要怎么处理呢?或者说要通过什么监听对象 People 的属性值。这时候 Object.defineProperty()就派上用场了,代码如下:
var People = {}
var name = ”;
Object.defineProperty(People, ‘name’, {
set: function (value) {
name = value;
console.log(‘ 你取了一个名叫做 ’ + value);
},
get: function () {
return ‘yu’ + name
}
})

People.name = ‘jiyang’; // 你取了一个名叫做 jiyang
console.log(People.name); // yujiyang
我们通过 Object.defineProperty()设置了对象 People 的 name 属性,对其 get 和 set 进行重写操作,顾名思义,get 就是在读取 name 属性这个值触发的函数,set 就是在设置 name 属性这个值触发的函数,所以当执行 People.name = ‘jiyang’ 这个语句时,控制台会打印出 “ 你取了一个名叫做 jiyang”,紧接着,当读取这个属性时,就会输出 “yujiyang”,因为我们在 get 函数里面对该值做了加工了。如果这个时候我们执行下下面的语句,控制台会输出什么?
console.log(People)
结果:

是不是跟我们在上面打印 vue 数据长得有点类似,这也进一步验证 vue 确实是通过这种方法来进行数据劫持的。接下来我们通过其原理来实现一个简单版的 mvvm 双向绑定代码。
mvvm 思路分析
实现 mvvm 主要包含两个方面,数据变化更新视图,视图变化更新数据:

view 更新 data 其实可以通过事件监听即可,比如 input 标签监听 ‘input’ 事件就可以实现了。所以我们着重来分析下,当数据改变,如何更新视图的?
其实上文我们已经给出答案了,就是通过 Object.defineProperty()对属性设置一个 set 函数,当数据改变了就会来触发这个函数,所以我们只要将一些需要更新的方法放在这里面就可以实现 data 更新 view 了。

现在思路有了,接下去就是实现过程了。
实现过程
监听器 Observer
首先我们需要实现一个数据监听器 Observer,用来监听所有属性,如果属性发上变化了,就执行相应方法。
function defineReactive(data, key, val) {
observe(val); // 递归遍历所有子属性
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function() {
return val;
},
set: function(newVal) {
val = newVal;
console.log(‘ 属性 ’ + key + ‘ 已经被监听了,现在值为:“’ + newVal.toString() + ‘”’);
}
});
}

function observe(data) {
if (!data || typeof data !== ‘object’) {
return;
}
Object.keys(data).forEach(function(key) {
defineReactive(data, key, data[key]);
});
};

var people = {
person1: {
name: ”
},
person2: ”
};
observe(people);
people.person1.name = ‘yjy’; // 属性 name 已经被监听了,现在值为:“yjy”
people.person2 = ‘ 没有此人 ’; // 属性 person2 已经被监听了,现在值为:“没有此人”
订阅器 Dep 与订阅者 Watcher
由于一个属性的 getter 可能在多处触发(也就是被多个 dom 使用),所以属性 setter 的时候需要执行多个改变 view 的方法,我们把通知执行一个改变 view 的方法抽象成一个订阅者 Watcher。有一个容器来专门收集 Watcher,叫做订阅器 Dep。
Watcher 初始化的时候,添加一个 Watcher 到 Dep。属性每次 setter 的时候,执行 Dep 中所有 Watcher。

有了这个思路我们结合 observer,来实现一下 Dep 和 Watcher
// observer.js
// observer+dep
function Observer(data) {
this.data = data;
this.walk(data);
}
Observer.prototype = {
walk: function(data) {
var self = this;
Object.keys(data).forEach(function(key) {
self.defineReactive(data, key, data[key]);
});
},
defineReactive: function(data, key, val) {
var dep = new Dep();
var childObj = observe(val);
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function() {
if (Dep.target) {
dep.addSub(Dep.target);
}
return val;
},
set: function(newVal) {
if (newVal === val) {
return;
}
val = newVal;
dep.notify();
}
});
}
};

function observe(value, vm) {
if (!value || typeof value !== ‘object’) {
return;
}
return new Observer(value);
};

function Dep () {
this.subs = [];
}
Dep.prototype = {
addSub: function(sub) {
this.subs.push(sub);
},
notify: function() {
this.subs.forEach(function(sub) {
sub.update();
});
}
};
Dep.target = null;
// watcher.js
function Watcher(vm, exp, cb) {
this.cb = cb;
this.vm = vm;
this.exp = exp;
this.value = this.get(); // 将自己添加到订阅器的操作
}

Watcher.prototype = {
update: function() {
this.run();
},
run: function() {
var value = this.vm.data[this.exp];
var oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value, oldVal);
}
},
get: function() {
Dep.target = this; // 缓存自己
var value = this.vm.data[this.exp] // 强制执行监听器里的 get 函数
Dep.target = null; // 释放自己
return value;
}
};
‘vue’的 v1 版本
了解了 Observer、Dep、Watcher 后,我们只要将 Observer 和 Watcher 关联起来,就可以实现一个简单的数据双向绑定了。因为这里没有还没有设计解析器 Compile,所以对于模板绑定的属性数据, 我们都进行写死处理,假设模板上有一个节点,且 id 号为 ’name’,并且双向绑定的绑定的变量也为 ’name’,且是通过两个大双括号包起来(这里只是为了掩饰,暂时没什么用处)。
先建立一个文件夹 v1,目录结构如下:

observer 和 watcher 我们已经实现,还需要实现一个关联 Observer 和 Watcher 的 index.js 和模板 index.html
<!–index.html–>
<!DOCTYPE html>
<html lang=”en”>
<head>
<meta charset=”UTF-8″>
<title>self-vue</title>
</head>
<style>
#name {
text-align: center;
}
</style>
<body>
<h1 id=”name”></h1>
</body>
<script src=”js/observer.js”></script>
<script src=”js/watcher.js”></script>
<script src=”js/index.js”></script>
<script type=”text/javascript”>

var ele = document.querySelector(‘#name’);
var vue = new Vue({
name: ‘hello world’
}, ele, ‘name’);

window.setTimeout(function () {
console.log(‘name 值改变了 ’);
vue.name = ‘yjy’;
}, 2000);

</script>
</html>
// index.js
function Vue (data, el, exp) {
var self = this;
this.data = data;

// 知道为什么还要这个操作???
Object.keys(data).forEach(function(key) {
self.proxyKeys(key);
});

observe(data);
el.innerHTML = this.data[exp]; // 初始化模板数据的值
new Watcher(this, exp, function (value) {
el.innerHTML = value;
});
return this;
}

Vue.prototype = {
proxyKeys: function (key) {
var self = this;
Object.defineProperty(this, key, {
enumerable: false,
configurable: true,
get: function proxyGetter() {
return self.data[key];
},
set: function proxySetter(newVal) {
self.data[key] = newVal;
}
});
}
}
解析器 Compile
虽然上面已经实现了一个双向数据绑定的例子,但是整个过程都没有去解析 dom 节点,而是直接固定某个节点进行替换数据的,所以接下来需要实现一个解析器 Compile 来做解析和绑定工作。
解析器 Compile 实现步骤:

解析模板指令,并替换模板数据,初始化视图
将模板指令对应的节点绑定对应的更新函数,初始化相应的订阅器

为了解析模板,首先需要获取到 dom 元素,然后对含有 dom 元素上含有指令的节点进行处理,因此这个环节需要对 dom 操作比较频繁,所有可以先建一个 fragment 片段,将需要解析的 dom 节点存入 fragment 片段里再进行处理:
function nodeToFragment (el) {
var fragment = document.createDocumentFragment();
var child = el.firstChild;
while (child) {
// 将 Dom 元素移入 fragment 中
fragment.appendChild(child);
child = el.firstChild
}
return fragment;
}
接下来需要遍历各个节点,对含有相关指定的节点进行特殊处理,这里咱们先处理最简单的情况,只对带有 ‘{{变量}}’ 这种形式的指令进行处理,后面再考虑更多指令情况:
function compileElement (el) {
var childNodes = el.childNodes;
var self = this;
[].slice.call(childNodes).forEach(function(node) {
var reg = /\{\{(.*)\}\}/;
var text = node.textContent; // 获取该节点以及其子节点所包含文本内容

if (self.isTextNode(node) && reg.test(text)) {// 判断是否是符合这种形式 {{}} 的指令且节点类型是文本类型
self.compileText(node, reg.exec(text)[1]);
}

if (node.childNodes && node.childNodes.length) {
self.compileElement(node); // 继续递归遍历子节点
}
});
},
function compileText (node, exp) {
var self = this;
var initText = this.vm[exp];
this.updateText(node, initText); // 将初始化的数据初始化到视图中
new Watcher(this.vm, exp, function (value) {// 生成订阅器并绑定更新函数
self.updateText(node, value);
});
},
function updateText (node, value) {
node.textContent = typeof value == ‘undefined’ ? ” : value;
},
function isTextNode (node) {
return node.nodeType == 3;
}
获取到最外层节点后,调用 compileElement 函数,对所有子节点进行判断,如果节点是文本节点且匹配 {{}} 这种形式指令的节点就开始进行编译处理,编译处理首先需要初始化视图数据,对应上面所说的步骤 1,接下去需要生成一个并绑定更新函数的订阅器,对应上面所说的步骤 2。这样就完成指令的解析、初始化、编译三个过程,一个解析器 Compile 也就可以正常的工作了。为了将解析器 Compile 与监听器 Observer 和订阅者 Watcher 关联起来,我们需要再修改一下‘Vue 类’:
// index.js
function Vue (options) {
var self = this;
this.vm = this;
this.data = options.data;

Object.keys(this.data).forEach(function(key) {
self.proxyKeys(key);
});

observe(this.data);
new Compile(options.el, this.vm); // 初始化视图和 watcher 初始化都封装在 compile 里面
return this;
}

Vue.prototype = {
proxyKeys: function (key) {
var self = this;
Object.defineProperty(this, key, {
enumerable: false,
configurable: true,
get: function proxyGetter() {
return self.data[key];
},
set: function proxySetter(newVal) {
self.data[key] = newVal;
}
});
}
}

更改后,我们就不要像之前通过传入固定的元素值进行双向绑定了,只要传入一个根节点就可以对所有子节点进行双向绑定了:
<body>
<div id=”app”>
<h2>{{title}}</h2>
<h1>{{name}}</h1>
</div>
</body>
<script src=”js/observer.js”></script>
<script src=”js/watcher.js”></script>
<script src=”js/compile.js”></script>
<script src=”js/index.js”></script>
<script type=”text/javascript”>

var Vue = new Vue({
el: ‘#app’,
data: {
title: ‘hello world’,
name: ‘ddvdd’
}
});

window.setTimeout(function () {
Vue.title = ‘ 你好 ’;
}, 2000);

window.setTimeout(function () {
Vue.name = ‘yjy’;
}, 2500);

</script>
完整的 Compile.js 代码如下:
function Compile(el, vm) {
this.vm = vm;
this.el = document.querySelector(el);
this.fragment = null;
this.init();
}

Compile.prototype = {
init: function () {
if (this.el) {
this.fragment = this.nodeToFragment(this.el);
this.compileElement(this.fragment);
this.el.appendChild(this.fragment);
} else {
console.log(‘Dom 元素不存在 ’);
}
},
nodeToFragment: function (el) {
var fragment = document.createDocumentFragment();
var child = el.firstChild;
while (child) {
// 将 Dom 元素移入 fragment 中
fragment.appendChild(child);
child = el.firstChild
}
return fragment;
},
compileElement: function (el) {
var childNodes = el.childNodes;
var self = this;
[].slice.call(childNodes).forEach(function(node) {
var reg = /\{\{(.*)\}\}/;
var text = node.textContent;

if (self.isTextNode(node) && reg.test(text)) {// 判断是否是符合这种形式 {{}} 的指令
self.compileText(node, reg.exec(text)[1]);
}

if (node.childNodes && node.childNodes.length) {
self.compileElement(node); // 继续递归遍历子节点
}
});
},
compileText: function(node, exp) {
var self = this;
var initText = this.vm[exp];
this.updateText(node, initText); // 将初始化的数据初始化到视图中
new Watcher(this.vm, exp, function (value) {// 生成订阅器并绑定更新函数
self.updateText(node, value);
});
},
updateText: function (node, value) {
node.textContent = typeof value == ‘undefined’ ? ” : value;
},
isTextNode: function(node) {
return node.nodeType == 3;
}
}
思考
到这里,一个数据双向绑定功能已经基本完成了,接下去就是需要完善更多指令的解析编译,在哪里进行更多指令的处理呢?
如何添加一个 v -model 指令和事件指令 v -on:click 的解析编译?
所有例子和最终思考答案
源码地址如果对你有帮助,麻烦 star 下~

正文完
 0