G12 小补丁:外链自动新标签页打开,内链原地走
G11 补齐了机器与迷路者的待遇,这一期回到正文内部。叶扬的教程里塞满了出处链接——GitHub 源码、官方 issue、Primer 文档——读者的典型动线是"读到一半点个参考链接,看完再回来"。可默认情况下,点链接是当前标签页直接跳走:看完参考资料想回来,得靠浏览器后退键重新找阅读位置,体验断裂。这一期用一个不到 50 行的小插件解决:站外链接新标签打开,站内链接保持原地跳转。需求出自官方 issue #186,作者 Meekdai 在帖里点评三件需求时说"1 可能难度大一点"——这一期就来看看难在哪。
先看现场
不用找别的页面,本文里的链接就是活 demo:
- 点这个站外链接:MDN 关于 noopener 的说明,应该在新标签页打开,本文还在原地等你;
- 点这个站内链接:G11 运维两小件,在当前标签页直接跳转,浏览器后退即可回来;
- 两种链接肉眼长得一模一样,区别全靠插件在浏览器里实时判定。
一、为什么不能靠写 Markdown 解决
第一反应或许是"写链接时手动加",但 GitHub 风格 Markdown(gfm)的链接语法根本没有 target 这个选项,构建时正文 POST 给 GitHub 渲染接口(G07 讲过的管线),输出的站外链接就是干干净净的 <a href="https://...">,不带 target。叶扬 curl 过线上文章源码确认:整页唯一自带 target="_blank" 的链接是页头那个"Issue"按钮,属于框架模板,不在正文里。
GitHub 自己的渲染器为什么不默认给外链加新窗口?因为链接怎么开是站点的 UX 决策,不是 Markdown 的事。那就由插件在运行时统一补上,好处是规则全站一致、以后改策略只动一个文件。
二、难在哪:内外链判定的边界
issue 里作者说"难度大一点",难的不是加属性,是怎么判内外。最朴素的写法是 href.startsWith('http'),一列边界就露馅:
| href 写法 | 真实归属 | startsWith('http') 的判决 |
|---|---|---|
https://github.com/Meekdai/Gmeek |
外链 | ✅ 外链 |
/post/18.html |
内链 | ✅ 内链 |
//primer.style/foundations/color |
外链(协议相对 URL) | ❌ 误判成内链 |
https://yeyangchen2009.github.io/archive.html |
内链(自己站点的绝对 URL) | ❌ 误判成外链 |
../about.html |
内链 | ✅ 内链 |
#先看现场 |
页内锚点 | ✅ 内链(不该处理) |
mailto:vipbigdata@qq.com |
邮件链接 | ✅ 内链(不该处理) |
协议相对 URL(//host/path,继承当前页协议)和"自己站点的绝对 URL"是两个经典坑。正确做法是把解析工作交给浏览器的 URL 构造器,再只比 hostname:
function toUrl(href) {
if (!href) return null;
href = href.trim();
if (!href || href.charAt(0) === '#') return null;
if (/^(mailto|tel|javascript):/i.test(href)) return null;
try {
return new URL(href, location.href); // 第二个参数是解析相对 URL 的基地址
} catch (e) {
return null;
}
}
// u.hostname === location.hostname 即内链,否则外链new URL(href, location.href) 一把梭:绝对 URL 直接解析,各种相对路径(/post/x、../x、x)以当前页地址为基地址补全,协议相对 URL 补协议,非法输入抛异常被 catch 成"不处理"。判据收敛成一句话:hostname 相同就是内链。叶扬给这个函数列了 15 条边界用例(含大写 HTTPS://、带中文锚点的内链等),全过才往下走。
三、新窗口的安全课:noopener
给外链加 target="_blank" 会引入一个老牌安全问题——反向标签劫持(reverse tabnabbing):新打开的页面能通过 window.opener 拿到原页面的引用,进而把原标签页重定向到钓鱼网址(用户回头一看"咦怎么让我重新登录")。防御是标准做法:
<a href="..." target="_blank" rel="noopener">三个关联属性的取舍,叶扬查了资料后是这样定的:
noopener:加。 切断window.opener。现代浏览器(Chrome 88+、近年的 Safari/Firefox)对target="_blank"已隐含 noopener,但显式写出来既兼容旧浏览器,也让意图自文档化(MDN 也是这个建议);noreferrer:不加。 它会顺带让目标站点收不到 Referer 头,对方就看不到"这个访客从叶扬博客点过来"。本博客的外链大多是向开源社区和作者致敬,保留来路是礼貌;安全上有 noopener 已经足够;nofollow:不加。 这是 SEO 语义不是安全属性,意思是"别把本页权重传给它"。G04 查 issue #145 时了解过,对自己主动引用的权威来源(官方仓库、MDN)加 nofollow 没有任何收益,正常引用即可。
四、两个"不添乱"原则
插件会长期跑在每一篇文章上,态度必须克制:
1. 尊重作者显式声明。 如果哪个链接手写了 target="_self"(明确要求当前页打开),插件不覆盖:
if (!a.hasAttribute('target')) a.setAttribute('target', '_blank');2. rel 是合并不是覆盖。 假如未来某篇文章的链接自带 rel="nofollow"(比如 G04 番外引用了不太熟的站点),插件要把 noopener 追加进去而不是替换掉:
function mergeRel(a, token) {
var rels = (a.getAttribute('rel') || '').split(/[ \t]+/).filter(Boolean);
if (rels.indexOf(token) === -1) {
rels.push(token);
a.setAttribute('rel', rels.join(' '));
}
}作用域同样收窄:只处理 #postBody 里的链接。config 的 script 字段本来就只注入文章页和固定页(首页列表不加载它);G10 的归档页虽然也加载,但时间线里全是 /post/ 内链,判定后自然跳过,而且它是 fetch 后异步渲染的,同步扫描时链接还没出现也无所谓——插件不依赖自己没把握的执行时机,只处理当下 DOM 里确定存在的东西。
完整代码
新建 static/plugins/GmeekExternal.js:
/* GmeekExternal —— 正文外链自动在新标签页打开
* 作用域:#postBody 内的 <a>(config 的 script 字段只注入文章页/固定页)
* 外链判定:交给 URL 构造器按 host 比较——绝对/协议相对/各种相对路径
* 都能正确归类,自己站点的绝对 URL 不会误判
* 安全:target=_blank 时补 rel=noopener(合并已有值,不覆盖)
* 不做:不自动加 nofollow(SEO 语义)、不强制 noreferrer(保留来源统计)
*/
(function () {
var body = document.getElementById('postBody');
if (!body) return;
var links = body.querySelectorAll('a[href]');
Array.prototype.forEach.call(links, function (a) {
var u = toUrl(a.getAttribute('href'));
if (!u) return; // 锚点/mailto/tel/javascript/非法值
if (u.hostname === location.hostname) return; // 站内链接原地跳
// 尊重作者显式声明(比如手写 target="_self")
if (!a.hasAttribute('target')) a.setAttribute('target', '_blank');
mergeRel(a, 'noopener');
});
function toUrl(href) {
if (!href) return null;
href = href.trim();
if (!href || href.charAt(0) === '#') return null;
if (/^(mailto|tel|javascript):/i.test(href)) return null;
try {
return new URL(href, location.href);
} catch (e) {
return null;
}
}
function mergeRel(a, token) {
var rels = (a.getAttribute('rel') || '').split(/[ \t]+/).filter(Boolean);
if (rels.indexOf(token) === -1) {
rels.push(token);
a.setAttribute('rel', rels.join(' '));
}
}
})();config 的 script 末尾拼上第六个标签,老规矩 push 后手动全局重建:
"script": "…<script src='/plugins/GmeekArchive.js'></script><script src='/plugins/GmeekExternal.js'></script>"验证:三层
- 边界单测:15 条 URL 用例喂给判定函数,协议相对 URL 判外链、自家绝对 URL 判内链、mailto/锚点跳过,全过;
- 真实数据回归:把 #1、#5、#14、#17、#18 五篇文章构建产物里的 href 全抽出来去重(62 个),分类结果是 42 个外链、20 个内链——其中
github.com/yeyangchen2009/.../issues/5(GitHub 仓库域)正确判外链,https://yeyangchen2009.github.io正确判内链; - DOM 桩:vm 沙箱里放六个假链接(普通外链、内链、显式
_self、已有rel="nofollow"、已有_blank、mailto)跑插件源码,断言 target/rel 的九种结果,并确认无正文容器时静默退出。
另外特意 curl 验证过:线上 HTML 源码里外链依然没有 target——属性是浏览器端运行时加的,构建产物保持干净。这对 SEO 没有任何影响:新窗口是纯浏览器行为,noopener 也不参与权重传递(会影响权重的是 nofollow,而我们没加)。
小结
issue #186 的头号需求,50 行内收口。真正的知识点只有两处:内外链用 URL 构造器按 hostname 判,不要自己拼字符串规则;新窗口必配 noopener,但别顺手塞 noreferrer/nofollow。再加上"不覆盖作者声明、rel 只合并不替换"两条克制原则,这个插件就能安静地长期服役。
下一期 G13 正好接住 #186 的第二个需求:G11 发布后文章数越过 15 篇,首页已经自动出现了框架原生的分页条——但它是英文"Previous/Next"且样式朴素,社区有人做了数字分页条插件,叶扬带大家把它接上并审一遍源码。
参考
- 需求出处:issue #186 提一些需求
- noopener 说明:MDN - a 元素 rel 属性
- 反向标签劫持原理:OWASP Reverse Tabnabbing
- 本站插件源码:GmeekExternal.js
- 相关前作:G04 SEO 与 sitemap、G11 robots 与 404
- 系列总揽:Gmeek 插件与功能全景调研(第 0 篇)