
1. 项目缘起一个被忽视的“小”问题做技术文档尤其是开源项目的文档最头疼的事情之一是什么是版本管理是内容组织都不是至少对我来说最头疼的是代码示例的引用和目录的确定性生成。这两个问题看似不起眼却实实在在地影响着文档的维护效率和读者的阅读体验。我维护的 DeepWiki 项目是一个基于 Markdown 的静态文档生成工具链旨在为技术团队提供一套轻量、高效、可定制的文档解决方案。在早期版本中我们直接使用了市面上流行的静态站点生成器但很快就遇到了两个“顽疾”代码块行号引用失效当你在文档中写下一段代码并想在其他地方引用“请看第 23 行的getUser函数”时你会发现这几乎是个不可能完成的任务。因为 Markdown 渲染器生成的代码块其行号通常是纯 CSS 样式无法被锚点Anchor直接定位。读者要么靠肉眼数要么复制代码到编辑器里看体验极差。目录TOC的随机性大多数生成器的目录是基于标题自动生成的这本身没问题。问题在于当你的文档结构复杂包含多个层级时目录的生成顺序和锚点 ID 的命名如#user-content-xxx有时会因构建环境或依赖版本的不同而产生微妙差异。这种“不确定性”在团队协作和持续集成CI中是个噩梦可能导致内部链接失效或者每次构建的产物哈希值都不同影响缓存和部署。这两个问题一个关乎阅读的精确性一个关乎构建的稳定性。它们都不属于核心功能却像鞋里的沙子不断提醒你系统的不完善。所以我决定在 DeepWiki 的优化中专门花力气解决它们。这不是炫技而是实实在在的“工匠活”目标是让文档工具本身变得“可靠”和“顺手”。2. 核心需求拆解我们要的到底是什么在动手之前必须把模糊的“优化”变成清晰、可衡量的技术需求。否则很容易陷入盲目编码或过度设计的陷阱。2.1 代码行号锚点从“展示”到“可交互”传统的代码高亮行号只是一个视觉辅助。我们的目标是让每一行代码都拥有一个唯一且稳定的 URL 片段标识符即锚点。这意味着可链接我可以复制https://docs.example.com/guide#L23-L25这样的链接发给同事他点开页面浏览器就会自动滚动并高亮显示第 23 到 25 行代码。可交互读者可以点击行号或行号区域浏览器的地址栏会实时更新为对应的锚点方便他们分享当前正在查看的代码片段。对无障碍友好锚点的存在也为屏幕阅读器等辅助工具提供了更精确的导航可能。这不仅仅是加个id属性那么简单。我们需要一个方案能无缝集成到现有的 Markdown 解析和代码高亮流程中并且保证生成的id是确定性的不随构建次数变化。2.2 确定性目录生成构建稳定性的基石目录的确定性核心在于锚点id的生成算法。Markdown 标题# ## ###转换成 HTML 的h1h2h3时需要为其生成id属性通常是将标题文本进行“slugify”别名化处理比如 “How to Install” 变成 “how-to-install”。问题就出在这个“slugify”过程和目录的组装逻辑上算法一致性不同的库甚至同一库的不同版本的 slugify 规则可能有细微差别如对待中文、标点、大小写的处理。我们必须锁定并统一这个算法。唯一性保证当出现重复标题时如两个二级标题都叫“安装步骤”需要有去重策略如追加-1,-2且这个策略必须是确定性的。结构稳定性目录的 HTML 结构、CSS 类名、数据属性等在一次构建中应该完全一致不因文件读取顺序在某些异步环境下等非内容因素而改变。确定性目录的直接好处是构建产物可缓存。如果内容没变每次构建生成的 HTML 文件应该是二进制一致的。这对于利用 CDN 缓存、加速 CI/CD 流程、实现增量部署至关重要。3. 技术方案选型与设计思路明确了需求接下来就是技术选型和架构设计。这里没有银弹关键是权衡和适配自己的技术栈。3.1 代码行号锚点方案对比我们调研了三种主流实现方式客户端渲染方案在浏览器中用 JavaScript 动态地为代码块的每一行插入带id的span。优点是实现简单与构建工具无关。缺点是严重依赖客户端 JS不利于 SEO且页面加载后会有明显的动态效果体验不佳。服务端预处理方案在 Markdown 解析阶段在代码块被语法高亮器处理之前就为其每一行插入特殊的行号标记。然后高亮器会将这些标记一并处理成 HTML。这种方式对高亮器有侵入性需要找到支持或能适配此功能的高亮库。服务端后处理方案先让 Markdown 解析和高亮流程正常进行生成包含代码块和可能由高亮库生成的行号结构的 HTML。然后再用一个独立的处理阶段遍历 DOM为这些行号元素添加id属性。这种方式与高亮器解耦但需要精确的 DOM 选择器且处理逻辑相对靠后。我们的选择是服务端后处理方案。理由如下解耦DeepWiki 允许用户配置不同的代码高亮引擎如 Prism.js, Highlight.js。后处理方案不关心高亮器的内部实现只关心最终输出的 HTML 结构兼容性最好。确定性在构建阶段服务端完成生成的锚点是静态的利于 SEO 和链接分享。可控性我们可以完全控制id的生成规则如codeblock-{index}-L{lineNumber}确保其唯一和稳定。3.2 确定性目录生成的关键决策目录生成通常由 Markdown 解析器插件或静态站点生成器的主题来完成。为了追求确定性我们需要介入甚至重写这个过程。锚点生成算法锁定我们放弃了依赖第三方 slugify 库的默认行为。而是选择了一个经过广泛验证、算法稳定的库例如github-slugger并将其版本锁定。同时编写了严格的测试用例涵盖中英文、数字、连字符、重复标题等边界情况确保在任何环境下生成的 slug 都一致。生成时机前置不在模板渲染阶段动态生成目录而是在 Markdown 转换为 AST抽象语法树之后立即进行标题的锚点id计算和目录结构生成并将结果作为元数据注入到页面数据对象中。这样目录结构在后续的模板渲染过程中只是一个简单的数据引用消除了任何不确定性。数据结构序列化生成的目录不是一个 HTML 字符串而是一个结构化的 JSON 数据。模板引擎根据这个 JSON 数据渲染出 HTML。这保证了数据源的唯一性避免了在字符串拼接环节可能出现的顺序问题。4. 代码行号锚点实现细节与避坑指南理论说完了来看看具体怎么干。我们以 DeepWiki 使用的 Markdown 解析库markdown-it和代码高亮库prismjs为例。4.1 第一步配置高亮器生成带行号的 HTML首先要确保prismjs能生成包含行号结构的 HTML。prismjs有一个line-numbers插件它会在代码块外围包裹一个pre并在内部为每一行创建一个span classline-number。但默认情况下这些span没有id。我们的目标是生成类似这样的结构pre classlanguage-javascript line-numbers>// 伪代码示例 const cheerio require(cheerio); function addLineIdsToCodeBlocks(htmlContent, pageSlug) { const $ cheerio.load(htmlContent); let codeBlockIndex 0; // 用于区分同一页面内的多个代码块 $(pre.line-numbers).each(function() { const $pre $(this); const $code $pre.find(code); const language $code.attr(class)?.replace(language-, ) || text; const $lineNumbers $pre.find(.line-number); $lineNumbers.each(function(lineNum) { // 生成确定性ID。使用页面路径、代码块索引、行号确保全局唯一。 const lineId LC-${pageSlug}-${codeBlockIndex}-${lineNum 1}; $(this).attr(id, lineId); // 可选将行号元素变为可点击的链接 $(this).html(a href#${lineId}${lineNum 1}/a); }); codeBlockIndex; }); return $.html(); }关键点与避坑经验ID 生成策略LC-${pageSlug}-${codeBlockIndex}-${lineNumber}这个模式很关键。pageSlug是页面 URL 路径保证了跨页面不冲突。codeBlockIndex是页面内代码块的顺序索引保证了同一页面内多个代码块不冲突。lineNumber就是行号。这个组合是绝对确定性的。cheerio的使用在 Node.js 构建环境中cheerio提供了类似 jQuery 的 API是进行 HTML 后处理的利器。比正则表达式可靠得多。行号点击事件如果你想让点击行号时更新浏览器 URL上述代码中将其包裹在a标签内即可。但要注意这可能会干扰代码的复制操作。一个更友好的做法是仅对行号区域添加点击监听器通过event.preventDefault()和history.pushState()来更新 URL而不真正跳转。这需要额外的客户端 JS 配合。性能考量如果页面代码块非常多遍历所有.line-number元素可能会有性能开销。在实际应用中我们会对这个处理函数进行缓存和优化确保在增量构建时只处理有变动的文件。5. 确定性目录生成从算法到集成目录生成的确定性核心在于一个“纯函数”输入是标题文本和上下文输出是唯一的id。5.1 实现一个确定性的 Slug 生成器我们封装了一个createSlugger函数它确保每次调用都从零开始生成 slug并且处理重复。// 伪代码示例 const GithubSlugger require(github-slugger); function createDeterministicTOC(headings) { const slugger new GithubSlugger(); // 每次重新实例化重置内部状态 const toc []; for (const heading of headings) { const rawText heading.text; // 从 AST 中获取标题纯文本 const level heading.depth; // 标题级别如 2 对应 ## // 使用 slugger 生成 slug它会自动处理重复项 const slug slugger.slug(rawText); toc.push({ level, text: rawText, slug: slug, // 例如 installation id: slug, // HTML 中的 id 属性值 }); } // 将 slugger 的状态序列化或丢弃确保下次调用是全新的开始 return toc; }5.2 在构建流程中集成我们通常在 Markdown 文件的元数据处理阶段调用这个函数// 伪代码示例在自定义的 markdown-it 插件中 module.exports function(md, options) { const originalRender md.renderer.rules.heading_open || function(tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); }; md.renderer.rules.heading_open function(tokens, idx, options, env, self) { const token tokens[idx]; const headingToken tokens[idx 1]; // 通常是 inline 类型的标题内容token const headingText md.utils.unescapeAll(headingToken.content); // 从环境变量或全局状态中获取当前页面的 slugger 实例 const pageSlugger env.pageSlugger; const slug pageSlugger.slug(headingText); // 将 slug 注入到 token 的属性中 token.attrSet(id, slug); // 同时将标题信息收集到页面元数据中用于生成 TOC if (!env.pageHeadings) { env.pageHeadings []; } env.pageHeadings.push({ depth: parseInt(token.tag.slice(1)), // 从 h2 中提取 2 text: headingText, slug: slug }); // 调用原始渲染函数 return originalRender(tokens, idx, options, env, self); }; };然后在页面模板中我们可以直接使用env.pageHeadings这个有序数组来渲染目录它的顺序完全由文档中的标题出现顺序决定是绝对确定的。5.3 一个真实的“坑”异步文件处理在早期的实现中我们为了提升构建速度使用了异步并行处理多个 Markdown 文件。这时发现即使每个文件内部的slugger是独立的但目录的生成顺序反映在导航菜单中有时会不同。原因是文件读取完成的顺序是不确定的。解决方案将“文件读取与解析”和“目录生成与页面渲染”两个阶段分离。第一阶段并行解析所有文件收集原始 AST 数据。第二阶段按确定的顺序如按文件路径字母顺序同步地处理这些数据执行 slug 生成和 TOC 构建。这样就消除了并行性带来的不确定性。6. 效果验证与质量保障功能做完了怎么证明它有效且可靠单元测试为核心算法编写测试。例如给定相同的标题列表createDeterministicTOC函数是否总是返回相同的 TOC 数组对于包含中文、特殊字符、重复词的标题生成的id是否符合预期且稳定快照测试Snapshot Testing这是验证确定性的神器。我们使用 Jest 或 Vitest 的 snapshot 功能将关键页面渲染后的 HTML特别是包含代码块和目录的部分保存为“快照”。在后续的构建或测试中会自动对比新生成的 HTML 与快照是否完全一致。任何非预期的差异都会导致测试失败这能立刻捕捉到因依赖升级或逻辑改动导致的非确定性变化。集成测试模拟整个构建流程从源代码 Markdown 到最终输出的 HTML 和资源文件计算其哈希值如 MD5。在内容未变更的情况下多次构建的哈希值应该完全相同。这个测试可以集成到 CI 流水线中。手动验证当然最终还是要人工点击检查。确保代码行号可以点击URL 能正确更新和分享确保目录链接能准确跳转到对应标题位置。7. 总结与延伸思考经过这一轮优化DeepWiki 的文档输出质量有了肉眼可见的提升。代码引用变得精准文档链接可以放心地分享构建缓存命中率大幅提高团队协作时再也没人抱怨“我本地生成的链接怎么跟你不一样”了。回过头看这两个优化点都属于“非功能性需求”但它们共同指向了软件工程中一个非常重要的品质确定性。对于开发者工具和基础设施来说确定性往往比拥有更多炫酷的功能更重要。它意味着可靠、可依赖、可调试。这个实践也给我带来一些延伸思考工具链的透明化作为工具开发者我们应该尽量让这些“优化”对使用者透明。用户不需要知道我们用了cheerio还是github-slugger他们只需要得到一个“开箱即用”的可靠体验。这就要求我们在设计 API 和配置项时要提供合理的默认值并将复杂性封装在内部。性能与功能的平衡后处理 HTML 和同步化构建阶段确实会引入一些额外的计算开销。但在文档构建这个场景下构建频率远低于访问频率用一次性的、稍长的构建时间换取产物的绝对确定性和缓存友好性是非常划算的 trade-off。拥抱社区标准在实现行号锚点时我们曾考虑过自定义一套id格式。但后来发现像 GitHub、GitLab 这样的平台它们使用的就是#L23-L25这样的格式。最终我们选择了贴近这个事实标准虽然内部实现不同但对外表现一致降低了用户的理解成本。优化永无止境。下一步我们可能会考虑为代码块增加“复制”按钮、支持仅高亮某几行、甚至与代码仓库如 GitHub的特定提交进行关联。但无论如何确定性和用户体验这两个核心原则会一直指导着 DeepWiki 的演进方向。毕竟好的工具应该让人感觉不到它的存在却又处处提供着便利。