我们在使用vue时,将需要双向绑定数据的输入控件上使用
v-model
指令,就可以让数据与视图同步更新,那么这到底是怎么做的呢?今天我们自己来实现一个简单MVVM,去探究下数据双向绑定的原理。
页面准备
搭好基础页面,方便随时查看和调试
...
<body>
<div id="app">
<h1>Simple V-Model</h1>
<input type="text" v-model="name" />
<h2>姓名:{{name}}</h2>
<hr>
<input type="number" v-model="age" />
<h2>年龄:{{age}}</h2>
</div>
</body>
<script>
const vm = new Vue({
el: '#app',
data: {
name: '中国',
age: 72,
},
});
</script>
然后随便写几行样式,预览一下
开始编码
观察vue初始化结构可知,
new Vue()
时传入了一些基础配置,el
为挂载的容器,data
为响应式数据,我们将这些都挂载到Vue的构造函数上。因此我们先写出Vue类,此处我用函数写的
function Vue(option){
this.$el = document.querySelector(option.el);
this.$data = option.data;
}
下一步需要思考的是如何去处理data中的数据。在Vue中,处理数据用的是Object.defineProperty
来劫持对象数据的,对于数组而言,则是重写能改变原数组的一些方法,此例中暂不考虑数组
定义observe方法来劫持数据
function observe(data) {
// 先判断是否为对象,不是直接跳出
if (({}).toString.call(data) !== '[object Object]') return;
let keys = Object.keys(data)
keys.forEach(key => {
defineReactive(data, key, data[key])
})
}
使用defineReactive
方法来对数据进行处理,内部是采用Object.defineProperty
重写setter 和 getter。源码方法名为defineReactive$$1
function defineReactive(obj, key, value) {
Object.defineProperty(obj, key, {
get() {
return value
},
set(newVal) {
value = newVal
},
enumerable: true
})
}
数据处理完,下一步我们要对模板进行编译,vue中采用的VNode,这里为了简便,我们用文档碎片来代替VNode实现
function Vue(option) {
this.$el = document.querySelector(option.el)
this.$data = option.data
// 数据劫持
observe(this.$data)
// dom编译
nodeToFragment(this.$el)
}
实现nodeToFragment方法。
function nodeToFragment(el) {
// 创建文档碎片
let fragment = document.createDocumentFragment()
let child;
// 将每一个文档节点进行编译,然后添加到文档碎片中
while (child = el.firstChild) {
compile(child)
fragment.appendChild(child)
}
// 将文档碎片加载到el上,挂载
el.appendChild(fragment)
}
编译时,我们需要根据节点的nodeType
来判断是元素还是文本类型
nodeType常用四种类型:1(元素)3(文本)8(注释)9(DOM根节点)
编译时,我们需要取到实例中data中的值,因此需要将this传入
function compile(node, vm) {
// 获取当前节点的节点类型
let nodeType = node.nodeType
if (nodeType == 1) {
// 如果是元素节点
// 获取当前元素的所有属性
let attrs = node.attributes;
[...attrs].forEach(attr => {
// 是否是以 v- 开头
// 此处我们只考虑 v-model
if (/^v-/.test(attr.nodeName)) {
// 拿到属性名 即 attr.nodeValue
let attrName = attr.nodeValue
// 只考虑 input 情况
// 将data中对应的值赋给input.value
let val = vm.$data[attrName]
node.value = val
}
});
// 以上只是处理了当前元素
// 对于元素的子元素,进行递归编译
[...node.childNodes].forEach(childNode => {
compile(childNode, vm)
})
} else {
// 如果是文本节点
// 再次重申:我们只考虑了元素和文本类型
let text = node.textContent
let reg = /\{\{(\w+)\}\}/
if (reg.test(text)) {
// 如果是{{}}的结构,就直接替换为值
text = text.replace(reg, (a, b) => {
return vm.$data[b]
})
node.textContent = text
}
}
}
至此,基本的功能已经完成。但是,我们在控制台改变数据时,视图并没有得到更新。
此时,我们需要一种方法去监视数据的改变。我们使用观察者模式来完成。
先创建监视器,在Dep中存放所有的监视器
class Dep {
constructor() {
this.subs = []
}
// 添加订阅
addSub(sub) {
this.subs.push(sub)
}
// 通知更新
notify() {
this.subs.forEach(sub => {
// todo 需要通知后做的事情
})
}
}
为了能监视到所有属性的变化,我们在劫持属性时,就给每一个属性新增监视器
function defineReactive(obj, key, value) {
// 添加监视器
let dep = new Dep()
Object.defineProperty(obj, key, {})
}
添加完监视器,我们还需要一个监视者,需要知道监视的对象、属性等
class Watcher {
constructor(target, key, vm) {
// 将当前的watcher实例赋值给Dep.target
Dep.target = this
this.target = target
this.key = key
this.vm = vm
// 获取值
this.getValue()
// 最后将Dep.target设为null是为了防止重复添加watcher
Dep.target = null
}
getValue() {
this.value = this.vm.$data[this.key]
}
}
在取值时,我们就收集watcher,当值发生变化时,通知更新
function defineReactive(obj, key, value) {
let dep = new Dep()
Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
dep.addSub(Dep.target)
}
return value
},
set(newVal) {
if (newVal !== value) {
value = newVal
dep.notify()
}
},
enumerable: true
})
}
notify需要做什么呢?当然是通知更新视图了。在Watcher中,添加update方法。
...
update() {
// 更新时取到最新的值
this.getValue()
if (this.target.nodeType == 1) {
// 如果是 input,就将值赋给input.value
this.target.value = this.value
} else {
// 若是文本,直接赋值textContent
this.target.textContent = this.value
}
}
在触发getter的地方,放上我们的观察者
function compile(node, vm) {
let nodeType = node.nodeType
if (nodeType == 1) {
let attrs = node.attributes;
[...attrs].forEach(attr => {
if (/^v-/.test(attr.nodeName)) {
let attrName = attr.nodeValue
let val = vm.$data[attrName]
new Watcher(node, attrName, vm)
node.value = val
}
});
[...node.childNodes].forEach(childNode => {
compile(childNode, vm)
})
} else {
let text = node.textContent
let reg = /\{\{(\w+)\}\}/
if (reg.test(text)) {
text = text.replace(reg, (a, b) => {
new Watcher(node, b, vm)
return vm.$data[b]
})
node.textContent = text
}
}
}
此时,我们再在控制台修改数据,就会发现页面上数据会同步变化。但还存在一个问题,我们在input中输入的文本并没有同步到text中,此时只需要在编译时给input添加一个事件即可
...
node.addEventListener('input', (e) => {
vm.$data[attrName] = e.target.value
})
到此,简单版双向绑定就搞定了。由于本次只是梳理双向数据绑定的基本原理,并没有对其它内容进行代码兼容处理,很多地方都采用了简化处理,想进一步了解原理,建议还是阅读下源码,大有裨益。
最后附上完整版代码,仅供参考,不足之处,望指正。
function Vue(option) {
this.$el = document.querySelector(option.el)
this.$data = option.data
// 数据劫持
observe(this.$data)
// dom编译
nodeToFragment(this.$el, this)
}
function observe(data) {
if (({}).toString.call(data) !== '[object Object]') return;
let keys = Object.keys(data)
keys.forEach(key => {
defineReactive(data, key, data[key])
})
}
function defineReactive(obj, key, value) {
let dep = new Dep()
Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
dep.addSub(Dep.target)
}
return value
},
set(newVal) {
if (newVal !== value) {
value = newVal
dep.notify()
}
},
enumerable: true
})
}
function nodeToFragment(el, vm) {
let fragment = document.createDocumentFragment()
let child;
while (child = el.firstChild) {
compile(child, vm)
fragment.appendChild(child)
}
el.appendChild(fragment)
}
function compile(node, vm) {
let nodeType = node.nodeType
if (nodeType == 1) {
let attrs = node.attributes;
[...attrs].forEach(attr => {
if (/^v-/.test(attr.nodeName)) {
let attrName = attr.nodeValue
let val = vm.$data[attrName]
new Watcher(node, attrName, vm)
node.value = val
node.addEventListener('input', (e) => {
vm.$data[attrName] = e.target.value
})
}
});
[...node.childNodes].forEach(childNode => {
compile(childNode, vm)
})
} else {
let text = node.textContent
let reg = /\{\{(\w+)\}\}/
if (reg.test(text)) {
text = text.replace(reg, (a, b) => {
new Watcher(node, b, vm)
return vm.$data[b]
})
node.textContent = text
}
}
}
class Dep {
constructor() {
this.subs = []
}
addSub(sub) {
this.subs.push(sub)
}
notify() {
this.subs.forEach(sub => {
sub.update()
})
}
}
class Watcher {
constructor(target, key, vm) {
Dep.target = this
this.target = target
this.key = key
this.vm = vm
this.getValue()
Dep.target = null
}
getValue() {
this.value = this.vm.$data[this.key]
}
update() {
this.getValue()
if (this.target.nodeType == 1) {
this.target.value = this.value
} else {
this.target.textContent = this.value
}
}
}