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

55 阅读5分钟

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

作者:Lyt.js Team | 发布时间:2025 年 4 月

响应式系统是现代前端框架的灵魂。Vue 3 用 Proxy,Solid.js 用 Signal,Svelte 5 用 Runes —— 每种方案都有其优势和取舍。Lyt.js 做出了一个大胆的决定:为什么不都支持呢?

响应式系统的两大流派

Proxy 流派:声明式响应式

以 Vue 3 为代表,通过 ES6 Proxy 拦截对象的读写操作,自动追踪依赖和触发更新。开发者只需要声明数据,框架自动处理响应式逻辑。

优势:心智负担低,代码直观,自动深层响应式

劣势:粒度较粗,整个组件重新渲染,需要虚拟 DOM diff 来精确更新

Signal 流派:细粒度响应式

以 Solid.js 为代表,通过 Signal(信号)建立精确的依赖关系。每个 Signal 独立追踪订阅者,更新时只通知相关的订阅者。

优势:极致性能,无虚拟 DOM 开销,精确更新

劣势:需要手动管理 Signal,心智负担较高

Lyt.js 的双模式设计

Lyt.js 同时实现了两套完整的响应式系统,并通过统一的组件接口让开发者自由选择:

import { defineComponent } from '@lytjs/core'

// Proxy 模式(默认)
const ProxyComponent = defineComponent({
  reactivityMode: 'proxy',
  state() {
    return { count: 0, name: 'Lyt.js' }
  },
  template: `<div>{{ count }} - {{ name }}</div>`
})

// Signal 模式
const SignalComponent = defineComponent({
  reactivityMode: 'signal',
  state() {
    return { count: 0, name: 'Lyt.js' }
  },
  template: `<div>{{ count }} - {{ name }}</div>`
})

两种模式使用完全相同的组件 API,模板语法也完全一致。区别只在于底层如何追踪和触发更新。

Proxy 模式深入解析

Lyt.js 的 Proxy 模式实现借鉴了 Vue 3 的设计,但做了多项优化:

三层缓存架构

使用三个 WeakMap 分别缓存普通代理、只读代理和浅层代理,确保同一个对象始终返回同一个代理实例:

const proxyMap = new WeakMap<object, any>()        // 普通响应式
const readonlyMap = new WeakMap<object, any>()     // 只读响应式
const shallowReactiveMap = new WeakMap<object, any>() // 浅层响应式

数组方法拦截

对数组的搜索方法和变异方法分别做了特殊处理:

  • 搜索方法(includes/indexOf/lastIndexOf):追踪每个元素的依赖,确保 arr.includes(item) 能正确触发更新
  • 变异方法(push/pop/shift/splice 等):暂停内部依赖收集,手动触发 length 更新,避免冗余通知

receiver 检查

Proxy 的 get 拦截器会检查 receiver,防止原型链上的重复触发:

get(target, key, receiver) {
  // 如果 receiver 不是代理本身,说明是通过原型链访问的
  if (target === toRaw(receiver)) {
    track(target, key)  // 收集依赖
  }
  const result = Reflect.get(target, key, receiver)
  if (isObject(result)) {
    return reactive(result)  // 深层递归代理
  }
  return result
}

Signal 模式深入解析

Lyt.js 的 Signal 实现参考了 Solid.js 和 Angular Signals,但保持了零依赖的纯净实现。

核心 API

import { signal, computed, effect, batch, untrack } from '@lytjs/reactivity/signal'

// 创建可写信号
const count = signal(0)

// 创建计算信号(只读,惰性求值)
const double = computed(() => count() * 2)

// 创建副作用
const dispose = effect(() => {
  console.log(`count = ${count()}, double = ${double()}`)
})

// 更新信号
count.set(1)   // 输出: count = 1, double = 2
count.update(n => n + 1)  // 输出: count = 2, double = 4

// 批量更新
batch(() => {
  count.set(5)
  count.set(10)
  // effect 只会执行一次
})

// 清理
dispose()

自动依赖追踪

Signal 的依赖追踪通过全局变量 activeSubscriber 实现。当 effect 或 computed 执行时,会将自身设为活跃订阅者,此时读取任何 Signal 都会自动建立依赖关系:

let activeSubscriber: Subscriber | null = null

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)
  }
  return sig
}

循环依赖检测

Computed 内置了循环依赖检测机制,避免无限递归:

export function computed<T>(fn: () => T): ComputedSignal<T> {
  let isComputing = false
  // ...
  const comp = function ComputedGetter(): T {
    if (isDirty) {
      if (isComputing) {
        throw new Error('[lyt:signal] 检测到循环依赖')
      }
      isComputing = true
      // 执行计算...
      isComputing = false
    }
    return cachedValue
  }
  return comp
}

嵌套安全的批量更新

batch 支持嵌套调用,只有最外层 batch 完成时才统一执行更新:

let batchDepth = 0
const pendingNotifications: Set<Subscriber> = new Set()

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

双模式如何统一?

关键在于 defineComponent 中的 reactivityMode 选项。当选择 Signal 模式时,Lyt.js 内部使用 SignalStateProxy 将 Signal 操作包装为类似 Proxy 的访问方式,使得 this.count++ 这样的代码在两种模式下都能正常工作:

// Signal 模式下,this.count 实际调用 signal()
// this.count++ 实际调用 signal.set(signal() + 1)
const state = createSignalStateProxy({
  count: signal(0),
  name: signal('Lyt.js')
})

state.count++  // 工作正常!
console.log(state.count)  // 读取也工作正常!

性能对比

两种模式各有适用场景:

维度Proxy 模式Signal 模式
更新粒度组件级(依赖 VDOM diff)节点级(精确更新)
内存占用较高(Proxy 对象 + VNode 树)较低(无 VNode)
首次渲染正常正常
更新性能中等优秀
学习成本低(自动响应式)低(API 统一)
适用场景大多数场景性能敏感、大数据量

总结

Lyt.js 的双响应式系统不是简单的"两种方案拼凑",而是经过深思熟虑的架构设计。它让开发者在保持 Vue 风格开发体验的同时,能够在需要时切换到 Signal 级别的性能。这种灵活性是其他框架所不具备的。

无论你是 Vue 开发者想要更好的性能,还是 Solid.js 开发者想要更友好的 API,Lyt.js 都能给你一个满意的选择。