熟悉vue
的小伙伴应该都知道,谈到vue
的原理,最重要的莫过于:响应式,虚拟dom
及diff
算法,模版编译,今天,我们一起来深入vue
的响应式,探讨vue2.x
响应式的实现原理与不足,以及vue3.0
版本如何重写响应式实现方案。
1. 什么是响应式
vue
是一个MVVM
框架,所谓MVVM
,最核心的就是数据驱动视图,通俗一点讲就是,用户不直接操作dom
,而是通过操作数据,当数据改变时,vue
内部监听数据变化然后更新视图。同样,用户在视图上的操作(事件)也会反过来改变数据。而响应式,则是实现数据驱动视图的第一步,即监听数据的变化,使得用户在设置数据时,可以通知vue
内部进行视图更新
比如
<template>
<div>
<div> {{ name }} </div>
<button @click="changeName">改名字</button>
</div>
</template>
<script>
export default {
data () {
return {
name: 'A'
}
},
methods: {
changeName () {
this.name = 'B'
}
}
}
</script>
上面代码,点击button
按钮后,name
属性会改变,同时页面显示的A
会变成B
2. vue2.x
实现响应式
2.1 核心API --- Object.defineProperty()
我想绝大多数人有了解过vue,都应该或多或少的知道一些,vue响应式的核心就是Object.defineProperty()
, 这里简单做一个回顾
const data = {}
let name = 'A'
Object.defineProperty(data, 'name', {
get () {
return name
},
set (val) {
name = val
}
})
console.log(data.name) // get()
data.name = 'B' // set()
上面代码中我们可以看到,Object.defineProperty()的用法就是给一个对象定义一个属性(方法),并提供set和get两个内部实现,让我们可以获取或者设置这个属性(方法)
2.2 如何实现响应式
首先,我们定义一个初始数据如下
const data = {
name: 'A',
age: 18,
isStudent: true,
gender: 'male',
girlFriend: {
name: 'B',
age: '19',
isStudent: true,
gender: 'female',
parents: {
mother: {
name: 'C',
age: '44',
isStudent: false,
gender: 'female'
},
father: {
name: 'D',
age: '46',
isStudent: false,
gender: 'male'
}
}
},
hobbies: ['basketball', 'one-piece', 'football', 'hiking']
}
我们同样定义一个渲染视图的方法
function renderView () {
// 数据变化时,渲染视图
}
以及一个实现响应式的核心方法,这个方法接收三个参数,target
就是数据对象本身,key
和value
是对象的key
以及对应的value
function bindReactive (target, key, value) {
}
最后我们定义实现响应式的入口方法
function reactive () {
// ...
}
我们最终调用就是
const reactiveData = reactive(data)
2.2.1 对于原始类型和对象
上面的数据,我们模拟了一个人的简单信息介绍,可以看到对象的字断值有字符串,数字,布尔值,对象,数组。对于字符串,数字,布尔值这样的原始类型,我们直接返回就好了
function reactive (target) {
// 首先,不是对象直接返回
if (typeof target !== 'object' || target === null) {
return target
}
}
const reactiveData = reactive(data)
如果字段值是对象这样的引用类型,我们就需要对对象进行遍历,分别设置对对象的每一个key值做Object.defineProperty()
,注意,这个过程是需要递归调用的,因为如我们给出的数据所示,对象可能是多层嵌套的。我们定义一个函数bindReactive
来描述响应式监听对象的过程
function bindReactive (target, key, value) {
Object.defineProperty(target, key, {
get () {
return value
},
set (val) {
value = val
// 触发视图更新
renderView()
}
})
}
function reactive (target) {
// 首先,不是对象直接返回
if (typeof target !== 'object' || target === null) {
return target
}
// 遍历对象,对每个key进行响应式监听
for (let key in target) {
bindReactive(target, key, target[key])
}
}
const reactiveData = reactive(data)
考虑到递归,我们需要在执行核心方法bindReactive
开始时,递归的调用reactive
为对象属性进行响应式监听,同时设置(更新)数据时候也要递归的调用reactive
更新,于是我们的核心方法bindReactive
变为
function bindReactive (target, key, value) {
reactive(value)
Object.defineProperty(target, key, {
get () {
return value
},
set (val) {
reactive(val)
value = val
// 触发视图更新
renderView()
}
})
}
function reactive (target) {
// 首先,不是对象直接返回
if (typeof target !== 'object' || target === null) {
return target
}
// 遍历对象,对每个key进行响应式监听
for (let key in target) {
bindReactive(target, key, target[key])
}
}
const reactiveData = reactive(data)
上面的代码可以做一步优化,就是set的时候,如果新设置的值和之前的值相同,不触发视图更新,于是我们的方法变为
function bindReactive (target, key, value) {
reactive(value)
Object.defineProperty(target, key, {
get () {
return value
},
set (newVal) {
if (newVal !== value) {
reactive(newVal)
value = newVal
// 触发视图更新
renderView()
}
}
})
}
function reactive (target) {
// 首先,不是对象直接返回
if (typeof target !== 'object' || target === null) {
return target
}
// 遍历对象,对每个key进行响应式监听
for (let key in target) {
bindReactive(target, key, target[key])
}
}
const reactiveData = reactive(data)
目前,我们以及实现了对于原始类型和对象的响应式监听,当数据变化时,会在数据更新后,调用renderView方法(这个方法可以做任何事情)进行视图更新。
2.2.2 对于数组
很明显,虽然Object.defineProperty()
很好的完成了对于原始类型和普通对象的响应式监听,但是这个方法对数组是无能为力的。那么,vue是如何实现数组的响应式监听的呢?
我们首先再次回到vue的官方文档
可以看到,vue在执行数组的push, pop, shift, unshift
等方法的时候,是可以响应式监听到数组的变化,从而触发更新视图的。
但是我们都知道,数组原生的这些方法,是不具有响应式更新视图能力的,所以,我们可以知道,vue
一定是改写了数组的这些方法,于是,现在问题就从数组如何实现响应式变成了,如何改写数组的api。
这里要用到的核心方法就是Object.create(prototype)
,这个方法就是创建一个对象,他的原型指向参数prototype
,于是,我们也可以实现对这些数组方法的改写了:
// 数组的原型
const prototype = Array.prototype
// 创建一个新的原型对象,他的原型是数组的原型(于是newPrototype上具有所有数组的api)
const newPrototype = Object.create(prototype)
const methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
methods.forEach(method => {
newPrototype[method] = () => {
prototype[method].call(this, ...args)
// 视图更新
renderView()
}
})
实现了数组的响应式,我们完善入口方法reactive
function bindReactive (target, key, value) {
reactive(value)
Object.defineProperty(target, key, {
get () {
return value
},
set (newVal) {
if (newVal !== value) {
reactive(newVal)
value = newVal
// 触发视图更新
renderView()
}
}
})
}
function reactive (target) {
// 首先,不是对象直接返回
if (typeof target !== 'object' || target === null) {
return target
}
// 对于数组,原型修改
if (Array.isArray(target)) {
target.__proto__ = newPrototype
}
// 遍历对象,对每个key进行响应式监听
for (let key in target) {
bindReactive(target, key, target[key])
}
}
const reactiveData = reactive(data)
到目前为止,我们已经讲述清楚了vue2.x版本的响应式原理
2.3 vue2.x版本响应式实现方案的弊端
通过我们的分析,也就看到了vue2.x版本响应式实现的弊端:
Object.defineProperty()
这个api无法原生的对数组进行响应式监听- 实现过程中对于深度嵌套的数据,递归消耗大量性能
- 我们注意到,
Object.defineProperty()
这种实现,以及数组的实现,都存在一个问题,那就是没办法监听到后续的手动新增删除属性元素,比如数组,直接通过索引去设置和改变值是不会触发视图更新的,当然vue为我们提供了vue.set
和vue.delete
这样的api
,但终究是不方便的
3. vue3.0
实现响应式
前不久vue3.0
也正式发布了,虽然还没有正式的推广,不过里面的一些变化是值得我们去关注和学习的
3.1 Proxy
和Reflect
因为vue2.x版本响应式的实现存在的那些问题,vue
官方在3.0版本中完全重写了响应式的实现,改用Proxy
和Reflect
代替Object.defineProperty()
。
3.1.1 Proxy
首先来看MDN对Proxy的定义:
The Proxy object is used to define custom behavior for fundamental operations(e.g. property lookup, assignment, enumeration, function invocation, etc).
翻译为中文大概就是:Proxy对象用来给一些基本操作定义自定义行为(比如查找,赋值,枚举,函数调用等等) 基本用法:
let proxy = new Proxy(target, handler)
上面的参数意义:(注意target
可以是原生数组)
target
: 用Proxy
包装的目标对象(可以是任何类型的对象,包括原生数组
,函数,甚至另一个代理)。handler
: 一个对象,其属性是当执行一个操作时定义代理的行为的函数。
举个栗子:
let handler = {
get: function(target, name){
return name in target ? target[name] : 'sorry, not found';
}
};
let p = new Proxy({}, handler);
p.a = 1;
p.b = undefined;
console.log(p.a, p.b); // 1, undefined
console.log('c' in p, p.c); // false, 'sorry, not found'
3.1.2 Reflect
首先来看MDN对Reflect的定义:
Reflect is a built-in object that provides methods for interceptable JavaScript operations. The methods are the same as those of proxy handlers. Reflect is not a function object, so it's not constructible.
大概意思就是说:Reflect 是一个内置的对象,提供拦截 JavaScript 操作的方法。这些方法与proxy的 handlers相同。Reflect不是一个函数对象,因此它是不可构造的。
Refelct对象提供很多方法,这里只介绍实现响应式会用到的几个常用方法:
Reflect.get()
: 获取对象身上某个属性的值,类似于target[name]
。Reflect.set()
: 将值分配给属性的函数。返回一个Boolean
,如果更新成功,则返回true
。Reflect.has()
: 判断一个对象是否存在某个属性,和in
运算符 的功能完全相同。Reflect.deleteProperty()
: 作为函数的delete操作符,相当于执行 delete target[name]。
于是,我们可以联合Proxy
和Reflect
完成响应式监听
3.2 Proxy
和Reflect
实现响应式
下面直接贴出代码,对之前我们实现的方法进行改造:
function bindReactive (target) {
if (typeof target !== 'object' || target == null) {
// 不是对象或数组,则直接返回
return target
}
// 因为Proxy原生支持数组,所以这里不需要自己实现
// if (Array.isArray(target)) {
// target.__proto__ = newPrototype
// }
// 传给Proxy的handler
const handler = {
get(target, key) {
const reflect = Reflect.get(target, key)
// 当我们获取对象属性时,Proxy只会递归到获取的层级,不会继续递归子层级
return bindReactive(reflect)
},
set(target, key, val) {
// 重复的数据,不处理
if (val === target[key]) {
return true
}
// 这里可以根具是否是已有的key,做不同的操作
if (Reflect.has(key)) {
} else {
}
const success = Reflect.set(target, key, val)
// 设置成功与否
return success
},
deleteProperty(target, key) {
const success = Reflect.deleteProperty(target, key)
// 删除成功与否
return success
}
}
// 生成proxy对象
const proxy = new Proxy(target, handler)
return proxy
}
// 实现数据响应式监听
const reactiveData = bindReactive(data)
上述代码我们可以看到,对于vue2.x
响应式存在的问题,都得到了很好的解决:
Proxy
支持监听原生数组Proxy
的获取数据,只会递归到需要获取的层级,不会继续递归Proxy
可以监听数据的手动新增和删除
那是不是vue3.0
的响应式方案就是完美的呢,答案是否定的,主要原因在于Proxy
和Reflect
的浏览器兼容问题,且无法被polyfill
。
4. 总结
本文详细深入的剖析了vue
响应式原理,对于2.x
和3.0
版本的实现差异,各有利弊,没有什么方案是完美的,相信未来,当浏览器兼容问题越来越少的时候,生活会更美好!