下一代鸿蒙打包工具链:arkdown

2 阅读20分钟

起

这篇文章主要记录我最近一段时间做的一件事情:基于 Rust 生态(OXC + Rolldown + tsrs),为鸿蒙(OpenHarmony / HarmonyOS)从头打造一套下一代打包构建工具链 —— arkdown。

目前 arkdown 已经完整实现了从 ArkTS/TypeScript 源码解析、类型检查、UI 语法转换、模块打包、C/C++ Native 编译、资源处理、字节码(ABC)生成到最终 HAP、HSP、HAR、APP 签名打包的全流程。

本文涉及的开源仓库在:arkdown

在开始阅读本文之前,建议你有如下背景才能够获得最佳的阅读体验:

  1. 有一定的鸿蒙原生应用(ArkTS)开发经验,了解基本的项目结构与开发套路。
  2. 对前端工程化打包工具(如 Rollup、Rolldown、OXC)有最基础的认知。
  3. 简单了解 TypeScript 编译器的基本工作机制。

一、为什么要写一个新的打包工具?

如果你平时只写一些几百行代码的鸿蒙 Demo 或者小型应用,你可能会觉得官方默认的构建工具 Hvigor 用起来还算顺手,似乎并没有什么非换不可的理由。

但是一旦进入到中大型生产级鸿蒙项目,或者需要在 CI/CD 流水线中高频执行构建任务时,传统构建链路的痛点就会迎来“当头一棒”。

翻开鸿蒙官方构建插件 developtools_ace_ets2bundle 的源码,我们可以看到目前的默认构建主链本质上是:基于 Node.js 运行的 Hvigor 任务编排 + 魔改版 TypeScript 4.9.5 编译器 + Rollup 打包器 + 多 Worker 进程池。

在一个包含上万个 .ts / .ets 文件的真实大型工程中,这套基于 JS/TS 实现的工具链会暴露出几个非常致命的问题:

  1. 冷构建耗时极长 在一个拥有 1.1 万+ 源码文件的生产级鸿蒙应用中,执行 clean 之后的冷构建动辄需要 10 分钟以上(620 秒+)。其中绝大部分时间都消耗在了 CompileArkTS 阶段的单线程/多 Worker AST 解析、全量类型检查以及 Rollup 模块依赖图扫描上。
  2. 内存占用爆炸,OOM 常客 体会过大型前端项目构建内存溢出的 JSer 应该都深有体会。在鸿蒙构建中,由于定制版 TypeScript 编译器与 Rollup 需要在 Node.js 内存中驻留海量的 AST 节点、符号表与 SourceMap 链,大型工程冷构建时的进程树峰值物理内存(Physical Footprint)甚至会飙升到 9.6 GiB 以上。对于还在使用 16G 内存开发机的同学或者云端构建容器来说,稍微开几个并发任务就会直接把机器干崩。
  3. 工具链沉重,不够“开箱即用” 传统构建流程强耦合了复杂的任务状态机、Daemon 进程以及各种隐式的环境约定。当我们想要在独立的命令行环境、自动化脚本或者跨平台工具链中干净、轻量地完成应用打包时,往往需要背负一整套沉重的运行时包袱。

与近两年 Web 前端工具链全面“Rust 化”(如 SWC、Rspack、Oxc、Rolldown)的演进思路类似,我们自然会思考这样一个问题:既然 ArkTS 1.1 本质上是带有声明式 UI 语法糖的 TypeScript 超集,我们能不能用纯 Rust 工具链把鸿蒙的打包器彻底重写一遍?

这就是 arkdown 诞生的初衷。

这也是 arkdown 最核心的工程原则:以 Hvigor 和 ets2bundle 官方源码作为唯一的行为基准,但不在内部保留任何 Hvigor 的兼容包装层,由 Rust 直接实现从解析到签名的每一个模块。

二、技术选型:为什么是 OXC + Rolldown + tsrs?

想要用 Rust 重写鸿蒙打包工具链,肯定不能所有能力都从零开始写。在核心底座的选型上,我们最终选择了 OXC + Rolldown + tsrs 的组合。

可能很多同学会好奇:为什么偏偏是这三个工具?它们在 arkdown 中各自承担了什么角色?

