G12 小补丁:外链自动新标签页打开,内链原地走

G11 补齐了机器与迷路者的待遇,这一期回到正文内部。叶扬的教程里塞满了出处链接——GitHub 源码、官方 issue、Primer 文档——读者的典型动线是"读到一半点个参考链接,看完再回来"。可默认情况下,点链接是当前标签页直接跳走:看完参考资料想回来,得靠浏览器后退键重新找阅读位置,体验断裂。这一期用一个不到 50 行的小插件解决:站外链接新标签打开,站内链接保持原地跳转。需求出自官方 issue #186,作者 Meekdai 在帖里点评三件需求时说"1 可能难度大一点"——这一期就来看看难在哪。

先看现场

不用找别的页面,本文里的链接就是活 demo

一、为什么不能靠写 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../xx)以当前页地址为基地址补全,协议相对 URL 补协议,非法输入抛异常被 catch 成"不处理"。判据收敛成一句话:hostname 相同就是内链。叶扬给这个函数列了 15 条边界用例(含大写 HTTPS://、带中文锚点的内链等),全过才往下走。

三、新窗口的安全课:noopener

给外链加 target="_blank" 会引入一个老牌安全问题——反向标签劫持(reverse tabnabbing):新打开的页面能通过 window.opener 拿到原页面的引用,进而把原标签页重定向到钓鱼网址(用户回头一看"咦怎么让我重新登录")。防御是标准做法:

<a href="..." target="_blank" rel="noopener">

三个关联属性的取舍,叶扬查了资料后是这样定的:

四、两个"不添乱"原则

插件会长期跑在每一篇文章上,态度必须克制:

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>"

验证:三层

  1. 边界单测:15 条 URL 用例喂给判定函数,协议相对 URL 判外链、自家绝对 URL 判内链、mailto/锚点跳过,全过;
  2. 真实数据回归:把 #1、#5、#14、#17、#18 五篇文章构建产物里的 href 全抽出来去重(62 个),分类结果是 42 个外链、20 个内链——其中 github.com/yeyangchen2009/.../issues/5(GitHub 仓库域)正确判外链,https://yeyangchen2009.github.io 正确判内链;
  3. 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"且样式朴素,社区有人做了数字分页条插件,叶扬带大家把它接上并审一遍源码。

参考

✍️ 原创文章,转载请注明出处~叶扬谢谢你来过 ❤️