从拆字到组字:一个汉字部件反向查询工具是怎样实现的
解析 cyeam.com 组字工具的使用方式与实现:反向索引、繁简与异体兼容、递归部件合并、性能控制,以及可收录的动态 SEO。
- 先理解工具解决的问题
- 数据如何变成可查的索引
- 为什么繁简与异体字不能只靠字符串相等
- 部件还能逐层合并
- 页面如何把查询变成探索
- 生僻字查到了,为什么还要处理“显示不出来”
- 动态查询页如何避免 SEO 失控
- 总结
查字典时,我们习惯从一个字出发,查看它由哪些部件组成:例如「豔」可拆作「豐、去、皿」。但在读古籍、识别生僻字或做汉字学习时,问题常常反过来:手里只有几个部件,它们能组成什么字?
cyeam.com 的组字工具 做的正是这件事。输入「豐去皿」,可以反查到「豔」;只记得部分部件时,还能得到包含这些部件的候选字。它看起来像一个小小的搜索框,背后却涉及数据建模、索引设计、字符变体处理、受限组合搜索,以及动态页面的 SEO 边界。
先理解工具解决的问题
工具的输入是连写的汉字部件,不需要加空格或分隔符。它有几种典型用法:
- 输入「豐去皿」,按完整部件组合反查「豔」。
- 输入一个字,例如「豔」,查看该字的拆分,并继续点击部件向下探索。
- 只输入记得的部分部件,查看「包含匹配」中的候选字。
- 输入
U+263F6一类 Unicode 码点,直接定位扩展区生僻字。 - 用简体、繁体或部分异体部件查询同一组字关系。
结果页不只是给出字符。每个字还会带上拼音和码点;扩展区字无法可靠显示时,页面改用字形图展示。单字查询会补充异体字与对应繁体写法,卡片可复制、放大预览,并能继续作为下一轮查询入口。
数据如何变成可查的索引
原始拆字数据的每一行记录一个汉字及其部件。例如可以把「豔 = 豐 + 去 + 皿」理解为一条 word + parts 记录。一个字可能有不止一种拆法,因此实现会保留所有记录,而不是只保留其中一种。
服务启动时读取拆字库,并同时建立两套索引:
| 索引 | 键 | 值 | 用途 |
|---|---|---|---|
| 精确索引 | 排序后的完整部件列表 | 汉字列表 | 查「这些部件正好组成什么字」 |
| 部件索引 | 单个部件 | 拆字记录下标列表 | 快速缩小「包含匹配」的候选范围 |
精确索引的关键是先将部件排序,再拼成稳定的键。这样用户输入的先后顺序不会影响结果:豐去皿 与 皿豐去 指向同一个键。
func decompKey(parts []string) string {
p := make([]string, len(parts))
copy(p, parts)
sort.Strings(p)
return strings.Join(p, " ")
}
第二个索引解决的是另一类问题。若只记得两个部件,最朴素的方案是扫描全部拆字记录,再逐条判断是否包含它们;字库增长后,这种做法会把大量无关记录带进每一次查询。
现在的做法是:先分别取出每个输入部件能命中的记录集合,选择其中最小的一组作为候选集,再检查候选记录是否同时满足其余部件。查询不再从整个字库开始,通常能显著减少需要检查的记录数。结果再按拆分部件数量从少到多排序,让结构较简单、相关性更强的字排在前面。
为什么繁简与异体字不能只靠字符串相等
汉字部件并不总以完全相同的字符出现。用户可能输入简体「虫」,字库中却记录为繁体「蟲」;也可能遇到「豊」与「豐」、或「支」与「攴」这样的形近或异体部件。若按字面相等查询,工具会制造许多“明明应该查到却查不到”的挫败感。
因此,查询开始前会为每个输入部件扩展一个候选集合:
- 保留原始字符;
- 用 OpenCC 尝试简体转繁体、繁体转简体;
- 加入部件级异体与形近映射。
随后精确匹配会枚举这些候选集合的组合,分别查询精确索引;包含匹配也会基于候选集合判断。这样既支持常见的繁简互查,也不会把字级异体字和部件级变体混在一起。
这里有一个很重要的分层:部件级变体参与匹配,字级异体字只用于展示。 字级异体表会在用户输入单个字、或精确结果出现后,补充可浏览的异体字卡片;它不会反过来扩大部件搜索。这个边界能避免结果无限膨胀,也让“匹配到”仍然有清晰含义。
部件还能逐层合并
汉字拆分并非总处于同一层级。假设用户输入「林隹攴」,而字库中一条中间记录是「㪔 = 林 + 攴」,目标字又记作「𣀧 = 㪔 + 隹」。如果只能比对输入部件和最终记录,就会错过这个合理结果。
工具把这种情况视为有限次的“中间部件合并”:
- 先直接用输入部件查精确索引;
- 枚举其中 2 到 4 个部件的子集;
- 若子集本身正好组成某个字,就以该字替换子集;
- 用新部件列表继续查询。
这相当于沿着拆字关系向上走一两层。它足以覆盖常见的层级拆分,却不能无限递归:组合数量会随着部件数迅速上升。实现因此设置了明确护栏——输入部件候选数不超过 8、合并深度最多 2 层、搜索预算最多 10,000 次、精确和包含结果各最多展示 100 个。对交互工具来说,“快速给出最有价值的一批结果”比无边界地穷举更重要。
页面如何把查询变成探索
组字页把一次检索设计成可继续深入的路径:
输入部件 → 精确匹配 / 包含匹配 → 点击某个结果字
↓
查看其拆分、异体与繁体
↓
点击任一部件继续反查
这让它不仅适合“找一个不知道怎么写的字”,也适合拆字学习、古文字阅读时的部件探索。前端用 Bootstrap 卡片组织结果,移动端可自适应排列;对超出 BMP 的生僻字符以字形图片降级显示,避免不同操作系统字体覆盖不一致导致“查到了却看不见”。
生僻字查到了,为什么还要处理“显示不出来”
能从字库检索到一个字,并不等于每位用户都能看见它。常用汉字大多位于 Unicode 的基本多文种平面(BMP);但扩展 B 区及之后的汉字码点超过 U+FFFF,需要更完整的 CJK 扩展字库。许多系统默认字体没有覆盖这些字符,浏览器就可能显示空白方框、替代符号,或在不同设备上呈现出不一致的字形。
下面是一套可以直接复用的渐进增强方案:普通字用文本和字体栈,扩展区字用 SVG 字形兜底;无论哪种显示路径,始终保留原始 Unicode 字符。
用到的工具与资源
| 工具或资源 | 在方案中的职责 | 是否必须 |
|---|---|---|
Go 标准库 fmt |
格式化 Unicode 码点,并拼出 SVG 地址 | 后端为 Go 时需要 |
| GlyphWiki | 按 Unicode 码点提供 CJK 扩展字的 SVG 字形 | 推荐,解决跨设备显示 |
| 程荣光刻楷 Web Font | 补强常用汉字和部分扩展字的浏览器字体栈 | 可选,但体验更好 |
| Bootstrap 5 | 本项目用它承载卡片、弹窗等界面;与字形方案无耦合 | 可替换 |
| 原生 JavaScript | 监听 SVG 加载失败并回退到原始文本 | 推荐,不额外引入包 |
这里不需要专门的“生僻字 npm 包”。关键在于 Unicode 码点判断、一个可信字形源,以及失败回退。若不是 Go 服务端,把下面的码点判断移到任意后端或浏览器端即可。
后端:标记扩展区字并生成字形地址
模型只需提供两个方法。rune 是 Go 的 Unicode 码点类型,因此无需按 UTF-8 字节手动切分字符串。
import "fmt"
// 是否包含基本多文种平面以外的字符。
func outsideBMP(word string) bool {
for _, r := range word {
if r > 0xFFFF {
return true
}
}
return false
}
// GlyphWiki 的字形文件名使用小写十六进制 Unicode 码点。
func glyphURL(word string) string {
for _, r := range word {
return fmt.Sprintf("https://glyphwiki.org/glyph/u%x.svg", r)
}
return ""
}
对组字工具而言,结果卡片会同时携带 word、outsideBMP 和 glyphURL。只要 outsideBMP 为真,模板便显示 SVG;否则直接渲染文字。请保留 alt、data-word 或等价字段为原字符,复制、检索、辅助技术和失败回退都会用到它。
模板:文本优先,SVG 只负责稳定呈现
下面以 Go 模板为例。普通字走字体栈;扩展区字改用图片,但 alt 始终为原始汉字。
<img class="glyph-image" src="" alt="" width="48" height="48" />
<span class="hanzi"></span>
<link rel="stylesheet" href="https://chinese-fonts-cdn.deno.dev/packages/crgkk/dist/程荣光刻楷/result.css" />
<style>
.hanzi {
font-family: chengrongguangke, "Songti SC", STSong, "SimSun-ExtB",
"Noto Serif CJK SC", HanaMinB, HanaMinA, serif;
}
</style>
字体栈仍然值得保留:它让普通字无需额外网络请求就能像文本一样选中、缩放和复制;SVG 只用于不能可靠显示的那一小部分结果。
前端:SVG 不可用时回退为可复制文本
外部字形源不是查询功能的前提。为每张字形图加上 error 监听;加载失败或浏览器已经报告自然宽度为零时,用原始文本替换图片。
function textFallback(word) {
const text = document.createElement("span");
text.textContent = word;
text.className = "hanzi";
text.style.userSelect = "text";
return text;
}
document.querySelectorAll(".glyph-image").forEach((image) => {
const fallback = () => image.replaceWith(textFallback(image.alt));
image.addEventListener("error", fallback, { once: true });
if (image.complete && image.naturalWidth === 0) fallback();
});
这套顺序的重点是:视觉呈现可以降级,信息不能丢失。 复制按钮应复制 data-word 中的原字符而不是图片 URL;放大预览也应复用同一套 SVG 与文本回退逻辑。这样,即便用户的字体不全、GlyphWiki 暂时不可用,仍能得到字、码点和可复制结果。
动态查询页如何避免 SEO 失控
/tool/zuzi?parts=豐去皿 与工具首页的内容不同,搜索引擎应该能理解这是一个独立结果页。因此页面的 canonical URL 会保留 parts 参数,标题和 description 也根据结果动态生成:
- 单字且有拆分数据时,突出“这个字怎么拆”;
- 有组字结果时,突出“这些部件可以组成什么字”,并列举少量候选;
- 完全没有结果时,保留帮助性标题,但输出
noindex,follow。
最后一点尤其关键。部件组合的可能性近乎无限,不能让每一种空组合都被收录;另一方面,单个有效部件的集合是有限的。站点 sitemap 只主动加入拆字库中去重后的有效汉字部件页,并将其优先级设为较低、更新频率设为每月。这样既为真实需求建立入口,也不会让无价值参数页稀释站点质量。
总结
一个好用的汉字组字工具,不只是把“拆字”倒过来查一遍。它至少要处理五件事:
- 用精确索引和部件索引,让完整匹配与模糊包含匹配都足够快;
- 理解繁简、异体和形近部件,而不是机械比较字符;
- 支持有限层级的部件合并,贴近真实的拆字关系;
- 用结果上限、深度和预算控制组合搜索;
- 为有价值的查询生成可收录页面,同时拒绝索引空结果。
这些细节共同决定了体验:用户只要把记得的部件输入进去,就能从一个模糊线索走到具体汉字,再沿着部件关系继续探索。下次遇到一个只认得结构、不知道读音或编码的字,不妨试试 组字 · 积部成字。
原文链接: 从拆字到组字:一个汉字部件反向查询工具是怎样实现的 ,转载请注明来源!
– EOF –