小程序集成HanziWriter:从web-view到Canvas重绘的实践
HanziWriter 是我在汉字学习类项目里用得比较顺手的一个开源库它把每个汉字的笔画顺序、字形路径都以 JSON 和 SVG 数据的形式暴露出来网页端直接调 API 就能生成一笔一画的书写动画配合鼠标或触摸事件还能做跟写练习。但一到微信小程序里情况就没那么美好了。小程序的运行环境跟浏览器有本质区别没有完整 DOM没有 SVG 节点操作能力很多原本在网页端开箱即用的库到了小程序里第一步就“落地成盒”。我最初想当然地把 HanziWriter 打包进小程序结果发现它内部大量操作 SVG 元素这套代码在小程序里根本跑不起来。这篇文章就把我在小程序端集成 HanziWriter 过程中的完整思考、路线选择和踩坑记录写下来给准备做汉字教学、笔顺练习、词典类小程序的朋友一个可参考的落地方案。1. HanziWriter 在网页端跑得挺顺为什么小程序里第一步就“落地成盒”1.1 先搞清楚 HanziWriter 到底做了什么HanziWriter 的核心能力其实可以拆成三块字符数据层每个汉字由若干笔画组成HanziWriter 使用的数据里包含每个笔画的 SVG path 轮廓以及笔画的书写中轴线medians动画就是靠这些数据驱动的。渲染层网页端默认使用 SVG 来渲染笔画路径通过给path设置stroke-dasharray、stroke-dashoffset或者动态更新 path 来实现逐笔书写效果。交互层实时监听鼠标/触摸点把用户输入的笔迹与目标笔画做匹配用于跟写练习场景。这个库在设计时假设自己运行在一个标准浏览器环境里可以自由创建 DOM 节点、操作 SVG 属性、使用requestAnimationFrame之外的浏览器 API。这些前提在小程序里全部不成立。1.2 小程序环境到底缺了什么先说结论小程序不是浏览器它的逻辑层运行在 JavaScriptCore 或 V8 里视图层由小程序框架自己接管。开发者拿不到document拿不到window更别提操作 SVG 了。我实测过直接在miniprogram目录下 npm 安装hanzi-writer然后 import 进页面项目能编译过但运行时立即报错报错点集中在document.createElementNS、getBBox这些浏览器专属 API 上。这不是 HanziWriter 写得不好而是它的运行前提跟小程序渲染机制天然冲突。所以在小程序里用 HanziWriter 只有两条路可以走把 HanziWriter 留在它熟悉的环境里运行小程序侧用web-view组件嵌入一个 H5 页面相当于在小程序里开了一个浏览器窗口。把 HanziWriter 当“数据源”用只提取它的字符数据和笔画逻辑渲染部分基于小程序 Canvas 自己重写。这两条路各有代价下面分别展开。2. 选路前先算账web-view 嵌入与 Canvas 重绘的取舍分析2.1 web-view 嵌入的思路这个方案的核心是不跟 HanziWriter 的 DOM 依赖硬碰硬而是让它在一个真正的浏览器环境里运行。小程序提供了web-view组件它会在页面上铺一个全屏的 WebView里面可以加载一个 HTTPS 网页。只要这个网页是在标准浏览器环境里运行的HanziWriter 就能原封不动地工作。从我的实际经验来看这种方案非常适合以下场景汉字笔顺展示用户只是看动画不需要跟原生小程序组件深度互动项目周期短需要快速上线一个可用版本后续主要是内容更新而不是交互升级。2.2 Canvas 重绘的思路另一个思路是把 HanziWriter 降级成一种“数据规范”虽然它的渲染代码不能直接搬进小程序但它定义的笔画路径数据格式、笔顺规则是通用的。我们完全可以自己写一个数据解析器把 SVG path 转成 Canvas 绘制指令再用requestAnimationFrame驱动逐笔动画。这个方案的适用场景是需要在小程序页面内直接嵌入笔顺演示比如点击一个汉字弹窗显示笔画用户需要在小程序原生 Canvas 上手指跟写并和写字板其他组件联动对首屏加载速度和交互流畅度有较高要求无法接受 web-view 白屏或通信延迟。2.3 两个方案的对比对比维度web-view 嵌入Canvas 重绘开发工作量低HanziWriter 原库直接可用高需要自己实现 path 解析和动画引擎首屏加载速度慢页面要加载空白 H5快只需加载数据 JSON原生交互弱消息通信有延迟和触发限制强可以直接响应触摸事件笔顺动画表现完整保留原库质感取决于你自己的实现质量后续扩展受限于 web-view 页面边界可以叠加评测、纠错等功能小程序包体积H5 页面需放服务器只需存字符数据体积可控我的最终建议会在文章后面给出来但先提醒一点如果你只是因为想快速看效果就选 web-view后面想加书写评测功能时会非常痛苦因为要把用户手写笔迹从 web-view 里传出来再在小程序里做匹配通信链路又长又不稳定。我在项目里就是因为这个原因最终放弃了 web-view 方案。3. 路线一web-view 方案的实施细节与双向通信处理3.1 落地步骤和域名配置如果你决定先用 web-view 方案跑通流程步骤如下建一个独立的 H5 页面在里面完整引入 HanziWriter 的 JS 和 CSS页面结构就是一个div idtarget。把这个 H5 页面部署到 HTTPS 服务器上。在小程序后台配置业务域名把 H5 页面的域名加进去。在小程序页面里写web-view srchttps://your-domain.com/hanzi-writer/index.html?char永 /。这里有个最关键的前置条件小程序中的web-view不允许加载任意网址必须在小程序管理后台配置业务域名而且要校验域名归属。开发阶段可以在开发者工具里勾选“不校验合法域名”但真机预览和正式版必须走正规域名配置。另外非常容易踩的一个坑是web-view组件会自动铺满整个小程序页面并且会覆盖掉页面上所有其他原生组件连自定义导航栏都可能被它顶掉。如果你需要保留小程序自己的顶部导航得在小程序页面里开启自定义导航或者在 H5 页面内部自己写一套导航栏。3.2 小程序和 H5 的双向通信这两端通信是我觉得 web-view 方案里最不顺手的地方。小程序向 H5 传参相对简单直接在src里拼 query 就行H5 侧用window.location.search解析。但这种方式有一个隐藏问题修改src会导致整个 web-view 重新加载页面如果用户正在看某个汉字的半截动画参数一变动画就没了。H5 向小程序传参要用wx.miniProgram.postMessage。在小程序页面里给web-view组件绑定bindmessage事件来接收web-view srchttps://your-domain.com/hanzi-writer/index.html bindmessageonWebViewMessage /小程序侧接收逻辑Page({ onWebViewMessage(e) { // e.detail.data 里就是 H5 页面 postMessage 传过来的数据 console.log(来自 H5 的消息, e.detail.data); } });但这里有一个容易让人困惑的限制postMessage传过来的消息并不是 H5 页面一调用就立刻回到小程序端而是要等特定时机才会触发bindmessage比如用户分享、后退、组件销毁的时候。我最初以为它是实时通信结果在调试面板里怎么等都等不到消息后来翻了文档才发现这个机制。所以如果你的场景里需要“用户每写完一个笔画小程序端立刻更新状态”这种实时反馈web-view 方案会让你很别扭。临时方案可以做定时轮询但体验和性能都不好。3.3 web-view 方案里的资源加载与白屏控制HanziWriter 网页端还会动态加载对应汉字的 JSON 数据。如果你把数据文件也放在 H5 服务器上需要保证所有资源走 HTTPS不能用 HTTPJSON 文件按需加载不要一次全量请求H5 首页面对小程序内置浏览器时要开启强缓存否则用户每次进入都白屏很久。我实测在 Android 上web-view 如果加载的 H5 页面资源较大首屏白屏时间可以达到 2 到 3 秒。优化方向是拆分字符数据、把常用字做成首屏预加载或者把整体页面做成 SPA 模式避免用户每次打开都是全新加载。4. 路线二用 Canvas 复刻 HanziWriter 渲染核心的关键步骤4.1 数据是第一优先级动画可以排在后面Canvas 重绘方案最核心的资产不是代码而是字符数据。HanziWriter 项目使用的数据格式很清晰每个汉字对应一个 JSON 对象核心字段有两个{ strokes: [ M 79.6 57.2 L 84.4 61.4 L ..., M 47.8 17.6 Q 52.4 15.7 ... ], medians: [ [ [79.6, 57.2], [84.4, 61.4] ], [ [47.8, 17.6], [52.4, 15.7] ] ] }strokes数组里每项是一个完整笔画的 SVG path 轮廓用于渲染字形medians数组里每项是这一笔的中轴线坐标点用于控制笔顺动画的书写方向和速度。如果你只是要做“逐笔显示”那么strokes就够用了如果你想做得接近真实书写手感让笔画像毛笔一样沿着中轴缓缓伸出medians才是核心。4.2 把 SVG path 转成 Canvas 绘制指令小程序 Canvas 不支持直接解析 SVG path 字符串所以我们需要自己实现一个精简的 path 解析器。SVG path 里最常用的命令就几个M移动到、L直线到、Q二次贝塞尔、C三次贝塞尔、Z闭合路径。我自己实现时的做法是先写一个 tokenizer把路径字符串拆成命令和坐标对然后映射到 Canvas 2D APIfunction parsePathToCanvas(ctx, d) { const commands d.match(/[MmLlQqCcZz]|[-]?\d(\.\d)?/g); // 这里省略对命令的分组解析核心逻辑是 // M - ctx.moveTo(x, y) // L - ctx.lineTo(x, y) // Q - ctx.quadraticCurveTo(cpx, cpy, x, y) // C - ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) // Z - ctx.closePath() }解析完成后调用ctx.stroke()或ctx.fill()就能画出这一笔。注意在小程序里优先使用 Canvas 2D 接口而不是旧版wx.createCanvasContext因为新版接口更接近浏览器标准性能也更好。4.3 用中轴线数据驱动书写动画如果只想让笔画“突然出现”那动画太生硬了。更好的方式是模拟写字的过程每一笔都从一个点开始沿着中轴线逐渐生长出来。这里的核心思路是不直接画完整笔画而是先计算这一个笔画的 SVG 路径在“当前时刻”应该露出多少。常见做法是给每个笔画分配一个时间区间在区间内对medians坐标进行插值得到当前的书写进度progress然后用ctx.setLineDash配合ctx.lineDashOffset实现路径的渐进绘制或者用路径截取算法只绘制路径的前半段。不过setLineDash只对描边有效对填充轮廓效果不一定好。想做更精细的效果可以在每次渲染时把完整路径拆成多段折线按进度只绘制其中一部分同时根据进度动态调整笔画透明度。我用一个简化模型描述const DURATION 600; // 每笔书写时长 ms function drawStrokeProgress(ctx, strokePath, progress) { // 将 progress 映射到 0~1控制当前笔画露出多少 const subPath clipPathByProgress(strokePath, progress); ctx.beginPath(); parsePathToCanvas(ctx, subPath); ctx.stroke(); }重点在于clipPathByProgress不能直接基于像素裁剪而要参考medians中轴线的长度来计算百分比这样笔画才会沿着正确的书写方向生长。我把自己写的第一版给同事试用时发现“横”的方向是对的但“钩”和“撇”经常从中间开始画问题就出在直接用路径长度算进度没有结合中轴线方向。5. 容易被忽略的一条边数据包、字体和真机适配5.1 字符数据的体积控制汉字常用字有 3500 个如果每个汉字一个 JSON全量塞进小程序包里包体积会直接爆掉。我的建议是首包只放最常用的一两百个字覆盖目标用户最常查的字其余字符数据放在服务器或云存储上前端按需拉取后缓存到本地数据文件开启强缓存同一个用户第二次查看同一个汉字不再重复下载。微信小程序主包大小限制是 2MB超过就得用分包。做一个词典类小程序字符数据几乎是必然要放到分包的。5.2 字体渲染差异在实际运行中Canvas 重绘方案遇到的另一个大坑是字体问题。HanziWriter 的笔画数据里是矢量路径理论上不依赖系统字体。但如果你在汉字下方同时渲染拼音、释义等文本小程序的 Canvas 对ctx.font的支持比浏览器弱很多中文字体在部分 Android 真机上会出现不渲染或者渲染成方块的情况。安全做法是动态文本统一使用sans-serif系统字体避免在 Canvas 里使用自定义字体文件。如果产品设计上需要特殊字体优先用普通 view 组件覆盖在 Canvas 上层而不是在 Canvas 内直接绘制。5.3 高清屏模糊问题和 Canvas 尺寸计算小程序 Canvas 在高清屏上如果不处理dpr画出来的笔画会明显发虚。这是因为 Canvas 的逻辑尺寸和物理像素尺寸不一样需要手动适配const dpr wx.getSystemInfoSync().pixelRatio; const width 300; // 逻辑宽度 const height 300; canvas.width width * dpr; canvas.height height * dpr; ctx.scale(dpr, dpr);我自己早期就吃过这个亏在开发者工具上看非常清晰真机一出图整体模糊后来检查发现是 Canvas 的宽高没有乘dpr。这个适配代码必须写在每个页面初始化 Canvas 的地方。5.4 页面切后台时的动画暂停小程序页面切到后台后requestAnimationFrame会被中断但如果你用的是setInterval驱动的动画循环切后台后 JS 逻辑可能不会立即停。这会导致用户回来后动画已经跳到了很后面的位置或者出现卡死。我处理的方法是在onHide里记录当前动画时间和总进度onShow时恢复现场并重置定时器。同时注意如果页面里同时有多个汉字动画在跑得维护一个统一的动画管理器不然页面很容易卡顿。6. 我完成小程序端 HanziWriter 适配后的一些经验总结6.1 两套方案我最终的判断依据如果你问我现在再做一个类似项目会怎么选我大概率会这样判断产品定位是“查询/展示型”比如词典里附带的笔顺演示优先选 web-view。原因是开发成本极低HanziWriter 的动画质量和笔画准确性有保障不需要自己再维护一套渲染引擎。产品定位是“练习/评测型”比如用户需要在界面上跟着写、需要检测笔画对不对、需要记录练习数据这类功能必须选 Canvas 重绘。因为 web-view 的通信限制会变成交互的瓶颈。两种方案我都实际跑过完整流程最终项目采用的是 Canvas 重绘方案核心就是把 HanziWriter 的数据层当成标准渲染层自己接管。这不代表 web-view 不好而是我们的产品需要把用户笔迹实时传到业务后台做分析web-view 这条链路做不到也不值得为了迁就它去改产品交互。6.2 一个小技巧能帮你少走很多弯路开发初期不要一上来就手写完整的 SVG path 解析器。可以先在小程序里内嵌一个 H5 页面做功能验证把 HanziWriter 的动画效果跑通确认产品交互可以用后再把动画逻辑重构成 Canvas 版本。这样既保证了功能方向是对的也不会因为一开始就重写渲染逻辑而陷入细节泥潭。我在最开始就是直接进入 Canvas 重画结果第一周都在跟 path 解析和笔画进度计算较劲连产品原型都还没定。后来先做了一个临时 H5 版本给团队看反而更快推动了交互方案确认。如果你在小程序里也遇到了类似问题希望这篇记录能帮你省下一些排查时间。尤其是那个dpr适配和medians方向判断这两个地方踩过的坑几乎一模一样提前避开能省不少事。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →