深入 Lyt.js 响应式系统:Proxy + Signal 双模式

147 阅读11分钟

深入 Lyt.js 响应式系统:Proxy + Signal 双模式

基于实际源码,逐行拆解 Lyt.js v6.6.0 @lytjs/reactivity 包中 Proxy 与 Signal 两套响应式系统的设计哲学、核心实现与工程权衡。

一、整体架构:为什么需要双模式?

Lyt.js 的 @lytjs/reactivity 包是整个框架的响应式基石,是 L1 核心原语层的核心模块。两套系统共存于同一个包中,导出统一的公共 API:

// Proxy 模式 API
export { reactive, readonly, shallowReactive, toRaw, isReactive, isReadonly }
export { ref, shallowRef, isRef, unref, toRef, toRefs, triggerRef }
export { computed, watch, watchEffect, nextTick }
export { effect, stop, track, trigger }

// Signal 模式 API
export { signal, batch, untrack }
export { useSignal, useSignalState }

设计动机:Proxy 模式提供自动化的深层响应式,上手成本极低,适合大多数业务场景;Signal 模式提供细粒度的节点级更新,在性能敏感和大数据量场景下优势明显。两者共享调度器基础设施,但在依赖追踪机制上完全独立。

二、Proxy 模式:自动化的深层响应式

2.1 三层缓存架构

Proxy 模式为三种不同的代理类型维护了独立的缓存 Map,确保同一个原始对象始终返回同一个代理实例:

// reactive.ts 源码
const proxyMap = new WeakMap<object, unknown>() // 普通 mutable 代理
const readonlyMap = new WeakMap<object, unknown>() // 只读代理
const shallowReactiveMap = new WeakMap<object, unknown>() // 浅层代理

使用 WeakMap 而非 Map 的原因:当原始对象失去外部引用被垃圾回收时,对应的代理缓存也会被自动清除,避免内存泄漏。

创建代理时的缓存检查逻辑(以 reactive 为例):

export function reactive<T extends object>(target: T, options: ReactiveOptions = {}): T {
  if (!isObject(target)) return target
  if ((target as any)[reactiveFlag]) return target // 已是代理,直接返回
  if ((target as any)[readonlyFlag]) return readonly(target) // 被标记只读

  const existingProxy = proxyMap.get(target) // 检查缓存
  if (existingProxy) return existingProxy as T

  const proxy = new Proxy(target, mutableHandlers) as T // 创建新代理
  proxyMap.set(target, proxy) // 写入缓存
  return proxy
}
2.2 三层依赖收集结构

Proxy 模式的核心数据结构是一个三层嵌套的 WeakMap

targetMap: WeakMap<target, Map<key, Set<ReactiveEffect>>>
  • 第一层 WeakMap:以目标对象为 key,value 是该对象所有属性的依赖映射
  • 第二层 Map:以属性名为 key,value 是依赖该属性的所有副作用集合
  • 第三层 Set:存储具体的 ReactiveEffect 实例(自动去重)
// effect.ts 源码
const targetMap = new WeakMap<object, DepsMap>()

export function track(target: object, key: unknown): void {
  if (!shouldTrack || !activeEffect) return

  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }

  let dep = depsMap.get(key)
  if (!dep) {
    dep = new Set()
    depsMap.set(key, dep)
  }

  if (!dep.has(activeEffect)) {
    dep.add(activeEffect)
    activeEffect.deps.add(dep) // 双向引用,便于后续清理
  }
}

双向引用设计:副作用不仅记录在 targetMap 中,副作用自身也通过 deps: Set<Set<ReactiveEffect>> 持有所有依赖集合的引用。这使得每次副作用执行前可以高效地清理旧依赖(cleanupEffect),实现依赖的动态更新。

2.3 Proxy Handler 深度解析

mutableHandlers 拦截了 5 个 trap:

get 拦截器
get(target: object, key: string | symbol, receiver: object): any {
  // 1. 特殊 Symbol 快速路径
  if (key === rawSymbol) return target
  if (key === reactiveFlag) return true

  // 2. 数组方法特殊处理
  if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
    return arrayInstrumentations[key as string]
  }

  // 3. 依赖收集
  track(target, key)

  const res = Reflect.get(target, key, receiver)

  // 4. 基本类型直接返回,对象类型递归代理
  if (!isObject(res)) return res
  if ((target as any)[skipFlag]) return res

  return reactive(res) // 深层响应式的关键
}

关键细节get 拦截器中对嵌套对象执行 reactive(res),这就是"深层响应式"的实现——访问嵌套对象时自动将其包装为代理。配合三层缓存,不会重复创建代理。

set 拦截器与 receiver 检查
set(target: object, key: string | symbol, value: any, receiver: object): boolean {
  const oldValue = (target as any)[key]

  const hadKey =
    Array.isArray(target) && isIntegerKey(key)
      ? Number(key) < target.length
      : hasOwn(target, key)

  const result = Reflect.set(target, key, value, receiver)

  // receiver 检查:防止原型链重复触发
  if (target === (receiver as any)?.[rawSymbol] || target === toRaw(receiver)) {
    if (hadKey) {
      if (hasChanged(value, oldValue)) {
        trigger(target, key, 'set', value)
      }
    } else {
      trigger(target, key, 'add', value)
    }
  }

  return result
}

receiver 检查 是 Proxy 模式中的一个精妙设计。当通过原型链访问属性时(如 obj.__proto__.foo = 1),set 会被触发两次——一次在原型对象上,一次在实例上。通过检查 target === toRaw(receiver),确保只在正确的目标对象上触发更新,避免重复通知。

数组方法拦截

数组是 Proxy 模式中处理最复杂的部分。Lyt.js 将数组方法分为两类:

搜索类方法includesindexOflastIndexOf):这些方法内部会遍历数组元素,必须对每个索引进行 track,否则依赖收集不完整:

['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
  arrayInstrumentations[method] = function(this: any[], ...args: any[]) {
    const arr = toRaw(this)
    for (let i = 0; i < arr.length; i++) {
      track(arr, String(i)) // 追踪每个元素的依赖
    }
    track(arr, 'length') // 追踪 length 依赖
    return (arr as any)[method](...args)
  }
})

变异类方法pushpopshiftunshiftsplicesortreverse):这些方法内部会读取 length 等属性,但我们不需要收集这些内部读取的依赖:

['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => {
  arrayInstrumentations[method] = function(this: any[], ...args: any[]) {
    pauseTracking() // 暂停依赖收集
    const res = (Array.prototype as any)[method].apply(this, args)
    resetTracking() // 恢复依赖收集
    // 手动触发 length 依赖更新
    trigger(toRaw(this), 'length', 'set', toRaw(this).length)
    return res
  }
})

注意:某些数组方法(如 push)内部通过 [[DefineOwnProperty]] 修改 length,不会触发 Proxy 的 set 拦截器,因此需要手动触发 length 依赖。

2.4 ReactiveEffect 与副作用生命周期

ReactiveEffect 是 Proxy 模式副作用的封装类,管理依赖关系和执行生命周期:

export class ReactiveEffect {
  fn: EffectFn
  scheduler?: (effect: ReactiveEffect) => void
  active: boolean = true
  deps: Set<Set<ReactiveEffect>> = new Set()
  id: number

  run(): any {
    if (!this.active) return this.fn()
    if (effectStack.includes(this)) return this.fn() // 防止递归

    try {
      this.beforeRun?.()
      effectStack.push(this)
      activeEffect = this
      cleanupEffect(this) // 每次执行前清理旧依赖
      return this.fn() // 执行过程中触发 track 收集新依赖
    } finally {
      this.afterRun?.()
      effectStack.pop()
      activeEffect = effectStack[effectStack.length - 1] || null
    }
  }

  stop(): void {
    if (this.active) {
      cleanupEffect(this)
      this.onStop?.()
      this.active = false
    }
  }
}

effectStack 副作用栈:用于处理嵌套副作用场景。栈顶始终是当前正在执行的副作用,activeEffect 指向栈顶元素。嵌套执行时,内层 effect 完成后自动恢复外层 effect。

2.5 Ref:基本类型的响应式包装

Proxy 只能拦截对象,无法拦截基本类型(string、number、boolean)。ref 通过一个内部存储对象 + Proxy 拦截 .value 来解决这个问题:

export function ref<T = any>(value: T): Ref<T> {
  if (isRef(value)) return value

  const r = {
    _value: convert(value), // 对象值用 reactive 包装
    _rawValue: value, // 保留原始值用于比较
    __v_isRef: true,
    [refSymbol]: true,
  } as unknown as Ref<T>

  const proxy = new Proxy(r, refHandlers as any) as Ref<T>
  refToRaw.set(proxy as object, r)
  return proxy
}

refHandlers 拦截 get('value') 时调用 track,拦截 set('value') 时调用 trigger,同时使用 Object.is 进行新旧值比较,避免无意义的更新通知。

2.6 Computed:惰性求值 + dirty 标记

Proxy 模式的 computed 基于 ReactiveEffect 实现,核心是 dirty 标记 + scheduler 的配合:

class ComputedRefImpl<T> {
  private _value: T
  private _dirty: boolean = true
  private _effect: ReactiveEffect

  constructor(getter: ComputedGetter<T>, setter?: ComputedSetter<T>) {
    this._effect = new ReactiveEffect(getter, {
      scheduler: () => {
        // 依赖变化时,只标记 dirty,不立即重算
        if (!this._dirty) {
          this._dirty = true
          this.triggerDep() // 通知下游
        }
      },
      lazy: true, // 首次不自动执行
    })
  }

  get value(): T {
    track(this, 'value') // 让外部副作用依赖此计算属性
    if (activeEffect && !this.deps.has(activeEffect)) {
      this.deps.add(activeEffect)
      activeEffect.deps.add(this.deps)
    }
    if (this._dirty) {
      this._value = this._effect.run() as T // 惰性求值
      this._dirty = false
    }
    return this._value
  }
}

惰性求值lazy: true 确保 getter 不会在创建时执行,只有访问 .value 时才真正计算。scheduler 在上游依赖变化时只标记 dirty = true,将实际计算延迟到下次访问。

三、Signal 模式:细粒度的响应式原语

3.1 核心设计哲学

Signal 模式与 Proxy 模式的根本区别在于依赖追踪方式

  • Proxy 模式:通过拦截对象属性访问自动收集依赖(隐式追踪)
  • Signal 模式:通过调用 Signal 函数(getter)显式建立依赖关系

Signal 系统完全独立于 Proxy 系统,不依赖 targetMaptracktrigger 等基础设施,是一个纯函数式的细粒度响应式实现。

3.2 自动依赖追踪机制

Signal 系统通过全局变量 activeSubscriber 实现自动依赖追踪:

// signal.ts 源码
let activeSubscriber: Subscriber | null = null
let isUntracked = false

export function signal<T>(initialValue: T): WritableSignal<T> {
  let value: T = initialValue
  const subscribers = new Set<Subscriber>()

  const sig = function SignalGetter(): T {
    // 在活跃订阅者上下文中调用时,自动建立依赖
    if (activeSubscriber && !isUntracked) {
      subscribers.add(activeSubscriber)
    }
    return value
  } as WritableSignal<T>

  sig.set = function(newValue: T): void {
    if (Object.is(value, newValue)) return // 值未变化则不通知
    value = newValue
    _notifySubscribers(subscribers)
  }

  sig.update = function(fn: (prev: T) => T): void {
    sig.set(fn(value))
  }

  sig.dispose = function(): void {
    subscribers.clear()
  }

  return sig
}

工作原理:当 effectcomputed 执行时,会将自身设为 activeSubscriber。此时如果函数体内调用了某个 Signal(即执行 SignalGetter),该 Signal 就会将 activeSubscriber 加入自己的 subscribers 集合。这种"在执行上下文中自动收集"的机制与 Proxy 模式的 activeEffect 异曲同工,但更加轻量。

3.3 Computed:循环依赖检测

Signal 模式的 computed 实现了完整的循环依赖检测:

