@vue/reactivity

前言

vue 的响应式是 数据更新视图,也就是当数据变更时,vue 会自动帮我们更新页面,我们无需手动操作 dom

这么理解当然是对的,但是探究 vue 的响应式之根本就不单单是 数据更新视图,而应该是 数据更新函数

数据更新视图,无非就是 ref/reactive 后的数据变更,然后触发了 render 函数的重新执行,最后才是我们看到的 视图 更新

因此数据更新视图,这个函数仅仅是 render,我们不妨跳出 render,来到函数这一层,这样也方便我们实现

我们继续来看响应式,如何理解数据和函数之间的关联

我们看 vue 的响应式就是 数据变更了,用过这些数据的函数能够一起执行

因此这个关联体现在

  1. 函数要能够监听数据的 读取 以及 修改
  2. 以及该数据对应了哪些函数

弄清了这两个要点,我们就有实现的方向了

实现拦截

第一点我们可以给一个最小实现 demo,也就是实现监听对象

实现监听对象我们有两个方法,一个是 es5 的 Object.defineProperty,另一个是 es6 的 Proxy ,我们不妨回顾下二者的区别

在用法上,Object.defineProperty 需要拿到对象以及对应的 key,对 key 添加 get,set 属性,比如下面

a 属性在 definedProperty 后得到了 get,set 属性,这样 a 属性就获得了一个监听

而 proxy 则是对整个对象添加 handlers,这个 handlers 里面总共有 13 种拦截属性,不仅仅是 get,set

单单从这里就可以看出 Object.defineProperty 的局限性在于必须清楚对象的属性名,动态新增/删除无法感知,而 Proxy 则是通过代理整个对象,对所有操作进行拦截,不仅仅是get,set,Object.defineProperty 要想代理对象所有属性还得遍历挨个实现,proxy 则是天然支持,无需遍历

要说 Object.defineProperty 相较于 Proxy 的优点,也就只有 兼容可谈,不过目前基本上浏览器都兼容 proxy,可能只有 IE 不行

接下来就用 proxy 来实现一个demo,我们尽量参考 vue/reactivity 的模块来写

首先,我们期望有个 reactive 能够把数据变成响应式数据,也就是可以进行监听

js 复制代码
import { reactive } from './reactive.js'

const obj = {
    a: 1,
    b: 2,
}

const state = reactive(obj)

function fn () {
  state.a;
}
fn()

这里 fn 执行,会读取到 state.a,那么应该会触发 get

因此在 reactive 里面实现一个 proxy 即可,proxy 的第二个参数是 handlers,考虑到延展,我们用单独一个文件 baseHandlers.js 里面存放 目前 有的 get,set

js 复制代码
import { baseHandlers } from './baseHandlers.js'

export function reactive (target) {
    return new Proxy(target, baseHandlers)
}

baseHandlers.js

js 复制代码
function get (target, key, value) {
    console.log('get', target, key);
    
    if (target[key]) {
        return target[key]
    } 
}

function set (target, key, value) {
    console.log('set', target, key, value);
    
    if (target[key] === value) return 
    target[key] = value
}

export const baseHandlers = {
    get,
    set, 
}

我们把 fn 改成修改值 state.a = 2,再来看

不出意外就会报错了

这是因为 proxy 的 set 操作返回一个 布尔,我们当然可以在结尾新增一个 return true,用 try catch 兜住错误。但是 es6 新增了一个 Reflect 对象,身上的 set 属性天然支持返回 true,因此我们 get,set 通通换成 Reflect 来做

那个 log 后续的逻辑其实就是依赖收集和派发更新,我们分别用函数 tracktrigger 代替

js 复制代码
export function track (target, key) {
    console.log('依赖收集', target, key);
}

export function trigger (target, key) {
    console.log('依赖触发', target, key);
}

然后我们的 handlers 就用 Reflect 去 set 和 get

js 复制代码
function get (target, key) {
    track(target, key)
    return Reflect.get(target, key)
}

function set (target, key, value) {    
    trigger(target, key)
    return Reflect.set(target, key, value)
}

至此,一个简单的响应式读取值和修改值的监听就实现了

后续的实现就是第二点,如何让一个数据能够收集到对应的使用过这些数据的函数

