回顾
源码实现前,先简单回顾Vue。Vue是一款响应式数据框架,它可以构成MVVM框架项目,即视图与业务逻辑分离,然后用数据模型操作视图,这样就可以避免大量的DOM操作,让开发者更关注于业务逻辑。 用一个简单的例子展示这个概念。
<!DOCTYPE html>
<html lang="cn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Vue 基础结构</title>
</head>
<body>
<div id="app">
<h1>差值表达式</h1>
<h3>{{ msg }}</h3>
<h3>{{ count }}</h3>
<h1>v-text</h1>
<div v-text="msg"></div>
<h1>v-model</h1>
<input type="text" v-model="msg">
<input type="text" v-model="count">
</div>
<script src="./js/vue.js"></script>
<script>
let vm = new Vue({
el: '#app',
data: {
msg: 'Hello Vue',
count: 20,
items: ['a', 'b', 'c']
}
})
</script>
</body>
</html>
上面的例子是一个简单的vue示例。用过vue的开发者都知道,在浏览器里,html里的插值表達式,如 {{msg}},{{count}}会被替换成Vue.data里的数据,如果我们修改data里的数据,页面里的相应的值也会同时作出变化,而有v-model的元素则会实现双向绑定,若在页面里对数据进行修改,则会修改vue里相应的数据,而修改vue里的数据,也会更改页面的值的显示。 这就是数据响应式。
源码实现
vue
现在我们可以开始实现一个简单的vue框架。我们希望所实现的框架也能做数据响应的效果。首先我们要创造一个Vue类,而且它的构造函数要接收一个对象。
class Vue {
constructor(options){
this.$options = options || {}
}
}
之后Vue会对options对象进行解析,把options.el和options.data作为data属性的值
class Vue {
constructor(options){
this.$options = options || {}
this.$data = options.data || {}
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
}
}
如果el是字符串,则通过它找到DOM树上相应的元素。还有一点是平时我们可以直接通过vue获取数据,这意味着vue本身已经有data里的属性,所以我们要写一个方法,把data的属性注入到vue实例。
_proxyData (data) {
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get () {
return data[key]
},
set (newValue) {
if (newValue === data[key]) {
return
}
data[key] = newValue
}
})
})
}
}
Object.defineProperty构造类的属性,第一个参数是被注入的类,这里是Vue,key就是属性名,就是data里的属性,最后一个参数是构造的属性。简单来说,这个方法就是把data的属性注入至Vue实例,然后为每一个属性添加getter和setter。最后在构造函数里调用:
class Vue {
constructor(options){
this.$options = options || {}
this.$data = options.data || {}
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
this._proxyData(this.$data)
}
}
observer
然而现在Vue仅仅有data的getter和setter,数据本身不是响应式。怎样才能把数据设置成响应式?。。。所谓的响应式,就是当数据进行修改时,之前接收数据的位置都会相应作出变更。这也意味着,一是当某个地方接收数据时,Vue要在这地方放一个 “信号接收器”,记录位置,如果源数据发生变动, “接收器”也要作出相应行动,二是修改数据时,要一个 “信号发射器”,向所有 “信号接收器”作出通知,它已经修改数据。
这叫做观察者模式,画一个图表示上述的关系:
观察者可能有一个或多个,它或它们观察发布者有没有变化,有的话,则作出变化。
Observer类的代码如下:
class Observer {
constructor (data) {
this.walk(data)
}
walk (data) {
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key])
})
}
defineReactive (obj, key, val) {
let dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get () {
Dep.target && dep.addSub(Dep.target)
return val
},
set (newValue) {
if (newValue === val) {
return
}
val = newValue
dep.notify()
}
})
}
}
上述的代码与之前的_proxyData类似,Observer类接收一个data对象,对其注入getter和setter,不同的是,这里多了一个Dep类,先不管它是怎样实现。它所扮演的角色就是信号接收/发射器,通过闭包,getter和setter可以引用Dep类。 Dep.target && dep.addSub(Dep.target)
这段代码就是记录getter使用的 “位置”,而 dep.notify()
就是 “发射信号”。
现在只是简单讲一下这两段代码的作用,之后才详细讲它们的作用。 Observer基本已经实现,但还是有些问题。如果data属性的值是对象要怎么办?上述的代码仅对原始数据实现响应式效果,如果是对象,仅会对其引用实现响应式,因此要用递归处理。
walk (data) {
if (!data || typeof data !== 'object') {
return
}
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key])
})
}
defineReactive (obj, key, val) {
let that = this
let dep = new Dep()
this.walk(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get () {
Dep.target && dep.addSub(Dep.target)
return val
},
set (newValue) {
if (newValue === val) {
return
}
val = newValue
that.walk(newValue)
dep.notify()
}
})
}
defineReactive里第一个walk把对象变成响应式,而第二个walk则是考虑到更改的值可能为对象,要用递归把值变成响应式。
之所以用that引用Observer实例,是因为当调用getter和setter时,里面的this不是指向observer。
Dep
Dep专门存储观察者,当数据修改,它调用存储的观察者的方法。
class Dep {
constructor () {
this.subs = []
}
addSub (sub) {
if (sub && sub.update) {
this.subs.push(sub)
}
}
notify () {
this.subs.forEach(sub => {
sub.update()
})
}
}
Dep的实现很简单,就是存储和调用。
Compiler
在构建观察者类前,我们需要考虑观察者到底出现在什么地方,不然我们不知道怎样写这个类。再一次回顾一下Vue。我们写好vue的代码,把vue类挂载到DOM树上某一元素,在html里有些地方 引用vue的数据或方法。所以我们要一个类处理页面,把引用的位置改成vue对应的数据,还要在引用节点添加观察者,以让数据变成响应式。
怎样写这个类?首先构造函数的参数一定是vue类,获得el的值,知道vue挂载的节点,对该节点遍历,进行编译,还要考虑到底引用的位置是文本还是节点属性,以作不同处理,即 {{msg}}还是 <a v-text= “msg”></a>
。
class Compiler {
constructor (vm) {
this.el = vm.$el
this.vm = vm
this.compile(this.el)
}
// 编译模板,处理文本节点和元素节点
compile (el) {
let childNodes = el.childNodes //获得子节点
Array.from(childNodes).forEach(node => { //遍历
// 处理文本节点
if (this.isTextNode(node)) {
this.compileText(node)
} else if (this.isElementNode(node)) {
// 处理元素节点
this.compileElement(node)
}
// 判断node节点,是否有子节点,如果有子节点,要递归调用compile
if (node.childNodes && node.childNodes.length) {
this.compile(node)
}
})
}
简单来说,就是把DOM树遍历,分情况处理,如果子节点下有自己的节点,则递归处理。
接下来把上述代码调数的方法实现。先把判断方法实现,因为它们是最简单的。
isDirective (attrName) {
return attrName.startsWith('v-')
}
isTextNode (node) {
return node.nodeType === 3
}
isElementNode (node) {
return node.nodeType === 1
}
isDirective判断元素属性是否为vue的指令,看一下属性开头是否为 ‘v-’则可,isTextNode判断是否为文本节点,isElementNode判断是否为元素。
compileText比较简单,就是获取节点的文本,找出引用vue数据的变量,把它替换。
compileText (node) {
let reg = /\{\{(.+?)\}\}/
let value = node.textContent
if (reg.test(value)) {
let key = RegExp.$1.trim()
node.textContent = value.replace(reg, this.vm[key])
}
}
用正则表达式找出vue数据引用,把它替换成vue的数据。
compileElement比较麻烦,因为它要处理不同的指令。最直接的方法是用if解决,获取vue指令,根据指令调用不同的方法,但这样写会产生大量的if…else if..语句,写起来不好看,日后处理也比较麻烦,其实可以用对象本身的特性处理,既然指令本身是字符串,我们直接用指令调用相关方法就可以了,即直接用 this[“xxx”]调用方法。
compileElement (node) {
Array.from(node.attributes).forEach(attr => {
let attrName = attr.name
if (this.isDirective(attrName)) {
attrName = attrName.substr(2)
let key = attr.value
this.update(node, key, attrName)
}
})
遍历节点属性,查看是否有vue指令,有的话,把 ‘v-’后缀字符取出,把节点,属性的值和指令名放入update函数,作为参数。
Update集中处理vue指令。
update (node, key, attrName) {
let updateFn = this[attrName + 'Updater']
updateFn && updateFn.call(this, node, this.vm[key], key)
}
看一下有没有指令相关的方法,有的话则调用。这里我们简单实现 v-text和v-model的方法。
// 处理 v-text 指令
textUpdater (node, value, key) {
node.textContent = value
}
// v-model
modelUpdater (node, value, key) {
node.value = value
// 双向绑定
node.addEventListener('input', () => {
this.vm[key] = node.value
})
}
textUpdater不多说,代码很清楚,modelUpdater是双向绑定,一方面是vue的值修改时,input的值也要修改,所以是 node.value = value
,另一方面input的值修改时,vue的数据也得改,所以相关的input元素要注册事件,当输入发生时,调用回调函数,所以是
node.addEventListener('input', () => {
this.vm[key] = node.value
})
Compiler这个类基本是完成了。然而如果现在进行测试,在vue里改数据,视图不会发生变化,因为节点没有观察者存在,不能接收数据发生改变的信号。所以我们得要在编译的同时,添加观察者。
Watcher
终于开始写观察者啦。写之前,我们先在Compiler类的编译位置添加Watcher。问题是,我们要传入什么参数给Watcher?或我们应该想,Watcher到底需要知道什么?首先肯定要知道它的位置,所以它必须要有节点引用,然后是vue实例,不然怎样进行观察?还得有要观察的data属性,最后要有一个回调函数,当数据改变时调用。 先在相关位置放上Watcher,之后再实现。
textUpdater (node, value, key) {
node.textContent = value
new Watcher(this.vm, key, (newValue) => {
node.textContent = newValue
})
}
modelUpdater (node, value, key) {
node.value = value
new Watcher(this.vm, key, (newValue) => {
node.value = newValue
})
node.addEventListener('input', () => {
this.vm[key] = node.value
})
}
compileText (node) {
let reg = /\{\{(.+?)\}\}/
let value = node.textContent
if (reg.test(value)) {
let key = RegExp.$1.trim()
node.textContent = value.replace(reg, this.vm[key])
new Watcher(this.vm, key, (newValue) => {
node.textContent = newValue
})
}
}
Watcher类的参数没什么好说,就是把节点的引用放在回调函数。 接下来前方高能,前方高能,前方高能(重要的事情说三次),可能会比较绕。先上代码,再配上图片,进行解释。
class Watcher {
constructor (vm, key, cb) {
this.vm = vm
// data中的属性名称
this.key = key
// 回调函数负责更新视图
this.cb = cb
Dep.target = this
this.oldValue = vm[key]
Dep.target = null
}
// 当数据发生变化的时候更新视图
update () {
let newValue = this.vm[this.key]
if (this.oldValue === newValue) {
return
}
this.cb(newValue)
}
}
先别管构造函数最后三行,先回去看一下Dep类:
class Dep {
constructor () {
this.subs = []
}
addSub (sub) {
if (sub && sub.update) {
this.subs.push(sub)
}
}
notify () {
this.subs.forEach(sub => {
sub.update()
})
}
}
Dep类就是专门管理watcher方法,当视图引用vue的数据时,就会构造一个Watcher,而Watcher则会叫相关的Dep把自己存在数组,当数据发生修改时,Dep就会遍历数组,调用Watcher实例的update方法。 现在的问题是到底所谓的 “相关的Dep” 在什么时侯出现? 囧。 我们再回退至Observer类,看一下它的defineReactive方法:
defineReactive (obj, key, val) {
let dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get () {
Dep.target && dep.addSub(Dep.target)
return val
},
set (newValue) {
if (newValue === val) {
return
}
val = newValue
dep.notify()
}
})
}
}
注意第二行,当每一个Vue实例的数据变成响应式时,都会生成一个Dep实例。当getter方法被调用时,就是当Compiler类里引用vue数据时,Dep实例会查看有没有一个Dep.target的静态属性,有的话就用数组存起来。这个target是什么时候出现? 当Watcher实例构成时,就是Watcher构造函数最后三行:
Dep.target = this
this.oldValue = vm[key]
Dep.target = null
首先生成一个target的静态属性,引用当前的Watcher实例,然后实例的oldValue引用vue相关的数据,这时候就调用了 相关数据的getter,相关数据的getter,相关数据的getter (重要的事情说三遍)。相关数据的getter把这个Watcher实例 (Dep.target)存入dep的数组里。
画图再解释:
首先生成Watcher实例,到了 Dep.target = this
发生的情况:
最后把Dep.target变成null,防止被其他地方引用。 过程有点绕,多看几次,或在纸上把程序的引用关系整理一下就好。
后面的setter就好理解,就是修改数据时,把dep里的Watcher实例拿出来,调用它们的update方法。
最后重构vue的构造函数:
constructor (options) {
this.$options = options || {}
this.$data = options.data || {}
this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
this._proxyData(this.$data)
new Observer(this.$data)
new Compiler(this)
}