如何对锚点自动生成并校验唯一性
解读
在国内前端工程化面试中,锚点(anchor)通常指 HTML 页面内用于定位的 id 属性,也可能是 Markdown 渲染后生成的 heading id。
Grunt 时代没有现成插件能“一键”完成“生成+校验”闭环,因此面试官想考察两点:
- 你能否用 Grunt 的“配置即代码”思想,把“生成规则”和“校验规则”都写成可重复的任务;
- 你能否保证在多人协作、增量构建、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']);中强制串行,保证每次构建都闭环。
答案
- 安装依赖
npm i -D grunt grunt-contrib-watch glob crc32 transliteration
- 在 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']);
};
- 在 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 小时即可生效。