网站有收录但是没排名免费推广的网站
前言
响应式系统(Reactivity System) 是现代前端框架的核心基础架构,实现数据变更到视图更新的自动化映射。本文将采用TDD(测试驱动开发)方式,完整构建一个简化的Vue3响应式系统,核心技术点包括:
- 依赖追踪机制 :建立数据与视图的关联关系
- 分支更新策略 :优化条件渲染场景的性能
- 嵌套函数管理 :支持组件化的副作用层级
1. 环境准备
技术栈
- 测试框架 : Vitest
- DOM环境 : jsdom
- Mock工具 :Vitest内置vi工具集
基础HTML结构
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Vue3响应系统实现</title>
</head>
<body></body>
<!-- 核心响应式系统实现 -->
<script src="reactivity.js"></script>
</html>
2. 基础响应式实现
2.1 需求
通过测试用例定义预期行为:
// reactivity.spec.mjs
import { JSDOM } from 'jsdom'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' let dom // 使用 describe 函数创建一个测试套件,用于组织一组相关的测试
describe('视图响应式更新', () => { // beforeEach 钩子会在每个测试用例运行前执行 beforeEach(async () => { // 启用虚拟定时器(模拟setTimeout、setInterval等) // 这样可以在测试中控制时间流转,避免真实等待vi.useFakeTimers() // 加载HTML文件并创建JSDOM实例 dom = await JSDOM.fromFile('./index.html', { resources: 'usable', // 允许加载外部资源(CSS、图片等) runScripts: 'dangerously', // 允许执行HTML中的脚本(可能有安全风险,但对测试必要) }) // 等待所有资源加载完成 await new Promise((resolve) => (dom.window.onload = resolve)) }) // afterEach钩子会在每个测试用例运行后执行 afterEach(() => { // 重置所有模拟对象和函数 // 确保测试之间完全隔离,不会互相影响vi.restoreAllMocks() // 关闭JSDOM创建的window对象 dom.window.close() }) // 定义单个测试用例,验证视图的响应式更新行为 test('应正确渲染和更新内容', async () => { // 验证初始渲染 expect(dom.window.document.body.textContent?.trim()).toBe('hello world') // 使用 vi.advanceTimersByTime 推进虚拟时间 vi.advanceTimersByTime(1000) // 验证更新后内容 expect(dom.window.document.body.textContent?.trim()).toBe('hello vue3') }, 5000) // 设置超时时间5秒
})
2.2 实现
首先执行测试:npm vitest run
,错误如下。
核心原理
响应系统通过Proxy拦截器实现数据访问追踪,结合副作用函数(effect function) 注册机制实现视图更新。当读取数据时收集依赖,修改数据时触发视图更新。
- Proxy代理机制:Proxy是ES2015的特性,可以拦截对象的基本操作(intercept operations)包括 get/set 两种操作。
- 依赖收集原理:读取数据(get)时收集副作用函数,修改数据(set)时触发收集的函数
由此我们可以实现一个最简单的版本:将下面内容保存到 reactivity.js
中。
// 存储副作用函数
const bucket = new Set()// 原始数据
const data = { text: 'hello world' }// 原始数据代理
const obj = new Proxy(data, {get(target, key) {bucket.add(effect) // 读取数据时收集依赖该数据的副作用函数return target[key]},set(target, key, value) {target[key] = valuebucket.forEach((fn) => fn()) // 设置数据时调用所有绑定的副作用函数return true},
})function effect() { document.body.textContent = obj.text
}effect()
setTimeout(() => {obj.text = 'hello vue3'
}, 1000)
技术细节 :
- 响应式绑定过程 :
- 首次执行
effect()
函数触发obj.text
的get操作 - get操作将
effect
函数添加到bucket
集合 - 数据变化时触发set操作,遍历执行
bucket
中所有函数
- 首次执行
- 代理拦截流程 :
再次运行测试:
✅ 测试验证:初始渲染"hello world",1秒后变为"hello vue3"
2.3 重构
先来看看之前的基础实现缺陷:
- 硬编码的
effect
函数不够灵活 - 全局只能注册一个副作用函数
由此我们进行重构:
完整代码如下:
// 存储副作用函数
const bucket = new Set() let activeEffect // 原始数据
const data = { text: 'hello world' } // 原始数据代理
const obj = new Proxy(data, { get(target, key) { // 读取数据时收集依赖该数据的副作用函数 if (activeEffect) { bucket.add(activeEffect) } return target[key] }, set(target, key, value) { target[key] = value bucket.forEach((fn) => fn()) // 设置数据时调用所有绑定的副作用函数 return true },
}) // 注册副作用函数并执行
function effect(fn) { activeEffect = fn fn()
} effect(() => { document.body.textContent = obj.text
}) setTimeout(() => { obj.text = 'hello vue3'
}, 1000)
技术优化点:
- 解耦设计:通过
activeEffect
动态引用当前副作用函数 - 函数式编程:将副作用函数作为参数传入,支持任意函数的响应式绑定
- 首次执行机制:
effect()
内立即执行函数,确保初始依赖收集
重构后再次运行测试,保证功能无异常。
为了方便后续测试代码的编写,我们还需要重构下之前的测试:
注意 我们还需要把
obj
暴露到window
对象上,方便测试的编写。
// 定义单个测试用例,验证视图的响应式更新行为
test('应正确渲染和更新内容', async () => { const { effect, obj, document } = dom.window effect(() => { document.body.textContent = obj.text }) setTimeout(() => { obj.text = 'hello vue3' }, 1000) // 验证初始渲染 expect(dom.window.document.body.textContent?.trim()).toBe('hello world') // 使用 vi.advanceTimersByTime 推进虚拟时间 vi.advanceTimersByTime(1000) // 验证更新后内容 expect(dom.window.document.body.textContent?.trim()).toBe('hello vue3')
}, 5000) // 设置超时时间5秒
3. 隔离属性的响应关系
3.1 需求
早期实现将所有副作用存入同一Set,导致所有属性变更都会触发更新。
由此我们先定义新的需求:
test('应隔离不同属性的响应', async () => { const { effect, obj, document } = dom.window const effectFn = vi.fn(() => { console.log('effect run') document.body.textContent = obj.text }) effect(effectFn) setTimeout(() => { obj.noExist = Date.now() // 无关属性的变更,不应该触发 effectFn 执行 }, 1000) // 验证初始调用 expect(effectFn).toBeCalledTimes(1) // 使用 vi.advanceTimersByTime 推进虚拟时间 vi.advanceTimersByTime(1000) // 不应该重复触发更新 expect(effectFn).toBeCalledTimes(1)
})
运行测试:
3.2 实现
使用 WeakMap(target→Map(key→Set)) 建立依赖关系树:
// 存储副作用函数
// WeakMap 键为原始对象,值为另一个 Map(存储对象属性和副作用函数集合的映射)
const bucket = new WeakMap() // 存储当前正在注册的副作用函数
let activeEffect // 原始数据
const data = { text: 'hello world' } // 创建响应式代理对象
window.obj = new Proxy(data, { // 拦截属性读取操作 get(target, key) { // 没有激活的副作用函数时直接返回属性值 if (!activeEffect) return target[key] // 获取对象对应的依赖映射(Map结构,存储属性与副作用集合的关联) let depsMap = bucket.get(target) if (!depsMap) { depsMap = new Map() bucket.set(target, depsMap) } // 获取属性对应的副作用集合(Set结构,自动去重副作用函数) let deps = depsMap.get(key) if (!deps) { deps = new Set() depsMap.set(key, deps) } // 将当前激活的副作用函数加入集合 deps.add(activeEffect) // 返回原始对象的属性值 return target[key] }, // 拦截属性设置操作 set(target, key, value) { // 设置原始对象属性值 target[key] = value // 获取对象对应的依赖映射 const depsMap = bucket.get(target) if (!depsMap) return true // 获取属性关联的所有副作用函数 const effects = depsMap.get(key) // 遍历并执行所有关联的副作用函数(触发更新) effects?.forEach((fn) => fn()) return true },
}) // 副作用函数注册器
function effect(fn) { // 设置为当前激活的副作用函数 activeEffect = fn // 首次立即执行(触发依赖收集) fn()
}
依赖树技术细节 :
- 三级存储结构 :
WeakMap
: 键为原始对象,保证对象释放后相关依赖自动回收Map
: 值为对象属性与依赖关系的映射Set
: 存储依赖于该属性的副作用函数集合
- 数据结构优势 :
- WeakMap内存管理 :
- 使用弱引用不会阻止垃圾回收
- 当对象不再被引用时,自动释放存储的依赖
- 避免内存泄漏问题
✅ 隔离测试:修改无关属性不会触发更新
3.3 重构
提取封装 trace
和 trigger
函数。
// 创建响应式代理对象
window.obj = new Proxy(data, { // 拦截属性读取操作 get(target, key) { // 追踪并存储依赖当前属性的副作用函数 trace(target, key) // 返回原始对象的属性值 return target[key] }, // 拦截属性设置操作 set(target, key, value) { // 设置原始对象属性值 target[key] = value // 触发依赖当前属性的副作用函数执行 trigger(target, key) return true },
}) // 追踪依赖响应对象更新的副作用函数
function trace(target, key) { // 没有激活的副作用函数时直接返回属性值 if (!activeEffect) return // 获取对象对应的依赖映射(Map结构,存储属性与副作用集合的关联) let depsMap = bucket.get(target) if (!depsMap) { depsMap = new Map() bucket.set(target, depsMap) } // 获取属性对应的副作用集合(Set结构,自动去重副作用函数) let deps = depsMap.get(key) if (!deps) { deps = new Set() depsMap.set(key, deps) } // 将当前激活的副作用函数加入集合 deps.add(activeEffect)
} // 触发响应对象的副作用函数执行
function trigger(target, key) { // 获取对象对应的依赖映射 const depsMap = bucket.get(target) if (!depsMap) return // 获取属性关联的所有副作用函数 const effects = depsMap.get(key) // 遍历并执行所有关联的副作用函数(触发更新) effects?.forEach((fn) => fn())
}
执行测试,避免危险重构。
4. 动态分支的依赖追踪
4.1 需求
分支切换场景
effect(() => {document.body.textContent = obj.ok ? obj.text : ''
})
当obj.ok
由true变为false,应移除对obj.text
的依赖。
分支切换问题分析 :
- 当条件
obj.ok
为true时,副作用函数依赖于obj.ok
和obj.text
- 当
obj.ok
变为false后,理论上不应再依赖obj.text
- 但默认实现中,
obj.text
修改仍会触发执行
编写测试:
test('不应追踪无效分支的响应', async () => { const { effect, obj, document } = dom.window obj.ok = true obj.text = 'ok' const effectFn = vi.fn(() => { console.log('effect run') document.body.textContent = obj.ok ? obj.text : '' }) effect(effectFn) // 第一次执行 effectFn expect(dom.window.document.body.textContent?.trim()).toBe('ok') obj.ok = false // 第二次执行 effectFn obj.text = 'not ok' // 不应该执行 effectFn expect(dom.window.document.body.textContent?.trim()).toBe('') expect(effectFn).toBeCalledTimes(2)
})
运行测试,查看错误:
4.2 实现
解决方案:清理历史绑定 + 重新收集依赖
// 副作用函数注册器
function effect(fn) { const effectFn = () => { // 清理当前副作用函数的历史绑定 cleanup(effectFn) // 设置为当前激活的副作用函数 activeEffect = effectFn // 首次立即执行(触发依赖收集) fn() } // 存储所有与该副作用函数相关联的依赖集合 effectFn.deps = [] effectFn()
}
function cleanup(effectFn) { for (let effects of effectFn.deps) { effects.delete(effectFn) // 从 Set 集合里移除当前 effectFn } effectFn.deps.length = 0 // 重置 effectFn 的依赖,重新收集
}
还需要修改 trace
与 trigger
函数:
完整代码如下:
// 存储副作用函数
// WeakMap 键为原始对象,值为另一个 Map(存储对象属性和副作用函数集合的映射)
const bucket = new WeakMap() // 存储当前正在注册的副作用函数
let activeEffect // 原始数据
const data = { text: 'hello world' } // 创建响应式代理对象
window.obj = new Proxy(data, { // 拦截属性读取操作 get(target, key) { // 追踪并存储依赖当前属性的副作用函数 trace(target, key) // 返回原始对象的属性值 return target[key] }, // 拦截属性设置操作 set(target, key, value) { // 设置原始对象属性值 target[key] = value // 触发依赖当前属性的副作用函数执行 trigger(target, key) return true },
}) // 追踪依赖响应对象更新的副作用函数
function trace(target, key) { // 没有激活的副作用函数时直接返回属性值 if (!activeEffect) return // 获取对象对应的依赖映射(Map结构,存储属性与副作用集合的关联) let depsMap = bucket.get(target) if (!depsMap) { depsMap = new Map() bucket.set(target, depsMap) } // 获取属性对应的副作用集合(Set结构,自动去重副作用函数) let deps = depsMap.get(key) if (!deps) { deps = new Set() depsMap.set(key, deps) } // 将当前激活的副作用函数加入集合 deps.add(activeEffect) // 将 Set 集合(引用)存储到对应的副作用函数的包装器上 activeEffect.deps.push(deps)
} // 触发响应对象的副作用函数执行
function trigger(target, key) { // 获取对象对应的依赖映射 const depsMap = bucket.get(target) if (!depsMap) return // 获取属性关联的所有副作用函数 const effects = depsMap.get(key) const effectsToRun = new Set(effects) // 遍历并执行所有关联的副作用函数(触发更新) effectsToRun?.forEach((fn) => fn())
} function cleanup(effectFn) { for (let effects of effectFn.deps) { effects.delete(effectFn) // 从 Set 集合里移除当前 effectFn } effectFn.deps.length = 0 // 重置 effectFn 的依赖,重新收集
} // 副作用函数注册器
function effect(fn) { const effectFn = () => { // 清理当前副作用函数的历史绑定 cleanup(effectFn) // 设置为当前激活的副作用函数 activeEffect = effectFn // 首次立即执行(触发依赖收集) fn() } // 存储所有与该副作用函数相关联的依赖集合 effectFn.deps = [] effectFn()
}
技术实现细节 :
- 双向依赖关系 :
- 依赖集合(Set)存储副作用函数
- 副作用函数存储其所属的依赖集合
- 建立双向引用便于清理
- 动态依赖收集流程 :
- 每次副作用执行前清除其所有依赖关系
- 执行期间重新收集实际访问的属性
- 自动移除不需要的依赖关系
- 伪代码流程 :
复制开始执行副作用函数:→ 清理历史依赖关系→ 执行用户函数→ 触发get操作时:- 将副作用添加到属性的依赖集合- 将依赖集合添加到副作用的依赖数组 结束执行
✅ 分支测试:切换分支后不再追踪无效属性
4.3 重构
可以执行一个微重构,避免每次都重建 Set
对象。
const effectsToRun = new Set()
// 触发响应对象的副作用函数执行
function trigger(target, key) { // 获取对象对应的依赖映射 const depsMap = bucket.get(target) if (!depsMap) return // 获取属性关联的所有副作用函数 const effects = depsMap.get(key) effectsToRun.clear() effects?.forEach(effect => {effectsToRun.add(effect)}) // 遍历并执行所有关联的副作用函数(触发更新) effectsToRun?.forEach((fn) => fn())
}
5. 嵌套副作用函数的支持
5.1 需求
组件化场景需求
嵌套调用副作用函数时需正确管理依赖关系:
effect(() => {effect(innerEffect) // 内层effecttemp = obj.foo
})
编写测试:
test('应正确追踪嵌套响应', async () => { const { effect, obj } = dom.window obj.foo = true obj.bar = true let temp1, temp2 const innerEffect = vi.fn(() => { console.log('innerEffect run') temp2 = obj.bar }) const outerEffect = vi.fn(() => { console.log('outerEffect run') effect(innerEffect) temp1 = obj.foo }) effect(outerEffect) // 触发 outerEffect 执行,嵌套的 innerEffect 也应该执行 expect(outerEffect).toBeCalledTimes(1) expect(innerEffect).toBeCalledTimes(1) obj.foo = false // 更新 obj.foo,触发 outerEffect 执行,嵌套的 innerEffect 也应该执行 expect(outerEffect).toBeCalledTimes(2) expect(innerEffect).toBeCalledTimes(2) obj.bar = false // 更新 obj.bar,触发 innerEffect 执行 1 次 expect(outerEffect).toBeCalledTimes(2) expect(innerEffect).toBeCalledTimes(3)
})
执行测试:
5.2 实现
可见当修改 obj.foo
时,outerEffect
没有执行,只有 innerEffect
执行了。回顾之前的代码,可以知道问题是出在 activeEffect
指向的 effectFn
在退出 innerEffect
后并没有重新指向 outerEffect
,故没有收集到 outerEffect
的依赖。
由此,我们可以使用栈结构来保存与恢复现场,添加 effectStack
,在执行副作用函数前后进行现场的保存与恢复。
再次执行测试:
这里为什么接着改变 obj.bar
时,innerEffect
多执行了一次呢?
因为我们实际存储在 bucket
中的是 innerEffect
的包装器,每次搜集时都会生成不同的包装器,包装器内部都引用了同一个 innerEffect
函数。故之前 effect(outerEffect)
生成了第一个 innerEffect
包装器,而 obj.foo = false
时生成了第二个 innerEffect
包装器。
由此,要避免每次都生成不同的包装器导致重复执行,我们需要将实际执行的副作用函数与其包装器进行绑定,从而避免重复注册不同的包装器。
最终代码:
const effectStack = [] // 副作用函数注册器
function effect(fn) { let effectFn = fn.__effect__ if (!effectFn) { effectFn = () => { // 清理当前副作用函数的历史绑定 cleanup(effectFn) // 设置为当前激活的副作用函数 activeEffect = effectFn // 执行 fn 之前先把 effectFn 压入栈中 effectStack.push(effectFn) // 首次立即执行(触发依赖收集) fn() // 执行完 fn 再恢复上一个栈信息 effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } fn.__effect__ = effectFn // 存储所有与该副作用函数相关联的依赖集合 effectFn.deps = [] } effectFn()
}
嵌套处理技术细节 :
- 执行栈机制 :
- 使用数组作为执行栈存储上下文
- 进入副作用时压入栈
- 执行完成时弹出栈
- 上下文维护 :
- 单例包装函数 :
- 通过
fn.__effect__
缓存包装函数 - 避免多次包装同一函数产生不同实例
- 确保依赖关系的稳定性
- 通过
测试验证:
6. 避免无限递归
6.1 需求
当副作用函数内同时读写同一属性:
effect(() => obj.count++)
触发get→收集effect→set→触发effect
的死循环。
编写测试:
test('应避免无限递归响应', async () => { const { effect, obj } = dom.window obj.count = 0 const effectFn = vi.fn(() => { obj.count++ }) effect(effectFn) expect(effectFn).toBeCalledTimes(1)
})
执行测试:
6.2 实现
问题分析
当我们在副作用函数里执行 obj.count++
时,首先触发了 obj.count
的 get
方法,调用了 trace
函数,然后又触发了 obj.count
的 set
方法,调用了 trigger
函数,而 trigger
函数又再次调用了 effectFn
函数,从而进入了无限递归,最终栈溢出。
由此,我们的解决方案就是跳过当前活跃的副作用函数:
// 触发响应对象的副作用函数执行
function trigger(target, key) { // 获取对象对应的依赖映射 const depsMap = bucket.get(target) if (!depsMap) return // 获取属性关联的所有副作用函数 const effects = depsMap.get(key) effectsToRun.clear() effects?.forEach((effect) => { if (effect !== activeEffect) { effectsToRun.add(effect) } }) // 遍历并执行所有关联的副作用函数(触发更新) effectsToRun?.forEach((fn) => fn())
}
递归防止机制 :
- 核心原理 :
- 在trigger中检查副作用函数是否是当前活跃的
- 如果正在执行则不触发,避免循环调用
- Set复制优化 :
- 拷贝 Set 避免遍历时修改原集合导致异常
- 保证遍历过程的稳定性
- 执行流程对比 :
未防止递归:执行effect → 读取count(收集依赖) → 设置count(触发依赖) → 再次执行effect → 无限循环 防止递归后:执行effect → 读取count(收集依赖) → 设置count(触发依赖) → 跳过正在执行的effect → 正常结束
✅ 递归测试:
7. 完整实现与应用
7.1 整合后的代码
// reactivity.js
// 存储副作用函数
// WeakMap 键为原始对象,值为另一个 Map(存储对象属性和副作用函数集合的映射)
const bucket = new WeakMap()// 存储当前正在注册的副作用函数
let activeEffect// 原始数据
const data = { text: 'hello world' }// 创建响应式代理对象
window.obj = new Proxy(data, {// 拦截属性读取操作get(target, key) {// 追踪并存储依赖当前属性的副作用函数trace(target, key)// 返回原始对象的属性值return target[key]},// 拦截属性设置操作set(target, key, value) {// 设置原始对象属性值target[key] = value// 触发依赖当前属性的副作用函数执行trigger(target, key)return true},
})// 追踪依赖响应对象更新的副作用函数
function trace(target, key) {// 没有激活的副作用函数时直接返回属性值if (!activeEffect) return// 获取对象对应的依赖映射(Map结构,存储属性与副作用集合的关联)let depsMap = bucket.get(target)if (!depsMap) {depsMap = new Map()bucket.set(target, depsMap)}// 获取属性对应的副作用集合(Set结构,自动去重副作用函数)let deps = depsMap.get(key)if (!deps) {deps = new Set()depsMap.set(key, deps)}// 将当前激活的副作用函数加入集合deps.add(activeEffect)// 将 Set 集合(引用)存储到对应的副作用函数的包装器上activeEffect.deps.push(deps)
}const effectsToRun = new Set()// 触发响应对象的副作用函数执行
function trigger(target, key) {// 获取对象对应的依赖映射const depsMap = bucket.get(target)if (!depsMap) return// 获取属性关联的所有副作用函数const effects = depsMap.get(key)effectsToRun.clear()effects?.forEach((effect) => {if (effect !== activeEffect) {effectsToRun.add(effect)}})// 遍历并执行所有关联的副作用函数(触发更新)effectsToRun?.forEach((fn) => fn())
}function cleanup(effectFn) {for (let effects of effectFn.deps) {effects.delete(effectFn) // 从 Set 集合里移除当前 effectFn}effectFn.deps.length = 0 // 重置 effectFn 的依赖,重新收集
}const effectStack = []// 副作用函数注册器
function effect(fn) {let effectFn = fn.__effect__if (!effectFn) {effectFn = () => {// 清理当前副作用函数的历史绑定cleanup(effectFn)// 设置为当前激活的副作用函数activeEffect = effectFn// 执行 fn 之前先把 effectFn 压入栈中effectStack.push(effectFn)// 首次立即执行(触发依赖收集)fn()// 执行完 fn 再恢复上一个栈信息effectStack.pop()activeEffect = effectStack[effectStack.length - 1]}fn.__effect__ = effectFn// 存储所有与该副作用函数相关联的依赖集合effectFn.deps = []}effectFn()
}
7.2 系统架构全景图
7.3 真实场景应用
// app.js
const { effect, obj } = window// 创建响应式UI
effect(() => {const app = document.getElementById('app')app.innerHTML = `<div>状态管理示例:</div><div>文本内容: ${obj.text}</div><div>计数: ${obj.count}</div><div>状态: ${obj.ok ? '启用' : '禁用'}</div><button onclick="obj.count++">增加计数</button><button onclick="obj.ok=!obj.ok">切换状态</button><input type="text" value="${obj.text}" oninput="obj.text = this.value">`
})
8. 总结与延伸
核心收获
- 响应式原理:通过Proxy拦截操作,结合WeakMap建立依赖关系树
- 依赖管理:动态绑定/解绑副作用函数,提升性能
- 执行控制:利用栈结构实现嵌套effect的正确执行
扩展学习
- WeakMap优化内存:无需手动清除依赖项,GC自动回收
- Vue3响应系统对比:
- 下期预告:
- 调度器:实现 effect 的灵活调度
- 计算属性:基于effect的延迟计算
- Watch API:调度器控制执行时机
延伸阅读:https://vuejs.org/guide/essentials/reactivity-fundamentals.html