纯浏览器端生成 Anki 卡组(.apkg):ankipack + sql.js 实战与踩坑记录
最近在做一个 PDF 转记忆卡片 的功能时,我把 .apkg 文件的生成从服务端整体迁移到了浏览器端。整个过程踩了不少坑,网上又几乎没有中文资料,这里完整记录一下,给后来人省点时间。

一、为什么要放在浏览器端做
.apkg 本质上是一个 ZIP 包,里面装着一个 SQLite 数据库(collection.anki21b,zstd 压缩)和媒体文件。也就是说,"生成一个卡组" = 建一个 SQLite 库 + 写 notes/decks 表 + 打包成 ZIP。
一开始我是放在服务端做的:前端把编辑好的卡片 POST 回来,服务端打包后返回二进制流。能用,但有两个问题:
- 这个接口没法收钱也不敢收钱------用户生成卡片时已经扣过费,导出只是重新打包,再扣一次说不过去;
- 免费 + 登录即可 = 攻击面。每次请求都是实打实的 CPU 开销(SQLite 建库 + zstd 压缩),刷起来很舒服。
后来想明白一件事:卡片数据本来就 100% 来自前端请求体,服务端纯属充当一个"昂贵的序列化工具"。那为什么不直接在浏览器里做完?服务器不再经手任何数据,上面两个问题直接消失。
二、选型:为什么是 ankipack
浏览器端生成 .apkg 的 JS 库,能找到的大致三个:
| 库 | 状态 | 备注 |
|---|---|---|
| genanki-js | 2021 年后停更 | AGPLv3 协议(商用注意),非 npm 包,要自己拼 sql.js + JSZip + FileSaver |
| anki-apkg-export | 多年失修 | webpack loader 时代的产物,有 sql.js 内存限制的已知 issue |
| ankipack | 活跃维护 | MIT,TypeScript 原生,官方支持浏览器/Node/Bun |
ankipack 几个打动我的点:
- 写的是 Anki 现代格式(schema V18、protobuf 编码的 deck config、FSRS 调度参数),Anki 2.1.55+(2022 年中以后的所有版本)原生导入;
- sql.js 是 optional peer dependency,由调用方初始化后传入------这意味着你可以完全控制 WASM 二进制的加载时机;
- 运行时依赖只有三个(fflate / fzstd / @bufbuild/protobuf),浏览器 bundle 约 63 kB gzipped,都不重。
三、核心实现
思路很简单:ankipack 和 sql.js 都不走首屏,用户第一次点"导出 .apkg"时才动态 import;WASM 文件放静态目录,让 CDN 缓存去扛。
typescript
import type { SqlJsStatic } from 'sql.js';
let sqlJsPromise: Promise<SqlJsStatic> | null = null;
/** sql.js WASM 约 640KB ------ 每个页面只加载一次,而不是每次导出都加载 */
function getSqlJs(): Promise<SqlJsStatic> {
if (!sqlJsPromise) {
sqlJsPromise = (async () => {
const initSqlJs = (await import('sql.js')).default;
return initSqlJs({
locateFile: () => '/sqljs/sql-wasm.wasm',
});
})();
}
return sqlJsPromise;
}
export async function exportAnkiApkg(cards: AnkiCard[], deckName: string) {
const [{ Package, Deck, Note, Notetype }, SQL] = await Promise.all([
import('ankipack'),
getSqlJs(),
]);
const deck = new Deck({ name: deckName });
const basic = Notetype.basic();
const cloze = Notetype.cloze();
for (const card of cards) {
const fields = [toApkgField(card.front), toApkgField(card.back)];
const notetype = card.type === 'cloze' ? cloze : basic;
deck.addNote(new Note({ notetype, fields, tags: card.tags }));
}
const pkg = new Package();
pkg.addDeck(deck);
// 直接拿到字节流,Blob 一下触发下载即可
return pkg.toUint8Array(SQL);
}
下载部分就是标准操作:
typescript
const bytes = await exportAnkiApkg(cards, deckName);
const blob = new Blob([new Uint8Array(bytes)], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${deckName}.apkg`;
a.click();
URL.revokeObjectURL(url);
四、踩坑记录(本文最有价值的部分)
坑 1:webpack 客户端构建直接报错
上线前构建就挂了:
Module build failed: UnhandledSchemeError: Reading from "node:fs/promises"
is not handled by plugins (Unhandled scheme).
原因:ankipack 的 writeToFile()(Node/Bun 专用方法)里有一句 await import("node:fs/promises")。浏览器导出路径根本不会执行这段代码,但 webpack 会静态分析所有 动态 import,而 web target 不认识 node: 协议。
第一个想到的 resolve.alias 指向空模块------没用 ,alias 对 node: scheme 的请求不生效。最后有效的方案是 NormalModuleReplacementPlugin:
javascript
// next.config.js
if (!isServer) {
const webpack = require('webpack');
config.plugins.push(
new webpack.NormalModuleReplacementPlugin(
/^node:fs\/promises$/,
'data:text/javascript,export default {}'
)
);
}
用 Vite/esbuild 的话同理:esbuild 加 --alias:node:fs/promises=./empty.js(指向一个 export default {} 的空文件)即可。
坑 2:sql.js 的 browser 入口默认找的 WASM 文件名不一样
sql.js 的 package.json 里 browser 入口是 sql-wasm-browser.js,它默认去找的文件名是 sql-wasm-browser.wasm ,而 Node 入口找的是 sql-wasm.wasm。如果你的 locateFile 写成拼接文件名:
typescript
locateFile: (file) => `/sqljs/${file}` // browser 下拼出 /sqljs/sql-wasm-browser.wasm ------ 404
而 dist 目录里这两个 .wasm 文件其实是字节级相同 的。最稳的做法是别管传入的文件名,直接固定路径:locateFile: () => '/sqljs/sql-wasm.wasm'。
坑 3:TypeScript 5.7 的 Uint8Array 泛型
TS 5.7 之后 Uint8Array 有了泛型参数,toUint8Array() 返回的 Uint8Array<ArrayBufferLike> 直接塞进 new Blob([...]) 会报类型不兼容(SharedArrayBuffer 缺属性之类的提示看着很吓人)。复制一层即可:
typescript
new Blob([new Uint8Array(bytes)])
五、怎么验证产出的 .apkg 是合法的
不用装 Anki 也能做自动化验证,两个层面:
- magic 检查 :apkg 是 ZIP,头两个字节必是
PK; - 解开看内容 :ZIP 解压后拿到
collection.anki21b,头四个字节是 zstd 的 magic(28 b5 2f fd),用 fzstd 解压后再拿 sql.js 读notes表,能查到卡片就算通。
我在真实浏览器里跑了这条完整链路(加载 WASM → 打包 → 校验产物),全部通过后才敢上线。浏览器里直接执行打包逻辑的验证方式也简单:把测试脚本用 esbuild 打成 IIFE,在页面上下文里 eval,检查全局变量上的结果即可。
写在最后
这套方案跑起来之后,导出变成了纯本地操作:用户点按钮,第一次多花一秒左右加载打包模块(之后有缓存),之后所有导出零网络请求。对用户是隐私加分(卡片内容不出浏览器),对站长是砍掉了一个需要限流的免费接口。
我把这套东西用在了自己做的 PDF 转 Anki 卡片 功能里------上传 PDF,AI 起草问答卡或填空卡,浏览器里逐张编辑筛选,然后一键导出 .apkg 直接导入 Anki,全程卡片数据不离开浏览器。如果你也在做类似的功能,欢迎交流。
参考: