# 响应式数据与副作用函数

响应系统也是 Vue.js 的重要组成部分。

# 响应式数据与副作用函数

# 副作用函数

副作用函数指的是会产生副作用的函数

//直接或者间接的影响其他函数的执行结果
function effect() {
    document.body.innerText = 'hello vue3'
}

effect 函数执行时,它会设置 body 的文本内容,但除了 effect 函数之外的任何函数都可以读取或设置 body 的文本内容。

// 全局变量
let val = 1

function effect() {
    val = 2 // 修改全局变量,产生副作用
}

一个函数修改了全局变量,这就是一个副作用。

# 响应式数据

响应式数据指的是数据变化时,依赖它的函数都会自动更新。

const obj = {
    text: 'hello world'
}

function effect() {
    // effect 函数的执行会读取 obj.text
    document.body.innerText = obj.text
}

obj.text = 'hello vue3' // 修改 obj.text 的值,同时希望副作用函数会重新执行

# 响应式数据的基本实现

  • 当副作用函数 effect 执行时,会触发字段 obj.text 的读取操作;
  • 当修改 obj.text 的值时,会触发字段 obj.text 的设置操作

当读取字段 obj.text 时,我们可以把副作用函数 effect 存储到一个“桶”里

响应式

接着,当设置 obj.text 时,再把副作用函数 effect 从“桶”里取出并执行即可

# 实现响应式数据

在 ES2015 之前,只能通过 Object.defineProperty 函数实现,这也是 Vue.js 2 所采用的方式。在 ES2015+ 中,我们可以使用代理对象 Proxy 来实现,这也是 Vue.js 3 所采用的方式。

// 存储副作用函数的桶
const bucket = new Set()
// 原始数据
const data = {
    text: 'hello world'
}
// 对原始数据的代理
const obj = new Proxy(data, {
    // 拦截读取操作
    get(target, key) {
        // 将副作用函数 effect 添加到存储副作用函数的桶中
        bucket.add(effect)
        // 返回属性值
        return target[key]
    },
    // 拦截设置操作
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal
        // 把副作用函数从桶里取出并执行
        bucket.forEach(fn => fn())
        // 返回 true 代表设置操作成功
        return true
    }
})

// 副作用函数
function effect() {
    document.body.innerText = obj.text
}
// 执行副作用函数,触发读取
effect()
// 1 秒后修改响应式数据
setTimeout(() => {
    obj.text = 'hello vue3'
}, 1000)

上述只是实现了最基本的响应式数据,实际上还有很多细节需要考虑,简单的实现了响应式数据的基本实现和工作原理。

# 设计一个完善的响应式系统

# 修改副作用函数的封装

effect 函数接收一个参数 fn,即要注册的副作用函数,这样可以用一个函数来注册多个副作用函数。

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

// 原始数据
const data = {
    text: 'hello world'
}

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

const obj = new Proxy(data, {
    get(target, key) {
        // 将 activeEffect 中存储的副作用函数收集到“桶”中
        if (activeEffect) { // 新增
            bucket.add(activeEffect) // 新增
        } // 新增
        return target[key]
    },
    set(target, key, newVal) {
        target[key] = newVal
        bucket.forEach(fn => fn())
        return true
    }
})

effect(
    // 一个匿名的副作用函数
    () => {
        document.body.innerText = obj.text
    }
)

setTimeout(() => {
    obj.text2 = 'hello vue3'
}, 1000)

上述代码中,如果在 setTimeout 中修改了其他的变量值,在匿名函数中并没有读取到,所以不应该触发副作用函数的执行。但是实际上,却执行了,这是不正确的。因为我们并没有在副作用函数和属性之间建立联系。

# 不同属性之间和副作用函数的联系

在副作用函数与被操作的字段之间建立联系即可,这就需要我们重新设计“桶”的数据结构

effect(function effectFn() {
    document.body.innerText = obj.text
})
// 被操作(读取)的代理对象 obj;
// 被操作(读取)的字段名 text;
// 使用 effect 函数注册的副作用函数 effectFn。

那么三者的联系应该如下关系

target
  └── key
       └── effectFn

如果有两个副作用函数同时读取同一个对象的属性值:

 effect(function effectFn1() {
   obj.text
 })
 effect(function effectFn2() {
   obj.text
 })

 target
  └── text
        └── effectFn1
        └── effectFn2

如果一个副作用函数中读取了同一个对象的两个不同属性:

 effect(function effectFn() {
    obj.text1
    obj.text2
 })

 target
    └── text1
          └── effectFn
    └── text2
           └── effectFn

如果在不同的副作用函数中读取了两个不同对象的不同属性:

 effect(function effectFn1() {
  obj1.text1
 })
 effect(function effectFn2() {
  obj2.text2
 })

target1
    └── text1
         └── effectFn1
target2
    └── text2
         └── effectFn2

用树型架构建立起来之后,单个字段之前更新,副作用函数不会被重复执行。

# 实现响应式系统的完整流程

需要更改 SetWeakMap (opens new window),以便存储多个副作用函数。

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

// 原始数据
const data = {
    text: 'hello world'
}
// 对原始数据的代理
const obj = new Proxy(data, {
    // 拦截读取操作
    get(target, key) {

        // 没有 activeEffect,直接 return
        if (!activeEffect) return target[key]
        // 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key -->effects
        let depsMap = bucket.get(target)
        //如果不存在 depsMap,则创建一个新的 Map 并与 target 建立联系
        if (!depsMap) {
            bucket.set(target, (depsMap = new Map()))
        }
        // 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
        // 里面存储着所有与当前 key 相关联的副作用函数:effects
        let deps = depsMap.get(key)
        // 如果 deps 不存在,同样新建一个 Set 并与 key 关联
        if (!deps) {
            depsMap.set(key, (deps = new Set()))
        }
        // 最后将当前激活的副作用函数添加到“桶”里
        deps.add(activeEffect)
        // 返回属性值
        return target[key]
    },
    // 拦截设置操作
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal
        // 把副作用函数从桶里取出并执行
        const depsMap = bucket.get(target)
        if (!depsMap) return
        const effects = depsMap.get(key)
        effects && effects.forEach(fn => fn())
    }
})

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect

function effect(fn) {
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = fn
    // 执行副作用函数
    fn()
}

effect(() => {
    console.log('effect run')
    document.body.innerText = obj.text
})

setTimeout(() => {
    obj.text = 'hello vue3'
}, 1000)

构建数据结构的方式,我们分别使用了WeakMap、Map 和 Set

  • WeakMap 由 target --> Map 构成
  • Map 由 key --> Set 构成。

WeakMap 的键是原始对象 target,WeakMap 的值是一个Map 实例,而 Map 的键是原始对象 target 的 key,Map 的值是一个由副作用函数组成的 Set

响应式

采用 WeakMap 的原因,WeakMap 对 key 是弱引用,不影响垃圾回收器的工作。据这个特性可知,一旦 key 被垃圾回收器回收,那么对应的键和值就访问不到了

const map = new Map();
const weakmap = new WeakMap();
(function() {
    const foo = {
        foo: 1
    };
    const bar = {
        bar: 2
    };
    map.set(foo, 1);
    weakmap.set(bar, 2);
})()

最后把 getset 方法做一些封装处理

const obj = new Proxy(data, {
    // 拦截读取操作
    get(target, key) {
        // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
        track(target, key)
        // 返回属性值
        return target[key]
    },
    // 拦截设置操作
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal
        // 把副作用函数从桶里取出并执行
        trigger(target, key)
    }
})
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
    // 没有 activeEffect,直接 return
    if (!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)
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
}

# 分支切换与 cleanup

# 分支切换的问题

