Vue3源码阅读——响应式是如何实现的(reavtive篇)
前言
本文属于笔者Vue3源码阅读系列第三篇文章,往期精彩:
响应式源码预计产出两篇文章,本文主要对应reactive
部分。主要内容:创建响应式代理对象以及代理对象handler
的实现。
本文字数12000+,阅读完预计10分钟。
什么是响应式
在探索源码之前,咱们先来聊一下响应式的概念。在Vue官方文档中写道:
响应性是一种可以使我们声明式地处理变化的编程范式。
定义不好理解?那官网还举了一个很经典的例子:
A2
的值通过公式来设置的,不论A0、A1
任意一个变化,A2
都会自动的重新计算。这就是响应式——自动更新,状态自动保持同步
先回顾一下Vue2的响应式实现
上图来自官方文档,从图中我们能够看到:初始化时对状态数据做了劫持,在执行组件的render
函数时,会访问一些状态数据,就会触发这些状态数据的getter
,然后render
函数对应的render watcher
就会被这个状态收集为依赖,当状态变更触发setter
,setter
中通知render watcher
更新,然后render
函数重新执行以更新组件。 就这样完成了响应式的过程。
Vue2中通过Object.defineProperty
给每个属性设置getter
、setter
,他的特点如下:
Object.defineProperty
是通过给对象新增属性/修改现有属性 来实现数据的劫持。需要遍历对象的每一个key
去实现,当遇到很大的对象或者嵌套层级很深的对象,性能问题会很明显。Object.defineProperty
这种方式无法拦截到给对象新增属性这种操作,因为组件初始化不能预知会新增哪些属性,也就没法设置getter/setter
,所以我们不得不使用Vue2
提供的$set
api,再去Object.defineProperty
给新增的属性加上getter/setter
。Object.defineProperty
支持IE
,兼容性较好。
正是因为第三点,因此Vue2
才使用Object.defineProperty
去实现数据的劫持,即便它有很多缺点。
深入Vue3 Reactivity
Vue3
使用Proxy
来实现数据的劫持,接下来我们进入源码(packages/reactivity/src/reactive.ts
):
在看响应式的实现之前,先来了解一下源码中的枚举变量:
export const enum ReactiveFlags {
SKIP = '__v_skip', // 跳过响应式处理
IS_REACTIVE = '__v_isReactive', // 是响应式的
IS_READONLY = '__v_isReadonly', // 是只读的
IS_SHALLOW = '__v_isShallow', // 是浅响应式的
RAW = '__v_raw' // 用来存代理的原始对象
}
// ...
const enum TargetType {
INVALID = 0, // 不能代理的类型
COMMON = 1, // Object、Array
COLLECTION = 2 // 集合类型 Map、Set、WeakMap、WeakSet
}
// target 和 type 的映射
function targetTypeMap(rawType: string) {
switch (rawType) {
case 'Object':
case 'Array':
return TargetType.COMMON
case 'Map':
case 'Set':
case 'WeakMap':
case 'WeakSet':
return TargetType.COLLECTION
default:
return TargetType.INVALID
}
}
// 根据target 返回 targetType
function getTargetType(value: Target) {
return value[ReactiveFlags.SKIP] || !Object.isExtensible(value)
? TargetType.INVALID
: targetTypeMap(toRawType(value))
}
ReactiveFlags
是响应式的标识,在对一个target
调用对应API
后,就会在这个target
对应的代理对象上打对应的标识,有意思的是RAW
——它是用来存原始对象的引用的,然后可通过toRaw(proxy)
来获取它。上面的代码很好理解,可以先看下,然后去看后面的代码更轻松。
reactive
接着看reactive
的源码:
reactive
的逻辑:先判断传入的对象是不是只读的,是就直接返回,否则调用createReactiveObject
。
createReactiveObject
createReactiveObject
中主要是一些校验,最关键的就是最后的new Proxy(...)
,来看下具体做了哪些校验:
isObject
校验,val !== null && typeof val === 'object'
。- 已经是
proxy
了,不能再响应式处理,但是有一种情况例外。const obj = reactive({}) const obj2 = reactive(obj) // 会直接返回obj obj === obj2 // true toReadonly(obj) // 这样是OK的
- 对一个原始对象多次响应式处理。
const obj = {} const objProxy = reactive(obj) const objProxy2 = reactive(obj) // 直接返回objProxy objProxy === objProxy2 // true
- 不能被响应式处理的情况。
接下来我们看mutableHandlers
的实现。
BaseHandlers
mutableHandlers
的实现在 packages/reactivity/src/baseHandlers.ts
中,在 basehandlers
中包含了四种 handler
:
mutableHandlers
可变处理。readonlyHandlers
只读处理。shallowReactiveHandlers
浅观察处理(只观察目标对象的第一层属性)。shallowReadonlyHandlers
浅观察 && 只读处理。
其中 readonlyHandlers
shallowReactiveHandlers
shallowReadonlyHandlers
都是 mutableHandlers
的变形版本,这里笔者将以 mutableHandlers
这个可变的来展开描述。
mutableHandlers
mutableHandlers
的定义:
咱们先看比较简单的几个:
deleteProperty
用于拦截从target
删除某个属性的操作(delete target.xxx
),会先检查target
中是否包含这个key
,然后获取这个属性的值,接着调用Reflect.deleteProperty
删除key
,如果删除成功的话,调用trigger
触发更新。暂时先记下trigger
的作用是为了触发更新,后续咱们再来分析它。
has
用于拦截判断某个key
是否存在于target
的操作(xxx in target
),先调用Reflect.has
拿到结果,然后判断如果这个key
不是Symbol
类型,则调用track
收集依赖;如果是Symbol
类型,那就判断这个key
在不在builtInSymbols
中,不在也调用track
收集依赖。暂时先记下track
的作用是为了收集依赖,后续咱们再来分析它。
builtInSymbols
:
ownKeys
用于拦截遍历的操作(for in、for of...
),先调用track
收集依赖,然后调用Reflect.ownKeys
返回结果。
get
const get = /*#__PURE__*/ createGetter()
createGetter
接受两个参数,可创建readonly
的get
方法,还能创建shallow
的get
方法:
createGetter
的逻辑一个屏截不完,咱们先看上图的逻辑:在get
中处理了这四个标识应该返回什么值,当调用proxy[ReactiveFlags.IS_xxx]
就会执行到这个地方对应的逻辑,咱们接着看后面的逻辑:
上图中逻辑:判断target
是不是Array
,如果是的话,判断key
是不是['push', 'pop', 'shift', 'unshift', 'splice','includes', 'indexOf', 'lastIndexOf']
中的一个,如果是的话就返回arrayInstrumentations
中对应重写过后的方法。
从上图中可以看到,分别对'includes', 'indexOf', 'lastIndexOf'
和 'push', 'pop', 'shift', 'unshift', 'splice'
两类的API
进行了重写。那么问题就来了:为什么要重写?
为什么要重写includes, indexOf, lastIndexOf
咱们先来看,重写干了些什么:
- 调用
toRaw
得到原始数组。 - 然后遍历原始数组,
track
每一项,key
就是索引。 - 调用数组的
includes/indexOf/lastIndexOf
得到结果res1
。 - 如果结果为
-1
或者false
,则将参数也调用toRaw
得到原始对象后再次调用数组的includes/indexOf/lastIndexOf
并返回结果res2
;否则直接返回res1
。
这里可以看到主要做了两件事:遍历原始数组调用track
& 如果直接用参数找不到,就调用toRaw
得到原始对象后再找一次,那我们还是分开来看这么做的原因。
为什么(遍历原始数组调用track
)
看个例子:
<script type="module">
import {
h,
ref,
createApp,
reactive,
effect
} from '../../dist/vue.runtime.esm-bundler.js'
const App = {
name: 'App',
setup() {
const arr = ['a', 'b', 'c', 'd']
const proxy = reactive(arr)
return {
proxy
}
},
render() {
return h('div', { tId: 1 }, [
h('p', {}, this.proxy.indexOf('d', 2)),
h(
'button',
{
onClick: () => {
this.proxy[0] = 'd'
}
},
'click'
)
])
}
}
createApp(App).mount(document.querySelector('#root'))
</script>
在上面的例子中,页面一开始会显示文本3
和一个click
按钮,当点击click
按钮,我们期望的是文本更新为0
,因为我把数组的第0
项改为d
了,但是在没有重写indexOf
的逻辑下,页面并不会更新。我们来找下原因,当执行render
中this.proxy.indexOf('d', 2)
的时候,会执行到get(arr, length) -> get(arr, 2) -> get(arr, 3)
,就找到d
的索引为3
了,在这个过程中,就只对arr[2]
和arr[3]
进行了track
,所以也只有修改arr[2]
和arr[3]
才会触发组件更新,因此我们修改arr[0]
并不会触发更新。说起原因还是由于这三个APIincludes/indexOf/lastIndexOf
的第二个参数,可以指定起始索引,所以就会漏掉一些数组的项没有track
,所以重写的时候才需要遍历track
。
为什么(如果直接用参数找不到,就调用toRaw得到原始对象后再找一次)
为了帮助理解再看一个例子:
<div id="root"></div>
<script type="module">
import {
h,
ref,
createApp,
reactive,
effect
} from '../../dist/vue.runtime.esm-bundler.js'
const App = {
name: 'App',
setup() {
const obj = {
text: '主页'
}
const proxy = reactive([obj])
return {
proxy,
obj
}
},
render() {
return h('div', { tId: 1 }, [
h('p', {}, this.proxy.indexOf(this.obj)),
h('p', {}, this.proxy.indexOf(this.proxy[0]))
])
}
}
createApp(App).mount(document.querySelector('#root'))
</script>
笔者在本地将重写的逻辑注释以后,上面的内容渲染结果如下:
为啥会这样?当调用this.proxy.indexOf(this.obj)
,会get(arr, 0)
,因为arr[0]
是一个Object
,那么调用reactive(arr[0])
,最终return
了arr[0]
的代理对象,代理对象肯定!==arr[0] 啊,因此返回-1
;当调用this.proxy.indexOf(this.proxy[0])
,会先this.proxy[0]
,即get(arr, 0)
,跟之前的一样return
了arr[0]
的代理对象,当执行indexOf
时,又会get(arr, 0)
,还是走到reactive(arr[0])
,只不过这个对象的代理已经创建过一次了,存在一个weakMap
中,就直接return
了weakMap
中对应的代理对象,所以二者相等,返回0
。
这也就是为什么要把参数调用toRaw
得到原始对象后再找一次,就是为了防止原始对象和其代理对象比较这种情况。
为什么要重写push, pop, shift, unshift, splice
同样的,先来看重写干了啥:
- 暂停
track
。 - 调用数组API得到结果。
- 恢复
track
。 - 返回结果。
为了理解到重写的背景,我们先看个例子:
我们能够看到,调用了一次push
,会触发一次length
的get
,一次length
的set
,而且刚好被重写的这些API都是会改变数组length
的API。这样的话,假如我们没有对这些API重写,在effect
中使用这些API会怎么样:
const arr = ['a', 'b', 'c', 'd']
const obj2 = reactive(arr)
effect(() => {
obj2.push('r')
})
effect(() => {
obj2.push('i')
})
/**
第1个effect:
收集key='length',触发track(target, ..., 'length')操作
相当于proxy[4]=r,触发key='4' 以及 key='length' 的 trigger 操作
第2个effect:
收集key='length',触发track(target, 'length')操作
相当于proxy[5]=i,触发key='5'以及key='length'的trigger操作
由于第1个effect收集了key='length',因此会触发第1个effect重新执行,再次收集key='length'和触发key='6'以及key='length'的trigger操作;
由于第2个effect收集了key='length',因此会触发第2个effect重新执行,再次收集key='length'和触发key='7'以及key='length'的trigger操作;
由于第1个effect收集了key='length',因此会触发第1个effect重新执行,再次收集key='length'和触发key='8'以及key='length'的trigger操作;
引起了死循环......
*/
根据上面的例子,不应该在调用push, pop, shift, unshift, splice
这些API
的时候进行track
,因此重写就是为了暂停调用这些API
过程中触发的track(target, ..., length)
,防止在某些情况下死循环。
到此对数组的重写原因就讲清楚了。接下来接着把get
的逻辑看完。
- 调用
Reflect.get(target, key, receiver)
获取本次get
的结果res
。 - 如果
key
是Symbol
类型并且这个key
包含在builtInSymbols
中,直接返回res
。(builtInSymbols
在has
部分已经说过。) - 如果
key
不是Symbol
型,但是这个key
在isNonTrackableKeys
中,不需要对这个key
进行track
,直接返回res
。const isNonTrackableKeys = /*#__PURE__*/ makeMap('__proto__,__v_isRef,__isVue')
- 接着,如果不是只读的,进行
track
,收集依赖。 track
完了以后,如果是浅响应式,不管target[key]
是不是对象,就直接返回了。- 如果
res
是Ref
并且target
是Array
并且key
是整数,返回res
,否则返回res.value
。 - 如果
res
是对象,那么就接着进行响应式处理,并返回代理对象,根据isReadonly
的值调用readonly/reactive
。可以看到嵌套对象的响应式是在get
才会响应式处理,懒响应式,相比Vue2
的递归getter/setter
好多了。 - 最后返回
res
,在没有命中以上的if
判断会执行到这里。
到此get
的逻辑就完了,接下来看set
。
set
- 先获取到当前值
oldValue
- 如果
oldValue
是只读的Ref
,但是value
(即将设置的值)不是Ref
,直接return false
。 - 如果
shallow
为false
- 如果
value
(即将设置的值)不是只读、浅响应的,把oldValue
和value
都toRaw
。 - 如果
target
不是Array
,并且oldValue
是Ref
,value
不是Ref
,那就直接设置oldValue.value
并返回。
- 判断要设置的
key
存不存在,数组的话判断key
是不是小于数组length
的整数,对象就调用hasOwn
。 - 调用
Reflect.set(target, key, value, receiver)
设置value
。 - 如果
target
是原型链上的东西,不触发更新。 - 如果
hadKey
为false
,代表是ADD
操作,需要触发更新。 - 如果
oldValue
和value
相等,不触发更新。
- 什么时候会有新旧值相等的情况?例如监听了一个数组,执行了
push
操作,会触发多次setter
;第一次setter
是新加的值,会触发更新;第二次是由于新加的值导致length
改变;此时value === oldValue
,不再重复触发。
总结
到此,响应式reactive
篇的内容已全部完成,最后来概括一下大致内容:
- 响应式的概念
Vue2
如何实现响应式reactive
函数源码createReactiveObject
的实现baseHandlers
中四种Handler
介绍mutableHandlers
的实现(get、set、has、deletedeleteProperty、ownKeys
)- 为什么要重写数组的
includes, indexOf, lastIndexOf
API - 为什么要重写
push, pop, shift, unshift, splice
API
本文涉及到的track
、trigger
,对应收集依赖过程以及触发更新的过程,将在下一篇详细展开。
这是笔者第三篇源码分析类的文章,如果掘友们有什么建议,或者文中有错误,还请评论指出,谢谢!
如果本文对你有一点点帮助,点个赞支持一下吧,你的每一个【赞
】都是我创作的最大动力 ^_^
。
转载自:https://juejin.cn/post/7245145631181307962