中文汉字笔画查询:当前字形与传统姓名学备注的工程实现

8月 13, 2026
JavaScript, TypeScript, Frontend, ByAI

AI 参与说明:本文由 Codex 根据公开数据源与一套已脱敏的 Web 实现辅助整理。文中的笔画值取决于固定版本的数据表与明确的展示口径,不应被理解为语言学、字典编纂或姓名学上的唯一结论;读者应结合文末原始资料交叉验证。

先说结论#

这套中文笔画查询并不是在浏览器里识别汉字的几何结构,再推导横、竖、撇、捺的数量。它采用的是更可控的工程方案:

  1. 在构建期读取固定版本的汉字数据,生成“Unicode 码点 → 笔画数”的静态查找表。
  2. 页面运行时按用户实际输入的字形查询主值。例如输入“韩”显示 12 画,输入“韓”显示 17 画。
  3. 对存在明确简繁对应关系的简体字,再单独给出传统姓名学备注。例如“韩”的主值仍是 12 画,同时备注“传统姓名学按繁体『韓』计 17 画”。
  4. 简体字可能对应多个繁体字时不替用户猜测,只提示“存在多种对应关系”。
  5. 数据表未收录的字返回 null,界面显示“—”或“暂无可靠数据”,绝不把缺失值伪装成 0 画。

主值与传统备注是两条独立的数据链。这个边界比“选择一个看起来权威的数字”更重要,因为“笔画数”本身必须先回答:按哪个字形、哪套字典和哪种用途来计数?

为什么同一个名字可能出现两套笔画数#

至少需要区分下面三个概念:

口径回答的问题示例
当前输入字形用户眼前输入的这个字通常写多少画 12 画, 17 画
对应繁体字形这个简体字明确转换为繁体后,繁体字形是多少画 7 画, 11 画
康熙/传统姓名学口径传统字书或姓名学数据如何计部首与字形 可备注为按 计 17 画

这三者有时相同,有时不同。以三点水为例,现代书写字形通常把 视为 3 画,传统字书按“水”部计数时可能按 4 画处理。于是,同一个繁体字的现代字形笔画与康熙口径仍可能相差 1 画。

因此,页面不应只写一个没有上下文的“笔画数”。更清楚的文案是:

  • 方格下方:12 画
  • 说明:上方按当前输入字形计
  • 补充:传统姓名学按繁体「韓」计 17 画

数据来源与职责#

本文整理的实现固定使用三份公开数据:

数据用途固定方式许可证
babyname/fate v4.0.0character.json当前输入字形的主笔画表固定 tag v4.0.0(commit a08ef790MIT
OpenCC STCharacters.txt简体字到繁体候选字的映射固定 commit 5249273Apache-2.0
kangxi-strokecount.csv繁体候选字的康熙笔画备注固定 commit 778d23dMIT

主字形数据与传统备注没有混成一张表:

  • 主值回答“用户输入的这个字是多少画”。
  • 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_strokekangxi_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),
  }));
}

中文姓名中的间隔号 · 不参与计数。若产品允许其他分隔符,也应在输入规范中明确,而不是把所有非汉字静默忽略。

传统姓名学备注:只补充,不覆盖#

传统备注的生成分为两步:

  1. 从 OpenCC 读取简体字的所有繁体候选。
  2. 到固定版本的 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]);

一套稳定的逐字展示策略是:

  1. 姓名字形先完整出现,并在后续阶段保持挂载和位置不变。
  2. 数据准备完成且进度进入“核对笔画”阶段后,首个标签经过一个很短的延迟再出现。
  3. 后续按固定间隔只增加一个已揭示标签,避免多个结果同帧闪现。
  4. 若笔画尚未逐字展示完,视觉进度停在该阶段的结束阈值,避免进度条已经进入下一阶段而笔画仍在补播。
  5. 开启 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: hiddenopacity: 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。
  • 姓名超过展示上限时,省略号不占笔画槽位,也不显示“待核”。
  • 异步分包加载失败时仍能完成动画,只把结果稳定降级为“—”。
  • 数据延迟到达时仍逐个显示,不能在同一帧补出多个标签。
  • 减少动态效果模式不应被动画时序阻塞。
  • 落地页初始分包不包含完整笔画数据,只有进入分析页后才请求异步分包。

准确性与产品边界#

这套方案仍有明确限制:

  1. 它是数据查询,不是字形识别。 数据源错误、缺失或口径变化会直接反映到结果中。
  2. 当前只生成 Extension A、基本 CJK Unified Ideographs 和 Extension B。 更后的 CJK 扩展区字符会返回 null,除非扩展生成范围和数据源覆盖。
  3. 当前生成器主动把有效笔画上限设为 63。 Uint8Array 本身可以表示 0–255;这里的 63 是数据校验约束,不是 byte 的物理上限。若未来要接受更大的值,应先审查数据来源和异常值,再决定放宽校验或升级编码。
  4. 单字简繁映射不理解上下文。 “发”和“别”这类一对多转换不能仅靠字符级映射确定词义。
  5. 未知字不能参与完整总数。 如果姓名中有一个字没有可靠数据,就不应把其余字的和包装成“姓名总笔画”。最多只能明确标成“已知部分合计”。
  6. 传统姓名学属于民俗文化口径。 页面应写清娱乐与文化参考属性,不能把结果包装成职业、婚恋、健康或投资判断。

如果业务要求接近字典工具或教育产品的准确度,还需要选择权威字典、明确地区字形标准、建立人工校勘流程,并为数据版本提供可追踪的变更记录。本文方案更适合需要快速、离线、可解释展示的 Web 交互,但不能替代字典编纂工作。

可继续演进的方向#

  • 将生成结果拆成更多 Unicode 分片,让浏览器只请求姓名实际命中的区间。
  • 为生成产物记录源版本、条目数和 SHA-256,在 CI 中阻止未经审查的数据漂移。
  • 建立人工覆写层,但每条覆写都要附来源、口径、审核人和回归测试。
  • currentGlyphStrokestraditionalCharactertraditionalStrokesambiguity 建模成独立字段,避免 UI 层再从一个数字猜含义。
  • 为长姓名、少数民族姓名间隔符、生僻字和多个地区字形标准设计明确的输入与展示策略。

参考资料#

本文共 4533 字,上次修改于 Aug 13, 2026,以 CC 署名-非商业性使用-禁止演绎 4.0 国际 协议进行许可。

相关文章

» 浏览器 Fetch receiver 陷阱:为什么网络错误可能发生在请求之前

» Sentry 配置实践:前后端项目划分、Logs、Tracing、Source Maps 与 CLI 迁移

» 使用 Fontsource 管理 Web 字体:从手工字体文件到 npm 依赖

» 快手 H5 落地页、监测链接、归因与支付的通用实践

» 全面掌握 tRPC:端到端类型安全的下一代 API 框架