如何对锚点自动生成并校验唯一性

解读

在国内前端工程化面试中,锚点(anchor)通常指 HTML 页面内用于定位的 id 属性,也可能是 Markdown 渲染后生成的 heading id。
Grunt 时代没有现成插件能“一键”完成“生成+校验”闭环,因此面试官想考察两点:

  1. 你能否用 Grunt 的“配置即代码”思想,把“生成规则”和“校验规则”都写成可重复的任务;
  2. 你能否保证在多人协作、增量构建、CI 流水线三种典型中国场景下,锚点 id 仍然全局唯一稳定不变
    回答时必须体现“先拿数据、再算哈希、再落盘、再校验”四步,并给出冲突降级策略,否则会被追问“如果两个人同时提交同名标题怎么办”。

知识点

  • Grunt 任务分阶段:initConfig 注册配置、registerTask 编排流程、registerMultiTask 写原子逻辑。
  • 锚点 id 的国内规范:只能包含小写英文字母、数字、下划线、连字符,长度 ≤ 60,且不能以数字开头(兼容微信、钉钉内置浏览器)。
  • 唯一性维度:同一仓库、同一分支、同一语言包(zh-CN、en-US)下唯一;若做多语言站点,需把语言 key 拼进命名空间。
  • 增量更新策略:用 git diff 拿到本次改动的文件列表,只对新增或修改的 heading 重新生成 id,未改动文件保持 id 不变,避免 SEO 外链失效。
  • 冲突消解:先“slug + 层级序号”降级,再“slug + 短哈希(crc32 截 5 位)”兜底,仍冲突则抛 Error 阻断 CI,倒逼人工 Review。
  • 落盘与回写:把最终 id 写回 Markdown 的 ## 标题 {#auto-id} 语法,或写进 JSON 映射表供模板引擎读取,禁止直接覆写用户手工 id
  • 校验任务:使用 grunt-contrib-watch 监听文件,调用自定义 checkAnchor 任务,在 grunt.registerTask('default', ['anchor', 'checkAnchor']); 中强制串行,保证每次构建都闭环。

答案

  1. 安装依赖
npm i -D grunt grunt-contrib-watch glob crc32 transliteration
  1. 在 Gruntfile.js 中定义双任务
module.exports = function(grunt) {
  grunt.initConfig({
    // 1. 生成任务
    autoAnchor: {
      options: {
        // 中文转拼音,兼容国内习惯
        transliterate: true,
        // 命名空间,防止多语言冲突
        namespace: 'zh-CN',
        // 冲突降级规则
        conflictSuffix: '-{{index}}'
      },
      src: ['docs/**/*.md']
    },
    // 2. 校验任务
    checkAnchor: {
      src: ['docs/**/*.md']
    },
    // 3. 文件监听
    watch: {
      docs: {
        files: ['docs/**/*.md'],
        tasks: ['autoAnchor', 'checkAnchor']
      }
    }
  });

  // 原子逻辑:生成
  grunt.registerMultiTask('autoAnchor', function() {
    const path = require('path');
    const crc32 = require('crc32');
    const { transliterate } = require('transliteration');
    const options = this.options();
    const seen = new Set();   // 本次构建已用 id
    const mapFile = 'grunt-anchor-map.json';
    const existingMap = grunt.file.exists(mapFile) ? grunt.file.readJSON(mapFile) : {};

    this.filesSrc.forEach(filepath => {
      let cnt = grunt.file.read(filepath);
      const newMap = {};
      // 正则兼容中英文混合标题
      const r = /^#{1,6}\s+(.+?)(?:\s*\{#(.+?)\})?$/gm;
      let m;
      while ((m = r.exec(cnt)) !== null) {
        const rawTitle = m[1].trim();
        const manualId = m[2];
        // 若用户已手工指定 id,直接采用
        if (manualId) {
          newMap[rawTitle] = manualId;
          continue;
        }
        // 自动生成
        let slug = options.transliterate ? transliterate(rawTitle) : rawTitle;
        slug = slug.toLowerCase()
                   .replace(/[^a-z0-9\u4e00-\u9fa5_-]/g, '-')
                   .replace(/^-+|-+$/g, '')
                   .substring(0, 60);
        if (!slug || /^[0-9]/.test(slug)) slug = 'h-' + slug;
        let index = 0;
        let id = slug;
        while (seen.has(id) || Object.values(existingMap).includes(id)) {
          index++;
          id = slug + options.conflictSuffix.replace('{{index}}', index);
          if (index > 99) {
            id = slug + '-' + crc32(rawTitle + filepath).toString(16).slice(-5);
            if (seen.has(id)) grunt.fail.warn(`锚点冲突无法消解:${rawTitle} in ${filepath}`);
          }
        }
        seen.add(id);
        newMap[rawTitle] = id;
        // 回写 Markdown
        const replacement = m[0].replace(m[1], m[1] + ' {#'+id+'}');
        cnt = cnt.replace(m[0], replacement);
      }
      grunt.file.write(filepath, cnt);
      // 更新映射表
      Object.assign(existingMap, newMap);
    });
    grunt.file.write(mapFile, JSON.stringify(existingMap, null, 2));
    grunt.log.ok('autoAnchor 完成,共生成 ' + seen.size + ' 个锚点');
  });

  // 原子逻辑:校验
  grunt.registerMultiTask('checkAnchor', function() {
    const mapFile = 'grunt-anchor-map.json';
    if (!grunt.file.exists(mapFile)) grunt.fail.fatal('缺少映射表,请先运行 autoAnchor');
    const map = grunt.file.readJSON(mapFile);
    const reverse = {};
    const errors = [];
    Object.entries(map).forEach(([title, id]) => {
      if (reverse[id]) errors.push(`冲突id "${id}" 出现在标题 "${title}" 与 "${reverse[id]}"`);
      reverse[id] = title;
    });
    if (errors.length) grunt.fail.fatal(errors.join('\n'));
    grunt.log.ok('checkAnchor 通过,无重复 id');
  });

  // 注册组合
  grunt.registerTask('anchor', ['autoAnchor', 'checkAnchor']);
  grunt.registerTask('default', ['anchor']);
};
  1. 在 CI 脚本(如 GitHub Actions 或阿里云的云效)里加一行
npx grunt anchor --verbose

若校验失败,非零 exit code 会直接阻断合并,符合国内“质量红线”要求。

拓展思考

  • 如果项目改用 Vite / Webpack,可以把同一份逻辑抽成 unplugin 插件,但思路不变:先读 AST(markdown-it 或 remark),再算哈希,再落盘,再校验。
  • 对于多人同时修改同一篇文档的极端场景,可在 pre-commit 钩子内把映射表上传到Redis 分布式锁,10 秒过期,防止冲突;Grunt 侧通过 grunt-exec 调用 shell 脚本完成加锁与释放。
  • 若要做SEO 友好升级,可把旧 id 做 301 跳转映射,Grunt 任务再加一个 exportRedirect 子任务,生成 Nginx 重写配置,直接发给运维,国内百度蜘蛛 48 小时即可生效