一 前言
- 本文介绍的是vue中双向数据绑定的基本原理,文中将通过手写代码的形式体会数据绑定的流程,在实现细节上可能和vue源码有所差异,但是数据绑定的核心代码和基本原理是一致的。
- 由于文中的重点是双向数据绑定原理,所以下面出现的模板编译函数不是我们的重点,所以这里的模板编译函数是一个经过简化的版本,实际上vue中的模板编译需要经历至少三个阶段。
- 文中的模板编译采取简单的正则匹配,只能识别
template 中的{{}} ,而不能识别v-for 这样的循环,但是这不是文章的重点,这里只需要读者知道这点即可。 - 学习本文,你可以收获如下知识点:发布订阅模式、数据劫持、数据代理、文档碎片、数据绑定的基本原理和流程。
- 参考视频:vue双向数据绑定原理
二 前置知识
2.1 学会使用reduce
reduce是什么?
reduce() 是数组的一个方法,对数组中的每个元素按序执行一个由您提供的 reducer 函数;- 每一次运行
reducer 会将先前元素的计算结果作为参数传入,最后将其结果汇总为单个返回值。 - 其使用的场景通常是累计,更通俗的讲,如果当前运算需要上一次的返回结果,那么将会考虑使用
reduce() 。
reduce的参数
下面展示的是reduce 的参数,reduce 接收两个参数:
- 第一个是回调函数
reducer ,而reducer 可以接收4个参数,重点掌握前两个。 - 第二是初始值
initialValue ,相当于第一次的返回值。 如果对reduce 还不是很熟悉,可以参考MDN文档,或者学习下面的应用实例。
使用reduce链式获取对象属性
我们学习reduce 方法的目的,就是为了链式获取对象的属性,这在后面的学习会经常用到。
const obj = {
name: 'zs',
info: {
address: {
location: '北京顺义'
},
},
}
const attrStr = 'info.address.location'
console.log(attrStr.split('.'));
const location = attrStr.split('.').reduce((newObj, key) => {return newObj[key]}, obj);
console.log(location);
2.2 发布订阅模式
发布订阅模式是什么?
发布订阅模式是实现双向数据绑定的一个重要知识点。 该模式主要由依赖(订阅者)收集器Dep 和订阅者Watcher 组成。 以下是Dep类 和Watcher类 需要具备的功能。
vue中发布订阅模式如何运作
发布订阅模式并不难理解,但是它在实际的场景是怎么运用的呢?
- 在
vue 中,只要我们为vue 中data 数据重新赋值,这个赋值的动作,会被vue 监听到 - 然后
vue 要把数据的变化,通过dep,通知到每个订阅者 ! ! ! - 接下来,订阅者(DOM元素)要根据最新的数据,更新自己的内容
实现一个简单的发布订阅模式
class Dep {
constructor() {
this.subs = []
}
addSub(wacther) {
this.subs.push(wacther)
}
notify() {
this.subs.forEach((wacther) => wacther.update)
}
}
class Wacther {
constructor(cb) {
this.cb = cb
}
update() {
this.cb()
}
}
const w1 = new Wacther(console.log("我是第一个订阅者"))
const w2 = new Wacther(console.log("我是第二个订阅者"))
const dep = new Dep()
dep.addSub(w1)
dep.addSub(w2)
dep.notify()
三 数据劫持和数据代理
3.1 Object.defineProperty()和Object.keys()
学习数据代理和数据劫持之前,必须先了解Object.defineProperty() 和Object.keys()
Object.defineProperty()
Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。Object.defineProperty() 有三个参数,重点是第三个参数descriptor ,是一个对象,有多个可选键值,重点是getter函数 和setter函数 。- 更多请见MDN文档
Object.keys()
Object.keys() 方法会返回一个由一个给定对象的自身可枚举属性组成的数组,数组中属性名的排列顺序和正常循环遍历该对象时返回的顺序一致 。
3.2 文件目录结构
前面的基础知识已经介绍的差不多了,接下来开始逐步实现数据的双向绑定; 这里我们需要一个src目录,以下有两个文件index.html (用于页面显示和测试),和vue.js (用于存放我们的代码,实质上是一个只实现了数据绑定功能的简易vue); 下面给出index.html 的模板代码,和vue 的初步框架。
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
<p>name: {{ name }}</p>
<p>age: {{ age }}</p>
<p>info.a: {{info.a}}</p>
<div>name的值:<input type="text" v-model="name"/></div>
<div>info.a的值:<input type="text" v-model="info.a"/></div>
</div>
<script src="./vue.js"></script>
<script>
const vm = new Vue({
el: '#app',
data: {
name: '张三',
age: 18,
info: {
a: 'a',
b: 'b'
}
}
})
</script>
</body>
</html>
vue.js
class Vue {
constructor(options) {
this.$data = options.data
}
}
3.3 数据劫持
数据劫持是什么?
- 数据劫持,指的是在访问或者修改对象的某个属性时,通过一段代码拦截这个行为,进行额外的操作或者修改返回结果。
- 在vue2.x中使用的是
Object.defineProperty() 实现;而在vue3.x改用Proxy 进行实现。 - 之所以需要数据劫持,是因为在vue中,在读取和设置data中的数据时,需要一些额外的操作。
- 在具体实现上,就是给数据添加上
getter 和setter 函数,在函数中,进行基本的数据读取和设置,以及调取额外的操作。
实现Observe方法
function Observe(obj) {
if(!obj || typeof obj !== 'object') return
Object.keys(obj).forEach(key => {
let value = obj[key]
Observe(value)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
console.log("调用了getter,在vue中要添加依赖,再返回该值")
return value
},
set(newValue) {
console.log("调用了setter,要设置新值,在vue中还要通知订阅者进行更新操作");
value = newValue
Observe(value)
}
})
})
}
在vue类中调用。
class Vue {
constructor(options) {
this.$data = options.data
Observe(this.$data)
}
}
在读取和设置时顺利进行了额外的操作:
嵌套对象中的属性也有setter和getter:
重新赋值的对象也有setter和getter:
3.4 数据代理
数据代理是什么?
- 通过一个对象代理对另一个对象中的属性的操作(读/写),就是数据代理。
- 上面我们都是通过
vm.$data.name 的方式实现数据获取,而在vue中,实际可以通过vm.name 直接获取数据。 - 也就是说,vue实例代理了对data中属性的操作(读/写)。
数据代理的实现
class Vue {
constructor(options) {
this.$data = options.data
Observe(this.$data)
Object.keys(this.$data).forEach(key => {
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get() {
return this.$data[key]
},
set(newValue) {
this.$data[key] = newValue
}
})
})
}
}
代理效果:
四 模板编译的简易实现
4.1 介绍文档碎片的作用
-
当我们对模板进行编译,最后将数据渲染到页面时, 每一次对 DOM 的操作都将发生 “重排”(位置发生变化)或 “重绘”(内容发生变化),这严重消耗性能,一般通常的做法是减少dom 操作, 减少发生重排或重绘的做法。 -
文档碎片是一个容器用于暂时存放创建的 DOM 元素。 -
如果我们将需要添加的大量元素,先添加到文档碎片中,再将文档碎片添加到需要插入的位置, 可以大大减少 DOM 操作, 提高性能。
4.2 创建文档碎片
function Compile(el, vm) {
vm.$el = document.querySelector(el)
const fragement = document.createDocumentFragment()
while((childNode = vm.$el.firstChild)) {
fragement.appendChild(childNode)
}
replace(fragement)
vm.$el.appendChild(fragement)
}
在vue类中调用,传入参数
class Vue {
constructor(options) {
this.$data = options.data
Observe(this.$data)
Object.keys(this.$data).forEach(key => {
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get() {
return this.$data[key]
},
set(newValue) {
this.$data[key] = newValue
}
})
})
Compile(options.el, this)
}
}
4.3 实现replace()方法
关于replace()方法
Compile() 函数中,实现模板编译的核心函数是replace() replace() 函数的定义可以放在Compile() 内部,其功能是匹配模板中的插值语法{{ }} ,完成数据的注入。replace() 函数主要步骤如下:
需要的正则知识
正则表达式
- 我们要匹配的是插值语法,正则表达式为:
/\{\{\s*(\S+)\s*\}\}/ / / 中间为正则的具体内容,\{\{ \}\} 匹配的是双大括号,\s* 表示匹配零个或多个空白字符, \S+ 表示匹配非空白字符1个或多个,() 表示分组捕获。- 所以该正则表达式匹配的字符串是
{{name}} 或者{{ name }} 这样的。
RegExp.prototype.exec()
exec() 方法在一个指定字符串中执行一个搜索匹配。返回一个结果数组或 null。
下面给出返回的数组各项内容:
relace()的实现
function replace(node) {
const regMustache = /\{\{\s*(\S+)\s*\}\}/
if(node.nodeType === 3) {
const text = node.textContent
const execResult = regMustache.exec(text)
console.log(execResult);
if(execResult) {
const value = execResult[1].split('.').reduce((newObj, k) => newObj[k], vm)
node.textContent = text.replace(regMustache, value)
}
return;
}
node.childNodes.forEach((child) => replace(child))
}
五 Dep类和Wacther类
5.1 创建Dep类和Watcher类
- 前面已经介绍了发布订阅在vue中如何运作。
- 下面就要开始用代码实现前面介绍的发布订阅模式了。
class Dep {
constructor() {
this.subs = []
}
addSub(wacther) {
this.subs.push(wacther)
}
notify() {
this.subs.forEach((wacther) => wacther.update())
}
}
class Wacther {
constructor(vm, key, cb) {
this.vm = vm
this.key = key
this.cb = cb
}
update() {
const value = this.key.split('.').reduce((newObj, k) => newObj[k], this.vm)
this.cb(value)
}
}
5.2 创建Wacther类的实例
创建Wacther类实例的时机是当我们进行模板编译的时候。 在初次进行模板编译的时候,就创建出订阅者,然后添加到依赖收集器dep中。 这样在后续数据更新的时候,dep就可以通知订阅者们进行相应的update() 操作。
function replace(node) {
const regMustache = /\{\{\s*(\S+)\s*\}\}/
if(node.nodeType === 3) {
const text = node.textContent
const execResult = regMustache.exec(text)
console.log(execResult);
if(execResult) {
const value = execResult[1].split('.').reduce((newObj, k) => newObj[k], vm)
node.textContent = text.replace(regMustache, value)
new Wacther(vm, execResult[1], (newValue) => {
node.textContent = text.replace(regMustache, newValue)
})
}
return;
}
node.childNodes.forEach((child) => replace(child))
}
}
5.3 三行神奇的代码
- 上面讲了创建订阅者的时机,但是还没有说明证明添加依赖到dep中。
- 添加依赖需要用到三行比较巧妙的代码。
首先,我们需要在Wacther的构造函数增加这三行代码,使其在创建实例时,顺便完成添加依赖操作。
class Wacther {
constructor(vm, key, cb) {
this.vm = vm
this.key = key
this.cb = cb
Dep.target = this
key.split('.').reduce((newObj, k) => newObj[k], vm)
Dep.target = null
}
update() {
const value = this.key.split('.').reduce((newObj, k) => newObj[k], this.vm)
this.cb(value)
}
}
前面数据劫持中,说过了我们需要通过getter 和setter 做一些额外的操作,也通过打印的方式说明这些操作是什么。 接下来要完善代码,实现这些额外的操作。
function Observe(obj) {
let dep = new Dep()
if(!obj || typeof obj !== 'object') return
Object.keys(obj).forEach(key => {
let value = obj[key]
Observe(value)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
Dep.target && dep.addSub(Dep.target)
return value
},
set(newValue) {
value = newValue
Observe(value)
dep.notify()
}
})
})
}
六 数据绑定的实现
6.1 data到view视图的单向数据绑定
- 此时,已经有单向数据绑定的效果了。
- 当修改data时,执行
setter ,dep调用notify ,订阅者执行update 操作,调取回调函数,实现页面数据更新。
6.2 文本框的单向数据绑定
- 前面
replace() 中只判断了文本节点,这里需要再判断input 框的情况。 - 在
replace() 方法判断完文本节点后添加如下代码:
if (node.nodeType === 1 && node.tagName.toUpperCase() === 'INPUT') {
const attrs = Array.from(node.attributes)
const findResult = attrs.find((x) => x.name === 'v-model')
if(findResult) {
const expStr = findResult.value
const value = expStr.split('.').reduce((newObj, k) => newObj[k], vm)
node.value = value
new Wacther(vm, expStr, (newValue) => {
node.value = newValue
})
}
}
6.3 文本框的双向数据绑定
- 要实现修改页面的数据,更新data中的数据,那么要监听修改操作。
- 在上述代码创建Wacther的实例后面添加如下代码实现双向数据绑定。
node.addEventListener('input', (e) => {
const keyArr = expStr.split('.')
const obj = keyArr.slice(0, keyArr.length - 1).reduce((newObj, k) => newObj[k], vm)
obj[keyArr[keyArr.length - 1]] = e.target.value
})
七 数据绑定的总结
- 看完上述的代码,我们已经对vue中双向数据绑定的原理有所理解,下面进行总结。
- 前面有些不太懂的流程,看完下面的总结,应该可以搞清楚。
Vue.js 是采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()来劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应的监听回调。主要分为以下几个步骤:
- 需要observe的数据对象进行递归遍历,包括子属性对象的属性,都加上setter和getter这样的话,给这个对象的某个值赋值,就会触发setter,那么就能监听到了数据变化
- compile解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图
- Watcher订阅者是Observer和Compile之间通信的桥梁,主要做的事情是: ①在自身实例化时往属性订阅器(dep)里面添加自己 ②自身必须有一个update()方法 ③待属性变动dep.notice()通知时,能调用自身的update()方法,并触发Compile中绑定的回调,则功成身退。
- MVVM作为数据绑定的入口,整合Observer、Compile和Watcher三者,通过Observer来监听自己的model数据变化,通过Compile来解析编译模板指令,最终利用Watcher搭起Observer和Compile之间的通信桥梁,达到数据变化 -> 视图更新;视图交互变化(input) -> 数据model变更的双向绑定效果。
备注:使用 Object.defineProperty() 来进行数据劫持有什么缺点?
在对一些属性进行操作时,使用这种方法无法拦截,比如通过下标方式修改数组数据或者给对象新增属性,这都不能触发组件的重新渲染,因为 Object.defineProperty 不能拦截到这些操作。更精确的来说,对于数组而言,大部分操作都是拦截不到的,只是 Vue 内部通过重写函数的方式解决了这个问题。
在 Vue3.0 中已经不使用这种方式了,而是通过使用 Proxy 对对象进行代理,从而实现数据劫持。使用Proxy 的好处是它可以完美的监听到任何方式的数据改变,唯一的缺点是兼容性的问题,因为 Proxy 是 ES6 的语法。
|