TokUI 是 JBoltAI 团队开源的流式 UI 描述与渲染框架,零运行时依赖。后端用极简 DSL 描述组件,经 SSE 推送,前端基于状态机增量解析,首个 Token 到达即渲染为真实 DOM。本文从七个维度拆解核心技术突破。
一、字符级真流式状态机
很多号称流式的方案,本质是把完整 JSON 切块发——块边界劈开标签就崩了。TokUI 的解析器在 TEXT、TAG_OPEN、TAG_CLOSE 三状态间切换,核心是状态可中途暂停:无论输入在哪个字符被切断,下一次 feed 到来都能从断点继续。
以一段简单的卡片 DSL 为例,SSE 可能在任意位置切断:
[card tt:任务进度] [p v:bold 当前进度 45%] [progress v:45 l:处理中] [/card]第一块可能只到达[card tt:任,第二块务进度]\n [p v:b,第三块old 当前进度 45%]。解析器在每次 feed 时都能正确拼接:findCloseBracket 跳过引号内的闭括号,缓冲区找不到闭括号时 break 等待更多数据,不报错也不丢字节。
构造函数中硬编码了 maxBuffer(1MB)和 maxDepth(100)两个资源防护限制,防止恶意输入撑爆内存或递归过深。SSE 可以把 DSL 切成 2 到 20 字节的任意片段推送,前端都能正确还原。
二、原始内容的流式逐字渲染
代码块、Markdown、diff、terminal、sandbox 等容器内部是原始文本,[不应被解析为标签。否则一段含[0,1)的代码会直接让解析器崩溃:
[code js] const arr = [0, 1, 2]; arr.forEach((item, index) => { console.log(`index[${index}] = ${item}`); }); [/code]原始内容模式的流式渲染要解决三个棘手边界问题。
半截闭标签的回持。流式中[/cod([/code]的前缀)可能先到达。如果直接当正文输出,用户会看到一串乱码。解析器计算缓冲区末尾是闭标签多长的前缀,回持这部分不发,等下一块到齐再决定。
转义序列被 chunk 劈开。AI 流式常吐字面\n,SSE 分块可能把\和n劈到两块。解析器检测安全尾部是否以奇数反斜杠结尾,若是则回持最后一个\。_unescapeRaw 仅认\n、\t、\r、\\四种转义,其余如正则\d、路径\w保留字面,不误伤代码。
增量 emit 避免重复。容器节点上的 _rawEmittedLen 记录"已发送偏移",每次只 emit 新增的安全尾部。从 JBoltAI 踩过的坑来看,早期版本代码块流式期间正文完全空白,直到[/code]到达才一次性吐出——增量 emit 机制上线后才实现真正的逐字流式高亮。
三、隐式闭合容错机制
AI 写 DSL 时常带 HTML 肌肉记忆——写[item]不闭合、容器标签漏写]。TokUI 用三套隐式闭合机制对齐 HTML 容错语义。
兄弟 item 隐式闭合。新[item]开标签时若栈顶已是未闭合 item,先把上一个关掉,对齐 HTML<li>裸标签语义。下面这段 AI 输出缺少[/item]也能正确渲染:
[list] [item 第一阶段:需求分析] [item 第二阶段:架构设计] [item 第三阶段:开发实现] [/list]p 自动闭合。用 P_INLINE_CHILDREN 白名单区分内联子节点和块级兄弟。有文本的叶 p 遇到非内联类型时自动闭合,保[p a][p b]的自闭合兄弟语义;而显式容器[p]则收所有子节点由[/p]关闭:
[p 第一段落文字] [p 第二段落文字] [p] [btn tx:确认 v:primary clk:onOk] [btn tx:取消 clk:onCancel] [/p]容器漏写 ] 的隐式补全。AI 常写[item 文本\n[list]这种跨行漏]的形式。解析器检测引号感知嵌套[——若[前是容器类型则自动闭合父标签。关键是引号感知:[item "生成 [0,1) 浮点数"]中引号内的字面[不会触发隐式闭合。
从 JBoltAI 踩过的坑来看,早期严格校验时约 15% 的 AI 输出因标签不闭合而渲染失败,容错机制上线后降到了接近零。
四、图表流式预览
图表是数据量最大的组件。等整个[chart]的]闭合才渲染,图表区域会长时间空白。以甘特图为例,几十条任务的数据可能在流式中持续增长:
[chart t:gantt tasks:"设计评审,0,2|接口开发,2,6|联调测试,6,8|部署上线,8,10" c:"#1677ff" ]TokUI 的方案是 TAG_OPEN 状态累积 chart 标签时,数据属性 d 或 tasks 每增长就 emit 一个半成品预览节点。Renderer 用 pending wrapper 承接:首次创建挂载,后续每次预览增量重绘。
最精巧的是引号未闭合时的放行策略——颜色属性c:"#16a...引号未闭合时不放行,避免半成品色值导致黑或乱色。但 tasks 的长引号值在闭引号到达前必须放行半成品,否则数据全齐才一次性 emit 是流式卡顿的根因。
以 JBoltAI 的实践来看,这个机制把图表首帧可见时间从秒级降到了毫秒级,用户在数据还在生成时就看到了图表骨架。
五、插槽栈与流式挂载
流式渲染下 DOM 是边收边长的。slotStack 插槽栈管理嵌套容器的正确挂载。当一个卡片打开、内部嵌套 row/col 时,每来一个子节点都必须挂到当前最内层容器的正确位置:
[card tt:用户信息] [row] [col w:120 [img s:avatar.jpg v:avatar]] [col [h3 张三] [tag t:success 在线] [p v:muted 产品经理] ] [/col] [ft [btn tx:发消息 v:primary clk:onChat]] [/card]两个关键工程细节值得关注。
插槽委托。tabs 的[tab]在容器上插入 radio 和 label,面板内容追加到 panel 自身。ft 在 card 或 dialog 内时直接挂到容器元素而非 body 插槽。图表容器内子节点不渲染为 DOM 而是喂给增量重绘。这些都是组件自行声明 _slot 指向正确挂载目标。
流式关闭钩子。picker、transfer 等组件必须在所有子元素就绪后才能绑定交互。TokUI 把初始化推迟到容器流式关闭时执行,通过 _streamCloseHook 回调。既保证 DOM 完整,又不破坏流式的即时性。
六、事件安全三层防御
在 AI 生成内容直接渲染的场景里,XSS 是头号风险。TokUI 从架构上做了三层防御。
第一层:命名引用事件模型。DSL 里写clk:handleLogin只是字符串名字,前端通过 registerHandler 预注册真实函数。DSL 里没有任何可执行代码:
[form sub:onSubmit] [input ph:"用户名" n:username req] [pwd ph:"密码" n:password req] [btn tx:登录 v:primary clk:handleLogin] [/form]event-bus.js 中硬编码了__proto__、constructor、prototype三个危险名称的黑名单,注册时直接拒绝。
第二层:DOM 创建时过滤危险属性。el() 函数创建元素时主动过滤所有 on* 开头的属性和 formaction,style 走 cssText 而非属性设置。全库除 md 组件和可信代码块外全部使用 textContent。
第三层:容错降级。未注册组件渲染为 div.tokui-unknown,渲染抛错时生成 details.tokui-error 降级显示。渲染深度超过 50 层时返回空文本节点防止栈溢出。从 JBoltAI 的工程经验来看,这三层防御在真实 AI 对话产品中从未被突破。
七、UMD 双模式与打包工程
源码是 UMD 双模式,同时挂 window.TokUI._internal 和 module.exports。当 Vite 8 / Rolldown 把 CJS 重写为 ESM 时,会移除 require、module、exports,导致 typeof require 分支全失效。
lib.js 的解法是显式按拓扑序 import 全部模块——叶子模块(event-bus、color-generator、parser、renderer、chart)到中层(basic、form、layout)到聚合器(components/index)到主类最后——用真实的 ESM 边强制 Rolldown 求值顺序。
同时刻意不具名导出 _internal,否则会覆盖 src 累加的结果。package.json 也不设 sideEffects,避免 Rolldown 树摇掉副作用 import 的 JS。从 JBoltAI 在实际项目中的验证来看,这个取舍体现了 TokUI 的工程哲学:组件必须全量注册,树摇对库无益,正确性高于体积。