ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

汉语词典数据库JSON结构解析与前端查询实战

汉语词典数据库JSON结构解析与前端查询实战

简介:汉语词典数据库为中文数据处理与前端开发者提供了一份可离线使用的结构化字词汇总,总计收录两万一千一百零四个汉字和符号,每条记录包含带音调拼音、无音调拼音、笔画数、偏旁等字段,并附有原页面链接与详情,适合构建拼音查询、汉字笔画检索或词典类小工具。压缩包共三十三个文件,以JSON数据文件为主体,辅以少量JavaScript脚本和说明文档,整体约三十三点三六MB,便于下载与二次开发。数据预留多种颗粒度:完整版包含全部字段,按千字分片的数据文件方便对比差异和增量更新,精简版移除详情与页面信息以缩减体积,纯汉字版只保留汉字,另有带调与不带调单韵母对照表,可应对不同使用场景。已有五百一十三人学习浏览,对需要现成字典数据的个人项目来说,省去爬取清洗的麻烦,可按需选用对应版本;个人使用无需授权,但请注意协议禁止商用。

1. 汉语词典数据库:离线也能跑的 JS 词库长什么样

做输入法、字形练习、成语接龙这类应用时,最让人头疼的不是前端交互,而是词库。见过不少人拿着抓下来的网页词表硬塞到前端,结构乱不说,多音字、简体繁体对不上,最后全在用户面前翻车。这份 chinese-dictionary 是一份以 JSON 形式组织的汉语词典数据,覆盖汉字、词语、拼音、释义等常见字段,可以直接被 JavaScript 读取,也能在浏览器里离线跑。适合想做中文检索、字谜、语言学习工具,又不想自己从零抓数据的开发者。下面直接拆数据结构,再讲怎么把它用起来。

2. 数据设计:从 JSON 结构到词条字段的选择

2.1 顶层是数组还是对象:先看两种布局的差异

打开词库文件,第一眼要看顶层结构。常见做法有两种,一种是顶层直接放数组,另一种是顶层放对象,以汉字或词语作为键。这份 chinese-dictionary 的实际落地里,数组布局更常见,因为词典天然是一个有序词条集合,数组可以保留原始顺序,也方便后续做分页、切片和遍历。

[ { "word": "你好", "pinyin": "nǐ hǎo", "definition": "用于初次见面或日常问候的用语", "radical": "亻", "strokes": 7, "frequency": 9821 }, { "word": "汉字", "pinyin": "hàn zì", "definition": "记录汉语的文字系统", "radical": "氵", "strokes": 5, "frequency": 7312 } ]

这段 JSON 展示了词条的最小完整结构。word是词条本身,pinyin保存带声调拼音,definition是释义,radical是部首,strokes是总笔画数,frequency是词频。其中frequency字段不是所有词库都有,但如果要排序或者做热度筛选,这个字段价值很高。

为什么不用对象以word为键?因为同一个词可能对应多个读音,比如“行”可以读 háng 也可以读 xíng,如果直接用对象做 map,后面的词条会覆盖前面的。数组布局允许同一个词出现多条记录,后期做多音字聚合时更安全。

2.2 字条与词条:字段怎么拆才不冗余

汉字类和词语类条目建议分两种结构存。单字条目重点在字形信息,比如笔画、部首、结构;词语条目重点在读音、释义和用法。如果在同一个数组里混装,就要靠type字段区分。

[ { "type": "char", "word": "好", "pinyin": ["hǎo", "hào"], "definition": "优点多或使人满意的;也指喜爱", "radical": "女", "strokes": 6, "structure": "左右结构" }, { "type": "word", "word": "美好", "pinyin": "měi hǎo", "definition": "令人满意的;完美", "source": "常用词表" } ]

加type字段之后,查询时可以先按类型过滤,再走具体逻辑。单字的pinyin用数组,是因为多音字很常见,数组可以直接承载多个读音;词语的拼音一般固定,用字符串就够了。structure字段只在字条里出现,对词语没有意义,这样拆可以避免大量空字段。

