在移动浏览器上做一个 word-finder/anagram solver 工具型 Web 应用,看起来只是把算法搬到页面上,实际落地要处理的东西不少。word-finder 负责根据输入字母找出合法英语单词,anagram solver 则把 n 个字母重排成词典中真实存在的单词。这类工具在拼字游戏、玩家查词、英语学习等场景里很实用,核心价值不是单个页面,而是它背后那条从词库、索引、查询到移动端渲染的完整链路。
阅读这篇文章的人,可以是刚接触前端、想做一个有趣小工具的学生,也可以是游戏开发者,想在自己的网页里集成查词能力。文章会围绕一个纯前端原型展开,从算法选型、最小实现、移动端适配,到性能排查和上线前改造。完成后,你可以在手机浏览器里直接打开这个工具,也可以把其中的索引和匹配思路迁移到自己的项目里。
1. 先想清楚 word-finder 和 anagram solver 到底在解决什么问题
很多需求描述会把这两个概念混在一起,但它们的计算模型并不相同。搞清楚差异,才能决定用哪种数据结构,才不会在代码写完后发现性能不可救药。
1.1 单词查找是检索,anagram 求解是组合匹配
word-finder 的核心场景是“我有一些字母,想知道能拼出哪些单词”。它可以只使用输入字母的一部分,也可以使用通配符填空,例如输入a?e,期望得到ace、age、ale、ate这类三个字母的单词。这里的关键是子集约束和位置约束。
anagram solver 的核心场景是“我有固定一组字母,找出所有把这些字母全部重排后形成的单词”。例如输入listen,期望得到的是silent、enlist、tinsel、inlets,因为这几个单词和listen包含完全相同的字母集合,只是顺序不同。
两种需求之间的差异决定了实现方式:
| 需求类型 | 是否允许少用字母 | 是否关心顺序 | 典型输入 | 对应算法思路 |
|---|---|---|---|---|
| word-finder | 允许,候选词是输入字母的子集 | 有时关心,例如a?e固定中间位置 | a?e | 字母频次匹配 + 模式过滤 |
| anagram solver | 不允许,必须使用全部字母 | 不关心,只看字母集合是否相同 | listen | 字母排序作为 key 的分组查找 |
实际工具通常会把两者做成同一种交互:用户输入一个字符串,点击查询,系统同时返回“正好用完所有字母的结果”和“允许少用字母的结果”。为了让用户理解差异,界面最好分成两个区域展示,不要只混在一起输出。
1.2 移动浏览器的约束决定了数据结构和算法选型
移动端和桌面端有一个明显区别:CPU 和内存资源更紧张,而且输入过程更容易造成页面卡顿。如果每次按键都触发一次全词库扫描,在十万词量级下即使只做字符串比较,也可能让页面明显掉帧。
解决思路是在算法层面降低单次查询成本。anagram 求解最经典的技巧是预先计算 canonical key:把一个单词的字母排序后得到的字符串作为分组依据。例如listen排序后是eilnst,silent排序后同样是eilnst。查询时,只需要把用户输入也用相同规则排序,然后在 Map 中做一次查找,就能拿到所有 anagram。
这种方式对移动端非常友好,因为查询阶段没有递归、没有排列枚举、没有大量字符串比较,只是排序输入加哈希查找。真正耗时的 canonical key 构建,可以放到开发阶段用脚本完成,然后生成一份预构建的索引 JSON,让浏览器直接加载。
word-finder 场景里有时需要“允许少用部分字母”,此时 canonical key 一次查找就不够了。没有通配符时,可以用字母频次校验:统计输入中每个字母的剩余次数,再逐个检查候选词;有通配符时,需要先枚举通配符的可能取值,再对每个候选做校验。这个流程要放在受限范围内执行,否则很容易碰到组合爆炸。
1.3 用户对移动端工具的真实期望
移动端用户使用这类工具,通常是在拼字游戏中途快速查词,输入方式以手机键盘为主。因此交互预期很明确:
- 键盘弹出后不能遮挡输入框。
- 输入字母时首字母不要被自动改成大写。
- 结果列表要能快速滚动,不能一次渲染上万条 DOM 节点。
- 无结果时要明确提示,而不是空白一片。
- 通配符太多时要提示“查询量过大”,而不是让页面卡死。
这些都不是算法问题,却直接影响工具能否在日常场景里被使用。后面实现时会按这个顺序逐步处理。
2. 明确功能边界、数据来源和技术栈
动手写代码之前,先把输入输出约定清楚。这个步骤能避免后期不断调整数据结构。
2.1 功能清单与输入输出约定
对于一个最小可用的 word-finder/anagram solver,第一版功能建议收窄到以下几点:
| 功能模块 | 输入 | 输出 | 说明 |
|---|---|---|---|
| anagram 查找 | 字母串,例如listen | 与输入字母集合完全一致的词典单词 | 必须使用全部输入字母 |
| 子集匹配 | 字母串,例如eat | 从这些字母中挑选部分组成的单词 | 输入字母次数是上限 |
| 通配符匹配 | 字母串中的?,例如a?e | 每个 ? 匹配任意一个字母的结果 | 第一版限制通配符数量 |
| 结果过滤 | 最小长度、最大结果数 | 过滤后的结果列表 | 减少移动端渲染压力 |
输入约定建议统一为小写英文字母和?,其他字符在预处理阶段直接忽略。这样做可以避免大小写、空格、连字符带来的边界问题。输出默认按字母序排列,结果过多时只显示前 N 条,并提示用户筛选。
在界面设计上,可以做成一个输入框加一个查询按钮,下方展示“完整 anagram”和“子集匹配”两个分组。如果通配符出现在输入中,则只展示通配符匹配结果。
2.2 词库怎么选:授权和体积都要考虑
词库是这类工具的核心资源。公开可用的英语词表有很多,例如 ENABLE word list、dwyl/english-words 这类开源仓库,以及 SCOWL 系列词表。选择时要确认两点:
第一是授权是否允许你在自己的项目中使用,尤其是如果未来要做商业产品。第二是词表体积是否适合移动端加载。一个小型演示词表几千条就够,一份较大词表可能几万到十几万条,直接放在 HTML 里会让首屏变慢,通常需要单独 JSON 文件并启用 gzip。
开发阶段建议先准备一个小词表用于验证逻辑,避免一开始就陷入加载性能问题。词表也需要预处理:全部转为小写、去除空白行、去除包含非法字符的词条、按字母序排序并去重。后续在预构建脚本里会体现这个过程。
2.3 技术栈:纯前端起步,必要时候再加薄后端
对这种工具型页面,第一版建议做成纯静态站点:
- HTML 负责页面结构。
- CSS 负责移动端布局和触控样式。
- JavaScript 负责加载词库、建立索引、执行查询和渲染结果。
- 词库存成 JSON 文件,随页面一起静态托管。
不做后端的优势是部署简单,GitHub Pages、Vercel、Netlify 这类静态托管都可以直接使用。如果词库非常大,或者希望保护词库不被完整下载,再引入 Node/Express 这类后端 API。
前端框架可以用原生 JavaScript,也可以用 React 或 Preact。这里使用原生 JS 是为了让核心算法更清楚,不引入框架编译和依赖成本。
3. 用最小可运行版本实现一个纯前端 anagram solver
这一部分会搭建一个可以在手机浏览器上直接使用的原型。核心思路是:先加载词表,构建 canonical key 索引,再把“输入字符串”也转成 canonical key,查询后渲染结果。
3.1 项目结构和静态资源
建议创建一个独立目录,保持文件职责清晰:
wordfinder/ index.html style.css app.js dict/ words-demo.jsonindex.html负责页面结构,style.css负责移动端适配,app.js负责算法和交互,dict/words-demo.json放演示词库。演示词库可以先手工准备几十个常见单词,确认功能后再替换成完整词库。
3.2 index.html 的基础结构
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Word Finder / Anagram Solver</title> <link rel="stylesheet" href="style.css" /> </head> <body> <main class="container"> <h1>Word Finder / Anagram Solver</h1> <input id="letters" type="text" autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false" placeholder="输入字母,例如 listen" /> <div class="controls"> <label> 最小长度 <input type="number" id="minLength" value="2" min="1" /> </label> <label> 最多显示 <input type="number" id="maxResults" value="50" min="1" /> </label> </div> <button id="solveBtn">查找</button> <p id="status"></p> <section> <h2>完整 anagram</h2> <ul id="resultsExact"></ul> </section> <section> <h2>子集匹配</h2> <ul id="resultsSubset"></ul> </section> </main> <script src="app.js"></script> </body> </html>这里有两个容易踩坑的属性。autocapitalize="none"用来防止 iOS 键盘自动把s变成大写S;autocorrect="off"和spellcheck="false"用来减少浏览器自动纠错对字母输入的干扰。没有这些设置,移动端输入体验会明显变差。
3.3 核心构建逻辑:从词表到 canonical key 索引
app.js的第一部分负责词表加载和索引构建。为了演示,先使用一份内置演示单词数组;完整项目里换成fetch加载 JSON 即可。
(async function () { const input = document.getElementById('letters'); const minLengthInput = document.getElementById('minLength'); const maxResultsInput = document.getElementById('maxResults'); const solveBtn = document.getElementById('solveBtn'); const status = document.getElementById('status'); const exactList = document.getElementById('resultsExact'); const subsetList = document.getElementById('resultsSubset'); function normalize(word) { return word.toLowerCase().replace(/[^a-z]/g, ''); } function canonical(word) { return normalize(word).split('').sort().join(''); } function buildIndex(words) { const index = new Map(); for (const word of words) { const key = canonical(word); if (!index.has(key)) { index.set(key, []); } index.get(key).push(word); } return index; } const demoWords = [ 'listen', 'silent', 'enlist', 'tinsel', 'inlets', 'emit', 'mite', 'time', 'item', 'test', 'settle', 'letters', 'rate', 'tear', 'tare', 'east', 'seat', 'easy' ]; const index = buildIndex(demoWords); })();normalize的目的是把所有输入统一成小写字母串,这样Listen和listen会得到相同结果。canonical把单词内部字母排序,使相同字母集合得到相同 key。buildIndex的结果是一个 Map,key 是排序后的字母串,value 是对应的真实单词数组。
这个索引构建只需要执行一次。正式项目中应该在脚本启动时完成,而不是在每次点击查询时重新构建,否则词库一大就会卡死。
3.4 查询逻辑:一次 Map 查找解决完整 anagram
查询逻辑可以拆分成两个函数:一个处理“必须用完所有字母”的 anagram,另一个处理“允许少用字母”的子集匹配。
function solveAnagram(letters) { const normalized = normalize(letters); const key = canonical(normalized); return index.get(key) || []; } function canBuild(word, letters) { const available = new Map(); for (const ch of letters) { available.set(ch, (available.get(ch) || 0) + 1); } for (const ch of word) { const count = available.get(ch) || 0; if (count === 0) { return false; } available.set(ch, count - 1); } return true; } function solveSubset(letters) { const normalized = normalize(letters); const results = []; for (const [key, words] of index) { const word = words[0]; if (canBuild(word, normalized)) { results.push(...words); } } return results; }solveAnagram逻辑很直观:用户输入listen,normalize 后得到listen,canonical 后得到eilnst,然后从 Map 里取出silent、enlist等单词。整个过程是 O(n log n) 的排序加一次哈希查找。
solveSubset使用字母频次校验:遍历词库索引,对每一组同 key 单词,取第一个单词检查是否能由输入字母构成。例如输入emit,mite和item都能通过canBuild,但emit本身也能通过。canBuild会正确拒绝emits,因为输入里没有s。
这里的solveSubset全量扫描在词库小的时候可用,词库大以后需要优化,后面章节会说明如何处理。
3.5 渲染结果和移动端交互
查询完成后需要区分两个结果区:
function renderList(listEl, words, max) { listEl.innerHTML = ''; const fragment = document.createDocumentFragment(); for (const word of words) { if (fragment.childElementCount >= max) { break; } const li = document.createElement('li'); li.textContent = word; fragment.appendChild(li); } listEl.appendChild(fragment); } function run() { const letters = input.value; const min = parseInt(minLengthInput.value, 10) || 2; const max = parseInt(maxResultsInput.value, 10) || 50; if (!letters.trim()) { status.textContent = '请输入字母'; exactList.innerHTML = ''; subsetList.innerHTML = ''; return; } const exactMatches = solveAnagram(letters) .filter((word) => word.length >= min) .slice(0, max); const subsetMatches = solveSubset(letters) .filter((word) => word.length >= min) .sort((a, b) => a.length - b.length || a.localeCompare(b)) .slice(0, max); status.textContent = `完整 anagram ${exactMatches.length} 个,子集匹配 ${subsetMatches.length} 个`; renderList(exactList, exactMatches, max); renderList(subsetList, subsetMatches, max); } function debounce(fn, wait) { let timer = null; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), wait); }; } solveBtn.addEventListener('click', run); input.addEventListener('input', debounce(run, 200)); })();渲染时使用DocumentFragment可以减少 DOM 重排次数。输入事件使用 200ms 防抖,避免每次按键都执行全量solveSubset扫描。sort将子集结果按长度升序排列,用户更容易找到短词。
对应的style.css可以做最小化的移动端适配:
.container { max-width: 560px; margin: 0 auto; padding: 16px; } #letters { width: 100%; font-size: 20px; padding: 12px; border: 1px solid #ccc; border-radius: 8px; box-sizing: border-box; } .controls { display: flex; gap: 12px; flex-wrap: wrap; margin: 12px 0; } .controls label { display: flex; align-items: center; gap: 6px; } .controls input { width: 64px; font-size: 16px; } #solveBtn { width: 100%; padding: 12px; font-size: 18px; border: none; border-radius: 8px; background: #2563eb; color: #fff; } ul { list-style: none; padding: 0; } li { padding: 8px 12px; border-bottom: 1px solid #eee; font-size: 18px; min-height: 44px; }min-height: 44px是移动端触控区域推荐值,避免用户点按困难。按钮使用整行宽度,在手机上更容易点击。
4. 把输入参数、通配符和边界条件讲清楚
最小版本能跑通之后,要考虑真实使用场景下的参数边界。很多看起来合理的功能,在输入值不规律时会拖慢页面或者产生错误结果。
4.1 输入参数和默认值
| 参数 | 含义 | 默认值或推荐范围 | 取值影响 |
|---|---|---|---|
letters | 用户输入的字母和通配符 | 无 | 非法字符会被去除,空输入直接返回提示 |
minLength | 结果单词的最小长度 | 2 | 过滤太短的结果,减少噪音 |
maxResults | 每个结果区的最大渲染数量 | 50 | 太小用户看不到更多结果,太大渲染卡顿 |
maxWildcards | 允许出现的最多通配符数量 | 3 | 每增加一个通配符,枚举次数指数增长 |
caseSensitive | 是否区分大小写 | false | 英文词表建议统一小写,不区分大小写 |
这几个参数必须在入口处统一处理,不能放任用户传入异常值。
4.2 字母频次匹配和 canonical key 的分工
canonical key 解决的是“字母集合完全相同”的匹配问题。它天然保证listen不会匹配到silent之外的、字母集合不同的单词。
字母频次匹配解决的是“候选是输入的字母子集”的问题。它关注的是每个字母的剩余数量。两个函数不能互相替代:
canBuild('ab', 'aab')返回 true,因为输入有两个a,可以用一个a和一个b构成ab。canonical('ab')得到ab,canonical('aab')得到aab,两者在 Map 中不会命中。
因此,如果你把子集匹配错误地实现成“先算出输入的所有子集,再对每个子集做 Map 查找”,会遇到组合爆炸。比如输入 10 个字母,子集数量是 2 的 10 次方减 1,也就是 1023 个,还能接受;但如果输入 20 个字母,子集数量就超过一百万,移动端根本扛不住。正确的做法是遍历词库索引,对每个词条做字母频次校验,而不是枚举输入子集。
4.3 通配符展开必须限制数量
加入通配符后,canonical key 直接查找失效,因为?不能简单排序后当成普通字母处理。常见的处理方式是把通配符展开成 26 个字母:
function expandWildcards(pattern, maxWildcards = 3) { const normalized = pattern.toLowerCase().replace(/[^a-z?]/g, ''); const wildcardCount = (normalized.match(/\?/g) || []).length; if (wildcardCount > maxWildcards) { throw new Error(`最多支持 ${maxWildcards} 个通配符`); } let candidates = [normalized]; for (let i = 0; i < wildcardCount; i++) { const next = []; for (const candidate of candidates) { for (let code = 97; code <= 122; code++) { next.push(candidate.replace('?', String.fromCharCode(code))); } } candidates = next; } return candidates; }一个通配符会变成 26 种可能,两个通配符会变成 676 种,三个通配符是 17576 种。如果每个候选都要对完整词库跑一遍字母频次校验,性能会迅速恶化。所以在expandWildcards阶段限制数量是必要的。
在大词库场景下,更合理的做法是使用 Trie 树(前缀树)配合通配符搜索,按位置逐步匹配,而不是展开所有可能。但 Trie 的实现复杂度更高,第一版先保持通配符数量上限和结果条数上限。
4.4 去重、排序和空状态
通配符展开后可能存在重复结果,例如输入a?,展开后可能同时生成am和am两种路径。渲染前建议使用Set去重:
function uniqueWords(words) { return Array.from(new Set(words)); }排序规则也要统一。anagram 结果通常按字母序排列;子集结果可以优先按长度升序,让用户先看到短词。结果为空时,界面要显示“没有匹配的单词”,而不是空白,避免用户误以为功能失效。
5. 在移动端运行验证:从本地启动到真机调试
写完最小版本后,必须跑通一个“本地启动、浏览器验证、真机检查”的完整流程。移动端工具最怕的是开发时看着正常,真机上一操作就卡。
5.1 本地静态服务启动方式
由于app.js使用了相对路径加载资源,直接双击打开 HTML 文件在某些浏览器里会因为 CORS 限制而无法加载词库,所以不能依赖file://协议。建议启动一个本地静态服务:
cd wordfinder python3 -m http.server 8080然后桌面浏览器访问:
http://localhost:8080手机和电脑连接到同一个局域网后,查看电脑 IP,手机访问:
http://192.168.x.x:8080第一次真机访问时,需要确认手机上的防火墙、局域网权限以及浏览器对 HTTP 资源的限制。有些手机浏览器会阻止纯 HTTP 局域网请求,如果遇到,可以使用https://的测试环境,或者用开发工具的内置转发功能。
5.2 功能测试用例
使用一份有明确预期结果的演示词库,可以快速判断实现是否正确:
| 输入 | 预期完整 anagram | 预期子集匹配 |
|---|---|---|
listen | silent, enlist, tinsel, inlets | listen 及其子词 |
emit | mite, time, item | emit 及其子词 |
a?e | 无(因为包含通配符) | 根据词库返回三个字母单词 |
test | 无或 test 的变位词 | test, set, est 等 |
| 空字符串 | 无 | 提示请输入字母 |
abc!@# | 无 | !@#被删除后剩余 abc,按 abc 计算 |
要注意a?e在前面的最小版本中没有实现通配符,所以测试它之前需要先把通配符逻辑加进去。演示词库很小,结果数量有限,替换成大词库后结果会明显增多。
5.3 通过 DevTools 模拟移动端观察性能
在桌面 Chrome 中按 F12,进入设备模拟模式,可以模拟 iPhone 和 Android 设备。查看两个关键指标:
- 词库加载时间:Network 面板里
words-demo.json的耗时。 - 输入响应时间:输入一个较长字母串,看每次
input事件到结果渲染之间的延迟。
如果需要量化算法耗时,可以在run()函数里临时加入 Performance API:
performance.mark('solve-start'); const exactMatches = solveAnagram(letters); const subsetMatches = solveSubset(letters); performance.mark('solve-end'); performance.measure('solve', 'solve-start', 'solve-end'); console.log(performance.getEntriesByName('solve'));在正式代码中,这种调试逻辑应该放在if (window.__DEBUG__)之类的开关里,避免影响线上日志。
5.4 真机测试时的常见差异
真机环境和桌面模拟器并不完全一致。以下是移动端特有的几个观察点:
- iPhone Safari 在输入框聚焦时会触发虚拟键盘,页面可能被压缩或遮挡,可以用
visualViewport调整滚动位置。 - Android Chrome 对
autocapitalize的支持和 iOS 略有差异,建议测试时重点确认字母输入有没有被自动大写。 - 真机 CPU 性能远弱于桌面,
solveSubset全量扫描在大词库下可能达到几百毫秒,需要观察是否出现卡顿。 - 手机屏幕宽度小,结果列表的字体和间距要足够明显。
验证完成后,可以把demoWords换成几百条的小词表,再逐步增加到完整词表,观察性能曲线。
6. 常见问题排查:从现象倒推原因
这类工具型页面一旦出现异常,最容易出问题的不是 UI,而是词库加载、索引构建、字母归一化和通配符展开这几个环节。按下面的排查顺序能快速定位。
6.1 问题现象和处理方案
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 页面卡死或长时间无响应 | 每次输入都全量重建索引,或通配符展开数量过大 | 打开 Performance 面板看长任务;在solveSubset和expandWildcards中加日志 | 索引只在启动时构建;限制通配符数量;对输入做防抖 |
| 结果始终为空 | canonical key 不匹配,或词库中包含非法字符,或输入包含非 a-z 字符 | 打印normalize和canonical结果;检查词库是否小写 | 统一在 normalize 中去除非 a-z;词表预处理为小写 |
输入listen但缺少部分 anagram | 词库中没有这些变位词,或词库只包含部分单词 | 检查词库文件是否包含 silent、enlist | 替换更完整的授权词库 |
| 手机键盘首字母自动大写 | 缺少autocapitalize="none"属性 | 查看 input 标签属性 | 添加autocapitalize、autocorrect、spellcheck |
| 局域网手机访问不到页面 | 静态服务未启动或 IP 错误 | 电脑访问 localhost 测试;手机检查 IP | 使用python3 -m http.server,关闭防火墙或换 HTTPS |
| 通配符查询结果太多 | 通配符数量没有被限制 | 查看expandWildcards的输入输出 | 设置maxWildcards,默认不超过 3 |
| 列表太长导致滚动卡顿 | 渲染了过多 DOM 节点 | 观察结果列表 DOM 数量 | 限制maxResults,使用虚拟滚动或分页加载 |
6.2 排查顺序:先看输入,再看路径,最后看性能
遇到异常时,可以按以下顺序逐步排除:
- 确认输入字符串经过 normalize 后是什么。如果输入是
Listen?,normalize 应该得到listen?,但?还需要单独处理。 - 确认词库文件是否被正确加载。打开 Network 面板,看 JSON 请求状态码和响应大小。
- 确认 canonical key 是否符合预期。在控制台执行
canonical('listen'),再和词库中的silent比较。 - 确认查询逻辑是否区分了完整 anagram 和子集匹配。
listen的完整 anagram 不是子集匹配,两者代码路径不同。 - 确认是否有异常输入导致无限循环。例如通配符数量很多时,展开循环是否会结束。
- 确认性能瓶颈到底在索引构建、查询扫描,还是渲染阶段。Performance 面板的长任务记录会告诉你答案。
6.3 日志打点建议
调试阶段可以在三个关键位置打点:
console.log('normalized input:', normalized); console.log('canonical key:', key); console.log('exact matches:', exactMatches.length);这些日志要尽量简短,避免在移动端调试台上刷屏。完成定位后,把日志移除或放入调试开关。
6.4 最容易忽略的边界场景
大词库下buildIndex的 Map 会占用较多内存,如果页面长时间不刷新,内存不会自动释放。移动端 Safari 在内存压力下可能回收整个 WebView,用户可以改用一个普通搜索按钮而不是每按键都搜索,降低重建和渲染频率。
还有一个容易被忽略的点是结果排序。如果index.get(key)返回的数组没有排过序,用户看到的 anagram 顺序会不稳定。词表预处理时按字典序排序,或者查询后在渲染前统一调用sort。
7. 生产化改造:预构建索引、Service Worker 和 API 架构
最小原型能跑通以后,如果要发布给其他用户使用,还需要从性能、加载和可靠性几个方面做生产化改造。
7.1 用 Node 脚本预构建索引
运行时buildIndex在词库只有几百条时没有问题,但完整词库十万条时,重新排序生成 Map 可能需要数百毫秒,甚至造成首屏卡顿。更合理的方式是在发布前用 Node 脚本把索引构建成 JSON 文件,客户端直接加载。
下面是一个预构建脚本示例:
const fs = require('fs'); const source = fs.readFileSync('words.txt', 'utf8'); const words = source .split('\n') .map((word) => word.trim().toLowerCase()) .filter((word) => /^[a-z]+$/.test(word)); const index = new Map(); for (const word of words) { const key = word.split('').sort().join(''); if (!index.has(key)) { index.set(key, []); } index.get(key).push(word); } const output = Object.fromEntries(index); fs.writeFileSync('dict/index.json', JSON.stringify(output));注意:Map 在使用Object.fromEntries转换时,所有 key 都会变成字符串。canonical key 本身就是字符串,所以这个转换是安全的。但 JSON 文件体积可能比单词表更大,因为同一个 key 会重复存储多个单词。
缓解方案:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 直接输出完整 JSON | 客户端简单,一次 fetch 即可 | 体积偏大,首屏加载慢 | 词库较小或网络环境好 |
| 按字母长度分片 | 可按需加载,减少体积 | 客户端逻辑复杂 | 词库很大 |
| 使用二进制格式 | 体积更小,解析更快 | 需要额外处理 | 对性能要求非常高的场景 |
如果选择完整 JSON,生产环境启用 gzip 或 Brotli 压缩,体积通常可以缩小到原来的三分之一以下。
7.2 使用 Service Worker 缓存词库
词库是静态资源,而且变化频率低,非常适合通过 Service Worker 缓存。用户第一次访问后,后续访问可以直接从本地缓存读取词库,加快启动速度。
// sw.js self.addEventListener('install', (event) => { event.waitUntil( caches.open('wordfinder-v1').then((cache) => cache.addAll([ './', './index.html', './style.css', './app.js', './dict/index.json' ]) ) ); }); self.addEventListener('fetch', (event) => { event.respondWith( caches.match(event.request).then((cached) => cached || fetch(event.request)) ); });在index.html中注册 Service Worker:
if ('serviceWorker' in navigator) { navigator.serviceWorker.register('./sw.js'); }需要特别注意:Service Worker 在 HTTP 非 localhost 环境下不会生效,所以生产环境必须启用 HTTPS。另外,缓存策略要保证词库文件更新后能正确失效,推荐使用带版本号的文件名,例如index.v2.json,并在install事件中更新缓存版本。
7.3 后端 API 和限流
如果词库非常大,或者你不想把完整词库暴露给客户端,可以加一层薄后端 API。后端负责接收输入字母,返回结果。
以 Node/Express 为例:
const express = require('express'); const app = express(); function solve(letters) { // 这里使用预加载的 index,和浏览器端逻辑一致 // 返回完整 anagram 和子集匹配结果 } app.get('/api/anagram', (req, res) => { const letters = String(req.query.letters || '').slice(0, 32); const minLength = Math.min(parseInt(req.query.min, 10) || 2, 10); const maxResults = Math.min(parseInt(req.query.max, 10) || 50, 200); if (letters.length === 0) { return res.status(400).json({ error: 'letters is required' }); } const result = solve(letters, minLength, maxResults); res.json(result); });后端方案的核心价值是保护词库和统一控制查询成本,但也引入服务器成本和部署复杂度。对于个人工具或学习项目,纯前端方案更合适。选择的关键在于词库规模、是否需要离线可用、是否有防爬需求。
7.4 发布前检查清单
上线前建议按下面的清单逐项检查,尤其是移动端:
| 检查项 | 做法 |
|---|---|
| 词库授权 | 确认词库使用许可,记录来源和版本 |
| 词库预处理 | 小写、去重、去除非法字符、按字典序排序 |
| 索引预构建 | 使用 Node 脚本生成 JSON,不在客户端重建 |
| 输入限制 | 限制输入长度和通配符数量,避免异常请求 |
| 防抖 | 输入搜索使用 200ms 左右防抖 |
| 结果限制 | 限制渲染条数,避免 DOM 节点过多 |
| 移动端键盘 | 设置autocapitalize="none"、autocorrect="off" |
| 触控区域 | 按钮和列表项高度不低于 44px |
| HTTPS | 生产环境启用 HTTPS,Service Worker 才能工作 |
| 静态资源压缩 | 启用 gzip 或 Brotli |
| 错误提示 | 空输入、无结果、通配符超限时给出明确提示 |
| 性能监控 | 观察首屏加载时间、输入响应时间、长任务时长 |
这个清单同样可以用于代码审查。当一个改动影响查询逻辑时,对照清单确认是否破坏了某个检查项。
8. 从演示工具到可扩展项目的路线
整个项目的核心判断是:anagram 求解本质是一次 canonical key 查找,word-finder 则是字母频次匹配,移动端 Web 场景要求你把这些计算尽量前置到索引构建阶段,把查询和控制留给客户端。
下一步的扩展方向可以按需求选择:
- 如果需要在游戏里使用,可以加入 Scrabble 字母分数和最佳得分排序。
- 如果需要支持提前缀匹配,可以把 Map 索引换成 Trie 树,支持
a?e这类位置通配模式而不必展开所有通配符。 - 如果需要支持其他语言,需要调整 normalize 规则和词库来源。
- 如果需要离线使用,可以结合 Service Worker 和 PWA manifest,让用户把页面添加到主屏幕。
对新手来说,最有价值的练习不是把界面装饰得更漂亮,而是把词库从几十条换成几万条,然后观察性能变化,再针对卡顿点做预构建和查询优化。这个过程会同时涉及算法、前端渲染、移动端兼容和生产发布,比单纯背 API 要有效得多。