首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >开源鸿蒙三方库适配:我把 ArkTS 移植经验写成 Skill,再拿 fast-diff 试了一遍

开源鸿蒙三方库适配:我把 ArkTS 移植经验写成 Skill,再拿 fast-diff 试了一遍

原创
作者头像
LucianaiB
发布于 2026-11-14 15:43:12
发布于 2026-11-14 15:43:12
150
举报
文章被收录于专栏:AIAI

前几天我把 jsonrepair 移植成了 ArkTS 版,过程写在另一篇文章里。做完回头看,花时间的其实不是改语法。真正费劲的是顺序和检查:动手前要确认没人做过,改代码前要先让上游的测试能跑在我的代码上,编译报错要一条条对着改,跑出来的数字哪些能信、哪些只代表电脑上的结果,也得分清。换一个库,这些又要从头想一遍。

我干脆把这套做法写成了一个 Skill。Skill 可以理解成一份写给 AI 编程助手的操作手册,助手读了它,就按里面的步骤和规矩干活。我给它起名叫 harmony-js-lib-port。写完我没急着写文章,先挑了一个 ohpm 上还没有的库 jhchen/fast-diff,让助手照着 Skill 从头移植一遍,看这份手册到底靠不靠谱。ohpm 是鸿蒙的三方库中心仓,相当于鸿蒙版的 npm。结果库是移植成功了,Skill 自己倒被我改了 11 处。下面主要讲影响最大的几处是怎么改出来的。

这次移植的代码和 Skill 都放在我的 AtomGit 仓库里,地址是 https://atomgit.com/lwcwam/fast-diff-arkts 。Skill 在仓库的 skills/harmony-js-lib-port 目录下,打开里面的 SKILL.md 就能看到完整步骤。

一、为什么要写这个 Skill

三方库适配,每一步单看都不难。麻烦在于漏掉一步不会当场报错,只是到头来结论站不住。ArkTS 严格模式编译通过,只说明语法合法。行为是不是和上游一样,得靠上游自己的测试,再加上拿同一批输入分别喂给原包和移植版、逐条比对输出,我管后面这个叫差分对照。在电脑上跑测试,用的是 Node 的 V8 引擎;到了鸿蒙上,执行代码的是方舟运行时。两边表现是否一致,又是另一回事。

用 AI 助手做这类活,我碰到最多的问题是它每次都要重新摸索一遍。这次记得先搭差分测试,下次可能一上来就改代码;这次知道 charAt 越界会返回空串,下次照样踩坑。所以我写 Skill 不是为了教它写代码,是想把几件事钉死:先做什么,做到什么程度才算完,哪些话不能乱说。比如没在设备上跑过,就不许写「已在鸿蒙上验证」。

二、Skill 长什么样

目录不大。SKILL.md 管整体流程,编译报错的对照表、命令和模板都放在旁边的子目录里,用到再翻:

代码语言:markdown
复制
skills/harmony-js-lib-port/
├── SKILL.md                     # 六个步骤、规则、验证闸门
├── references/
│   ├── arkts-strict-fixes.md    # 编译报错 → 改法对照表
│   ├── verification-gates.md    # 每道闸门的命令和通过标准
│   ├── porting-log-template.md  # 移植日志模板
│   └── changelog.md             # v1 → v2 改了什么、为什么
├── scripts/
│   ├── check-ohpm.sh            # 查重:ohpm info + 中心仓搜索接口
│   └── scaffold.sh              # 一条命令生成工程
└── templates/                   # 根工程 / HAR / 调用方 entry / 差分测试

SKILL.md 开头这几行,决定了助手什么时候会想起用它:

代码语言:markdown
复制
---
name: harmony-js-lib-port
description: 把一个纯 JS/TS 的 npm 库移植成 OpenHarmony / HarmonyOS 可用的 ArkTS 严格模式 HAR,并用上游官方测试集 + 原包差分在主机侧证明语义没变。用户要「把 xxx 库适配到鸿蒙 / 开源鸿蒙三方库适配 / 做成 ohpm 包」时使用。不适用于依赖 DOM、Node 内置模块(fs、stream、Buffer)或原生扩展的库。
---

正文第一句我是这么写的:目标只有一个,ArkTS 严格模式编得过,行为和上游一模一样;性能优化、新 API、示范界面都放到以后,别在移植阶段混进来。这是 jsonrepair 那次留下的教训。当时我一边移植一边做性能优化,差分测试得同时兜住两件事,出了问题很难分清是哪边引起的。

