模块:Meteor
来自「荏苒之境」
此模块的文档可以在模块:Meteor/doc创建
local table_clear = require('table.clear')
local pop = function(t)
local len = #t
local r = t[len]
t[len] = nil
return r
end
local push = function(t, v)
t[#t+1] = v
end
---@class meteor.package
local meteor = {}
---@type table[]
meteor.array_pool = {}
meteor.signal_scope_pool = {}
meteor.scheduler_cache_pool = {}
--#region signal system
---@alias meteor.signal_listener fun(signal_type: any, ...)
---@class meteor.signal_system
---@field listeners { [any]: meteor.signal_listener[] }
local signal_system_mt = {}
signal_system_mt.__index = signal_system_mt
---@return meteor.signal_system
meteor.new_signal_system = function()
return setmetatable({ listeners = {} }, signal_system_mt)
end
meteor.signal_system = meteor.new_signal_system()
---@param listeners? { [any]: meteor.signal_listener }
function signal_system_mt:push_scope(listeners)
local scope = pop(meteor.signal_scope_pool) or {}
scope.listeners = listeners
self[#self+1] = scope
end
---@return any[]
function signal_system_mt:pop_scope()
local i = #self
local top = self[i]
self[i] = nil
return top
end
---@generic T
---@param f fun(): T
---@param listeners? { [any]: meteor.signal_listener }
---@return boolean success, table signals, T|string result-or-error
function signal_system_mt:with_scope(f, listeners)
self:push_scope(listeners)
local succ, res_or_err = pcall(f)
return succ, self:pop_scope(), res_or_err
end
---@generic T
---@param signal_type T
---@param f meteor.signal_listener
function signal_system_mt:register_listener(signal_type, f)
local ls = self.listeners[signal_type]
if ls == nil then
ls = {}
self.listeners[signal_type] = ls
end
ls[#ls+1] = f
end
---@generic T
---@param signal_type T
---@param f meteor.signal_listener
---@return boolean
function signal_system_mt:unregister_listener(signal_type, f)
local ls = self.listeners[signal_type]
if ls == nil then
return false
end
for i = 1, #ls do
if ls[i] == f then
for j = i, #ls do
ls[j] = ls[j+1]
end
if #ls == 0 then
self.listeners[signal_type] = nil
end
return true
end
end
return false
end
---@param signal_type any
---@param ... any
function signal_system_mt:trigger(signal_type, ...)
if signal_type == nil then
error("#1 signal_type cannot be nil")
end
local signal = { signal_type, ... }
local top = self[#self]
if top ~= nil then
top[#top+1] = signal
local ls = top.listeners
if ls ~= nil then
local l = ls[signal_type]
if l ~= nil then
l(signal)
end
end
end
local global_ls = self.listeners[signal_type]
if global_ls then
for i = 1, #global_ls do
global_ls[i](signal)
end
end
end
--#endregion signal system
--#region reactive object
local SIG_REACTIVE_READ = {}
local SIG_REACTIVE_WRITE = {}
local KEY_REACTIVE_VALUE = {}
local KEY_REACTIVE_TAG = {}
local KEY_REACTIVE_DEPS = {}
local KEY_REACTIVE_EXPR = {}
local KEY_REACTIVE_INDEX = {}
local KEY_VALUE = {}
meteor.KEY_VALUE = KEY_VALUE
---@alias meteor meteor.reactive<any>
---@alias meteor.reactive_tag 'value'|'expr'|'lazy'|'table'|table
---@class meteor.reactive<T>: { value: T? } }
local reactive_mt = {}
function reactive_mt:__index(k)
meteor.signal_system:trigger(SIG_REACTIVE_READ, self)
local v = rawget(self, KEY_REACTIVE_VALUE)
if k == "value" then
return v
else
return v[k]
end
end
function reactive_mt:__newindex(k, new)
local sig_sys = meteor.signal_system
local v
if k == "value" then
v = new
rawset(self, KEY_REACTIVE_VALUE, v)
sig_sys:trigger(SIG_REACTIVE_WRITE, self, v)
else
if k == KEY_VALUE then
k = "value"
end
v = rawget(self, KEY_REACTIVE_VALUE)
local prev = v[k]
v[k] = new
sig_sys:trigger(SIG_REACTIVE_WRITE, self, v, k, prev, new)
end
end
---@generic T
---@param initial T?
---@return meteor.reactive<T>
meteor.ref = function(initial)
return setmetatable({ [KEY_REACTIVE_VALUE] = initial }, reactive_mt)
end
---@generic T
---@param ref meteor.reactive<T>
---@return T
meteor.peek_ref_value = function(ref)
return rawget(ref, KEY_REACTIVE_VALUE)
end
---@param ref meteor
---@return meteor[]?
meteor.get_ref_deps = function(ref)
return rawget(ref, KEY_REACTIVE_DEPS)
end
---@param ref meteor
---@return meteor.reactive_tag
meteor.get_ref_tag = function(ref)
return rawget(ref, KEY_REACTIVE_TAG) or 'value'
end
---@generic T
---@param ref meteor.reactive<T>
---@return (fun(): T)?
meteor.get_ref_expr = function(ref)
return rawget(ref, KEY_REACTIVE_EXPR)
end
-- TODO: support return reactive object directly
---@generic T
---@param f fun(): T
---@return meteor[], T
local function collect_reactive(f)
local signal_sys = meteor.signal_system
local succ, scope, res = signal_sys:with_scope(f)
if not succ then
error("error during reactive object collection: "..res)
end
local rs = pop(meteor.array_pool) or {}
for i = 1, #scope do
local sig = scope[i]
if sig[1] ~= SIG_REACTIVE_READ then
signal_sys:trigger(unpack(sig))
else
rs[#rs+1] = sig[2]
end
end
table_clear(scope)
push(meteor.signal_scope_pool, scope)
return rs, res
end
---@generic T
---@param expr fun(): T
---@return meteor.reactive<T>
meteor.compute = function(expr)
local rs, v = collect_reactive(expr)
local r = meteor.ref(v)
rawset(r, KEY_REACTIVE_TAG, 'expr')
rawset(r, KEY_REACTIVE_DEPS, rs)
rawset(r, KEY_REACTIVE_EXPR, expr)
return r
end
---@generic T
---@param expr fun(): T
---@return meteor.reactive<T>
meteor.lazy = function(expr)
local rs, v = collect_reactive(expr)
local r = meteor.ref(v)
rawset(r, KEY_REACTIVE_TAG, 'lazy')
rawset(r, KEY_REACTIVE_DEPS, rs)
rawset(r, KEY_REACTIVE_EXPR, expr)
return r
end
---@generic T: table
---@param t T
---@return meteor.reactive<T>
meteor.table = function(t)
local index_map = {}
local r = meteor.compute(function()
local o = {}
for k, v in pairs(t) do
if getmetatable(v) == reactive_mt then
o[k] = v.value
index_map[v] = k
else
o[k] = v
end
end
return o
end)
rawset(r, KEY_REACTIVE_TAG, 'table')
rawset(r, KEY_REACTIVE_INDEX, index_map)
return r
end
---@generic K, V, U
---@param t table<K, V>
---@param f (fun(v: V, k?: K): meteor.reactive<U>)
---@return meteor.reactive<table<K, U>>
meteor.map = function(t, f)
local exprs = {}
for k, v in pairs(t) do
exprs[k] = meteor.compute(function()
return f(v, k).value
end)
end
local r = meteor.table(exprs)
rawset(r, KEY_REACTIVE_TAG, 'map')
return r
end
---@alias meteor.inspect_config {
--- formatter: (fun(v: any): string),
--- indent_text: string,
---}
---@param t string[]
---@param r meteor
---@param indent integer
---@param config meteor.inspect_config
local function do_inspect(t, r, indent, config)
local indent_text = config.indent_text or ' '
for _ = 1, indent do
t[#t+1] = indent_text
end
t[#t+1] = tostring(meteor.get_ref_tag(r))
t[#t+1] = ': '
t[#t+1] = config.formatter(meteor.peek_ref_value(r))
t[#t+1] = '\n'
local deps = meteor.get_ref_deps(r)
if deps ~= nil then
local indent_plus = indent + 1
for i = 1, #deps do
do_inspect(t, deps[i], indent_plus, config)
end
end
end
---@param r meteor
---@param config meteor.inspect_config
---@return string
meteor.inspect = function(r, config)
local t = {}
do_inspect(t, r, 0, config)
return table.concat(t)
end
--#endregion reactive object
--#region scheduler
---@alias meteor.scheduler.reactive_cache {
--- tag: meteor.reactive_tag,
--- value: any,
--- downstreams: { [meteor]: true },
--- pinned: boolean,
--- version: integer,
--- lazy_dirty: boolean,
--- is_prev: boolean,
---}
---@class meteor.scheduler
---@field caches { [meteor]: meteor.scheduler.reactive_cache }
---@field dirty_seq meteor[]
---@field dirty_seq_upstream meteor[]
---@field version integer
---@field private read_listener meteor.signal_listener
---@field private write_listener meteor.signal_listener
local scheduler_mt = {}
scheduler_mt.__index = scheduler_mt
---@param schd meteor.scheduler
---@param r meteor
---@return meteor.scheduler.reactive_cache
local function register_reactive_to_scheduler(schd, r)
local cache = schd.caches[r]
if cache ~= nil then
return cache
end
cache = pop(meteor.scheduler_cache_pool) or {}
cache.tag = meteor.get_ref_tag(r)
cache.value = meteor.peek_ref_value(r)
cache.downstreams = {}
schd.caches[r] = cache
print("register: "..tostring(cache.value))
local deps = meteor.get_ref_deps(r)
if deps ~= nil then
for i = 1, #deps do
local dep_cache = register_reactive_to_scheduler(schd, deps[i])
dep_cache.downstreams[r] = true
end
end
return cache
end
---@param schd meteor.scheduler
---@param r meteor
local function unregister_reactive_from_scheduler(schd, r)
print("unregister: "..meteor.peek_ref_value(r).name)
local caches = schd.caches
local cache = caches[r]
caches[r] = nil
table_clear(cache)
push(meteor.scheduler_cache_pool, cache)
local deps = meteor.get_ref_deps(r)
if deps ~= nil then
for i = 1, #deps do
unregister_reactive_from_scheduler(schd, deps[i])
end
end
end
---@param schd meteor.scheduler
---@param r meteor
---@param upstream meteor
local function mark_reactive_dirty(schd, r, upstream)
local cache = schd.caches[r]
if cache.tag == 'lazy' then
cache.lazy_dirty = true
else
local d = schd.dirty_seq
local du = schd.dirty_seq_upstream
d[#d+1] = r
du[#du+1] = upstream
end
end
---@param schd meteor.scheduler
---@param t table?
---@param new_t table
---@param initializer fun(cache: meteor.scheduler.reactive_cache, r: meteor, k: any, t: table, schd: meteor.scheduler)
---@param deinitializer fun(cache: meteor.scheduler.reactive_cache, r: meteor, k: any, t: table, schd: meteor.scheduler): boolean
local function generic_refresh_reactive_table(schd, t, new_t, initializer, deinitializer)
local caches = schd.caches
if t ~= nil then
for _, r in pairs(t) do
local cache = caches[r]
if cache then
cache.is_prev = true
end
end
end
for k, r in pairs(new_t) do
if getmetatable(r) == reactive_mt then
local cache = register_reactive_to_scheduler(schd, r)
if cache.is_prev then
cache.is_prev = nil
else
initializer(cache, r, k, new_t, schd)
end
end
end
if t ~= nil then
for k, r in pairs(t) do
local cache = caches[r]
if cache ~= nil and cache.is_prev then
cache.is_prev = nil
if deinitializer(cache, r, k, t, schd) then
unregister_reactive_from_scheduler(schd, r)
end
end
end
end
end
---@param schd meteor.scheduler
---@param r meteor
---@param cache meteor.scheduler.reactive_cache
local function refresh_reactive_expr(schd, r, cache)
local expr = meteor.get_ref_expr(r)
if expr == nil then
error('fatal: expr')
end
local prev_deps = meteor.get_ref_deps(r)
local deps, v = collect_reactive(expr)
rawset(r, KEY_REACTIVE_VALUE, v)
rawset(r, KEY_REACTIVE_DEPS, deps)
cache.value = v
generic_refresh_reactive_table(schd, prev_deps, deps,
function(dep_cache)
dep_cache.downstreams[r] = true
end,
function(dep_cache)
local ds = dep_cache.downstreams
ds[r] = nil
return next(ds) == nil and not cache.pinned
end)
if prev_deps ~= nil then
table_clear(prev_deps)
push(meteor.array_pool, prev_deps)
end
end
---@param schd meteor.scheduler
---@param r meteor
---@param cache meteor.scheduler.reactive_cache
---@param upstream meteor?
---@param version integer
local function refresh_reactive(schd, r, cache, upstream, version)
print('refresh '..cache.tag..' '..tostring(cache.value)..', upstream: '..tostring(upstream and meteor.peek_ref_value(upstream) or 'nil'))
-- refresh reactive expr
local tag = cache.tag
if tag == 'expr' then
if cache.version == version then
print('---> same version!')
return
end
cache.version = version
refresh_reactive_expr(schd, r, cache)
elseif tag == 'table' then
local t = cache.value
local index_map = rawget(r, KEY_REACTIVE_INDEX)
if upstream == nil then
error('upstream cannot be nil when refreshing reactive table')
end
local k = index_map[upstream]
local new = meteor.peek_ref_value(upstream)
if t[k] == new then
print('---> table no change!')
return
end
t[k] = new
end
local caches = schd.caches
for dr in pairs(cache.downstreams) do
refresh_reactive(schd, dr, caches[dr], r, version)
end
end
---@param schd meteor.scheduler
---@return meteor.signal_listener
local function create_read_listener(schd)
local caches = schd.caches
return function(s)
local r = s[2]
local cache = caches[r]
if cache == nil then
return
end
if cache.lazy_dirty then
refresh_reactive(schd, r, cache, nil, schd.version)
for dr in pairs(cache.downstreams) do
local dr_cache = caches[dr]
if dr_cache.tag == 'lazy' then
dr_cache.lazy_dirty = true
else
refresh_reactive(schd, r, cache, nil, schd.version)
end
end
end
end
end
---@param schd meteor.scheduler
---@return meteor.signal_listener
local function create_write_listener(schd)
local caches = schd.caches
return function(s)
local r = s[2]
local cache = caches[r]
if cache == nil then
return
end
local prev = cache.value
local new, k = s[3], s[4]
if k ~= nil and cache.tag == 'table' then
local prev_v = s[5]
local new_v = s[6]
if prev_v == new_v then
return
end
local index_map = rawget(r, KEY_REACTIVE_INDEX)
if getmetatable(prev_v) == reactive_mt then
local prev_cache = caches[prev_v]
local prev_ds = prev_cache.downstreams
prev_ds[r] = nil
if next(prev_ds) == nil then
unregister_reactive_from_scheduler(schd, prev_v)
end
index_map[prev_v] = nil
end
if getmetatable(new_v) == reactive_mt then
local new_cache = register_reactive_to_scheduler(schd, new_v)
new_cache.downstreams[r] = true
new[k] = new_cache.value
index_map[new_v] = k
end
elseif prev ~= new then
if cache.tag == 'table' then
local index_map = rawget(r, KEY_REACTIVE_INDEX)
generic_refresh_reactive_table(schd, prev, new,
function(v_cache, v, vk)
index_map[v] = vk
v_cache.downstreams[r] = true
end,
function(v_cache, v)
index_map[v] = nil
local ds = v_cache.downstreams
ds[r] = nil
return next(ds) == nil
end)
end
cache.value = new
else
return
end
for dr in pairs(cache.downstreams) do
mark_reactive_dirty(schd, dr, r)
end
end
end
---@return meteor.scheduler
meteor.new_scheduler = function()
local t = {
caches = {},
dirty_seq = {},
dirty_seq_upstream = {},
version = 0
}
t.read_listener = create_read_listener(t)
t.write_listener = create_write_listener(t)
setmetatable(t, scheduler_mt)
meteor.signal_system:register_listener(SIG_REACTIVE_READ, t.read_listener)
meteor.signal_system:register_listener(SIG_REACTIVE_WRITE, t.write_listener)
return t
end
---@generic T
---@param expr fun(): T
---@return meteor.reactive<T>
function scheduler_mt:capture(expr)
local r = meteor.compute(expr)
local cache = register_reactive_to_scheduler(self, r)
cache.pinned = true
return r
end
function scheduler_mt:refresh()
local dirty_seq = self.dirty_seq
if #dirty_seq == 0 then
return
end
local version = self.version
self.version = version + 1
local caches = self.caches
local dirty_seq_upstream = self.dirty_seq_upstream
for i = 1, #dirty_seq do
local dr = dirty_seq[i]
local cache = caches[dr]
local upstream = dirty_seq_upstream[i]
refresh_reactive(self, dr, cache, upstream, version)
end
table_clear(dirty_seq)
table_clear(dirty_seq_upstream)
end
function scheduler_mt:dispose()
meteor.signal_system:unregister_listener(SIG_REACTIVE_WRITE, self.write_listener)
end
return meteor