Appearance
深入Vue的响应式原理
学习目标
- 【掌握】Vue2的响应式原理
- 【掌握】手写实现Vue2的响应式原理
- 【掌握】Vue3的响应式原理
- 【掌握】手写实现Vue3的响应式原理
之前的课程我们都是在学习Vue的基本使用,都能感受到Vue作为一个视图框架给我们开发带来的便捷,毫无疑问,响应式数据是Vue最强大的特性,那么响应式原理内部到底是如何实现的呢?Vue2和Vue3在实现响应式又有哪些区别呢?我们现在会尽量深入,引领大家能手写实现Vue的响应式原理,从Vue2开始,再来研究Vue3。
1.Vue2的响应式原理
在Vue2中,我们会把数据放到配置选项data中,一旦我们这样做了,我们心里清楚此时一旦视图中的操作修改了data中的数据,视图会立刻(这个立刻仍然需要等待一个异步更新队列 在nextTick中讨论过这个问题)自动更新。
似乎data的每个数据都被“劫持”了,一旦它被修改为了一个新值,Vue就能监测到这个变化通知视图更新。
在ES5的规范中,提供了一个API叫做Object.defineProperty()
,它可以为对象属性添加getter/setter,从而对对象的存值和取值行为进行拦截,而这个API即是Vue2响应式原理的实现核心,由于这个API是ES5中一个无法被Babel模拟的属性,所以Vue无法支持像IE8等低版本浏览器。
每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染,当然今天我们不会关注这个通知的过程,我们重点在于关注响应式原理的实现。
2.Vue2检测变化的注意事项
Vue2在检测数据和对象变化时,是存在一些不足的,之前我们提到过,这里再来回顾下,我们后面手写原理实现时需要考虑到。
2.1 检测对象
Vue2是无法检测到数据的添加或者删除的,所以我们在初始化数据的时候推荐把可能用到的响应式数据都设置到data中。
对于已经创建的实例,Vue 不允许动态添加或删除根级别的响应式数据。如果你希望响应式地添加或者删除,可以调用Vue.set
或者Vue.delete
来嵌套添加。
js
var vm = new Vue({
data:{
a:1
}
})
Vue.set(vm,'b',2) //这么做是错误地 因为'b' 被视作了根级别地数据
js
var vm = new Vue({
data:{
msg:{
a:1
}
}
})
vm.msg.b=2 //不具有响应性
Vue.set(vm.msg,'b',2) //这样做是符合Vue规范地
2.2 检测数组
Vue2中是无法检测数组的以下变化的:
- 利用索引直接设置一个数组项
- 修改数组的长度
js
var vm = new Vue({
el: '#app',
data() {
return {
list: [1, 2, 3, 4, 5],
}
},
})
vm.list[0] = 10 //直接通过索引值修改某一项无法实现响应性
vm.list.splice(0, 1, 10) //利用数组方法可以实现响应性
vm.list.length = 2
实现数组的响应性必须调用常见的改变数组的方法来实现,而这些方法在Vue2中是经过重新包装的,我们在手写原理实现的时候需要单独处理。
3.手写Vue2响应式原理
了解完毕后,我们来实现一个简易版本的响应式原理。明确一下,示例代码中的一些函数名称尽量和Vue源码中保持一致,但具体实现要简单很多。
js
import newArrayPrototype from './3.array.js' //引入重写数组的方法
//定义一个起始数据data data有可能是一个函数 也可能是一个对象 所以需要做一个判断
data = typeof data === 'function' ? data() : data
//然后定义一个方法 用于观测数据
observe(data)
function observe(data){
// 数据是基本数据类型 直接终止
if (typeof data !== 'object' || typeof data === null) return
//如果数据是引用值类型 我们就需要分为对象和数组2大类 来单独处理
if (Array.isArray(data)) {
observeArray(data) //进一步观测数组中的每一项
//这里重写部分数组方法 实现数组操作的响应性 这里的思路是在数组的原型链上多加一层 因为除开重写的方法 还有其他数组方法不能被丢失
data.observeArray=observeArray //给自身挂载一个观测数组的方法 在下面会用到
data.__proto__= newArrayPrototype //这个newArrayPrototype 可以放到单独文件中编写实现 然后引入
} else {
walk(data) //对象的处理方法
}
}
//先处理对象 实现walk方法
function walk(data){
//对象数据 需要对data中的每一个数据都实现响应式 所以需要遍历
Object.keys(data).forEach((key) => {
defineReactive(data, key, data[key]) //这里的defineReactive即是实现响应式原理的核心方法
})
}
function defineReactive(data, key, value) {
observe(value) //对象内部可能存在多层嵌套 所以要递归观测
Object.defineProperty(data, key,{ //这里就调用核心API来实现数据劫持
get() {
return value
},
set(newValue) {
if (newValue === value) return
observe(newValue) //修改后的数据也要观测 变成响应式的
value = newValue
console.log('视图更新成功') //这里不关注是如何通知视图的 用一句输出语句代替
},
})
}
//再来处理数组
function observeArray(data){ //数组中的具体每一项仍旧需要观测
data.forEach((item) => {
observe(item)
})
}
js
const oldArrayPrototype = Array.prototype //数组本身的方法需要拷贝一份 因为有些方法没有重写 需要保留下来
const newArrayPrototype = Object.create(oldArrayPrototype) //在data和Array中间加了一层原型链
let arrayLists = [ //这是7个改变数组的方法 需要重写 其他数组方法不用重写
'push',
'pop',
'shift',
'unshift',
'reverse',
'sort',
'splice',
]
arrayLists.forEach((item) => {
newArrayPrototype[item] = function (...arg) {
const result = oldArrayPrototype[item].call(this, ...arg) //实现数组方法本身的功能
let inserted //有3个方法 push unshift splice 会加入新数组 这些数组也需要被观测
switch (item) {
case 'push':
case 'unshift':
inserted = arg
break
case 'splice':
inserted = arg.slice(2)
default:
break
}
if(inserted){
this.observeArray(inserted) //这里的this 指向data
}
console.log('视图更新成功') //通知视图更新
return result
}
})
export default newArrayPrototype
再来总结一下刚才实现的几个核心方法:
- observe 用来观测数据 判断数据的类型 对象和数组需要做不同的处理
- observeArray 进一步观测数组中的每一项数据
- newArrayPrototype 重写数组方法 在数组原型链上多加的一层原型对象
- walk 遍历对象的每一个属性 添加响应式
- defineReactive 响应式的具体实现 通过getter/setter 实现数据拦截
写完之后,我们可以来到浏览器中验证一下:
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>
<script>
let data = {
name: '张三',
friends: ['李四', '王五'],
info: { age: 18, tall: 180 },
}
</script>
<script src="./3.手写Vue2响应式原理.js" type="module"></script>
</body>
</html>
分别尝试修改源数据data中的name,friends和info属性,可以在控制台都能看到试图更新成功的提示,说明响应式已经完美实现了。
这些方法在源码中都可以找到更具体全面的实现,想必到这里对于Vue2中如何具体实现响应式数据应该有了更深刻的体会。
4.Vue3的响应式原理
在Vue3中,响应式原理被重新实现了!换言之,Vue3不再是利用Object.defineProperty
这个核心API来实现响应式了,它利用了ES6的一个新的内置类Proxy来实现,而且解决了Vue2中检测数据变化的一些问题(不能添加删除对象属性 不能通过索引操作数组)
在这里我们不会探讨Proxy本身的内容,在日常业务开发中我们几乎不会直接操作Proxy。大家只需要明白Proxy 是一个对象,它包装了另一个对象,并允许你拦截对该对象的任何交互。
基于这一点,我们同样可以实现数据的响应式,而且更加完美。通过具体的代码实现,我们对于Proxy本身也会有一定的了解。
5.手写Vue3的响应式原理
刚刚Vue2的实现其实已经非常接近真实源码的实现,Vue3中我们更多需要体会一下Proxy对于数据是如何“劫持”的。
正式书写代码之前,我们仍需要验证一件事,就是Vue2中检测数据变化的问题(不能添加删除对象属性 不能通过索引操作数组)是不是真的被完全解决了。
html
<!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="app">
<p>{{msg.a}}--{{msg.b}}</p>
<p v-for="item in list">{{item}}</p>
</div>
<script src="vue3.js"></script>
<script>
var app = Vue.createApp({
data() {
return {
list: [1,2,3],
msg: {
a: 1,
},
}
},
}).mount('#app')
</script>
</body>
</html>
通过验证,Vue3中的响应式确实更加强大,现在利用Proxy来尝试实现它,Vue3中提供了reactive方法内部即是利用Proxy来实现的,可能有的同学会疑惑那不是还有一个ref吗,ref处理基础数据类型的时候仍然是沿用Vue2的那一套方法,而且基础数据也不存在操作对象和数组的问题。
声明一点,Vue3的代码实现更多是希望能理解到和Vue2的区别变化,没有像Vue2实现那样考虑到很多的细节,这一点希望大家能够注意。
js
function reactive(data) { //定义自己的reactive
const proxy = new Proxy(data, { //利用ES6新增的Proxy类 来创造一个代理对象
get(_data, propName) { //对代理对象任何属性的访问都会走 get
console.log(`你访问了${propName}属性`)
return _data[propName]
},
set(_data, propName, newValue) { //对代理对象任何属性的修改和增加都会走 set
if (newValue !== _data[propName]) {
_data[propName] = newValue
console.log('视图更新成功')
}
},
deleteProperty(_data, propName) { //对代理对象任何属性的删除都会走deleteProperty
delete _data[propName]
console.log('视图更新成功')
},
})
return proxy
}
然后尝试用自己实现的reactive来获得一个数据,通过修改这个数据我们在控制台看到响应式也完美实现了。
js
let data = {
name: '张三',
age: 18,
tall: 20,
}
let proxyData = reactive(data)
对比一下Proxy和Object.defineProperty,我们能总结一下其中的优势:
- 代码本身的实现要简洁很多 Vue2中定义了大量函数方法反复调用 Vue3几乎都没有
- Vue2中需要对data中每个数据进行递归遍历添加getter/setter,Vue3通过Proxy能一次性实现代理。
- Vue2中不能直接增删对象数据,不能通过索引值操作数组,Vue3中也不存在了这些问题。
- ....
上面我们的代码实现没有考虑到深度代理,如果大家有兴趣,可以自行研读一下下面的代码,进一步实现了深度代理。
js
// 创建一个深度代理函数
function deepProxy(object, handler) {
if (isComplexObject(object)) {
addProxy(object, handler)
}
return new Proxy(object, handler)
}
// 新增代理函数实现
function addProxy(obj, handler) {
for (let i in obj) {
if (typeof obj[i] === 'object') {
if (isComplexObject(obj[i])) {
addProxy(obj[i], handler)
}
obj[i] = new Proxy(obj[i], handler)
}
}
}
// 判断是不是是一个对象
function isComplexObject(object) {
if (typeof object !== 'object') {
return false
} else {
for (let prop in object) {
if (typeof object[prop] == 'object') {
return true
}
}
}
return false
}
function reactive(data) {
const proxy = deepProxy(data, {
get(_data, propName) {
console.log(`你访问了${propName}属性`)
return _data[propName]
},
set(_data, propName, newValue) {
if (newValue !== _data[propName]) {
_data[propName] = newValue
console.log('视图更新成功')
}
},
deleteProperty(_data, propName) {
delete _data[propName]
console.log('视图更新成功')
},
})
return proxy
}