在 effectFn 函数内部存在一个三元表达式,根据字段 obj.ok值的不同会执行不同的代码分支。当字段 obj.ok 的值发生变化时,代码执行的分支会跟着变化,这就是所谓的分支切换。

 const data = {
     ok: true,
     text: 'hello world'
 }
 const obj = new Proxy(data, {
     /* ... */
 })

 effect(function effectFn() {
     document.body.innerText = obj.ok ? obj.text : 'not'
 })

分支切换的副作用函数如下

data
    └── ok
         └── effectFn
    └── text
        └── effectFn
  • 如果 obj.ok 的值从 true 变为 false,那么 obj.text 的值应该就不会被读取。
  • 如果再修改obj.text 的值,effectFn 应该不会再被执行。但是实际上,effectFn 还是会被执行。

# 断开副作用函数与响应式数据之间的关系

我们需要做到每次副作用函数执行前,将其从相关联的依赖集合中移除。

修改 effect 函数, 在内部定义 effectFn 函数,并添加一个 effectFn.deps 到依赖集合中。该属性是数组,用来存储所有包含该副作用函数的依赖项

// 用一个全局变量存储被注册的副作用函数
let activeEffect

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

function track(target, key) {
    // 没有 activeEffect,直接 return
    if (!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 中
    deps.add(activeEffect)
    // deps 就是一个与当前副作用函数存在联系的依赖集合
    // 将其添加到 activeEffect.deps 数组中
    activeEffect.deps.push(deps) // 新增
}

function trigger(target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    // effects && effects.forEach(fn => fn()) // 问题出在这句代码 这里会导致重复执行
    const effectsToRun = new Set(effects) // 新增
    effectsToRun.forEach(effectFn => effectFn()) // 新增
}

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

effect(() => {
    console.log('effect run')
    document.body.innerText = obj.ok ? obj.text : 'not'
})

# 嵌套的 effect 与 effect 栈

嵌套的 effect 指的是一个 effect 函数内部调用了另一个 effect 函数。

effect(function effectFn1() {
    effect(function effectFn2() {
        /* ... */
    })
    /* ... */
})

当组件发生嵌套时,例如 Foo 组件渲染了 Bar 组件

 // Bar 组件
 const Bar = {
     render() {
         /* ... */
     },
 }
 // Foo 组件渲染了 Bar 组件
 const Foo = {
     render() {
         return <Bar / > // jsx 语法
     },
 }

 //相当于

 effect(() => {
     Foo.render()
     // 嵌套
     effect(() => {
         Bar.render()
     })
 })
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect

function effect(fn) {
    const effectFn = () => {
        cleanup(effectFn)
        // 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
        activeEffect = effectFn
        fn()
    }
    // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
    effectFn.deps = []
    // 执行副作用函数
    effectFn()
}

当前的逻辑是不支持嵌套的,用全局变量 activeEffect 来存储通过 effect 函数注册的副作用函数,这意味着同一时刻 activeEffect 所存储的副作用函数只能有一个。

为了解决这个问题,我们需要一个副作用函数栈 effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。

 // 用一个全局变量存储当前激活的 effect 函数
 let activeEffect
 // effect 栈
 const effectStack = [] // 新增

 function effect(fn) {
     const effectFn = () => {
         cleanup(effectFn)
         // 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
         activeEffect = effectFn
         // 在调用副作用函数之前将当前副作用函数压入栈中
         effectStack.push(effectFn) // 新增
         fn()
         // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把还原为之前的值activeEffect 
         effectStack.pop() // 新增
         activeEffect = effectStack[effectStack.length - 1] // 新增
     }
     // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
     effectFn.deps = []
     // 执行副作用函数
     effectFn()
 }

# 避免无限递归循环

const data = {
    foo: 1
}
const obj = new Proxy(data, {
    /*...*/
})
effect(() => obj.foo++)

//  Uncaught RangeError: Maximum call stack size exceeded

在这个语句中,既会读取 obj.foo 的值,又会设置 obj.foo 的值, 在执行中,还没执行完毕,就开始下一次的执行。这样就会导致无限递归循环,最终导致栈溢出。

function trigger(target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    const effectsToRun = new Set()
    effects && effects.forEach(effectFn => {
        // 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
        if (effectFn !== activeEffect) { // 新增
            effectsToRun.add(effectFn)
        }
    })
    effectsToRun.forEach(effectFn => effectFn())
    // effects && effects.forEach(effectFn => effectFn())
}

# 调度执行

当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式

const data = {
    foo: 1
}
const obj = new Proxy(data, {
    /* ... */
})
effect(() => {
    console.log(obj.foo)
})
obj.foo++
console.log('结束了')

// 输出为
// 1
// 2
// 结束了

//需要改动为
// 1
// 结束了
// 2

# 控制执行的时机

effect 函数注册副作用函数时,可以传递第二个参数 options。它是一个对象,其中允许指定scheduler 调度函数,同时在 effect 函数内部我们需要把options 选项挂载到对应的副作用函数上

function effect(fn, options = {}) {
    const effectFn = () => {
        cleanup(effectFn)
        // 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
        activeEffect = effectFn
        // 在调用副作用函数之前将当前副作用函数压栈
        effectStack.push(effectFn)
        fn()
        // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把
        activeEffect 还原为之前的值
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
    }
    // 将 options 挂载到 effectFn 上
    effectFn.options = options // 新增
    // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
    effectFn.deps = []
    // 执行副作用函数
    effectFn()
}

function trigger(target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    const effectsToRun = new Set()
    effects && effects.forEach(effectFn => {
        if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn)
        }
    })
    effectsToRun.forEach(effectFn => {
        // 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
        if (effectFn.options.scheduler) { // 新增
            effectFn.options.scheduler(effectFn) // 新增
        } else {
            // 否则直接执行副作用函数(之前的默认行为)
            effectFn() // 新增
        }
    })
}

