手写一个Vue双向绑定实现的Demo

本文最后更新于:2022年7月19日 上午

浅谈 ES6 中的 Proxy 用法 中我们提到,在 Vue2 中使用 Object.defineProperty 和 Vue3 中使用 Proxy 实现了双向绑定。那么它是如何具体实现的?以下,我们便探讨如何自己手写一个双向绑定。

Object.defineProperty

首先我们的 HTML 中渲染一个 <p>标签 和一个 <input> 标签。

1
2
3
4
<div id="app">
<p class="title-1">Vue Demo: Object.defineProperty()</p>
<input class="input-1" />
</div>

如果我们通过 Object.defineProperty 来实现双向绑定,我们需要重写 data 中的 getset 方法。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 此处我们将 data.content 的值绑定至 p 和 input 上。
let data = {
content: ''
}

let el = {
title: document.querySelector('.title-1'),
input: document.querySelector('.input-1')
}

// 在 Vue2 中实际是对 data 进行了一个 Object.keys() 遍历后再重写的方法。这里我们简写即可。
Object.defineProperty(data,'content',{
get(){
return el.title.innerHTML
},
set(val){
el.input.value = val
el.title.innerHTML = val
}
})

但这还不够,既然是双向绑定,那当我们输入值的时候,输入框里面的值更改也会影响到 data.content 的值,所以我们还得对 input 加入一个 keyup或者 input 的监听事件。如下:

1
2
3
4
el.input.addEventListener('keyup',(e)=>{
data.content = e.target.value
})
// 此外,在 Vue 中的 v-model 实际是一个语法糖,实现了 :value="myVal" 和 @input="myVal=$event.target.value"

在线示例

CodePen.io

具体效果如下:

Vue Demo: Object.defineProperty()

Proxy

然后我们使用 Proxy 来实现这个双向绑定。同理,我们在页面渲染一个 <p> 标签和一个 <input> 标签。

1
2
3
4
<div id="app">
<p class="title-2">Vue Demo: Proxy</p>
<input class="input-2" />
</div>

js代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let data = {
content: ''
}

let el = {
title: document.querySelector('.title-2'),
input: document.querySelector('.input-2')
}

data = new Proxy(data,{
get(target,property,receiver){
return Reflect.get(target,property,receiver)
},
set(target,property,value,receiver){
el.input.value = value
el.title.innerHTML = value
return Reflect.set(...arguments)
}
})

el.input.addEventListener('keyup',(e)=>{
data.content = e.target.value
})

在线示例

CodePen.io

具体效果如下:

Vue Demo: Proxy

Demo

以上只是开胃菜,现在我们还实现一个稍微完整点的Demo。包含 v-bind v-model @click 等。

HTML代码如下:

1
2
3
4
5
6
7
<div id="app">
<p v-bind="count"></p>
<input type="text" v-model="count"/>
<button @click="add">+</button>
<button @click="reduce">-</button>
<button @click="reset">Reset</button>
</div>

js代码如下:

Vue类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
class Vue{
constructor(options){
const vm = this
vm.$el = document.querySelector(options.el)
vm.$methods = options.methods
let data = options.data
data = vm._data = typeof data === "function" ? data.call(vm,vm) : data || {}
vm._binding = {}
vm._observer(data)
vm._compile(vm.$el)
}
_pushWatcher(watcher){
if(!this._binding[watcher.key]){
this._binding[watcher.key] = []
}
this._binding[watcher.key].push(watcher)
}
_observer(data){
const vm = this
const handler = {
set(target,property,value){
const res = Reflect.set(...arguments)
vm._binding[property].map(item => {
// 调用指定方法,更新视图
item.update()
})
return res
}
}
// 利用 Proxy 实现的双向绑定
vm._data = new Proxy(data,handler)
}
_compile(root){
const nodes = [...root.children]
const data = this._data
nodes.map(node => {
// 遍历根元素节点下的子元素
if(node.children && node.children.length){
// 如果子元素下仍然包含子元素,则递归遍历
this._compile(node.children)
}
const $input = node.tagName.toLowerCase() === 'input'
const $textarea = node.tagName.toLowerCase() === 'textarea'
const $vmodel = node.hasAttribute('v-model')
if($vmodel && ($input || $textarea)){
// 此处判断该 input 或 textarea 元素是否含有 v-model 双向绑定相关的属性。如果有则添加 Watcher 和事件监听
const key = node.getAttribute('v-model')
this._pushWatcher(new Watcher(node,'value',data,key))
node.addEventListener('input',() =>{
data[key] = node.value
})
}
if(node.hasAttribute('v-bind')){
// 此处判断指定节点是否存在 v-bind。若存在则添加 Watcher
const key = node.getAttribute('v-bind')
this._pushWatcher(new Watcher(node,'innerHTML',data,key))
}
if(node.hasAttribute('@click')){
// 此处判断指定节点是否存在 @click。若存在则添加相关事件绑定。
const name = node.getAttribute('@click')
const method = this.$methods[name].bind(data)
node.addEventListener('click',method)
}
})
}
}

Watcher类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Watcher{
constructor(node,attr,data,key){
this.node = node
this.attr = attr
this.data = data
this.key = key
this.update() // 将视图初始化
}
update(){
// 此处实际上就是更新视图的。操作类似于 el.value = newVal / el.innerHTML = newVal
let value = Reflect.get(this.data,this.key)
Reflect.set(this.node,this.attr,value)
}
}

Vue实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const count = 99

new Vue({
el: "#app",
data(){
// 为什么 data 是一个函数而不是一个对象。
// 因为 Vue 中可以会存在多个实例,如果它是一个对象,会导致所有的实例公用一个对象。从而会导致数据出现混乱的情况。
// 而使用函数,每次实例化一个Vue都会返回一个全新的data,不会导致数据出现混乱的情况。
return{
count: count
}
},
methods:{
add(){
this.count++
},
reduce(){
this.count--
},
reset(){
this.count = count
}
}
})

在线示例

CodePen.io

具体效果如下:

Vue Demo

THE END

综上便是一个 Vue 双向绑定的 Demo 实现。实际上 Vue 的源码层面还是稍微有点复杂的,在整个实例创建过程中还涉及到虚拟DOM、生命周期(Hook函数)等等操作。有兴趣可以直接前往 Vue 仓库查看具体代码实现。


手写一个Vue双向绑定实现的Demo
https://toflying.com/2022/07/14/8-vue-demo/
作者
KingChen
发布于
2022年7月14日
更新于
2022年7月19日
许可协议