但是目前实现第二点还有点距离需要爬,我们先看下有些特殊情况,可能读写不会被监听上

in 的读取 --- has

我们来看一个 in 操作符

js 复制代码
import { reactive } from './reactive.js'

const obj = {
    a: 1,
    b: 2,
}

const state = reactive(obj)

function fn () {
  if ('a' in state) {
    console.log('aaaa');
  }
}
fn()

'a' in state 这里会读取 a ,按道理应该可以触发 track 依赖收集,但是并没有

而 in 这个操作符其实本质上调用的是 hasProperty,proxy 里面拦截 hasProperty 的操作属性为 has

因此我们需要往 baseHandlers 中加一个 has

js 复制代码
function has (target, key, value) {
    track(target, key)
    return Reflect.has(target, key, value)
}

这样一来就能拦截 'a' in state 了

for in 的读取 -- ownKeys

我们再来看个 for in 的例子

js 复制代码
const obj = {
    a: 1,
    b: 2,
}

const state = reactive(obj)

function fn () {
  for (const key in state) {
  } 
}
fn()

这里按道理预期也是 会触发 track ,因为我把这个响应式数据的每个 key 都读取了一遍

这个 for in 其实是需要对象有 iterate 属性,而这个刚好对应着 ReflectownKeys 属性,不过这个迭代是不需要 key 的,因此这里的 key 就不用传给 track 了

js 复制代码
function ownKeys (target) {
    track(target)
    return Reflect.ownKeys(target)
}

ownKeys 和 属性无关,因此没有 key value

操作类型与拦截类型

其实到这里你肯定会发现规律,那就是可能还会有很多操作类型,不仅仅是 get,set,has,ownKeys,还有 add,delete 等,考虑得越全面这些东西就越多,而这些属性其实刚好可以分为 操作类型 和 拦截类型,

后续在 track / trigger 中,可以根据这些类型去优化,比如 set 能够影响到的 只有 get,而 add 能够影响的就多了,有 get,has,ownKeys

我们先给出一个类型文件,里面存放 trackOpTypes 以及 triggerOpTypes

js 复制代码
export const trackOpTypes = {
  GET: "get",
  HAS: "has",
  ITERATE: "iterate",
};

export const triggerOpTypes = {
  SET: "set",
  ADD: "add",
  DELETE: "delete",
};

刚刚聊到的 for in,其实对应的 track 类型为 iterate,后续还能发现有新的类型就再补全

receiver

我们再来看一个🌰

js 复制代码
import { reactive } from './reactive.js'

const obj = {
    a: 1,
    b: 2,
    get c () {
      return this.a + this.b
    }
}

const state = reactive(obj)

function fn () {
  state.c;
}
fn()

当我们访问 c 属性时,c 会返回 a 和 b,按道理会触发 三次 get,对应 a,b,c

但是这里实际上只有 c

我们可以在 c 里面添加一个 log ,看看 this 是啥,结果你会发现就是 obj,这当然符合预期,但是要想 a 和 b 也能被 get 到,是不是得希望 this 是 Proxy 后的 obj

实际上 state.c 就是 get 里面的 target[key],而 [[get]] 其实调用的是 [key, receiver],默认语法上,get 第二个参数就是 this 指向的对象,这里对应的就是 obj,我们无法更改

但是这个却可以通过 Reflect 解决,Reflect 支持更改 this,我们可以在 get 中多加一个 入参 receiver,也就是 Reflect.get(target, key, receiver)

js 复制代码
function get (target, key, receiver) {
    track(target, key)
    return Reflect.get(target, key, receiver)
}

深度监听

我们再来看一个🌰

JS 复制代码
import { reactive } from './reactive.js'

const obj = {
    a: 1,
    b: 2,
    c: {
      d: 3
    }
}

const state = reactive(obj)

function fn () {
  state.c.d;
}
fn()

这里读取 state.c.d ,但道理 get 应该监听到 c 和 d,但是实际上只有 c,其实也符合预期,因为 state.c 返回的 对象是 { d } ,而非 Proxy 后的对象,因此在 get 中我们不用着急返回 Reflect.get,我们可以先判断其返回值是否为 对象,是就用 reactive 再包裹一层

