不手撸一个极简版Vue响应式系统,记不住实现原理呀
前言
对Vue响应式系统的实现原理,只知道整体流程,对于关键细节,不清不楚。为了消除这个知识点模糊地带,以前就有通过手撕代码,一步一步实现一个可用的极简版Vue响应式系统的想法。可是太懒,有拖延症,一拖就拖了大半年。物极必反,心里想的事情一直不去做的话,也是如鲠在喉,这几天身心状态较好,决定一鼓作气,把这个事情了解了,免得一直占用心智空间。废话说完了,现在我们进入正题。最终实现的效果如下所示:
本文将讲解如何一步步的实现一个极简的Vue响应式系统。
极简版Vue响应式实现
先用最朴素的思想,实现一个视图和模型可以双向绑定的响应式功能。
朴素的双向绑定
在视图部分, 定义一个输入框,用于和模型数据进行互动。
<div id="app">
<input id='name' type="text" />
</div>
在模型部分, 先实现模型到视图的同步,用Proxy对模型数据进行劫持,监听模型数据的修改。这里有两点要说明一下:
const reactive = (obj) => {
return Proxy(obj, {
get: function (target, key, receiver) {
return Reflect.get(target, key, receiver);
},
set: function (target, key, value, receiver) {
document.querySelector('#name').value=value;
return Reflect.set(target, key, value, receiver);
},
});
};
const data=reactive({name:'张三'});
// 初始化赋值
document.querySelector('#name').value=data.name;
// 后面的赋值就有数据劫持效果
setTimeout(() => {
data.name = "李四";
}, 1000);
再实现视图到模型的同步,通过监听输入事件,很容易做到将视图数据同步到模型。
document.querySelector('#name').addEventListener("input", (e)=>{
data.name = e.target.value;
});
目前,我们已经实现了一个最简单的数据双向绑定。这个例子中有一个隐含的前提条件,就是我们预设data是视图中依赖的响应式变量,那如何用程序自动判断视图中用到的响应式变量呢? 因为Vue中视图中需要添加响应式功能的数据都有一些典型的特征,如{{xxx}}
,v-属性名=xxx
等, 可以递归扫描根容器下的所有子节点元素,依据这些特征,识别出视图中用到的响应式变量,监听这些变量的改变。这就需要一个编译的过程。
引入编译
假设视图中有如下三种类型响应式变量写法,现在我们要实现一个编译类,从视图中找到这三种类型的响应式写法对应的变量。
<div id="app">
<div>
<span class="label">双括号变量:</span>
<span>{{data.name}}</span>
</div>
<div>
<span>v-model变量:</span>
<input id="name" v-model="data.name" type="text" />
</div>
<div>
<span class="label">v-text变量:</span>
<span v-text="data.name"></span>
</div>
</div>
先定义一个编译类,这个类的构造器,接受两个参数,一个是视图根容器元素,用于遍历搜寻视图中使用的响应式变量,一个是模型中定义的响应式数据,用于视图中的数据初始化赋值。还需要一个编译方法。
export default class Compiler {
constructor(el, data) {
this.data = data;
this.el = el;
// 编译模板
this.compile(this.el);
}
}
在编译方法中,遍历根元素下的子节点,由于通过childNodes
属性获取的子元素中有大量的换行元素,遍历时需要剔除。遍历时先判断节点的类型,是文本节点还是元素节点,{{}}
这种形式的响应式变量一般存在于文本节点中,v-xxx
指令式的响应式变量一般存在于元素节点中,不同类型的节点走不同的处理流程。因为子节点下面可能还有子节点,所以要进行递归遍历。
// 编译模板
compile(el) {
// 获取子节点
let childNodes = [...el.childNodes].filter((node) => {
// 过滤掉换行和空的文本节点
return !(node.nodeType == "3" && node.nodeName == "#text" && /\s/.test(node.nodeValue));
});
childNodes.forEach((node) => {
// 根据不同的节点类型进行编译
// 文本类型的节点
if (this.isTextNode(node)) {
// 编译文本节点
this.compileText(node);
} else if (this.isElementNode(node)) {
//元素节点
this.compileElement(node);
}
// 判断是否还存在子节点考虑递归
if (node.childNodes && node.childNodes.length) {
// 继续递归编译模板
this.compile(node);
}
});
}
// 判断是否是元素节点
isElementNode(node) {
return node.nodeType === 1;
}
// 判断是否是 文本 节点
isTextNode(node) {
return node.nodeType === 3;
}
先看文本节点的处理流程:核心思想是利用把正则表达式,提取出{{}}
里面的响应式变量,并把响应式变量的值赋值给当前节点。由于提取出的变量是data.name
这种多级属性的格式,无法通过this.data[key]
这种写法获取到属性值。所以需要用处理一下。我们写个getNestedProperty
方法处理一下。
// 编译文本节点(简单的实现)
compileText(node) {
// 再去Vue找这个变量赋值给node.textContent
let reg = /\{\{(.+?)\}\}/;
// 获取节点的文本内容
let val = node.textContent;
// 判断是否有 {{}}
if (reg.test(val)) {
// 获取分组一 也就是 {{}} 里面的内容 去除前后空格
let key = RegExp.$1.trim();
let value = getNestedProperty(this.data, key);
// 进行替换再赋值给node
node.textContent = val.replace(reg, value);
// 这里的逻辑后面再说 ...
}
}
在getNestedProperty
函数中,在reduce
方法中对传入的初始值obj
,依次逐级查找切割出来的属性值,最终达到通过obj['a.b.b']
这种书写方式, 获取obj.a.b.c
的值的效果。
export function getNestedProperty(obj, key) {
// 将key按照点号分隔成数组
let keys = key.split(".");
// 遍历数组,逐层深入对象
return keys.reduce((acc, cur) => {
// 如果acc不是对象,或者对象中不含有cur属性,返回undefined
if (acc && acc.hasOwnProperty(cur)) {
return acc[cur];
} else {
return undefined;
}
}, obj);
}
接着在说说元素节点的处理流程,遍历节点属性名称,查找有没有以v-
开头的属性,如果有,提取出指令类型以及绑定的响应式变量。不同的指令走不同的处理流程。为了避免大量的判断,不同的指令执行的方法名借鉴了策略模式的思想。以指令名+固定后缀进行命名。
// 编译元素节点这里只处理指令
compileElement(node) {
// 获取到元素节点上面的所有属性进行遍历
[...node.attributes].forEach((attr) => {
// 获取属性名
let attrName = attr.name;
// 判断是否是 v- 开头的指令
if (this.isDirective(attrName)) {
// 除去 v- 方便操作
attrName = attrName.substr(2);
// 获取 指令的值就是 v-text = "data.name" 中data.name
let key = attr.value.trim();
// 找到指令后执行指令操作
// vue指令为了避免大量判断,使用了策略模式
this.update(node, key, attrName);
}
});
}
// 添加指令方法 并且执行
update(node, key, attrName) {
// 比如添加 textUpdater 就是用来处理 v-text 方法
// 我们应该就内置一个 textUpdater 方法进行调用
// 使用了策略模式思想
let updateFn = this[attrName + "Updater"];
// 如果存在这个内置方法 就可以调用了
updateFn && updateFn.call(this, node, key, getNestedProperty(this.data, key));
}
// 判断元素的属性是否是 vue 指令
isDirective(attr) {
return attr.startsWith("v-");
}
v-text
指令执行textUpdater
方法,v-model
指令执行modelUpdater
方法, 都会将响应式变量的值赋值给节点,不同的是v-model
指令需要添加dom事件,这样才能将视图数据同步给响应式变量。dom事件中的赋值操作需要处理一下,setNestedProperty
与getNestedProperty
是对称操作,内部逻辑参照getNestedProperty
理解,就不详细展开了。
textUpdater(node, key, value) {
node.textContent = value;
// 这里的功能后面再讲
}
modelUpdater(node, key, value) {
node.value = value;
// 这里实现双向绑定
node.addEventListener("input", () => {
// 由于key是data.name的形式,不能直接通过this.data['data.name']这种方式赋值,需要用setNestedProperty处理一下
setNestedProperty(this.data, key, node.value);
});
// 这里的功能后面再讲
}
function setNestedProperty(obj, key, value) {
// 将key按照点号分隔成数组
let keys = key.split(".");
// 最后一个属性名
let lastKey = keys.pop();
// 遍历数组,逐层深入对象
let nested = keys.reduce((acc, cur) => {
if (!acc[cur]) {
acc[cur] = {}; // 如果不存在该属性则创建空对象
}
return acc[cur];
}, obj);
// 设置最后一级的属性值
nested[lastKey] = value;
}
现在已经能自动扫描出视图中用到的响应式变量了,并给相应的dom节点赋初始值。可是又面临下一个问题,当响应式数据和视图元素是一对一时,当响应式变量发生改变时,我们可以这样更新视图:
const reactive = (obj) => {
return Proxy(obj, {
set: function (target, key, value, receiver) {
document.querySelector('#name').value=value;
return Reflect.set(target, key, value, receiver);
},
});
};
可是实际应用场景中响应式变量和视图元素往往是是多对多的关系,如果还是按照原来的思路去编码。就像下面这样:
const reactive = (obj) => {
return Proxy(obj, {
set: function (target, key, value, receiver) {
//
if (key === "x1") {
domA.value = value;
domB.textContent = value;
// ...
} else if (key === "x2" || key === "x3") {
if(key === "x2"){
domC.value =value+target.x3;
}else if(key === "x3"){
domC.value =value+target.x2;
}
// ...
}
// ...
return Reflect.set(target, key, value, receiver);
},
});
};
当视图和模型数据都很多的时候,它们之间的依赖关系会变得难以维护。面对这个问题,得考虑使用设计模式来破局。那应该使用哪种设计模式呢?根据使用场景,你应该很容易想到观察者模式,对,就得用它,解决视图和模型之间依赖关系的维护问题。
引入观察者模式
使用观察者模式时,首先要清楚谁是被观察者,谁是观察者。很明显,在响应式系统这种场景中,模型数据是被观察者,视图是观察者。观察者模式的运作方式是被观察者把观察者添加到消息通知队列,当被观察对象属性发生变化时,通知所有的观察者。观察者需要实现一个接收通知方法,当收到新的消息时,执行自定义逻辑,一般多为更新数据操作。思路清晰了,现在我们理论结合一下实践,先实现被观察者的功能。
被观察者至少应该有两个方法,一个方法用来添加观察者(如addSub),另外一个方法用来通知观察者队列(如notify),
/* dep.js */
export default class Dep {
constructor() {
// 存储观察者
this.subs = [];
}
// 添加观察者
addSub(sub) {
// 判断观察者是否存在 和 是否拥有update方法
if (sub && sub.update) {
this.subs.push(sub);
}
}
// 通知方法
notify() {
// 触发每个观察者的更新方法
this.subs.forEach((sub) => {
sub.update();
});
}
}
观察者至少要实现一个接收通知的方法(如update),当被观察对象变化时,供被观察者通知调用。
/* watcher.js */
import Dep from "./dep.js";
import { getNestedProperty } from "./compiler.js";
export default class Watcher {
constructor(data, key, cb) {
this.data = data;
// key 是 data 中的属性
this.key = key;
// cb 回调函数 更新视图的具体方法
this.cb = cb;
// 把观察者的存放在 Dep.target
Dep.target = this;
// 旧数据 更新视图的时候要进行比较
// 在get 方法中把 观察者 通过dep.addSub(Dep.target) 添加到了依赖通知队列 dep.subs中
this.oldValue = getNestedProperty(data, key);
// Dep.target 就不用存在了 因为上面的操作已经存好了
Dep.target = null;
}
// 观察者中的必备方法 用来更新视图
update() {
// 获取新值
let newValue = getNestedProperty(this.data, this.key);
// 比较旧值和新值
if (newValue === this.oldValue) return;
this.oldValue = newValue;
// 调用具体的更新方法
this.cb(newValue);
}
}
何时添加观察者呢?应该在数据劫持的get方法中,将观察者添加到被观察者维护的观察者队列。那怎样才能触发get方法执行这个流程呢?
import Dep from "./dep.js";
export const reactive = (obj) => {
let dep = new Dep();
return Proxy(obj, {
get: function (target, key, receiver) {
// 在这里添加观察者对象 Dep.target 表示观察者
Dep.target && dep.addSub(Dep.target);
return Reflect.get(target, key, receiver);
},
// ...
});
};
肯定是应该在compile函数中,第一次将响应式变量赋值给dom节点的时候,收集观察者比较合理。给视图中的dom元素赋值的时候,创建对应响应式变量的观察者,执行观察者类的构造器逻辑。
/* compiler.js */
import Watcher from "./watcher.js";
export default class Compiler {
// ...
// 编译文本节点(简单的实现)
compileText(node) {
// ...
// 进行替换再赋值给node
node.textContent = val.replace(reg, value);
// 创建观察者
new Watcher(this.data, key, (newValue) => {
node.textContent = newValue;
});
}
}
// 提前写好 相应的指定方法比如这个 v-text
textUpdater(node, key, value) {
node.textContent = value;
// 创建观察者
new Watcher(this.data, key, (newValue) => {
node.textContent = newValue;
});
}
// v-model
modelUpdater(node, key, value) {
node.value = value;
// 创建观察者
new Watcher(this.data, key, (newValue) => {
node.value = newValue;
});
// ...
}
// ...
当执行到观察者构造器方法中的 this.oldValue = getNestedProperty(data, key);
这一句时,就会触发劫持数据中的get方法。
export default class Watcher {
constructor(data, key, cb) {
// ...
// 把观察者的存放在 Dep.target
Dep.target = this;
// 触发响应式变量的get方法,在get方法中把观察者通过dep.addSub(Dep.target) 添加到了依赖通知队列dep.subs中
this.oldValue = getNestedProperty(data, key);
// Dep.target 就不用存在了 因为上面的操作已经存好了
Dep.target = null;
}
此时Dep.target在前一步已经赋值,所以Dep.target && dep.addSub(Dep.target);
会得到执行,当劫持数据的get方法逻辑执行完成之后,接着执行会观察者构造器方法中的Dep.target = null;
,不再符合将观察者添加到观察者列表的条件。
何时通知观察者,被观察者对象有变化? 应该在劫持数据的set方法中,通知观察者队列,被观察对象有更新。通过调用被观察者的notify
,遍历执行每个观察者的update方法,通知相应的观察者,数据有更新。
import Dep from "./dep.js";
export const reactive = (obj) => {
let dep = new Dep();
return Proxy(obj, {
// ...
set: function (target, key, value, receiver) {
const ret = Reflect.set(target, key, value, receiver);
// 触发通知 更新视图
dep.notify();
return ret;
},
});
};
每个观察者执行视图数据更新的回调,就实现了模型数据更新,依赖这个模型数据的所有视图随之更新的效果。
现在我们把整个流程串一下:先定义响应式数据,对数据进行代理,在get方法中,收集观察者。在set方法中,通知观察者队列,数据有更新。 接着执行编译流程,递归遍历传入的根容器元素下面的所有子元素,识别出响应式变量并创建观察者,此外,对于需要进行双向数据绑定的变量,还需要创建响应的dom事件,在dom事件中完成视图数据改变到模型数据的更新操作。到此,就能实现本文开头的效果了,一个极简版的Vue响应式系统,已经被我们实现了。
<html>
<head>
<style>
.label {
display: inline-block;
padding-left: 2px;
width: 110px;
}
</style>
</head>
<body>
<div id="app">
<div>
<span class="label">双括号变量:</span>
<span>{{data.name}}</span>
</div>
<div>
<span>v-model变量:</span>
<input id="name" v-model="data.name" type="text" />
</div>
<div>
<span class="label">v-text变量:</span>
<span v-text="data.name"></span>
</div>
</div>
</body>
<script type="module">
import { reactive } from "./reactive.js";
import Compiler from "./compiler.js";
let data = reactive({ name: "张三" });
new Compiler(document.querySelector("#app"), { data });
</script>
</html>
另外,reactive
的实现方法需要改进一下,因为Proxy
只能监听到对象第一层属性的变化,如果要支持对更深层次的属性的数据劫持,就需要做如下修改。
import Dep from "./dep.js";
export const reactive = (obj) => {
let dep = new Dep();
return deepProxy(obj, {
get: function (target, key, receiver) {
// 在这里添加观察者对象 Dep.target 表示观察者
Dep.target && dep.addSub(Dep.target);
return Reflect.get(target, key, receiver);
},
set: function (target, key, value, receiver) {
// 判断旧值和新值是否相等
if (target[key] === value) return true;
const ret = Reflect.set(target, key, value, receiver);
// 触发通知 更新视图
dep.notify();
return ret;
},
});
};
function deepProxy(target, handler) {
if (typeof target !== "object" || target === null) {
throw new Error("Target must be an object");
}
if (Array.isArray(target)) {
target = target.slice();
} else {
target = { ...target };
}
for (let key in target) {
if (typeof target[key] === "object" && target[key] !== null) {
target[key] = deepProxy(target[key], handler);
}
}
return new Proxy(target, handler);
}
最后
没动手亲自实现Vue的响应式系统之前,一直对数据劫持get方法中,观察者被添加到观察者队列的时机不是很清楚。对编译函数具体做了什么事情,也不是特别清楚,亲自手敲了一遍之后,才感觉豁然开朗。感觉很有收获。希望你看完此文,也能对Vue响应式的实现拨云见雾。本文的代码已上传到码云,欢迎点击下载,学习,探讨交流。
转载自:https://juejin.cn/post/7388347353407537189