字段命名上建议统一用英文小写加下划线,方便后面接 Node.js 或数据库导出,避免中文键名在编码转换时出问题。

2.3 原数据可能没有的字段:值不值得自己补

拿到一份词典数据,最常缺的是词频、标签和例句。词频可以决定搜索排序,标签可以用来做分类筛选,例句能直接提升学习类应用的完成度。但补字段前要考虑数据一致性。

我一般会写一个独立的增强脚本,不直接改原始词库,而是生成一个新的 JSON 文件。这样原始数据还在,后面发现补错了可以随时重跑。常见做法是把常用词表按词频排序,然后用二分查找给现有词条追加frequency,查不到的就默认给一个低频值。这个脚本跑一次也就几十秒,比手动改靠谱得多。

不要试图把所有字段都塞进一个文件里,几个文件分开放反而好维护。后面章节会专门提到怎么把这些文件合并着用。

3. 加载与查询:把词典在浏览器里真正跑起来

3.1 fetch 加载本地 JSON:处理编码与路径问题

浏览器里读 JSON 最直接的方式是fetch,但本地调试时经常踩到file://协议下的 CORS 报错。这个问题不解决,后面再好的代码也跑不起来。

async function loadDictionary(url) { const response = await fetch(url); if (!response.ok) { throw new Error(`词库加载失败:HTTP ${response.status}`); } const data = await response.json(); return Array.isArray(data) ? data : data.words; } const dict = await loadDictionary('./data/dictionary.json'); console.log(`共加载 ${dict.length} 个词条`);

这段代码的核心是fetch加response.json()两步。先检查response.ok,可以提前拦截 404 或 500 的错误,不至于让页面直接白屏。最后一行Array.isArray(data) ? data : data.words是为了兼容顶层是数组或对象两种结构,避免换个词库格式就崩。

注意:如果直接在浏览器里双击 HTML 文件打开,fetch会被浏览器拦截,因为file://协议不属于 HTTP 协议。最简单的办法是起一个本地静态服务器,比如npx serve .,然后用localhost地址访问页面。这是几乎所有词典类前端项目都会遇到的第一道坎。

3.2 构建内存索引:拼音、部首、简化字三种键

词库文件可能有几万条记录,如果每次查询都遍历整个数组,输入框一敲字就得等几十毫秒,用户体验完全没法看。正确做法是加载完成后立即构建索引。

function buildIndex(dict) { const byPinyin = new Map(); const byRadical = new Map(); const byFirstChar = new Map(); for (const entry of dict) { const pinyinStr = Array.isArray(entry.pinyin) ? entry.pinyin.join(' ') : entry.pinyin; pinyinStr.split(' ').forEach((py) => { if (!byPinyin.has(py)) byPinyin.set(py, []); byPinyin.get(py).push(entry); }); const radical = entry.radical || ''; if (!byRadical.has(radical)) byRadical.set(radical, []); byRadical.get(radical).push(entry); const firstChar = entry.word.charAt(0); if (!byFirstChar.has(firstChar)) byFirstChar.set(firstChar, []); byFirstChar.get(firstChar).push(entry); } return { byPinyin, byRadical, byFirstChar }; }

这个索引构建逻辑里,byPinyin把每个声调拼音映射到词条数组,搜nǐ时直接拿数组,不用全表扫。byRadical用于按部首筛选,byFirstChar用于首字搜索,比如输入“美”时快速找出所有“美”开头的词。

注意多音字处理:单个汉字条目的pinyin是数组,拼成字符串后按空格拆分,索引里每个读音都能指向同一个字条。这样搜“hǎo”和“hào”都能找到“好”字。索引构建是在内存里完成的,几万条数据构建时间通常不超过几百毫秒,不用刻意优化。

3.3 查询与分页:大数据量下保持帧率

索引建好了,但查询结果可能一次性命中几百条。如果全塞到页面上,DOM 渲染会卡。更稳妥的做法是内存分页或增量渲染。