js 复制代码
function get (target, key, receiver) {
    track(target, key)
    const res = Reflect.get(target, key, receiver)
    if (isObject(res)) {
        return reactive(res)
    } else {
        return res
    }
}

add & delete

add 以及 delete 都是派发更新,我们现在来补充下这两个函数

先看 add,看这个🌰

js 复制代码
import { reactive } from './reactive.js'

const obj = {
    a: 1,
    b: 2,
    c: {
      d: 3
    }
}

const state = reactive(obj)

function fn () {
  state.e = 4
}
fn()

我们希望 trigger 的时候能看到 add 信息

add 其实就是 set,当 key 存在就是 set,不存在就是 add

因此add我们可以在 set 里面补充,另外,我们可以把 track 和 trigger 的 log 添加 type 信息

js 复制代码
export function track (target, key, type) {
    console.log('依赖收集', target, key, type);
    
}

export function trigger (target, key, type) {
    console.log('依赖触发', target, key, type);
}

我们在 set 中区分出 add

vbnet 复制代码
function set (target, key, value, receiver) {    
    const type = target.hasOwnProperty(key) ? TriggerOpTypes.SET : TriggerOpTypes.ADD
    trigger(target, key, type)
    return Reflect.set(target, key, value, receiver)
}

其实 set 还有处可以优化,当 set 原来的值就不用 trigger 了

也就是补充 if (target[key] === value) return true

再来看 delete,我们希望下面这个例子能看到 delete 信息

js 复制代码
const obj = {
    a: 1,
    b: 2,
    c: {
      d: 3
    }
}

const state = reactive(obj)

function fn () {
  delete state.e
}
fn()

那就得新增一个 deletePropertyhandlers

js 复制代码
function deleteProperty (target, key) {
    if (!target.hasOwnProperty(key)) return true 
    trigger(target, key, TriggerOpTypes.DELETE)
    return Reflect.deleteProperty(target, key)
}

前面判断是因为若本身就没有这个 key,那就不用 trigger 了

数组的拦截

includes 为例

你或多或少听说过 vue 有重写数组的方法,这就是因为数组的有些修改监听不上,下面就一一来举例说明

首先看 includes 对象时的情况

js 复制代码
import { reactive } from './reactive.js'

const obj = {}

const arr = [1, obj, 2]

const state = reactive(arr)

function fn () {
  let index = state.includes(obj)
  console.log(index)
}
fn()

state 是 Proxy(arr),它调用 includes 就会访问 includes 属性,我们可以看看会输出什么

js 复制代码
依赖收集 (3) [1, {...}, 2] includes get
依赖收集 (3) [1, {...}, 2] length get
依赖收集 (3) [1, {...}, 2] 0 get
依赖收集 (3) [1, {...}, 2] 1 get
依赖收集 (3) [1, {...}, 2] 2 get
false

includes 内部实现必然会访问到 includes ,访问 length 其实也好理解,要我们自己手写一个 includes,不就是一个 for(let i = 0; i < arr.length; i++),然后找不到就返回 false 或者说 -1,这里很奇怪,includes 遍历了所有项都找不到 obj,最终返回了 false

我们若 includes(1) 那便输出如下

js 复制代码
依赖收集 (3) [1, {...}, 2] includes get
依赖收集 (3) [1, {...}, 2] length get
依赖收集 (3) [1, {...}, 2] 0 get
true

这是符合预期的,为啥 obj 就找不到

其实细想下也好理解,因为state 调用 includes 时,state 可是 Proxy,之前我们已经做了深度监听,也就是说 state 里面的 obj 会被再次 reactive 一次,proxy 里面找 原生的 obj,这个 obj 并非 proxy,因此可以理解为 proxy !== raw 导致的 false

但是我们肯定不希望把 深度监听去掉,那就灵活点,当 includes 找不到时,我们再次处理下,能否让原生的 arr 去调用 includes

在 get 中我们有三个入参,分别为 原生的 target,key,以及 proxy 后的 receiver

若 key 为 includes 那就特殊处理,我们把处理过的 includes 给到 proxy

在 includes 内部,找不到时我们给一个唯一属性,让 proxy 访问这个唯一属性时,再次触发 get,此时原生 arr,也就是 target 返回出去,这样就实现了 数组特定方法调用时,避免了深度监听

具体实现如下,indexOflastIndexOf 同理

