hanzi-writer适配微信小程序并发布到npm

前言

最近在开发「写个字吧」微信小程序端的笔顺演示模块,需要在字格中展示汉字的笔顺动画,并支持用户逐笔描红练习。和写个字吧官网的笔顺演示功能是一致的,本来想直接使用chanind/hanzi-writer 库,但是调试发现并不适用。

借助AI成功适配了微信小程序端,效果如下

效果演示

记录下将 hanzi-writer 适配到微信小程序,从 fork 源码、修改渲染层、处理兼容性问题,到最终发布到 npm 供社区使用的完整过程。

以下部分内容由AI生成,仅供参考


一、hanzi-writer 做了什么

在动手适配之前,先简单了解一下 hanzi-writer 的架构。它本质上是一个 Canvas 渲染引擎,核心流程是:

  1. 从 CDN 加载汉字的 SVG 笔顺数据(来自 Make Me a Hanzi 项目)
  2. 将 SVG 路径解析为内部笔画模型
  3. 通过 Canvas API 逐笔渲染,实现动画和交互

它支持四种模式:

模式 说明
Animation(动画演示) 自动播放汉字笔顺动画,展示每一笔的书写顺序
Stroke(逐笔绘制) 每次点击或调用 API 才画下一笔,适合教学场景
Quiz(笔顺练习) 用户在 Canvas 上手写,系统判断笔画是否正确
Display(静态展示) 显示完整汉字及其笔画轮廓

hanzi-writer 在 GitHub 上有 3k+ star,npm 周下载量稳定在数万次,是汉字笔顺领域的标杆项目。

hanzi-writer官网


二、浏览器 vs 小程序:不兼容的地方在哪里

把 hanzi-writer 放到微信小程序里跑,第一个报错就是 document is not defined。微信小程序的运行环境不是浏览器,没有 DOM、BOM 这些浏览器专属 API。

具体来说,有四个层面的不兼容:

2.1 Canvas 初始化方式不同

原版 hanzi-writer 通过 document.getElementById 获取 Canvas 元素,通过 document.createElement 动态创建 Canvas:

1
2
3
4
5
6
7
8
9
// 原版 — 依赖 DOM API
static init(elmOrId: string | HTMLCanvasElement) {
const elm = typeof elmOrId === 'string'
? document.getElementById(elmOrId) as HTMLCanvasElement
: elmOrId;
// ...
elm.setAttribute('width', `${width}`);
elm.setAttribute('height', `${height}`);
}

小程序中,Canvas 节点通过 wx.createSelectorQuery() 获取,且不支持 setAttribute,尺寸直接通过 .width / .height 属性设置。

2.2 没有 getBoundingClientRect()

hanzi-writer 大量使用 getBoundingClientRect() 来获取 Canvas 在页面中的位置,从而计算触摸点相对坐标。小程序的 Canvas 节点没有这个方法。

2.3 全局事件监听不可用

hanzi-writer 在 quiz 模式下通过 document.addEventListener('mouseup')document.addEventListener('touchend') 监听全局指针释放事件——这在小程序中完全不可行。小程序的事件绑定必须在 .wxml 模板中声明,通过 bindtouchstart / bindtouchmove / bindtouchend 处理。

2.4 Path2D 不支持

原版 hanzi-writer 使用 new Path2D() 构造笔画路径以加速渲染。但微信小程序的 Canvas 2D 不支持 Path2D,控制台会输出 new Path2D() should not be used 警告,且路径渲染可能异常。

总结成一张表:

问题 原版行为 小程序限制
Canvas 获取 document.getElementById wx.createSelectorQuery
Canvas 创建 document.createElement 模板声明,节点引用
尺寸设置 setAttribute('width', ...) canvas.width = ...
位置计算 getBoundingClientRect() 不存在,需要模拟
事件绑定 document.addEventListener bindtouchstart 模板绑定
路径缓存 new Path2D() 不支持,需禁用

三、适配方案:逐层改造渲染层

理清了不兼容点之后,适配思路就很清晰了——不需要重写整个库,只需修改渲染层中与平台相关的代码。基于原版 v3.0.0 源码,共修改了 4 个核心文件。

3.1 RenderTarget.ts —— 核心改动

这是改动最大的文件,涉及 Canvas 初始化、事件系统、尺寸管理的全面重写。

一、重写 init() 静态方法

移除了所有 DOM 操作,直接接收 Canvas 节点引用:

1
2
3
4
5
6
7
// 适配后 — 直接使用节点引用
static init(elmOrId: string | HTMLCanvasElement, width = '100%', height = '100%') {
const canvas = elmOrId as HTMLCanvasElement;
canvas.width = typeof width === 'number' ? width : parseInt(String(width), 10) || 150;
canvas.height = typeof height === 'number' ? height : parseInt(String(height), 10) || 150;
return new RenderTarget(canvas);
}

二、模拟 getBoundingClientRect()

返回一个基于 Canvas 实际尺寸的虚拟矩形:

1
2
3
4
5
6
7
8
9
getBoundingClientRect() {
const { width, height } = this.node;
return {
left: 0, top: 0, width, height,
x: 0, y: 0,
bottom: height, right: width,
toJSON: () => ''
};
}

三、事件系统改为手动触发模式

hanzi-writer 内部注册了 addPointerStartListener / addPointerMoveListener / addPointerEndListener 三个回调。适配版将回调缓存起来,提供三个公开方法供小程序页面在触摸事件中主动调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 缓存回调
addPointerStartListener(cb) { this._startCb = cb }
addPointerMoveListener(cb) { this._moveCb = cb }
addPointerEndListener(cb) { this._endCb = cb }

// 供页面调用的触发方法
emitTouchStart(x: number, y: number) {
this._startCb?.({ getPoint: () => ({ x, y }), preventDefault: () => {} })
}
emitTouchMove(x: number, y: number) {
this._moveCb?.({ getPoint: () => ({ x, y }), preventDefault: () => {} })
}
emitTouchEnd() {
this._endCb?.()
}

这样,小程序页面只需要在模板中绑定触摸事件,然后转发坐标:

1
2
3
4
5
<!-- WXML -->
<canvas type="2d" id="strokeCanvas"
bindtouchstart="onTouchStart"
bindtouchmove="onTouchMove"
bindtouchend="onTouchEnd" />
1
2
3
4
5
// TS
onTouchStart(e: any) {
const touch = e.touches[0];
this._renderTarget.emitTouchStart(touch.x, touch.y);
}

3.2 RenderTargetBase.ts —— 指针释放监听

addPointerEndListener 中的 document.addEventListener 改为 this.node.addEventListener,避免依赖全局 document 对象:

1
2
3
4
5
// 修改后
addPointerEndListener(callback: () => void) {
this.node.addEventListener('mouseup', callback);
this.node.addEventListener('touchend', callback);
}

3.3 CharacterRenderer.ts —— 禁用 Path2D

原版在构造 StrokeRenderer 时传入 stroke 对象,内部会自动创建 Path2D 路径。适配版传入 false 来禁用 Path2D,改为每次渲染时直接用 Canvas 2D API 绘制路径:

1
2
3
4
5
6
// 修改后 — 第二个参数传入 false,禁用 Path2D
constructor(character: Character) {
this._strokeRenderers = character.strokes.map(
(stroke) => new StrokeRenderer(stroke, false)
);
}

3.4 rollup.config.js —— 构建优化

  • target: 'es5' — 确保输出兼容性
  • skipLibCheck: true — 跳过类型库检查加速构建
  • CJS 输出 sourcemap: false — 避免小程序构建 npm 时产生 sourcemap 警告

额外改动

除了 4 个核心文件,还做了两处类型修复:

  • src/utils.ts 添加 declare const global: any,解决 types: [] 配置下找不到 global 的类型错误
  • src/Mutation.ts 添加 declare namespace NodeJS,解决 NodeJS.Timeout 类型缺失

四、npm 发布:从本地 fork 到可安装的包

4.1 发布前的准备工作

最早为了方便开发,在 xgzb-mini 项目中写了一个 build-mp.py 脚本,自动从 fork 目录 copy 源码并构建。但这显然不够优雅——应该让所有人都能通过 npm install 直接使用。

发布到 npm 需要准备以下几项:

package.json 关键配置:

1
2
3
4
5
6
7
8
9
{
"name": "hanzi-writer-wechatmini",
"version": "3.0.1",
"main": "dist/index.cjs.js",
"miniprogram": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/types/index.esm.d.ts",
"files": ["dist"]
}

其中 miniprogram 字段是微信小程序特有的,告诉开发者工具在”构建 npm”时使用哪个文件作为入口。

构建产物:

文件 格式 说明
dist/index.cjs.js CommonJS 小程序构建 npm 使用的入口
dist/index.esm.js ES Module 现代打包工具使用
dist/hanzi-writer.js IIFE 浏览器直接引用
dist/hanzi-writer.min.js IIFE 压缩版 浏览器生产环境

prepublishOnly 脚本:

1
"prepublishOnly": "npm run clean && rollup -c && node -e \"require('fs').appendFileSync('./dist/index.cjs.js','\\n')\""