1. 为什么选择 OXC?

在鸿蒙构建流程中,.ets 文件里包含了大量非标准 TS 的声明式 UI 语法(如 struct、@Component、@Builder、链式布局调用)以及 @interface 注解。构建工具需要将这些语法转换为标准的 JS/TS 代码、生成精准的 SourceMap,并在 Release 模式下完成 ArkGuard 源码混淆。

OXC(The JavaScript Oxidation Compiler)是目前 Rust 生态中性能最顶尖、架构设计最现代的 JS/TS 编译器基础设施之一:

  • 它基于 Arena 内存分配器(oxc_allocator)实现了极其紧凑的 AST 内存布局,解析与遍历速度极快。
  • 提供了开箱即用的 Parser、Semantic、AST Visitor、Codegen、Minifier 以及高性能的模块路径解析器 oxc_resolver。

基于 OXC 来扩展 ArkTS 语法,意味着我们不仅可以直接复用其高性能的代码生成与 SourceMap 能力,甚至还能顺手把官方的 oxlint(静态检查)和 oxfmt(代码格式化)一并带入到鸿蒙生态中。

2. 为什么选择 Rolldown?

如果翻看过 ets2bundle 内部实现或者 hvigor 反解源码的同学应该知道,鸿蒙 Stage / ESMODULE 模式的编译底座本身就是重度基于 Rollup 构建的,并且对外暴露了一套兼容 Rollup 钩子(如 buildStart、resolveId、load、transform、moduleParsed、buildEnd)的编译插件机制。

而 Rolldown 作为下一代 Rust 打包器,恰好具备两个非常核心的优势:

  1. 天然兼容 Rollup 插件与模块图模型:这让我们可以在 JS / N-API 边界以最小的适配成本,无缝承接鸿蒙工程中现有的自定义 TS/JS 编译插件。
  2. 与 OXC 彻底同源:Rolldown 底层直接使用了 OXC 的 Parser、AST 与 Resolver。选择 Rolldown,可以让源码语法转换与模块图扫描在同一个 AST 体系下无缝衔接,避免不同工具之间反复解析与序列化的开销。

3. 为什么还需要 tsrs?

在聊 tsrs 之前,我们先澄清一个很多同学第一反应会问的问题:既然已经有了 OXC 和 Rolldown,我们直接像编译普通 TS 项目一样只用 OXC 做单文件语法擦除和转换,不就能把包打出来了吗?为什么还要引入一个完整的 TypeScript 类型检查器?

回答:不可以。

为什么不可以?这里最关键的原因在于两点:

  1. 鸿蒙构建内置了极其严格的语法限制与 SDK 合规检查 在 CompileArkTS 阶段,除了基础的 TS 类型检查外,还必须执行完整的 ArkTS 1.1 Linter(如禁止 any、动态属性限制、Sendable 并发安全校验等),以及基于 SDK JSDoc 的 API 校验(如 @since 版本门禁、@syscap 系统能力、@permission 权限、@crossplatform 跨平台校验等)。
  2. ArkUI 的代码转换强依赖跨文件的真实类型信息 这是最容易踩坑的地方。在把 ArkUI 转换为运行时代码时,很多转换逻辑并不是纯语法层面的,而是必须知道某个标识符背后的真实推导类型。 举个最简单的例子:当处理 @ObjectLink 装饰器时,转换器必须准确判断该属性的类型是不是可空联合类型(Nullable Union)、基础类型、@ObservedV2 类或者函数类型;再比如跨文件导入的 @Builder 识别、枚举成员的常量折叠、@interface 注解的 Retention 策略等,如果仅靠单文件 AST 猜测而不做完整的跨文件类型推导,生成出来的字节码在真机运行时一定会直接翻车。

在这块选型上,我们也走过一段弯路:

早期我们曾接入过基于 Go 语言移植的 tsgo。虽然 tsgo 解决了原生编译速度的问题,但它作为一个独立的 Go 二进制程序,不可避免地带来了跨进程 IPC/RPC 通信开销;更痛苦的是,在发布 npm 包时还需要跨平台构建和分发独立的编译器可执行文件,进程生命周期管理非常繁琐。

