中文汉字笔画查询:当前字形与传统姓名学备注的工程实现
8月 13, 2026
AI 参与说明:本文由 Codex 根据公开数据源与一套已脱敏的 Web 实现辅助整理。文中的笔画值取决于固定版本的数据表与明确的展示口径,不应被理解为语言学、字典编纂或姓名学上的唯一结论;读者应结合文末原始资料交叉验证。
先说结论#
这套中文笔画查询并不是在浏览器里识别汉字的几何结构,再推导横、竖、撇、捺的数量。它采用的是更可控的工程方案:
- 在构建期读取固定版本的汉字数据,生成“Unicode 码点 → 笔画数”的静态查找表。
- 页面运行时按用户实际输入的字形查询主值。例如输入“韩”显示 12 画,输入“韓”显示 17 画。
- 对存在明确简繁对应关系的简体字,再单独给出传统姓名学备注。例如“韩”的主值仍是 12 画,同时备注“传统姓名学按繁体『韓』计 17 画”。
- 简体字可能对应多个繁体字时不替用户猜测,只提示“存在多种对应关系”。
- 数据表未收录的字返回
null,界面显示“—”或“暂无可靠数据”,绝不把缺失值伪装成 0 画。
主值与传统备注是两条独立的数据链。这个边界比“选择一个看起来权威的数字”更重要,因为“笔画数”本身必须先回答:按哪个字形、哪套字典和哪种用途来计数?
为什么同一个名字可能出现两套笔画数#
至少需要区分下面三个概念:
| 口径 | 回答的问题 | 示例 |
|---|---|---|
| 当前输入字形 | 用户眼前输入的这个字通常写多少画 | 韩 12 画,韓 17 画 |
| 对应繁体字形 | 这个简体字明确转换为繁体后,繁体字形是多少画 | 张 7 画,張 11 画 |
| 康熙/传统姓名学口径 | 传统字书或姓名学数据如何计部首与字形 | 韩 可备注为按 韓 计 17 画 |
这三者有时相同,有时不同。以三点水为例,现代书写字形通常把 氵 视为 3 画,传统字书按“水”部计数时可能按 4 画处理。于是,同一个繁体字的现代字形笔画与康熙口径仍可能相差 1 画。
因此,页面不应只写一个没有上下文的“笔画数”。更清楚的文案是:
- 方格下方:
12 画 - 说明:
上方按当前输入字形计 - 补充:
传统姓名学按繁体「韓」计 17 画
数据来源与职责#
本文整理的实现固定使用三份公开数据:
| 数据 | 用途 | 固定方式 | 许可证 |
|---|---|---|---|
babyname/fate v4.0.0 的 character.json | 当前输入字形的主笔画表 | 固定 tag v4.0.0(commit a08ef790) | MIT |
OpenCC STCharacters.txt | 简体字到繁体候选字的映射 | 固定 commit 5249273 | Apache-2.0 |
kangxi-strokecount.csv | 繁体候选字的康熙笔画备注 | 固定 commit 778d23d | MIT |
主字形数据与传统备注没有混成一张表:
- 主值回答“用户输入的这个字是多少画”。
- OpenCC 只负责给出可能的繁体候选,不负责提供笔画数。
- Kangxi 数据只用于传统备注,不覆盖主值。
固定 tag 或 commit 很关键。上游数据发生修订时,不应让线上结果在没有审查、测试和发布记录的情况下悄悄变化。生成产物进入应用前,还应保留第三方许可证和来源说明。
整体数据流#
flowchart TB
A["babyname/fate 字形数据"] --> D["构建期生成器"]
B["OpenCC 简繁候选"] --> D
C["Kangxi 传统笔画"] --> D
D --> E["码点区间的 Uint8Array 数据"]
D --> F["传统备注映射"]
E --> G["分析页按 Unicode 码点查询"]
F --> G
G --> H["先显示当前字形笔画"]
G --> I["再显示明确或歧义的传统备注"]应用不会为每个汉字调用远程笔画 API。数据生成完成后,浏览器只需按需加载随应用发布的静态分包,再在本地完成查询。
构建期:生成紧凑的码点表#
主数据表优先读取 simplified_stroke,缺失时再尝试 traditional_stroke 和 kangxi_stroke。这是一项明确的数据契约:字段名不能被泛化成“所有传统字都按简体规则计算”,而应理解为“这份固定数据中用于当前字形展示的优先字段”。
下面是生成逻辑的核心部分,示例使用 Node.js ESM:
const strokeByCodePoint = new Map();
for (const entry of source) {
const codePoint = entry.char?.codePointAt(0);
const strokes = Number(
entry.simplified_stroke ||
entry.traditional_stroke ||
entry.kangxi_stroke,
);
if (!codePoint) continue;
if (!Number.isInteger(strokes) || strokes <= 0 || strokes > 63) continue;
if (strokeByCodePoint.has(codePoint)) continue;
strokeByCodePoint.set(codePoint, strokes);
}实现把数据写入三个连续的 Unicode 区间。这些边界来自 Unicode 17.0.0 Blocks.txt:
const ranges = [
{ name: "CJK_EXTENSION_A", start: 0x3400, end: 0x4dbf },
{ name: "CJK_UNIFIED", start: 0x4e00, end: 0x9fff },
{ name: "CJK_EXTENSION_B", start: 0x20000, end: 0x2a6df },
];
for (const range of ranges) {
const bytes = Buffer.alloc(range.end - range.start + 1);
for (let codePoint = range.start; codePoint <= range.end; codePoint += 1) {
bytes[codePoint - range.start] = strokeByCodePoint.get(codePoint) || 0;
}
const encoded = bytes.toString("base64");
// 将 start 与 encoded 写入生成的 TypeScript 模块。
}每个码点占 1 byte:
1..63表示已知笔画数。0表示未收录。- 数组下标就是
codePoint - range.start。
这种结构会为区间中的空位保留 0,但查询简单、解码成本低,也比直接发布大量 JSON key 更容易压缩。最终体积仍应在目标构建器、Source Map 与压缩配置下实测,并通过异步分包避免进入首屏关键路径。这些区间只定义数组槽位,并不代表每个码点都有可靠笔画数据。
运行时:按 Unicode 码点做 O(1) 查询#
浏览器第一次访问某个区间时,用 atob() 解码 Base64,并缓存 Uint8Array。后续查询只需要判断区间并读取一个下标:
import {
CJK_EXTENSION_A_START,
CJK_EXTENSION_A_STROKES,
CJK_EXTENSION_B_START,
CJK_EXTENSION_B_STROKES,
CJK_UNIFIED_START,
CJK_UNIFIED_STROKES,
} from "./generatedStrokeData.js";
type StrokeRange = {
start: number;
encoded: string;
bytes?: Uint8Array;
};
const strokeRanges: StrokeRange[] = [
{ start: CJK_EXTENSION_A_START, encoded: CJK_EXTENSION_A_STROKES },
{ start: CJK_UNIFIED_START, encoded: CJK_UNIFIED_STROKES },
{ start: CJK_EXTENSION_B_START, encoded: CJK_EXTENSION_B_STROKES },
];
function decodeRange(range: StrokeRange) {
if (range.bytes) return range.bytes;
const binary = atob(range.encoded);
range.bytes = Uint8Array.from(
binary,
(character) => character.charCodeAt(0),
);
return range.bytes;
}
export function getStrokeCount(character: string) {
const codePoint = character.codePointAt(0);
if (!codePoint) return null;
for (const range of strokeRanges) {
const offset = codePoint - range.start;
if (offset < 0) continue;
const bytes = decodeRange(range);
if (offset >= bytes.length) continue;
return bytes[offset] || null;
}
return null;
}区间数量固定为 3,因此单字查询可以视为 O(1),空间复杂度则由三个稠密 byte 数组决定。
姓名拆分要使用 Array.from(),不能直接依赖 UTF-16 code unit。CJK Extension B 中的字符位于基本多文种平面之外,在 JavaScript 字符串里会占用一对 surrogate:
export function getNameStrokeCounts(name = "") {
return Array.from(name.replace(/·/g, "")).map((character) => ({
character,
strokes: getStrokeCount(character),
}));
}中文姓名中的间隔号 · 不参与计数。若产品允许其他分隔符,也应在输入规范中明确,而不是把所有非汉字静默忽略。
传统姓名学备注:只补充,不覆盖#
传统备注的生成分为两步:
- 从 OpenCC 读取简体字的所有繁体候选。
- 到固定版本的 Kangxi 数据中查询候选字的传统笔画。
只有同时满足下面条件时,才生成确定备注:
- OpenCC 只有一个候选字。
- 该候选在 Kangxi 数据中有有效笔画数。
- 传统笔画与当前字形主值不同。
如果 OpenCC 给出多个候选,例如:
发 發 髮
别 別 彆单看一个字无法知道用户想表达的词义。实现会记录为 ambiguous,页面只提示“可能对应多种繁体,本页不代选”,而不是选择笔画差异最大或列表中的任意一个候选。
建议把返回类型设计成显式的联合类型:
import { TRADITIONAL_NOTES } from "./generatedStrokeData.js";
type TraditionalNote =
| { kind: "traditional"; character: string; strokes: number }
| { kind: "ambiguous" }
| null;
export function getTraditionalNote(character: string): TraditionalNote {
if (!(character in TRADITIONAL_NOTES)) return null;
const note = TRADITIONAL_NOTES[character];
if (!note) return { kind: "ambiguous" };
return {
kind: "traditional",
character: note[0],
strokes: note[1],
};
}以“韩”为例,完整结果是:
{
"character": "韩",
"strokes": 12,
"traditionalNote": {
"kind": "traditional",
"character": "韓",
"strokes": 17
}
}主界面应先显示“韩 12 画”,传统备注后出现。不能为了贴合传统姓名学口径,直接把用户输入的“韩”显示成 17 画。
与分析动画配合#
笔画数据适合在进入分析页之后动态加载,避免增加落地页首屏负担:
useEffect(() => {
let cancelled = false;
void import("./strokeLookup").then(
({ getStrokeCount }) => {
if (!cancelled) {
setResult(characters.map((character) => getStrokeCount(character)));
}
},
() => {
if (!cancelled) setResult(characters.map(() => null));
},
);
return () => {
cancelled = true;
};
}, [characters]);一套稳定的逐字展示策略是:
- 姓名字形先完整出现,并在后续阶段保持挂载和位置不变。
- 数据准备完成且进度进入“核对笔画”阶段后,首个标签经过一个很短的延迟再出现。
- 后续按固定间隔只增加一个已揭示标签,避免多个结果同帧闪现。
- 若笔画尚未逐字展示完,视觉进度停在该阶段的结束阈值,避免进度条已经进入下一阶段而笔画仍在补播。
- 开启
prefers-reduced-motion: reduce时,不强制播放逐字动画,数据准备好后一次显示全部结果。
控制函数可以保持为纯函数,便于测试:
function getNextRevealCount(
current: number,
total: number,
dataReady: boolean,
reducedMotion = false,
) {
if (!dataReady || total <= 0) return current;
if (reducedMotion) return total;
return Math.min(total, current + 1);
}
function getDisplayedProgress(
rawProgress: number,
revealed: number,
total: number,
) {
const strokePhaseEnd = 35;
if (rawProgress > strokePhaseEnd && revealed < total) return strokePhaseEnd;
return rawProgress;
}字形与笔画标签应从首帧就占好各自位置。未揭示的 <small> 可以保持 visibility: hidden 或 opacity: 0,但不要在计数时才插入 DOM,否则新增第二行会让已经显示的姓名方格发生跳动,看起来像“名字重新播放了一次”。
回归测试#
最低限度要覆盖当前字形、传统字形、歧义和缺失值:
import assert from "node:assert/strict";
import test from "node:test";
import {
getNameStrokeCounts,
getStrokeCount,
getTraditionalNote,
} from "./strokeLookup.js";
test("uses the glyph entered by the user", () => {
assert.deepEqual(getNameStrokeCounts("其韩文"), [
{ character: "其", strokes: 8 },
{ character: "韩", strokes: 12 },
{ character: "文", strokes: 4 },
]);
assert.equal(getStrokeCount("张"), 7);
assert.equal(getStrokeCount("張"), 11);
assert.equal(getStrokeCount("韓"), 17);
});
test("does not guess ambiguous or unsupported input", () => {
assert.deepEqual(getTraditionalNote("发"), { kind: "ambiguous" });
assert.deepEqual(getTraditionalNote("别"), { kind: "ambiguous" });
assert.equal(getStrokeCount("A"), null);
assert.equal(getStrokeCount("…"), null);
});还应增加以下工程检查:
- 生成器对非整数、0、负数、超出 byte 范围的数据 fail closed。
- Extension B 字符按一个 Unicode code point 处理,而不是拆成两个 surrogate。
- 姓名超过展示上限时,省略号不占笔画槽位,也不显示“待核”。
- 异步分包加载失败时仍能完成动画,只把结果稳定降级为“—”。
- 数据延迟到达时仍逐个显示,不能在同一帧补出多个标签。
- 减少动态效果模式不应被动画时序阻塞。
- 落地页初始分包不包含完整笔画数据,只有进入分析页后才请求异步分包。
准确性与产品边界#
这套方案仍有明确限制:
- 它是数据查询,不是字形识别。 数据源错误、缺失或口径变化会直接反映到结果中。
- 当前只生成 Extension A、基本 CJK Unified Ideographs 和 Extension B。 更后的 CJK 扩展区字符会返回
null,除非扩展生成范围和数据源覆盖。 - 当前生成器主动把有效笔画上限设为 63。
Uint8Array本身可以表示 0–255;这里的 63 是数据校验约束,不是 byte 的物理上限。若未来要接受更大的值,应先审查数据来源和异常值,再决定放宽校验或升级编码。 - 单字简繁映射不理解上下文。 “发”和“别”这类一对多转换不能仅靠字符级映射确定词义。
- 未知字不能参与完整总数。 如果姓名中有一个字没有可靠数据,就不应把其余字的和包装成“姓名总笔画”。最多只能明确标成“已知部分合计”。
- 传统姓名学属于民俗文化口径。 页面应写清娱乐与文化参考属性,不能把结果包装成职业、婚恋、健康或投资判断。
如果业务要求接近字典工具或教育产品的准确度,还需要选择权威字典、明确地区字形标准、建立人工校勘流程,并为数据版本提供可追踪的变更记录。本文方案更适合需要快速、离线、可解释展示的 Web 交互,但不能替代字典编纂工作。
可继续演进的方向#
- 将生成结果拆成更多 Unicode 分片,让浏览器只请求姓名实际命中的区间。
- 为生成产物记录源版本、条目数和 SHA-256,在 CI 中阻止未经审查的数据漂移。
- 建立人工覆写层,但每条覆写都要附来源、口径、审核人和回归测试。
- 把
currentGlyphStrokes、traditionalCharacter、traditionalStrokes、ambiguity建模成独立字段,避免 UI 层再从一个数字猜含义。 - 为长姓名、少数民族姓名间隔符、生僻字和多个地区字形标准设计明确的输入与展示策略。
参考资料#
babyname/fate v4.0.0(commita08ef790cc4e63157470417ce909a240a6a9b495)及其 MIT License- OpenCC 固定提交、
STCharacters.txt与 Apache License 2.0 breezyreeds/kangxi-strokecount固定提交、kangxi-strokecount.csv与 MIT License- Unicode 17.0.0 Character Database:Blocks
- MDN:
String.prototype.codePointAt() - MDN:
Array.from() - MDN:
Window.atob()