- 状态:已定(2026-09-16;2026-09-17 加 §2.6
observable、§1 命名规则,createResource改名resource、host()改名manifest();2026-09-24 加 §11 兼容承诺,§9 删去version入口与 K0 版本核对,见 ADR-006 §2.11);M1 的实现依据。系统说明见 js-runtime.html,本文只放定义 - 来源:ADR-001(Signal、所有权)、ADR-002(桥入口、事务、错误)、ADR-004(ref + cmd、事件 payload)、ADR-005(组件函数必须同步、
resource、原生 Promise) - 范围:业务可见的 API、它们的精确语义、页面模块契约、以及运行时与 Kotlin 之间的桥入口(内部契约)。JSX 写法如何变成对这些 API 的调用见 jsx-transform.md;产出的 patch 形态见 patch-protocol.md
| 类别 | 导出 | 一句话 |
|---|---|---|
| 响应式 | signal(init) → [get, set] |
状态单元,=== 判等,整体替换 |
memo(fn) → get |
派生值,读取时按需重算 | |
effect(fn) |
副作用,依赖变化后在 flush 时重跑 | |
onCleanup(fn) |
登记清理,随最近的 effect 或结构作用域执行 | |
untrack(fn) |
读取但不订阅 | |
observable(init) → 代理对象 |
对象 / 数组的属性级响应式,直接赋值 | |
unwrap(store) |
取回代理背后的原始数据 | |
| 节点 | h(type, props, ...children) → Node |
建节点 / 调组件,JSX 的目标 |
Fragment |
多个兄弟节点 | |
thunk(fn) |
编译器包动态 prop 用;业务一般不手写 | |
| 结构 | For |
keyed 列表 |
Show |
条件分支 | |
| 命令 | ref() → Ref,ref.cmd(name, args?) |
对节点发一次性动作 |
| 异步 | resource(fetcher) |
同步期建 signal,异步期写 |
setTimeout / clearTimeout |
全局,经宿主定时器实现 | |
| 页面 | pageVisible() |
页面是否可见(K1 visible) |
manifest() |
Kotlin 下发的组件 / 能力清单 | |
| 版本 | VERSION |
包版本,也是运行时与 Kotlin 侧对齐的依据(patch-protocol.md §6) |
| 内部 | internal.{call, query, send, onEmit} |
core 与 tinyui-native 之间的桥契约(§9),业务不用 |
不在 v1:createContext、ErrorBoundary、Suspense、Switch/Match、Index、Portal、lazy、setInterval。页内跨组件共享状态直接用模块顶层的 signal / observable(每页一个引擎,模块作用域就是页面作用域)。
命名规则(2026-09-17 定):状态与异步原语一律无前缀小写(signal / memo / effect / resource / observable),不用 create*、use* 前缀——use* 会被读成 React hooks 的"每次渲染重跑",而组件只跑一次;组件与结构原语首字母大写。observable 原拟名 createStore(Solid),改名因 "store" 留给 tinyui-native 的跨页 KV(业界 store 的主流含义)。
function signal<T>(init: T): [get: () => T, set: (next: T | ((prev: T) => T)) => void]get()在 effect / memo 执行期间被调用即建立订阅;在 handler、回调、untrack里调用只取值set(next):next === 当前值时什么都不做;否则写入并把订阅者标记为待重算。写入立即可见(紧接着get()返回新值),但订阅者不立即重跑set(fn):fn(prev)的返回值作为新值。signal 的值本身不能是函数- 数组 / 对象按引用判等:
set([...list(), x])触发,list().push(x)不触发
function memo<T>(fn: () => T): () => T- 惰性:创建时不算;第一次读取时算并缓存;依赖变化后标记为脏,下一次读取时重算。同一事务里
setCount(1)之后立刻读double()得到 2 - 重算结果
===缓存值时不通知下游 - 订阅 memo 的 effect 在 memo 的依赖变化时被调度,与直接订阅 signal 无差别
function effect(fn: () => void): void- 创建时立即同步跑一次(收集依赖);此后每当任一依赖变化,在本事务的 flush 阶段重跑
- 重跑前先解绑全部旧依赖、执行本 effect 登记的
onCleanup,再重新收集。条件分支切换后不残留过期订阅 - 调度:待重跑的 effect 进 FIFO 队列,flush 按入队顺序跑;跑的过程中新入队的在同一次 flush 内继续跑;已被 dispose 的跳过。同一个 effect 在一次 flush 里被重跑超过 100 次视为更新环,抛 E2
- 必须在同步渲染期内创建:即在
mount、For建行、Show切分支、或另一个 effect 的执行期间。此外(异步回调、handler、.then)调用直接抛错(ADR-001 §3.7) - 归属:创建时登记到当前 owner(根 / 行 / 分支);在另一个 effect 执行期间创建的 effect 归属同一个 owner,不归属外层 effect(effect 不拥有 effect)
function onCleanup(fn: () => void): void- 在 effect 执行期间调用:登记到该 effect,于它下次重跑前和 dispose 时执行
- 其余同步渲染期内调用:登记到当前 owner,于 owner dispose 时执行。写在页面组件函数体里就是"页面卸载"钩子,写在行组件里就是"行被删"钩子
- 渲染期外调用抛错
function untrack<T>(fn: () => T): T执行 fn 期间不建立任何订阅。用于 effect 里"读一下但不想因它重跑"。
function observable<T extends object>(init: T): T
function unwrap<T>(value: T): Tinit必须是纯对象或数组,返回它的 Proxy;传入已是 observable 的对象原样返回。可在任何地方创建,不要求渲染期- 读即订阅,按属性:effect 里读
state.user.name只订阅user对象上的name;Object.keys/for…in/JSON.stringify订阅键集合;"k" in state订阅k这个键(增删它时重跑) - 写即通知,直接赋值:
state.user.name = "x"、state.list.push(x)、splice/sort/length = 0/delete都触发;===同值写入不触发。写入时机与signal相同,在 flush 统一重跑,受同一个更新环检测 - 嵌套的纯对象 / 数组在第一次读到时才被代理,代理按原对象缓存:
state.list[0] === state.list[0]。class 实例、Map/Set/Date按值存放,不深追踪,只有替换引用才触发;冻结对象同样按值 - 存进去的对象被直接持有(不拷贝):绕过 Proxy 改原对象不会触发
- 与
For的配合:each={state.list}时行 accessoritem()返回缓存的代理,行内绑定落到属性级——改一行的一个字段只重跑读了它的绑定,For不重算;push/splice触发一次 reconcile unwrap(value):返回代理背后的原对象,并把嵌套的代理原地换回原对象;发请求体、打日志时用。非代理原样返回- 分工:原始值用
signal;有独立变化字段的对象 / 数组用observable;跨页共享走tinyui-native的store(native-api.md §3);signal<T[]>整体替换仍合法。不做reconcile/produce/ 只读视图(roadmap D 组)
type Node = /* 不透明句柄 */
function h(type: string | Component, props: Props | null, ...children: Child[]): Node
type Child = Node | Node[] | null | undefined | falsetype 为字符串时是内置 / 宿主组件("Text"、"pp.KycCard"),为函数时是业务组件。
内置节点(type 为字符串):分配 id,发 c;逐个 prop:
| prop | 处理 |
|---|---|
ref |
把节点 id 填进 Ref,不过桥 |
on[A-Z]… |
值必须是函数;存进 handler 表,发 ["p", id, name, true] |
thunk(fn) 包过的 |
建 effect 绑定:跑 fn,结果与上次 !== 时发 p(第一次总发);结果是函数 / 对象 / 数组抛 E2 |
| 其余 | 静态值,发一次 p。允许 string / number / boolean / null / undefined(后两者都发 null,恢复默认);函数、对象、数组抛 E2(prop 摊平,ADR-002),典型是 text={fmt} 忘了调用 |
内置 / 宿主节点的 prop 类型集是封闭的,因为它们全部要变成 patch、函数不过桥;这个限制只在这里。
children 展平(数组、Fragment、null / false 忽略)后按顺序发 i。children 在创建后不可变:结构变化只经 For / Show。
业务组件(type 为函数):把 props 里 thunk 包过的项转成 getter 属性、其余原样复制(任何值都合法,含函数、对象、数组:render prop、格式化函数都是普通用法,它们不过桥)、children 放进 props.children,调用一次 type(props),返回值原样作为本节点。组件函数:
- 只跑一次,函数体就是挂载逻辑;没有
useEffect,需要副作用用effect - 读
props.title拿到的是实时值(getter),在 effect 里读会订阅;组件不该把它解构成常量再用 - 必须同步返回一个
Node。返回 Promise(async组件)、数组、null抛 E2;多根用Fragment时只能作为 children,不能作为组件 / 行 / 分支的根 - 不开作用域:组件本身在运行时不留任何东西
handler 签名 (payload: P) => void,P 是该事件 schema 声明的扁平对象(ADR-004 §3.4),没有字段时为 {}。K2 到达时查 handlers[nodeId + ":" + event],查不到静默忽略(节点已删的合法竞态)。handler 抛错走 E1:上报、不回滚、照常 flush。
一个节点同一事件只能有一个 handler;h() 之后不能增删(事件标记是创建期决定的)。
h(Fragment, null, ...children) 返回 Node[],只允许出现在 children 位置。
<For each={items()} key={(it) => it.id}>
{(item, index) => <Row title={item().title} />}
</For>interface ForProps<T> {
each: T[] // 编译器包成 thunk
key: (item: T, index: number) => string | number
children: (item: () => T, index: () => number) => Node
}For自己不是节点,没有 Kotlin 侧对应:它是父节点 children 里的一个可变长槽位,行节点直接插进父节点。运行时给每个内置节点维护槽位表(静态 child 长度 1,For长度 = 行数,Show长度 0 / 1),i/m的 index = 前面槽位长度之和 + 槽内位置,所以For/Show可以和普通兄弟节点混排、一个父节点可以有多个- 因此
For/Show只能直接写在内置节点的 children 里:不能作为组件的返回值、行根、分支根或页面根(这些位置要求恰好一个节点)。需要时外面包一层Box/Column;违反时h()抛 E2 - 在一个 effect 里读
each,对新旧 key 序列做 keyed reconcile,只产出i/m/r(算法与 benchreconcileKeys一致) - 建行:新建行 owner,在其中调
children(item, index),返回值必须是一个Node,插到对应 index。行 owner 由For持有,不挂在For的 effect 之下 - 删行:dispose 行 owner(解绑该行全部 effect、跑 cleanups、删 handler),发一条
["r", 行id] item()是行内的 accessor:同一 key 的元素引用变了(!==)就更新,读它的绑定随之重算,行不重建。index()同理。这是不用observable、"整体替换数组"时仍能做到行级更新的机制- key 重复抛 E2
<Show when={user()} fallback={() => <Text text="loading" />}>
{() => <Text text={user()!.name} />}
</Show>interface ShowProps<T> {
when: T // 编译器包成 thunk
children: () => Node
fallback?: () => Node
}- 同
For,Show是父节点 children 里长度为 0 / 1 的槽位,分支根节点直接插进父节点 - 只在
when的真值性变化时切分支:user()从一个对象换成另一个对象不重建;从对象变null才切到 fallback - 切换 = dispose 旧分支 owner + 发
r,再在新分支 owner 里调分支函数 + 发i。再显示是全新的一套节点与 effect,不复用 - 没有 fallback 且条件为假时该位置为空
function ref(): Ref
interface Ref { cmd(name: string, args?: Record<string, string | number | boolean | null>): void }<LazyColumn ref={list} /> 后 list.cmd("scrollTo", { index: 0 }) 往本事务的 patch 推 ["x", id, "scrollTo", {"index":0}]。语义见 ADR-004 §3.2:一次性、晚于组合、无回执。未绑定节点时调用抛错。
function resource<T>(fetcher: () => Promise<T>): [
data: () => T | undefined,
{ loading: () => boolean; error: () => unknown; refetch: () => void },
]- 创建时(同步渲染期)建三个 signal 并立即调一次
fetcher;loading为 true - Promise 落定时写
data/error、loading置 false。落定发生在 K3 之后的微任务排空期,所以写入与随后的 flush 在同一事务 - 所属 owner 已 dispose 后落定的结果丢弃
- rejection 写进
error(),不抛、不进 E7 refetch()再跑一次fetcher;旧请求的结果若后到,按到达顺序覆盖(v1 不做竞态取消)
数据获取一律走它,不写 async 组件(§3.1)。
setTimeout(fn, ms) / clearTimeout(id) 为全局函数,由运行时经 J3 timer.schedule 实现,id 与 J3 的 cbId 同一空间。回调在 K3 到达的那次事务里执行;回调里创建 effect 抛错(§2.3)。页面卸载即引擎关闭,未触发的定时器随之消失。
// pages/home.tsx
export default function Home(props: HomeProps): Node { … }default导出是一个组件函数,K1mount时在根 owner 里调用一次;返回的节点作为页面根挂到 Kotlin 侧的根容器(id 0)props是navigation.push传来的 JSON 对象,静态,不响应式- 顶层
await只允许等同步就绪的东西(ADR-005),模块求值时仍 pending 由 K0 拒绝 pageVisible()是一个 accessor,K1visible(bool)更新它,可在 effect 里订阅;Kotlin 侧由TinyUIPage跟随 Lifecycle 的 START / STOP 发visible(前后台切换、被其他页面覆盖都算,app-model.md §2)- 卸载:根 owner dispose(跑业务的
onCleanup),不产出 patch,随后引擎关闭
业务不可见,是 tinyui-core 与 compose/ 之间的接口;改动两侧同一 PR。
Kotlin → JS:运行时模块求值时挂 globalThis.__tinyui,Kotlin 在 K0 之后取它一次并持有。
| 入口 | 签名 | 说明 |
|---|---|---|
| K1 | mount(page: Function, propsJson: string, hostJson: string) |
page 是页面模块的 default 导出(Kotlin 以 JsRef 传入);hostJson 为 ADR-003 的类型 / 能力清单 { components: { [type]: { props: string[], events: string[], commands: string[] } }, capabilities: string[] } |
| K1 | unmount() / visible(v: boolean) |
|
| K2 | dispatch(nodeId: number, event: string, payloadJson: string) |
|
| K3 | resolve(cbId: number, resultJson: string) / reject(cbId: number, errorJson: string) |
errorJson 为 { code: string, message: string } |
| K5 | emit(topic: string, payloadJson: string) |
每个 K 入口在 Kotlin 侧是同一次 withEngine 里的两次调用:先调入口函数,再调 flush()(理由见 js-runtime.html §2)。
| 入口 | 签名 | 说明 |
|---|---|---|
| — | flush() |
每个 K 入口之后调一次;mount 之后也要调(首帧 patch 在这里发出) |
JS → Kotlin:Kotlin 在 K0 之前用 registerFunction 注册全局函数(quickjs-kmp 只注册平坦的全局名)。
| 入口 | 全局名 | 签名 | 说明 |
|---|---|---|---|
| J1 | __host_apply |
(patchJson: string) => void |
每次 flush 至多一次;patch 为空时不调 |
| J2 | __host_query |
(name: string, argsJson: string) => string | number | boolean | null |
白名单同步查询;对象结果为 JSON 文本 |
| J3 | __host_call |
(name: string, cbId: number, argsJson: string) => void |
结果经 K3 |
| J4 | __host_send |
(name: string, argsJson: string) => void |
即发即忘 |
| J5 | __host_report |
(kind: "E1", detailJson: string) => void |
运行时捕获但不中断事务的业务错误 { entry, message, stack };console.* 走引擎 logger |
tinyui-native 是这五个全局的类型化封装(http.get = __host_call("http.get", …) 包成 Promise),经 core 的 internal.{call, query, send, onEmit} 调用;业务不直接碰 __host_* 也不碰 internal。internal 留在主入口而不是子路径 tinyui-core/internal:运行时模块按名字注册进引擎的模块表,子路径会成为第三个模块名,要么再注册一个模块、要么被打进 native 的字节码而复制一份 core 的模块状态(pending 表分裂)。
错误分类的运行时侧行为(ADR-002 §3.5):
| 运行时做什么 | |
|---|---|
| E1 handler / 回调抛错 | dispatch / resolve / reject / emit 内 catch → __host_report("E1") → 入口正常返回,Kotlin 照常调 flush() |
| E2 渲染期抛错 | mount() 或 flush() 直接抛出(JsException 带 JS 栈),运行时先清空 patch 缓冲;Kotlin 不再调 flush(),判页面失败 |
E3 reject 到达 |
对应 Promise reject 为 HostError,其 stack 是 J3 调用点(call() 时记下),不是 reject 到达处;无人 catch 走 E7(引擎的 unhandled rejection 回调,Kotlin 侧接) |
E4 __host_query 抛错 |
在 JS 里是普通异常,落入 E1 |
| E7 未处理 rejection | quickjs-kmp onUnhandledRejection → Kotlin PageError("E7"),页面继续 |
Kotlin 侧的统一上报对象 PageError 与栈回映射见 build-chain.md §7。
- ADR-001 §5 写"动态 prop 写成
() => …thunk",本文改为编译器生成thunk(fn)包装、组件侧以 getter 读取;运行时接口仍是"函数型动态 prop",只是多了一层可识别的包装(区分动态 prop 与For/Show的 children 函数) - ADR-002 J 入口写成
__host.apply等方法,本文改为平坦全局名,原因是 quickjs-kmp 的registerFunction只注册全局函数 - ADR-004 的
argsJson在 patch 里是内嵌 JSON 对象,不是字符串(整条消息本来就是 JSON)
运行时随宿主(ADR-006 §2.11):页面用构建时的 tinyui 版本编出,在这个版本及以上的宿主上都要能跑。所以:
运行时公开面只增不删、不改语义。 公开面 = 编译器注入的 h / Fragment / thunk(jsx-transform.md)、§1 列出的 API、内置组件的 schema(components.md)。破坏性修改只能加新名字,或升 major;1.0 之前不允许破坏,真要破坏即发 1.0。升 major 后旧 major 构建的页面被客户端判不兼容(updates.md §1.1),宿主须加 hostVersion。
挡住的改动:内置组件 prop 改名或改义;删除已公开的 API;改编译器注入函数的调用约定。§9 的桥入口是运行时与 Kotlin 之间的内部契约,两侧同一 PR 改、随同一个库版本发,不在此列。