正因如此,当 Rust 原生实现的 TypeScript 7 编译器移植项目 tsrs 出现时,我们果断将检查器全面迁移到了 tsrs。

迁移到 tsrs 最大的工程意义在于:我们可以将 tsrs_api、tsrs_core 和 tsrs_vfs 直接作为 Cargo 依赖静态链接进 arkdown 的 N-API 原生动态库中。

这样一来,我们就不再需要启动外部子进程和进行 JSON-RPC 跨进程通信,也不需要额外分发独立的可执行文件,所有的源码快照、类型推导查询与诊断都在同一个 Rust 进程内完成。

三、我们对上游源码做了哪些核心改动?

当然,开源社区上游的 OXC、Rolldown 和 tsrs 默认只认识标准的 JavaScript / TypeScript,完全不认识鸿蒙特有的 .ets 语法和构建规则。

为了让这三个核心库真正跑通鸿蒙的全链路构建,我们在 ohos-rs 组织下对上游仓库进行了深度的定制改造。这里简单挑几个最核心的改动跟大家分享一下。

1. 对 OXC 的改造:原生支持 ArkTS / ArkUI 语法树

我们对 ohos-rs/oxc 的改造主要集中在 oxc_ast、oxc_parser、oxc_codegen 以及配套的工具链上:

  • 扩展 ArkUI 与 ETS 专属 AST 节点(arkui.rs / ets.rs) 在 oxc_span::SourceType 中新增了对 .ets 和 .d.ets 的原生识别,并在 AST 中落地了完整的自定义组件 struct 声明(支持可选方法、get/set 访问器、export declare struct)、组件调用表达式(ArkUIComponentExpression)、尾随闭包子组件块以及链式属性调用。
  • 处理 ArkUI DSL 的上下文感知与链式调用等边界情况 写过解析器的同学都知道,将自定义 DSL 混入通用编程语言中最头疼的就是“语法冲突”。例如鸿蒙特有的 import lazy 延迟导入语法(需要保证当普通默认导入的变量名叫 lazy 时不会误判)、@interface 注解声明、以及组件链式调用中的跨行点号(Leading Dot)等。我们在 Parser 中增加了严格的 ArkUI 上下文状态机,将 DSL 解析精准限制在合法的作用域内,并将所有语法错误统一收敛到 parser_diagnostics! 中。
  • Codegen 与全套工具链适配 通过扩展 OXC 的 ast_tools 代码生成器,我们让新增的 ArkTS 节点自动生成了全套的 AstBuilder、Visit、Traverse 与 CloneIn 实现;同时在 oxc_codegen 中对齐了 ArkUI 装饰器的换行与 export 导出顺序,确保生成的 SourceMap 能够与下游严格对齐。此外,我们还顺手为 oxfmt 和 oxc_minifier 补齐了 .ets 支持。

2. 对 Rolldown 的改造:面向字节码编译的“低内存扫描模式”

在改造 ohos-rs/rolldown 时,我们发现了一个非常关键的架构差异点:

在传统的 Web 前端构建中,打包器(Rollup / Rolldown)的核心任务是把成千上万个小模块经过 Link(符号绑定、作用域提升、Tree-shaking)之后,拼接生成最终的 JS Bundle 文件(generateBundle)。因此,Rolldown 默认会在整个构建周期内把所有模块的 EcmaAst、语句分析信息(stmt_infos)和符号引用库(symbol_ref_db)全部驻留在内存中,留给后续的 Link 阶段使用。

但是在鸿蒙的 Stage / ESMODULE 构建模式中,情况完全不同。在鸿蒙上游 ets2bundle 中,默认开启了 generateModuleGraphOnly 和 disableGenerateBundle。简单来说,鸿蒙并不需要打包器把代码拼成一个大 JS 文件,而是利用打包器走完 Resolve → Load → Transform → 模块依赖图扫描,然后把每个转换后的单文件源码和模块映射表交给 SDK 中的 es2abc 去直接编译成方舟字节码(modules.abc)。