发布前自动清理旧产物、重新构建,并在 CJS 文件末尾追加换行——这是实践中发现的小细节,微信开发者工具构建 npm 时对文件末尾换行有要求。

4.2 发布到 npm

1
2
npm login
npm publish --access public

2026 年 6 月 9 日,hanzi-writer-wechatmini v3.0.1 正式发布到 npm。

npm页

GitHub 仓库https://github.com/wooaooo/hanzi-writer-wechatmini

README 中包含了完整的适配说明、API 文档、使用方法,以及与上游的差异对照表,方便其他开发者了解这个分支与原版的具体区别。


五、在小程序中的实际使用

5.1 安装和构建

1
npm install hanzi-writer-wechatmini

安装后在微信开发者工具中点击 工具 → 构建 npm,会自动将 dist/index.cjs.js 复制到 miniprogram_npm/hanzi-writer-wechatmini/index.js

5.2 基本使用

在 WXML 中声明 Canvas:

1
2
3
4
5
6
7
8
9
10
<view class="stroke-wrapper">
<canvas
type="2d"
id="strokeCanvas"
style="width: 300px; height: 300px;"
bindtouchstart="onTouchStart"
bindtouchmove="onTouchMove"
bindtouchend="onTouchEnd"
/>
</view>

在 TS 中初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import HanziWriter from 'hanzi-writer-wechatmini';

Page({
_writer: null as any,
_renderTarget: null as any,

onLoad() {
wx.createSelectorQuery()
.select('#strokeCanvas')
.fields({ node: true, size: true })
.exec((res) => {
const canvas = res[0].node;
const dpr = wx.getWindowInfo().pixelRatio;

canvas.width = 300 * dpr;
canvas.height = 300 * dpr;

this._writer = HanziWriter.create(canvas, '汉', {
width: 300 * dpr,
height: 300 * dpr,
strokeAnimationSpeed: 1,
delayBetweenStrokes: 300,
});

this._renderTarget = this._writer.target;
});
},

// 触摸事件转发(仅 quiz 模式需要)
onTouchStart(e: any) {
const t = e.touches[0];
this._renderTarget?.emitTouchStart(t.x, t.y);
},
onTouchMove(e: any) {
const t = e.touches[0];
this._renderTarget?.emitTouchMove(t.x, t.y);
},
onTouchEnd() {
this._renderTarget?.emitTouchEnd();
},
});

六、踩坑记录

适配过程中踩了不少坑,挑几个印象深的记录下来:

6.1 Canvas 尺寸与像素比

小程序中 Canvas 的 CSS 尺寸和实际像素尺寸是两回事。CSS 尺寸通过 WXSS 控制,实际像素尺寸需要乘以 pixelRatio。如果忘记这一步,在高 DPI 设备上笔画会模糊。

1
2
3
const dpr = wx.getWindowInfo().pixelRatio;
canvas.width = cssWidth * dpr;
canvas.height = cssHeight * dpr;

6.2 触摸事件坐标

小程序 Canvas 的 e.touches[0].x 返回的是相对于 Canvas 左上角的坐标,直接传给 hanzi-writer 就行,不需要再做坐标变换——这是和浏览器 e.clientX 的重要区别,也是适配中的一个幸运点,省去了坐标换算的麻烦。


七、总结

hanzi-writer 是浏览器端的优秀作品,但微信小程序的运行环境差异注定了它无法开箱即用。好在它的渲染层设计得比较清晰,我们不需要重写业务逻辑,只需要把与平台 API 交互的少数几个文件做适配,就能让它在小程序里正常运行。

适配的核心思路

  1. Canvas 节点通过 wx.createSelectorQuery 获取,直接用引用而非 ID 字符串
  2. 事件系统改为手动触发模式,由小程序模板绑定触摸事件后转发坐标
  3. 禁用 Path2D,改用原生 Canvas 2D API 绘制路径
  4. 模拟 getBoundingClientRect(),返回基于 Canvas 实际尺寸的虚拟矩形

发布到 npm 的要点

  1. miniprogram 字段:告诉微信开发者工具使用哪个入口文件
  2. CJS 输出不生成 sourcemap:避免构建警告
  3. prepublishOnly 确保构建质量:发布前自动清理 + 构建 + 修复
  4. 完整的 README 文档:帮助其他开发者快速上手

从 fork 代码到 npm 发布,整个过程大约花了两天时间。现在 hanzi-writer-wechatmini 已经在「写个字吧」小程序中稳定运行,也希望这个小工具能帮助到其他需要在微信小程序里展示汉字笔顺的开发者。


相关链接