起因
女儿上小学二年级,语文作业里有两类题目让她很头疼:
- 笔顺练习:老师要求按正确顺序书写,但家里没有专门的点读笔,她总是记错"先横后竖"还是"先撇后捺"。
- 字帖临摹:学校发的纸质字帖字太小,印刷模糊,她趴在桌上看得很费力。
市面上倒是有不少 App,但要么需要注册账号,要么广告满屏,要么需要付费解锁。我想要的很简单:打开浏览器,输入今天要学的字,立刻开始练习。于是花了几个晚上,做了两个纯 HTML 页面。
两个工具
工具一:汉字笔顺演示(hanzi.html)
核心功能:
- 输入任意汉字或带标点的句子
- 每个汉字自动显示田字格 / 米字格辅助线
- 动画逐笔演示笔顺,支持三种播放模式:顺序播放、循环播放、只播一次
- 点击单个汉字可单独对其循环播放,方便重点攻克难字
- 支持显示拼音(声调符号,如 nǐ hǎo)
工具二:汉字字帖(hanzi2.html)
核心功能:
- 将课文段落渲染成米字格字帖
- 每个格子内用 HanziWriter 显示汉字轮廓,供孩子描红
- 支持可调格子大小,适配不同打印纸张
- 支持拼音模式,在汉字上方标注拼音
- 浏览器打印友好(
@media print隐藏 UI 控件)
技术选型
HanziWriter
HanziWriter 是这两个工具的核心依赖,一个开源的 JavaScript 库,提供:
- 完整的汉字 SVG 笔画数据(基于 Make Me a Hanzi 数据集)
animateCharacter()API 按正确笔顺播放动画showOutline()显示灰色轮廓供描红- 偏旁部首可单独染色(
radicalColor)
引入方式极简,一个 CDN script 标签即可:
<scriptsrc="https://cdn.jsdelivr.net/npm/hanzi-writer@3.5/dist/hanzi-writer.min.js"></script>pinyin-pro
拼音功能使用 pinyin-pro,支持带声调符号的标准拼音(ā á ǎ à),多音字根据上下文取最常用读音:
pinyinPro.pinyin("重",{toneType:"symbol",type:"array"});// => ["zhòng"]同样是一个 CDN script 标签搞定,无需 Node.js / 构建工具。
关键实现细节
1. 并发动画控制(笔顺演示)
字帖有时需要同时展示几十个汉字,如果全部同时播放动画,浏览器会卡顿。解决方案是维护一个异步工作池:
constMAX_CONCURRENT_ANIMATIONS=32;constloopState={cursor:0};// 启动 N 个并发 worker,每个 worker 循环取下一个可见字播放for(leti=0;i<workerCount;i++){playVisibleLoop(writerEntries,runId,loopState);}每个 worker 通过共享的loopState.cursor原子性地认领下一个待播字符,避免重复播放。
2. IntersectionObserver 懒执行
页面很长时,只对当前视口内的汉字播放动画,滚出屏幕的字暂停,滚回来再继续:
constobserver=newIntersectionObserver((entries)=>{entries.forEach((entry)=>{entry.target.__writerEntry.visible=entry.isIntersecting;});},{rootMargin:"120px 0px"});writerEntries.forEach((entry)=>observer.observe(entry.element));rootMargin: "120px 0px"让字进入视口前 120px 就提前标记为可见,动画能无缝衔接。
3. 拼音与字格的对齐
拼音模式下每个字符需要上下两层结构(拼音 + 格子),用 flex column 实现:
.char-cell{display:inline-flex;flex-direction:column;align-items:center;gap:4px;}.pinyin-label{font-size:13px;font-weight:600;color:var(--primary);min-height:15px;/* 非汉字字符也占位,保持行底对齐 */white-space:nowrap;}关键是给非汉字字符(标点、空格)的拼音位置也保留min-height,这样整行元素能整齐地底部对齐,不会因为有无拼音而高低错落。
4. 田字格 / 米字格用纯 CSS 实现
没有用<canvas>或额外的 DOM 节点,格子辅助线完全用多层linear-gradient叠加:
/* 田字格:竖中线 + 横中线 */.writer-target.grid-tian{background-image:linear-gradient(to right,transparentcalc(50% - 0.5px),rgba(23,88,200,0.22)calc(50% - 0.5px),rgba(23,88,200,0.22)calc(50% + 0.5px),transparentcalc(50% + 0.5px)),linear-gradient(to bottom,transparentcalc(50% - 0.5px),rgba(23,88,200,0.22)calc(50% - 0.5px),rgba(23,88,200,0.22)calc(50% + 0.5px),transparentcalc(50% + 0.5px));}米字格再加两条 45° 对角线即可。线宽精确到 1px(calc(50% - 0.5px)到calc(50% + 0.5px)),在 Retina 屏上也锐利清晰。
5. 状态持久化
设置项(格子大小、字格类型、是否显示拼音)和输入文本都存入localStorage,刷新页面后自动恢复,不需要每次重新输入:
constSTORAGE_KEYS={text:"hanzi-demo:text",cellSize:"hanzi-demo:cellSize",gridType:"hanzi-demo:gridType",playbackMode:"hanzi-demo:playbackMode",showPinyin:"hanzi-demo:showPinyin",};6. 防抖动画竞态(runId 机制)
用户快速修改文字、点击"开始演示"时,旧的异步动画循环需要立刻失效,否则新旧动画会混在一起:
letactiveRunId=0;asyncfunctionstartAnimation(){activeRunId+=1;// 每次启动递增 IDconstrunId=activeRunId;// 所有异步操作前都检查:if(!isRunActive(runId))return;}functionisRunActive(runId){returnrunId===activeRunId;}这是一种轻量级的"取消令牌"模式,无需引入AbortController。
零依赖构建
两个页面都是单文件 HTML,没有npm install,没有打包步骤,没有服务端。直接双击用浏览器打开,或者扔到任意静态托管(GitHub Pages、Nginx、OSS)就能用。
对家长来说最重要的是:不需要注册,不需要 App,不收集任何数据。
可以在 “四楼没电梯” 的公众号里面获取地址
源码
两个文件加起来不到 1500 行,全部是原生 HTML / CSS / JS,没有框架依赖。
hanzi.html— 笔顺动画演示hanzi2.html— 字帖生成与打印