Vue是现在前端非常流行的一个前端框架了,了解它的实现原理现在基本已经快成为前端开发一个必备的基本功了,这篇文章将尝试写一个简单的Vue框架。
Vue数据监听架构
Vue主要架构分为三个部分Compile
、Observer
和Watcher
结构图如下:
Obserer负责监听Vue中的数据,Compile负责Vue中涉及dom节点的渲染,Compile和Observer通过Watcher关联,当Observer监听到数据变化会通过watcher使Compile更新页面,反之亦然。
下边就一部分一部分拆解Vue数据监听架构。
Vue函数
这里简单模拟Vue函数,el为Vue作用的dom节点钩子,data为Vue主要监听的数据,option为Vue中dom交互事件函数放置的地方。
class Vue {
constructor(el, data, option) {
this.$el = el;
this.$data = data;
this.$option = option; // 绑定方法放在这里
if (this.$el) {
new Observer(this.$data)
new Compile(this.$el, this);
}
}
}
Compile
构造函数
compile负责Vue数据在页面上的渲染,首先看构造函数:
constructor(el, vm) {
this.vm = vm;
if (el && el.nodeType === 1) {
this.$el = el;
} else {
this.$el = document.querySelector(el);
}
const fragment = this.createFragment(this.$el);
this.compile(fragment);
this.$el.appendChild(fragment);
}
createFragment(el) {
const fragment = document.createDocumentFragment();
while (el.firstChild) {
fragment.appendChild(el.firstChild);
}
return fragment;
}
都是比较简单的功能,首先在Vue构造函数中将el与vue实例通过构造函数传递进来,其他值得一说的就是为了减少dom结构变化造成的重排,使用了fragment,先将el子节点缓存在fragment中,然后compile后一次性插入el子节点中。
compile
compile(fragment) {
fragment.childNodes.forEach((childNode) => {
if (childNode && childNode.nodeType === 1) {
this.compileElement(childNode)
} else {
this.compileText(childNode)
}
if (childNode && childNode.childNodes.length > 0) {
this.compile(childNode);
}
})
}
遍历子节点,发现如果是element节点进行子节点的递归调用,这里简单处理为子节点只有element与text类型节点。分别针对element与text节点做编译处理。
编译text与element类型子节点
compileElement(node) {
const attributes = Array.from(node.attributes);
attributes.forEach((attribute) => {
const {name, value} = attribute;
if (this.isDirective(name)) {
const [, directive] = name.split('-');
const [directiveName, eventName] = directive.split(':');
CompileUtil[directiveName](node, value, this.vm, eventName);
}
})
}
compileText(node) {
if (node.textContent && node.textContent.includes('{{')) {
CompileUtil['text'](node, node.textContent, this.vm)
}
}
isDirective(name) {
if (typeof name !== 'string') {
return false;
}
return name.startsWith('v-');
}
编译element节点
编译element节点首先遍历节点属性,找出v-
开头的属性,简单假定这些就是vue框架渲染节点的钩子属性。
然后拆分钩子属性获取到expr(获取data值的属性表达式
),绑定的事件名称,然后开始渲染页面。
渲染页面部分是个很独立的一块工作,所以这里封装了一个工具对象。
编译text节点
compileText(node) {
if (node.textContent && node.textContent.includes('{{')) {
CompileUtil['text'](node, node.textContent, this.vm)
}
}
文本类型节点主要判断出是否是{{template }}
类型的节点,然后将textConten传递给CompileUtil渲染到页面。
CompileUtil
结构图
首先针对vue的几个常用指令
v-text、v-html、v-modal与v-on
对应了几个操作方法,update是对应渲染到页面方法的工具对象。
首先从text方法来开始看:
text(node, expr, vm) {
let value = null;
if (expr.includes('{{')) {
value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
new Watch(args[1], vm, (newValue) => {
this.update.textUpdate(node, newValue);
});
return this.getValue(args[1], vm);
})
} else {
value = this.getValue(expr, vm);
new Watch(expr, vm, (newValue) => {
this.update.textUpdate(node, newValue);
});
}
this.update.textUpdate(node, value);
},
首先通过expr区分出是模版渲染还是v-text渲染,如果是模版渲染就用replace抽取出表达式,然后通过公用的表达式获取值方法
拿到值渲染到页面。
watch类通过表达式关联vm中的对象变化,然后通过回调函数重新渲染页面。
getValue方法很简单,表达式通过‘.’拆分为数组,进行reduce操作,然后将vue实例中的data作为起始值。
getValue(expr, vm) {
return expr.split('.').reduce((data, attr) => {
return data[attr];
}, vm.$data)
},
Watch与Dep
Dep
Dep类非常简单
class Dep {
constructor() {
this.subs = [];
}
add(watcher) {
this.subs.push(watcher);
}
notify() {
this.subs.forEach((sub) => {
sub.update();
})
}
}
Dep对象中负责添加watcher,在需要的时候发起通知,让watcher更新页面
watch
class Watch {
constructor(expr, vm, callBack) {
this.expr = expr;
this.vm = vm;
this.callBack = callBack;
this.oldValue = this.getOldValue();
}
update() {
const newValue = CompileUtil.getValue(this.expr, this.vm);
if (this.oldValue !== newValue) {
this.callBack(newValue);
this.oldValue = newValue;
}
}
getOldValue() {
Dep.target = this; // 用这种方式就不能Dep类与Watch类分在两个文件,webpack打包target值会丢掉
const oldValue = CompileUtil.getValue(this.expr, this.vm); // 获取data中的值,在get中添加Watch入Dep
Dep.target = null;
return oldValue;
}
}
watch类中在构造函数中传递expr vm 与跟新的回调函数,最重要的是getOldValue函数,在这里边在Dep类中添加了target属性,属性值存了Watch实例对象,这里的关键思想是在这里通过CompileUtil.getValue获取Vue中data值,并在Dep中上存了一个watch,获取data属性值的时候会调用这个属性的get方法,如果Dep对象上target有值,就在Dep对象上添加一个watch。
update方法通过CompileUtils.getValue获取watch中表达式值如果新值不等于老值就调用callback跟新页面
Observer
Observer类是核心对象,这里通过构造函数传递Vue需要监听的对象
class Observer {
constructor(data) {
this.observe(data);
}
observe(data) {
if (data && typeof data === 'object') {
for (const key of Object.keys(data)) {
this.defineReactive(data, key, data[key]);
}
}
}
defineReactive(data, key, value) {
this.observe(value);
const dep = new Dep();
Object.defineProperty(data, key, {
configurable: false,
enumerable: true,
get: () => {
Dep.target && dep.add(Dep.target)
return value;
},
set: (v) => {
this.observe(v);
if (v !== value) {
value = v;
dep.notify();
}
}
})
}
}
在observe方法中遍历data对象,然后调用核心方法defineReactive
,这里注意的是在方法中首先回调了observe方法,因为对象的属性值可能也是个对象,所以回调了一下observe方法进行深度监听,这里遍历对象的每个属性值,然后添加get 与set方法,get方法中与watch对象中的getOldValue进行联动,在set方法中因为新设置的值可能也是一个对象,所以也要回调一此observe方法,如果属性设置的值与老值不同就调用dep进行广播所有watch进行页面更新。
这里set方法有个小技巧,set方法构成一个闭包,v关联了data的属性值所以每次更新值都可以和data中的属性值进行比较。
测试
下边简单测试一下功能
html部分的代码
{{text.value}}
js部分的代码
var vue = new Vue(
'#box',
{
text: {
value: '文本'
},
html: 'html
',
inputValue: 'input'
},
{
clickButton() {
alert(this.$data.text.value);
}
}
)
const input = document.getElementById('input');
input.addEventListener('input', (e) => {
vue.$data.text.value = e.target.value;
})
为了测试效果给input绑定时间修改input值修改文本绑定的变量
测试结果
改变input值后效果
v-html效果
v-html比较简单,首先看CompileUtil部分代码:
html(node, expr, vm) {
const value = this.getValue(expr, vm);
this.update.htmlUpdate(node, value);
new Watch(expr, vm, (newValue) => {
this.update.htmlUpdate(node, newValue);
})
},
...
htmlUpdate(node, value) {
node.innerHTML = value;
},
...
思路很简单通过expr获取变量值然后渲染到页面,watch监听到变化后重新调用update
测试
html部分代码:
html
js部分代码
const htmlBtn = document.getElementById('changeHtmlBtn');
htmlBtn.addEventListener('click', (e) => {
vue.$data.html = 'changeHtml
'
})
当点击button后修改div下的html
v-modal
v-modal就是我们常说的双向绑定
一样我们先看CompileUtil部分代码
...
setValue(expr, vm, inputValue) {
expr.split('.').reduce((data, currentValue, currentIndex, array) => {
if (currentIndex === array.length - 1) {
// 最后一个属性值赋值input输入的值
data[currentValue] = inputValue;
}
return data[currentValue];
}, vm.$data)
},
...
modal(node, expr, vm) {
node.addEventListener('input', (e) => {
const value = e.target.value;
this.setValue(expr, vm, value);
}, false);
new Watch(expr, vm, (newValue) => {
this.update.modalUpdate(node, newValue);
});
this.update.modalUpdate(node, this.getValue(expr, vm));
},
update: {
...
modalUpdate(node, value) {
node.value = value;
}
...
}
其实也很简单给节点绑定一个input事件,事件回调函数给vue中的data赋值,watch监听框架中的变量变化后更新节点的value值,赋值操作封装一个setValue方法,setValue方法和getValue方法一样使用reduce方法,在最后一个属性赋值inputValue
测试
html代码
inputValue初始值赋值为input
效果
input初始值赋值为input
修改input输入框值后,页面动态发生变化
结语
这里只是简单模拟vue框架,有很多地方存在缺陷,大家有选择的阅读思考就好,感谢阅读。