☰
纯静态HTML实现Lojban无空格切词与词典检索
2026/10/27 17:59:13 网站建设 项目流程

简介:面向 Lojban 语言学习者、构造语言研究者以及词典与解析工具开发者的组合资源,汇集解析器、词典软件、搜索界面和配套数据,并以 Docker 或 podman 容器化方式组织,方便在统一环境中体验与二次开发。压缩包共 2000 个文件,约 44.38MB,以 mp3 音频数量最多,另含 C 源码、JavaScript、JSON 配置、HTML 页面、SVG/PNG 图形、ogg 音频及 Perl 脚本等,覆盖词条数据、解析实现、前端展示和机器人配置等多类内容,目录中保留多种实现与配置样例,便于对照不同模块的职责。已有 169 人学习下载,适合希望了解 Lojban 工具链全貌的读者。对语言爱好者而言,词典与发音素材可辅助词义和读音学习;对开发者来说,解析器源码、检索界面与容器配置提供了可参考的实现路径,也便于在此基础上扩展查询、机器人或前端功能,具备较好的研究与复用价值。

1. 一句话里没有空格,怎么查?

拿到一句没写空格的 Lojban 文本,我第一反应是拆词,结果卡在「该从哪儿下刀」上:klama、laklama这种连续字母流,肉眼看不出边界。livla 就是冲着这个场景做的——它把 parser、词典检索和 search-interface 三件事塞进一个纯静态 HTML 页面,输入原文,中间出切好的词,右侧出释义,切错的地方标红。它不做成套的语法分析,也不求把整句译出来,只解决「读到一半卡住、想立刻查明白」这一段。

它适合三类人:读文本时词形记不全、需要边读边查的;想给自己那套小工具配一个现成前端外壳的;以及想看看 dictionary-software 在纯浏览器里怎么落地的。整个项目没有后端、没有数据库,fetch三个 JSON 就能跑,改起来成本很低。

2. 整体架构:parser、词典、搜索界面在同一个 HTML 里怎么分工

2.1 三层职责:解析是纯函数,词典是只读数据,渲染只碰 DOM

我把这个项目切成三层,切分的依据不是文件多少,而是依赖方向。最底下的解析层(lexer.js+parser.js)是纯函数:输入字符串和词典 Map,输出一个普通数组,不碰 DOM、不读全局状态。中间的数据层(dict.js)只负责把 JSON 装进Map,装完就不再变。最上面的视图层(render.js+main.js)拿解析结果去拼 HTML,只有它碰 DOM。

这么切有两个直接好处。第一,解析层能单独跑单元测试——node --test里直接import { tokenize }就行,不用起浏览器。第二,词典能整体替换:想加一门语言的释义,只要新 JSON 的 key 结构对得上,lexer和render一行都不用改。反过来,如果一开始就把「切词 + 查词 + innerHTML」写在一个input事件回调里,等你发现某条词形规则写错时,得先把渲染逻辑扒开才能调试,这是血泪经验。

三层之间没有框架、没有响应式状态。我一般是这个顺序:input事件 →normalize归一化 →tokenize切词 →parse补位置和配对信息 →render拼 HTML。每一步的产物都是可打印的普通对象,出问题直接console.log中间结果,比在 DevTools 里打断点省事。

2.2 目录结构与构建产物

项目是纯静态的,index.html直接放在根目录,靠一个静态服务器托管。目录长这样:

livla/ ├── index.html # 唯一入口,输入框 + 结果区 ├── assets/ │ ├── css/style.css # 词类配色、错误态样式 │ └── js/ │ ├── main.js # 事件绑定,串联三层 │ ├── lexer.js # 归一化 + 切词 + 词形判定 │ ├── parser.js # 括号/引号配对,产出 span 列表 │ ├── dict.js # 词典装载、正查、反查 │ └── render.js # 转义 + 高亮拼接 ├── data/ │ ├── cmavo.json # 结构词 │ ├── gismu.json # 词根 │ └── index.json # 预构建的英文倒排索引 └── tools/ └── build-index.mjs # Node 脚本,生成 index.json

