vue篇之三:响应式系统的作用与实现

155 阅读8分钟

一、副作用函数

指的是会产生副作用的函数,例如

01 function effect() { 
02   document.body.innerText = 'hello vue3' 
03 }

当 effect 函数执行时,它会设置 body 的文本内容,但除了 effect 函数之外的任何函数都可以读取或设置 body 的文本内容。也 就是说,effect 函数的执行会直接或间接影响其他函数的执行,这时 我们说 effect 函数产生了副作用。副作用很容易产生,例如一个函数修改了全局变量,这其实也是一个副作用,如下面的代码所示:

01 // 全局变量 
02 let val = 1 
03 
04 function effect() { 
05   val = 2 // 修改全局变量,产生副作用 
06 }

二、响应式原理基本实现

拦截数据, 在读取数据的时候,把副作用函数effect放到一个桶中,在设置数据的时候,从桶中取出副作用函数,执行。

三、完善的响应系统

3.1 基本实现

上面我们硬编码了副作用函数的名字(effect),导致一旦副作用函数的名字不叫 effect,那么这段代码就不能正确地工作了。而我们希望的是,哪怕副作用函数是一个匿名函数,也能够被正确地收集到 “桶”中。为了实现这一点,我们需要提供一个用来注册副作用函数的机制,如以下代码所示:

02 let activeEffect  // 用一个全局变量存储被注册的副作用函数 
03 // effect 用于注册副作用函数 的 函数
04 function effect(fn) { 
06   activeEffect = fn  // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect 
08   fn() // 执行副作用函数 
09 }

01 effect( 
03   () => {   // 一个匿名的副作用函数 
04     document.body.innerText = obj.text // 触发get
05   } 
06 )
01 const obj = new Proxy(data, { 
02  get(target, key) { 
03    // 将 activeEffect 中存储的副作用函数收集到“桶”中 
04    if (activeEffect) { // 新增 
05      bucket.add(activeEffect) // 新增 
06    } // 新增 
07    return target[key] 
08  }, 
09  set(target, key, newVal) { 
10    target[key] = newVal 
11    bucket.forEach(fn => fn()) 
12    return true 
13  } 
14 })

3.2 第二版:副作用函数 与 被操作的目标字段建立联系

如上面的代码所示,由于副作用函数已经存储到了 activeEffect 中,所以在 get 拦截函数内应该把 activeEffect 收集到“桶”中,这样响应系统就不依赖副作用函数的名字了。

但如果我们再对这个系统稍加测试,例如在响应式数据 obj 上设置一个不存在的属性时:

01 effect( 
02   // 匿名副作用函数 
03   () => { 
04     console.log('effect run') // 会打印 2 次 
05     document.body.innerText = obj.text 
06   } 
07 ) 
08 
09 setTimeout(() => { 
10    // 副作用函数中并没有读取 notExist 属性的值 
11    obj.notExist = 'hello vue3' 
12 }, 1000)

读取的时候,无论读取哪个属性,都会收集到bucket中,同样的设置的时候,无论设置哪个属性,都会执行副作用函数。我们没有在副作用函数被操作的目标字段之间建立明确的联系。

为了解决这个问题,我们需 要重新设计“桶”的数据结构。

01 effect(function effectFn() { 
02   document.body.innerText = obj.text 
03 })

仔细观察,在这段代码里有三种角色,

  • 1)被操作(读取)的代理对象 obj;
  • 2)被操作(读取)的字段名 text;
  • 3)使用 effect 函数注册的副作用函数 effectFn。
01 target 
02    └── key 
03         └── effectFn
// 两个副作用同时读取同一个对象的同一个属性
01 effect(function effectFn1() { 
02   obj.text 
03 }) 
04 effect(function effectFn2() { 
05   obj.text 
06 })

01 target 
02   └── text 
03        └── effectFn1 
04        └── effectFn2
01 effect(function effectFn() { 
02   obj.text1 
03   obj.text2 
04 })
.....

如何实现

const bucket = new WeakMap() // 存储副作用函数的桶

