- 状态:已完成(2026-09-16,PR #2;2026-09-17 加 §7 错误上报与 source map);2026-09-19 修订模块名规则——加包名前缀、去
pages/段,引入tinyui.config.json(ADR-006 §2.10),随热下发 M1 实施,sample 与 TrendingAI 的现有模块名一并改;2026-09-24 按 ADR-006 §2.11 修订:运行时模块改由库构建、随库发布,tinyui build只编页面;2026-09-25 按 ADR-007 加包内 i18n 资源(§8)、SVG 图标(§9)与网络通道名的静态识别(§5.1) - 来源:ADR-005 §4 "语言目标 / 模块" 两行的展开;roadmap A 组"构建链打通"
- 范围:
tinyui-cli的tinyui build、compose/的 K0 加载序列、sample 的接线与 CI。自动 thunk(roadmap C 组)、页面级配置文件不在本期(qjsc-kmp二进制分发已于 2026-09-23 随热下发 M5.2 做完,见 §5);字节码行号回映射见 §7
tinyui build 把每个页面的 TSX 编成一个 ESM 模块字节码;tinyui-core / tinyui-native 各编成一个运行时模块字节码,由库构建时编好、随库发布(ADR-006 §2.11)。每页一个 Runtime 按 ADR-002 K0 的顺序加载。
验收:sample(包名 sample)的 src/pages/home.tsx 经 CLI 产出 pages/home.jsb,Android / iOS 上 App 启动后显示页面 default 导出的返回值,其中含 import.meta.url 的值 tinyui:sample/home——模块名、external、注册顺序三件事一次验完。CI 的 gradle 与 pnpm 两条 job 都经过这条链。
src/pages/home.tsx ──esbuild──▶ dist/pages/home.js + .map ──qjsc-kmp -m -n <pkg>/home──▶ dist/pages/home.jsb
tinyui-core 包入口 ──esbuild──▶ runtime/core.js ──qjsc-kmp -m -n tinyui-core──▶ runtime/core.jsb (`pnpm runtime` 生成 compose 的 RuntimeBytecode.kt,随库发布)
tinyui-native 同上(对它而言 tinyui-core 是 external)
| 步骤 | 做什么 | 不做什么 |
|---|---|---|
| esbuild | TS 类型擦除;JSX → h()(jsxFactory: "h",inject 一个内存模块自动引入 h / Fragment,页面不手写);业务内部相对 import 合并进页面模块;tinyui-core / tinyui-native 在插件 onResolve 里标 external + sideEffects: false,保持裸说明符且未用到时整条 import 被去掉;format: "esm",target: "esnext";输出 source map |
任何语法降级;对运行时模块的解析 |
| qjsc-kmp | 源码模块 → 字节码,-n 给定模块名,--strip-source 保留行号去源码 |
校验模块图(引擎加载时查表) |
页面名即模块名即路由键:<pkg>/<src/pages 下的相对路径>——包 subscription 的 src/pages/home.tsx → subscription/home,src/pages/plans/detail.tsx → subscription/plans/detail,无后缀。包名来自工程根的 tinyui.config.json({ "name", "publicKey", "pages": "src/pages", "defaultLocale", "i18n": "i18n" },后两个见 §8,name 匹配 [a-z0-9-]+,publicKey 见 updates.md §7),是命名空间,所以没有 pages/ 段;一个 App 挂几个包(ADR-006 §2.10),路由键就靠这个前缀不撞。页面路径的每一段限定 [A-Za-z0-9._-]+(tinyui build 拒绝其余的,含中文文件名):它随产物路径进热下发的投递 URL,而 percent-encoding 要在 CLI、服务端、Kotlin 客户端三处各对称实现一次,任一处偏差就是客户端收不到更新且只能发 App 修(updates.md §6.1、§7 同一类风险)。页面内容与组件文案不受影响,只有文件名受限;日后若要放开,改 build 一处把模块名与输出路径解耦即可(files 本就是映射),协议与另外两端不动。运行时模块名固定 tinyui-core、tinyui-native,由库构建并随库发布,不进 tinyui build 的产物。产物目录 pages/**/*.jsb、manifest.json(页面模块清单、每个模块的产物路径 files 与 buildIds,包身份 name / publicKey 与热下发用的 version / createdAt / engine / tinyui / hashes 见 updates.md §1.1;Kotlin 侧路由表的来源,宿主用 BuildManifest.file(name) 定位 .jsb / .js.map,见 app-model.md)。
业务工程的 tsconfig 用 jsx: "react-jsx" + jsxImportSource: "tinyui-core" 做类型检查(jsx-transform.md §4),而 esbuild 会读到这份 tsconfig 并按它产出 import { jsx } from "tinyui-core/jsx-runtime",即使 build() 显式传了 jsx: "transform";引擎里没有这个模块,加载报 module 'tinyui-core/jsx-runtime' is not registered(2026-09-16 CI 实测)。页面构建要传 tsconfigRaw 覆盖 tsconfig 的 jsx 三项。
inject 的 import 不能只写 external 配置项:esbuild 对 external 列表里的模块一律保留 import(当作有副作用),无 JSX 的页面也会 import { h } from "tinyui-core",引擎链接模块时因 core 没有该导出而报 SyntaxError(2026-09-16 Android 模拟器实测)。走插件 onResolve 返回 sideEffects: false 才会被摇掉。
一个页面 = 一个路由单元 = 一个 Runtime(ADR-002)= 一份模块字节码;分发单元是包(一次 build 的全部页面,运行时随宿主,ADR-006)。构建产物按页自包含:页面 import 的业务内部代码(工具函数、业务组件)全部合并进该页模块,两个页面共用的代码在两份字节码里各有一份。
这样定的理由:每页一个 Runtime 且模块缓存挂在 JSContext 上(quickjs-kmp docs/decisions.md"Runtime / Context"条),跨页共享代码在引擎层本来就不存在——即使拆出共享 chunk,每页仍要各注册、各编译一遍,运行时的时间与内存一分不省。重复只花安装包体积,而字节码模块的加载路径(注册 → import 命中表)对页面数是线性的,不需要 chunk 清单这层东西。
实现上"一页一次 build()"是对产物形态的描述,不是对 esbuild 调用次数的要求:所有页面作为 entryPoints 放进一次 build()、splitting: false,每个入口的输出仍各自自包含,且共享解析缓存。
不做页面间或包间的共享模块:包自包含是多包模型的前提(ADR-006 §2.10),共享模块会成为一条独立的分发链。安装包体积成问题时的出口是拆包,不是拆 chunk。
- TS 擦除,不做任何降级:顶层
await、import.meta.url原样保留 - JSX →
h(),工厂名可指定 - 打包:合并业务内部 import;运行时模块不解析
- ESM 单文件输出 + source map
- 以库形式被 CLI 调用
| 候选 | 擦 TS | JSX → h() |
打包 + external | 结论 |
|---|---|---|---|---|
| tsc 单独用 | 是 | 是 | 否,不合并模块图 | 仍需打包器 |
| Babel + Rollup | 插件 | 插件 | 是 | 三四个包加配置层,JS 实现,慢 |
| SWC / oxc 单独用 | 是 | 是 | 否,只是转译器 | 同 tsc |
| Vite | 内部 esbuild + Rollup / rolldown | 是 | 是 | 面向 dev server 与浏览器,对库式逐页调用多一层 |
| rolldown | 是(内置 oxc) | 是 | 是 | 能力对等,Rollup 同形 API;仍在向 1.0 收敛,插件生态迁移期 |
| Bun bundler | 是 | 是 | 是 | 绑定 Bun 运行时,CLI 是 Node 生态的 npm 包 |
| esbuild | 是 | 是 | 是,external 支持通配 |
单一依赖、Go 二进制、API 长期稳定 |
- 需求全在内置能力里:
bundle/external/format/target/jsxFactory/sourcemap六个选项即全部配置,零插件 - 不降级是默认行为:它的定位就是擦除与打包,
esnext下不碰语法,与 ADR-005 "CLI 只做 TS 擦除与 JSX 变换"一致;以转换为核心的工具反而要花精力确认它没改写语法 - 一个依赖、一个二进制:CLI 是业务 App 要安装的包,依赖树越小越好
- 速度:单页毫秒级,watch 与几十页全量构建不需要额外优化
esbuild 的 JSX 变换是内置固定逻辑、不可编程。C 组的自动 thunk 要对 JSX 属性表达式判断后包成 getter,esbuild 做不到。出口:onLoad 插件拦截 .tsx,先跑自有 JSX 变换(TypeScript transformer API 或 oxc),把已是 h() 调用的 JS 交回 esbuild 合并——esbuild 退化为"擦除 + 合并",变换逻辑在自己手里。
rolldown 是真正的备选(Rollup 同形 API、oxc 转译)。CLI 只包一次 build() 调用,切换成本在一个文件内,不需要现在锁定。
字节码编译放 CLI 而不放 Gradle:业务 App 不会有本仓的 build-logic,tinyui build 必须自己出最终产物。qjsc-kmp 二进制查找顺序:--qjsc 参数 → TINYUI_QJSC 环境变量 → npm 包 qjsc-kmp 的预编译版 → PATH。
字节码绑定引擎构建(文件头记上游 commit,quickjs-kmp docs/native-build.md"字节码与宿主工具"),CLI 用的 qjsc-kmp 必须与 App 链接的 quickjs-kmp 同一上游 commit。tinyui-cli 依赖的 qjsc-kmp 通常与 catalog 的 quickjsKmp 同版本,但校验的是上游 commit 而不是版本号:内置运行时由 CLI 的 qjsc-kmp 编出(pnpm runtime:check 保证入库的与现编一致),RuntimeBytecodeTest 核对它的文件头与链接引擎的 QuickJs.upstreamCommit 相等,两边 commit 不同 CI 即失败。所以上游 commit 不变的 quickjs-kmp 版本两边可以不同步:0.10.1 起 catalog 为 0.1.4,qjsc-kmp 仍是 0.1.2(0.1.3、0.1.4 只把 Android .so 改成 16 KB 页面对齐,编译器源码未动,npm 上也没有这两版)。JS 工程的 tinyui-cli 与 App 的 tinyui 之间,只要两边的上游 commit 相同,引擎就对得上;JS 工程的 tinyui-cli 须与目标宿主的 tinyui 下限兼容:同 major 且不高于它(updates.md §1.1、§1.3)。某个 tinyui 版本升级了 quickjsKmp 且上游 commit 随之改变(即 manifest.engine 变了)时,宿主升到它就是换引擎,按 updates.md §4.1 加 hostVersion,下限随之抬到这个版本;只改构建参数或绑定层、上游 commit 不变的升级(如 0.10.1 的 quickjs-kmp 0.1.4)不算换引擎,旧字节码照常被接受,不加。
| 场景 | 来源 |
|---|---|
| 默认 | npm 包 qjsc-kmp:入口包加 @qjsc-kmp/{darwin-arm64,darwin-x64,linux-x64,linux-arm64} 平台包(esbuild 模式,Linux 为 musl 全静态),npm 只装与本机匹配的那个;由 quickjs-kmp 随 tag 经 OIDC 发布 |
| 本地联调 | local.properties 有 quickjs-kmp.dir 时,Gradle 把 <dir>/library/build/native/host-tools/bin/qjsc-kmp 经 --qjsc 传给 CLI,用的是本地源码编出的那份 |
| 其他平台 | Windows 等没有预编译版的平台用 --qjsc / TINYUI_QJSC 指向自己编的那份 |
热下发发布前要核对"这个包用到的宿主能力、网络通道与宿主组件,目标宿主版本都提供"(updates.md §1.3),所以 tinyui build 必须能从页面代码里找全这些名字,四条硬规则,违反即构建失败:
host.call的第一个参数是字符串字面量:host.call("billing.prices")可以,host.call(analytics.${kind})或把名字放进变量都不行,要改写成若干个明确的字面量调用。host也只能以host.call(…)的形式使用,不能赋给别的变量或传出去;整体导入时(import * as native)只能写native.host.call(…)这类按成员的用法session.signIn按成员调用:session.signIn(…)记作能力session.signIn进requires;session同样不能赋给别的变量或传出去http.client的参数是字符串字面量:http.client("app")可以,变量或模板串不行;http的其他用法(http.get等,走default通道)不受限。http.client同样只能按成员调用,不能赋给别的变量或传出去(2026-09-25)- 宿主组件的类型能静态解析:直接写
<ta.Icon>这类标签(tinyui schema生成的ta对象),或字符串字面量类型;条件选择(cond ? ta.Icon : ta.Loading)可以。先赋给变量再用(const C = ta.Icon; <C />)、来自函数参数、运行时计算的类型都不行;ta这类组件对象不能被改写、删属性或整个传出去 - 页面里不要再声明名叫
host、http、session、h的局部变量或参数:解析按名字进行,重名会让所有用法都无法确定
内置组件不受限(随 tinyui 版本走,发布前核对目标宿主的下限与包的 tinyui 兼容即可(updates.md §1.1))。放宽的出口是在 tinyui.config.json 里手工声明额外用到的能力与组件,现在不开,有真实需求再加。
| 位置 | 改动 |
|---|---|
packages/cli |
bin 入口 tinyui,子命令 build,读工程根 tinyui.config.json(包名、公钥、页面目录),参数 --root / --out / --qjsc;依赖 esbuild。测试:夹具 TSX 打包后断言 h( 调用、裸说明符保留、相对 import 已合并、.map 存在;qjsc 步骤在找不到 qjsc-kmp 时跳过 |
packages/core / native |
不动运行时 API,只保证入口能打成单模块,各导出 VERSION 供 sample 显示 |
compose/ |
ADR-002 K0 最小版:注册 core → 注册 native → 运行页面模块 → 返回 namespace。命名待 M1 定,本期只保证序列在库里而不在 sample 里。commonTest 用 JsBytecode.compile 现场编字节码,不依赖宿主工具 |
sample/js |
新 pnpm workspace 成员,包名 sample,src/pages/home.tsx。本期页面用纯 TS 只 import VERSION:h 的签名是 C 组的事,不在 core 放临时实现;JSX 变换的正确性由 CLI 单元测试覆盖 |
sample/shared |
Gradle:pnpm run build → tinyui build → Sync 只取 .jsb 与清单 → compose.resources.customDirectory,Res.readBytes("files/tinyui/…") 读入后走 K0。AGP 9 的 KMP 库插件默认不处理 assets,Compose resources 在 Android 走 assets,必须 androidResources { enable = true },否则 APK 里没有资源且构建不报错(2026-09-16 实测) |
settings.gradle.kts |
有 quickjs-kmp.dir 时把 qjsc-kmp 路径传给 Exec 任务 |
| CI | gradle job 加 setup-node + pnpm,clone quickjs-kmp 的 catalog 版本 tag 编 buildNativeHostJni(Android host 测试要加载宿主 JNI 库,Maven 包里没有),经 TINYUI_QUICKJS_HOST_JNI 传入;qjsc-kmp 来自 npm 包(2026-09-23 起);ios.yml 同样 |
定于 2026-09-17。七类错误(ADR-002 §3.5,E1~E7)统一成一个 PageError(kind、页面模块名、buildId、message、E1 的入口名、原始 JS 栈、解析 / 映射后的帧、E5 的 op 原文)经 PageSink.error() 交给宿主;console.* 与引擎日志仍走 PageSink.log()。E2 / E6 同时置 PageHost.failure,错误页由 Kotlin 渲染,TinyUI.debug 为 true 时显示映射后的栈;E1 / E7 只上报不叠层,页面继续。
栈的形态:QuickJS 打印 at fn (模块名:行:列),--strip-source 保留行与列(宿主测试断言)。J3 的 HostError 在 K3 reject 处创建,它自己的栈只会指向 core 的入口,所以 call() 时先记下调用点的栈(约 1 µs),rejection 带着它——await http.get 无人 catch 的 E7 因此指向业务的 await 那一行。
回映射在哪做:dev 构建把 .js.map 一起打进 App,Kotlin 侧 SourceMaps 按模块名惰性解析(source map v3 的 VLQ,约百行,无依赖),只解析栈里出现的行;release 不进包,sink 拿到原始栈 + buildId,离线对 map(Sentry / Crashlytics 的做法)。否掉 JS 侧运行时映射:字节码与内存都涨,出错时还要跑 JS。
buildId:CLI 对每个模块的 .js 取 sha256 前 8 位写进 manifest.json 的 buildIds(运行时模块不进包,栈帧按 tinyui 版本对回,ADR-006 §2.11),BuildManifest.parse 读出,PageModule(name, bytecode, buildId) 交给 PageHost。宿主发布时把 out/ 目录整体归档即可对回。
map 的路径:thunk pass 的内联 map 以绝对路径作 source(相对路径会被 esbuild 再按文件目录解析一次,出现 src/pages/src/pages/ 的重复,2026-09-17 修),esbuild 输出后 CLI 把 sources 改写为相对项目根(src/pages/todos.tsx、../../packages/core/src/host.ts),运行时与离线符号化都以此显示。
sample 的接线:collectTinyUIResources 默认连 .js.map 一起进资源,-Ptinyui.maps=false 不带;App.kt 读 manifest,能读到的 map 交给 SourceMaps,读不到就按原始栈上报。真实 App 按构建变体决定要不要带 map,库不替宿主决定。
定于 2026-09-25(ADR-007 §3.2,运行时契约见 native-api.md §8)。
- 位置:工程根
i18n/(tinyui.config.json的i18n可改),一个语言一个文件<locale>.json,文件名是 BCP 47 标签(en、zh、zh-Hant);内容是扁平对象,key → 字符串,key 限[A-Za-z0-9._-]+ - 默认语言:
tinyui.config.json的defaultLocale,必须有对应文件;运行时回退链的终点 tinyui build校验,失败即构建失败:每个语言的 key 集合与默认语言相同(缺的、多的都报);同一 key 在各语言里的{name}占位符集合相同;值都是字符串- 产物:文件原样复制到输出目录的
i18n/,进 manifest 的i18n({ "default": "en", "files": { "en": "i18n/en.json", … } })与hashes,随包签名(updates.md §1.1) - 类型:
tinyui i18n --ts <file>生成对tinyui-native的模块扩充(每个 key 及其占位符名),i18n.t的 key 与参数因此在编译期检查;与tinyui schema一样把生成物提交进仓 - 页面没有用到 i18n 的包可以没有
i18n/目录,此时不写defaultLocale
定于 2026-09-25(ADR-007 §3.7,组件契约见 components.md §3)。页面 import icon from "….svg" 得到 Icon 要的字符串:tinyui build 在 esbuild 里给 .svg 挂加载器,构建期转换,设备上不解析 SVG。
- 填充图标:
<svg viewBox>下的path/circle/ellipse/rect(含圆角)/line/polyline/polygon合并成一个 path,得"<viewBox>|<d>";fill只能缺省或currentColor - 描边图标:根元素
fill="none"且有stroke(Lucide 的写法),得"<viewBox>|<d>|<stroke-width>" - 构建失败:渐变、
image、text、use、mask、transform、style、透明度、另一种颜色的fill、没有viewBox(且没有width/height) - 类型:页面工程的 tsconfig 加
"types": ["tinyui-core/assets"],*.svg的默认导出是string