const data = {
    foo: 1
}
const obj = new Proxy(data, {
    /* ... */
})
effect(
    () => {
        console.log(obj.foo)
    },
    // options
    {
        // 调度器 scheduler 是一个函数
        scheduler(fn) {
            // 将副作用函数放到宏任务队列中执行
            setTimeout(fn)
        }
    }
)
obj.foo++
console.log('结束了')

使用调度器就可以控制副作用函数的执行时机。

# 控制执行的次数

const data = {
    foo: 1
}
const obj = new Proxy(data, {
    /* ... */
})
effect(() => {
    console.log(obj.foo)
})
obj.foo++
obj.foo++

//输出
// 1
// 2
// 3

上述输出2只是一个过渡状态,我们期望最终结果是3,因为副作用函数只执行一次。

 // 定义一个任务队列
 const jobQueue = new Set()
 // 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
 const p = Promise.resolve()
 // 一个标志代表是否正在刷新队列
 let isFlushing = false

 function flushJob() {
     // 如果队列正在刷新,则什么都不做
     if (isFlushing) return
     // 设置为 true,代表正在刷新
     isFlushing = true
     // 在微任务队列中刷新 jobQueue 队列
     p.then(() => {
         jobQueue.forEach(job => job())
     }).finally(() => {
         // 结束后重置 isFlushing
         isFlushing = false
     })
 }
 effect(() => {
     console.log(obj.foo)
 }, {
     scheduler(fn) {
         // 每次调度时,将副作用函数添加到 jobQueue 队列中
         jobQueue.add(fn)
         // 调用 flushJob 刷新队列
         flushJob()
     }
 })
 obj.foo++
 obj.foo++

使用微任务调度器,将副作用函数添加到 jobQueue 队列中,执行 flushJob 函数, 该函数通过开关 isFlushing 控制队列的刷新,确保副作用函数只执行一次。

Vue.js 中连续多次修改响应式数据但只会触发一次更新,实际上 Vue.js 内部实现了一个更加完善的调度器,思路与上文介绍的相同。