前段时间笔者学习了一下Vue的源码,也看了一些前辈对Vue源码研究的博客,然后用es实现了一个基础的数据双向绑定框架Hue,作为学习成果之一,在此分享给大家。Hue实现了@click,v-model, watch监听属性变化这几个基本的功能,后续如有需要大家可以自行扩展,比如hook之类的,整个框架的组织架构也可以自行调整。其中很多内容的设计和实现都是参照了Vue源码来实现的,后面会对代码实现做进一步的阐述。
先看一下实现的效果动态图:
下图是Hue实例化的运行过程,也是整个Hue实现数据双向绑定的过程总结:
下图是Hue的代码文件结构:
浏览器运行index.html就能看到动图中的内容,以下是index.html的代码:
<div id="app">
<span>Welcome to {{project}}, it's an example</span>
<div style="margin-top: 10px">
<div class="row">
<div class="label">name: </div>
<input v-model="people.name" />
</div>
<div class="row">
<div class="label">height:</div>
<input v-model="people.height" />
</div>
</div>
<div>Hello, I am called {{people.name}} and my height is {{people.height}} cm</div>
<div>
<button @click="getTxt(people.name, people.height)">信息输出</button>
<button @click="change('code', 173)">设置</button>
</div>
</div>
<script src="./util.js"></script>
<script src="./observer.js"></script>
<script src="./watcher.js"></script>
<script src="./dep.js"></script>
<script src="./compiler.js"></script>
<script src="./main.js"></script>
<script>
new Hue({
el: '#app',
data: {
count: 1,
project: 'Hue',
txt: '35',
people: {
name: 'Alice',
height: 160
}
},
watch: {
'people.name': {
handler (n, o) {
console.log('watch ===> newName:', n, 'oldName:', o)
}
},
// 对象监听
// 'people': {
// deep: true,
// handler (n, o) {
// console.log('people changed...')
// }
// }
},
methods: {
change (name, height) {
this.people.name = name
this.people.height = height
},
getTxt (name, height) {
console.log('name:', name, 'height:', height)
}
}
})
</script>
<style>
.label{
width: 60px;
text-align: right;
margin-right: 10px;
}
.row{
display: flex;
margin-bottom: 10px;
}
</style>
整个风格也是照着Vue来实现的,这个例子测试了三部分的内容:1 数据双向绑定;2属性变化监听;3不同参数类型的函数绑定(针对基本数据类型和data中的属性)。从Hue实例化开始,数据双向绑定的大门就被打开了,接下来让我们一起解开那些背后的秘密。以下是main.js文件:
class Hue {
constructor (options) {
let vm = this
vm.$options = options
vm._data = options.data
for (let key in vm._data) {
proxy(vm, '_data', key)
}
// 初始化$watch监听函数
vm.$watch = function (key, cb) {
new Watcher(vm, key, cb)
}
initOptions(vm)
// watch 选项解析
resolveWatch(vm)
// 编译模板
new Compiler(vm.$options.el, vm)
}
}
这里做的第一件事情就是把Hue实例化中的参数挂载到实例的$options上去,方便Hue中的函数获取相关的数据,其中proxy函数对_data的内容做了一层拦截,起到在实例内部简化调用的作用(例如data中有个属性name, 可以通过this._data.name获得,经过proxy代理之后可以通过this.name获取,符合我们的调用习惯)。紧接着我们开始初始化$watch,函数内部实例化了一个Watcher,这是为data数据添加回调函数的。后面陆续进行了options的数据劫持,实例watch的解析,模板的编译。当中的函数来自util.js文件,以下是文件的内容:
function proxy (target, sourceKey, key) {
Reflect.defineProperty(target, key, {
set (newVal) {
target[sourceKey][key] = newVal
},
get () {
return target[sourceKey][key]
}
})
}
function observe (data) {
if (typeof data !== 'object') return false
return new Observer(data)
}
function defineReactive (obj, key, value) {
let dep = new Dep()
Reflect.defineProperty(obj, key, {
get () {
if (Dep.target) {
dep.addDepend()
}
return value
},
set (newVal) {
value = newVal
dep.notify()
}
})
}
function pushTarget (watcher) {
Dep.target = watcher
}
function popTarget () {
Dep.target = null
}
function resolveWatch (vm) {
let watch = vm.$options.watch
if (!watch) return 'no watch'
Object.keys(watch).forEach(item => {
if (typeof watch[item] === 'function') {
vm.$watch(item, watch[item])
}
if (typeof watch[item] === 'object') {
if (watch[item].deep) {
Object.keys(vm[item]).forEach(key => {
vm.$watch(item + '.' + key, watch[item].handler)
})
} else {
vm.$watch(item, watch[item].handler)
}
}
})
}
function initOptions (vm) {
observe(vm._data)
}
我们看到proxy函数里面使用了Reflect而不是Object上的defineProperty函数去进行对象属性的setter,getter设置,这是因为Reflect对以往Object内部的一些方法的错误行为结果进行了return false的处理而不是直接报错,以及它收录了以往的一些命令式的操作,把他们都函数化了,例如 delete o.name 变成了Reflect.deleteProperty(o, 'name'), 'name' in obj 变成了Reflect.has(obj, 'name') 等等,以上仅仅是Reflect对象的一部分功能,详情可以去参考es6 Reflect对象的相关内容。
接下来我们看$watch函数中实例化的Watcher内容,以下是watcher.js内容:
class Watcher {
constructor (vm, expression, cb) {
this.vm = vm
this.expression = expression
this.cb = cb
this.value = this.get()
}
get () {
let val = this.vm
pushTarget(this)
this.expression.split('.').forEach(item => {
val = val[item]
})
popTarget()
return val
}
update () {
let val = this.vm
this.expression.split('.').forEach((key) => {
val = val[key]
})
this.cb.call(this.vm, val, this.value)
this.value = val
}
addDep (dep) {
dep.addSub(this)
}
}
这是发布-订阅模式中的订阅者,构造函数中传入了Hue实例,订阅的属性,回调函数。this.value用以存储当前的属性值,之后会随着属性值的变化而更新。接下来我们看watcher的get函数,pushTarget的目的是把当前的watcher实例赋值给Dep.target,接下来我们根据expression对属性取值,这时候会触发属性的getter,发布者会进行依赖收集(defineReactive进行数据劫持的时候会在详细讲解)。最后我们通过popTarget函数把Dep.target赋值为null,返回属性值,get函数的功能就完了。pushTarget,popTarget这一组函数用来对Dep.target绑定和释放当前的watcher实例。vue源码里面会复杂一点,通过数组targetStack来追踪记录watcher,函数仍然是pushTarget和popTarget这一组。
initOptions开始处理data数据了,我们看到最后是调用了util中的observe函数,如果data不是对象,直接返回false。如果是对象那就开始Observer的实例化。下面是observer.js的内容:
class Observer {
constructor (data) {
this.walk(data)
}
walk (data) {
for (let key in data) {
if (typeof data[key] === 'object') {
this.walk(data[key])
continue
}
defineReactive(data, key, data[key])
}
}
}
Observer实例化的时候通过walk函数遍历了data的属性,通过defineReactive函数进行数据劫持,属性值为对象的则继续递归遍历。
function defineReactive (obj, key, value) {
let dep = new Dep()
Reflect.defineProperty(obj, key, {
get () {
if (Dep.target) {
dep.addDepend()
}
return value
},
set (newVal) {
value = newVal
dep.notify()
}
})
}
整个数据双向绑定的灵魂就在于数据的获取和设置是如何被监听的,一旦监听到变化我们就可以做相应的操作,defineProperty定义的getter和setter天然就提供了这么一种监听的能力。当我们访问一个对象属性的时候,getter就会被触发,当我们设置对象属性的时候setter就会被触发。defineProperty 定义getter和setter的过程就是所谓的数据劫持了。我们看到defineReactive函数先实例化了一个Dep(发布者),接着开始进行数据劫持,当Dep.target有绑定watcher(订阅者)的时候就开始进行依赖收集(将当前订阅者添加到发布者的sub数组中,详情可见下文Dep对象的介绍)。当数据更新时会触发setter,这时将新值赋值给value用于getter的返回值,同时dep开始通知(notify)他的订阅者(sub数组中的所有watcher实例)进行相应的操作(dom更新,或者是执行相关的回调函数)。下面我们来看发布者Dep对象的内容,以下是dep.js的内容:
class Dep {
constructor () {
// 存放watcher
this.sub = []
}
// 依赖添加
addDepend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
addSub (sub) {
this.sub.push(sub)
}
notify () {
for (let sub of this.sub) {
sub.update()
}
}
}
这里比较有意思的一点就是依赖添加的时候是从dep的addDepend绕到了watcher的addDep,最后又绕到了dep的addSub去进行最终的添加,而不是在addDepend中直接把Dep.target所绑定的watcher直接加到sub中去,这样做的目的是watcher需要记录发布者的信息,以防重复添加相同的发布者,在vue源码中有所体现,而本文做极简处理就不考虑dep的记录了。以下是vue源码的关于addDep函数的代码,位置在源码src\core\observer\watcher.js处:
/**
* Add a dependency to this directive.
*/
addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}
最后我们看一下notify函数,这里就是通知sub中所有的watcher进行更新操作了。
到这里为止发布-订阅的模式就已经都实现了,接下来就是考虑如何更新{{}}中的内容了,也是就实例化Compiler,开始编译$el的内容。让我们看一下compiler.js的内容:
class Compiler {
constructor (el, vm) {
vm.$el = document.querySelector(el)
this.replace(vm.$el, vm)
}
replace (el, vm) {
let childNodes = [...el.childNodes]
let self = this
childNodes.forEach(node => {
let txt = node.textContent
// 正则匹配{{}}
let reg = /\{\{(.*?)\}\}/g
if (node.nodeType === Node.TEXT_NODE && reg.test(txt)) {
// 考虑文本内容出现多次{{}}
let moustache = txt.match(reg)
moustache.forEach(subMoustache => {
// 为{{}}中的属性绑定watcher
this.watch(node, subMoustache, vm, moustache, txt)
})
}
// 如果是元素节点
if (node.nodeType === Node.ELEMENT_NODE) {
let nodeAttr = [...node.attributes]
nodeAttr.forEach(attr => {
let name = attr.name
let exp = attr.value
switch (name) {
case 'v-model':
node.value = this.getExpValue(exp, vm)
vm.$watch(exp, function(newVal) {
node.value = newVal
})
node.addEventListener('input', e => {
let newVal = e.target.value
self.setExpValue(exp, newVal, vm)
})
break
case '@click':
node.addEventListener('click', e => {
let tep = /(.+)\((.*)\)/.exec(exp)
let [func, params] = [tep[1], tep[2].split(',')]
// 判断参数是否来自data选项,若否则直接以字符形式作为入参
params = params.map(key => {
return self.getExpValue(key, vm) || key
})
vm.$options.methods[func].apply(vm, params)
})
break
}
})
}
// 如果还有子节点,继续递归replace
if (node.childNodes && node.childNodes.length) {
this.replace(node, vm);
}
})
}
watch (node, content, vm, moustache, txt) {
let self = this
let prop = (/\{\{(\S*)\}\}/).exec(content)[1]
self.replaceContent(node, moustache, vm, txt)
vm.$watch(prop, function () {
self.replaceContent(node, moustache, vm, txt)
})
}
replaceContent (node, moustache, vm, txt) {
for (let mkey of moustache) {
let prop = (/\{\{(\S*)\}\}/).exec(mkey)[1]
let value = this.getExpValue(prop, vm)
txt = txt.replace(mkey, value).trim()
}
node.textContent = txt
}
getExpValue (exp, vm) {
if (/^\'(.*)\'$/.test(exp) || /^(\d+)$/.test(exp)) return RegExp.$1
let arr = exp.trim().split('.')
let val = vm
for (let key of arr) {
val = val[key]
}
return val
}
setExpValue (exp, value, vm) {
let arr = exp.split('.')
let val = vm
arr.forEach((key, i)=> {
if (i === arr.length - 1) {
val[key] = value
return
}
val = val[key]
})
}
}
首先获取el标识的dom,然后开始递归遍历dom的子节点(childNodes),如果是元素节点那就遍历节点的属性开始实现自定义的指令比如v-model, @click等。如果是文本节点那就匹配文本中{{}}出现的属性,在watch函数里面用属性值先替换掉{{}}中的内容,然后通过$watch监听这些属性变化,在回调函数里面放置replaceContent以便更新节点的textContent(文本内容)。到此为止一个基础版的es数据双向绑定框架Hue就初步完成了。其中compiler里的难点就是同一个文本节点出现多个{{}}的时候如何替换,更进一步的话还需要考虑{{}}中是表达式的情况,这个时候就需要进一步的解析。这里就不再进行更多的实现了,大家有兴趣的话可以自行实现。判断节点类型的时候笔者用到了Node.ELEMENT_NODE而不是1,用常量去代替具体的值,这是一个比较好的习惯,一方面让代码更具可读性,另一方面也可以使之更加容易维护。
仿vue实现数据双向绑定的分享到此就结束了,如果对于本文有任何疑问可以在下方留言。