深入理解vue双向绑定原理,一文手把手带你用原生js实现vue双向绑定
一、vue双向绑定简介
双向绑定简单来说,其实就是数据能影响视图,而视图也能反过来影响数据,例如当一个输入框和js对象的某个属性实现了双向绑定,那么属性值的变化会影响输入框中的值,输入框中的值变化也会影响属性的值,接下来,笔者将在此基础上,用原生js从零开始实现vue的双向绑定。
二、代码编写
1. 事前准备
我们先准备好这样一个简单html页面,写过vue的同学应该都不会陌生,当然这里并没有引入vue的js,而是我们待会需要实现的MyVue.js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="main">
<div>
<input type="text" v-model="name">
<input type="text" v-model="msg.text"
</div>
<div>
<div>name: {{ name }}</div>
<div>msg: {{ msg.text }}</div>
</div>
</div>
<script src="MyVue.js"></script>
<script>
const vue = new MyVue({
el: '#main',
data: {
name: 'jonny',
msg: {
text: 'hello'
}
}
})
</script>
</body>
</html>
下面就是页面最开始的样子,视图和数据之间没有任何联系
接下来我们开始编写MyVue.js的逻辑
2.MyVue.js中的MyVue类
MyVue类是MyVue.js中的主体部分,先创建一个MyVue.js文件,之后我们先编写MyVue类的构造方法,先把传入对象的el属性和data属性赋值给成员属性
class MyVue {
constructor(data_instance) {
this.$data = data_instance.data
this.$el = document.querySelector(data_instance.el)
}
}
3.设置getter和setter进行数据监听
接下来就是第一个重点,数据监听,这里我们使用Object.defineProperty这个方法来实现数据劫持并实现数据监听,使用这个方法,来为$data中的每一个属性都设置getter和setter,当获取该属性值时,就会执行getter函数的代码,当为该属性设置值时,就会执行setter函数的代码,由于$data中的属性也有可能是一个对象,所以我们可以递归的去遍历每一个属性,并为其设置监听,当传入的值不是一个对象或者为null或undefined时,递归便可终止,我们可以在MyVue类中实现名叫ListenData的方法来设置监听,并在MyVue的构造方法中调用。我们可以在控制台打印一下看看数据劫持有没有成功。
class MyVue {
constructor(data_instance) {
this.$data = data_instance.data
this.$el = document.querySelector(data_instance.el)
//调用方法,进行数据监听
MyVue.listenData(this.$data)
}
static listenData(data_instance) {
//当传入的参数不是对象或者为null和undefined时,递归终止
if (!data_instance || typeof data_instance !== 'object') return
//Object.keys方法可获取对象中的所有属性名,返回一个数组,遍历数组并进行数据劫持
Object.keys(data_instance).forEach(key => {
//这里需要用临时变量value保存一下属性值,因为调用了设置了get和set后数据就会被劫持
let value = data_instance[key]
MyVue.listenData(value)
Object.defineProperty(data_instance, key, {
//设置getter
get: () => {
//打印测试
console.log('get value')
return value
},
//设置setter
set: (newVal) => {
//打印测试
console.log('set value')
value = newVal
//每次设置新值时我们也要把新的值递归遍历设置监听
MyVue.listenData(value)
}
})
})
}
}
我们可以在控制台测试一下,成功打印信息,getter setter设置成功
4.解析页面信息
接下来我们就需要解析页面的重点信息,把带有v-model和有{{}}符号的信息提取出来,这里我们将使用js的文档碎片来实现,即document fragment。文档碎片是一个轻量级的document对象,我们可以在文档碎片中解析信息,然后再把它拼接回去,我们再MyVue类中实现docParse方法,并在构造方法中调用
constructor(data_instance) {
this.$data = data_instance.data
this.$el = document.querySelector(data_instance.el)
MyVue.listenData(this.$data)
//调用方法
MyVue.docParse(this)
}
static docParse(vm) {
//获取文档碎片
let fragment = document.createDocumentFragment()
let node
//将页面中的节点循环加入到文档碎片中
while (node = vm.$el.firstChild) {
fragment.append(node)
}
}
这时刷新你的页面,如果你发现页面空空如也,那么就说明节点加入文档碎片成功了
接下来我们开始对文档碎片中的内容进行处理,实现名为processFragment的方法,把文档碎片对象和vm传入,我们首先解析像{{xxx}}这样格式的文本信息,我们可以用正则匹配来实现,即/\{\{\s*(\S+)\s*\}\}/,\s*是因为前后可能有空格,\S+则是我们真正需要的字符串,即data对象中的属性,当然,节点可以包含节点,所以我们可以用递归的方法来遍历所有节点,把节点类型为3(即文本节点)并且能够通过正则匹配的节点文本信息挑选出来,调用exec进行正则匹配,然后把文本信息替换为$data中的属性信息,最后再把文档碎片放回页面中。
static docParse(vm) {
let fragment = document.createDocumentFragment()
let node
while (node = vm.$el.firstChild) {
fragment.append(node)
}
MyVue.processFragment(fragment, vm)
//一定要把文档碎片加回去,否则页面不会显示
vm.$el.appendChild(fragment)
}
static processFragment(node, vm) {
//正则表达式
let match = /\{\{\s*(\S+)\s*\}\}/
//获取节点类型为3的节点
if (node.nodeType === 3) {
//用exec进行正则匹配
let res = match.exec(node.nodeValue)
//判断是否匹配成功
if (res) {
//打印测试
console.log(res)
}
return
}
//递归的遍历每一个节点的子节点
node.childNodes.forEach(e => MyVue.processFragment(e, vm));
}
刷新页面后控制台就会打印筛选之后的信息
发现返回的是数组,而且数组的第二个元素,也就是下标为1的元素才是我们所需要的,里面的文本可以帮助我们获取$data中的属性值,可以直接用$data['name']这样的方式获取值,但是有些属性是嵌套的,例如msg.text,直接用$data['msg.text']是获取不到的,所以这里我们需要链式获取,把字符串用'.'进行分割获取字符串数组, 然后用数组的reduce方法进行链式获取。获取信息后我们再把{{}}中的信息替换为$data中属性的值
static docParse(vm) {
let fragment = document.createDocumentFragment()
let node
while (node = vm.$el.firstChild) {
fragment.append(node)
}
MyVue.processFragment(fragment, vm)
vm.$el.appendChild(fragment)
}
static processFragment(node, vm) {
let match = /\{\{\s*(\S+)\s*\}\}/
if (node.nodeType === 3) {
let res = match.exec(node.nodeValue)
if (res) {
/*将我们需要的字符串的下标用'.'进行分割获取字符串数组,然后使用
reduce方法进行链式获取,reduce中初始值的参数为$data,这样即便
是嵌套的属性也能获取到属性值*/
let text = res[1].split('.').reduce((pre, next) => pre[next], vm.$data)
//将节点文本中的{{xxx}}替换为$data中的数据
node.nodeValue = node.nodeValue.replace(match, text)
}
return
}
node.childNodes.forEach(e => MyVue.processFragment(e, vm));
}
当我们再次刷新页面时,页面中的{{xxx}}已经成功替换成了$data中的数据了
5.实现视图绑定数据
虽然信息可以再页面成功显示,但是当数据发生变化时,视图并不会发生变化,要实现数据变化影响视图变化,这里就需要发布者-订阅者模式。我们先创建发布者类Publish,该类维护了一个订阅者数组,Publish类负责通知订阅者更新数据。
class Publish {
//构造方法,创建订阅者数组
constructor() {
this.subs = []
}
//向订阅者数组中添加订阅者
addSub(sub) {
this.subs.push(sub)
}
//通知每个订阅者更新数据
notify() {
this.subs.forEach(e => e.update())
}
}
接着我们再创建一个订阅者类,为了方便,我们向构造方法中传入vm, 属性字符串(例如'msg.text'), 和一个回调函数,用来告诉订阅者如何更新自己的数据,这里使用回调是因为后面可以用闭包,可以少传参数
class Subscribe {
//构造方法
constructor(vm, attribute, callback) {
this.$vm = vm
this.$attribute = attribute
this.$callback = callback
}
//订阅者更新方法,具体逻辑我们稍后来实现
update() {
}
}
创建好订阅者和发布者之后,我们的问题就变成了什么时候创建订阅者和发布者对象。
先说订阅者,我们其实在解析页面的时候就可以创建订阅者了,我们回到processFragment方法,加上创建订阅者的代码。传入属性字符串和负责更新的回调函数。
static processFragment(node, vm) {
let match = /\{\{\s*(\S+)\s*\}\}/
if (node.nodeType === 3) {
let res = match.exec(node.nodeValue)
//这里添加一个临时变量来存储一下nodeValue
let nodeTemp = node.nodeValue
if (res) {
let text = res[1].split('.').reduce((pre, next) => pre[next], vm.$data)
node.nodeValue = node.nodeValue.replace(match, text)
//创建订阅者对象
new Subscribe(vm, res[1], newVal => {
//这里是数据更新的方式,就是更新时,把页面节点的值替换为新的值
node.nodeValue = nodeTemp.replace(match, newVal)
})
}
return
}
node.childNodes.forEach(e => MyVue.processFragment(e, vm));
}
之后我们就需要把订阅者对象添加到发布者对象的订阅者数组里了,我们可以在订阅者类的构造方法中加入新的逻辑,把订阅者自己存在一个临时变量里,然后触发设置了监听属性的getter方法,把订阅者加入发布者的订阅者数组中。
class Subscribe {
constructor(vm, attribute, callback) {
this.$vm = vm
this.$attribute = attribute
this.$callback = callback
//将自己放入发布者类的一个临时变量中
Publish.temp = this
//这里是为了能够触发每一个属性的getter方法,来把该订阅者添加到发布者中
attribute.split('.').reduce((pre, next) => pre[next], vm.$data)
//放到发布者的订阅者数组中后,要将其置为空
Publish.temp = null
}
update() {
}
}
因为我们是在设置监听时的getter中把订阅者加入订阅者数组的,所以我们需要回到listenData方法中去添加代码,首先我们要在方法里创建发布者类,在getter中添加订阅者,在setter中调用notify方法来通知订阅者更新节点信息
static listenData(data_instance) {
if (!data_instance || typeof data_instance !== 'object') return
//创建发布者对象
const publish = new Publish()
Object.keys(data_instance).forEach(key => {
let value = data_instance[key]
MyVue.listenData(value)
Object.defineProperty(data_instance, key, {
get: () => {
//判断发布者中的临时变量是否存在,存在就将其加入到订阅者数组中
if (Publish.temp) {
publish.addSub(Publish.temp)
}
return value
},
set: (newVal) => {
value = newVal
MyVue.listenData(value)
//设置完新之后,通知订阅者更新页面的节点信息
publish.notify()
}
})
})
}
到这一步之后,我们就可以回去完成订阅者类的update方法了
class Subscribe {
constructor(vm, attribute, callback) {
this.$vm = vm
this.$attribute = attribute
this.$callback = callback
Publish.temp = this
attribute.split('.').reduce((pre, next) => pre[next], vm.$data)
Publish.temp = null
}
update() {
//获取$data中对应的属性的值
let val = this.$attribute.split('.').reduce(
(pre, next) => pre[next], this.$vm.$data
)
//调用传入的回调函数,也就是更新页面节点的值
this.$callback(val)
}
}
到这,视图绑定数据就完成了,可以测试一下,当$data中的值改变时,视图的值也会一起发生变化
6.实现数据绑定视图
视图变化,数据也要发生变化。我们需要在页面中解析出带有v-model属性的input元素,然后当input的value发生变化时,$data中的数据也要一起发生变化。 我们回到processFragment方法,向里面添加筛选input标签的代码,其具体逻辑和之前处理文本节点大致相同
static processFragment(node, vm) {
let match = /\{\{\s*(\S+)\s*\}\}/
if (node.nodeType === 3) {
let res = match.exec(node.nodeValue)
let nodeTemp = node.nodeValue
if (res) {
let text = res[1].split('.').reduce((pre, next) => pre[next], vm.$data)
node.nodeValue = node.nodeValue.replace(match, text)
new Subscribe(vm, res[1], newVal => {
node.nodeValue = nodeTemp.replace(match, newVal)
})
}
return
}
//判断是否为input节点
if (node.nodeType === 1 && node.nodeName === 'INPUT') {
let attr = Array.from(node.attributes)
//遍历节点属性值,判断是否有v-model标签
attr.forEach(e => {
if (e.nodeName === 'v-model') {
let v = e.nodeValue.split('.').reduce(
(pre, next) => pre[next], vm.$data
)
node.value = v
//同样需要一个订阅者来实时更新input中的value值
new Subscribe(vm, e.nodeValue, newVal => {
node.value = newVal
})
}
})
}
node.childNodes.forEach(e => MyVue.processFragment(e, vm));
}
再次刷新页面,就可以发现输入框中也已经和数据实现了绑定
接下来,就是最后一步了,当input框中的内容发生变化时,也要影响$data中的数据,这里可以给input元素添加监听事件来实现,然后把文本值赋值给$data中的属性。由于这里是给属性设置值,所以需要用一个中间数组转换一下。
static processFragment(node, vm) {
let match = /\{\{\s*(\S+)\s*\}\}/
if (node.nodeType === 3) {
let res = match.exec(node.nodeValue)
let nodeTemp = node.nodeValue
if (res) {
let text = res[1].split('.').reduce((pre, next) => pre[next], vm.$data)
node.nodeValue = node.nodeValue.replace(match, text)
new Subscribe(vm, res[1], newVal => {
node.nodeValue = nodeTemp.replace(match, newVal)
})
}
return
}
if (node.nodeType === 1 && node.nodeName === 'INPUT') {
let attr = Array.from(node.attributes)
attr.forEach(e => {
if (e.nodeName === 'v-model') {
let v = e.nodeValue.split('.').reduce(
(pre, next) => pre[next], vm.$data
)
node.value = v
new Subscribe(vm, e.nodeValue, newVal => {
node.value = newVal
})
//为input节点添加事件监听,当输入文本时便更新$data中的数据
node.addEventListener('input', ev => {
let temp = e.nodeValue.split('.')
let temp_1 = temp.splice(0, temp.length - 1)
let temp_2 = temp_1.reduce((pre, next) => pre[next], vm.$data)
//将$data中对应属性的值设置为input中的值
temp_2[temp[temp.length - 1]] = ev.target.value
})
}
})
}
node.childNodes.forEach(e => MyVue.processFragment(e, vm));
}
至此,双向绑定完成,刷新页面,输入框中值变化时,页面中的其他文本包括数据也会一起变化。
三、总结
vue2双向绑定的核心就是利用Object.defineProperty方法,对vm对象进行数据劫持,最终实现双向绑定的功能,vue3采用的是Proxy,整体上的原理大同小异,最后附上MyVue.js的全部源码
class MyVue {
constructor(data_instance) {
this.$data = data_instance.data
this.$el = document.querySelector(data_instance.el)
MyVue.listenData(this.$data)
MyVue.docParse(this)
}
static docParse(vm) {
let fragment = document.createDocumentFragment()
let node
while (node = vm.$el.firstChild) {
fragment.append(node)
}
MyVue.processFragment(fragment, vm)
vm.$el.appendChild(fragment)
}
static processFragment(node, vm) {
let match = /\{\{\s*(\S+)\s*\}\}/
if (node.nodeType === 3) {
let res = match.exec(node.nodeValue)
let nodeTemp = node.nodeValue
if (res) {
let text = res[1].split('.').reduce((pre, next) => pre[next], vm.$data)
node.nodeValue = node.nodeValue.replace(match, text)
new Subscribe(vm, res[1], newVal => {
node.nodeValue = nodeTemp.replace(match, newVal)
})
}
return
}
if (node.nodeType === 1 && node.nodeName === 'INPUT') {
let attr = Array.from(node.attributes)
attr.forEach(e => {
if (e.nodeName === 'v-model') {
let v = e.nodeValue.split('.').reduce(
(pre, next) => pre[next], vm.$data
)
node.value = v
new Subscribe(vm, e.nodeValue, newVal => {
node.value = newVal
})
node.addEventListener('input', ev => {
let temp = e.nodeValue.split('.')
let temp_1 = temp.splice(0, temp.length - 1)
let temp_2 = temp_1.reduce((pre, next) => pre[next], vm.$data)
temp_2[temp[temp.length - 1]] = ev.target.value
})
}
})
}
node.childNodes.forEach(e => MyVue.processFragment(e, vm));
}
static listenData(data_instance) {
if (!data_instance || typeof data_instance !== 'object') return
const publish = new Publish()
Object.keys(data_instance).forEach(key => {
let value = data_instance[key]
MyVue.listenData(value)
Object.defineProperty(data_instance, key, {
get: () => {
if (Publish.temp) {
publish.addSub(Publish.temp)
}
return value
},
set: (newVal) => {
value = newVal
MyVue.listenData(value)
publish.notify()
}
})
})
}
}
class Publish {
constructor() {
this.subs = []
}
addSub(sub) {
this.subs.push(sub)
}
notify() {
this.subs.forEach(e => e.update())
}
}
class Subscribe {
constructor(vm, attribute, callback) {
this.$vm = vm
this.$attribute = attribute
this.$callback = callback
Publish.temp = this
attribute.split('.').reduce((pre, next) => pre[next], vm.$data)
Publish.temp = null
}
update() {
let val = this.$attribute.split('.').reduce(
(pre, next) => pre[next], this.$vm.$data
)
this.$callback(val)
}
}