简介这是一份面向微信小程序开发者的 Hanzi Writer 组件源码包用于在小程序内快速集成汉字书写器实现笔画顺序动画、写法演示与问答交互等教学功能。组件原仓库虽已停止维护但作者提供了 npm 的 beta 安装方式适合二次开发或离线部署场景。整个压缩包约 20KB共 39 个文件包含 19 个 JS 脚本承担笔画渲染、事件分发与数据加载6 个 JSON 负责组件和项目配置3 个 WXSS 与 2 个 WXML 构成界面样式及模板另有构建脚本、测试用例和说明文档等目录结构清晰便于按模块查阅。已有 295 人学习下载适合刚接触小程序原生组件开发或希望研究汉字笔画机制的初级到中级开发者。通过阅读源码和 demo可掌握自定义组件封装、Canvas 绘图交互流程以及将第三方库适配到小程序环境的方法为教育类小程序开发提供可直接上手的基础模板。1. 把汉字笔顺动画搬进微信小程序这套组件到底解决什么问题当你在做识字或书法类微信小程序时最容易低估的需求是“汉字笔顺”。一个至少包含基础笔顺动画、描红和书写评判的功能模块拆开后涉及数据、渲染、触摸三个环节。比如我在做语文启蒙小程序时一个弹窗需要对生字展示正确书写过程笔画的先后、方向、收笔位置都不能错还需用户跟写。浏览器实现很顺手但换到微信小程序发现SVG不能用、DOM路径长度取不到、原来的HTML渲染逻辑几乎全部作废。这套 Wechat Miniprogram plugin for Hanzi Writer微信小程序组件解决的就是这个问题它把 Hanzi Writer 的汉字笔画数据和动画引擎封装成微信小程序原生插件组件使开发者不写SVG、不做浏览器端适配直接在小程序里获得可交互的汉字书写环境。适合识字、书法、作业辅导、幼教类工具小程序也适合想给自己小程序加“书写评测”功能的团队。2. 把 SVG 路径翻译成 Canvas 折线渲染原理与数据格式2.1 一个汉字不是字体文件而是一份结构化笔画数据Hanzi Writer 的核心思路是把一个汉字的书写过程拆成数据而不是当成字体轮廓或图片。每个汉字对应一份 JSON 数据数据里最关键的有两部分一个是strokes保存了每一笔的轮廓路径用的是类似 SVG path 的字符串语法另一个是medians保存了每一笔的中轴线坐标点即运笔轨迹。这种理念决定了后续在小程序里的实现方式。strokes是描边的完整轮廓适合最终静态展示medians则是“书写路线”适合做动画轨迹和触摸评测。我在接入时实际动画只用medians因为它的点数量少一般单字 5 到 30 个点渲染成本低而且视觉上已经能还原笔画走向。至于strokes可以直接画也可以不画只在需要“带轮廓的田字格”时用。一份典型的数据片段看起来是这样的{ strokes: [ M 120 250 Q 140 220 160 250 L 160 350 Q 150 380 120 350 Z ], medians: [ [{x: 106, y: 62}, {x: 90, y: 70}, {x: 84, y: 88}] ] }参数说明strokes数组里每个元素是一条笔画的封闭路径M代表起始点Q是二次贝塞尔控制点L是直线点Z闭合路径。medians是每笔的中线坐标值对应 0 到 1024 的抽象画布坐标系渲染时需要等比缩放到实际 canvas 尺寸。不同汉字的数据体积差别很大常见字约几十 KB生僻字可能超过两百 KB这个体积直接决定了你后续缓存和分包策略。2.2 为什么微信小程序不能直接复用 Hanzi Writer 的浏览器渲染代码浏览器版 Hanzi Writer 基于 SVG 渲染动画依赖getTotalLength()获取路径总长再配合 stroke-dasharray 控制描边进度。小程序 WXML 没有 SVG 标签JavaScript 运行环境里也没有 DOM 与路径长度接口。如果你自己用wx.createCanvasContext这套旧接口重画又会被它“只能异步批量绘图”的限制卡住旧接口无法高效地逐帧更新某条路径。正确做法是使用基础库 2.9.0 之后支持的type2dCanvas。这种 canvas 节点能通过node.getContext(2d)拿到接近浏览器标准的 CanvasRenderingContext2D 对象支持clearRect、lineTo、bezierCurveTo、getImageData等操作是实现逐帧动画的基础。初始化 2d canvas 的标准写法我一般这样处理const query wx.createSelectorQuery() query.select(#hwCanvas) .fields({ node: true, size: true }) .exec((res) { if (!res || !res[0]) return const { node, width, height } res[0] const dpr wx.getWindowInfo().pixelRatio node.width width * dpr node.height height * dpr const ctx node.getContext(2d) ctx.scale(dpr, dpr) this._canvasNode node this._ctx ctx })这段代码的逻辑先通过createSelectorQuery从节点树里找到idhwCanvas的 canvas 元素拿到实际节点、css 宽度和高度。因为小程序内部渲染用的是物理像素而你布局时用的是逻辑像素所以需要把node.width和node.height设为width * dpr再用ctx.scale(dpr, dpr)做整体缩放否则真机上线条会发虚。dpr可以从wx.getWindowInfo().pixelRatio获取建议不要写死 2不同安卓机的 dpr 差异很大。2.3 把 medians 转成折线最稳妥的动画实现方式拿到medians之后如果直接把点按顺序连接点的密度往往不够动画会出现折角感尤其“横折钩”这类笔画。常见做法是相邻两点之间做线性插值把一条稀疏折线加密成平滑可用的轨迹。我封装了一个简单的插值函数function buildStrokePolylines(medians, steps 8) { const polylines [] for (const median of medians) { const pts [] for (let i 0; i median.length - 1; i) { const a median[i] const b median[i 1] for (let s 0; s steps; s) { const t s / steps pts.push({ x: a.x (b.x - a.x) * t, y: a.y (b.y - a.y) * t }) } } pts.push(median[median.length - 1]) polylines.push(pts) } return polylines }这里steps是每两个原始点之间的插值数量。设得太小折线棱角明显设得太大增加每帧计算量。我一般对中轴线数据用 8对最终轮廓数据用 4这样在 300x300 的 canvas 上既能保证圆弧平滑又不会让低端机在每帧重绘时卡顿。插值后的折线还承担另一个任务触摸评测时计算“当前手写点离中轴线的距离”点越密判断越准确。有了折线渲染动画就变得直接按时间进度把当前笔画的折线部分画出来。每帧先clearRect整个区域再把已完成笔画的折线用实色画出最后把当前笔画的可见部分用高亮色画到对应进度。动画时间可以用各笔画的线段总长来分配而不是平均分配这样长笔画不会显得走得太快。3. 把它封装成微信小程序插件目录结构、组件属性与动画控制3.1 先搞清楚微信小程序插件的工程结构微信小程序插件不是普通页面里的组件而是一套独立工程需要在你声明的 plugin 目录里开发经过微信公众平台审核后才能被其他小程序通过app.json的plugins字段引用。如果你只是在单个小程序内部复用也可以直接以普通 Component 方式注册不一定要走插件审核流程。但从传播和复用角度插件模式更符合你“一行代码接入”的目标。插件的代码结构通常这样组织plugin/ plugin.json index.js components/ hanzi-writer/ hanzi-writer.js hanzi-writer.json hanzi-writer.wxml hanzi-writer.wxss data/ char-6211.js char-4e00.jsplugin.json中声明对外暴露的组件{ publicComponents: { hanzi-writer: components/hanzi-writer/hanzi-writer }, main: index.js }参数解释publicComponents里是外部小程序可引用的组件名和路径hanzi-writer就是这个组件的对外名称。main一般指入口 JS 文件。使用方在自己的app.json里注册插件{ plugins: { hanzi-writer: { version: 0.0.1, provider: wxxxxxxxx } } }这里的provider是插件的 appid需要在微信公众平台申请。申请审核周期较长如果你只是内部项目用我建议先在usingComponents里直接配置本地路径等稳定后再发布成插件。3.2 数据文件怎么来、怎么放按需加载与常备字库Hanzi Writer 本身有公开的汉字数据仓库每种汉字以 Unicode 码点命名比如“我”的编码是 6211数据文件名就是char-6211.js。但小程序包体积有限不能把所有汉字数据一次性塞进去。我在实际项目中把数据分为两层第一层是“小学前 300 高频字”打包成多个 JS 文件放进data目录用require直接读取保证核心功能零网络依赖第二层是剩余字通过网络接口按需请求。按需加载时可以用这样的辅助函数function ensureCharData(char) { if (this._charCache[char]) { return this._charCache[char] } const codeHex char.codePointAt(0).toString(16).padStart(4, 0) const data require(../data/char-${codeHex}.js) this._charCache[char] data return data }这里的关键是codePointAt(0).toString(16)拿到汉字对应的 Unicode 十六进制码点然后拼出文件名。padStart(4, 0)保证码点不足四位时不会因为文件名错位而require失败。要注意这个方案只适用于数据文件已经在代码包里的情况如果走网络加载则应改为wx.request或 CDN 地址并将返回的 JSON 缓存下来。3.3 组件属性对外要暴露哪些参数组件封装的重点是属性和方法。属性直接影响使用者的接入成本太多的复杂配置会让别人不敢用。我保留这些常用属性Component({ properties: { char: { type: String, value: }, strokeColor: { type: String, value: #333333 }, drawingColor: { type: String, value: #2b7de9 }, leniency: { type: Number, value: 0.15 }, speed: { type: Number, value: 1 }, showHint: { type: Boolean, value: true } }, ... })参数说明char是要展示的单个汉字传入多字会直接忽略只取第一个。strokeColor是落笔完成的笔画颜色drawingColor是正在书写中的笔画提示色。leniency是容差系数0 到 1 之间0.15 表示用户写偏不超过当前笔画中轴线总长度的百分之十五就算正确初学者场景建议调到 0.25。speed是动画倍率1 为正常0.5 为慢速适合教学讲解。showHint控制在描红模式下是否显示内部灰色引导线。组件的 WXML 结构要确保事件能正确绑定并且 canvas 不会被其他原生组件遮挡view classhw-wrapper canvas type2d idhwCanvas classhw-canvas disable-scroll{{true}} bindtouchstartonTouchStart bindtouchmoveonTouchMove bindtouchendonTouchEnd /canvas /viewdisable-scroll很关键它让用户在 canvas 上竖直书写时不会触发页面滚动尤其在真机上这是拖写操作反馈稳定性的基础。canvas 的样式宽高我会用 px 写死而不是 rpx避免不同屏幕宽度下内部坐标系换算混乱。3.4 动画循环setTimeout 驱动的逐帧更新在小程序里最稳妥的动画驱动不是requestAnimationFrame因为它在真机上对 2d canvas 的支持并不稳定。我用setTimeout做 16ms 的定时器模拟 60 帧。核心的动画更新函数逻辑如下_tick() { const now Date.now() const elapsed (now - this._startTime) * this.data.speed const ctx this._ctx const { canvasWidth, canvasHeight } this.data ctx.clearRect(0, 0, canvasWidth, canvasHeight) // 画已完成笔画 this._polylines.forEach((line, index) { if (index this._currentStrokeIndex) { drawLine(ctx, line, this.data.strokeColor) } }) // 画当前笔画 const current this._polylines[this._currentStrokeIndex] if (current) { const t Math.min(1, elapsed / this._strokeDurations[this._currentStrokeIndex]) const endIndex Math.floor(t * current.length) drawLine(ctx, current.slice(0, endIndex 1), this.data.drawingColor) } if (this._currentStrokeIndex this._polylines.length - 1) { this._timer setTimeout(() this._tick(), 16) } else { this.triggerEvent(complete, { char: this.data.char }) } }这里需要解释分配时间和绘制顺序的逻辑_strokeDurations不是平均分配的而是按每笔中轴线的折线总长度占整字总长度的比例分配长笔画动画时间更长视觉上更自然。drawLine封装了对ctx.beginPath、moveTo、lineTo、stroke的调用并设置线帽为round。3.5 控制方法play、pause、reset 都要有插件组件应该提供方法让使用者通过selectComponent操作methods: { play() { this._startTime Date.now() this._currentStrokeIndex 0 this._tick() }, pause() { clearTimeout(this._timer) this._pausedElapsed ... }, reset() { clearTimeout(this._timer) this._currentStrokeIndex 0 this._ctx.clearRect(...) this.drawBackground() } }play从头开始播放动画pause需要记录当前已走的时间reset则清空画布并重绘背景。这里有个容易被忽略的坑pause不只是clearTimeout还需要把进度保存下来否则恢复播放时_startTime从旧起点算会直接跳到后面的笔画。我通常用_accumulatedTime保存暂停前累计时间恢复时重新计算_startTime Date.now() - _accumulatedTime。4. 描红与手写评判把触摸坐标换算成笔画误差4.1 触摸坐标到画布坐标的三层换算用户手指在 canvas 上滑动时事件对象里给的是相对屏幕的逻辑坐标clientX和clientY。但你需要把它变成和medians数据一致的抽象坐标中间经过三层换算。先拿到 canvas 节点在页面中的位置query.select(#hwCanvas).boundingClientRect((rect) { this._rect rect })然后在触摸事件里onTouchStart(e) { const touch e.touches[0] const px (touch.clientX - this._rect.left) / this._rect.width const py (touch.clientY - this._rect.top) / this._rect.height const x px * 1024 const y py * 1024 this._touchPoints [{ x, y }] }第一层把clientX减去rect.left再除以rect.width得到 0 到 1 的归一化比例第二层乘以 1024得到和medians相同的抽象坐标系坐标。这样你后续比较距离时不需要再做单位转换。如果组件内部还做了缩放 padding比如在字形周围留白那第一步计算出来的px还要减去留白比例再乘一个缩放因子这个我建议直接做成组件属性不要在代码里写死。4.2 点与中轴线的距离计算描红评判的核心算法是判断用户当前手指坐标点是否落在某一笔中轴线附近。为了算“附近”需要用到点到折线段的距离函数。function distToSegment(p, a, b) { const dx b.x - a.x const dy b.y - a.y const lenSq dx * dx dy * dy if (lenSq 0) return Math.hypot(p.x - a.x, p.y - a.y) let t ((p.x - a.x) * dx (p.y - a.y) * dy) / lenSq t Math.max(0, Math.min(1, t)) const projX a.x dx * t const projY a.y dy * t return Math.hypot(p.x - projX, p.y - projY) }这个函数把当前触摸点投影到线段 ab 上并夹取t的范围保证投影点不会跑出线段两端。用Math.hypot直接算欧氏距离在大多数手机性能上完全够用。如果担心低端机每秒 60 次触摸事件里频繁计算几十段折线有压力可以先把折线点按横坐标排序只查当前点附近一小段范围。实际判定时我每帧取当前触摸点对当前笔画的中轴折线从头扫到尾找到最小距离minDist。然后算这一笔中轴线的总长度totalLen。const threshold this.data.leniency * totalLen if (minDist threshold) { this._progressIndex Math.max(this._progressIndex, nearestSegmentIndex) } else { this._wrongCount this.triggerEvent(wrongstroke, { strokeIndex: this._currentStrokeIndex }) }threshold的物理含义是“允许写偏的绝对距离”它随笔画长度变化。短笔画阈值小要求更准长笔画阈值大容忍度更高。这比固定 10px 的阈值科学得多因为不同汉字缩放尺寸不同。4.3 笔顺错误与完成事件除了偏离距离还要判断用户当前写的到底是“当前该写的笔画”还是“写串了”。当用户手指落在前面某笔附近而距离当前笔很远时说明笔顺错误。这时我触发wrongstroke事件并且不推进_currentStrokeIndex。完成状态要判断两个条件所有笔画都写完且最后一笔也通过了误差检测。这时组件对外抛出complete事件附带错误次数和时长。使用方可以在这个事件里决定是播放鼓励动画还是跳转下一个字。this.triggerEvent(complete, { char: this.data.char, wrongCount: this._wrongCount, duration: Math.round((Date.now() - this._startTime) / 1000) })设计参数时注意两个细节第一leniency不要设成固定绝对值因为不同字号下用户手写体验差异很大我建议在使用方属性里公开它让业务方根据学员年龄调节第二错误计数不要在同一笔画里连续触发多次否则用户还没写完一个字就收到几十次错误反馈体验极差。我通常对同一笔只触发一次wrongstroke等进入下一笔后再重新放开。5. 常见问题与避坑微信小程序里接入汉字组件最容易翻车的 4 个点5.1 坑一canvas 拿不到 2d 上下文现象在组件的attached生命周期里执行createSelectorQuery().select(#hwCanvas).fields({ node: true })回调里res[0].node是空的或node.getContext(2d)返回 null。原因attached时组件节点可能还没渲染完成canvas 元素不在节点树中。更隐蔽的原因是你把type2d写成了type2d或者整个 canvas 标签都没有type属性导致小程序把它当成了旧版 canvas拿不到 node 节点。解决把初始化操作放到lifetimes.ready里并且用wx.nextTick包一层lifetimes: { ready() { wx.nextTick(() { this.initCanvas() }) } }如果你在ready里仍然拿不到就在setTimeout里再试两次每次间隔 50ms。真机上 canvas 初始化有时比开发工具慢一帧这是经验值。5.2 坑二真机上笔画发虚边缘有锯齿现象开发工具里线条很清晰一上真机就糊尤其安卓中低端机。原因没有处理设备像素比。canvas 标签的 CSS 宽度是逻辑像素而 canvas 内部缓冲区默认也是逻辑像素高 DPR 屏幕下需要两倍甚至三倍缓冲区尺寸。解决初始化时把node.width和node.height设置为 css 宽高乘以wx.getWindowInfo().pixelRatio然后ctx.scale(dpr, dpr)。注意ctx.scale要在所有绘制之前调用而且不要多次调用否则线条会被重复放大。同时所有坐标计算依旧用逻辑像素这样代码逻辑不用改。5.3 坑三描红时手指轨迹和笔画对不上现象手指落点位置明显偏右上或整个字在 canvas 内偏移真机和开发工具表现不一致。原因触摸坐标使用的是clientX相对页面左上角而 canvas 内部坐标系是从 canvas 元素左上角开始。如果页面有导航栏、自定义顶部栏或者 canvas 所在的容器有padding、transform偏差就会很严重。另一个常见原因是 canvas 的class用了width: 100%但父容器实际尺寸和 canvas 内部缓冲区尺寸不匹配。解决每次触摸开始时重新获取boundingClientRect和display信息不要缓存上次的rect用一辈子。然后先检测 canvas 的布局尺寸是否等于你在 JS 里记录的canvasWidth和canvasHeight如果不一致重新初始化。我在做“微信小程序项目”时还遇到过一个极端情况iPhone 开启缩放模式后clientX和rect的比例关系被系统除以了一个缩放因子这个只能通过真机调试才能发现。5.4 坑四插件包体积太大进入页面白屏现象小程序加载插件后首屏出现明显的白屏时间有些低端机上甚至直接崩溃。原因汉字数据是按字存 JSON常用 300 字数据总量可以达到五六兆超过了微信小程序分包的体积限制。而白屏的直接原因往往是组件在ready时同步require了大量数据文件阻塞了渲染线程。解决把常用字数据拆成 50 字一个 JS 文件每次只require当前字所在文件。未打包的剩余字通过网络加载并在wx.setStorageSync里做本地缓存。加载过程中组件先渲染一个田字格背景等数据到后再画笔画避免整页空白。数据缓存这块我多说一句Hanzi Writer 的 JSON 是稳定数据版本不更新就可以长期缓存。小程序本地缓存里有 10MB 的上限存下 2000 个字的数据没有问题。你可以把缓存键设计成hw:data:{codeHex}同时记录一个版本号字段升版本时统一删除旧缓存。这就是典型的“微信小程序设置缓存时间”场景用版本号替代过期时间更可靠。6. 进阶缓存预热、预加载与数据正确性验证组件稳定运行后使用体验的差距主要体现在细节上。我第一个建议是做一个“高频字缓存预热”。在用户进入写字页面之前就后台请求当前课程会涉及的生字数据并缓存这样用户点击某个字时几乎无等待。具体做法可以在父页面onLoad时拿到生字列表循环调用插件的loadCharacter方法但要注意控制并发我一般每次同时加载 2 个字免得一上来就把带宽打满。第二个建议是预留文字书写评测的校准接口。不同老师对笔顺的容错定义不一样有的必须严格按笔顺有的允许写歪但不允许跳笔。我会在组件里加一个strokeOrderStrict属性布尔值。严格模式下写串了立即触发wrongstroke非严格模式下只在当前笔持续偏离超过 1 秒后才提示错误。这个细节会让初学者觉得“跟得上”。第三个建议是数据正确性验证。Hanzi Writer 的数据来自众包个别字体可能有笔画顺序与教学规范不一致的情况。我建立一个简单测试文件对所有内置字跑一遍function validateCharData(char, data) { if (data.strokes.length ! data.medians.length) { console.warn(${char}: 笔画数不一致) return false } if (!Array.isArray(data.medians[0])) { console.warn(${char}: medians 格式异常) return false } return true }最关键的是校验strokes和medians数量一致以及medians的每个元素是坐标点数组而不是单个对象。之前遇到一份转换脚本生成的数据里medians被压成了字符串导致Array.isArray判断失败动画直接空白。最后提一个我自己的使用习惯正式上线前把leniency默认值设为 0.2因为真实小学生的手指精细度远不如成人太严格会劝退用户。等积累了用户数据再按年龄细分调整。笔顺展示模式用speed: 1描红练习模式用speed: 0.7慢一点反而能让数据评测更准。希望这篇文章能让你绕过我踩过的那些坑。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?