Skip to content

分包、字节码与热更新

构建工具链

App 端用 esbuild 打包(不是 Vite),由 @ufjs/clibundler/ 负责:

text
src/main.ts ─┐
.vue SFC     ├─► esbuild + fjs 的 Vue 插件 ─► dist/app/*.js ─► fjsc ─► *.fjsbundle
npm 依赖     ┘     (SFC 编译、虚拟模块、           (JS)         (QuickJS 字节码)
                    CSS 注入、alias)

fjs 的 esbuild 插件做了这几件事:

  • 编译 SFC:用 @vue/compiler-sfc,给 isNativeTag 传入内置标签表,让 <view><text> 被当作元素而不是组件
  • 生成虚拟模块fjs/pages(扫 src/pages)、fjs/plugins(扫 src/plugins 和模块组件)
  • 注入样式<style> 块原样注入运行时 CSS 引擎
  • aliasvue@vue/runtime-core(不拉 DOM 运行时),fjs/router → Flutter 实现
  • 桩掉 Node 内置模块utilfs 等指向调用即抛的桩,只 import 不调用的库能过

esbuild 以 platform: 'neutral'(QuickJS 既不是 Node 也不是浏览器)输出 IIFE 格式的单文件。

pages 分包

fjs build --pages 把产物拆成三种:

产物内容什么时候加载
shared.jsVue、@ufjs/runtime、Shell、公共组件、被多页引用的模块启动时一次,挂到 globalThis.__FJS_SHARED
bundle.js入口(main.ts 自己,通常只有几 KB)启动时
pages/<chunk>.js单个页面自己的代码进入这个页面时

哪些模块进 shared 是自动算的:先跑一次 esbuild 探测,统计每个模块被哪些入口引用。入口可达的、被两个以上页面引用的进 shared;页面文件本身永远是独立 chunk。

页面 chunk 对共享模块的引用被改写成从 __FJS_SHARED 取,所以所有页面拿到的是同一个实例 —— 模块级的 store、缓存天然跨页共享。第三方库默认不在共享名单里,带模块级状态的要登记 fjs.shared(见添加插件)。

hello-fjs 实测:

text
shared.fjsbundle              1,210,078 B   ← 每个 VM 装一次
bundle.fjsbundle                  6,163 B
pages/comp-button.fjsbundle      23,811 B   ← 进这个页面才加载

页面 chunk 怎么被加载

dart
engine.addPrelude(shared);                          // reset 时会自动重放
engine.chunkLoader = (chunk) async => loadChunk(chunk);
engine.runBundle(bundle);

router.push 时,Dart 立刻开始原生转场,同时异步调用 chunkLoader 取回该页 chunk、在同一个 VM 里执行,完成后回派 navMount,JS 才把页面挂上去。转场不等 JS。

没有 src/pages 的项目(比如纯 element API 项目)不分包,因为 shared 里的 Vue 它根本用不到。

字节码

--release 自动开启 --bytecode:用 fjsc 把每个 JS 产物编译成 QuickJS 字节码。

text
.fjsbundle 格式
[0..4)   magic "FJSB"
[4..6)   u16 格式版本
[6..8)   u16 engine id 长度
[8..]    engine id + QuickJS 字节码

App 加载时用 JS_ReadObject 直接读入,跳过词法和语法分析。加载前校验 magic、格式版本和 engine id —— fjsc 和 App 里的引擎必须来自同一份 QuickJS 源码,版本不一致会直接拒绝加载,而不是运行到一半崩溃。

fjsc@ufjs/cli 按平台安装(@ufjs/fjsc-<平台>)。查找顺序:环境变量 FJSC_PATH → ufjs 仓库内自编的版本 → npm 包。

--gz 会把复制进宿主的 .fjsbundle 再 gzip,启动时自动解压。

热更新

dev server(fjs dev --pages,默认端口 38900)提供:

路径内容
/manifest.json分包清单
/shared.js /bundle.js /pages/<chunk>.js源码形式的 bundle
/units.js /units/<id>.js模块级热替换用的 unit
/wsWebSocket,推送 reload 指令

文件变化后,dev server 判断改动范围,推送最小粒度的 reload:

改动推送设备行为
页面独占的代码reload pages:<chunk>只重新执行该页 chunk 并重挂
多页共享的 app 模块reload units:<ids> pages:<chunks>同一 VM 内重新定义受影响的 unit 和引用它们的页面;VM 不重建,页面栈、全局状态保留
Shell、入口、路由表、配置reload销毁并重建整个 VM

unit:模块级热替换

dev 构建时,每个「入口直达或被两页以上引用」的 app 模块被单独编成一个 unit:

js
__fjsDefineUnit("src/components/Panel.vue", factory)   // 只注册,不执行

页面 chunk 里对它的 import 被改写成 __fjsRequireUnit("src/components/Panel.vue")。工厂在第一次 require 时才执行,所以循环依赖是安全的。热替换时,设备端重新 define 受影响的 unit,按依赖顺序重新 require,再重新执行引用它们的页面 chunk(chunk 在执行时就把 exports 捕获进了闭包,只换 unit 不换 chunk 会留下旧组件)。

release 构建里没有任何 unit 机制。

完整重建为什么安全

engine.reset() 销毁整个 VM,所有全局对象随之消失。shared prelude 由引擎在新 VM 里自动重新执行,宿主不需要关心顺序 —— 这是 dev 热更新能只推变更 chunk 的前提。

连接 dev server

fjs run 通过 --dart-define=FJS_DEV=<host:port> 把地址注入 App。fjs go 则通过扫码、局域网 UDP 广播发现或手输获得地址。

App 启动时拉取 manifest、prelude、bundle,失败会按 1→2→3→5→8 秒退避重试、之后固定 8 秒,不设上限 —— 因为 iOS 的网络授权弹窗是异步的,弹出来时第一次请求早已失败。授权后几秒内自动连上,不需要重启 App。

fjs log / fjs eval 也连的是 dev server:它们自报「工具」身份,由 dev server 转发给应用。fjs eval 的返回值走日志通道回来,所以不需要为它新增任何消息类型。

基于 MIT 协议发布