后面是六个步骤:查重,固定上游版本,搭工程,先搭差分测试再改代码,逐个文件移植,然后过验证闸门。闸门就是每一步结束时必须通过的检查,过不了就不往下走。表里有几个词先解释一下:HAR 是鸿蒙的静态库包,移植好的库就打包成一个 .har 文件;codelinter 是鸿蒙命令行工具自带的代码检查工具;hdc 是连接设备用的命令行工具。v1 版的闸门是这样写的:

闸门

命令

通过标准

G1 编译

hvigorw --mode module -p module=<har>@default -p product=default assembleHar --no-daemon

BUILD SUCCESSFUL,产出 .har

G2 静态检查

codelinter .

0 错误

G3 上游官方测试

cd tools/upstream-difftest && npm test

上游用例全部通过,数量和上游一致

G4 原包差分

同上,extra/oracle-diff.test.ts

固定种子 ≥1000 条输入与 npm 原包逐字节一致

G5 设备

hdc list targets 非空时装示范 HAP 或跑 hypium

能在方舟上跑出同样结果;没有设备就明确写「未在设备上验证」

这张表后来改得最多。下面这张图是改完以后的流程,橙色标签标的是 v2 动过的地方。

三、拿 fast-diff 走一遍

挑库和查重

拿来验证的库,我定了几个条件:纯 JS 或 TS 写的,许可证宽松,自带测试,不依赖浏览器 DOM 和 Node 内置模块,代码量在一两千行以内。我先想到的是 compare-versions 和 ms,一查 ohpm,两个都已经有人搬上去了。于是换成 fast-diff。它是一个按字符比对两段文本差异的库,Quill 编辑器的 delta 就依赖它,算法来自 google-diff-match-patch,整个库就一个 1138 行的文件,许可证是 Apache-2.0。

Skill 的第 0 步是查重,脚本会同时按精确包名和关键词去中心仓搜。fast-diff 和 diff-match-patch 都搜不到。按 text diff 搜出来一个 diff 7.0.0,那是另一个库 jsdiff 的移植,算法和 API 都不一样,不算重复。不过我还是在 README 里把两者的区别写清楚了。

环境与版本

项

值

上游

fast-diff 1.3.0,对应提交 3398e94,之后的两次提交只改了 README 和 LICENSE;许可证 Apache-2.0;npm 包里的 diff.js 与仓库一致

工具链

Command Line Tools 26.0.0.865:hvigor 6.26.9,SDK API 26,ohpm 26.0.0.630;JDK 21

工程配置

compatibleSdkVersion 6.0.0(20),targetSdkVersion 26.0.0,Stage 模型,ArkTS 严格模式

主机侧测试

CLT 自带 Node v24.14.1,vitest 4.1.4,tsx;上游测试依赖 lodash、seedrandom

工程结构

仓库 fast-diff-arkts 里有三部分:fastdiff 目录是库本身,打包成 HAR,包名 @lwcwam/fast-diff;entry 是调用这个库的示例应用;tools/upstream-difftest 放电脑上跑的测试

交给助手的只有三样东西:这个 Skill,上游的包名和版本,工程目录。ArkTS 的规则我没另外写提示词,全在 Skill 里。每一步做完,我都让它把命令和原始输出存成日志。文章里的终端截图都是从这些日志渲染出来的,内容没有手改。

先接测试管道

Skill 要求先打通一条管道:让上游的测试跑在移植后的代码上,通了再去改语法。v1 的模板默认上游测试是用 vitest 或 jest 这类测试框架写的,改一下 import 就能接上,jsonrepair 就是这样。fast-diff 不一样。它的测试是一个 398 行的 Node 脚本,用 CommonJS 的 require("./diff.js") 引入库,依赖 lodash 和 seedrandom,出错就 throw,全部通过时打印 Success!。它还会读 diff.INSERT 这种挂在函数对象上的属性,而且每次运行都换一个随机种子。

接法其实很简单,只把那一行 require 换掉,测试文件其余部分一个字不改:

代码语言:javascript
复制
const TESTS = {
  // fast-diff's test.js is a plain CommonJS script (no test framework); it requires ./diff.js
  'test.js.txt': { 'require("./diff.js")': 'require(process.env.DIFF_TARGET === "upstream" ? "fast-diff" : "./diff-shim.cts")' },
}

diff-shim.cts 这个文件把移植版的 diff 包了一层,再把 INSERT、DELETE、EQUAL 三个常量挂回去。DIFF_TARGET=upstream 这个开关是这一步额外加的。同一份测试先拿 npm 原包跑,通过了,说明管道本身没毛病,后面再出红灯,就只可能是移植的问题。v1 里没有这一条。v2 在差分管道那一步补了两句:先看清上游测试是什么形态,再拿原包把管道跑通。

