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

深入 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 同时实现了两套完整的响应式系统,并通过统一的组件接口让开发者自由选择:

javascript 复制代码
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 分别缓存普通代理、只读代理和浅层代理,确保同一个对象始终返回同一个代理实例:

javascript 复制代码
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,防止原型链上的重复触发:

javascript 复制代码
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

javascript 复制代码
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 都会自动建立依赖关系:

javascript 复制代码
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 内置了循环依赖检测机制,避免无限递归:

javascript 复制代码
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 完成时才统一执行更新:

javascript 复制代码
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++ 这样的代码在两种模式下都能正常工作:

javascript 复制代码
// 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 都能给你一个满意的选择。

相关推荐
gnip4 分钟前
跨域常用解决方案
前端·javascript
用户2986985301411 分钟前
前端如何把 TXT 文本文件转成 Word 文档:React 实践
前端·javascript·react.js
FEF前端团队11 分钟前
03# Claude Code实战:终端里的逻辑引擎
前端·ai编程·claude
FEF前端团队15 分钟前
04# Codex CLI实战:OpenAI的编程代理
前端·chatgpt·ai编程
掘金挖土17 分钟前
前端手摸手跑路之 AI 应用开发(七)
前端·后端
data analyse 45617 分钟前
重复事件怎么去重:按请求ID、用户+时间窗还是会话聚合?
前端·数据分析
亿元程序员18 分钟前
让Codex直接生成PSD难吗?提示词其实很简单
前端
Moment20 分钟前
为什么越来越多开发者开始用 PostgreSQL?
前端·后端·面试
mmsx21 分钟前
MapLibre 实战 13|让比例尺显示 100m 而不是 347.2m:屏幕距离换算与两个易错点
android·前端·app
YHL22 分钟前
🚀 从 SPA 到 Next.js 全栈:一个大前端的 SEO 突围笔记
前端·后端