Skip to content

实现响应式的测试框架

本节完成 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.ts

reactive 完整测试

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: 2
bash
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'

本节小结

  1. 测试策略 — 按模块组织,覆盖正常流程和边界情况
  2. 边界情况 — 自循环依赖、嵌套 effect、分支切换、链式 computed
  3. 三种调试方式 — vitest 测试(推荐)、Node.js 脚本、浏览器
  4. 完整 API 导出 — 整理 reactivity 模块的公开接口

至此,响应式模块完成!下一节进入虚拟 DOM 的世界。

用心学习,用代码说话 💻