本文是个人学习Vue2双向绑定源码的手撕实现
本文是整理小破站up@技术蛋老师的讲解而得:www.bilibili.com/video/BV193...
演示的DOM如下:
html
<body>
<div id="app">
<span>所以你叫什么:{{ name }}</span>
<input type="text" v-model="name" />
<span>个性:{{ personality.like }}</span>
<input type="text" v-model="personality.like" />
</div>
<!-- 该js文件用于实现构造方法Vue -->
<script src="./Vue_Model.js"></script>
<script>
const vm = new Vue({
el: '#app',
data: {
name: '超级三文鱼',
personality: {
like: '14',
},
},
});
console.log(vm);
</script>
</body>
书写Vue的构造方法 用于实现数据的双向绑定
双向绑定可以分为两部分:数据劫持 和 发布订阅
首先获取组件的data属性
js
// Vue_Model.js
class Vue {
constructor(obj_instance) {
this.$data = obj_instance.data;
}
}
数据劫持
为什么要进行数据劫持?
解释:JS中,如果对象的属性发生了变化,需要通知我们,然后把更改后的属性更新到对应的DOM节点身上 。换句话说,我们需要做到能够监听对象里属性的变化,因此这初始化Vue实例的时候调用数据监听函数Observer
Observer
js
// Vue_Model.js
class Vue {
constructor(obj_instance) {
this.$data = obj_instance.data;
Observer(this.$data);
}
}
// 数据劫持 - 监听实例里的数据
function Observer(data_instance) {}
打开浏览器看一眼,可以发现我们的vm实例已经创建出来了

说到数据监听,会想到getter和setter,接下来我们补充下Observer函数的内容
js
function Observer(data_instance) {
Object.keys(data_instance).forEach((key) => {
let value = data_instance[key];
Object.defineProperty(data_instance, key, {
enumerable: true, // 表示数据是可枚举的
configurable: true, // 表示属性描述符可以被改变
get() {
console.log(`访问了属性:${key} -> 值:${value}`);
return value;
},
set(newValue) {
console.log(`属性${key}的值${value}修改为 -> ${newValue}`);
value = newValue;
},
});
});
}
做到这里,Observer只能浅拷贝data,而还无法获得获得personality.like这个属性,所以接下来我们加入递归
js
function Observer(data_instance) {
// 递归出口
if (!data_instance || typeof data_instance !== 'object') return; // 新增
Object.keys(data_instance).forEach((key) => {
let value = data_instance[key];
// 递归 - 子属性数据劫持
Observer(value); // 新增
Object.defineProperty(data_instance, key, {
enumerable: true,
configurable: true,
get() {
console.log(`访问了属性:${key} -> 值:${value}`);
return value;
},
set(newValue) {
console.log(`属性${key}的值${value}修改为 -> ${newValue}`);
value = newValue;
},
});
});
}
看上去好像没啥毛病了,其实我们忽略一个问题:
假如我们改写data属性里的name:vm.$data.name = { 'Hello' : 'JS是世界上最好的语言' },并没有创建对应Hello的getter和setter,改写personality也是同理。