data/里的三个文件是运行时产物,不是手写源码。原始词表通常从社区词典导出,字段一大堆,直接丢给前端加载会拖慢首屏。tools/build-index.mjs的职责就是把原始词表压成前端真正需要的最小结构:{ 词形: { type, gloss, rafsi } },顺便把英文释义切成倒排索引。这一步放在构建期做,浏览器端就只剩一次fetch。

index.html里所有<script>都要带type="module",否则import直接报语法错误,而且这个错误在控制台里不显眼,很容易被当成「文件没加载」。

2.3 一次查询的完整链路

main.js是唯一知道所有模块存在的地方,它短到可以整段贴出来:

// main.js —— 只做三件事:装词典、绑事件、串流水线 import { normalize, tokenize } from './lexer.js'; import { parse } from './parser.js'; import { loadDict } from './dict.js'; import { render } from './render.js'; const $src = document.querySelector('#src'); const $out = document.querySelector('#out'); const $hits = document.querySelector('#hits'); // 顶层 await:模块脚本里合法。词典没装完就切词,等于让 parser 空手跑 const dict = await loadDict('./data'); $src.addEventListener('input', () => { const source = normalize($src.value); const tokens = tokenize(source, dict.byWord); const spans = parse(tokens, source); render(source, spans, $out); // 原文着色 renderHits(tokens, dict, $hits); // 右侧释义列表 }); function renderHits(tokens, dict, el) { const seen = new Set(); const rows = []; for (const tk of tokens) { if (seen.has(tk.text)) continue; // 同一句里重复的词只列一次 seen.add(tk.text); const entry = dict.byWord.get(tk.text); rows.push(`<li><b>${tk.text}</b> <i>${tk.type}</i> ${entry?.gloss ?? '未收录'}</li>`); } el.innerHTML = rows.join(''); }

这里有两个参数值得说清楚。dict.byWord是个Map,切词时当查询表用;tokens里的每一项都带text和type,type是cmavo、brivla、cmevla、error之一,下游渲染靠它决定配色。renderHits里那个seen去重是有意为之——一段话里le可能出现七八次,全列出来右侧就没法看了。

