Appearance
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="" 看着碍眼,所以这还不是我想要的结果。
继续翻源码,找到link_open这条渲染规则
既然 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=""', '')
}
}
}
})这段代码做的事情:
- 先把原来的
link_open渲染规则(也就是VitePress自己注册的那个)保存一份引用。 - 用一个新函数覆盖掉这个规则,新函数内部先调用原来的逻辑,拿到它生成的完整HTML字符串。
- 对这个字符串做一次简单的文本替换,把
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>这样就是"默认全部去掉、个别手动保留"的效果,不用为了一条特殊链接去改全局逻辑。
小结
这次踩坑的根本教训是:文档没写清楚的地方,光凭字面意思猜配置行为是靠不住的,得去翻源码确认实际逻辑。中间走的弯路是:
- 以为"不写某个key"等于"不设置这个属性"——实际上是对象合并,默认值会一直在,除非显式覆盖。
- 以为"把值设成空字符串"等于"属性消失"——实际上空字符串的值还是会被写进HTML里,只是功能上等效于没有。
- 真正要让属性彻底消失,得绕开配置项本身的合并机制,用
config钩子在渲染完之后再做一次后处理。
下次遇到类似"配置了但没生效"的问题,这个排查顺序应该能更快定位:先确认不是缓存问题,再去翻源码确认这个配置到底是"替换"还是"合并",而不是在文档的字面意思上反复试错。