女儿上小学二年级,语文作业里有两类题目让她很头疼:
市面上倒是有不少 App,但要么需要注册账号,要么广告满屏,要么需要付费解锁。我想要的很简单:打开浏览器,输入今天要学的字,立刻开始练习。于是花了几个晚上,做了两个纯 HTML 页面。

hanzi.html)核心功能:
hanzi2.html)核心功能:
@media print 隐藏 UI 控件)HanziWriter 是这两个工具的核心依赖,一个开源的 JavaScript 库,提供:
animateCharacter() API 按正确笔顺播放动画showOutline() 显示灰色轮廓供描红radicalColor)引入方式极简,一个 CDN script 标签即可:
<script src="https://cdn.jsdelivr.net/npm/hanzi-writer@3.5/dist/hanzi-writer.min.js"></script>拼音功能使用 pinyin-pro,支持带声调符号的标准拼音(ā á ǎ à),多音字根据上下文取最常用读音:
pinyinPro.pinyin("重", { toneType: "symbol", type: "array" });
// => ["zhòng"]同样是一个 CDN script 标签搞定,无需 Node.js / 构建工具。
字帖有时需要同时展示几十个汉字,如果全部同时播放动画,浏览器会卡顿。解决方案是维护一个异步工作池:
const MAX_CONCURRENT_ANIMATIONS = 32;
const loopState = { cursor: 0 };
// 启动 N 个并发 worker,每个 worker 循环取下一个可见字播放
for (let i = 0; i < workerCount; i++) {
playVisibleLoop(writerEntries, runId, loopState);
}每个 worker 通过共享的 loopState.cursor 原子性地认领下一个待播字符,避免重复播放。
页面很长时,只对当前视口内的汉字播放动画,滚出屏幕的字暂停,滚回来再继续:
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
entry.target.__writerEntry.visible = entry.isIntersecting;
});
},
{ rootMargin: "120px 0px" }
);
writerEntries.forEach((entry) => observer.observe(entry.element));rootMargin: "120px 0px" 让字进入视口前 120px 就提前标记为可见,动画能无缝衔接。
拼音模式下每个字符需要上下两层结构(拼音 + 格子),用 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,这样整行元素能整齐地底部对齐,不会因为有无拼音而高低错落。
没有用 <canvas> 或额外的 DOM 节点,格子辅助线完全用多层 linear-gradient 叠加:
/* 田字格:竖中线 + 横中线 */
.writer-target.grid-tian {
background-image:
linear-gradient(
to right,
transparent calc(50% - 0.5px),
rgba(23, 88, 200, 0.22) calc(50% - 0.5px),
rgba(23, 88, 200, 0.22) calc(50% + 0.5px),
transparent calc(50% + 0.5px)
),
linear-gradient(
to bottom,
transparent calc(50% - 0.5px),
rgba(23, 88, 200, 0.22) calc(50% - 0.5px),
rgba(23, 88, 200, 0.22) calc(50% + 0.5px),
transparent calc(50% + 0.5px)
);
}米字格再加两条 45° 对角线即可。线宽精确到 1px(calc(50% - 0.5px) 到 calc(50% + 0.5px)),在 Retina 屏上也锐利清晰。
设置项(格子大小、字格类型、是否显示拼音)和输入文本都存入 localStorage,刷新页面后自动恢复,不需要每次重新输入:
const STORAGE_KEYS = {
text: "hanzi-demo:text",
cellSize: "hanzi-demo:cellSize",
gridType: "hanzi-demo:gridType",
playbackMode: "hanzi-demo:playbackMode",
showPinyin: "hanzi-demo:showPinyin",
};用户快速修改文字、点击"开始演示"时,旧的异步动画循环需要立刻失效,否则新旧动画会混在一起:
let activeRunId = 0;
async function startAnimation() {
activeRunId += 1; // 每次启动递增 ID
const runId = activeRunId;
// 所有异步操作前都检查:
if (!isRunActive(runId)) return;
}
function isRunActive(runId) {
return runId === activeRunId;
}这是一种轻量级的"取消令牌"模式,无需引入 AbortController。
两个页面都是单文件 HTML,没有 npm install,没有打包步骤,没有服务端。直接双击用浏览器打开,或者扔到任意静态托管(GitHub Pages、Nginx、OSS)就能用。
对家长来说最重要的是:不需要注册,不需要 App,不收集任何数据。
两个文件加起来不到 1500 行,全部是原生 HTML / CSS / JS,没有框架依赖。
hanzi.html — 笔顺动画演示hanzi2.html — 字帖生成与打印