如果有问题,或者有更好的解决方案,欢迎大家分享和指教。
交个朋友或进入前端交流群:-GuanEr-
本笔记的目的是更加深入完善的理解响应式。
一、最终目标
- 编译DOM,解析插值表达式,将表达式对应的值渲染在页面上(简单解析,不考虑 {{}} 中复杂的运算);
- 编译元素节点,解析到元素上绑定的指令,执行对应操作,
html
指令,text
指令,model
指令; - 解析元素节点上绑定的
@xxxx
的事件 - 数据的动态响应和双向绑定效果
二、重要的类
Vue
类,创建 Vue 实例,确定被编译的范围Compile
类,模板编译器,解析模板,分析表达式、指令、事件,并执行对应操作Observer
类,执行数据响应化处理Watcher
类,管理视图对数据的订阅,执行视图对数据更新的响应Dep
类,管理数据的依赖,通知指定数据的依赖,执行更新操作
三、实践目录
// 为了方便理解,本次实践不拆分复杂的目录树,后期如果觉得繁杂,可以再做拆分
project // 文件夹
index.html // html 内容,包含对 index.js 使用的测试用例
index.js // 本次封装的全部代码
复制代码
四、index.html 内容(对于封装的调用,测试用例)
index.html
希望引入 index.js
之后,以下功能都能正常实现。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app">
<!-- 插值表达式 -->
{{counter}}
<p>{{counter}}</p>
<h2>{{msg}}</h2>
<!-- 指令 html text -->
<p my-html="msg2"></p>
<p my-text="msg2"></p>
<p my-text="msg3"></p>
<!-- model指令的双向响应 -->
<input type="text" my-model="inpVal">
<p>{{inpVal}}</p>
<!-- 事件的绑定与实现 -->
<button @click="clickFn">点击</button>
</div>
<script src="./index.js"></script>
<script>
const app = new MyVue({
el: '#app',
data: {
counter: 1,
msg: 'hello world',
msg2: '<div style="color: #f00;">你好世界</div>',
msg3: '世界你好',
inpVal: 'this is input init value',
},
methods: {
clickFn() {
this.counter++;
}
}
});
</script>
</body>
</html>
复制代码
五、index.js 的封装
1. 数据响应式的实现
在封装 Vue
类之前,我们要先了解 Vue
实现数据动态响应的方式(此处只考虑对象)。
Vue2
中是通过 Object.defineProperty
函数,为 data
中的数据添加了 getter
和 setter
,数据每次被视图调用时,都会创建一个订阅者,数据每次被赋值时,通知这个数据的订阅者,让它们执行对应的操作。
以上便是 Vue2
实现数据响应的过程。
所以我们先封装一个函数,该函数的功能就是为指定的对象添加 getter
和 setter
。
1.1 一个简单的拦截函数
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
get() {
// todo 数据被调用,出现了一个订阅者,要在此处保留这个订阅者
return val;
},
set(newVal) {
if(newVal !== val) {
// todo 数据被赋值,要在此处通知该数据的订阅者执行相关操作
val = newVal;
}
}
});
}
复制代码
1.2 对象嵌套情况下的响应式
为了拆分功能,我们用一个新的 observe
函数实现指定对象的拦截响应,以及嵌套对象的递归。
也可以不拆分,此处拆分是为了模仿源码,更好理解源码的封装模式。因为 Vue2
中,将数组和对象的响应式分开处理,所以 observe
函数中,还有一些关于数组的处理,这里省略了,所以 observe
函数的根本功能是,判断一个数据的类型,并执行对应的响应处理。
function defineReactive(obj, key, val) {
observe(val);
// 初次绑定时,val 如果是对象,继续响应
Object.defineProperty(obj, key, {
get() {
// todo 数据被调用,出现了一个订阅者,要在此处保留这个订阅者
console.log('get' + key);
return val;
},
set(newVal) {
if(newVal !== val) {
console.log('set' + key);
observe(val);
// todo 数据被赋值,要在此处通知该数据的订阅者执行相关操作
val = newVal;
}
}
});
}
function observe(obj) {
if(typeof obj !== 'object' || obj === null) {
return;
}
Object.keys(obj).forEach(key => defineReactive(obj, key, obj[key]));
}
// 以下示范测试数据,理解完 observe 的嵌套递归之后,就可以删除了
const data = {
num: 1,
child: {
str: 'test'
}
};
observe(data);
data.num; // get num
data.child; // get child
data.child.str; // get child, get str
复制代码
2. 封装一个 Observer
类
我们知道,在 Vue
中,对象的响应式,不仅仅是为其添加 getter
、setter
那么简单,还有收集依赖分派更新等等动作。
Observe
类就是为了更加统一、方便的管理响应式。
Observe
类的功能如下:
- 判断一个数据的类型(object/array),执行对应的响应式数据的创建
- 管理依赖收集和数据更新
Observer
类的封装,以及结合了 Observer
类的 observe
函数和 defineReactive
函数:
function defineReactive(obj, key, val) {
observe(val);
Object.defineProperty(obj, key, {
get() {
console.log('get ' + key);
// todo 收集依赖
return val;
},
set(newVal) {
if(newVal !== val) {
console.log('set ' + key);
observe(newVal);
val = newVal;
// todo 分派更新
}
}
});
}
// observe 函数将在初始化一个 Vue 实例的时候被调用
function observe(obj) {
if(typeof obj !== 'object' || obj === null) {
return;
}
// 实例化一个 Ovserver 类
new Observer(obj);
}
class Observer {
constructor(value) {
this.value = value;
// 根据数据类型执行对应的操作
if(Array.isArray(value)) {
this.observerArray(value);
} else {
this.walk(value);
}
}
// 对象响应式处理
walk(obj) {
Object.keys(obj).forEach(key => defineReactive(obj, key, obj[key]));
}
// 数组响应式处理
observerArray(arr) {
// todo
}
}
复制代码
3. MyVue 类的封装
我们想要实现的 MyVue
类的功能:
- 处理用户传入的
data
,methods
,el
- 为了让数据的存取更安全,将
data
代理到Vue
实例上,我们就可以通过MyVue
实例vm
直接获取data
,否则就要这样获取vm.$data.xxx
- 编译模板的
DOM
内容(Compile
类,将在后文中实现)
// data 的代理函数
function proxy(vm) {
Object.keys(vm.$data).forEach(key => {
Object.defineProperty(vm, key, {
get() {
return vm.$data[key];
},
set(v) {
vm.$data[key] = v;
}
});
});
}
class MyVue {
constructor(options) {
this.$options = options || {};
this.$data = options.data || {};
this.$methods = options.methods || {};
// data 响应式处理
observe(this.$data);
// 数据代理,将实例上 $data 的所有数据绑定给 this 本身
proxy(this);
// 编译 DOM 内容,即将封装,封装好之后,即可取消注释
// new Compile(options.el, this);
}
}
复制代码
我们可以再回头看一下 MyVue
类的调用,并测试,传入的 data
中的数据,是否能被 MyVue
实例成功调用:
const app = new MyVue({
el: '#app',
data: {
counter: 1,
msg: 'hello world',
//... 其余数据
},
methods: {
// 函数
}
});
// 测试是否能通过 Vue 实例访问数据,并且实现数据拦截(如果控制台 log 出 get counter 和 get msg,则说明绑定成功)
app.counter; // get counter
app.msg; // get msg
复制代码
4. Compile 类的封装
4.1 Compile
类编译模板,需要实现的功能:
- 处理插值表达式
{{表达式}}
- 处理指令和事件
my-html, my-text, @event
- 插值表达式、指令对应数据的初始化和更新
4.2 Compile
的过程:
- 遍历实例化
MyVue
实例时指定的el
对应的DOM - 判断子节点类型,如果是文本节点,判断其内容是否是插值表达式(这里我们就简单的实现文本内容是
{{数据}}
形式的插值表达式解析) - 子节点类型如果是元素节点,解析器所有
attributes
,遍历它们,判断是否是指令/事件,如果是,执行对应操作 - 子节点如果是元素节点,还要遍历该子节点的所有子节点(深度遍历)
4.3 代码
进行到这里的时候就可以把 MyVue
类构造函数中下面的代码取消注释
// new Compile(options.el, this);
复制代码
class Compile {
constructor(el, vm) {
// el: new MyVue 时指定的元素选择器 vm 当前的 vue 实例
this.$vm = vm;
this.$el = document.querySelector(el); // 不考虑边界判断
if(this.$el) {
this.compile(this.$el);
}
}
// 解析器
compile(el) {
/* *
* 遍历 el 子节点,判断他们的类型,做相应的处理
* 注意这里一定是 childNodes,因为 children 只能获取到元素子节点
*/
const childNodes = el.childNodes;
childNodes.forEach(node => {
// 获取元素类型
const type = node.nodeType;
// 元素节点
if(type === 1) {
const attrs = node.attributes;
Array.from(attrs).forEach(attr => {
const attrName = attr.name;
const exp = attr.value;
// 判断是否为指令
if(this.isDirective(attrName)) {
const dir = attrName.substr(3);
// 判断该指令在系统中是否存在,如果存在,执行对应操作
this[dir] && this[dir](node, exp);
}
// 如果当前属性是想绑定一个事件
if(this.isEvent(attrName)) {
// 事件名称
const dir = attrName.substr(1);
// 添加对应事件监听
this.eventHandler(node, exp, dir);
}
});
} else if(this.isInter(node)) {
// 编译动态文本节点(插值表达式)
this.compileText(node);
}
// 如果当前元素有子节点,要递归判断
if(childNodes) {
this.compile(node);
}
});
}
// 是否为指令
isDirective(attr) {
return attr.startsWith('my-');
}
// 是否为事件
isEvent(attr) {
return attr.startsWith('@');
}
// 是否为插值表达式
isInter(node) {
return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent);
}
text(node, exp) {
// 处理指令 my-text
this.update(node, exp, 'text');
}
textUpdater(node, value) {
node.textContent = value;
}
html(node, exp) {
// 处理指令 my-html
this.update(node, exp, 'html');
}
htmlUpdater(node, value) {
node.innerHTML = value;
}
// 编译文本节点
compileText(node) {
// RegExp 表示与正则表达式匹配的第一个子匹配字符串
this.update(node, RegExp.$1, 'text');
}
update(node, exp, dir) {
// 1. 初始化
const fn = this[dir + 'Updater'];
fn && fn(node, this.$vm[exp]);
}
eventHandler(node, exp, dir) {
const fn = this.$vm.$methods[exp];
node.addEventListener(dir, fn.bind(this.$vm));
}
// model
model(node, exp) {
this.update(node, exp, 'model');
node.addEventListener('input', e => {
// 将新的值赋值给数据即可
this.$vm[exp] = e.target.value;
});
}
// 只完成赋值更新
modelUpdater(node, value) {
// 仅考虑 input
node.value = value;
}
}
复制代码
注
- 在这个封装中,拆分了好多过程,大家可以结合源码和代码执行的步骤,仔细思考一下,为什么要做这样的拆分,不拆分可不可以,在被拆分的功能中,是不是还有其他逻辑要处理。
Compile
封装结束后,页面就可以解析到MyVue
实例的data
了- 上面的封装只能页面初始化的时候解析一次,想要在数据改变时,也能动态的更新视图,或者
input
的数据变化时,也能更新到data
,需要更进一步的响应处理
5. Watcher
类和 Dep
类
通过上面的代码,我们可以知道,在模板被编译的过程中,每次,某个数据被视图调用,我们就知道,这个数据在此处,被调用了,下次如果该数据更新了,那么这个调用要重新执行一次,其实这是一个订阅模式。
我们可以声明一个 Watcher
类,来管理这种视图对数据的订阅。
5.1 Watcher
类
我们当然也可以直接使用函数、数组这种方式保留数据被订阅时的回调,然后等到数据被赋值时,依次执行。
但是为了让整个项目中的响应更完善,更系统,我们将这些动作,都封装在 Watcher
类中。
// 监听器,负责依赖的更新
class Watcher {
constructor(vm, key, cb) {
// 保留视图的 MyVue 实例
this.vm = vm;
// 保留该 vm 中被订阅的数据 key
this.key = key;
// 保留 key 被订阅时,需要执行的操作,其实就是 Compile 类中,数据执行的 update 函数
this.cb = cb;
}
update() {
// 执行实际的更新操作
this.cb.call(this.vm, this.vm[this.key]);
}
}
复制代码
下一个问题就是,在什么地方,如何使用这个 Watcher
,我们要做的是,保留每个数据被调用时执行的那个操作(update),等到下一次,这个数据被赋值时,重新执行这些操作。
回到 Compile
类中,我们观察到,无论哪个数据被调用,都会经过 Compile
类的 update
函数
class Compile {
...
update(node, exp, dir) {
// 1. 初始化
const fn = this[dir + 'Updater'];
fn && fn(node, this.$vm[exp]);
}
...
}
复制代码
当一个节点被编译时,compile 函数判断到这个节点调用了一个数据,就会调用 update
函数,update
接收到这个信息,判断该数据对应的操作是否存在,如果存在,分发任务。
无论哪个数据,做什么操作,都会先被 Compile
类的 update
分发,所以我们在 update
函数内部,保留数据被订阅时,数据所属的实例(MyVue实例),数据名(key),和 vm
中,该实例对应的操作。
我们暂时可以这样保留所有数据的更新:
- 在全局的位置,声明一个数组
watches
- 每次有数据被视图订阅时,我们就将该数据对应操作通过
Watcher
类保留 - 将这个
Watcher
实例添加到watches
数组中 - 数据被赋值时,执行
watches
数组中的任务
代码实现:
// index.js 第一行,全局位置
const watches = [];
复制代码
// Compile 类的 update 函数
class Compile {
...
update(node, exp, dir) {
// 1. 初始化
const fn = this[dir + 'Updater'];
fn && fn(node, this.$vm[exp]);
// 2. 保留操作,并且添加到 watches 数组中
matches.push(new Watcher(this.$vm, exp, function (val) {
// 这里的 fn,其实就是 exp 对应的 updator
// val 之所以要传入,是因为此时并不知道 exp 被更新的值
// exp 最新的值,是在这个数据被赋值时,再在 Watcher 的 update 里获取
fn && fn(node, val);
}));
}
...
}
复制代码
// defineReactive 函数
function defineReactive(obj, key, val) {
observe(val);
Object.defineProperty(obj, key, {
get() {
return val;
},
set(newVal) {
if(newVal !== val) {
observe(val);
val = newVal;
// 每当数据被赋值时,就让 matches 中所有的任务都执行
matches.forEach(item => {
item.update();
});
}
}
});
}
复制代码
这样是实现了数据响应的功能,但是也存在一非常严重的问题。watches
数组中,保留的是每一个数据被某个视图订阅的任务列表,每次在数据被赋值时,我们遍历了 matches
数组,让里面所有的任务都执行了,也包含那些未改变的数据的视图任务。
// 假如经过编译之后 matches 数组中的任务列表如下:
matches:
[
数据 A 被视图 V1 调用的任务, // 任务一
数据 B 被视图 V2 调用的任务, // 任务二
数据 B 被视图 V3 调用的任务, // 任务三
数据 C 被视图 V4 调用的任务, // 任务四
数据 C 被视图 V5 调用的任务, // 任务五
数据 C 被视图 V6 调用的任务, // 任务六
数据 D 被视图 V7 调用的任务, // 任务七
数据 D 被视图 V8 调用的任务, // 任务八
]
当数据 D 被更新时,只需要执行任务七和任务八,但是根据我们现在的逻辑,matches 数据被遍历,其中所有的任务都会执行
复制代码
所以我们需要更有规律的管理数据和其所有 Wathcer
。
在 Vue
源码中,这个工具就是 Dep
类。
5.2 Dep
类
Dep
类的核心内容
- 每个数据都应该有一个
Dep
- 这个
Dep
拥有一个数组 - 该数组管理订阅了该数组的所有任务
- 每次该数据被更新时,
Dep
会通知其管理的任务数组,让他们执行对应任务
// 管理依赖,通知数据相关 watcher,执行更新操作
class Dep {
constructor() {
// 声明一个数组,管理数据被订阅时的任务列表
this.deps = [];
}
addDep(watcher) {
// 每当数据被新的视图订阅时,都要为该数据添加一个 watcher
this.deps.push(watcher);
};
notify() {
this.deps.forEach(dep => dep.update());
}
}
复制代码
Dep
类声明好了之后,我们需要一次做以下几点。
5.2.1 在什么时候创建 Dep
实例
根据我们前面的分析,每个数据,都应该拥有一个 Dep
实例。如果这些数据是对象,我们可以将该对象的 Dep
实例添加成一个键,然后使用。但是 MyVue
实例中,并不是所有的数据都是对象,也有可能是数字,字符串,null等等。
我们可以通过闭包的方式,实现为每个数据添加一个 Dep
实例。
在数据传入 MyVue
的最初,每个数据都要经历一次 defineReactive
函数,我们就可以在这个时候,声明一个 Dep
实例。
function defineReactive(obj, key, val) {
const dep = new Dep();
... // 其余代码
}
复制代码
5.2.2 在什么时候收集依赖
依赖的收集肯定不在 Compile
的 update
函数中,因为此处,每个数据被订阅,都会执行,此时收集的依赖,依旧无法对应到指定数据,所以我们还是结合 Dep
实例的闭包,在数据被调用时,收集依赖。
数据被调用,且此时是被视图调用,那就收集一个依赖。
依赖是被收集到 Dep
实例的 deps
数组中的,而 Dep
实例又是 definedReactive
函数的局部变量,那么我们在 Compile
类的 update
函数中创建的 watcher
,就需要一个中转的过程,才能被添加到数据对应的 deps
数组中。
我们可以暂时使用一个全局变量 _tempWatcher
实现这个中转收集依赖的过程。
// index.js 第一行,全局位置
let _tempWathcer = [];
复制代码
// Compile 的 update 函数
class Compile {
...
update(node, exp, dir) {
// 1. 初始化
const fn = this[dir + 'Updater'];
fn && fn(node, this.$vm[exp]);
_tempWatcher = new Watcher(this.$vm, exp, function (val) {
fn && fn(node, val);
})
/*
* 注意这个举动是在触发收集依赖,
* 因为每次代码执行到这里的时候,对应数据已经被取过值了
* 就是在上面的 fn 执行的地方
*
* 所以要通过这种方式触发依赖
*
* 如果不想通过这种方式唤起 getter,可以把 new Watcher 放到上面的步骤1之前
* 但是这样会破坏代码的阅读顺序,体验不好
*/
this.$vm[exp];
_tempWatcher = null;
}
...
}
复制代码
// defineReactive 函数,经过上面的封装,我们就可以在这里收集依赖,分派更新了
function defineReactive(obj, key, val) {
observe(val);
Object.defineProperty(obj, key, {
get() {
_tempWatcher && dep.addDep(_tempWatcher);
return val;
},
set(newVal) {
if(newVal !== val) {
observe(val);
val = newVal;
// 根据闭包原则,此时执行的任务列表,就是该数据对应的任务列表,数据与数据的任务不会相互污染
dep.notify();
}
}
});
}
复制代码
5.2.3 更好的代码模式
虽然实现了功能,但是我们暴露了一个全局变量 _tempWatcher
,这显然是不合理和不安全的,所以我们可以将 _tempWatcher
绑定成 Dep 的一个静态属性。
我们还可以归置一下 Watcher
的创建和添加,将对应功能放到对应的类中,让所有代码都分工明确。
// Compile 的 update 函数
class Compile {
...
update(node, exp, dir) {
// 1. 初始化
const fn = this[dir + 'Updater'];
fn && fn(node, this.$vm[exp]);
// 2. 在这里只做依赖创建
new Watcher(this.$vm, exp, function (val) {
fn && fn(node, val);
})
}
...
}
复制代码
// Watcher 类
class Watcher {
constructor(vm, key, cb) {
this.vm = vm;
this.key = key;
this.cb = cb;
// 在此处触发依赖收集
Dep.target = this;
this.vm[this.key];
Dep.target = null;
}
// 未来被 Dep 调用
update() {
// 执行实际的更新操作
this.cb.call(this.vm, this.vm[this.key]);
}
复制代码
// defineReactive
function defineReactive(obj, key, val) {
observe(val);
const dep = new Dep();
Object.defineProperty(obj, key, {
get() {
// 收集依赖
Dep.target && dep.addDep(Dep.target);
return val;
},
set(newVal) {
if(newVal !== val) {
observe(newVal);
val = newVal;
dep.notify();
}
}
});
}
复制代码