调用链路追踪法:从断点到根因

2 min read
Zekari
架构调试SOLID插件化

问题的表象

双链语法 [[wikilink]] 在 Callout 组件中显示为纯文本。

这是一个简单的功能失效。但表象背后,是一条完整的调用链路。

绘制调用链路

从 MDX 文件到浏览器渲染,经历了五个环节:

💡 Click the maximize icon to view in fullscreen

链路中有两个断点。

第一个:Remark 阶段缺失 wikilink 插件,[[slug]] 未被转换为链接 AST 节点。

第二个:即使有链接,Callout 的 prose 样式也会覆盖默认链接样式。

第一个是根因。第二个是次要影响。

定位根因

MDX 是插件化的解析系统。remark 插件处理 Markdown 语法,rehype 插件处理 HTML 输出。

[[wikilink]] 不是标准 Markdown 语法。没有插件处理它,它就是普通文本。

next-mdx-remote 不知道如何将 [[slug]] 转换为链接。它需要一个插件告诉它怎么做。

这和 写代码前先思考 的道理一样。在动手修复之前,要先理解系统的运作方式。盲目打补丁只会制造更多问题。

插件的缺失,导致整条链路在第一步就断了。其他环节再完美,也无法挽回。

修复的正确层次

断点在 Remark 阶段,修复就应该在 Remark 阶段。

创建一个 remark 插件,在 AST 层面将 [[slug]] 转换为链接节点。这符合 从意图到架构 的思路:在正确的层次做正确的事。

AST (Abstract Syntax Tree) 是编译器将源代码转换为树状结构的中间表示。每个节点代表一个语法元素。

Remark 插件的工作是遍历 AST,找到特定模式的节点,将它们转换为其他节点。

在这个案例中,我们找到包含 [[slug]] 的文本节点,将它替换为链接节点。这是 AST 层面的转换,不是字符串替换。

插件实现 (lib/remark-wikilinks.ts)

import { visit } from 'unist-util-visit';
import type { Root, Text, Link } from 'mdast';

export function remarkWikilinks() {
  return (tree: Root) => {
    visit(tree, 'text', (node: Text, index, parent) => {
      const wikiLinkRegex = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;

      if (!wikiLinkRegex.test(node.value)) return;

      const newNodes: (Text | Link)[] = [];
      let lastIndex = 0;

      node.value.replace(wikiLinkRegex, (match, slug, displayText, offset) => {
        // 添加匹配前的文本
        if (offset > lastIndex) {
          newNodes.push({
            type: 'text',
            value: node.value.slice(lastIndex, offset),
          });
        }

        // 添加链接节点
        newNodes.push({
          type: 'link',
          url: `/blog/${slug}`,
          children: [{ type: 'text', value: displayText || slug }],
        });

        lastIndex = offset + match.length;
        return match;
      });

      // 添加匹配后的文本
      if (lastIndex < node.value.length) {
        newNodes.push({
          type: 'text',
          value: node.value.slice(lastIndex),
        });
      }

      if (newNodes.length > 0 && parent && index !== undefined) {
        parent.children.splice(index, 1, ...newNodes);
      }
    });
  };
}

这个插件遵循单一职责原则。它只做一件事:将 [[slug]] 转换为链接 AST 节点。

支持两种语法:

  • [[slug]] → 显示文本为 slug
  • [[slug|display]] → 显示文本为 display

单一职责不是让每个函数只有一行代码。而是让每个模块只负责一个变化的原因。

这个插件的职责是"处理 wikilink 语法"。无论是支持 [[slug]] 还是 [[slug|display]],都属于同一个职责。

但如果你让这个插件同时处理 wikilink 和图片优化,那就违反了单一职责原则。因为这是两个独立的变化原因。

这个原则的本质是降低耦合。当需求变化时,你只需要修改一个地方。

集成到 MDX 配置

app/blog/[slug]/page.tsx 中添加插件到 remarkPlugins 数组:

<MDXRemote
  source={post.content}
  components={mdxComponents}
  options={{
    mdxOptions: {
      remarkPlugins: [remarkGfm, remarkWikilinks],
    },
  }}
/>

修复 Callout 样式

第二个断点:Callout 的 prose 样式覆盖链接样式。

components/blog/callout.tsx 的内容区域添加:

className="prose prose-sm dark:prose-invert max-w-none
  prose-a:text-primary
  prose-a:no-underline
  hover:prose-a:underline"

这确保链接在 Callout 内有正确的颜色和 hover 效果。

插件化架构的本质

插件不是为了炫技。插件是为了分离关注点。

MDX 核心只负责解析标准 Markdown。非标准语法的处理,交给插件。这符合开闭原则:对扩展开放,对修改封闭。

新增语法,只需添加插件。删除语法,只需移除插件。MDX 核心不需要改动。

插件化架构将系统分解为可替换的模块。每个模块只负责一个功能。这不是技术细节。这是设计哲学。

调用链路追踪的价值

双链无法显示,是表象。缺失插件,是根因。

调用链路追踪,是从表象到根因的方法。

很多开发者习惯在表层打补丁。看到 Callout 里的链接不显示,就在 Callout 组件里加一堆样式覆盖。看到双链不工作,就写一个字符串替换函数。

这些补丁确实能让功能暂时工作。但它们不解决根本问题。下次遇到类似问题,还是要打新补丁。

追踪调用链路的价值,是找到正确的修复层次。断点在哪里,修复就在哪里。不在表层堆砌逻辑,不在深层过度设计。

修复不是简单地让功能工作。修复是理解系统,在正确的层次做正确的事。

这是调试的本质。

如果你想了解更多关于架构设计和系统思考的内容: