Skip to content

VitePress外部链接去掉noreferrer,踩了两层坑才搞定 ​

博客里引用外部链接的时候,VitePress默认会给所有外链自动加上 target="_blank" rel="noreferrer"。大部分情况这样挺好,但自己想要更精细地控制——大部分链接不需要这个属性,个别链接想手动保留。本来以为这是个一行配置就能解决的小事,结果来回折腾了两轮才算真正搞定,记一下整个排查过程。

第一次尝试:照着文档加配置,没生效 ​

VitePress的站点配置里有个 markdown.externalLinks 选项,文档写的是"给外部链接添加额外属性",第一反应是加上这段配置,把 target 显式写一下,rel 不写,以为不写就是不加:

ts
// docs/.vitepress/config.mts
export default defineConfig({
  markdown: {
    externalLinks: {
      target: '_blank'
    }
  }
})

改完重新 push、确认 Cloudflare 也构建成功了,结果打开页面检查元素,外部链接还是老样子:

html
<a href="https://www.sger.net/" target="_blank" rel="noreferrer">SGER 住宅IP独立审计</a>

rel="noreferrer" 纹丝不动。一开始怀疑是缓存问题,强刷浏览器、开无痕窗口都试了,确认不是缓存——配置改了,但是没起作用。

查源码才发现:这是"合并"不是"覆盖" ​

文档里没写清楚这个配置到底是替换默认值还是追加到默认值上,只能去翻VitePress的实际源码。把npm包下载下来解压,在编译后的代码里搜到了真正的逻辑:

js
.use(
  linkPlugin,
  { target: "_blank", rel: "noreferrer", ...options.externalLinks },
  base
)

问题就出在这一行。options.externalLinks 是用对象展开(...)合并进默认值的,不是替换掉默认值。我只写了 target: '_blank',rel 这个key我没碰,默认的 rel: "noreferrer" 自然原封不动地保留在合并结果里。换句话说,这行配置从一开始就没有机会生效——不写 rel,默认值就一直在。

第二次尝试:显式把rel设成空字符串 ​

既然是合并逻辑,那就不能"不写",得显式覆盖。把配置改成这样:

ts
markdown: {
  externalLinks: {
    target: '_blank',
    rel: ''
  }
}

这次重新构建之后,检查元素看到的是:

html
<a href="https://www.xxxx.net/" target="_blank" rel="">文字文字</a>

rel 的值确实变成空字符串了,从浏览器实际行为上讲,空的 rel 跟没有这个属性是等效的。但我想要的是属性本身完全消失,不想在源码里留一个空的 rel="" 看着碍眼,所以这还不是我想要的结果。

既然 externalLinks 这个配置项天生就带着强制合并的默认值,没法通过它本身让属性彻底消失,那就得从更底层的渲染环节入手。继续在源码里找,定位到了实际生成这个属性的函数:

js
const linkPlugin = (md, externalAttrs, base) => {
  md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
    const token = tokens[idx];
    // ...省略内部链接处理逻辑
    if (isExternal(url)) {
      Object.entries(externalAttrs).forEach(([key, val]) => {
        token.attrSet(key, val);
      });
    }
    // ...
    return self.renderToken(tokens, idx, options);
  };
  // ...
};

核心就是这个 Object.entries(externalAttrs).forEach 循环,只要 externalAttrs 对象里有 rel 这个key,不管值是什么,token.attrSet('rel', val) 都会把这个属性设置上去。这就是为什么单靠配置项没法让属性彻底消失的根本原因——只要走VitePress这套自动处理逻辑,rel 这个属性就一定会被写出来。

最终方案:用config钩子在渲染完之后二次处理 ​

VitePress的Markdown配置里还有个 config 钩子,可以拿到markdown-it的实例做自定义处理,而且执行时机是在VitePress自己的链接处理规则注册完之后。思路就是:先让VitePress按原来的逻辑处理一遍,生成的HTML字符串里必然会带一个 rel="",再把这一小段文字从结果里替换掉:

ts
// docs/.vitepress/config.mts
export default defineConfig({
  markdown: {
    externalLinks: {
      target: '_blank',
      rel: ''
    },
    config: (md) => {
      const defaultLinkOpen = md.renderer.rules.link_open!
      md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
        const html = defaultLinkOpen(tokens, idx, options, env, self)
        return html.replace(' rel=""', '')
      }
    }
  }
})

这段代码做的事情:

  1. 先把原来的 link_open 渲染规则(也就是VitePress自己注册的那个)保存一份引用。
  2. 用一个新函数覆盖掉这个规则,新函数内部先调用原来的逻辑,拿到它生成的完整HTML字符串。
  3. 对这个字符串做一次简单的文本替换,把 rel="" 这几个字符整个删掉,再把处理过的字符串返回。

重新构建之后,检查元素终于看到了想要的结果:

html
<a href="https://www.xxxx.net/" target="_blank">文字文字</a>

rel 属性彻底不见了,只保留 target="_blank"。

个别链接想保留rel="noreferrer"怎么办 ​

这个钩子只处理Markdown语法 [文字](网址) 转换出来的链接,如果在 .md 文件里直接写原生HTML标签,是原样输出的,不会被这段逻辑处理到。所以想单独给某条链接保留属性,直接手写HTML就行:

md
<a href="https://example.com" target="_blank" rel="noreferrer">某条需要保留的链接</a>

这样就是"默认全部去掉、个别手动保留"的效果,不用为了一条特殊链接去改全局逻辑。

小结 ​

这次踩坑的根本教训是:文档没写清楚的地方,光凭字面意思猜配置行为是靠不住的,得去翻源码确认实际逻辑。中间走的弯路是:

  1. 以为"不写某个key"等于"不设置这个属性"——实际上是对象合并,默认值会一直在,除非显式覆盖。
  2. 以为"把值设成空字符串"等于"属性消失"——实际上空字符串的值还是会被写进HTML里,只是功能上等效于没有。
  3. 真正要让属性彻底消失,得绕开配置项本身的合并机制,用 config 钩子在渲染完之后再做一次后处理。

下次遇到类似"配置了但没生效"的问题,这个排查顺序应该能更快定位:先确认不是缓存问题,再去翻源码确认这个配置到底是"替换"还是"合并",而不是在文档的字面意思上反复试错。