const obj = new Proxy(data, {

    get(target, key) {
        if (!activeEffect) return target[key] // 没有 activeEffect,直接 return
        let depsMap = bucket.get(target) // Map 类型:key -->effects
        if (!depsMap) {
           bucket.set(target, (depsMap = new Map()))
        }
        let deps = depsMap.get(key) // Set 类型, key 相关联的副作用函数:effects
        if (!deps) {
            depsMap.set(key, (deps = new Set())) // deps 不存在,新建一个 Set
        }
    
        deps.add(activeEffect) // 最后将当前激活的副作用函数添加到“桶”里
        return target[key] // 返回属性值
    },

    set(target, key, newVal) {
        target[key] = newVal // 设置属性值
        const depsMap = bucket.get(target) // Map类型,它是 key --> effects
        if (!depsMap) return
        const effects = depsMap.get(key) // 根据 key 取得所有副作用函数 effects
        effects && effects.forEach(fn => fn())
    }

 })

附: bucket 为什么用WeakMap呢? 简单来说,weekMap是弱引用,如果target没有被引用的话,说明用户侧不在需要target,垃圾回收器会回收,那么这种情况下用Map的话,target就一直不能被回收。导致内存溢出。

将这部分逻辑单独封装到一 个track函数中, 把触发副作用函数重新执行的逻辑封装到 trigger函数中:

const bucket = new WeakMap() // 存储副作用函数的桶
 const obj = new Proxy(data, {
    get(target, key) {
        track(target, key) // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
        return target[key] // 返回属性值
    },

    set(target, key, newVal) {
        target[key] = newVal // 设置属性值
        trigger(target, key) // 把副作用函数从桶里取出并执行
    }
})

// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
    if (!activeEffect) return // 没有 activeEffect,直接 return
    let depsMap = bucket.get(target)
    if (!depsMap) {
        bucket.set(target, (depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if (!deps) {
        depsMap.set(key, (deps = new Set()))
    }
    deps.add(activeEffect)
}

function trigger(target, key) { // 在 set 拦截函数内调用 trigger 函数触发变化
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
}

四、 分支切换与 cleanup

分支切换的定义:

const data = { ok: true, text: 'hello world' }
const bucket = new WeakMap() // 存储副作用函数的桶

effect(function effectFn() {
   console.log('执行中');
   document.body.innerText = obj.ok ? obj.text : 'not' // 这里的三元表达式就是分支
})

image.png

图一

场景:data.ok 开始是true, 后面改成false。收集的结果始终是图一。所以data.ok后面改成false之后,在修改data.text,依然会触发副作用函数(包括遗留的)执行,如图一,每次都会触发 console.log('执行中');

在修改data.ok为false,理想情况结果是这样的,如下图二。造成上面的情况的原因是,data.ok 改为false的时候,没有重新收集副作用函数,还保留这上次的收集的副作用函数。导致页面上没有data.text了,修改data.text还能触发副作用函数执行。

image.png

图二

  • 解决办法:当副作用函数执行的时候,重新收集依赖集合,要想把一个副作用函数从与他关联的依赖集合中删除,就必须知道哪些依赖集合中包含他。重新设计副作用函数。
let activeEffect;
function effect(fn) {
    const effectFn = () => {
        cleanup(effectFn) // 调用 cleanup 函数完成清除工作
        // 当 effectFn 执行时,将其设置为当前激活的副作用函数
        activeEffect = effectFn
        fn()
    }
    effectFn.deps = [] // 存储所有与当前副作用函数相关联的依赖集合
    effectFn() // 执行副作用函数
}

effectFn.deps 数组中的依赖集合是如何收集的呢?

function track(target, key) {
    if (!activeEffect) return // 没有 activeEffect,直接 return
    let depsMap = bucket.get(target)
    if (!depsMap) {
        bucket.set(target, (depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if (!deps) {
        depsMap.set(key, (deps = new Set()))
    }
    deps.add(activeEffect)
    activeEffect.deps.push(deps) // 新增  将其添加到 activeEffect.deps 数组中
}

有了 effectFn.deps, 保存所有与当前副作用相关联的依赖集合,接下来就能删除了。

function cleanup(effectFn) {
    for (let i = 0; i < effectFn.deps.length; i++) { // 遍历 effectFn.deps 数组
        const deps = effectFn.deps[i] // deps 是依赖集合
        deps.delete(effectFn) // 将 effectFn 从依赖集合中移除
    }
    effectFn.deps.length = 0 // 最后需要重置 effectFn.deps 数组
}

执行上面的,会发现无限循环。

function trigger(target, key) { // 在 set 拦截函数内调用 trigger 函数触发变化
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key) 
    effects && effects.forEach(fn => fn())  // 问题出在这句代码
}

在trigger函数内部,我们遍历 effects 集合,它是一个 Set 集合,里面存储着副作用函数。当副作用函数执行时,会调用 cleanup 进行清除,实际上就是从 effects 集合中将当前执行的副作用函数剔除,但是副作用函数的执行会导致其重新被收集到集合 中,而此时对于 effects 集合的遍历仍在进行。这个行为可以用如下 简短的代码来表达:

const set = new Set([1])
set.forEach(item => {
    set.delete(1)
    set.add(1)
    console.log('遍历中')
})

// 解决办法:
 const set = new Set([1])
 const newSet = new Set(set)
 newSet.forEach(item => {
    set.delete(1)
    set.add(1)
    console.log('遍历中')
 })
function trigger(target, key) { // 在 set 拦截函数内调用 trigger 函数触发变化
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    const effectsToRun = new Set(effects) // 新增
    effectsToRun.forEach(effectFn => effectFn()) // 新增    
    // effects && effects.forEach(fn => fn())  // 问题出在这句代码
}

五、完整代码

const data = { ok: true, text: 'hello world' }
const bucket = new WeakMap() // 存储副作用函数的桶
let activeEffect;
 const obj = new Proxy(data, {
    get(target, key) {
        track(target, key) // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
        return target[key] // 返回属性值
    },

    set(target, key, newVal) {
        target[key] = newVal // 设置属性值
        trigger(target, key) // 把副作用函数从桶里取出并执行
    }
})
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
    if (!activeEffect) return // 没有 activeEffect,直接 return
    let depsMap = bucket.get(target)
    if (!depsMap) {
        bucket.set(target, (depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if (!deps) {
        depsMap.set(key, (deps = new Set()))
    }
    deps.add(activeEffect)
    activeEffect.deps.push(deps) // 新增 // deps 就是一个与当前副作用函数存在联系的依赖集合 // 将其添加到 activeEffect.deps 数组中
}

function trigger(target, key) { // 在 set 拦截函数内调用 trigger 函数触发变化
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    const effectsToRun = new Set(effects) // 新增
    effectsToRun.forEach(effectFn => effectFn()) // 新增    
    // effects && effects.forEach(fn => fn())  // 问题出在这句代码
}

// function effect(fn) {
//     activeEffect = fn;
//     fn();
// }

function effect(fn) {
    const effectFn = () => {
        cleanup(effectFn) // 调用 cleanup 函数完成清除工作
        // 当 effectFn 执行时,将其设置为当前激活的副作用函数
        activeEffect = effectFn
        fn()
    }
    effectFn.deps = [] // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
    effectFn() // 执行副作用函数
}

function cleanup(effectFn) {
    for (let i = 0; i < effectFn.deps.length; i++) { // 遍历 effectFn.deps 数组
        const deps = effectFn.deps[i] // deps 是依赖集合
        deps.delete(effectFn) // 将 effectFn 从依赖集合中移除
    }
    effectFn.deps.length = 0 // 最后需要重置 effectFn.deps 数组
}

effect(function effectFn() {
   console.log('执行中');
   document.body.innerText = obj.ok ? obj.text : 'not'
})


// const set = new Set([1])
// set.forEach(item => {
//     set.delete(1)
//     set.add(1)
//     console.log('遍历中')
// })


//  const set = new Set([1])
//  const newSet = new Set(set)
//  newSet.forEach(item => {
//     set.delete(1)
//     set.add(1)
//     console.log('遍历中')
//  }