链路里最容易被忽略的是normalize和tokenize的分工:归一化只管字符替换(大小写、h和'的等价、标点单独成词),切词只管划分边界和判定词类。两件事混在一个函数里,等你发现「标点处理错了」时,得连着切词逻辑一起改,非常难受。

3. 解析器拆解:从连续字母流切出 cmavo、gismu 和 cmevla

3.1 为什么不能只靠空格切词

Lojban 的构词设计本身就保证了词是自定界的:一个词的形态能告诉你它到哪儿结束。所以书写时经常省略空格,.i开头的句子、cmevla 两侧的句点、y作为音节分隔符,都是这条规则的产物。

这就带来一个麻烦:split(' ')在这种文本上完全失效。一整串miklamalezarcicu'icai拿过来,你没有现成的边界可用,只能靠形态规则回溯。我在项目早期图省事用了纯正则,结果coi ro do能过、.lojban.能过,一到la .lojban. cu xamgu .i ku'i mi na djuno就原形毕露——cu被当成 brivla 的一部分吃掉了,整句的词类全错。

正确的做法是词典优先 + 形态兜底:能整词命中就采信词典,命中不了再按形态规则猜一个类别。这个顺序不能反,因为形态规则只能给出大类,给不出具体词条;而词典能给出精确的词类和释义。反过来,如果先按形态切、再查词典,遇到 fu'ivla 这类不规则借用词就彻底没辙。

这里还有个细节容易被忽略:h和'在 Lojban 里是同一个音位的两种写法。真实文本里两种写法都有人用,甚至同一篇文章里混用。归一化阶段必须统一,否则mi'e和mihe在Map里是两个完全不同的 key,查询永远命中不了。我一开始就栽在这儿,写了一个下午的「元音开头的 cmavo 查不到」的 bug,最后发现是数据层的问题。

另一个容易漏掉的是句点。.在 Lojban 里既是 cmevla 的定界符,也是句号,还可能是.i这种句首分隔符的一部分。我的处理方式是先把.从词首词尾剥掉再查,剥完如果是空的就跳过。这样.lojban.、lojban、.lojban三种写法会归到同一个 key,用户不管怎么写都不影响查询结果。

3.2 切词器实现

lexer.js的前半段是字符层面的判断,都是小函数:

// lexer.js —— 字符分类与归一化 const VOWELS = new Set(['a', 'e', 'i', 'o', 'u', 'y']); // y 在 lojban 里是元音 const CONSONANTS = new Set([...'bcdfgjklmnprstvxz']); // 不含 ' 和 h,它们单独归一 export function normalize(text) { return text .toLowerCase() .replace(/h/g, "'") // h 与 ' 等价,统一成 ' .replace(/([.,!?;:])/g, ' $1 ') // 标点单独成 token,别粘在词上 .replace(/\s+/g, ' ') .trim(); } // gismu / lujvo / fu'ivla 的硬性特征:前几个字符里必然出现辅音对 function hasConsonantPair(s) { const head = s.slice(0, 6); for (let i = 0; i < head.length - 1; i++) { if (CONSONANTS.has(head[i]) && CONSONANTS.has(head[i + 1])) return true; } return false; }

hasConsonantPair里的slice(0, 6)不是随手写的。gismu 的两种标准形态是 CVCCV 和 CCVCV,长度都是 5,辅音对一定落在前 4 个字符内。取 6 是给 lujvo 的前缀组合留一点余量,同时避免把整词扫一遍——长文本里这个函数会被调用上千次,多扫几个字符就是几百次无谓比较。

normalize里那句标点处理用了捕获组包一层,目的是让.两边都有空格,这样后面split(' ')才切得干净。我试过直接replace(/\./g, ' . '),结果...这种连续句点会被拆成一堆空串,filter(Boolean)之后还得额外去重,反而更绕。

3.3 词形判定与词典兜底的优先级

真正的切词函数按四级优先级走,顺序就是注释里标的 1 到 4:

// lexer.js —— 按优先级判定,越靠前越可信 export function tokenize(text, dict) { const out = []; for (const raw of normalize(text).split(' ')) { if (!raw) continue; const word = raw.replace(/^\.+|\.+$/g, ''); // 剥掉 cmevla 的定界句点 if (!word || !/^[a-z']+$/.test(word)) continue; // 1) 词典整词命中,直接采信,这是唯一能给出精确词类的路径 if (dict.has(word)) { out.push({ text: word, type: dict.get(word).type }); continue; } // 2) 以辅音结尾 → cmevla(名字词),形态上不会错 if (CONSONANTS.has(word.at(-1))) { out.push({ text: word, type: 'cmevla' }); continue; } // 3) 前六个字符含辅音对 → brivla,gismu/lujvo/fu'ivla 都归这里 if (hasConsonantPair(word)) { out.push({ text: word, type: 'brivla' }); continue; } // 4) 兜底:剩下的按结构词处理 out.push({ text: word, type: 'cmavo' }); } return out; }

第 2 条规则(辅音结尾是 cmevla)是我最信任的一条,因为它在 Lojban 的构词规则里没有例外——brivla 和 cmavo 都以元音结尾,只有名字词以辅音结尾。这条规则的代价是:lojban这种既可能是 cmevla 又可能被当成普通字母串的写法,会被无条件判成 cmevla。我接受这个偏差,因为真实文本里它确实基本都是名字。

第 3 条规则是近似判断。严格来说,cmavo 内部不允许出现辅音对,所以这条规则在单句层面是可靠的;但y作为音节分隔符出现的场合(比如ky、lyn)会让判定抖动。我的处理是不做特殊分支,接受轻微误判——反正渲染时 brivla 和 cmavo 只是颜色不同,不影响查词。

对于完全没写空格的连续流,还需要一个回溯切分器:

// lexer.js —— 无空格连续流的贪心切分,取最长命中,失败退一格 export function segment(run, dict, maxLen = 12) { const spans = []; let i = 0; while (i < run.length) { let hit = null; const ceiling = Math.min(maxLen, run.length - i); for (let len = ceiling; len >= 1; len--) { const cand = run.slice(i, i + len); if (dict.has(cand)) { hit = cand; break; } } if (!hit) hit = run[i]; // 兜底:单字符前进,保证循环一定收敛 spans.push({ text: hit, type: dict.get(hit)?.type ?? 'unknown', start: i, }); i += hit.length; } return spans; }

maxLen = 12是我按实际词长统计定的:绝大多数 cmavo 不超过 6 个字符,lujvo 偶尔到 10 以上,12 已经能覆盖到绝大多数情况。这个值调大会让每个位置的回溯次数线性上升,一段 200 字符的文本就能明显卡顿;调小则会漏掉长 lujvo。你可以按自己的语料分布调,但别指望一套参数通吃。

segment的贪心策略在 Lojban 上并不完备——存在「短词优先反而正确」的场合。我的取舍是:先用segment出一版结果,渲染时把unknown类型的 token 标成灰色,让用户自己看出来哪一段没切对,而不是硬猜。

3.4 轻量语法层:括号、引号与位置回填

parser.js不做句法树,只做三件事:把 token 在原始字符串里的位置填回去、做括号配对、给未闭合的括号打标记。原因很实际——Lojban 的完整语法是一套 PEG,前端全量实现会让包体积翻几倍,而这个项目的定位只是辅助查词。

// parser.js —— 补位置 + 括号配对,产出给渲染层用的 span 数组 const PAIRS = { '(': ')', '[': ']', '{': '}' }; const CLOSERS = new Set(Object.values(PAIRS)); export function parse(tokens, source) { const spans = []; const stack = []; let cursor = 0; for (const tk of tokens) { // indexOf 从 cursor 起找,避免同一个词多次出现时位置错乱 const start = source.indexOf(tk.text, cursor); if (start < 0) continue; // 归一化后对不上,直接跳过 const end = start + tk.text.length; cursor = end; if (PAIRS[tk.text]) { stack.push({ ch: tk.text, at: spans.length }); } else if (CLOSERS.has(tk.text)) { const top = stack.pop(); if (!top || PAIRS[top.ch] !== tk.text) { spans.push({ kind: 'error', start, end, text: tk.text, msg: '括号不匹配' }); continue; } } spans.push({ kind: tk.type, start, end, text: tk.text }); } // 结算时栈里还剩的,全是没闭合的左括号 for (const left of stack) spans[left.at].unclosed = true; return spans; }

位置回填那段用source.indexOf(tk.text, cursor)而不是从 0 开始找,是关键。同一个词在一句里出现三次,从 0 开始找会让三个 token 的位置全落在第一处,渲染出来高亮就会重叠。这个 bug 我在测试mi klama .i mi klama的时候才撞上,表现是第二句整段没颜色。

unclosed标记是我加的一个小功能:左括号没闭合时不报错,只在渲染时给这个 token 加一条下划线。真实写作里括号未闭合太常见了,直接抛异常会让整个输入框罢工,体验很差。

4. 词典与搜索:dictionary-software 的数据形态和 search-interface 的实现

4.1 词典数据用 JSON 还是预渲染 HTML

最早我把词条直接写进index.html里,几百条还行,上千条之后文件体积直接上到几兆,而且每加一个词就得改 HTML。后来改成运行时fetchJSON,体积问题解决了,但首屏又慢——每次输入都重新解析几兆 JSON,明显卡。

现在的方案是两段式:构建期用tools/build-index.mjs把原始词表压成前端最小结构,运行期一次性fetch三个文件后常驻内存。三个文件分别是cmavo.json、gismu.json和index.json,前两个是词条本体,最后一个是英文倒排索引。Promise.all并发拉,总耗时约等于最慢那一个。

为什么不干脆用一个dictionary.json?因为正查和反查的更新频率不一样。词条本体基本是稳定的,倒排索引则要看释义文本是否需要重新切词——如果只是想改某个词的释义,改完重新跑一遍索引脚本就行,词条文件的 diff 很小,方便人工核对。合成一个文件之后,每次改一个词都会产生几千行 diff,没法看。

4.2 正查:按词形检索

dict.js的装载部分很直接,重点是byWord这个Map——它同时承担了切词时的查询表和正查时的索引:

// dict.js —— 三个文件并发装载,合并成一张查询表 export async function loadDict(base = './data') { const [cmavo, gismu, index] = await Promise.all([ fetch(`${base}/cmavo.json`).then(r => r.json()), fetch(`${base}/gismu.json`).then(r => r.json()), fetch(`${base}/index.json`).then(r => r.json()), ]); const byWord = new Map(); // cmavo 先放,gismu 后放:同名词形以词根为准 for (const [w, e] of Object.entries({ ...cmavo, ...gismu })) { byWord.set(w, { type: e.type ?? 'brivla', gloss: e.gloss ?? '', rafsi: e.rafsi }); } return { byWord, reverse: new Map(Object.entries(index)) }; }

合并顺序是有意为之:{ ...cmavo, ...gismu }让 gismu 覆盖同名 cmavo。原因是历史上出现过词形冲突,而词根的释义更完整,覆盖掉损失更小。

正查函数三级降级:精确命中优先,其次前缀匹配,最后不做模糊匹配(因为 Lojban 词形太规整,编辑距离匹配出来的结果噪音极大)。

// dict.js —— 正查:精确 > 前缀,上限 20 条防止长前缀拖垮渲染 export function searchWord(dict, q, limit = 20) { const key = q.toLowerCase().replace(/h/g, "'").trim(); if (!key) return []; if (dict.byWord.has(key)) { return [{ word: key, exact: true, ...dict.byWord.get(key) }]; } const out = []; for (const [word, entry] of dict.byWord) { if (word.startsWith(key)) { out.push({ word, exact: false, ...entry }); if (out.length >= limit) break; } } return out; // 可能为空,交给视图层显示「无匹配」 }

limit = 20不是随便定的。用户输入一个a,byWord里以a开头的词可能有几百个,全量渲染成列表会让右侧面板卡住半秒。截断之后再在列表底部显示「还有 N 条」,这个 N 需要额外跑一次完整计数——我的做法是干脆不显示,让用户多打几个字符。

前缀匹配用的是Map的线性遍历,词表规模在几千条时完全够用。如果你想扩到几万条,就得换成按首字母分桶的Map<string, string[]>,或者干脆把倒排索引做成 trie,这属于第 6 章的话题。

4.3 反查:从英文释义找词

反查是最实用的功能,也是我花时间最多的地方。用户想表达「走」这个概念,但不知道对应哪个词根,就得从英文释义反推。

难点在于释义文本是自然语言,直接includes匹配完全不靠谱:goes和go对不上,walking和walk也对不上。我的处理是在构建期做一次简单的词干化:

// tools/build-index.mjs —— 把释义切成倒排索引,键是词干 import { readFile, writeFile } from 'node:fs/promises'; const STOP = new Set(['x1', 'x2', 'x3', 'x4', 'x5', 'the', 'a', 'an', 'to', 'of', 'or', 'and']); const stem = w => w .replace(/ies$/, 'y') .replace(/(ches|shes|xes|ses)$/, m => m.slice(0, -2)) .replace(/(ing|ed)$/, '') .replace(/s$/, ''); const gismu = JSON.parse(await readFile('data/gismu.json', 'utf8')); const index = {}; let entryCount = 0; for (const [word, entry] of Object.entries(gismu)) { entryCount++; for (const raw of (entry.gloss ?? '').toLowerCase().split(/[^a-z]+/)) { if (raw.length < 2 || STOP.has(raw)) continue; const key = stem(raw); const bucket = (index[key] ??= []); if (!bucket.includes(word)) bucket.push(word); // 同一词根重复出现只记一次 } } await writeFile('data/index.json', JSON.stringify(index)); console.log(`词根 ${entryCount} 条,索引键 ${Object.keys(index).length} 个`);

stem里的顺序不能乱。先处理ies再处理s,否则stories会先被砍成storie再砍成stori;ches/shes/xes/ses那几个必须排在s前面,因为watches直接去s会得到watche。这类规则永远覆盖不全,我在脚本最后打了一行统计,跑完看一眼索引键数量,如果比词根数量还少,说明词干化过头了,得回去改正则。

反查的查询侧做的是多词 AND:

// dict.js —— 反查:多个英文词取交集,"go to" 会同时匹配到包含 go 和 to 的词根 export function searchGloss(dict, q, limit = 20) { const terms = q.toLowerCase().split(/\s+/).filter(Boolean).map(stemLite); if (!terms.length) return []; const sets = terms.map(t => new Set(dict.reverse.get(t) ?? [])); const [first, ...rest] = sets; const ids = [...first].filter(w => rest.every(s => s.has(w))); return ids.slice(0, limit).map(w => ({ word: w, ...dict.byWord.get(w) })); } // 查询侧只做最轻的处理,复杂规则全在构建期跑过了 function stemLite(w) { return w.replace(/ies$/, 'y').replace(/(ing|ed|s)$/, ''); }

构建期和查询期的词干化必须用同一套规则,否则索引里的键和查询时算出来的键永远对不上。我把stem的主体逻辑复制了一份到查询侧,只砍掉最耗时的几条分支。这是典型的「一致性 vs 性能」取舍:规则完全一致最安全,但前端每次按键都跑一遍全量正则不划算。

4.4 渲染与高亮:把 span 拼回 HTML

渲染层只有一条铁律:先转义,再插标记,顺序不能反。用replace做高亮是最容易出事的写法——如果先插<mark>再整体转义,你自己插的标签会被转义掉;如果先转义再对整串做replace,用户输入的<script>会被当成标签解析出去。

正确做法是单次拼接,从头到尾走一遍:

// render.js —— 逐段拼接:原文片段转义、token 包 span、两者交替 const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }; const escapeHtml = s => s.replace(/[&<>"']/g, c => ESC[c]); export function render(source, spans, el) { let html = ''; let cursor = 0; for (const sp of spans) { // 两个 token 之间的空白和标点,原样转义输出 html += escapeHtml(source.slice(cursor, sp.start)); const cls = sp.kind === 'error' ? 'tok tok-error' : `tok tok-${sp.kind}${sp.unclosed ? ' tok-unclosed' : ''}`; html += `<span class="${cls}"># 三个里挑一个,都行 python3 -m http.server 8000 npx serve . php -S 127.0.0.1:8000 # 第一次跑之前记得先构建索引,否则 data/index.json 不存在 node tools/build-index.mjs

启动后打开http://localhost:8000,按三步验证:

第一步,输入mi klama le zarci。预期结果是mi标成 cmavo、klama和zarci标成 brivla、le标成 cmavo,右侧列出四个词条释义。如果全部标成灰色或者全部标成同一种颜色,说明词典没加载完,去看 Network 面板里三个 JSON 是不是都 200。

第二步,输入coi ro do。这句里coi是问候、ro是「全部」、do是「你」,三个都应该是 cmavo。如果coi被标成 brivla,说明cmavo.json里缺这一条,走了形态兜底。

第三步,输入la .lojban. cu xamgu。lojban两侧的句点应该被剥掉,lojban标成 cmevla(因为它以辅音n结尾),cu和xamgu正常显示。如果句点还留在词上,检查tokenize里那句replace(/^\.+|\.+$/g, '')有没有被误删。

第四步,在搜索框里输walk,应该能反查出对应词根;输walk to,结果集会变小。如果两次结果一样,说明多词 AND 那段没生效。

5.2 排查清单:现象 → 原因 → 解决

下面五条是我自己撞过的,按出现频率排序。

现象一:整句全标成同一种颜色,右侧释义全是「未收录」。原因:main.js里loadDict是异步的,如果写在DOMContentLoaded回调里但又没await,input事件可能在词典装好之前就触发了,tokenize拿到一个空Map,所有词都走形态兜底。 解决:把loadDict放在模块顶层await(模块脚本支持),或者用Promise门闩——词典没回来之前禁用输入框,$src.disabled = true,装完再放开。我选前者,代码更短。

现象二:mi'e能查到,mihe查不到。原因:h和'的等价只在normalize里做了,但dict.js装载时没做同样的归一化,词典里的 key 是'版本,用户输入h版本,Map里对不上。 解决:归一化必须是双向的——查询侧归一化、词典装载侧也要归一化。我在loadDict里加了一行w.toLowerCase().replace(/h/g, "'"),问题消失。这条教训是:只要涉及字符等价,两头都得处理,只改一头等于没改。

现象三:反查go搜不到词根,但正查明明能查到那个词。原因:索引构建时stem('goes')得到goe(es$规则没覆盖到),索引里存的键是goe,查询时go算出来的还是go,两边对不上。 解决:把词干化规则收敛成一组,构建期和查询期用同一份代码。我的做法是把stem抽到一个共享的.mjs里,build-index.mjs和dict.js都import它,虽然打包时会重复一份逻辑,但一致性有保障。宁可多几 KB,也不要两个地方各写一套。

现象四:输入带尖括号的文本,页面直接崩掉或者弹窗。原因:渲染时先做了字符串replace高亮,再赋值innerHTML,用户输入的<img onerror=...>被当成真实标签解析执行了。 解决:改成 4.4 里那种逐段拼接的写法,每个要进 HTML 的字符串都过一遍escapeHtml。这条没有折中方案,任何「先拼标签再转义」的做法都是错的。

现象五:粘贴一段 500 字的长文本,输入框卡顿明显。原因:input事件是每次按键都触发,长文本下tokenize加parse加重新渲染全跑一遍,一秒钟能跑几十次。 解决:加一个简单的防抖,setTimeout200 毫秒,input时清掉上一个定时器。另外把renderHits里对byWord的重复查询结果缓存到一个局部Map里,同一段文本里重复词很多,能省掉一多半查询。

segment那个回溯函数是长文本卡顿的主要来源。如果只处理带空格的文本,可以在tokenize里判断一下split(' ')之后每段长度,超过 20 个字符才走segment,其余直接按整词处理,性能能提升一个数量级。

6. 二次开发:换词典、加语言、扩规则

我在这个项目上做过三次改造,都集中在三处,改之前先看清依赖方向就不会乱。

加一门语言的释义。这是成本最低的扩展。新建data/zh.json,key 和gismu.json完全一致,value 里多一个glossZh字段;loadDict的Promise.all数组里加一项;renderHits里把entry.gloss换成entry.glossZh ?? entry.gloss。整个过程不用碰lexer.js和parser.js,因为解析层只认词形和词类,不认释义语言。

加大词表。当词表从几千条涨到几万条,searchWord里的线性遍历会成为瓶颈。改法是按首字母分桶:装载时建一个Map<string, string[]>,a到z各一个数组,查询时只遍历对应桶。再进一步可以上前缀树,但对 Lojban 这种词形规整的语言,首字母分桶的收益已经足够——大部分词的第一个字母就能把候选集砍到几十分之一。

扩词形规则。想支持带y的音节分隔写法(比如ky、lyn),得改hasConsonantPair:y不参与辅音对判定,同时要在tokenize里加一条「只含辅音 + y 的短串按 cmavo 处理」。改完必须回归测试那几条老用例,特别是la .lojban. cu xamgu——y规则最容易误伤 cmevla 的边界判定。

验证方式我固定成两步。第一步,跑一组纯函数用例,直接node --test:

// test/lexer.test.mjs —— 只测纯函数,不起浏览器 import { test } from 'node:test'; import assert from 'node:assert/strict'; import { normalize, tokenize } from '../assets/js/lexer.js'; const dict = new Map([ ['mi', { type: 'cmavo' }], ['klama', { type: 'brivla' }], ['le', { type: 'cmavo' }], ]); test('h 与 \' 等价', () => { assert.equal(normalize('mihe'), "mi'e"); }); test('辅音结尾判为 cmevla', () => { const [tk] = tokenize('.lojban.', dict); assert.equal(tk.type, 'cmevla'); }); test('词典命中优先于形态兜底', () => { const [, tk] = tokenize('mi klama', dict); assert.equal(tk.type, 'brivla'); });

第二步,改完后在浏览器里把 5.1 那四步验证重跑一遍,特别关注高亮颜色和右侧释义是否一致。这两步听起来啰嗦,但词形规则的改动影响面很大,纯靠肉眼看颜色很容易漏。

最后一次踩坑是因为我图快,直接在生产数据上手改了一条词形规则,本地测试全绿,上线之后发现有一整类 lujvo 全被切错,原因是我本地测试用例里压根没覆盖到那类形态。从那以后我每次动lexer.js之前,都强制先把当前所有通过的输入输出存成快照,改完逐条比对差异,只要有一条变了就得说清楚为什么变。希望这个习惯能帮你少返工几次。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询