代码实现 vue 双向绑定系列
上文已经实现了双向绑定的数据劫持、订阅者和发布者,接下来要继续实现双向绑定中的解析器,以及 Vue 这样的 MVVM 框架如何搭建。
参考资料
友情提示:本文可以伴着vue源码一起看,对源码的理解会更加深入
实现 Vue 框架
参照 Vue 的构造形式,生成一个数据绑定的入口,通过 Observer 监听自己的数据的变化,用 Compiler 去解析指定的模块,利用 Watcher 实现解析器和数据监听之间的通信。只要监听到数据变化,Watcher 收到通知就去更新视图。
首先声明一个 Vue 类,获取用户定义的options
并且添加到vm.$options
中。
// vue.js
export default class Vue {
constructor(options) {
const vm = this
vm.$options = options
}
}
复制代码
<div id="app">
<input type="text" id="input" v-model="msg"/>
<p>{{ msg }}</p>
</div>
<script type="module" src="./vue.js"></script>
<script type="module">
import Vue from './vue.js'
var vm = new Vue({
el: '#app',
data: {javascript
msg: 'Hello'
}
})
</script>
复制代码
当我们在index.html
中打印vm.msg
可以看到结果是undefined
,但是使用 Vue 的时候是能打印出值的。因为在 Vue 框架中,这里面还做了一个步骤,那就是代理data
,将里面的值挂载到 Vue 实例里面。这里面主要的方法也是Object.defineProperty
// vue.js
export default class Vue {
constructor(options) {
const vm = this
vm.$options = options
initData(vm)
}
}
function initData(vm) {
var data = vm._data = vm.$options.data
// 这里可以加上对 data props methods 中重复键值的判断
// ...
// 属性代理实现 vm[dataKey]
Object.keys(data).forEach(key => {
proxy(vm, key)
})
}
// 此方法可以拓展一个参数,用来代理实例中不同属性的属性
// 比如 props methods computed 等等
function proxy(target, key) {
Object.defineProperty(target, key, {
configurable: false,
enumerable: true,
get: function() {
return target._data[key]
},
set: function(newVal) {
target._data[key] = newVal
}
})
}
复制代码
这里不要忘记我们需要给data
添加监听器Observer
去监听data
中每个属性的变化,便于我们在解析的添加Watcher
。
import observe from './observer.js'
export default class Vue {
// ...
function initData(vm) {
var data = vm._data = vm.$options.data
// 这里可以加上对 data props methods 中重复键值的判断
// 属性代理实现 vm[dataKey]
Object.keys(data).forEach(key => {
proxy(vm, key)
})
observe(data, this)
}
}
复制代码
这里就实现了一个简易版的 Vue 框架,还要实现一个解析器(Compiler)去连接之前写好的 Watcher 和 Observer。
实现解析器
解析器需要大量的 DOM 操作,要求我们要熟悉 DOM 操作的一些 API,比如获取节点以及节点的信息,获取子节点,对文档碎片的操作等等,看到不懂的 API 自行查阅,这里不详细解释。
首先要先弄清楚解析器需要做什么:
- 获取指定 HTML 文档中所有的节点
- 根据指定的指令(
v-bind
、v-on
、v-model
、{{ xxx }}
等等),对所有节点进行遍历,根据指令的规则对节点中的变量替换成数据或者执行函数。 - 对
v-modal
、{{ xxx }}
,初始化的时候添加数据的订阅者,当数据变化的时候,收到通知去更新视图
从上面的功能点得出首先传入options
中el
,编译器去获取 HTML 文档所有节点
import Compiler from './compile.js'
export default class Vue {
constructor(options) {
const vm = this
// ...
vm.$compile = new Compiler(options.el, vm)
}
}
复制代码
// compile.js
// 编译器 对文档里面的变量替换为数据,并且初始化的时候将每个节点绑定更新函数,添加监听数据的订阅者
// 数据变化的时候,收到通知去更新视图
export default class Compiler {
constructor(el, vm) {
this.$vm = vm
// 获取节点
this.$el = document.querySelector(el);
}
复制代码
由于要进行大量的 DOM 操作,出于性能的考虑,我们先将节点转换为文档碎片document.createDocumentFragment()
进行解析编译,完成之后再将其渲染到 DOM 节点中。
编译过程简单来说就是遍历每一个节点,根据节点类型和规定指令进行不同的编译操作。
// compile.js
export default class Compiler {
constructor(el, vm) {
this.$vm = vm
// 获取节点
this.$el = document.querySelector(el);
if (this.$el) {
// 为了性能,先将节点转换成文档碎片进行解析编译
this.$fragment = this.nodeToFragment(this.$el);
this.init();
// 解析编译完成,再将碎片添加到 dom 节点中
this.$el.appendChild(this.$fragment)
}
}
init() {
this.compileElement(this.$fragment);
}
nodeToFragment(el) {
const fragment = document.createDocumentFragment();
let child;
// 把原生节点拷贝到 fragment
while ((child = el.firstChild)) {
fragment.appendChild(child);
}
return fragment;
}
// 遍历所有节点及子节点进行解析编译
compileElement(el) {
let childNodes = el.childNodes;
[].slice.call(childNodes).forEach((node) => {
// 根据节点类型 nodeType 进行区分编译
// https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeType
// nodeType === 1
if (this.isElementNode(node)) {
this.compile(node);
let text = node.textContent; // 针对 <p>{{ msg }}</p> 中的 content 进行编译解析
const reg = /{{(.*)}}/; // 匹配双括号的正则表达式
if (reg.test(text)) {
const exp = text.match(reg)[1].trim()
this.compileText(node, exp)
}
}
// nodeType === 3
if (this.isTextNode(node)) {
// ...
}
});
}
compile(node) {
const nodeAttrs = node.attributes;
// 对节点属性进行遍历编译
[].slice.call(nodeAttrs).forEach((attr) => {
const attrName = attr.name;
// 根据属性名对规定的指令进行编译
// 比如 v-model v-bind v-on 等等
// 这里只对 v-model 进行编译
if (this.isDirective(attrName)) {
const exp = attr.value; // 取出绑定的 key
const dir = attrName.substring(2); // 获取对应的指令 bind model on等等
if (this.isEventDirective(dir)) {
// v-on 事件指令
// ...
} else {
// 其余普通指令
// compileUtil 自定义一个编译工具
if(compileUtil[dir]) {
compileUtil[dir] && compileUtil[dir](node, this.$vm, exp)
}
}
}
});
}
// utils
isDirective(attr) {
return attr.indexOf("v-") == 0;
}
isElementNode(node) {
return node.nodeType === 1;
}
isTextNode(node) {
return node.nodeType === 3;
}
isEventDirective(dir) {
return dir.indexOf("on") === 0;
}
}
// 自定义一个编译工具
const compileUtil = {
// v-model
model(node, vm, exp) {
},
// {{ exp }}
text(node, vm, exp) {
},
}
复制代码
到这里我们就已经完成了对每个节点进行遍历,并且根据节点类型和规定指令进入到了不同的编译工具compileUtil
里。不同的指令对应的编译流程不同,这里只针对{{ }}
和 v-model
进行编译,其余的指令可以参考这两个指令的思路自己编写。
不同指令的解析
解析工具的实现思路:
- 写一个对于这个指令的更新函数(当数据发生变化的时候要对这个节点做什么)
- 执行一次更新函数(相当于进行初始化)
new Watcher
绑定一个 Wacther 添加到这个属性的Dep
中,当数据发生改变的时候,Watcher
触发更新函数对节点进行操作
不同指令还要对该节点做一些操作(比如绑定事件),具体的更新函数要针对不同的指令进行编写。
v-model
对于v-model
来说,我们需要对该节点绑定一个输入事件(或者是选择事件,这里还可以根据节点来判断,这里暂不做判断),当用户输入的时候去更新data
里面对应的属性。
// compile.js
// 指令解析
const compileUtil = {
model(node, vm, exp) {
let val = this._getVal(vm, exp);
// 对节点添加监听事件
node.addEventListener("input", (e) => {
const newVal = e.target.value;
if (val === newVal) {
return;
}
this._setVal(vm, exp, newVal);
val = newVal;
});
},
_getVal(vm, exp) {
let val = vm;
// 对象遍历取值
exp = exp.split(".");
exp.forEach((key) => {
val = val[key];
});
return val;
},
_setVal(vm, exp, value) {
let val = vm;
// 对深层对象进行赋值
exp = exp.split(".");
exp.forEach((key, index) => {
if (index !== exp.length - 1) {
val = val[key];
} else {
val[key] = value;
}
});
},
}
复制代码
由于我们在initData()
方法中已经添加了Observer
,我们在控制台就已经能看到data.msg
的订阅者数组,调用_getVal()
方法时触发了getter
,手动在 input 框输入内容导致data.msg
改变的时候触发setter
。
上面实现了手动输入之后去改变对应data
中属性的值,接下来要
- 实现一个更新函数。
- 初始化的时候执行一次更新函数,将
data
中属性的值渲染到 input 框中。 - 给它添加一个
Watcher
,在 JS 中手动修改data
的值,input 框的值会发生改变,这就完成了双向绑定。
//index.html
// ...
<script type="module" src="./vue.js"></script>
<script type="module">
import Vue from './vue.js'
var vm = new Vue({
el: '#app',
data: {
msg: 'Hello'
}
})
// 模拟 JS 中修改 data 的数据
setTimeout(() => {
vm.msg = 'Hi'
}, 2000)
</script>
复制代码
// compile.js
// 指令解析
const compileUtil = {
model(node, vm, exp) {
this.bind(node, vm, exp, 'model')
let val = this._getVal(vm, exp); // 3 - 触发了一次 getter
// ...
},
bind(node, vm, exp, dir) {
// 获取指令对应的更新函数,在 Watcher 的 update() 中调用
const update = updater[dir + 'Updater']
// 执行一次更新函数,相当于初始化
update && update(node, this._getVal(vm, exp)) // 1 - 触发了一次 getter
// 绑定一个 Watcher
var watcher = new Watcher(vm, exp, (value, oldValue) => { // 2 - 触发了一次 getter
update && update(node, value)
})
},
// ...
};
// 更新函数集合
// 不同的指令进行不同的更新动作
const updater = {
modelUpdater(node, value, oldValue) {
node.value = value
}
}
复制代码
完成之后可以看到 input 框中初识显示的“Hello”,在2s后自动修改为“Hi”,控制台也能看到订阅者里面已经成功添加了一个Watcher
。这样我们v-model
指令就已经完成了。
模板字符串 {{ msg }}
这个实现起来比较简单,只需要将节点的textContent
中有双括号的字符替换成data
中的属性值就可以。直接上代码
// compile.js
export default class Compiler {
// 遍历所有节点及子节点进行解析编译
compileElement(el) {
let childNodes = el.childNodes;
[].slice.call(childNodes).forEach((node) => {
if (this.isElementNode(node)) {
this.compile(node);
let text = node.textContent; // 针对 <p>{{ msg }}</p> 中的 content 进行编译解析
const reg = /{{(.*)}}/; // 匹配双括号的正则表达式
if (reg.test(text)) {
const exp = text.match(reg)[1].trim()
this.compileText(node, exp)
}
}
});
}
compileText(node, exp) {
compileUtil.text(node, this.$vm, exp);
}
}
const compileUtil = {
// ...
text(node, vm, exp) {
this.bind(node, vm, exp, 'text')
},
}
const updater = {
textUpdater(node, value, oldValue) {
node.textContent = value
}
}
复制代码
好了现在两个指令都已经完成,双向绑定的编写也完成了,两篇文章实现了一个简易的 Vue 双向绑定架构,如果你也动手写完了这些代码,我相信你在理解 Vue 源码的时候也是有一定帮助的。
码字不易,若有收获,看完点个赞~