js 复制代码
const arrayInstrumentations = {}

const RAW = Symbol('raw');

['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
    const originMethod = Array.prototype[method]
    arrayInstrumentations[method] = function (...args) {
        let res = originMethod.apply(this, args)
        if (res === false || res === -1) {
            res = originMethod.apply(this[RAW], args)
        }
        return res
    }
})

function get (target, key, receiver) {
    if (key === RAW) return target
    track(target, key, TrackOpTypes.GET)
    const res = Reflect.get(target, key, receiver)
    if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
      return arrayInstrumentations[key].bind(receiver)
    }
    return isObject(res) ? reactive(res) : res;
}

length

我们再来看个栗子

js 复制代码
import { reactive } from './reactive.js'

const obj = {}

const arr = [1, obj, 2]

const state = reactive(arr)

function fn () {
  state[4] = 2
}
fn()

这里通过下标的形式新增了一个 item,那必然会 tirgger 一次 add

但是这样数组的 length 也变了,按道理 length 也要一次 set ,但是实际上目前并没有 length 的 trigger

其实 length 的变化就相当于用 Object.defineProperty() ,这种变化是 trigger 不到的

既然如此,我们就在这种情况下手动 trigger 下 length

这种情况就是数组 add,判断两下就好了,具体实现直接看下面代码

js 复制代码
function set (target, key, value, receiver) {    
    const type = target.hasOwnProperty(key) ? TriggerOpTypes.SET : TriggerOpTypes.ADD
    const oldValue = target[key]
    const oldLen = Array.isArray(target) ? target.length : null
    const res = Reflect.set(target, key, value, receiver)
    const newLen = Array.isArray(target) ? target.length : null
    if (!Object.is(oldValue, value) || type === TriggerOpTypes.ADD) {
        trigger(target, key, type)
        if (Array.isArray(target) && oldLen !== newLen && key !== 'length') {
            trigger(target, 'length', TriggerOpTypes.SET)
        }
    }

    return res
}

通过下标新增 item,length 的写会被监听不上,若通过 length 删减 arr,item 的删除会被监听上吗,实际上并不会

js 复制代码
import { reactive } from './reactive.js'

const obj = {}

const arr = [1, obj, 2]

const state = reactive(arr)

function fn () {
  state.length = 2
}
fn()

目前这个情况只能 trigger 到 length

既然失真了,我们同样手动处理,刚才的实现我们是 key !== length

现在则是 key === length ,那么就是从 oldLen 到 newLen 的区别去 trigger 下标就行,并且类型给一个 delete

js 复制代码
function set (target, key, value, receiver) {    
    const type = target.hasOwnProperty(key) ? TriggerOpTypes.SET : TriggerOpTypes.ADD
    const oldValue = target[key]
    const oldLen = Array.isArray(target) ? target.length : null
    const res = Reflect.set(target, key, value, receiver)
    const newLen = Array.isArray(target) ? target.length : null
    if (!Object.is(oldValue, value) || type === TriggerOpTypes.ADD) {
        trigger(target, key, type)
        if (Array.isArray(target) && oldLen !== newLen) {
            if (key !== 'length') {
                trigger(target, 'length', TriggerOpTypes.SET)
            } else {
                for (let i = newLen; i < oldLen; i++) {
                    trigger(target, i, TriggerOpTypes.DELETE)
                }
            }
        }
    }

    return res
}

push

我们再来看一个 push 案例

js 复制代码
import { reactive } from './reactive.js'

const obj = {}

const arr = [1, obj, 2]

const state = reactive(arr)

function fn () {
  state.push(3)
}
fn()

这里的 log 如下

js 复制代码
依赖收集 (3) [1, {...}, 2] push get
依赖收集 (3) [1, {...}, 2] length get
依赖触发 (4) [1, {...}, 2, 3] 3 add
依赖触发 (4) [1, {...}, 2, 3] length set

看着并没有问题,最后的 length set 也是对的,但是当我们多次重复调用 fn 时,你就会觉得 length 每次 push 一次 length 都会被 track 依赖收集一次显得重复,因此我们需要避免重复触发 length 相关依赖

那就对 push 这类 方法做点手脚,当 push 时,内部就会有 length 的 key,此时暂停 track,等调用完再恢复 track

