本文记录 Immer 10.0.3-beta 适配仓颉(Cangjie)的完整过程。适配目标是得到一个可以被仓颉项目直接依赖的跨端三方库,并在终端中用真实示例验证不可变状态更新、对象字段修改、深拷贝和补丁结果等能力。
这类适配与 OpenHarmony 原生三方库不同:仓颉版本不依赖 ArkTS、HAP、设备传感器或系统权限,而是通过
cjpm组织源码、通过静态类型表达状态树,再以静态库形式供其他仓颉项目复用。
项目地址: AtomGit/nutpi_cj/immer
上游仓库: immerjs/immer
开发工具: 华为云码道
一、背景
1.1 为什么适配 Immer
Immer 是 JavaScript 生态中常用的不可变状态工具库。它允许调用方在 produce 的草稿对象上直接描述修改,再返回一个新的状态,从而避免直接修改旧状态。React、Redux 和其他状态管理场景经常使用这种模式。
JavaScript 版本依赖动态值、异构数组和运行时对象判断,而仓颉强调静态类型、泛型和显式的状态表示。直接把 JavaScript 源码逐行改写为仓颉并不能保留清晰的类型边界,因此本项目先把状态树映射为仓颉可表达的值,再围绕复制、修改和比较重新组织 API。
1.2 跨端三方库的适配边界
| 边界 | 仓颉实现 |
|---|---|
| 包管理 | cjpm.toml、cjpm build 和 target/release/immer_cangjie |
| 状态数据 | ImmerValue 枚举、HashMap<String, ImmerValue> 和 ArrayList<ImmerValue> |
| 缺省值 | 对象缺失字段返回 ImmerValue.Null |
| 修改入口 | ImmerDraft 和 (ImmerDraft) -> Unit recipe |
| 交付形态 | 仓颉静态库 libimmer_cangjie.a 和接口文件 immer_cangjie.cjo |
| 验证方式 | test/main.cj 独立测试程序和 example/example.cj 终端示例 |
这里没有 HAP 构建、ArkTS 桥接和真机系统能力验收。只要目标平台提供兼容版本的仓颉 SDK,就可以按同一套源码和 cjpm 配置构建并消费这个库。
1.3 适配后的能力
当前仓颉源码按 public 声明核对出 5 个公开类型和 8 个顶层公开函数,核心能力包括:
- 状态值:
Null、Boolean、Integer、StringValue、ObjectValue和ArrayValue; - 状态构造:
emptyObject、emptyArray、objectWith和arrayOf; - 不可变更新:
produce接收深拷贝后的ImmerDraft,返回新的状态树; - 草稿操作:对象字段的
set、get、remove,以及数组的append; - 辅助能力:
clone深拷贝和isEqual递归按值比较; - 补丁结果:
produceWithPatches返回新状态、正向补丁和反向补丁。
当前实现面向可预测的 JSON-like 状态树,不宣称覆盖原版 Immer 的全部 JavaScript 动态语义。补丁接口已经保留,当前记录的是根状态替换补丁,具体边界在第七节说明。
二、实现路线图
text
第 1 阶段:盘点 API ── 对照 Immer 的状态更新模型,确定仓颉值类型和草稿边界
第 2 阶段:建立 cjpm 工程 ── 固定包名、版本、源码目录和静态库输出
第 3 阶段:类型映射 ── 用 ImmerValue、HashMap、ArrayList 和函数值表达状态树
第 4 阶段:分层实现源码 ── 完成状态值、草稿操作、复制比较和补丁结果
第 5 阶段:测试与核对 ── 编译测试入口,执行原状态不变和新状态更新断言
第 6 阶段:终端演示 ── 构建 example,输出真实的 `count = 1` 运行结果
每个阶段都有可检查的产物:工程阶段得到 cjpm.toml,实现阶段得到 target/release/immer_cangjie,验证阶段得到可执行的测试程序,最终示例则输出一组可直接放入文章的运行结果。
三、逐步实现过程
第 1 阶段:盘点原库 API 和仓颉工程边界
1.1 识别动态 API
Immer 的 JavaScript API 通常允许 recipe 直接修改任意对象、数组或嵌套值。JavaScript 可以在运行时处理不同形状的对象,而仓颉需要在编译期确定数据类型。因此本适配先选择可由静态类型稳定表达的状态树:标量、对象和数组都由 ImmerValue 统一表示,修改操作集中在 ImmerDraft 上。
1.2 固定仓颉项目结构
以下目录树按照当前 immer 项目的仓颉适配文件整理,只展示仓颉相关文件;原始 Immer JavaScript/TypeScript 源码、测试和构建目录保持原位置,不在此处重复列出:
text
immer/
├── cjpm.toml
├── cjpm.lock
├── README.Cangjie.md
├── README.Cangjie_CN.md
├── src/
│ └── immer.cj
├── test/
│ └── main.cj
└── example/
├── cjpm.toml
├── cjpm.lock
└── example.cj
为了符合"只新增仓颉相关文件"的目录约束,所有仓颉实现集中在 src/immer.cj。测试入口和示例工程独立于库源码,原有目录不移动、不重命名,也不改写原有文件。
第 2 阶段:创建 cjpm 工程
根目录 cjpm.toml 使用静态库输出类型:
toml
[package]
cjc-version = "1.1.3"
name = "immer_cangjie"
version = "1.0.0"
output-type = "static"
[dependencies]
构建成功后,最重要的两个产物是:
text
target/release/immer_cangjie/libimmer_cangjie.a
target/release/immer_cangjie/immer_cangjie.cjo
其中 .a 用于链接,.cjo 提供编译器需要的接口信息。独立示例使用本地路径依赖:
toml
[dependencies]
immer_cangjie = { path = ".." }
示例配置把当前目录作为源码目录,因此保持用户要求的 example/example.cj 文件位置,不额外创建示例 src/ 目录。
第 3 阶段:把 JavaScript 动态状态映射为仓颉类型
3.1 数组和对象
对象使用 HashMap<String, ImmerValue>,数组使用 ArrayList<ImmerValue>。这两个容器被放进 ImmerValue 枚举中,recipe 可以通过 ImmerDraft 访问当前根值:
cangjie
let state = objectWith("count", Integer(0))
let next = produce(state, { draft =>
draft.set("count", Integer(1))
})
对象字段写入使用 set,读取使用 get,删除使用 remove;数组草稿使用 append 增加元素。容器的元素类型固定为 ImmerValue,因此不需要使用仓颉无法静态检查的无类型 Any。
3.2 用 ImmerValue.Null 表示缺失值
原版 Immer 运行在 JavaScript 的动态对象模型中,读取不存在的字段通常会得到 undefined。本适配在 ImmerDraft.get 中使用 ImmerValue.Null 表示缺失字段:
cangjie
let draft = ImmerDraft(emptyObject())
let missing = draft.get("missing")
调用方可以通过 match 区分 Null 和实际值。这样空值行为在类型层面明确,不会把不存在的字段混淆成一个具体的字符串或数字。
3.3 Recipe 函数保持参数类型
produce 的 recipe 使用明确的函数值类型 (ImmerDraft) -> Unit。recipe 只负责描述对草稿的修改,复制和结果返回由 produce 统一处理:
cangjie
let next = produce(state, { draft =>
draft.set("count", Integer(1))
})
这种签名让调用方在编译期知道 recipe 接收的是 ImmerDraft,也避免了 JavaScript 中任意参数数量和任意返回值形状带来的不确定性。
第 4 阶段:分层实现源码
4.1 状态值和核心函数 src/immer.cj
核心文件定义 ImmerValue、ImmerDraft、ImmerPatch 和 ImmerResult,并实现 produce、clone、emptyObject、emptyArray、objectWith 和 arrayOf。produce 先深拷贝输入值,再把拷贝包装为草稿,recipe 执行结束后再次复制结果,确保调用方不能通过旧容器引用继续修改返回值。
4.2 草稿操作和值比较
ImmerDraft 把常用的对象和数组变更集中起来:set、get、remove 处理对象字段,append 处理数组元素。isEqual 递归比较标量、对象字段和数组元素,用于测试和调用方的状态判断。
4.3 补丁结果 produceWithPatches
produceWithPatches 返回 ImmerResult,其中包含 value、patches 和 inversePatches。当前版本记录整个根状态的 Replace 操作,正向补丁携带新状态,反向补丁携带原状态:
cangjie
let result = produceWithPatches(state, { draft =>
draft.set("count", Integer(1))
})
这种实现保留了补丁交付形态和回滚方向,后续可以在不改变调用入口的情况下继续细化到对象路径和数组下标。
第 5 阶段:建立自动化测试
test/main.cj 是一个直接链接仓颉静态库的可执行测试程序。测试覆盖以下行为:
| 测试组 | 示例 |
|---|---|
| 对象创建 | objectWith 创建初始状态 |
| 草稿修改 | produce 调用 ImmerDraft.set |
| 不可变性 | 原状态仍为 Integer(0) |
| 新状态 | 返回状态为 Integer(1) |
| 值比较 | isEqual 比较状态字段 |
断言失败会抛出异常并以非零状态退出;本测试程序实际编译运行成功。由于用户要求测试文件位于根目录 test/main.cj,它不属于 cjpm 默认扫描的 src/*_test.cj 目录,因此 cjpm test 会报告 0 个自动发现的测试;测试入口本身通过独立 cjc 编译和运行验证。
第 6 阶段:核对 Immer 仓颉导出名称
使用文本扫描核对 src/immer.cj 的公开声明,当前结果为:
text
Public types: 5
Top-level public functions: 8
Missing source declarations: 0
这个检查说明当前仓颉适配的公开入口都已经落到源码中,不代表它与 JavaScript Immer 的所有动态行为完全一致;行为仍以仓颉签名、测试断言和下文的兼容边界为准。
四、完整验证步骤
以下命令均以仓库根目录 immer/ 为当前目录。先确认仓颉编译器和包管理器可用:
bash
cjc --version
cjpm --version
然后检查并构建静态库:
bash
cjpm check
cjpm build
编译并运行测试入口:
bash
cjc test/main.cj \
--import-path target/release/immer_cangjie \
-L target/release/immer_cangjie \
-limmer_cangjie \
-o /tmp/immer-cangjie-test
/tmp/immer-cangjie-test
再构建并运行独立示例工程:
bash
cd example
cjpm update
cjpm build
cjpm run
示例会直接调用 produce 并打印结果,而不是打印预先写死的文本:
text
count = 1
cjpm run finished
cjpm 可能提示某些原有目录没有 .cj 文件。这些是目录扫描警告,不影响 cjpm build success 和示例运行结果。
五、运行效果
下面的效果图来自 example/example.cj 的实际终端运行,包含静态库构建后的 Immer Cangjie 调用结果和最终完成状态。

终端中的 count = 1 说明 recipe 已经把新状态的 count 更新为 1;测试程序同时检查旧状态仍保持 0。截图中的目录扫描 Warning 只表示 JavaScript 项目目录和构建目录里没有 .cj 文件,不是程序运行失败。
六、关键决策说明
6.1 为什么不用无类型 Any
仓颉适配的价值在于让调用方在编译期知道状态形状。项目使用 ImmerValue、HashMap<String, ImmerValue>、ArrayList<ImmerValue> 和明确的 recipe 函数值,保留对象、数组和回调的可检查边界。若所有状态都退化成无类型值,虽然表面上接近 JavaScript,却会失去仓颉的静态检查能力。
6.2 为什么保留 Immer 的 produce 名称
保留 produce、produceWithPatches 和 clone 等名称可以降低迁移成本。熟悉 Immer 的开发者只需要适应 ImmerValue 枚举、ImmerDraft 方法和仓颉的函数值写法,不必重新记忆一套完全不同的状态更新入口。
6.3 为什么把测试和示例分开
测试程序负责断言原状态和新状态,适合自动化执行;示例工程负责展示,适合在终端中观察真实结果和截图。两者都使用同一个 Immer 仓颉静态库,因此效果图来自库的实际运行输出,而不是模拟文本。
七、兼容边界和遗留问题
JavaScript Immer 与仓颉的运行时模型不同,以下限制需要在接入时提前了解:
- JavaScript 的任意动态值、异构数组、原型链和 Proxy 机制没有直接的仓颉静态等价物;当前适配以
ImmerValue状态树为边界。 - 当前
produce通过深拷贝保证原状态不变,优先保证语义清晰;它没有复刻原版 Immer 基于 Proxy 和结构共享的内部实现。 ImmerDraft的set、get和remove面向根对象草稿,append面向根数组草稿;更复杂的嵌套状态需要调用方按ImmerValue的对象或数组分支显式处理。produceWithPatches当前生成根级Replace补丁和反向补丁,不提供原版 Immer 那样的逐字段 JSON Patch 路径差异。- 仓颉
HashMap和ArrayList的元素类型固定为ImmerValue,不能直接接受任意 JavaScript 值或混合的宿主对象。 - 当前版本没有提供 JavaScript 的 Proxy、自动冻结、插件、Map/Set 专用适配和异步 recipe;这些能力需要后续根据仓颉运行时模型单独设计。
这些是适配边界,不是构建错误。构建时出现的 there is no '.cj' file 信息属于目录扫描警告;只要 cjpm build 成功、测试程序退出码为 0、示例输出 count = 1,就说明当前版本已经完成本地编译和行为验收。
八、接入方式
在另一个仓颉项目中,可以将本仓库作为本地依赖使用。先在本仓库根目录生成 target/release/immer_cangjie,再把相同的 --import-path 和 -L 参数传给调用方的 cjc 命令。调用代码导入:
cangjie
import immer_cangjie.*
main() {
let state = objectWith("count", Integer(0))
let next = produce(state, { draft =>
draft.set("count", Integer(1))
})
println("state updated")
}
如果使用 cjpm 管理依赖,则在业务工程的 cjpm.toml 中加入本地路径依赖:
toml
[dependencies]
immer_cangjie = { path = "../immer" }
跨端项目可以复用相同的状态值、recipe 和测试思路;具体的 UI 更新、线程调度、持久化或网络同步由宿主应用负责。
九、总结
本次适配完成了 Immer 状态更新模型到仓颉静态类型体系的迁移:状态由 ImmerValue 表示,对象使用 HashMap<String, ImmerValue>,数组使用 ArrayList<ImmerValue>,recipe 通过 ImmerDraft 表达修改,clone 和 isEqual 提供复制与比较,produceWithPatches 保留补丁交付入口。
验证链路包括 cjpm check、cjpm build、测试程序独立编译运行和示例工程运行。最终效果图来自真实的仓颉可执行程序,能够直观看到状态从 count = 0 更新到 count = 1。对于无法一比一映射 JavaScript Proxy、结构共享和动态补丁路径的部分,项目通过静态签名和文档边界保持行为可解释,方便后续继续扩展。
适配过程 FAQ
以下问题来自仓颉工程创建、
cjpm依赖、与 HarmonyOS 设备联调的实际流程。
Q1:cjpm 依赖解析失败或拉取超时?
- 检查
cjpm.toml中的依赖坐标与版本号; - 配置可用的仓颉包仓库镜像或代理;
- 删除
target、cjpm.lock后重新执行cjpm update/cjpm build。
Q2:编译报找不到仓颉 SDK 或 cjc 版本不匹配?
- 设置
CANGJIE_HOME指向仓颉 SDK 根目录; - 确认
cjc --version、cjpm --version与工程要求的版本一致; - 清理构建缓存后重新生成。
Q3:与 HarmonyOS 设备联调时安装失败?
- 用
hdc list targets确认设备在线; - 确认 HAP 已签名,
bundleName与签名 profile 一致; - 设备需加入调试 profile 的
deviceIds。
Q4:动态库或 FFI 符号找不到?
- 检查导出符号名称、ABI 与打包路径;
- 确认
.so已随 HAP 一起打包到libs/<abi>/; - 用
llvm-readelf -d检查依赖库是否齐全。
Q5:测试用例在设备上失败,但本机通过?
- 对比设备与主机的架构、SDK 版本和环境变量;
- 用
hdc hilog抓取运行日志; - 优先复现最小用例,确认是否为平台差异或时序问题。