搭管道的时候还发现 scaffold.sh 有个 bug。这个脚本负责一键生成工程骨架,它先替换占位符、后复制差分测试模板,结果 prepare.mjs 里的 __HAR__ 没被换掉。问题不大,可它说明 v1 的脚本我自己都没完整跑过一遍。

直译,然后让编译器列清单

按 v1 的第 4 步,助手先照规则表把代码改写了一轮:var 改成 let,补上类型,正则字面量换成 new RegExp。规则里写着嵌套函数要改成私有类,可这两个内层函数一个外层变量都没用到,助手就直接把它们挪到了模块顶层。然后是第一次编译:

只报了 4 条。三条 arkts-no-any-unknown 都出在 new Array(v_length) 上,写成 new Array<number>(v_length) 就好了。剩下一条是可空类型的收窄问题:上游靠逻辑保证 hm1 不会是空值,可 TypeScript 推断不出来。

代码语言:typescript
复制
// 上游(直译后报错)
if (!hm1 && !hm2) {
  return null;
} else if (!hm2) {
  hm = hm1;   // Type 'string[] | null' is not assignable to type 'string[]'
} else if (!hm1) {
  hm = hm2;
} else {
  hm = hm1[4].length > hm2[4].length ? hm1 : hm2;
}

// 改写后:分支一一对应,不用 as 和 !
if (hm1 !== null && hm2 !== null) {
  hm = hm1[4].length > hm2[4].length ? hm1 : hm2;
} else if (hm1 !== null) {
  hm = hm1;
} else if (hm2 !== null) {
  hm = hm2;
} else {
  return null;
}

改完再编译一次就过了。这反倒让我有点不放心。editBefore: { ... break editBefore; } 这种带标签的代码块,diff.INSERT = DIFF_INSERT 这种往函数上挂属性的写法,s.match(re) && ... 这种把对象当布尔值用的写法,我本来以为都会被拒,编译器却一声没吭。

我就让助手写了一个探针文件,把上游代码里常见的写法一个个放进去编译,看哪些真的不行:

被拒的有嵌套函数、var、索引签名、没声明类型的对象字面量、函数表达式、解构赋值,还有给函数挂一个函数类型的属性。正则字面量不在其中。v1 的规则表写着不支持正则字面量、要改成 RegExp,那是我照旧资料写的,jsonrepair 也照着改了,可在现在的工具链上根本不用改。改了不会出错,但每多改一处,就多一个要靠差分测试兜底的地方。所以我把 Skill 第 4 步改成先直译、再按报错改,编译器没报的写法一律不动。

同一个探针文件留在工程里,再跑一遍 codelinter,结果是这样:

明明有 11 处 ArkTS 语法错误,codelinter 却报 Errors: 0,只提示 ForEach 缺 keyGenerator、启动图标太大。v1 把 codelinter 零错误当成一道闸门,可它根本不查 ArkTS 语法,只能当补充。v2 就在 G2 那一行加了一句:它不查 ArkTS 语法,不能代替 G1 的编译。

输出怎么检查

编译过了,接着跑差分。第一版差分测试是助手自己写的,图省事直接写成 expect(port(a, b)).toEqual(upstream(a, b)),意思是移植版和原包的输出必须相等。emoji 那一组当场栈溢出。下面这张截图是我事后按第一版写法重新跑出来的,报错和当时一样:

调用栈全在移植版里,看着像是我把递归改坏了。其实 v1 的模板里写了一个 run(),把两边的调用都包起来,比较的是结果或者抛出的异常。只是正文没讲为什么要这么做,写测试的时候就被跳过了。换成 run() 以后才看清楚:npm 原包在同样的输入上也栈溢出。

代码语言:typescript
复制
type Outcome = { ok: true; out: unknown } | { ok: false; err: string }
function run(f: () => unknown): Outcome {
  try { return { ok: true, out: f() } } catch (e) { return { ok: false, err: (e as Error).name + ': ' + (e as Error).message } }
}
// const want = run(() => upstream(a, b, c, cleanup))
// expect(run(() => port(a, b, c, cleanup)), `case ${n}`).toEqual(want)

问题出在随机生成的输入上。一个 emoji 在 JS 字符串里占两个 UTF-16 码元,生成器按码元切字符串,把 emoji 从中间切开,留下半个字符,这种半个字符叫孤立代理项。fast-diff 本来就假定输入是合法的 Unicode 字符串,所以这不是移植的 bug,是上游遇到非法输入时本来的行为。Skill 里有一条规矩:不要顺手修上游的怪行为。它这时候派上了用场,移植版保持和上游一致,这个行为写进 README 的已知限制。v2 的差分那一步也写明了:要比较结果或异常,随机输入要遵守上游对输入的约定。

