「这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战」。
本文实现了一个自定义vue,逐步实现了数据的双向绑定,即数据驱动视图,视图驱动数据
文末有总结
创建vue类
-
1,vue最少需要两个参数:模板和数据data
-
2,创建Compiler类,用于解析模板中的vue指令,将所需的data渲染到模板中,最后挂载到指定跟节点上。
-
3,创建Observer类,用于对data的每个属性都进行get/set拦截。在Compiler类解析模板的时候会触发get拦截,这时候就可以获取到该data所被依赖的所有dom节点,当修改该data的值时,会触发set,就可以将收集的所有依赖dom依次修改,实现了数据驱动视图。
class MyVue {
// 1,接收两个参数:模板(根节点),和数据对象
constructor(options) {
// 保存模板,和数据对象
if (this.isElement(options.el)) {
this.$el = options.el;
} else {
this.$el = document.querySelector(options.el);
}
this.$data = options.data;
if (this.$el) {
// 2,拦截data所有属性的get/set
new Observer(this.$data);
// 3,解析模板中的vue指令
new Compiler(this)
}
}
// 判断是否是一个dom元素
isElement(node) {
return node.nodeType === 1;
}
}
解析模板
实现数据首次渲染到页面
Compiler
1,node2fragment 函数将模板元素提取到内存中,方便将数据渲染到模板后,再一次性挂载到页面中
2,模板提取到内存后,使用 buildTemplate 函数遍历该模板元素
-
元素节点
- 使用 buildElement 函数检查元素上以v-开头的属性
-
文本节点
- 用 buildText 函数检查文本中有无 {{}} 内容
3,创建 CompilerUtil 类,用于处理vue指令和 {{}},完成数据的渲染
4,到此就完成了首次数据渲染,接下来需要实现:数据改变时,自动更新视图。
class Compiler {
constructor(vm) {
this.vm = vm;
// 1.将网页上的元素放到内存中
let fragment = this.node2fragment(this.vm.$el);
// 2.利用指定的数据编译内存中的元素
this.buildTemplate(fragment);
// 3.将编译好的内容重新渲染会网页上
this.vm.$el.appendChild(fragment);
}
node2fragment(app) {
// 1.创建一个空的文档碎片对象
let fragment = document.createDocumentFragment();
// 2.编译循环取到每一个元素
let node = app.firstChild;
while (node) {
// 注意点: 只要将元素添加到了文档碎片对象中, 那么这个元素就会自动从网页上消失
fragment.appendChild(node);
node = app.firstChild;
}
// 3.返回存储了所有元素的文档碎片对象
return fragment;
}
buildTemplate(fragment) {
let nodeList = [...fragment.childNodes];
nodeList.forEach(node => {
// 需要判断当前遍历到的节点是一个元素还是一个文本
if (this.vm.isElement(node)) {
// 元素节点
this.buildElement(node);
// 处理子元素
this.buildTemplate(node);
} else {
// 文本节点
this.buildText(node);
}
})
}
buildElement(node) {
let attrs = [...node.attributes];
attrs.forEach(attr => {
// v-model="name" => {name:v-model value:name}
let { name, value } = attr;
// v-model / v-html / v-text / v-xxx
if (name.startsWith('v-')) {
// v-model -> [v, model]
let [_, directive] = name.split('-');
CompilerUtil[directive](node, value, this.vm);
}
})
}
buildText(node) {
let content = node.textContent;
let reg = /\{\{.+?\}\}/gi;
if (reg.test(content)) {
CompilerUtil['content'](node, content, this.vm);
}
}
}
工具类CompilerUtil
let CompilerUtil = {
getValue(vm, value) {
// 解析this.data.aaa.bbb.ccc这种属性
return value.split('.').reduce((data, currentKey) => {
return data[currentKey.trim()];
}, vm.$data);
},
getContent(vm, value) {
// 解析{{}}中的变量
let reg = /\{\{(.+?)\}\}/gi;
let val = value.replace(reg, (...args) => {
return this.getValue(vm, args[1]);
});
return val;
},
// 解析v-model指令
model: function (node, value, vm) {
// 在触发getter之前,为dom创建Wather,并为Watcher.target赋值
new Watcher(vm, value, (newValue, oldValue) => {
node.value = newValue;
});
let val = this.getValue(vm, value);
node.value = val;
},
// 解析v-html指令
html: function (node, value, vm) {
// 在触发getter之前,为dom创建Wather,并为Watcher.target赋值
new Watcher(vm, value, (newValue, oldValue) => {
node.innerHTML = newValue;
});
let val = this.getValue(vm, value);
node.innerHTML = val;
},
// 解析v-text指令
text: function (node, value, vm) {
// 在触发getter之前,为dom创建Wather,并为Watcher.target赋值
new Watcher(vm, value, (newValue, oldValue) => {
node.innerText = newValue;
});
let val = this.getValue(vm, value);
node.innerText = val;
},
// 解析{{}}中的变量
content: function (node, value, vm) {
let reg = /\{\{(.+?)\}\}/gi;
let val = value.replace(reg, (...args) => {
// 在触发getter之前,为dom创建Wather,并为Watcher.target赋值
new Watcher(vm, args[1], (newValue, oldValue) => {
node.textContent = this.getContent(vm, value);
});
return this.getValue(vm, args[1]);
});
node.textContent = val;
}
}
实现数据驱动视图
Observer
1,使用 defineRecative 函数对 data 做 Object.defineProperty 处理,使得可以拦截 data 中的每个数据的get/set。
class Observer {
constructor(data) {
this.observer(data);
}
observer(obj) {
if (obj && typeof obj === 'object') {
// 遍历取出传入对象的所有属性, 给遍历到的属性都增加get/set方法
for (let key in obj) {
this.defineRecative(obj, key, obj[key])
}
}
}
// obj: 需要操作的对象
// attr: 需要新增get/set方法的属性
// value: 需要新增get/set方法属性的取值
defineRecative(obj, attr, value) {
// 如果属性的取值又是一个对象, 那么也需要给这个对象的所有属性添加get/set方法
this.observer(value);
Object.defineProperty(obj, attr, {
get() {
return value;
},
set: (newValue) => {
if (value !== newValue) {
// 如果给属性赋值的新值又是一个对象, 那么也需要给这个对象的所有属性添加get/set方法
this.observer(newValue);
value = newValue;
console.log('监听到数据的变化');
}
}
})
}
}
2,接下来将考虑:如何在监听到data值改变后,更新视图内容呢?
- 使用观察者设计模式,创建Dep和Wather类。
使用观察者设计模式,创建Dep和Wather类
1,在解析模板,收集data中各个属性在模板中被引用的dom节点集合,当该数据改变时,更新依赖了该数据的dom节点集合,就实现了数据驱动页面更新。
2,创建Dep类和Watcher类
-
Dep:
用于收集某个data属性依赖的dom节点集合,并提供更新方法
-
Watcher:
每个dom节点的包裹对象
- attr:该dom使用的data属性
- cb:修改该dom内容的回调函数,在对象创建的时候会接收
3,到这里感觉思路是没问题了,已经是胜券在握了。那Dep和Watcher该怎么使用呢?
-
为data的每个属性添加一个dep数组,用来收集依赖的dom节点。
-
因为vue实例初始化的时候会解析模板,会触发data数据的getter,所以在此收集dom。
-
具体如何收集呢?
-
在CompilerUtil类解析v-model,{{}}等命令时,会触发getter。
-
我们在触发之前创建Wather对象,该对象在初始化的时候调用getOldValue,首先为Dep添加一个静态属性target,值为该dom节点。
-
再调用CompilerUtil.getValue,获取该data的当前值,此时就以及触发了getter。然后我们在getter函数里面获取该静态变量Dep.target,并添加到对应的依赖数组dep中了,就完成了一次收集。
-
因为每次触发getter之前都对该静态变量赋值,所以不存在收集错依赖的情况。
-
class Dep {
constructor() {
// 这个数组就是专门用于管理某个属性所有的观察者对象的
this.subs = [];
}
// 订阅观察的方法
addSub(watcher) {
this.subs.push(watcher);
}
// 发布订阅的方法
notify() {
this.subs.forEach(watcher => watcher.update());
}
}
class Watcher {
constructor(vm, attr, cb) {
this.vm = vm;
// 该dom使用的data属性
this.attr = attr;
// 修改该dom内容的回调函数
this.cb = cb;
// 在创建观察者对象的时候就去获取当前的旧值
this.oldValue = this.getOldValue();
}
getOldValue() {
Dep.target = this;
let oldValue = CompilerUtil.getValue(this.vm, this.attr);
Dep.target = null;
return oldValue;
}
// 定义一个更新的方法, 用于判断新值和旧值是否相同
update() {
let newValue = CompilerUtil.getValue(this.vm, this.attr);
if (this.oldValue !== newValue) {
this.cb(newValue, this.oldValue);
}
}
}
4,修改get/set方法
defineRecative(obj, attr, value) {
this.observer(value);
// 1,创建了属于当前属性的依赖收集对象
let dep = new Dep();
Object.defineProperty(obj, attr, {
get() {
// 2,在这里收集依赖
Dep.target && dep.addSub(Dep.target);
return value;
},
set: (newValue) => {
if (value !== newValue) {
// 如果给属性赋值的新值又是一个对象, 那么也需要给这个对象的所有属性添加get/set方法
this.observer(newValue);
value = newValue;
// 通知到视图更新
dep.notify();
console.log('监听到数据的变化');
}
}
})
}
5,到这里就实现了数据绑定时,视图自动更新。
实现视图驱动数据
其实就是监听输入框的input、change事件。修改CompilerUtil的model方法。具体代码如下
model: function (node, value, vm) {
new Watcher(vm, value, (newValue, oldValue)=>{
node.value = newValue;
});
let val = this.getValue(vm, value);
node.value = val;
// 看这里
node.addEventListener('input', (e)=>{
let newValue = e.target.value;
this.setValue(vm, value, newValue);
})
},
总结
vue双向绑定原理
vue接收一个模板和data参数。1,首先将data中的数据进行递归遍历,对每个属性执行Object.defineProperty,定义get和set函数。并为每个属性添加一个dep数组。当get执行时,会为调用的dom节点创建一个watcher存放在该数组中。当set执行时,重新赋值,并调用dep数组的notify方法,通知所有使用了该属性watcher,并更新对应dom的内容。2,将模板加载到内存中,递归模板中的元素,检测到元素有v-开头的命令或者双大括号的指令,就会从data中取对应的值去修改模板内容,这个时候就将该dom元素添加到了该属性的dep数组中。这就实现了数据驱动视图。在处理v-model指令的时候,为该dom添加input事件(或change),输入时就去修改对应的属性的值,实现了页面驱动数据。3,将模板与数据进行绑定后,将模板添加到真实dom树中。
如何将watcher放在dep数组中?
在解析模板的时候,会根据v-指令获取对应data属性值,这个时候就会调用属性的get方法,我们先创建Watcher实例,并在其内部获取该属性值,作为旧值存放在watcher内部,我们在获取该值之前,在Watcher原型对象上添加属性Watcher.target = this;然后取值,将讲Watcher.target = null;这样get在被调用的时候就可以根据Watcher.target获取到watcher实例对象。
methods的原理
创建vue实例的时候,接收methods参数
在解析模板的时候遇到v-on的指令。会对该dom元素添加对应事件的监听,并使用call方法将vue绑定为该方法的this:vm.$methods[value].call(vm, e);
computed的原理
创建vue实例的时候,接收computed参数
初始化vue实例的时候,为computed的key进行Object.defineProperty处理,并添加get属性。
点赞收藏关注不迷路