js 复制代码
["push", "pop", "shift", "unshift", "splice"].forEach((method) => {
  arrayInstrumentations[method] = function (...args) {
    pauseTrack();
    const res = Array.prototype[method].apply(this, args);
    resumeTrack();
    return res;
  };
});

pauseTrackresumeTrack 也很容易实现

js 复制代码
let shouldTrack = true;

export function track (target, key, type) {
    if (!shouldTrack) return
    console.log('依赖收集', target, key, type);
    
}

export function trigger (target, key, type) {
    console.log('依赖触发', target, key, type);
}

export function pauseTrack () {
    shouldTrack = false
}

export function resumeTrack () {
    shouldTrack = true
}

track 和 trigger

现在开始实现 track 和 trigger

目前的实现仅仅是拿到 target, key, type 信息,但是具体的功能并没有实现

比如 track 依赖收集,他需要收集对应的函数,trigger 则是把这些收集到的函数重新触发执行

另外我们又要继续探讨一个问题,是否所有用到的响应式数据的函数都要收集进来呢

js 复制代码
render1() {
    render2() {
        .....
    }
}

比如上面这个情景,父组件里面包含一个子组件,这在实际 vue 项目中是个非常常见的情景,render2 里面的响应式数据若发生变更,render2 组件就会重新渲染,但是 render1 不会,若 render2 里面的响应式数据被 render1 用到了,数据变了 render1 就会重新渲染

要想实现到这个精度,那肯定不是一股脑把所有用到响应式的数据全部收集起来,既然是人为控制,那么我们可以给一个 effect 函数,你若希望 fn 能够被收集进来,那么将 fn 传入进去即可,state(fn)

js 复制代码
const obj = {
  a: 1
}

const state = reactive(obj)

function fn1 () {
  function fn2 () {
    state.a;
  }
  fn2()
}

effect(fn1)

比如这里,希望 fn1 被 track 拿到,但是 fn1 是函数 effect 的入参,那就在 effect.js 文件中把 fn 保存到全局中去让 track 拿到

js 复制代码
let shouldTrack = true;
let activeEffect = undefined;

export function effect (fn) {
    activeEffect = fn
    fn()
    activeEffect = null
}

export function track (target, key, type) {
    if (!shouldTrack || !activeEffect) return
    console.log('依赖收集', target, key, type, activeEffect);
    
}

收集依赖时其实需要函数运行才能确定,而非编译阶段,举一个直观的栗子

js 复制代码
const obj = {
  a: 2
}

const state = reactive(obj)

function fn1 () {
  if (state.a === 1) {
    state.b
  } else {
    state.c
  }
}

effect(fn1)

fn1 执行过程中才能确定到底是依赖 a,b 还是 a,c

而运行时就肯定会产生嵌套的情况,嵌套就会每层作用域有对应的 activeEffect,因此我们收集函数不应该仅仅只是收集 fn,而是把 effect 里面的内容都收集进来

js 复制代码
export function effect (fn) {
    const effectFn = () => {
        try {
            activeEffect = fn
            return fn()
        } finally {
            activeEffect = null
        }
    }
    effectFn()
}

这里用 try catch 处理是因为用户写的函数可能会有报错的情况

现在开始用 map 去把一个 target 的对应的函数 去串起来

一个 target 里面会有多个 key,一个 key 又可以有多个 操作类型,操作类型后才对应着 effectFn

除了 effectFn 可以用 set 去个重,其余都可以用 map 表示,另外这里有个 iterate 的操作类型是没有 key 的,因此我们需要手动给 iterate 加一个 key

js 复制代码
const targetMap = new WeakMap()
const ITERATE_KEY = Symbol('iterate')

export function track (target, key, type) {
    if (!shouldTrack || !activeEffect) return
    
    let propMap = targetMap.get(target)
    if (!propMap) {
        propMap = new Map()
        targetMap.set(target, propMap)
    }
    if (key === TrackOpTypes.ITERATE) {
        key = ITERATE_KEY;
    }

    let typeMap = propMap.get(key)
    if (!typeMap) {
        typeMap = new Map()
        propMap.set(key, typeMap)
    }

    let depSet = typeMap.get(type)
    if (!depSet) {
        depSet = new Set()
        typeMap.set(type, depSet)
    }

    if (!depSet.has(activeEffect)) {
        depSet.add(activeEffect)
        activeEffect.deps.push(depSet)
    }

    console.log('targetMap', targetMap);
}

track 基本上把 effectFns 收集到 targetMap 中了,现在 trigger 的目的则是用 target,key,type 去寻找对应的 effectFns,然后触发他们的执行

寻找 effectFns 我们单独给一个函数 getEffectFns 来做这件事,其实寻找对应的 effectFns 主要是需要注意 操作类型 之间的对应关系,比如 trigger 时,我们用了set 类型,但是 map 里面的只有 get ,因此我们需要处理每个 TriggerOpType 对应的所有 TrackOpType,然后挨个遍历,有就输出 effectFn 即可

我们先把 操作类型 之间的对应关系 map 给补上

js 复制代码
const triggerTypeMaps = {
    [TriggerOpTypes.SET]: [TrackOpTypes.GET],
    [TriggerOpTypes.ADD]: [TrackOpTypes.ITERATE, TrackOpTypes.GET, TrackOpTypes.HAS],
    [TriggerOpTypes.DELETE]: [TrackOpTypes.ITERATE, TrackOpTypes.GET, TrackOpTypes.HAS],
}

然后就是 trigger

JS 复制代码
export function trigger (target, key, type) {
    const effects = getEffectFns(target, key, type)
    effects.forEach(effectFn => effectFn())
}

现在实现 getEffectFns

js 复制代码
function getEffectFns (target, key, type) {
    const propMap = targetMap.get(target)
    if (!propMap) return []
    const keys = [key]
    if (type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE) {
        keys.push(ITERATE_KEY)
    }

    const effects = new Set()
    for (const key of keys) {
        const typeMap = propMap.get(key)
        if (!typeMap) continue

        const types = triggerTypeMaps[type]
        for (const type of types) {
            const depSet = typeMap.get(type)
            if (!depSet) continue
            depSet.forEach(effectFn => effects.add(effectFn))
        }
    }
    return [...effects]
}

我们再来看一个栗子

js 复制代码
const obj = { a: 1, b: 2, c: 3 };

const state = reactive(obj);

function fn1() {
  console.log("fn1 执行");
  if (state.a === 1) {
    state.b;
  } else {
    state.c;
  }
}
effect(fn1);

state.a = 2;
state.b = 3;

这里会触发三次执行,看似没毛病,实际上只应该触发两次,因为 state.a 被赋值为 2 时,函数的依赖项就和 state.b 无关了

因此我们要实现的就是当 effectFn 执行时重新把 effectFn 里面的依赖项给清空便是

js 复制代码
export function effect (fn) {
    const effectFn = () => {
        try {
            activeEffect = effectFn
            cleanup(effectFn)
            return fn()
        } finally {
            activeEffect = null
        }
    }
    effectFn.deps = []
    effectFn()
}

function cleanup (effectFn) {
    const { deps } = effectFn
    if (!deps.length) return  
    deps.forEach(dep => dep.delete(effectFn))
    effectFn.deps.length = 0
}

当然,这需要我们在 track 中给 activeEffect 加一个 deps 属性,把 depSet 挂上去

我们再来看一个例子

js 复制代码
const obj = { a: 1, b: 2, c: 3 };

const state = reactive(obj);

function fn1() {
  console.log("fn1 执行");
  effect(() => {
    console.log("fn1 inner");
    state.a;
  });
  state.b;
}

effect(fn1);

state.b = 3;

这里按道理会执行两次 fn1 和 fn1 inner,但是实际上只执行了一次

这是因为在执行 inner 时,activeEffect 已经被置为 null 了,而此时 state.b 还没来得及收集,因此 b 的修改没被监听上

之前的 activeEffect 就不能直接 置为 null,这由于调用栈的关系我们只需要取当前栈的栈顶

js 复制代码
let effectStack = []

export function effect (fn) {
    const effectFn = () => {
        try {
            activeEffect = effectFn
            effectStack.push(effectFn)
            cleanup(effectFn)
            return fn()
        } finally {
            effectStack.pop()
            activeEffect = effectStack[effectStack.length - 1]
        }
    }
    effectFn.deps = []
    effectFn()
}

