背景
背景 vu3出来有一段时间了,最近刚好有空可以去学习下,直接看源码的话有点难懂,索性就对着文档和源码自己慢慢去实现一把,所以在这背景上,决定从0开始对着api,造一个最精简的依赖收集和响应
依赖收集和响应
实现响应式有2个最关键的步骤:依赖收集和响应,其中Vue是通过数据劫持的方式,vue2因为兼容性原因采用Object.defineProperty,也带来一些问题
- 性能差,初始化开始就必须递归将所有数据进行劫持
- 只能对对象属性进行劫持,添加、删除属性需要用set函数进行操作 vue3正式采用proxy方式进行劫持
- 默认只劫持顶层对象,只有访问过的子对象才会进行进一步劫持
- proxy可以针对属性的增、删进行劫持
reactive
vue3中使用 reactive生成响应式对象,实际上就是返回Proxy代理对象,可以尝试自己写一个
const obj = {
bar: {
title: 'hello'
},
foo: 1
}
const myReactive = (obj) => {
return new Proxy(obj, {
get(target, key, receiver) {
console.log('get!!!!')
const res = Reflect.get(target, key, receiver)
return res
},
set(target, key, value, receiver) {
console.log('set!!!')
target[key] = value
return true
}
})
}
const myReactiveObj = myReactive(obj)
console.log(myReactiveObj.bar) //get!!! {title: 'hello'}
myReactiveObj.bar = { // set!!!
title: 'world'
}
myReactiveObj.bar.title = 'world1' //get!!!
这样一来无论是get/set都能触发相应的拦截函数
嵌套对象如何触发set
上面的例子,执行 myReactiveObj.bar.title = 'world1'时,只触发了get,原因在于只有顶层对象和属性被代理,子对象没有被代理
解决方式也简单,只要在get拦截上再次代理子对象即可
const isObject = (val)=> val !== null && typeof val === 'object'
const obj = {
bar: {
title: 'hello'
},
foo: 1
}
const myReactive = (obj) => {
return new Proxy(obj, {
get(target, key, receiver) {
console.log('get!!!!')
const res = Reflect.get(target, key, receiver)
return isObject(res) ? myReactive(res) : res
},
set(target, key, value, receiver) {
console.log('set!!!')
target[key] = value
return true
}
})
}
const myReactiveObj = myReactive(obj)
console.log(myReactiveObj.bar) //get!!! {title: 'hello'}
myReactiveObj.bar = { // set!!!
title: 'world'
}
myReactiveObj.bar.title = 'world1' //get!!! set!!!
effect
定义一个副作用函数,具体的描述可以看下官方文档,主要是将副作用函数以及响应式对象关联起来,执行副作用函数收集依赖,修改响应式对象再执行副作用函数
const isObject = (val)=> val !== null && typeof val === 'object'
const obj = {
bar: {
title: 'hello'
},
foo: 1
}
const myReactive = (obj) => {
return new Proxy(obj, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
track(target, key) //收集依赖
return isObject(res) ? myReactive(res) : res
},
set(target, key, value, receiver) {
target[key] = value
trigger(target, key) //触发副作用函数
return true
}
})
}
//全局记录收集的依赖对象
const targetMap = new WeakMap()
//收集依赖
const track = (target, key) => {
const dep = targetMap.get(target)
if(!dep) {
targetMap.set(target, [key])
}
}
//响应
const trigger = (target) => {
const dep = targetMap.get(target)
if(dep && activeEffect) {
activeEffect()
}
}
//记录当前执行的副作用函数
let activeEffect = null
const myEffect = (fn) => {
activeEffect = fn
return fn()
}
const myReactiveObj = myReactive(obj)
myEffect(() => {
console.log('副作用函数执行')
console.log(myReactiveObj.bar)
})
myReactiveObj.bar = {
title: 'world'
}
myReactiveObj.bar = {
title: 'world1'
}
实现方式非常简单
- 执行
myEffect传入的函数,触发依赖收集 - 在get拦截函数上触发
track函数,targetMap保存依赖,key就是当前执行的target,value暂不传入 - 修改值时触发set函数,执行2收集的依赖函数(effect)
这样就简单的实现了一个依赖收集和响应的系统,但是存在一些问题
无意义的响应
比如上面的示例改成
myEffect(() => {
console.log('副作用函数执行')
console.log(myReactiveObj.bar)
})
myReactiveObj.foo = 2
副作用函数执行了2次,但是函数内只读取了myReactiveObj.bar。
原因在于targetMap使用了{target: [effect]}这样的数据结构,.foo和.bar都属于myReactiveObj的属性,所以被触发了
解决方式也简单,将key也加入依赖集合的对象
//收集依赖
const track = (target, key) => {
let deps = targetMap.get(target) //deps 即为不同key下的依赖集合
if(!deps) {
targetMap.set(target, (deps = new Map()))
}
let dep = deps.get(key)
if(!dep) {
deps.set(key, {})
}
}
//响应
const trigger = (target, key) => {
const deps = targetMap.get(target)
if(!deps) return
const dep = deps.get(key)
if(dep && activeEffect) {
activeEffect()
}
}
targetMap的数据结构将变成这样
{
target: {
key1: {}
key2: {}
}
}
响应死循环
如果在effect里面同时进行get/set的话,就会变成死循环,effect -> get/set -> effect -> ... ,比如
myEffect(() => {
console.log('副作用函数执行')
console.log(myReactiveObj.bar)
myReactiveObj.bar = {
title: 'world'
}
})
track阶段记录key关联的effecttrigger阶段判断下当前key关联的effect跟activeEffect是否同一个,触发新的effect时将activeEffect指向到1关联的effectmyEffect执行完成之后释放activeEffect 修改之后如下
let activeEffect = null
const myEffect = (fn) => {
try {
activeEffect = fn
return fn()
}finally {
activeEffect = null //执行完之后需要释放activeEffect
}
}
//收集依赖
const track = (target, key) => {
let deps = targetMap.get(target) //deps 即为不同key下的依赖集合
if(!deps) {
targetMap.set(target, (deps = new Map()))
}
let dep = deps.get(key)
if(!dep) {
deps.set(key, activeEffect) //依赖收集阶段记录当前执行的effect
}
}
//响应
const trigger = (target, key) => {
const deps = targetMap.get(target)
if(!deps) return
const dep = deps.get(key)
if(dep !== activeEffect) { //判断activeEffect和key记录的effect是否一致
activeEffect = dep // 触发trigger再次记录当前执行的effect
activeEffect()
}
}
无法定义多个副作用函数
myEffect(() => {
console.log('副作用函数1执行')
console.log(myReactiveObj.bar)
})
myEffect(() => {
console.log('副作用函数2执行')
console.log(myReactiveObj.bar)
})
myReactiveObj.bar = { //set之后,只有副作用函数2被触发
title: 'world'
}
原因在于,targetMap的数据结构只记录一个activeEffect,再触发trigger响应时自然就只会执行第一个收集的effect,可以改成在 track 阶段关联key和多个effect,trigger阶段循环去执行即可(修改下变量命名)
//收集依赖
const track = (target, key) => {
let keyMap = targetMap.get(target) //keyMap 即为不同key下的依赖集合
if(!keyMap) {
targetMap.set(target, (keyMap = new Map()))
}
let deps = keyMap.get(key) || []
if(!deps.includes(activeEffect)) { //避免重复收集
deps.push(activeEffect)
keyMap.set(key, deps) //deps这里变成一个数组,记录多个effect
}
}
//响应
const trigger = (target, key) => {
const keyMap = targetMap.get(target)
if(!keyMap) return
const deps = keyMap.get(key)
for(const dep of deps) {
if(dep !== activeEffect) { //判断activeEffect和key记录的effect是否一致
activeEffect = dep // 触发trigger再次记录当前执行的effect
activeEffect()
}
}
}
嵌套死循环
示例如下
myEffect(() => {
console.log('副作用函数1执行')
console.log(myReactiveObj.bar)
myReactiveObj.bar = {
title: 'world1'
}
})
myEffect(() => {
console.log('副作用函数2执行')
console.log(myReactiveObj.bar)
myReactiveObj.bar = {
title: 'world2'
}
})
通过依赖收集可以看到.bar这个key记录2个effect,里面都修改了.bar的属性,那么按照trigger的逻辑,只会过滤当前执行的activeEffect,还是会执行另外一个effect,从而导致了死循环
解决方式关键在于 activeEffect在每次trigger触发的时候需要记录调用栈,在触发的时候去判断该effect是否存在调用栈中
class ReactiveEffect {
constructor(fn) {
this.fn = fn
this.parent = null
}
run() {
let parent = activeEffect
while (parent) {
if (parent === this) {
return
}
parent = parent.parent
}
try {
this.parent = activeEffect //每次调用时记录当前的执行栈
activeEffect = this
return this.fn()
}finally {
activeEffect = this.parent
this.parent = undefined //一次set的响应结束后需要释放当前的执行栈
}
}
}
const myEffect = (fn) => {
//activeEffect的数据结构就变成activeEffect: ReactiveEffect
const _effect = new ReactiveEffect(fn)
return _effect.run()
}
const trigger = (target, key) => {
const keyMap = targetMap.get(target)
if(!keyMap) return
const deps = keyMap.get(key) || []
for(const dep of deps) {
if(dep !== activeEffect) { //判断activeEffect和key记录的effect是否一致
dep.run()
}
}
}
这段逻辑比较绕,可以这样梳理下
- 执行effect1时,
console.log(myReactiveObj.bar)触发.bar依赖收集,此时的activeEffect和deps数据结构就是
// deps
[
{ //ReactiveEffect
fn: effec1,
parant: null
}
]
// activeEffect
{
fn: effec1,
parent: null
}
myReactiveObj.bar = xxx时,因为activeEffect.fn === 'bar[0].fn',所以trigger阶段的effect1并没有触发
2. 执行effect2时,console.log(myReactiveObj.bar)触发.bar依赖收集,此时的activeEffect和deps数据结构就是
// deps
[
{ //ReactiveEffect
fn: effec1,
parant: null
},
{ //ReactiveEffect
fn: effec2,
parant: null
}
]
// activeEffect
{
fn: effec2,
parent: null
}
执行到myReactiveObj.bar = xxx时,trigger阶段,循环deps,因为deps[0].fn !== activeEffect.fn,所以触发了deps[0].fn.run
注意!!!此时的activeEffect和deps数据结构已经变成
//deps
[
{ //ReactiveEffect
fn: effec1,
parant: effec2 //记录了上一次执行的effect
},
{ //ReactiveEffect
fn: effec2,
parant: null
}
]
// activeEffect
{
fn: effec1,
parent: effec2 //记录了上一次执行的effect
}
- 再执行effect1,get阶段不会再次收集同个依赖(去重),set阶段触发trigger,循环deps,
deps[0].fn === activeEffect.fn,故不执行,执行deps[1].fn.run,递归判断activeEffect的调用栈有deps[1].fn,不执行effect1,直接返回 - 回到2的循环deps中,因为
deps[1].fn !== activeEffect.fn,不执行,结束所有的响应
至此,一个基本的依赖收集和响应的雏形就出来了,上面都是通过reactive创建响应式对象进行举例,实际上官方还有很多API创建响应式对象,比如shallowReactive、ref、shallowRef等,但是实际上依赖收集和响应都差不多,大家可以自己去看下。下面简单介绍下官方的几个API实现吧
reactive
可以使用reactive函数创建一个深响应式对象、数组、Map、Set,最终会返回Proxy代理对象
import { reactive, isReactive } from 'vue'
const obj = {
bar: {
title: 'hello'
}
}
const reactiveObj = reactive(obj)
深响应式的实现方式就是在于访问顶层对象触发get拦截时, 将嵌套的子对象转换为代理对象。同时针对不同数据类型返回了不同的代理对象,object、array类型数据返回的代理对象 mutableHandlers ,
Map、Set、WeakMap、WeakSet返回的代理对象 mutableCollectionHandlers ,其余数据类型直接返回原始值
为什么针对Map、Set、WeakMap、WeakSet类型的数据只拦截了get呢?
主要是因为map类型数据都是通过map.set('key', 'value')调用set方法操作,也就是先获取.set,故只需要拦截get操作,在get内部实现 .set 等方法的拦截,即createInstrumentations
shallowReactive
返回浅响应数据的代理对象,实现方式跟reactive差不多,唯一区别在于访问深层次的对象,reactive返回的是代理对象,shallowReactive返回原始对象,相关源码
import { shallowReactive, effect } from 'vue'
const obj = {
bar: {
title: 'hello'
}
}
const shallowObj = shallowReactive(obj)
effect(() => {
console.log('副作用函数1执行')
console.log(shallowObj.bar)
})
//因为顶层对象有被代理,所以会触发set
shallowObj.bar = {
title: 'world'
}
//对深嵌套对象进行赋值,会先获取顶层对象,触发get,返回原始对象,再继续对返回的对象进行赋值,所以没有触发set
shallowObj.bar.title = 'world'
readonly
返回只读代理对象,get阶段不进行依赖收集(相关源码),拦截set不改变原始值(相关源码)
import { readonly } from 'vue'
const obj = {
bar: {
title: 'hello'
}
}
const readonlyObj = readonly(obj)
//warning: Set operation on key "bar" failed: target is readonly
readonlyObj.bar = {
title: 'world'
}
console.log(readonlyObj.bar.title) //hello
shallowReadonly
返回浅响应只读的代理对象,阻止修改且不进行依赖收集,实现原理其实就是只代理了顶层对象,然后拦截了get(不进行依赖收集)、set(阻止修改)
isReactive、isReadonly、isShallow、isProxy
判断数据类型,是否由对应响应式函数创建出来的数据,实现方式非常简单
- 尝试访问几个枚举的属性ReactiveFlags,相关源码
- 触发get拦截,判断特定key === ReactiveFlags枚举,返回true/false,相关源码
有一点副作用,比如原始对象里面刚好包含了枚举值里的key属性,那么也会返回true,比如
import { isReactive } from 'vue'
const obj = {
bar: {
title: 'hello'
}
}
console.log(isReactive({
...obj,
__v_isReactive: true
})) //true
markRaw
标记某个对象使其不会成功响应式对象,实现原理如下
import { markRaw, reactive } from 'vue'
const obj = {
bar: {
title: 'hello'
}
}
const reactiveObj = reactive(markRaw(obj)) // 直接返回原始对象
console.log(reactiveObj === obj) // true
其实也可以手动给对象添加__v_skip属性,同样会跳过代理
import { reactive } from 'vue'
const obj = {
bar: {
title: 'hello'
}
__v_skip: true
}
const reactiveObj = reactive(obj) // 直接返回原始对象
console.log(reactiveObj === obj) // true
toRaw
返回创建响应式对象的原始对象,实现原理如下
reactive创建响应式对象时,通过proxyMap记录原始对象(key)和响应式对象(value),缓存原始对象,避免重复创建同个对象的响应式对象 相关源码- 调
toRaw的时候,访问__v_raw,触发get拦截函数,在里面判断特定key,返回proxyMap记录的原始对象 相关源码
import { reactive, toRaw } from 'vue'
const obj = {
bar: {
title: 'hello'
}
}
const reactiveObj = reactive(obj)
console.log(toRaw(reactiveObj) === obj) // true
ReactiveFlags
内置的枚举值,上面也有说到,简单说明下各自的作用 相关源码
export const enum ReactiveFlags {
SKIP = '__v_skip', //跳过代理
IS_REACTIVE = '__v_isReactive', //访问该key时返回是否有代理的标记
IS_READONLY = '__v_isReadonly', //访问该key时返回是否只读的标记
IS_SHALLOW = '__v_isShallow', //访问该key时返回是否为浅响应对象的标记
RAW = '__v_raw' //访问该key时返回原始对象的标记
}
ref
reactive通过Proxy实现代理对象,但是只支持对象类型,故vue3还提供了ref函数,可以针对任意类型的数据生成响应式对象
import { ref } from 'vue'
const obj = {
bar: {
title: 'hello'
}
}
const text = 'hello'
const textRef = ref(text)
const objRef = ref(obj)
console.log(textRef.value)
console.log(objRef.value)
textRef.value = 'world'
objRef.value.bar.title = 'world'
将原始数据包装为一个带 .value property 的 ref 对象,实现方式如下 相关源码
- new 一个ref对象,绑定value属性的getter/setter进行读写的拦截
- 初始化私有属性
_value(getter直接返回该私有属性),如果传入对象、set、map的话,调用reactive返回其代理对象,其它返回原始值
后续需要读写其响应式对象时,均需要带上value
//Ref内部私有属性
class RefImpl<T> {
private _value: T //响应式对象或者原始值
private _rawValue: T //原始数据
public dep?: Dep = undefined
public readonly __v_isRef = true //是否已转换过响应式对象的标记
//xxxxx
}
shallowRef
返回浅响应式对象,实现方式很简单,_value直接返回原始值即可,这样的话,只会拦截.value的getter/setter,访问深层次的对象时操作的是原始值,并不会触发reactive代理对象的get/set拦截函数 相关源码
isRef
判断是否属于Ref对象,通过ref/shallowRef函数转换过的响应式对象均会带上一个内置属性__v_isRef,故只需要判断该属性true/false即可 相关源码
同isReactive,如果原始对象就有__v_isRef属性,也会返回true
toRef
在写代码过程中,有一些写法可能会导致响应式丢失的情况,比如解构
import { reactive, toRef } from 'vue'
const obj = {
bar: {
title: 'hello'
}
}
const reactiveObj = reactive(obj)
let {title} = reactiveObj.bar
title = 'world' //修改title并不会触发响应
这时候需要使用toRef关联上源响应式对象
import { reactive, toRef } from 'vue'
const obj = {
bar: {
title: 'hello'
}
}
const reactiveObj = reactive(obj)
let title = toRef(reactiveObj.bar, 'title')
title.value = 'world'
console.log(reactiveObj.bar.title) //world
reactiveObj.bar.title = 'hello'
console.log(title.value) //hello
当然你也可以把一个普通对象的key转换为Ref对象,但是该ref对象进行get/set操作时,并不会触发依赖收集和响应 相关源码
import { toRef, effect } from 'vue'
const obj = {
bar: {
title: 'hello'
}
}
const toRefObj = toRef(obj, 'bar')
//effect只会触发一次
//副作用,后面会讲到
effect(() => {
console.log(toRefObj.value)
})
toRefObj.value.title = 'world' //修改不会触发响应
toRefs
toRef只能转换单个key,toRefs将对象的所有key都转换成ref对象 相关源码
import { toRefs, reactive } from 'vue'
const obj = {
bar: {
title: 'hello'
}
}
const reactiveObj = reactive(obj)
const {bar} = toRefs(reactiveObj) //直接解构不会丢失响应式
bar.value = 'world'
console.log(reactiveObj.bar.title) //world
比较不一样的是,如果传入的不是一个reactive响应式对象,那么会有告警toRefs() expects a reactive object but received a plain one.(为啥toRef就没有呢?)
proxyRefs
官方文档没有该API的说明,笔者这里猜测应该是下面这些原因
在深嵌套对象中有部分子对象是ref类型的场景下,在操作子对象时候非常麻烦,需要通过isRef判断需不需要加上.value,比如
import { ref, isRef, proxyRefs } from 'vue'
const data = {
bar: ref(0)
}
if(isRef(data.bar)) {
console.log(data.bar.value)
}else{
console.log(data.bar)
}
为了减少心智负担,提供了proxyRefs包装这类型的对象
import { ref, isRef, proxyRefs } from 'vue'
const data = {
bar: ref(0)
}
if(isRef(data.bar)) {
console.log(data.bar.value)
}else{
console.log(data.bar)
}
let proxyRefsObj = proxyRefs(data)
proxyRefsObj.bar = 1 //这里可以直接.bar方式修改
console.log(data.bar.value) //1
console.log(proxyRefsObj.bar) //1
实现方式很简单,就是给ref对象套一层proxy,在get/set自动加上.value 相关源码
effect
前面实现了基础的响应系统,这里介绍下官方提供的一些能力以及实现的思路
lazy
定义一个副作用函数会立即执行,但是加上lazy之后就不会,需要手动去执行runner
import { effect, reactive } from 'vue'
const obj = {
bar: {
title: 'hello'
}
}
const reactiveObj = reactive(obj)
const runner = effect(() => {
console.log('副作用函数1执行')
console.log(reactiveObj.bar)
}, {
lazy: true
})
reactiveObj.bar = {
title: 'world'
}
runner() //输出 "world"
有什么应用场景呢?如果你的响应函数非常耗性能,你希望自己去控制触发,那么你可以加上lazy,在合适的时机触发响应,有点类似小程序的setData
scheduler
副作用函数调度器,如果你希望修改reactive不想频繁执行副作用函数又不希望手动去处理(lazy),那么你可以通过调度器去指定副作用函数如何触发,比如实现一个防抖
import { effect, reactive } from 'vue'
// 使用 reactive() 函数定义响应式数据
const obj = {
bar: {
title: 'hello'
}
}
let timer = null
const reactiveObj = reactive(obj)
const runner = effect(() => {
console.log('副作用函数1执行')
console.log(reactiveObj.bar)
}, {
scheduler: (fn) => {
clearTimeout(timer)
timer = setTimeout(() => {
runner()
}, 300)
}
})
reactiveObj.bar = {
title: 'world'
}
reactiveObj.bar = {
title: 'world1'
}
reactiveObj.bar = { //world2
title: 'world2'
}
定义了调度器之后,除了第一次会执行副作用之外,后面所有的trigger都只会执行调度器的函数
scope
给该effect副作用函数创建到一个作用域里面(想不到更好的描述了),便于后续回收、销毁,需要先理解vue3.2之后引用的新API effectScope,如果没有该API,那么你需要自己收集响应,然后在合适的时机进行销毁,比如
const effectList = []
const runner1 = effect(() => {
console.log('副作用函数1执行')
console.log(reactiveObj.bar)
})
const runner2 = effect(() => {
console.log('副作用函数2执行')
console.log(reactiveObj.bar)
})
effectList.push(runner1)
effectList.push(runner2)
//....
//销毁
effectList.forEach(e => {
stop(e)
})
有了effectScope之后就可以很方便的进行销毁了
const scope = effectScope()
scope.run(() => {
effect(() => {
console.log('副作用函数1执行')
console.log(reactiveObj.bar)
}, {
scope
})
effect(() => {
console.log('副作用函数2执行')
console.log(reactiveObj.bar)
}, {
scope
})
})
//....
scope.stop()
当前创建effect时也可以指定到某个scope下,比如
effect(() => {
console.log('副作用函数2执行')
console.log(reactiveObj.bar)
}, {
scope
})
allowRecurse
这个配置很有迷惑性,刚看到的时候以为是允许递归调用自身effect(可能会导致死循环,参考响应死循环章节),实际看源码发现不是,跟 scheduler 有关,比如
effect(() => {
console.log('副作用函数1执行')
console.log(reactiveObj.bar)
reactiveObj.bar = { //修改不会再次触发effect
title: 'world1'
}
}, {
scheduler: () => { //也不会触发scheduler
console.log('scheduler')
},
})
加上allowRecurse
effect(() => {
console.log('副作用函数1执行')
console.log(reactiveObj.bar)
reactiveObj.bar = { //修改不会再次触发effect
title: 'world1'
}
}, {
scheduler: () => { //会触发scheduler
// reactiveObj.bar = { // schedulerz再次修改的话,就变成死循环了,千万要注意
// title: 'world1'
// }
console.log('scheduler')
},
allowRecurse: true
})
所有 allowRecurse 配置是针对是否允许递归调用 scheduler
onStop
这个配置就很好理解,销毁响应时的回调