本项目用于学习和梳理响应式机制,也记录实现过程中的思考;目标是最终能在公司项目里直接落地,因此设计成零依赖的单文件实现,方便低成本注入。
下面先用一个覆盖常见交互场景的示例来说明目标。我们声明两个信号:userData(包含嵌套表的用户资料)与 collections(可能增删的物品列表),然后用多个 useEffect 订阅不同字段或派生表达式。无论哪段数据被改写,所有触及它们的副作用都会自动重新执行。
local userData = reactable({
name = "A",
age = 18,
grades = {
math = { 18, 19, 40 },
english = { 22, 21, 24, 25 },
},
})
local collections = reactable ({
{
id = 1,
count = 2
},
{
id = 3,
count = 4
},
{
id = 7968,
count = 1
}
})
useEffect(function()
print(userData.name)
end)
useEffect(function()
print(userData.grades.math[1])
end)
useEffect(function()
print(#userData.grades.math)
end)
useEffect(function()
print("[profile change]", userData.name)
local total = 0
for _, item in ipairs(collections) do
total += item.count
end
print("[collection total]", total)
end)前三个副作用分别订阅 userData 的基础字段、深层嵌套字段与长度运算;最后一个副作用同时依赖 userData.name 和 collections 聚合结果。对业务代码而言,只需“声明式”读取这些字段即可:代理会在 getter 阶段自动记录依赖,在 setter 阶段触发依赖。于是任意相关字段变化都会驱动所有 useEffect,无需显式回调或事件绑定。
核心诉求可以压缩成一句话:某个字段被写入后,所有依赖它的函数(即数据更改的副作用)应当自动重跑。要做到这一点,无论使用什么样的语法糖,我们都必须在“读/写”两个时机插入钩子,下面通过set,get来指代这两个钩子
进入代理的 set 流程后,系统能够精确知道“哪个字段被写入”。接下来就需要把这个字段映射到所有依赖它的副作用,因此我们必须维护一张依赖表,结构大致是 <fieldKey, effectList>。
第一反应是手动登记:
-- 朴素做法:用户显式声明依赖
subscribe(depTable, fieldKey, effect)但是这种写法既冗长又容易漏记——字段多、effect 也多时,维护会很困难。于是继续思考:既然在 useEffect 内部我们能掌控 effect 的执行,是否可以在 effect 运行期间自动记录它访问过的字段?
一个做法是在执行 effect 时把它暂存到一个全局“当前活跃 effect”指针,而每当该 effect 通过代理去读取字段时,就利用这个指针把 effect 塞进 deps[fieldKey](Vue 的做法)。为了防止 effect 同一次运行多次访问同一字段导致重复添加,需要把 deps[fieldKey] 设计成去重集合(例如用 table 作为哈希集合)。也可以反向记录“effect 依赖了哪些 field”,但字段数量通常远大于 effect 数量,故更常见的方案是在登记 effect。
proxy对象保持是个空表,这样每次对 proxy.field的访问都会走到proxy.__index, 每次写入都会使用proxy.__newindex。
使用metatable带来的读写性能损失相比代理和收集依赖的逻辑相比可以忽略,而带来的好处是不必使用proxy.get("field")和proxy.set("field")这样啰嗦的写法。
local function reactable(data)
local proxy = {}
local raw_data = data
local get = function(_, key)
return raw_data[key]
end
local set = function(_, key, value)
raw_data[key] = value
end
return setmetatable(proxy, {
__index = get,
__newindex = set
})
end
每个 table 里面持有一个索引表 <key, fnList>, 通过 key 访问到 list。
因为 list 里面应该去重,所以 fnList 是一个 Set,<key fnSet>
一种方式是在最开始初始化对象的时候就给所有字段添加好依赖桶,另一种方式是懒加载,get 读取到的时候再添加。 第二种相比于第一种方式,桶的初始化从集中扎堆变成了分散的,而且避免了很多不必要的桶创建。另外使 set 新增字段的时候不需要维护桶。
嵌套表需要“按层级代理”:get 读到 table 时必须立即转换为 reactable,并缓存回来,让后续对子表的读写继续经过 __index/__newindex。否则依赖永远只挂在外层,内层字段的变化不会触发任何 effect。
更麻烦的是整块替换:outer.inner = { ... } 会直接把旧的子代理替换成普通表,导致之前收集的依赖失效。我的做法是在 outer 的 setter 里拦截此类赋值,把新表里的字段逐项用子代理的 setter 方式更新。
这样的更新会有一些问题,会导致依赖了 inner 多个字段的 effect 被反复执行。但是我们可以通过一个全局变量临时禁止更新,然后用一个去重 Set 储存等待调用的 effect, 等这些 setter操作 全部更新完以后,再一次性执行去重后的effect。
伪代码示意:
local shouldUpdate = true
local pendingEffectsSet = {}
local function runEffect(effect)
if shouldUpdate then
effect()
else
pendingEffects[effect] = true
end
end
local function flushPending()
shouldUpdate = true
for effect in pendingEffectSet do
effect()
end
pendingEffectSet = nil
end批量替换时先把 shouldUpdate 设为 false,逐字段执行内部 setter(这些 setter 会调用 runEffect),最终调用 flushPending() 保证每个 effect 只触发一次。这样既能保证依赖重建,又避免了大表替换导致的 N 次重复执行。