function searchByPinyin(index, query, page = 1, pageSize = 20) { const results = index.byPinyin.get(query) || []; const start = (page - 1) * pageSize; const end = start + pageSize; return { total: results.length, items: results.slice(start, end), page, pageSize }; } const result = searchByPinyin(indexMap, 'hǎo', 1, 20); console.log(result.total, result.items);

这里把分页参数直接内联到查询函数里,page默认从 1 开始,pageSize默认 20。返回对象里带上total是为了让前端能渲染总数,通用做法是配合分页组件一起用。

如果词条特别多,可以考虑用IntersectionObserver做无限滚动,但个人经验是:先做前端分页最简单,等真实用户反馈说翻页不方便再改不迟,不要一开始就上无限滚动。

4. 场景实战:分词、简繁对照与查询接口组装

4.1 用正向最大匹配做一个中文分词器

词典数据可以做的第一件实活是分词。虽然浏览器里不能跑重型 NLP 库,但基于词典的正向最大匹配足以应付查询提示、标题切片这类轻量场景。

function segment(text, dict, maxLen = 4) { const words = new Set(dict.map((item) => item.word)); const result = []; let i = 0; while (i < text.length) { let matched = null; for (let len = maxLen; len >= 1; len--) { const slice = text.slice(i, i + len); if (words.has(slice)) { matched = slice; break; } } if (matched) { result.push(matched); i += matched.length; } else { i += 1; } } return result; } const dict = [{ word: '汉语' }, { word: '词典' }]; console.log(segment('汉语词典数据库', dict));

逻辑不复杂:从当前位置起,按最长 4 个字往前切,命中词库就记录并跳过去,没命中就移动一个字符继续。这个算法叫正向最大匹配,核心优势是代码量小、不依赖外部服务。

maxLen是最大词长,语料里常见词一般不超过 4 个字,设成 4 是性能与正确率的折中。如果某些专有名词更长,可以调大这个参数,但分词耗时会线性增加。这个词组树适合离线或工具场景,不适合追求召回率的复杂文本分析。

4.2 简体转繁体:复用词典字段实现映射

很多输入法和学习工具需要简繁转换。如果词库里有simple和traditional字段,可以顺带做一个转换函数。

const simpleToTraditional = new Map(); dict.forEach((entry) => { if (entry.simplified && entry.traditional) { simpleToTraditional.set(entry.simplified, entry.traditional); } }); function toTraditional(text) { return text .split('') .map((char) => simpleToTraditional.get(char) || char) .join(''); } console.log(toTraditional('汉语'));

这里依赖的是词库里的字段映射,不是转换算法。词库里有simplified和traditional字段时,构建一个 Map 就能完成单字转换;词库没有这两个字段时,只能退而求其次用外部映射表,但那种做法处理多音字时很容易出错。

短语级别的转换还要注意上下文,比如“里面”和“裏面”在不同地区习惯不同。单字映射只适合工具型功能,真要做全文转换,建议把词条级映射也带上。

4.3 把词库封装成一个 REST 风格查询接口

前端资源可以配合一个简单的 Node 服务把词库暴露成接口。常用于团队内部或者其他前端项目快速接入。

import { createServer } from 'http'; import { readFile } from 'fs/promises'; const dict = JSON.parse( await readFile('./dictionary.json', 'utf-8') ); createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); const word = url.searchParams.get('word'); if (!word) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: '缺少 word 参数' })); return; } const matches = dict.filter((item) => item.word.includes(word)); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ total: matches.length, items: matches.slice(0, 50) })); }).listen(3000);

这个接口读取查询参数word,在词库数组里做includes过滤,返回前 50 条结果。对小型项目够用,但每次请求都做全量遍历,并发上来会吃 CPU。更稳妥的做法是启动时先把byFirstChar索引构建好,接口里只查索引,查询成本能降一个数量级。

返回结果强行限制 50 条是一个好习惯,避免接口响应体过大,前端渲染也轻松。生产环境如果数据量再涨,可以再套一层缓存或者直接换数据库,但作为资源落地方案,这一步已经覆盖大多数需求。

