前言
看了Vue的一些思想之后,开始有想法去模仿Vue写一个小的MVVM,奈何当自己真正开始写的时候才知道有多难,不过也让自己明白,自身的编码水平和设计代码的思维还有很大的提升空间,哈哈哈。
开始
先来一个基本的index.html文件,然后我们模仿Vue的写法,实例化一个MVVM类和定义data对象(Vue里为了拥有自己的命名空间data应该为函数)
<!DOCTYPE html><html lang="en">
<head> ```</head>
<body>
<div id="app">
<div>
<div>
<span>{{hello}}</span>
</div>
<div>{{msg}}</div>
</div>
</div>
<script src="./src/index.js"></script>
<script>
const app = new MVVM({
$el: '#app',
data: {
msg: 'mvvm',
hello: 'david'
},
})
</script>
</body>
</html>我们设想是这样来操作滴,然后就可以编写我们的MVVM类了。我感觉写这个的话一种由上而下的思路会比较好,就是先把最顶层的思路想好,然后再慢慢往下写细节。
MVVM
class MVVM {
constructor(options) {
this.$el = options.$el
this.data = options.data
if (this.$el) {
const wathcers = new Compiler(this.$el, this)
new Observer(this.data, wathcers)
}
}
}这里我们定义了一个MVVM类,在options里面可以拿到$el和data参数,因为我们上面的模板里面就是这么传的。如果传入的$el节点确实存在的话,就可以开始我们的初始化编译模板操作。
Compiler
function Compiler(el, vm) {}看上面我们知道,Compiler的参数有两个,一个是$el字符串,还有一个就是我们的MVVM实例,上面我传了this 。
遍历子节点
首先我们先来思考,编译模板的时候希望的是将类似{{key}} 的部分用我们的data对象中的对应的value来取代。所以我们应该先遍历所有的dom节点,找到形如{{key}}所在的位置,再进行下一步操作。先来两个函数
this.forDom = function (root) {
const childrens = root.children
this.forChildren(childrens)
}这是一个获取dom节点的子节点的函数,然后将子节点传入下一个函数
this.forChildren = function (children) {
for (let i = 0; i < children.length; i++) {
//每个子节点
let child = children[i];
//判断child下面有没有子节点,如果还有子节点,那么就继续的遍历
if (child.children.length !== 0) {
this.forDom(child);
} else {
//将vm与child传入一个新的Watcher中
let key = child.innerText.replace(/^\{\{/g, "").replace(/\}\}$/g, "")
let watcher = new Watcher(child, vm, key)
//初始转换模板
compilerTextNode(child, vm)
watchers.push(watcher)
}
}
}如果子节点还有子节点,就继续调用forDOM函数。否则就将标签中{{key}}里面的key拿出来(这里我只考虑了形如<div>{{key}}</div>的情况,大佬轻喷),拿到key之后就实例化一个watcher,让我们来看看watcher做了啥。
Watcher
function Watcher(child, vm, initKey) {
this.initKey = initKey
this.update = function (key) {
if (key === initKey) {
compilerTextNode(child, vm, initKey)
}
}
}首先把所对应的子节点child传入,然后vm实例也要传入,因为下面有一个函数需要用到vm实例,然后这个initKey是我自己的一些骚操作(流下了没有技术的泪水),它的作用主要是记录一开始的那个key值,为啥要记录呢,请看下面的方法。
compilerTextNode
compilerTextNode = function (child, vm, initKey) {
if (!initKey) {
//第一次初始化
const keyPrev = child.innerText.replace(/^\{\{/g, "").replace(/\}\}$/g, "") //获取key的内容
if (vm.data[keyPrev]) {
child.innerText = vm.data[keyPrev]
} else {
throw new Error(
`${key} is not defined`
)
}
} else {
child.innerText = vm.data[initKey]
}首先这个函数会有两个逻辑,一个是初始化的时候,还有一个是数据更新的时候。可以看到初始化的时候我们是这样做的compilerTextNode(child, vm),也就是会进入这个if逻辑。这里就是拿到了模板中的key值,然后节点的值替换成我们data对象里面的值。为啥要记录这个initKey呢,就是在这里如果模板的innerText直接被整个替换掉了,例如说原本模板中是{{msg}},它经过这个函数处理之后,会变成mvvm,那我们的data中是没有mvvm这个key的,这里记录是为了更新的时候用。最后,所有的watcher都会被push进watchers数组里,并且返回。
Observer
function Observer(data, watchers) {}然后就到了我们熟悉的响应式数据啦,这个函数接受两个参数,一个就是我们一开始定义的data对象,还有一个就是刚才我们拿到的watchers数组。
observe
this.observe = function (data) {
if (!data || typeof data !== 'object') {
return
}
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key])
this.observe(data[key]) //递归深度劫持
})
}首先我们先来对data做一下判断,然后调用defineReactive方法对data做响应式处理,最后来个递归深度劫持data。
defineReactive
this.defineReactive = function (obj, key, value) {
let that = this
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
return value
},
set(newValue) {
if (newValue !== value) {
that.observe(newValue)
value = newValue
//重新赋值之后 应该通知编译器
watchers.forEach(watcher => {
watcher.update(key)
})
}
}
})
}get方法调用时直接返回value,set方法调用时如果value有重新赋值,那么应该重新监听value的新值,然后用watcher通知编译器重新渲染模板。
然后调用observe方法,this.observe(data)
这里我们再看回watcher.update方法,在defineReactive方法中调用时传入的key是我们data中定义的,而这个initKey也就是我们之前在初始化模板的时候保存的,当这两个相等的时候才重新渲染对应的模板块
this.update = function (key) {
if (key === initKey) {
compilerTextNode(child, vm, initKey)
}
}最后让我们来看一眼效果,加上一小段改变数据的代码。
setTimeout(() => {
app.data.msg = 'change'
}, 2000)
总结与反思
我们来思考一下Observer、Watcher、Compiler三者之间的关系。Observer最重要的职责是把数据变成响应式的,换句话说就是我们可以在数据被取值或者赋值的时候加入一些自己的操作。Compiler就是把HTML模板中的{{key}}变成我们data中的值。Watcher就是它们二者之间的桥梁了,在一开始的时候观察所有存在插值的节点,当data中的数据更新时,可以通知模板,让其重新渲染同步data中的数据。
最后,其实我也不知道写的这个算不算MVVM(捂脸),编码能力真心还有待提高,继续加油吧!