双向绑定原理
原理
vue数据双向绑定是通过数据劫持结合发布者-订阅者模式的方式来实现的
实现
-
首先,设置监听器Observer,对vue实例中的data数据进行劫持监听,核心方法就是Object.defineProperty(),递归遍历所有属性,添加get、set方法,其中利用订阅器Dep来收集订阅者,若访问属性则添加到订阅器,若数据变化则通知订阅者
-
其次,设置订阅者Watcher,对实例中data的属性绑定更新函数,当数据发生变化时执行相应函数,更新视图,其中订阅器Dep在监听器Observer和订阅者Watcher起到统一管理的作用
-
最后,实现指令解析器Compile,创建fragment文档片段接收节点元素并进行解析,解析过程是
- 遍历子节点集合,如果是v-指令的元素节点,分别对v-model和v-on等不同类型的指令进行解析
- 其中对v-model指令,将节点内容更新的方法加入到Watcher订阅者,并监听input输入事件,当数据变化时执行相应的函数更新视图;对v-on类型的事件指令添加监听方法,当事件触发时执行绑定的对应函数
- 如果是{{}}文本节点,则初始化Watcher订阅者,并指定节点内容更新的方法,当然,如果有孩子节点则递归解析
- 解析完毕,将文档片段追加到dom元素上
利用ES6 class关键字定义类的写法实现
1. Observer
- this接收Vue实例中的data
- 执行walk方法,若注入的是对象则遍历data,利用Object.defineProperty添加get、set方法,将其属性值转换为响应式数据
- 构造并初始化订阅器,如果调用该属性,则在执行get方法时添加至订阅器
- 数据改变执行set方法时,如果新赋值的是对象,添加set、get,将其内部的属性转换为响应式数据,并通知所有订阅者Watcher执行update函数,更新视图
/**
* @description Observer-->数据监听器 (对Vue实例中的data进行监听)
* @param {Object} data Vue实例中的data
*/
class Observer {
constructor (data) {
this.data = data
this.walk(data)
}
walk (data) {
if (!data || typeof data !== 'object') return
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key])
})
}
defineReactive (data, key, val) {
const self = this
// 如果注入的是对象,则添加get、set,转化为响应式数据
this.walk(val)
// 初始化订阅器
let dep = new Dep()
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get () {
if (Dep.target) {
dep.addSub(Dep.target)
}
return val
},
set (newVal) {
if (newVal == val) return
val = newVal
// 如果新赋值的是对象,添加setter、getter,把val内部的属性转换为响应式数据
self.walk(newVal)
// 数据变化,通知所有订阅者Watcher
dep.notify()
}
})
}
}
/**
* @description Dep-->订阅器
* 1、this接收subs初始的空数组
* 2、定义addSub方法,以便Observer监听器中属性调用get方法时,将订阅者Watcher添加至订阅器
* 3、定义notify方法,用于Observer监听器中属性值变化时,调用update函数,更新视图
*/
class Dep {
constructor () {
this.subs = []
}
addSub (sub) {
this.subs.push(sub)
}
notify () {
this.subs.forEach(sub => {
sub.update()
})
}
}
Dep.target = null
2. Watcher
- this接收vm, exp, cb参数
- value接收get方法返回Vue实例中data对象的属性值,Dep.target缓存Watcher订阅器自己,value接收指令或文本节点绑定的属性值并返回,释放自己
- 定义update更新方法,oldVal接收之前存入的value为旧值,newVal重新接收Vue实例中data对象的属性值,将新值newVal赋于value,传新值、旧值入Watcher绑定的更新函数cb,利用call函数将this指向Vue实例本身并执行函数
/**
* @description Watcher-->订阅者 (对vue实例的某个属性exp绑定更新函数cb)
* @param vm Vue对象
* @param exp node节点的v-指令、{{}}绑定的属性名
* @param cb Watcher绑定的更新函数
*/
class Watcher {
constructor (vm, exp, cb) {
this.vm = vm
this.exp = exp
this.cb = cb
this.value = this.get() // 将自己添加到订阅器的操作
}
get () {
Dep.target = this
let value = this.vm.data[this.exp]
Dep.target = null
return value
}
update () {
let oldVal = this.value
let newVal = this.vm.data[this.exp]
if (newVal === oldVal) return
this.value = newVal
this.cb.call(this.vm, newVal, oldVal)
}
}
3. Compile
- this接收vm实例、el节点,创建fragment文档片段
- 初始化,将元素节点转换为文档片段,解析节点,最后将文档片段追加到#app的dom元素上
- 解析节点:遍历子节点集合childNodes判断元素节点(节点类型为1)还是文本节点(节点类型为3), 再遍历子节点的属性对不同指令进行不同的操作
/**
* @description Compile -->解析指令 (对vue实例的dom元素el进行解析)
* @param el dom节点,如#app
* @param vm Vue实例
*/
class Compile {
constructor (el, vm) {
this.vm = vm
this.el = document.querySelector(el)
// 暂存处理后的新节点,最后追加到dom元素上,确保一次内容渲染刷新,提高性能
this.fragment = null
this.init()
}
// ------------初始化------------
// 如果节点存在,则转换为文档片段对象,并解析节点,最后将文档片段追加到根节点#app的dom元素上
init () {
if (this.el) {
this.fragment = this.nodeToFragment(this.el)
this.compileElement(this.fragment)
this.el.appendChild(this.fragment)
} else {
console.error('Dom元素不存在')
}
}
// ------------节点转文档片段对象------------
// 创建文档片段,循环将dom元素的第一个子节点放入fragment文档片段
nodeToFragment (el) {
let fragment = document.createDocumentFragment()
let child = el.firstChild
while (child) {
fragment.appendChild(child) // 将Dom元素移入fragment中
child = el.firstChild
}
return fragment
}
// ------------解析节点------------
// 对nodeList类型的子节点集合,利用数组原型上的slice方法,并结合call在其作用域中调用,转换为数组从而使用forEach遍历
// 如果是元素节点且有v-指令,则解析元素节点
// 如果是文本节点有{{}},则解析文本节点
// 如果有孩子节点则递归
compileElement (el) {
// [].slice === Array.prototype.slice true
[].slice.call(el.childNodes).forEach(node => {
let reg = /\{\{(.*)\}\}/
let text = node.textContent
if (this.isElementNode(node)) {
this.compile(node)
} else if (this.isTextNode(node) && reg.test(text)) {
this.compileText(node, reg.exec(text)[1])
}
if (node.childNodes && node.childNodes.length) {
this.compileElement(node)
}
})
}
// ------------遍历节点属性,解析元素节点------------
// 如果是v-开头的指令节点,再判断如果是v-model解析model指令、v-on则解析事件指令
// 最后移除属性
compile (node) {
Array.prototype.forEach.call(node.attributes, attr => {
let attrName = attr.name // v-model v-on:click
let exp = attr.value // name clickMe
if (this.isDirective(attrName)) { // v-开头
if (this.isEventDirective(attrName)) { // v-on:click事件指令(截取v-后on开头)
this.compileEvent(node, exp, attrName)
} else { // model指令
this.compileModel(node, exp)
}
node.removeAttribute(attrName)
}
})
}
// ------------解析文本节点------------
// 定义{{}}属性的值
// 将节点内容更新的方法加入到订阅者
compileText (node, exp) {
let initText = this.vm[exp]
this.updateText(node, initText)
new Watcher(this.vm, exp, value => {
this.updateText(node, value)
})
}
// ------------解析事件指令------------
// 如果事件类型和方法存在,则对节点元素添加监听方法,当事件触发时执行相应函数
compileEvent (node, exp, attrName) {
let eventType = attrName.substring(2).split(':')[1] // click
let cb = this.vm.methods && this.vm.methods[exp] // clickMe方法名
if (eventType && cb) {
node.addEventListener(eventType, cb.bind(this.vm))
}
}
// ------------解析model指令------------
// 定义v-model属性的值
// 将节点内容更新的方法加入到订阅者
// 监听input输入,如果变化的新值不等于v-model属性的值,将v-model属性的值修改为新值
compileModel (node, exp) {
let value = this.vm[exp] // Vue实例中的属性值
this.modelUpdater(node, value)
new Watcher(this.vm, exp, value => {
this.modelUpdater(node, value)
})
node.addEventListener('input', e => {
let newValue = e.target.value
if (value === newValue) return
this.vm[exp] = newValue
value = newValue
})
}
// {{}}更新
updateText (node, value) {
node.textContent = typeof value == 'undefined' ? '' : value
}
//model更新
modelUpdater (node, value) {
node.value = typeof value == 'undefined' ? '' : value
}
isDirective (attr) {
return attr.indexOf('v-') == 0
}
isEventDirective (attrName) {
return attrName.substring(2).indexOf('on:') === 0
}
isElementNode (node) {
return node.nodeType == 1
}
isTextNode (node) {
return node.nodeType == 3
}
}
4. Vue模拟对象
- this接收Vue实例中的el、data、mounted、methods参数
- 执行run方法,遍历Vue实例中的data数据,对其属性添加get、set函数, 利用Object.defineProperty将其转换为响应式数据
- 初始化Observer监听器实例,将data数据传入,对数据进行劫持监听
- 初始化Compile指令解析器实例,将el节点#app,Vue实例对象注入,解析指令
- 调用mounted方法执行挂载函数
/**
* @description Vue-->小型Vue (对vue实例的data属性进行监听,dom节点el进行解析,mounted挂载函数执行)
* @param {Object} options vue实例注入的对象,包含data、mehtods、mounted等
*/
class Vue {
constructor (options) {
this.el = options.el
this.data = options.data
this.mounted =options.mounted
this.methods = options.methods
this.run()
}
run () {
Object.keys(this.data).forEach(key => {
this.proxyKeys(key)
})
new Observer(this.data)
new Compile(this.el, this)
this.mounted.call(this) // 所有事情处理好后执行mounted函数
}
proxyKeys (key) {
let self = this
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get () {
return self.data[key]
},
set (newVal) {
if (newVal === self.data[key]) return
self.data[key] = newVal
}
})
}
}
5. index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>two-way Binding principle</title>
</head>
<style>
#app {
text-align: center;
}
</style>
<body>
<div id="app">
<h2>{{title}}</h2>
<input v-model="name">
<h1>绑定值:{{name}}</h1>
<button v-on:click="clickMe">click me!</button>
</div>
</body>
<script src="js/observer.js"></script>
<script src="js/watcher.js"></script>
<script src="js/compile.js"></script>
<script src="js/index.js"></script>
<script type="text/javascript">
new Vue({
el: '#app',
data: {
title: 'hello, world',
name: 'yang'
},
mounted() {
window.setTimeout(() => {
this.title = '你好,世界'
}, 1000)
},
methods: {
clickMe () {
this.title = 'hello, universe'
}
}
})
</script>
</html>