在此背景下,我们在 Rolldown 中做了两个核心改动:

  1. 新增轻量级 scan_module_sources 专用通道 在 Bundle 与 ScanStage 中直接暴露了面向模块源码图扫描的接口,跳过整个沉重的 Link 和 Chunk 生成阶段,直接输出结构化的 ModuleSourceScanOutput(包含每个模块的转换后源码、导出类型、以及带精确源码区间的 imports 依赖列表)。
  2. 引入 retain_link_output 内存即时释放机制 在 ModuleLoader 的任务循环中,当处于模块扫描模式时,每个文件一旦完成 AST 依赖扫描,立即在当前 Worker 中主动 drop 掉它的 EcmaAst、stmt_infos 和 local_symbol_ref_db,同时不再驻留未使用的转换阶段 SourceMap 链。
// 在模块扫描模式下,提取完依赖图信息后立即释放 AST 与符号表内存
if self.retain_link_output {
    *self.intermediate_normal_modules.index_ecma_ast.get_mut(runtime.id()) = Some(ast);
    *self.intermediate_normal_modules.stmt_infos.get_mut(runtime.id()) = Some(stmt_infos);
    self.symbol_ref_db.store_local_db(runtime.id(), local_symbol_ref_db);
} else {
    drop((ast, stmt_infos, local_symbol_ref_db));
}

这一改动直接砍掉了上万个文件在打包阶段常驻内存的 AST 与符号表开销,也是 arkdown 能够将内存占用压到极低的核心原因之一。

3. 对 tsrs 的改造:完整移植鸿蒙 TS 4.9 语义与进程内嵌入式引擎

对 ohos-rs/tsrs 的改造可以说是整个项目中工作量最大、也最复杂的一部分,核心包含以下几块:

  1. 完整移植动态 ArkTS 语法、模块解析与 1003 条 Linter 规则 我们对照 OpenHarmony 官方的 third_party_typescript 与 ets2bundle 源码,在 tsrs 中完整实现了 .ets/.d.ets 的解析与绑定、虚拟组件节点构造、ohpm 模块解析(支持 oh_modules、oh-package.json5、.so 映射)、@kit.* 导入的语义级自动展开,以及全套 ArkTS 1.1 Linter。 为了确保万无一失,我们搭建了自动化差分测试框架(Differential Testing):官方 Linter 语料库中的 1,003/1,003 个测试用例以及捕获自官方编译器的 243/243 个完整工程诊断测试,在错误码、行列号、错误文案及诊断顺序上实现了 100% 对齐。
  2. 精准还原 OH TypeScript 4.9 的兼容性门禁 由于 tsrs 上游基于最新的 TypeScript 7 语义,而鸿蒙 SDK 基于 TypeScript 4.9.5,很多新版 TS 的类型收窄规则在鸿蒙工程中反而会导致误报。我们在 tsrs 中专门为鸿蒙模式补齐了 TS 4.9 兼容门禁(例如数值枚举在相等性判断中的分支收窄规则、含计算成员的枚举流分析、正则字面量扫描差异以及泛型公共超类型推断顺序等)。
  3. 打造 tsrs_api::embedded 进程内快照服务与 SDK 插件回调 我们在 tsrs_api 中新增了 embedded::Compiler 接口,固定以 noEmit 模式运行,让 arkdown 可以直接在 Rust 进程内创建和增量更新不可变源码快照(ProgramSnapshot),并批量查询 OXC 转换所需的类型。 同时,针对鸿蒙 SDK 中基于 CommonJS 编写的外部 API 检查插件(如检查 @since、@syscap),我们实现了线程安全的 ohsdkplugin 回调路由,并严格对齐了上游 LinterRunner 的生命周期——只在 normal semantic 阶段触发一次 JSDoc 回调,光这一项修复就直接减少了真实大型工程中 65% 以上(超过 85 万次) 的冗余回调执行。

四、最终收益是什么?

讲了这么多底层架构与源码改造,大家最关心的肯定还是:这套东西落地之后,在实际项目中的表现到底怎么样?

我们可以从性能与内存、产物正确性以及开发体验三个维度来看最终的收益。

1. 冷构建提速 8.4 ~ 10 倍,物理内存占用下降 75%

我们在真实的大型生产级鸿蒙应用上进行了严格的端到端基准测试。

测试环境:Apple M2 Pro(12 核 CPU,16 GiB 内存),macOS 15.6.1,DevEco Studio 26.0.0.821 / HarmonyOS API 26。 测试工程:包含 11,627 个 TypeScript 文件 + 23 个 ETS 文件 的真实大型商业应用,构建产物为保留完整 Native 调试符号(约 1.5 GB)的 Debug HAP。

测试结果如下所示:

构建工具与模式clean 后冷构建耗时无变更二次构建峰值进程树 RSS (冷 / 热)峰值物理内存 Footprint (冷 / 热)
Hvigor 6.26.4629.69 s5.96 s2.90 / 0.54 GiB9.68 / 0.49 GiB
Arkdown(完整 Checker)75.36 s17.28 s1.71 / 0.42 GiB2.39 / 0.34 GiB
Arkdown(--skip-checker)62.94 s15.84 s1.68 / 0.43 GiB2.37 / 0.34 GiB

从实测数据中我们可以直观地看到:

  • 冷构建耗时大幅缩短:原本使用 Hvigor 需要等待 10 分半钟(629.69 秒) 的冷构建,使用 arkdown(开启完整类型检查与 Linter)仅需 75.36 秒,耗时减少 88.0%,直接提速 8.4 倍。
  • 快速出包模式(--skip-checker):如果你在本地调试或者只关心快速打出安装包,可以通过 --skip-checker 显式跳过 Linter 和语法/语义诊断(注意:此时 arkdown 依然会通过 tsrs 查询 @ObjectLink、Builder、注解等代码生成所需的类型信息,确保生成代码行为完全一致),冷构建耗时进一步缩短至 62.94 秒(提速整整 10 倍)。
  • 内存开销大幅降低:冷构建峰值物理内存从 Hvigor 的 9.68 GiB 直接降到了 2.39 GiB,内存占用下降 75.3%(峰值 RSS 也降低了 41.1%)。即使在 16G 内存的开发机或受限的 CI 容器中,也能流畅地完成大型鸿蒙工程的构建。

当然,实事求是地看表格,你也会发现:目前在源码完全未变更的跨进程二次构建(Hot Rebuild)中,arkdown 需要 15~17 秒,比 Hvigor 的 5.96 秒要慢。

原因是:目前 arkdown 的跨进程缓存已经覆盖了 OXC 转换、Checker、ABC 字节码以及打包签名阶段(热构建时 Bundling 仅耗时 0.46s,ABC 为 0s),但由于目前上游 Rolldown 还没有提供安全的结构化磁盘模块图反序列化接口,每次启动新进程时仍需花费约 6.9 秒做项目快照准备、以及约 7.3 秒对 1.5GB 的大包做内容哈希校验。这块也是我们接下来重点优化的目标。

2. 37/37 严格产物对齐(Strict Hvigor Parity)

对于一个打包工具来说,跑得快只是加分项,打出来的包与官方 100% 一致、能稳定装上真机运行才是根本。

在开发 arkdown 的过程中,我们建立了一套直接与真实 hvigorw 逐产物比对的回归测试矩阵:

ARKDOWN_PARITY_HVIGORW=/path/to/command-line-tools/bin/hvigorw \
ARKDOWN_PARITY_STRICT=1 \
cargo test -p arkdown-napi --test hvigor_parity -- --test-threads=1

在 ARKDOWN_PARITY_STRICT=1 模式下,没有任何白名单豁免(Allowlist)。目前覆盖 HAP、HSP、HAR、包含多个 HAP+HSP 的 APP、热重载 HQF、ArkGuard 源码与字节码混淆以及真实签名验签的 37/37 项核心 Fixture 测试全部实现零差异通过。

3. 开箱即用与更多实用特性

在使用体验上,arkdown 做到了真正的干净与开箱即用。我们不需要安装任何额外的 Go 环境或独立编译器,直接通过 npm 安装即可:

npm install @ohos-rs/arkdown

紧接着只需要一行命令,就能直接编译现有的鸿蒙工程:

npx arkdown build --project ./my-app --target hap --mode debug

