这是我参与8月更文挑战的第N天,活动详情查看:8月更文挑战(已经更文3天)
前言
在手把手教你实现vue数据双向绑定(上)中,我们实现了一个青春阳光版
的简易的vue
。它实现了数据的双向绑定,但是仅此而已。今天我们在继续完善我们的简易版vue
,给它加入模板编译
以及深度监听对象变化
的功能。
修改模板
在上一篇文章中,我们最终实现的vue
的HTML结构是这样子的:
<div id="app">
<input type="text" id="ceshi">
<p id="watcher"></p>
</div>
复制代码
这跟我们平时开发的.vue
文件好像长得不太一样,缺少了指令
以及双括号语法
等。所以,我们把它给改造一下:
<div id="app">
val:<input type="text" v-model="val" name="">
info:<input type="text" v-model="info.text" name="">
<p v-bind="val"></p>
<p v-bind="info.text"></p>
<p>属性节点:{{val}}</p>
<p>对象节点:{{ info.text}}</p>
</div>
复制代码
之后,我们把定义在Vue.js
里的测试方法
部分代码给删除了。我们再来看看现在页面的效果:
想一想为什么失去了数据双向绑定
的功能?我们的data
对象数据劫持这部分代码还是保留着的,那么在我们删除掉的测试方法
里,删除掉了什么部分呢?我们来分析一下测试方法
都有些什么:
// 测试方法 - 监听input输入
const input = document.getElementById("ceshi");
input.addEventListener( "input", (e) => { vm.$data.val = e.target.value; // 赋值操作 }, false );
// 获取观众
const watcher = document.getElementById("watcher");
// 新建观众
// 这里订阅的是 val 这个频道
new Watcher(vm.$data, "val", (newVal) => { watcher.innerHTML = newVal; });
复制代码
-
第一部分是
监听input输入
,我们之前是直接获取这个input节点,给它绑定上事件来实现的。而现在经过我们的改造,我们在input上面绑定上了v-model
指令,所以我们的模板编译
的一个目标出来了:通过v-model
指令来实现input的输入监听包括初始化的赋值。 -
第二部分是获取需要实现
数据双向绑定
的节点,当data
对象改变时自动改变其文本节点的值。而经过我们的改造,我们在p标签上绑定了v-bind
指令和在文本节点上添加了{{}}双括号语法
,所以我们的模板编译
的第二个目标出来了:通过v-bind
指令和在文本节点上添加{{}}双括号语法
实现文本节点的自动更新。
模板编译
我们在Vue.js
的原型上面新增一个complie
方法,用于进行我们的模板编译
:
Vue.prototype.complie = function (root) {
// 遍历根节点下的所有节点,解析指令
const nodes = root.children;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
// 如果还有子节点,则递归遍历
if (node.children.length) {
this.complie(node);
}
}
};
复制代码
这个方法接受一个参数,这个参数就是我们在定义vue实例
时传入的根节点。我们在这个方法中,遍历此根节点下的所有节点,去逐个检测每个节点中是否存在指令
或者双括号语法
。
我们现在是直接操作浏览器的DOM元素,这样做是非常消耗性能的。而vue处理模板编译是先把template转换成render,通过虚拟DOM去操作真实DOM,具体的在这边就不展开了。
解析模板指令
我们先来实现解析模板指令这一功能,具体的实现逻辑就是:通过hasAttribute
去判断某个节点是否存在我们自定义的指令,通过getAttribute
去获取这个指令所绑定的data
对象的具体属性
(也就是频道)。
v-model
v-model
的具体作用是绑定input
或者textarea
标签的输入事件,当输入改变时,改变data
对象中的具体属性的值,来发布订阅
:
if (
node.hasAttribute("v-model") &&
(node.tagName == "INPUT" || node.tagName == "TEXTAREA")
) {
// 如果元素绑定了 v-model指令 且 元素为输入框
node.addEventListener(
"input",
(e) => {
// 赋值对应的属性,更新订阅
const attr = node.getAttribute("v-model");
this.$data[attr] = e.target.value;
},
false
);
}
复制代码
v-bind
既然发布了订阅,那么就要有响应这些订阅的地方,我们的v-bind
就是响应订阅:
if (node.hasAttribute("v-bind")) {
const attr = node.getAttribute("v-bind");
// 创建 观众 - 自动订阅频道
new Watcher(this.$data, attr, (newVal) => {
node.innerText = newVal;
});
}
复制代码
我们现在的指令已经基本上都解析好了,但是我们先不着急看效果,再来把双括号语法给解析一下。
解析双括号
跟v-bind
的思路一样,区别在于双括号语法是存在文本节点中的,我们无法通过getAttribute
去直接获取,所以我们要解析存在双括号语法的文本节点:
if (node.childNodes.length !== 0) {
const childList = node.childNodes;
childList.forEach((child) => {
if (child.nodeType === 3) {
// 文本节点
replaceStr(this.$data, child);
}
});
}
复制代码
replaceStr
则是我们定义的一个解析文本节点并替换的方法,我们新建一个utils
文件夹,在该文件夹下新建一个index.js
来存放我们的这个方法:
src/utils/index.js
import Watcher from "../core/Watcher";
export const replaceStr = function (data, node) {
const replaceReg = /\{\{((?:.|\r?\n)+?)\}\}/g;
const attrReg = /\{\{((?:.|\r?\n)+?)\}\}/;
const str = node.data;
const attr = str.match(attrReg)[1].trim();
// 初始化赋值
const initRes = str.replace(replaceReg, function (...args) {
return data[attr];
});
node.data = initRes;
// 创建 观众 - 自动订阅频道
new Watcher(data, attr, (newVal) => {
const res = str.replace(replaceReg, function (...args) {
return newVal;
});
node.data = res;
});
};
复制代码
验证一下
我们在Vue.js
中引入模板编译的方法:
Vue.js
import Dep from "./Dep";
import Watcher from "./Watcher";
import { replaceStr } from "../utils";
export default class Vue {
constructor(option) {
this._init(option);
}
}
Vue.prototype._init = function (option) {
const vm = this;
vm.$el = document.querySelector(option.el); // 项目根节点
vm.$data = option.data(); // data对象
// 劫持data对象
vm.defineReactive(vm.$data);
vm.complie(vm.$el);
};
Vue.prototype.defineReactive = function (obj) {
if (!(obj instanceof Object)) return;
// 遍历对象
for (let key in obj) {
let val = obj[key]; // 赋值
const dep = new Dep();
Object.defineProperty(obj, key, {
enumerable: true, // 是否可枚举
configurable: true, // 是否可配置
get() {
console.log(`get:${key} - ${val}`);
dep.depend(); // 订阅
return val;
},
set(newVal) {
if (newVal === val) return;
console.log(`set:${key} - ${newVal}`);
val = newVal;
dep.notify(); // 发布
},
});
}
};
Vue.prototype.complie = function (root) {
// 遍历根节点下的所有节点,解析指令
const nodes = root.children;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
// 如果还有子节点,则递归遍历
if (node.children.length) {
this.complie(node);
}
/*-----------------解析模板指令-------------------*/
// 绑定子节点指令
if (
node.hasAttribute("v-model") &&
(node.tagName == "INPUT" || node.tagName == "TEXTAREA")
) {
// 如果元素绑定了 v-model指令 且 元素为输入框
node.addEventListener(
"input",
(e) => {
// 赋值对应的属性,更新订阅
const attr = node.getAttribute("v-model");
this.$data[attr] = e.target.value;
},
false
);
}
if (node.hasAttribute("v-bind")) {
const attr = node.getAttribute("v-bind");
// 创建 观众 - 自动订阅频道
new Watcher(this.$data, attr, (newVal) => {
node.innerText = newVal;
});
}
/*-----------------解析 {{}} -------------------*/
if (node.childNodes.length !== 0) {
const childList = node.childNodes;
childList.forEach((child) => {
if (child.nodeType === 3) {
// 文本节点
replaceStr(this.$data, child);
}
});
}
}
};
复制代码
效果如下:
我们可以看到,data
对象里的val
属性已经监听成功了,但是info
属性却还是没有成功,打印除了undefined。这是为什么呢?原来我们读取data
对象属性的时候,是用的this.$data[attr]
,如果属性是一个基础类型,这样取值并没有什么问题,但如果是一个复杂类型呢?那么我们的取值后就变成了this.$data['info.text']
,这样当然打印出的就是undefined咯。所以,我们还得解析对象变化
。
深度监听对象变化
既然是深度监听对象变化,我们的劫持对象事件也需要递归进行深度监听,因为之前我们只监听了第一层的变化。我们来修改一下我们的defineReactive
方法:
修改defineReactive方法
Vue.prototype.defineReactive = function (obj) {
if (!(obj instanceof Object)) return;
// 遍历对象
for (let key in obj) {
let val = obj[key]; // 赋值
if (val instanceof Object) {
this.defineReactive(val);
}
const dep = new Dep();
Object.defineProperty(obj, key, {
enumerable: true, // 是否可枚举
configurable: true, // 是否可配置
get() {
// console.log(`get:${key} - ${val}`);
dep.depend(); // 订阅
return val;
},
set(newVal) {
if (newVal === val) return;
// console.log(`set:${key} - ${newVal}`);
val = newVal;
dep.notify(); // 发布
},
});
}
};
复制代码
修改data对象取值方式
我们不能再通过简单的点语法取值了,那么我们该如何取值data
对象的属性呢?我们这里只分析点语法取值,也就是a.b.c.d
之类的语法解析,中括号语法暂且不解析。那么该怎么取值呢?我们来分析一下a.b.c.d
,一个字符串用句号分开,是不是很熟悉?直接用split('.')
截取呀,这样我们就能拿到一个层级列表。我们再遍历这个列表,一层一层向下取值,是不是就能完成了。话不多说,让我们来实现吧:
src/utils/index.js
var unicodeRegExp = /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/;
/**
* Parse simple path.
*/
var bailRE = new RegExp('[^' + unicodeRegExp.source + '.$_\\d]');
// 取值:当 v-bind {{}} 绑定的值是对象时,取到data中的对象值
export const parsePath = function (path) {
if (bailRE.test(path)) return;
var segments = path.split('.');
return function (obj) {
for (var i = 0; i < segments.length; i++) {
if (!obj) {
return;
}
obj = obj[segments[i]];
}
return obj;
};
};
复制代码
既然取值有了,那么赋值呢?同样的思路,我们实现一下:
src/utils/index.js
// 赋值:当 v-model 绑定的值是对象时,赋值给data对象
export const setAttr = function (obj, path, val) {
if (!obj) return;
if (bailRE.test(path)) return;
var segments = path.split('.');
for (var i = 0; i < segments.length - 1; i++) {
obj = obj[segments[i]];
}
obj[segments[segments.length - 1]] = val;
};
复制代码
至此,来看下我们的src/utils/index.js
的完整代码:
src/utils/index.js
import Watcher from "../core/Watcher";
var unicodeRegExp =
/a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/;
/**
* Parse simple path.
*/
var bailRE = new RegExp("[^" + unicodeRegExp.source + ".$_\\d]");
// 取值:当 v-bind {{}} 绑定的值是对象时,取到data中的对象值
export const parsePath = function (path) {
if (bailRE.test(path)) return;
var segments = path.split(".");
return function (obj) {
for (var i = 0; i < segments.length; i++) {
if (!obj) {
return;
}
obj = obj[segments[i]];
}
return obj;
};
};
// 赋值:当 v-model 绑定的值是对象时,赋值给data对象
export const setAttr = function (obj, path, val) {
if (!obj) return;
if (bailRE.test(path)) return;
var segments = path.split(".");
for (var i = 0; i < segments.length - 1; i++) {
obj = obj[segments[i]];
}
obj[segments[segments.length - 1]] = val;
};
export const replaceStr = function (data, node) {
const replaceReg = /\{\{((?:.|\r?\n)+?)\}\}/g;
const attrReg = /\{\{((?:.|\r?\n)+?)\}\}/;
const str = node.data;
const attr = str.match(attrReg)[1].trim();
// 初始化赋值
// 先获取data中此attr属性的值
const getter = parsePath(attr);
const initValue = getter(data);
const initRes = str.replace(replaceReg, function (...args) {
return initValue;
});
node.data = initRes;
// 创建 观众 - 自动订阅频道
new Watcher(data, attr, (newVal) => {
const res = str.replace(replaceReg, function (...args) {
return newVal;
});
node.data = res;
});
};
复制代码
根据我们的工具函数,改造下我们的Vue.js
:
import Dep from "./Dep";
import Watcher from "./Watcher";
import { setAttr, parsePath, replaceStr } from "../utils";
export default class Vue {
constructor(option) {
this._init(option);
}
}
Vue.prototype._init = function (option) {
const vm = this;
vm.$el = document.querySelector(option.el); // 项目根节点
vm.$data = option.data(); // data对象
// 劫持data对象
vm.defineReactive(vm.$data);
vm.complie(vm.$el);
};
Vue.prototype.defineReactive = function (obj) {
if (!(obj instanceof Object)) return;
// 遍历对象
for (let key in obj) {
let val = obj[key]; // 赋值
if (val instanceof Object) {
this.defineReactive(val);
}
const dep = new Dep();
Object.defineProperty(obj, key, {
enumerable: true, // 是否可枚举
configurable: true, // 是否可配置
get() {
// console.log(`get:${key} - ${val}`);
dep.depend(); // 订阅
return val;
},
set(newVal) {
if (newVal === val) return;
// console.log(`set:${key} - ${newVal}`);
val = newVal;
dep.notify(); // 发布
},
});
}
};
Vue.prototype.complie = function (root) {
// 遍历根节点下的所有节点,解析指令
const nodes = root.children;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
// 如果还有子节点,则递归遍历
if (node.children.length) {
this.complie(node);
}
// 这里要先解析双括号,因为要防止双括号初始化会向节点中添加文本节点,会在模板解析中再次初始化
/*-----------------解析 {{}} -------------------*/
if (node.childNodes.length !== 0) {
const childList = node.childNodes;
childList.forEach((child) => {
if (child.nodeType === 3) {
// 文本节点
replaceStr(this.$data, child);
}
});
}
/*-----------------解析模板指令-------------------*/
// 绑定子节点指令
if (
node.hasAttribute("v-model") &&
(node.tagName == "INPUT" || node.tagName == "TEXTAREA")
) {
const attr = node.getAttribute("v-model");
// 初始化赋值
const getter = parsePath(attr);
const initValue = getter(this.$data);
node.value = initValue;
// 如果元素绑定了 v-model指令 且 元素为输入框
node.addEventListener(
"input",
(e) => {
// 当input变化时,更新订阅
setAttr(this.$data, attr, e.target.value);
},
false
);
}
if (node.hasAttribute("v-bind")) {
const attr = node.getAttribute("v-bind");
// 初始化赋值
const getter = parsePath(attr);
const initValue = getter(this.$data);
node.innerText = initValue;
// 创建 观众 - 自动订阅频道
new Watcher(this.$data, attr, (newVal) => {
node.innerText = newVal;
});
}
}
};
复制代码
细心的同学就会发现了,我们把解析双括号语法
提前了,原因就是要防止双括号初始化会向节点中添加文本节点,会在模板解析中再次初始化。感兴趣的小伙伴可以仔细研究一下,这里就不赘述了。
我们再来看下现在的效果:
我们发现:现在data.val
的初始化以及数据响应都是没问题了,但是data.info.text
的初始化是完成了,但是它的数据响应有问题。我们debug一下,到底是哪里出了问题呢?当input输入后,走了setAttr
方法,在defineReactive
中打开set方法
的打印,发现雀食赋值成功了。
我们再往下走,对象节点:123
是一个观众,那么是不是new Watcher
的时候有问题呢?我们来到Watcher
构造函数中,发现它确实有一个取值操作,哦,原来问题出在这里啊~
它还是用的普通的点语法来进行取值操作,这能答应吗?办他!
Watcher.js
import Dep from "./Dep";
import { parsePath } from "../utils";
export default class Watcher {
constructor(obj, key, cb) {
this._data = obj; // data对象
this.key = key; // 频道
this.cb = cb; // 副作用函数
this.getter = parsePath(this.key);
this.get();
}
// 订阅频道
get() {
Dep.target = this;
this.getter.call(this._data, this._data); // 触发data的get方法,进行自动订阅
Dep.target = null;
}
// 更新订阅
update() {
const newVal = this.getter.call(this._data, this._data);
this.cb(newVal);
}
}
复制代码
验证一下
现在,我们的青春版vue
终于实现了,加上了模板编译
和深度监听对象变化
是不是更像那么回事了?但青春版终究还是青春版,还欠缺一些东西:比如监听数组变化
和其他指令v-for
等。这里画个饼,下次一定加上~
结语
通过实现这样一个青春版的vue,我们更加能理解vue
的数据响应原理
。在平时的工作中亦或者是出去找工作的时候,我们在面对面试官提出的:你了解vue数据响应原理
吗?这样的问题时,我们就能爽快的回答:我了解啊,我还动手实现过一个呢~
本文完整代码:完整代码
觉得这篇文章给你带来了收获的小伙伴不妨点个赞点点关注,谢谢您啦,祝您生活愉快。