深入 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 将数组方法分为两类:
搜索类方法(includes、indexOf、lastIndexOf):这些方法内部会遍历数组元素,必须对每个索引进行 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)
}
})
变异类方法(push、pop、shift、unshift、splice、sort、reverse):这些方法内部会读取 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 系统,不依赖 targetMap、track、trigger 等基础设施,是一个纯函数式的细粒度响应式实现。
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
}
工作原理:当 effect 或 computed 执行时,会将自身设为 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 内部又触发了自身的读取(直接或间接),isComputing 为 true,立即抛出循环依赖错误。这比 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 模式的场景:
- 业务逻辑为主的组件:表单、列表展示、普通页面
- 团队熟悉 Vue 3 风格:学习成本低,迁移平滑
- 快速开发:自动响应式,不需要手动管理依赖
推荐使用 Signal 模式的场景:
- 性能敏感组件:实时数据面板、高频更新的图表
- 大数据量渲染:长列表、表格、数据可视化
- 微前端场景:需要细粒度控制更新范围
混合模式建议:
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 包提供的响应式能力。