我们再来看个 bug

js 复制代码
let state = reactive({
  a: 1,
  b: 2,
  c: 3,
});

function fn1() {
  console.log("fn1 执行");
  state.a++;
}
effect(fn1);

目前这么运行会出现爆栈的问题,这是因为 state.a++ 会先 get 后 set,get 时记录了当前 fn,set 时重新执行 fn,重新执行时之前的逻辑是会重新收集依赖,这就导致了无限递归

解决方案那就在 trigger 时,判断函数是不是当前的 activeEffect,若是则不执行 effectFn

js 复制代码
export function trigger (target, key, type) {
    const effects = getEffectFns(target, key, type)
    for (const effectFn of effects) {
        if (effectFn === activeEffect) {
            continue
        }
        effectFn()
    }
}

包括我们可以实现一个 lazy 的 effect 函数

js 复制代码
let state = reactive({
  a: 1,
  b: 2,
  c: 3,
});

function fn1() {
  console.log("fn1 执行");
  state.a++;
}
let fn = effect(fn1, {lazy: true});
fn()

lazy 时,就返回函数,而不是执行,非 lazy 就是正常执行

js 复制代码
export function effect (fn, options = {}) {
    const { lazy } = options 
    const effectFn = () => {
        try {
            activeEffect = effectFn
            effectStack.push(effectFn)
            cleanup(effectFn)
            return fn()
        } finally {
            effectStack.pop()
            activeEffect = effectStack[effectStack.length - 1]
        }
    }
    effectFn.deps = []
    if (!lazy) {
        effectFn()
    }
    return effectFn
}

包括多次更改,渲染一次的调度器

调度器需要存到 effectFn.options 上,给到 trigger 去执行

js 复制代码
export function effect (fn, options = {}) {
    const { lazy } = options 
    const effectFn = () => {
        try {
            activeEffect = effectFn
            effectStack.push(effectFn)
            cleanup(effectFn)
            return fn()
        } finally {
            effectStack.pop()
            activeEffect = effectStack[effectStack.length - 1]
        }
    }
    effectFn.deps = []
    effectFn.options = options
    if (!lazy) {
        effectFn()
    }
    return effectFn
}

export function trigger (target, key, type) {
    const effects = getEffectFns(target, key, type)
    for (const effectFn of effects) {
        if (effectFn === activeEffect) {
            continue
        }
        if (effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn)
        } else {
            effectFn()
        }
    }
}

实现 ref

现在实现 ref,有了 track ,trigger,基本上 ref 可以很轻松实现

ref 其实就是利用 对象 get set 属性,get value 就是依赖收集,调用 track,返回 value,set value 就是派发更新,调用 trigger

js 复制代码
import { track, trigger } from './effect.js'
import { TrackOpTypes, TriggerOpTypes } from './constants.js'
import { reactive } from './reactive.js'
import { isObject } from './utils.js'

export function ref (value) {
    return {
        get value () {
            track(this, TrackOpTypes.GET, 'value')
            return value
        },
        set value (v) {
            if (v !== value) {
                value = v
                trigger(this, 'value', TriggerOpTypes.SET)
            }
        }
    }
}

当然 ref 也要支持 对象,对象就需要调用 reactive 了,另外还需要 reactive 支持 value

实现 computed

computed 其实就是一个仅访问的 effect 函数,只不过里面支持两种写法,一个是写的 getter,setter,一个就是直接写的函数,里面返回的响应式数据

既然支持两种入参,我们可以在 computed 里面先把参数归一化,统一弄成 getter,setter 的形式,但是一般来说我们用函数的形式,函数就是 getter,setter 默认给一个初始值不作用就行

js 复制代码
import { effect } from './effect.js'
import { track, trigger } from './effect.js'
import { TrackOpTypes, TriggerOpTypes } from './constants.js'

function normalizeOptions (getterOptions) {
    let getter, setter
    if (typeof getterOptions === 'function') {
        getter = getterOptions
        setter = () => {
            console.warn('Write operation failed: computed value is readonly')
        }
    } else {
        getter = getterOptions.get
        setter = getterOptions.set
    }
    return { getter, setter }
}

