使用 grunt-lunr 生成客户端全文搜索索引

解读

在国内前端工程化面试中,“如何离线生成 lunr 索引”常被用来考察候选人对构建阶段数据处理性能优化的理解。
题目表面是“配一个 Grunt 插件”,实则想听你如何:

  1. 非结构化 Markdown/JSON转成结构化文档数组
  2. 构建机(Node 端)一次性生成索引,避免浏览器端遍历大文档带来的首次解析卡顿
  3. 把索引文件gzip 后小于 100 KB,满足国内移动端2G/3G 弱网秒开要求
  4. Vue/React 单页路由结合,实现无后端搜索方案,节省SEO 服务器压力

如果仅回答“装 grunt-lunr → 配 Gruntfile”只能拿到 60 分;必须补充中文分词、同义词映射、增量更新、缓存策略才能拿到 90+。

知识点

  • grunt-lunr 插件职责:在 Node 环境运行 lunr.js,把文档数组序列化成 lunr.Index 的 JSON 快照,供浏览器直接 load
  • lunr 核心概念:document 结构、ref 唯一键、field 权重、pipeline(tokenizer + filter)、中文分词插件 lunr-languages/zh
  • Grunt 多任务流程:initConfig 中定义 lunr 任务 → grunt.registerTask('search', ['clean:searchIndex', 'lunr', 'compress:gzip'])
  • 性能红线:索引体积 ≤ 初始包 20 %,WebWorker 加载避免阻塞主线程,PWA CacheFirst 策略
  • 国内特殊场景阿里云效/腾讯云 CI 默认 Node 14,需锁死 lunr 2.3 版本避免 polyfill 差异微信内置 X5 内核不支持 TextEncoder,需引入 fast-text-encoding

答案

  1. 安装依赖

    npm i -D grunt-lunr lunr lunr-languages grunt-contrib-clean grunt-contrib-compress
    
  2. 准备文档源
    src/docs/ 下放置 Markdown,通过前置任务 grunt-markdown-to-json 生成 dist/search/docs.json,格式:

    [
      { "id": "api-quickstart", "title": "快速开始", "body": "本文介绍如何在 5 分钟接入..." },
      ...
    ]
    
  3. Gruntfile.js 配置

    module.exports = function(grunt) {
      grunt.initConfig({
        clean: { searchIndex: ['dist/search/index.json'] },
    
        lunr: {
          client: {
            src: 'dist/search/docs.json',
            dest: 'dist/search/index.json',
            options: {
              ref: 'id',
              fields: [
                { name: 'title', boost: 10 },
                { name: 'body', boost: 1 }
              ],
              pipelineFunctions: [
                // 中文分词 + 同义词
                function(token, idx, tokens) {
                  const lunrZh = require('lunr-languages/lunr.zh');
                  return lunrZh.tokenizer(token);
                },
                function(token) {
                  const synonym = { 'js': 'javascript', '小程序': 'weapp' };
                  return synonym[token] || token;
                }
              ]
            }
          }
        },
    
        compress: {
          gzip: {
            options: { mode: 'gzip' },
            expand: true,
            cwd: 'dist/search/',
            src: 'index.json',
            dest: 'dist/search/',
            ext: '.json.gz'
          }
        }
      });
    
      grunt.loadNpmTasks('grunt-contrib-clean');
      grunt.loadNpmTasks('grunt-lunr');
      grunt.loadNpmTasks('grunt-contrib-compress');
    
      grunt.registerTask('build:search', ['clean:searchIndex', 'lunr', 'compress:gzip']);
    };
    
  4. 浏览器端使用

    // 预加载 WebWorker
    const worker = new Worker('/search.worker.js');
    worker.postMessage({ cmd: 'load', url: '/dist/search/index.json.gz' });
    
  5. 验证

    • 执行 grunt build:search 后,index.json.gz 体积 82 KB(原始 310 KB)
    • Lighthouse TTI 降低 0.9 s,搜索响应 < 16 ms

拓展思考

  • 增量更新:在 CI 中对比 git diff --name-only,只对变更文档重跑 lunr,再把新索引 merge 到旧索引,避免全量 3 000 篇文档重编
  • 多语言隔离:为 en / zh / jp 分别生成 index.{lang}.json.gz,前端按 navigator.language 懒加载,减少首包 30 %
  • Node 端检索回退:对低端机(内存 < 1 GB)无法在 WebWorker 初始化,可降级为调用云函数,把关键词传给 Node 版 lunr,返回 10 条 id 列表,再回前端补数据
  • 可视化调试:在 grunt-lunr 后增加子任务 grunt-lunr-inspect,输出 token 频率热力图,帮助运营发现同义词缺失导致的零结果
  • 安全合规:国内备案要求搜索关键词不落日志,因此索引文件必须纯静态,禁止回传用户 query 到自建日志系统,避免通管局审查风险