export function computed<T>(fn: () => T): ComputedSignal<T> {
  let cachedValue: T
  let isDirty = true
  let isComputing = false
  const dependencies = new Set<Dependency>()
  const subscribers = new Set<Subscriber>()

  const comp = function ComputedGetter(): T {
    if (activeSubscriber && !isUntracked) {
      subscribers.add(activeSubscriber)
    }

    if (isDirty) {
      // 循环依赖检测
      if (isComputing) {
        throw new LytError(
          LytErrorCodes.LYT_REACTIVITY_CIRCULAR_DEPENDENCY,
          'computed 在其自身的计算图中'
        )
      }
      isComputing = true

      // 清除旧依赖,收集新依赖
      for (const dep of dependencies) {
        dep._unsubscribe(comp as unknown as Subscriber)
      }
      dependencies.clear()

      const prevSubscriber = activeSubscriber
      activeSubscriber = comp as unknown as Subscriber
      try {
        cachedValue = fn()
      } finally {
        activeSubscriber = prevSubscriber
        isComputing = false
      }
      isDirty = false
    }

    return cachedValue
  } as ComputedSignal<T>

  // 实现 Subscriber 接口:上游依赖变化时被通知
  const subscriberImpl: Subscriber = {
    _dirty: false,
    notify(): void {
      isDirty = true
      _notifySubscribers(subscribers) // 传播给下游
    },
  }

  comp.notify = subscriberImpl.notify.bind(subscriberImpl)
  comp._dirty = false

  return comp
}

isComputing 标记:当 computed 正在重新计算时,如果其 getter 内部又触发了自身的读取(直接或间接),isComputingtrue,立即抛出循环依赖错误。这比 Proxy 模式的 effectStack.includes(this) 检查更加明确。

3.4 Effect:带清理回调的副作用
export function effect(
  fn: (onCleanup: (cleanup: EffectCleanup) => void) => void
): () => void {
  let cleanupFn: EffectCleanup | null = null
  let isDisposed = false
  const dependencies = new Set<Dependency>()

  const run = (): void => {
    if (isDisposed) return

    if (cleanupFn) { cleanupFn(); cleanupFn = null }

    // 清除旧依赖
    for (const dep of dependencies) {
      dep._unsubscribe(effectSubscriber)
    }
    dependencies.clear()

    // 设为活跃订阅者,收集新依赖
    const prevSubscriber = activeSubscriber
    activeSubscriber = effectSubscriber
    try {
      fn((cleanup: EffectCleanup) => { cleanupFn = cleanup })
    } finally {
      activeSubscriber = prevSubscriber
    }
  }

  const effectSubscriber: Subscriber = {
    _dirty: false,
    notify(): void {
      if (batchDepth > 0) {
        pendingNotifications.add(effectSubscriber) // batch 中延迟执行
      } else {
        run()
      }
    },
  }

  run() // 首次立即执行

  // 返回 dispose 函数
  return (): void => {
    isDisposed = true
    if (cleanupFn) { cleanupFn(); cleanupFn = null }
    for (const dep of dependencies) {
      dep._unsubscribe(effectSubscriber)
    }
    dependencies.clear()
    pendingNotifications.delete(effectSubscriber)
  }
}

onCleanup 回调:Signal 的 effect 接收一个函数参数,该函数的参数是 onCleanup 回调。用户可以在副作用中注册清理逻辑(如清除定时器、取消请求),在下次执行前或 dispose 时自动执行。

3.5 嵌套 Batch 与批量更新
let batchDepth = 0
const pendingNotifications: Set<Subscriber> = new Set()
let isFlushing = false

export function batch(fn: () => void): void {
  batchDepth++
  try {
    fn()
  } finally {
    batchDepth--
    if (batchDepth === 0) {
      _flushPending() // 只有最外层完成才 flush
    }
  }
}

function _flushPending(): void {
  if (isFlushing) return
  isFlushing = true
  try {
    const snapshot = new Set(pendingNotifications) // 快照,防止无限循环
    pendingNotifications.clear()
    for (const subscriber of snapshot) {
      subscriber.notify()
    }
    // flush 过程中可能产生新通知,递归处理
    if (pendingNotifications.size > 0) {
      _flushPending()
    }
  } finally {
    isFlushing = false
  }
}

嵌套 batch 支持:通过 batchDepth 计数器实现。内层 batch 不会触发 flush,只有最外层 batch 完成时才统一执行所有待处理的通知。_flushPending 使用 snapshot 防止 flush 过程中新增通知导致的无限循环。

batchDepth 与 effect.notify 的配合:当 batchDepth > 0 时,effect 的 notify 方法将自身加入 pendingNotifications 队列而非立即执行,从而实现批量更新。

3.6 Untrack:跳过依赖追踪
export function untrack<T>(fn: () => T): T {
  const prevUntracked = isUntracked
  isUntracked = true
  try {
    return fn()
  } finally {
    isUntracked = prevUntracked
  }
}

untrack 在执行函数期间将 isUntracked 设为 true,此时 Signal 的 getter 不会将 activeSubscriber 加入订阅者集合。适用于在 effect 中读取某些值但不希望建立依赖关系的场景。

四、双模式对比:设计差异与工程权衡

4.1 依赖追踪机制对比
维度Proxy 模式Signal 模式
追踪方式隐式(属性访问自动触发)显式(调用 Signal 函数触发)
依赖存储三层 WeakMap 结构每个 Signal 维护 subscribers Set
嵌套对象自动递归代理不需要,Signal 本身就是细粒度
数组处理需要特殊 instrumentation不需要,数组作为普通值处理
内存开销较高(缓存 Map + 依赖 Map)较低(仅 subscribers Set)
4.2 更新粒度对比
维度Proxy 模式Signal 模式
更新单位组件级别节点级别
更新范围整个组件重新渲染仅依赖该 Signal 的节点更新
VNode 创建每次更新创建新 VNode 树不需要 VNode(Vapor 模式)
Diff 开销存在不存在(Vapor 模式)
适用场景大多数业务场景性能敏感场景
4.3 开发体验对比
维度Proxy 模式Signal 模式
上手难度低(声明式,自动响应)中(需要理解 Signal 概念)
代码风格类似 Vue 3类似 Solid.js
样板代码中等
调试难度中等较低(细粒度追踪)
4.4 性能对比
维度Proxy 模式Signal 模式
首次渲染较快略慢(需要建立绑定)
更新性能中等(组件级更新)极高(细粒度更新)
内存占用较高较低
大数据量可能卡顿依然流畅

五、如何选择合适的模式?

推荐使用 Proxy 模式的场景:
  1. 业务逻辑为主的组件:表单、列表展示、普通页面
  2. 团队熟悉 Vue 3 风格:学习成本低,迁移平滑
  3. 快速开发:自动响应式,不需要手动管理依赖
推荐使用 Signal 模式的场景:
  1. 性能敏感组件:实时数据面板、高频更新的图表
  2. 大数据量渲染:长列表、表格、数据可视化
  3. 微前端场景:需要细粒度控制更新范围
混合模式建议:

Lyt.js 支持在同一个应用中混合使用两种模式:

import { reactive, signal } from '@lytjs/reactivity'

// Proxy 模式:适合业务逻辑
const user = reactive({ name: 'Alice', age: 25 })

// Signal 模式:适合高频更新
const counter = signal(0)
const doubleCounter = computed(() => counter() * 2)

这种灵活性让开发者可以根据具体场景选择最优方案,兼顾开发效率和运行性能。

六、在 v6.6.0 中的位置

在 Lyt.js v6.6.0 的 8 层架构中,@lytjs/reactivity 位于 L1 核心原语层,是整个框架的地基:

L0: 基础工具层 (common-*)
  ↓
L1: 核心原语层 (@lytjs/reactivity, @lytjs/vdom, @lytjs/compiler)
  ↓
L2: 渲染引擎层 (@lytjs/renderer, @lytjs/component)
  ↓
L3: 核心运行时层 (@lytjs/core)

所有上层模块(renderer、component、router、store)都依赖 reactivity 包提供的响应式能力。