主题
实现响应式的测试框架
本节完成 reactivity 模块的完整测试,并搭建调试环境
测试策略
响应式模块是纯逻辑模块,不依赖 DOM 环境,非常适合单元测试。我们使用 vitest 构建完整的测试套件。
测试目录结构
packages/reactivity/
├── src/
│ ├── reactive.ts
│ ├── effect.ts
│ ├── ref.ts
│ ├── computed.ts
│ └── index.ts
└── __tests__/
├── reactive.spec.ts
├── effect.spec.ts
├── ref.spec.ts
├── computed.spec.ts
├── readonly.spec.ts
└── watch.spec.tsreactive 完整测试
ts
import { describe, it, expect } from 'vitest'
import { reactive, isReactive, toRaw } from '../src'
// reactive 模块的核心功能测试
describe('reactivity/reactive', () => {
// 测试:普通对象的响应式代理基本行为
it('Object', () => {
const original = { foo: 1 }
const observed = reactive(original) // 创建响应式代理
expect(observed).not.toBe(original) // 代理对象与原始对象不是同一引用
expect(isReactive(observed)).toBe(true) // 代理对象应被识别为 reactive
expect(isReactive(original)).toBe(false) // 原始对象不应被识别为 reactive
expect(observed.foo).toBe(1) // 通过代理可以正常读取属性值
expect('foo' in observed).toBe(true) // in 操作符正常工作
expect(Object.keys(observed)).toEqual(['foo']) // Object.keys 正常枚举属性
})
// 测试:嵌套对象和数组中的元素也应自动变为响应式(深层代理)
it('nested reactives', () => {
const original = {
nested: { foo: 1 },
array: [{ bar: 2 }],
}
const observed = reactive(original)
expect(isReactive(observed.nested)).toBe(true) // 嵌套对象是响应式的
expect(isReactive(observed.array)).toBe(true) // 数组是响应式的
expect(isReactive(observed.array[0])).toBe(true) // 数组中的对象元素也是响应式的
})
// 测试:对同一原始对象多次调用 reactive 应返回同一个 Proxy(缓存机制)
it('observing already observed value should return same Proxy', () => {
const original = { foo: 1 }
const observed = reactive(original)
const observed2 = reactive(original) // 再次对同一原始对象调用 reactive
expect(observed).toBe(observed2) // 应返回相同的 Proxy 实例(从 reactiveMap 缓存获取)
})
// 测试:对已有的 Proxy 对象再次调用 reactive,应直接返回该 Proxy 本身
it('observing the same value multiple times should return same Proxy', () => {
const original = { foo: 1 }
const observed = reactive(original)
const observed2 = reactive(observed) // 传入的是已有的 Proxy
expect(observed).toBe(observed2) // 不会重复包装,直接返回
})
// 测试:toRaw 应能获取代理对象对应的原始对象
it('toRaw', () => {
const original = { foo: 1 }
const observed = reactive(original)
expect(toRaw(observed)).toBe(original) // 从代理中提取出原始对象
expect(toRaw(original)).toBe(original) // 对非代理对象调用 toRaw 返回自身
})
})effect 边界情况测试
ts
import { describe, it, expect, vi } from 'vitest'
import { reactive, effect, stop } from '../src'
// effect 的边界情况和高级特性测试
describe('reactivity/effect', () => {
// 测试:一个 effect 可以同时追踪多个响应式属性
it('should observe multiple properties', () => {
const obj = reactive({ a: 1, b: 2 })
let dummy: number
effect(() => {
dummy = obj.a + obj.b // effect 中同时访问 a 和 b,两个属性都会被追踪
})
expect(dummy!).toBe(3) // 初始值 1 + 2 = 3
obj.a = 10 // 修改 a,触发 effect 重新执行
expect(dummy!).toBe(12) // 10 + 2 = 12
})
// 测试:同一个属性可以被多个 effect 追踪,修改时所有 effect 都应执行
it('should handle multiple effects on same property', () => {
const obj = reactive({ a: 1 })
let dummy1: number, dummy2: number
effect(() => { dummy1 = obj.a }) // 第一个 effect 追踪 obj.a
effect(() => { dummy2 = obj.a * 2 }) // 第二个 effect 也追踪 obj.a
expect(dummy1!).toBe(1)
expect(dummy2!).toBe(2)
obj.a = 5 // 修改 a,两个 effect 都应重新执行
expect(dummy1!).toBe(5)
expect(dummy2!).toBe(10)
})
// 测试:effect 中自增操作(同时读写同一属性)不会导致无限循环
it('should avoid infinite loops with self-mutation', () => {
const obj = reactive({ count: 0 })
effect(() => {
// obj.count++ 会先读取 count(触发 track)再写入(触发 trigger)
// 响应式系统需要检测"当前正在执行的 effect"来避免无限递归
obj.count++
})
expect(obj.count).toBe(1) // effect 执行一次,0 + 1 = 1
obj.count = 10 // 外部修改触发 effect 重新执行
expect(obj.count).toBe(11) // effect 再次执行,10 + 1 = 11(不会无限循环)
})
// 测试:支持 effect 嵌套,内外层 effect 的依赖收集应互不干扰
it('should allow nested effects', () => {
const nums = reactive({ num1: 0, num2: 1, num3: 2 })
const dummy: Record<string, number> = {}
// 外层 effect 追踪 num1 和 num3
effect(() => {
dummy.num1 = nums.num1
// 内层 effect 追踪 num2(嵌套 effect 需要正确管理 activeEffect 栈)
effect(() => {
dummy.num2 = nums.num2
})
dummy.num3 = nums.num3
})
expect(dummy).toEqual({ num1: 0, num2: 1, num3: 2 })
nums.num1 = 3 // 修改 num1 应触发外层 effect
expect(dummy).toEqual({ num1: 3, num2: 1, num3: 2 })
nums.num2 = 4 // 修改 num2 应只触发内层 effect
expect(dummy).toEqual({ num1: 3, num2: 4, num3: 2 })
})
// 测试:scheduler 选项可以自定义 effect 触发时的行为
it('scheduler', () => {
let dummy: number
let run = false
const obj = reactive({ foo: 1 })
// scheduler 是一个自定义调度函数,当依赖变化时会调用它而不是直接重新执行 effect
const scheduler = vi.fn(() => {
run = true // 标记 scheduler 被调用了
})
// 第二个参数传入 options,包含 scheduler
effect(() => { dummy = obj.foo }, { scheduler })
expect(dummy!).toBe(1) // effect 首次执行时正常运行(不走 scheduler)
expect(run).toBe(false) // scheduler 还未被调用
obj.foo++ // 依赖变化,此时走 scheduler 而非重新执行 effect
expect(dummy!).toBe(1) // effect 没有重新执行,dummy 仍为 1
expect(run).toBe(true) // scheduler 被调用了
})
// 测试:stop 可以停止 effect 的响应式追踪
it('stop', () => {
let dummy: number
const obj = reactive({ prop: 1 })
const runner = effect(() => {
dummy = obj.prop // effect 追踪 obj.prop
})
obj.prop = 2
expect(dummy!).toBe(2) // 正常响应变化
stop(runner) // 停止 effect 的响应式追踪
obj.prop = 3
expect(dummy!).toBe(2) // stop 后不再响应变化,dummy 仍为 2
// 测试:stop 后仍可手动调用 runner 执行 effect
runner()
expect(dummy!).toBe(3) // 手动调用后 dummy 更新为当前值 3
})
})computed 缓存与链式测试
ts
import { describe, it, expect, vi } from 'vitest'
import { reactive, computed, effect } from '../src'
// computed 的核心特性测试:惰性求值、缓存、与 effect 联动
describe('reactivity/computed', () => {
// 测试:computed 的惰性求值和缓存机制
it('should cache value', () => {
const obj = reactive({ foo: 1 })
const getter = vi.fn(() => obj.foo) // 用 mock 函数追踪 getter 调用次数
const cValue = computed(getter) // 创建 computed
// 还未访问 .value,getter 不应被调用(惰性求值)
expect(getter).not.toHaveBeenCalled()
// 首次访问 .value 时触发 getter 计算
expect(cValue.value).toBe(1)
expect(getter).toHaveBeenCalledTimes(1)
// 多次访问使用缓存,getter 不会被重复调用
cValue.value
cValue.value
expect(getter).toHaveBeenCalledTimes(1) // 仍然只调用了 1 次
// 修改依赖值
obj.foo = 2
// 修改后未访问 .value,getter 不会立即重新计算(惰性)
expect(getter).toHaveBeenCalledTimes(1)
// 再次访问 .value 时才重新计算
expect(cValue.value).toBe(2)
expect(getter).toHaveBeenCalledTimes(2) // 此时调用了 2 次
})
// 测试:computed 在 effect 中使用时,computed 依赖变化能触发外层 effect 更新
it('should trigger effect when used in effect', () => {
const obj = reactive({ foo: 1 })
const cValue = computed(() => obj.foo) // computed 依赖 obj.foo
let dummy: number
effect(() => {
dummy = cValue.value // effect 依赖 computed 的值
})
expect(dummy!).toBe(1)
obj.foo = 2 // 修改 obj.foo → computed 重新计算 → 触发 effect 重新执行
expect(dummy!).toBe(2)
})
// 测试:computed 链式依赖(computed 依赖另一个 computed)
it('should work with chained computeds', () => {
const obj = reactive({ foo: 1 })
const c1 = computed(() => obj.foo) // c1 依赖 obj.foo
const c2 = computed(() => c1.value + 1) // c2 依赖 c1,形成链式依赖
expect(c2.value).toBe(2) // 1 + 1 = 2
obj.foo = 10 // 修改源头 → c1 变为 10 → c2 变为 11
expect(c2.value).toBe(11) // 10 + 1 = 11
})
})调试方式
方式一:vitest 单元测试
最推荐的调试方式。修改代码后运行测试,快速验证:
bash
# 运行 reactivity 包下的所有测试文件
pnpm --filter @mini-vue/reactivity test
# 运行指定的单个测试文件(精确调试某个模块时使用)
npx vitest run packages/reactivity/__tests__/effect.spec.ts
# 以 watch 模式运行测试,文件变化时自动重新执行(开发时推荐)
npx vitest --watch packages/reactivity/方式二:Node.js 脚本
创建一个调试脚本直接运行:
ts
// scripts/debug-reactivity.ts — 用于快速验证响应式行为的调试脚本
import { reactive, effect, ref, computed } from '../packages/reactivity/src'
// 创建响应式对象
const obj = reactive({ count: 0 })
// 创建 effect,首次执行时会打印 "count is: 0"
effect(() => {
console.log('count is:', obj.count) // 每次 count 变化都会重新执行
})
obj.count = 1 // 触发 effect,输出: count is: 1
obj.count = 2 // 再次触发 effect,输出: count is: 2bash
npx tsx scripts/debug-reactivity.ts方式三:浏览器调试
创建一个简单的 HTML 文件加载打包后的模块:
html
<!DOCTYPE html>
<html>
<body>
<div id="app"></div>
<script type="module">
// 引入打包后的响应式模块(ESM 格式)
import { reactive, effect } from './packages/reactivity/dist/reactivity.esm-bundler.js'
// 创建响应式状态对象
const state = reactive({ count: 0 })
// 创建 effect:当 state.count 变化时,自动更新 DOM 内容
effect(() => {
document.getElementById('app').textContent = `Count: ${state.count}`
})
// 每秒自增 count,触发 effect 重新执行,实现自动更新 DOM 的效果
setInterval(() => {
state.count++
}, 1000)
</script>
</body>
</html>响应式模块的 API 导出
至此,响应式模块的完整 API:
ts
// packages/reactivity/src/index.ts — 响应式模块的统一入口,导出所有公开 API
// 从 reactive.ts 导出对象级别的响应式 API
export {
reactive, // 创建深层响应式代理
readonly, // 创建深层只读代理
shallowReactive, // 创建浅层响应式代理(只代理第一层)
shallowReadonly, // 创建浅层只读代理
isReactive, // 判断是否为 reactive 代理
isReadonly, // 判断是否为 readonly 代理
isProxy, // 判断是否为任意响应式代理(reactive 或 readonly)
toRaw, // 获取代理对应的原始对象
markRaw, // 标记对象为永不转换为响应式
} from './reactive'
// 从 ref.ts 导出值类型的响应式 API
export {
ref, // 创建 ref(对象值会自动转为 reactive)
shallowRef, // 创建浅层 ref(对象值不会转为 reactive)
isRef, // 判断是否为 ref
unref, // 获取 ref 的值(如果不是 ref 则返回自身)
proxyRefs, // 自动解包 ref(用于 template 中无需写 .value)
} from './ref'
// 从 computed.ts 导出计算属性 API
export { computed } from './computed'
// 从 effect.ts 导出副作用相关 API
export {
effect, // 创建响应式副作用函数
stop, // 停止 effect 的响应式追踪
ReactiveEffect, // 底层 Effect 类,供高级用法使用
} from './effect'
// 从 watch.ts 导出侦听器 API
export { watch, watchEffect } from './watch'本节小结
- 测试策略 — 按模块组织,覆盖正常流程和边界情况
- 边界情况 — 自循环依赖、嵌套 effect、分支切换、链式 computed
- 三种调试方式 — vitest 测试(推荐)、Node.js 脚本、浏览器
- 完整 API 导出 — 整理 reactivity 模块的公开接口
至此,响应式模块完成!下一节进入虚拟 DOM 的世界。