之后差分拆成了两组。一组是合法输入,按完整字符切,要求输出完全一致,而且原包一次都不能抛异常。另一组专门按码元切,只要求两边失败的方式相同。这一组 5000 次调用里,原包失败了 126 次,移植版在同样的输入上也都失败了。电脑上跑出来的结果是这样的:

上游的 test.js 我换了 6 个随机种子各跑一次,都通过了。它覆盖 7 组回归用例、两轮各 10000 次的随机模糊测试、72 条光标用例,每条光标用例还会派生出 6 种变体,另外还有 emoji 用例和语义整理。原包差分用了 4 种字母表,分别是 DNA 序列、带换行的英文、emoji 和中英混排,每种 2500 对输入,cleanup 开关开着、关着各跑一次,一共 20000 次调用,结果逐项一致。

测试全绿还不够,我得确认它们真能抓到错。办法是故意把代码改坏,看测试会不会变红,这叫突变测试。我让助手改坏了两处:一处是把语义整理里的 score >= bestScore 改成 >,另一处是删掉计算公共前缀时防止把 emoji 切成两半的那一步。

两处都被抓到了,改回去又恢复全绿。这一步在 v2 里成了闸门 G4b:故意改坏一处,测试必须变红。

还剩调用方。HAR 自己能编译,只能说明库内部合法;别的 ArkTS 模块按正常写法调用它行不行,是另一回事。比如编译生成的类型声明文件里,diff.INSERT 变成了 declare namespace diff { var INSERT: number };CursorInfo 这种联合类型的参数,调用方传对象字面量时必须用导出的 interface 标注类型。所以我加了一个最小的 entry 模块,把每个导出都调用一遍,diff.INSERT 也读了一次。这一步在 v2 里成了闸门 G1b:

设备这一步没做成。本机的 HarmonyOS 7.0 模拟器一开始因为 /dev/kvm 的权限问题起不来,处理完能启动了,可两次冷启动都在 72 秒左右出事:系统核心服务 foundation 崩溃,系统 panic 重启,hdc list targets 一直查不到设备。entry 里我已经写好了方舟上的自检:先在电脑上用 npm 原包对 400 条固定输入算好结果,打包进应用后逐条比对,再按上游 test.js 的规则校验 3000 次随机 diff。这段代码编译过了,但还没在方舟上跑过。所以这篇文章里说的通过,都只限于电脑上的测试和编译层面。

四、哪些地方管用,哪些还得人来判断

最管用的是顺序。差分管道在第一次编译之前就搭好了,编译一过,马上就知道行为对不对。查重在挑库阶段就挡掉了两个候选,省得我白读源码。不要顺手修上游那条规矩,让助手在栈溢出时停下来记录,而不是去修一个上游本来就有的行为。要是它当时动了递归逻辑,移植版和原包的输出就对不上了,差分反而会变红,整件事只会更乱。

需要我自己拿主意的地方也不少。挑哪个库,Skill 只给了硬性条件,要选一个真有用、又能半天做完的,得靠我对使用场景的判断。栈溢出那一下,到底是生成器的问题还是移植的问题,Skill 只提醒了要比较异常,输入约定这一层是事后才补进去的。diff.INSERT 留不留,一边是和上游 API 保持一致,一边是更像 ArkTS 的写法,我选择留着,同时另外导出 DIFF_INSERT 这些常量。模拟器起不来的时候,我接受这一步没做,没有拿 jsonrepair 那次的截图来凑。Skill 本身要不要改、改哪几条,助手可以列出来,可哪条是 Skill 写错了,哪条只是这个库比较特殊,还得我自己想清楚。

五、改了哪几条规则,为什么改

v2 一共改了 11 处,完整清单在仓库的 skills/harmony-js-lib-port/references/changelog.md 文件里。下面这张表只列前面走流程时碰到的几处:

改了什么

为什么改,在 fast-diff 上碰到了什么

第 4 步:从「拿规则表预先改写」改成「先直译,让编译器列清单」

第一次编译只报了 4 条,而预先改掉的正则字面量根本不用改

规则表重写,细节写在表后面

v1 写的「不支持正则字面量」是按旧资料写的,探针文件实测能编译

嵌套函数不再一律改类:先看有没有捕获外层变量,再决定怎么改

jsonrepair 的内层函数共享游标和输出字符串,改成私有类是对的;fast-diff 的两个内层函数什么都没捕获,直接提到模块级就行