在此基础上,我们也顺手加入了一些提升日常开发体验的实用功能:

  • 支持 --enable-zig 零配置交叉编译 Native 代码:如果你看过笔者之前写的 《当zig-lang构建系统遇上鸿蒙》,应该知道我一直是 zig cc 的重度爱好者。在 arkdown 中,你只需要传一个 --enable-zig 参数,就能直接使用本机的 Zig 工具链参与鸿蒙 Native C/C++ 模块的编译构建。
  • 支持 arkdown.config.toml 全局编译常量覆盖:无需修改复杂的 build-profile.json5,即可在项目根目录用简单的 TOML 配置覆盖全局 BuildProfile 常量。
  • 同时支持 CLI 与 Node.js API:不仅在终端下提供了带阶段耗时与彩色进度条的清爽输出,还提供了结构化的 build() Node.js API 与日志订阅回调,非常方便集成到自定义的研发平台或流水线中。

五、后续持续迭代的规划

虽然目前 arkdown 已经跑通了生产级大型应用的构建并且取得了非常可观的性能收益,但要作为一个完善的构建工具,后续依然有不少需要持续迭代的地方。

接下来我会重点推进以下几件事情:

  1. 优化跨进程热构建性能与落地磁盘模块图缓存 如前文所说,目前无变更二次构建的耗时主要卡在新进程的模块扫描与大体积产物校验上。后续我们将推进结构化的跨进程模块图缓存(Disk Graph Cache)以及更细粒度的字节码增量失效策略,进一步缩短二次增量构建的耗时。
  2. 补齐纯 Rust 原生 HAR 签名能力 目前 arkdown 中 HAP、HSP、HQF 和 APP 的签名都已经通过我们用 Rust 编写的 hapsigner-rs 原生完成并直接内嵌;唯独 HAR 的目录签名协议目前仍通过调用 SDK 的 sdk-sign-tool-full.jar 兜底。接下来我们会在 hapsigner-rs 中补齐该协议的原生实现,彻底移除最后一个 Java 依赖。
  3. 跟进 ArkTS 1.2 正式工具链与更多边界场景 目前 arkdown 在架构上已经完成了对 ArkTS 1.2(支持纯静态、纯动态以及同时包含 modules_static.abc 与 modules.abc 的混合模块)的构建实现。随着后续官方 SDK 正式发布完整的 ets/static 与静态字节码混淆组件,我们将第一时间完成真机与全量对齐矩阵的验证。
  4. 完善基于 OXC + tsrs 的鸿蒙周边开发工具链 为了写好 arkdown,我们已经把最重要的部分——ohos-rs/oxc(ArkTS 解析/格式化/Lint)和 ohos-rs/tsrs(ArkTS 原生类型检查)都完成了。其价值绝不仅限于打包:无论是毫秒级的 ArkTS 代码格式化工具(oxfmt)、静态检查工具(oxlint),还是为 Zed、VS Code 等轻量级编辑器提供低内存、高性能的 ArkTS 语言服务支持(LSP),都将在此基础上持续演进。

尾

本文主要是简单介绍和复盘了我最近在做的下一代鸿蒙打包工具链 arkdown,包括为什么要造这个轮子、为什么选型 OXC + Rolldown + tsrs、我们对上游源码做了哪些核心改造,以及最终在真实大型项目上拿到的性能收益。

回看整个过程,从最初尝试用 Rust 解析 .ets 文件,到一步步把 OXC、Rolldown、tsrs 和 hapsigner-rs 集成,再到与官方 ets2bundle 源码死磕完每一个字节的产物对齐,虽然踩坑无数,但看到原本 10 分钟的构建被压缩到 1 分钟出头、内存直接砍掉四分之三的那一刻,还是非常有成就感的,最重要的是对笔者的日常开发有着非常重要的帮助,能够大幅降低编译开发耗时。

如果你正在饱受大型鸿蒙项目构建缓慢或内存溢出的困扰,或者对 Rust 前端基建与鸿蒙工具链感兴趣,非常欢迎尝试使用或者到仓库提 Issue / PR 一起交流学习。 同时也非常感谢 Void 团队开源的高性能实现,我们才能够有机会实现对鸿蒙生态工具链的改造。当然也厚颜无耻的希望你能够为 arkdown 点一个 ⭐️,希望本文对你有所帮助~