为什么会这样呢?因为我们根本没有设置,只需要在setter中增加Observer(newValue)即可解决问题
js
function Observer(data_instance) {
if (!data_instance || typeof data_instance !== 'object') return;
Object.keys(data_instance).forEach((key) => {
let value = data_instance[key];
Observer(value);
Object.defineProperty(data_instance, key, {
enumerable: true,
configurable: true,
get() {
console.log(`访问了属性:${key} -> 值:${value}`);
return value;
},
set(newValue) {
console.log(`属性${key}的值${value}修改为 -> ${newValue}`);
value = newValue;
Observer(newValue); // 新增
},
});
});
}
当然,其实还有一种情况我们没有考虑到:当我们添加一个data中没有的属性,同样是没有getter和setter的,这属于是另一种情况,本文着重考虑new一个Vue实例时背后发生的事情。
我们在能够劫持数据以后就要把Vue里的数据应用到页面DOM上,插值表达式里的数据就是我们需要进行更新的。实际步骤可以参考如下:
获取页面元素 -> 放入临时内存区域 -> 应用Vue数据 -> 渲染页面
中间开辟一个内存区域,把所有的数据更新后再渲染页面,而不是获取一个元素就渲染一个页面,可以减少频繁操作DOM。所以接下来我们需要书写解析函数Compile了。
Compile
添加:在创建Vue实例时我们就可以直接调用Compile模板解析器
Compile函数有两个形参,第一个是要编译的DOM元素,第二个是vm实例(会用到data里的数据)
js
class Vue {
constructor(obj_instance) {
this.$data = obj_instance.data;
Observer(this.$data);
Compile(obj_instance.el, this); // 新增
}
}
// HTML模板解析 - 替换DOM内容
function Compile(element, vm) {}
接下来我们补充Compile函数:
首先获取页面元素DOM,然后创建临时内存,把该DOM的子节点逐个逐个加到fragment变量里面
js
function Compile(element, vm) {
vm.$el = document.querySelector(element);
const fragment = document.createDocumentFragment();
let child = null;
while ((child = vm.$el.firstChild)) {
fragment.append(child); // 同时会移除页面上的DOM
}
}
而我们的目的是:修改DOM里的文本,也就是插值语法 {{}} 里的内容(文本节点),所以接下来我们写一个fragment_compile函数并调用它去实现这个一个修改
js
function Compile(element, vm) {
vm.$el = document.querySelector(element);
const fragment = document.createDocumentFragment();
let child = null;
while ((child = vm.$el.firstChild)) {
fragment.append(child);
}
// 以下全部内容为新增
fragment_compile(fragment);
// 替换文档碎片内容(文本节点)
function fragment_compile(node) {
// 通过正则表达式来匹配文本节点里的文本
const pattern = /\{\{\s*(\S+)\s*\}\}/;
// 文本节点的nodeType是3
if (node.nodeType === 3) {
console.log(node);
console.log(node.nodeValue);
return;
}
// 文本节点里可能还有子节点,进行递归
node.childNodes.forEach((child) => fragment_compile(child));
}
}
在上面的代码中,我们打印了文档碎片内容:如下


发现:有一些内容是我们不想要的,比如换行符,大括号等等 ,我们只想取到插值语法里面的内容,就是这里的name和personality.like。
一个很常见的匹配方法就是正则:const pattern = /\{\{\s*(\S+)\s*\}\}/;
解释下这里的正则:花括号我们用了一个反斜杠来转义,在连续两个花括号的内部考虑到程序员会有空格,所以用了一个\s*来匹配前后的空格,中间的小括号部分是我们要匹配出来的。
修改一些代码 -> 运用正则的exec()方法获取到我们想要的内容:
js
function fragment_compile(node) {
const pattern = /\{\{\s*(\S+)\s*\}\}/;
if (node.nodeType === 3) {
// 修改部分-始
const result_regex = pattern.exec(node.nodeValue);
if (result_regex) {
console.log(node.nodeValue);
console.log(result_regex);
}
// 修改部分-终
return;
}
// 文本节点里可能还有子节点,进行递归
node.childNodes.forEach((child) => fragment_compile(child));
}
我们打印了文档碎片fragment的值和正则匹配后得到的值,如下图所示:我们把换行符给干掉了

而我们想要提取的元素正是正则匹配出来的索引为1的元素,也就是属性的名字 name 和 personality.like
接下来我们就可以把这些需要的元素取出来了。需要注意的是:对于personality这个属性,因为他是一个对象,所以我们需要采用链式获取属性里子属性like的值,而关于这个链式获取,我们可以采用数组的reduce方法,又因为正则匹配出来的数据中,索引为1的这个数据是一个字符串,所以我们需要先将其用split方法转换为一个数组。
js
function fragment_compile(node) {
const pattern = /\{\{\s*(\S+)\s*\}\}/;
if (node.nodeType === 3) {
const result_regex = pattern.exec(node.nodeValue);
if (result_regex) {
// 修改部分-始
const arr = result_regex[1].split('.');
const value = arr.reduce(
(total, current) => total[current],
vm.$data
);
console.log(value);
// 修改部分-终
}
return;
}
// 文本节点里可能还有子节点,进行递归
node.childNodes.forEach((child) => fragment_compile(child));
}
同样的,我们打印下获取结果,控制台输出如下:可以看出我们成功获取了对应的值

然后我们就可以把当前节点值的内容用replace方法进行替换,然后再赋值把原先的节点内容给覆盖掉
js
function fragment_compile(node) {
const pattern = /\{\{\s*(\S+)\s*\}\}/;
if (node.nodeType === 3) {
const result_regex = pattern.exec(node.nodeValue);
if (result_regex) {
const arr = result_regex[1].split('.');
const value = arr.reduce(
(total, current) => total[current],
vm.$data
);
// 修改部分-始
node.nodeValue = node.nodeValue.replace(pattern, value);
console.log(node.nodeValue);
// 修改部分-终
}
return;
}
// 文本节点里可能还有子节点,进行递归
node.childNodes.forEach((child) => fragment_compile(child));
}
我们同样打印下修改后fragment碎片中的值,可以看到原本有插值表达式的文本已经被替换过来了

接下来我们就可以把文本碎片应用到对应的DOM里面了,因为文本碎片我们已经修改好了,完成这一步之后我们页面的DOM就恢复了,并且是没有插值表达式的大括号的
js
function Compile(element, vm) {
// ...
fragment_compile(fragment);
function fragment_compile(node) {
// ...
}
// 新增部分
vm.$el.appendChild(fragment); // 把文本碎片应用到vm里的$el属性里面
}

自此,我们就实现了数据劫持,并把数据应用到页面了。但是数据变动还不能实时更新,接下来我们来实现发布者-订阅者模式。
发布者-订阅者模式
首先我们来创建两个个类,类(Dependency)用于收集和通知订阅者,类(Watcher)是订阅者
js
// 依赖 - 收集和通知订阅者
class Dependency {
constructor() {
this.subsribers = []; // 收集所有的订阅者
}
addsubsriber(sub) {
this.subsribers.push(sub); // 由于一开始并非知道有多少个订阅者
}
notify() {
this.subsribers.forEach((sub) => sub.update()); // 通知订阅者去更新数据
}
}
// 订阅者
class Watcher {
constructor(vm, key, callback) {
this.vm = vm; // vm实例
this.key = key; // 对应的属性名
this.callback = callback; // 回调函数:记录如何更新文本内容
}
update() {
this.callback();
}
}
那么接下来的问题就是什么时候来创建订阅者实例呢?
解答:在模板解析的时候,我们就会修改文本内容,也就是替换节点值内容的时候,所以创建订阅者实例的时机就是:替换fragment文档碎片内容的时候
js
function Compile(element, vm) {
// ...
fragment_compile(fragment);
function fragment_compile(node) {
const pattern = /\{\{\s*(\S+)\s*\}\}/;
if (node.nodeType === 3) {
// 注意:这里必须存储旧的fragment碎片值,因为创建Watcher实例时并未调用回调函数
const node_oldValue = node.nodeValue; // 新增
const result_regex = pattern.exec(node.nodeValue);
if (result_regex) {
const arr = result_regex[1].split('.');
const value = arr.reduce(
(total, current) => total[current],
vm.$data
);
// 这里也修改为了node_oldValue
node.nodeValue = node_oldValue.replace(pattern, value);
// 新增:创建订阅者
new Watcher(vm, result_regex[1], (newValue) => {
node.nodeValue = node_oldValue.replace(pattern, newValue);
})
}
return;
}
node.childNodes.forEach((child) => fragment_compile(child));
}
vm.$el.appendChild(fragment);
}
创建完了Watcher实例,我们该如何把该订阅者实例存储到Dependency实例的数组中呢?
我们进行如下修改:在Dependency类中增加temp属性存储订阅者实例上下文
js
// 订阅者
class Watcher {
constructor(vm, key, callback) {
this.vm = vm; // vm实例
this.key = key; // 对应的属性名
this.callback = callback; // 回调函数:记录如何更新文本内容
// 新增:在Dependency类中增加temp属性存储订阅者实例上下文
Dependency.temp = this;
}
update() {
this.callback();
}
}
接下来我们要把这个新的订阅者添加到订阅者数组里,而且我们要为所有的订阅者都进行该操作
我们可以利用getter的特性来执行这个操作:初次渲染也就是将插值语法转换为值的时候,肯定会用到getter,我们在getter中便可以把这个订阅者实例给push到Dependency实例的订阅者数组中。
当然了,Dependency实例也是需要new的;还有另一个关键点,订阅者实例加入订阅者数组结束后,需要把Dependency.temp置空,防止同一watcher被反复加入订阅者数组。
js
function Observer(data_instance) {
if (!data_instance || typeof data_instance !== 'object') return;
// 创建Dependency实例
const dependency = new Dependency(); // 新增
Object.keys(data_instance).forEach((key) => {
let value = data_instance[key];
Observer(value);
Object.defineProperty(data_instance, key, {
enumerable: true,
configurable: true,
get() {
console.log(`访问了属性:${key} -> 值:${value}`);
// 订阅者加入dependency实例的数组
Dependency.temp && dependency.subsribers.push(Dependency.temp); // 新增
return value;
},
set(newValue) {
console.log(`属性${key}的值${value}修改为 -> ${newValue}`);
value = newValue;
Observer(newValue);
},
});
});
}
// 订阅者
class Watcher {
constructor(vm, key, callback) {
this.vm = vm; // vm实例
this.key = key; // 对应的属性名
this.callback = callback; // 回调函数:记录如何更新文本内容
Dependency.temp = this;
// 新增:只遍历,不做其他操作
key.split('.').reduce(
(total, current) => total[current],
vm.$data
);
// 新增:getter调用完后,将temp置空
Dependency.temp = null;
}
update() {
this.callback();
}
}
好的,getter处理好了就轮到setter了。我们在修改数据的时候一定会通知订阅者来进行更新,因此我们在setter里调用dependency实例身上的notify方法
js
function Observer(data_instance) {
if (!data_instance || typeof data_instance !== 'object') return;
const dependency = new Dependency();
Object.keys(data_instance).forEach((key) => {
let value = data_instance[key];
Observer(value);
Object.defineProperty(data_instance, key, {
enumerable: true,
configurable: true,
get() {
console.log(`访问了属性:${key} -> 值:${value}`);
Dependency.temp && dependency.subsribers(Dependency.temp);
return value;
},
set(newValue) {
console.log(`属性${key}的值${value}修改为 -> ${newValue}`);
value = newValue;
Observer(newValue); // 新增
},
});
});
}
通知过程如下:

到这里就已经实现了:修改组件的data,页面能同步更新展示了。
input标签的绑定
完成了文本的绑定,接下来我们完成带有v-model的input标签的绑定
那首先我们需要判定哪个节点有v-model:元素节点的节点类型是1,我们可以用nodeName来匹配INPUT元素;因此,我们便可以在判断文本节点下面进行新的if判断
js
function Compile(element, vm) {
// ...
fragment_compile(fragment);
function fragment_compile(node) {
// ...
if (node.nodeType === 3) {...}
// 新增:元素节点的nodeType是1
if (node.nodeType === 1 && node.nodeName === 'INPUT') {
const attr = node.attributes;
console.log(attr); // 打印属性
}
// ...
}
// ...
}
把获取到的attr打印出来如下图:

我们将attr转换成数组:const attr = Array.from(node.attributes)

接下来我们完善代码,实现:修改组件的data数据后,input标签的value能同步更新显示
js
function Compile(element, vm) {
// ...
fragment_compile(fragment);
function fragment_compile(node) {
// ...
if (node.nodeType === 3) {...}
if (node.nodeType === 1 && node.nodeName === 'INPUT') {
const attr = Array.from(node.attributes);
attr.forEach((i) => {
if (i.nodeName === 'v-model') {
const value = i.nodeValue.split('.').reduce(
(total, current) => total[current],
vm.$data
);
node.value = value;
// 同样需要new Watcher
new Watcher(vm, i.nodeValue, newValue => {
node.value = newValue;
})
}
})
}
// ...
}
// ...
}
这里我们已经实现修改组件的data数据后,input标签的value能同步更新显示啦
最后,我们还需要实现最后一个功能:页面上input标签发生input事件时,要同步修改组件的data里的属性
我们就在有v-model的input标签上增加input监听事件就可以了
js
function Compile(element, vm) {
// ...
fragment_compile(fragment);
function fragment_compile(node) {
// ...
if (node.nodeType === 3) {...}
if (node.nodeType === 1 && node.nodeName === 'INPUT') {
const attr = Array.from(node.attributes);
attr.forEach((i) => {
if (i.nodeName === 'v-model') {
const value = i.nodeValue.split('.').reduce(
(total, current) => total[current],
vm.$data
);
node.value = value;
new Watcher(vm, i.nodeValue, newValue => {
node.value = newValue;
})
// 新增:难点在于赋值data中深层次的属性
node.addEventListener('input', (e) => {
const arr1 = i.nodeValue.split('.');
const arr2 = arr1.slice(0, arr1.length - 1);
const final = arr2.reduce(
(total, current) => total[current],
vm.$data
);
final[arr1[arr1.length - 1]] = e.target.value;
});
}
})
}
// ...
}
// ...
}
关于这里的赋值操作,并不是像之前取值直接的split('.').reduce(...),我们来详细看一下这里的操作:
比如给personality.like赋值时,i.nodeValue这里的值为'personality.like',而我们是不能用这种方式来赋值的:vm.$data[personality.like] = e.target.value,因为取不到。
这里先把i.nodeValue转换为数组并存储在arr1中,arr1即为['personality', 'like'],然后把arr1的最后一个元素去掉并存储在arr2中,arr2即为['personality'],然后再对arr2用reduce方法一直得到arr1最后一个元素的前一层级的对象并存储在final中,最后再把新值赋值给final.like,就可以成功更换组件中data里的深层次属性了。
而又因为我们已经实现了:修改data里的属性后,页面能同步更新显示,所以到这里我们就实现了:修改input标签的内容,页面同步更新显示了,至此我们整个双向绑定就完成了。
附录:Vue_model.js源代码
tip:DOM结构在页面最上方就不重复写在此处了
js
class Vue {
constructor(obj_instance) {
this.$data = obj_instance.data;
Observer(this.$data);
Compile(obj_instance.el, this);
}
}
// 数据劫持 - 监听实例里的数据
function Observer(data_instance) {
// 递归出口
if (!data_instance || typeof data_instance !== 'object') return;
const dependency = new Dependency();
Object.keys(data_instance).forEach((key) => {
let value = data_instance[key];
// 递归 - 子属性数据劫持
Observer(value);
Object.defineProperty(data_instance, key, {
enumerable: true, // 表示数据是可枚举的
configurable: true, // 表示属性描述符可以被改变
get() {
console.log(`访问了属性:${key} -> 值:${value}`);
// 订阅者加入dependency实例的数组
Dependency.temp && dependency.subsribers.push(Dependency.temp);
return value;
},
set(newValue) {
console.log(`属性${key}的值${value}修改为 -> ${newValue}`);
value = newValue;
Observer(newValue);
dependency.notify();
},
});
});
}
// HTML模板解析 - 替换DOM内容
function Compile(element, vm) {
vm.$el = document.querySelector(element);
const fragment = document.createDocumentFragment();
let child = null;
while ((child = vm.$el.firstChild)) {
fragment.append(child); // 同时会移除页面上的DOM
}
fragment_compile(fragment);
// 替换文档碎片内容(文本节点)
function fragment_compile(node) {
// 通过正则表达式来匹配文本节点里的文本
const pattern = /\{\{\s*(\S+)\s*\}\}/;
// 文本节点的nodeType是3
if (node.nodeType === 3) {
const node_oldValue = node.nodeValue;
const result_regex = pattern.exec(node.nodeValue);
if (result_regex) {
const arr = result_regex[1].split('.');
const value = arr.reduce((total, current) => total[current], vm.$data);
node.nodeValue = node_oldValue.replace(pattern, value);
// 创建订阅者
new Watcher(vm, result_regex[1], (newValue) => {
node.nodeValue = node_oldValue.replace(pattern, newValue);
});
}
return;
}
// 元素节点的nodeType是1
if (node.nodeType === 1 && node.nodeName === 'INPUT') {
const attr = Array.from(node.attributes);
attr.forEach((i) => {
if (i.nodeName === 'v-model') {
const value = i.nodeValue
.split('.')
.reduce((total, current) => total[current], vm.$data);
node.value = value;
// 同样需要new Watcher
new Watcher(vm, i.nodeValue, (newValue) => {
node.value = newValue;
});
// input事件时,要同步修改组件的data里的属性
node.addEventListener('input', (e) => {
const arr1 = i.nodeValue.split('.');
const arr2 = arr1.slice(0, arr1.length - 1);
const final = arr2.reduce(
(total, current) => total[current],
vm.$data
);
final[arr1[arr1.length - 1]] = e.target.value;
});
}
});
}
// 文本节点里可能还有子节点,进行递归
node.childNodes.forEach((child) => fragment_compile(child));
}
// 把文本碎片应用到vm里的$el属性里面
vm.$el.appendChild(fragment);
}
// 依赖 - 收集和通知订阅者
class Dependency {
constructor() {
this.subsribers = []; // 收集所有的订阅者
}
addsubsriber(sub) {
this.subsribers.push(sub); // 由于一开始并非知道有多少个订阅者
}
notify() {
this.subsribers.forEach((sub) => sub.update()); // 通知订阅者去更新数据
}
}
// 订阅者
class Watcher {
constructor(vm, key, callback) {
this.vm = vm; // vm实例
this.key = key; // 对应的属性名
this.callback = callback; // 回调函数:记录如何更新文本内容
// 临时属性 - 触发getter
Dependency.temp = this;
// 只遍历,不做其他操作
key.split('.').reduce((total, current) => total[current], vm.$data);
// getter调用完后,将temp置空
Dependency.temp = null;
}
update() {
const newValue = this.key
.split('.')
.reduce((total, current) => total[current], this.vm.$data);
this.callback(newValue);
}
}