差分管道:先认清上游测试是哪种形态

fast-diff 的测试是 398 行的 CommonJS 脚本,不是 vitest 或 jest 用例,改一下 import 接不上

差分管道:先用 DIFF_TARGET=upstream 开关拿 npm 原包把管道跑通

原包先通过,说明管道本身没问题,后面再红就只可能是移植的问题

差分要用 run() 比较结果或异常,随机输入要遵守上游的输入约定

直接 toEqual 时 emoji 那一组栈溢出,看上去像改坏了递归;换成 run() 才看清原包同样栈溢出,原因是生成器切出了孤立代理项

闸门新增 G1b 调用方编译:用最小 entry 模块按用户的写法调用每个导出

HAR 编得过不代表调用方能用,比如 diff.INSERT 在声明文件里变成了 namespace,CursorInfo 参数必须用导出的 interface 标注

闸门新增 G4b 突变:故意改坏一处,测试必须变红

把 score >= bestScore 改成 >,再删掉防止把 emoji 切成两半的那一步,两处都被抓到了,这样的绿灯才算可信

G2 补一句「它不查 ArkTS 语法,不能代替 G1」

探针文件里 11 处 ArkTS 语法错误,codelinter 报 Errors: 0

G5 写清模拟器的启动命令、/dev/kvm 的权限问题,以及没有设备时报告该怎么写

模拟器先因 /dev/kvm 权限起不来,之后两次冷启动都在 72 秒左右崩溃,hdc list targets 一直是空的

第 4 步现在是这么写的:

代码语言:markdown
复制
做法是**先直译、后按报错改**,不要拿着规则表把能想到的写法预先全改一遍:

1. 直译:`var` → `let`,给参数、返回值、空数组补类型,`new Array(n)` 写成 `new Array<number>(n)`。逻辑结构(标签块、`switch`、`while(true)`)原样保留。
2. 编译(G1)。ArkTS 报错带规则名,比如 `arkts-no-any-unknown`,按规则名查 `references/arkts-strict-fixes.md` 改。
3. 编译器没报的写法,不要为了「看起来像 ArkTS」去改:每多改一处,就多一处要靠差分测试兜底的地方。

规则表也跟着重写了。每一行都补上了编译器的原始报错和错误码,比如 10605008 ... (arkts-no-any-unknown);新加了一节,专门列实测能编译、不必改的写法;还记了一个小现象:编译结果的汇总行写着 ERROR:5,下面却只列了 4 条,探针那次是汇总 12 条、实际列出 11 条,看错误数要以逐条列表为准。

六、现在做到哪一步,还缺什么

fast-diff 的 ArkTS 版现在是这个状态:HAR 和调用方都能编译,上游 test.js 通过,和 npm 上的 fast-diff@1.3.0 比对了 20000 次调用,结果逐项一致,故意改坏的两处也都能被测试抓到。API 和上游一样,还是 diff(text1, text2, cursorPos?, cleanup?),diff.INSERT 这些常量也都留着。

还缺的我也写在这里。它没在方舟运行时上跑过,设备上的自检写好了,还没执行;电脑上的测试跑在 V8 上,不能等同于方舟。输入里有孤立代理项时会栈溢出,上游也一样,到了方舟上会怎样还不知道。包名定成了 @lwcwam/fast-diff,还没发布到 ohpm,现在要用只能先把仓库克隆下来,按本地路径引用。Skill 也只在两个库上用过,都是纯算法库。碰到依赖 Buffer、正则后行断言或者大量动态属性的库,估计还得再改一轮。

这次对我触动最大的,倒不是又多了一个能在鸿蒙上用的库。我原以为把经验写下来就够了,结果第一次真拿来用,就改了 11 处。有的是我凭印象写的规则,一实测就不对;有的规则没写为什么,助手执行时头一个跳过的就是它。下次再移植别的库,我还会照这个办法,边用边改。

仓库还是开头那个:https://atomgit.com/lwcwam/fast-diff-arkts 。Skill 在 skills/harmony-js-lib-port 目录里。每道闸门用了什么命令、跑出了什么结果,我都记在仓库的 docs/VERIFY.md 文件里。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、为什么要写这个 Skill
  • 二、Skill 长什么样
  • 三、拿 fast-diff 走一遍
    • 挑库和查重
    • 环境与版本
    • 先接测试管道
    • 直译,然后让编译器列清单
    • 输出怎么检查
  • 四、哪些地方管用,哪些还得人来判断
  • 五、改了哪几条规则,为什么改
  • 六、现在做到哪一步,还缺什么
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档