5. 常见问题与避坑:五个容易翻车的地方

5.1 页面卡顿不是词库大,是索引构建时机不对

现象:加载词库之后,页面卡住好几秒,点输入框都没反应。

原因:词库加载完立刻在主线程上跑循环构建索引,几万条数组的多次 Mapset操作叠加起来,阻塞了渲染线程。

解决:把索引构建放到requestIdleCallback里,或者干脆放到 Web Worker 里。词库仍用fetch加载,但解析完成后把原始数组传给 Worker,Worker 构建完索引再把结果传回主线程。构建期间可以先渲染页面框架,用户看到的是可用界面,感知不到卡顿。

5.2 多音字查询结果全乱

现象:搜“hǎo”时把“爱好”也带出来了,用户看得很困惑。

原因:词条的pinyin字段是数组时,代码用join(' ')拼接再拆开索引。但“爱好”的“好”读 hào,却被当成了 hǎo 的词条索引。

解决:索引构建时区分单字和词语。单字走字典音索引,词语走整体拼音索引。查询时先判断输入是单字拼音还是完整拼音,再决定查哪个索引。不要试图用一个索引兼管单字和词语,这纯属给自己挖坑。

5.3 加载本地词库被 CORS 拦截

现象:双击 HTML 文件打开页面,词库加载失败,控制台报 CORS 错误。

原因:浏览器安全策略限制file://协议下的fetch请求,不允许网页直接读取本地文件。

解决:不要直接打开文件,起一个本地静态服务。命令行里跑npx serve .或者用 Node 写一个一百行的静态服务器。同一个词库放到 HTTP 协议下访问,CORS 问题自然消失。另一个办法是直接把词库内联成 JS 变量,适合词库小于 10MB 的场景。

5.4 词条缺失或释义为空

现象:某些生僻字搜不到,或者找到了却有definition为空。

原因:原始词库来自自动抓取或数据合并,覆盖率不全,清洗阶段也没过滤空字段。

解决:查询函数里做兜底判断。如果definition为空,可以返回“暂无释义”而不是抛异常。更细一点的做法是在加载阶段就跑一遍数据校验,统计空字段比例,超过阈值直接告警。这个地方宁可用一个干净的过滤后数据文件,也不要带着一堆空壳词条上线。

5.5 繁体简体系混乱,搜索对不上

现象:用户搜“著”时,词库里既有“著”也有简体“着”,但检索结果只命中其中一个。

原因:词库数据源混用了简繁两个标准,又没有统一字段。

解决:加载阶段构建一个简繁别名表,把simplified和traditional字段都放到索引里,查询时两个字段都查一遍,结果合并去重。更彻底的做法是把词库所有词条统一转成简体,备份一份繁体恢复表,展示时按用户设置再转回去。这个方案能避免 90% 以上简繁引起的翻车。

6. 进阶用法:把索引下沉到 Web Worker 并做缓存

词库做到一定规模,光靠主线程内存索引是不够的。我的习惯是先把词库原始 JSON 缓存在 IndexedDB 里,第二次打开页面直接读缓存而不是重新 fetch。缓存成功之后,再让 Web Worker 来完成索引构建,主线程只负责接收查询指令。

// worker.js self.onmessage = (event) => { const { type, payload } = event.data; if (type === 'build') { const index = buildIndex(payload.dict); self.postMessage({ type: 'index-ready', payload: index }); } };

主线程里用new Worker('./worker.js')创建 Worker,把词库数据传过去,构建完再接回索引。这样做的好处是,即使词库达到二十万条,页面输入框的输入响应依然维持在 16ms 以内。查询请求通过postMessage异步往返,交互体验接近原生应用。

从这次拆词库的经历里,我学到的不是某个 API 怎么用,而是拿到任何数据资源都要先确定三件事:数据结构稳不稳、加载链路通不通、查询路径有没有兜底。从那以后,我每次处理词典类数据都强制走一遍“先看结构、再跑加载、最后写查询”的三步流程,这个习惯帮我避开了很多后续返工。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进