export function computed (getterOptions) {
    const { getter, setter } = normalizeOptions(getterOptions)
    const effectFn = effect(getter, {
        lazy: true,
        scheduler () {
            dirty = true
            trigger(obj, 'value', TriggerOpTypes.SET)
        }
    })
    let value;
    let dirty = true;
    const obj = {
        get value () {
            track(obj, 'value', TrackOpTypes.GET)
            if (dirty) {
                value = effectFn()
                dirty = false
            }
            return value
        }, 
        set value (v) {
            setter(v)
        }
    }
    return obj
}

实现 watchEffect

watchEffect 会自动追踪所有响应式依赖,依赖项发生变化会自动执行传入的 fn

watchEffect 肯定是 lazy false,因为函数在组件加载时就会开始执行一次

js 复制代码
export function watchEffect (fn) {
    const effectFn = effect(fn, {
        lazy: false,
        scheduler (effectFn) {
            effectFn()
        }
    })

    return () => {
        effectFn.deps.forEach(dep => dep.delete(effectFn))
        effectFn.deps.length = 0
    }
}

实现 watch

watch 会接受三个参数,分别为 source,callback,options,source 就是响应式数据,callback 其实就是 getter 函数 ,options 支持 deep,lazy 等,其实 reactive 默认就是 deep

watch 的 deep 指的是是否要把对象的所有内部属性纳入观察依赖中,默认 false

实现 deep 则是通过 traverse 去深度遍历 get 所有属性

js 复制代码
export function watch (source, callback, options = {}) {
    const { immediate = false, deep = false } = options

    let getter
    let oldValue

    if (typeof source === 'function') {
        getter = deep ? () => traverse(source()) : source
    } else if (isObject(source)) {
        getter = () => traverse(source)
    } else {
        console.warn('watch source must be a function or an object')
        return () => {}
    }
    
    const effectFn = effect(getter, {
        lazy: true,
        scheduler (effectFn) {
            const newValue = effectFn()
            callback(newValue, oldValue)
            oldValue = newValue
        }
    })
    
    if (immediate) {
        const newValue = effectFn()
        callback(newValue, undefined)
        oldValue = newValue
    } else {
        oldValue = effectFn()
    }
    
    return () => {
        effectFn.deps.forEach(dep => dep.delete(effectFn))
        effectFn.deps.length = 0
    }
}

function traverse (value, seen = new Set()) {
    if (!isObject(value) || seen.has(value)) {
        return value
    }
    
    seen.add(value)
    
    for (const key in value) {
        traverse(value[key], seen)
    }
    
    return value
}

最后

v3 响应式就是通过 proxy 代理实现的,proxy 天生支持多种 handlers ,我们要做的无非就是在这个基础上进行封装,而数组对于我们的系统来说,很多方法内部会读取 length 或逐个索引可能不符合我们的预期,这才需要进行稍微修改,优先在 proxy 身上找,找不到就再在 this[RAW] 原始数组上找

而 track 依赖收集就是帮我们把数据和对应的 key 之间所有的关系给 weakmap 起来,target -> Map(key) -> Map(type) -> Set(effectFn), 这样我们 trigger 派发更新时就可以依靠这个联系挨个触发。给一个 effect 函数其目的也主要是要收集到函数,给函数打上对应的 deps 标记

至此,@vue/reactivity 基本上实现了个大概。

相关推荐
该用户已不存在6 小时前
程序员的噩梦,祖传代码该怎么下手?
前端·后端
怪兽20146 小时前
请谈谈什么是同步屏障?
android·面试
namehu6 小时前
前端性能优化之:图片缩放 🚀
前端·性能优化·微信小程序
摸鱼的春哥6 小时前
【编程】是什么编程思想,让老板对小伙怒飙英文?Are you OK?
前端·javascript·后端
尘世中一位迷途小书童6 小时前
版本管理实战:Changeset 工作流完全指南(含中英文对照)
前端·面试·架构
尘世中一位迷途小书童6 小时前
VitePress 文档站点:打造专业级组件文档(含交互式示例)
前端·架构·前端框架
甜瓜看代码6 小时前
666
前端
吃饺子不吃馅7 小时前
【八股汇总,背就完事】这一次再也不怕webpack面试了
前端·面试·webpack
Amos_Web7 小时前
Rust实战教程--文件管理命令行工具
前端·rust·全栈