规则页:样式表跟着脚本一起来
Mistboard 会预先把每个规则页和博客文章构建成完整的 HTML 文件,这样读者和搜索引擎不必等脚本就能拿到文字。网站用 Vite 构建,Vite 会把每段代码用到的 CSS 拆出来,放在那段代码旁边。文章版面跟着文章代码走,而页面要等启动之后才加载那段代码。
所以 HTML 到达时内容完整,却只链接了全站通用的样式表。浏览器先用这些样式表绘制文字,再去获取文章代码,然后才拿到排版文章的规则。规则页侧栏里的每次点击都是一次完整的页面加载,所以读者每翻一页都会看到闪烁。
现在构建会在它生成的每个页面里链接文章样式表,顺序与 Vite 加载它们的顺序相同。象棋规则页的样式表从四个变成了 24 个:
<!-- Before: only the site-wide stylesheets -->
<link rel="stylesheet" href="/assets/theme.css">
<link rel="stylesheet" href="/assets/index.css">
<link rel="stylesheet" href="/assets/pages-static.css">
<link rel="stylesheet" href="/assets/static-page-shell.css">
<!-- After: the same four, then what the article code brings -->
<link rel="stylesheet" href="/assets/articles-data.css">
<link rel="stylesheet" href="/assets/interactive.css">
<link rel="stylesheet" href="/assets/xiangqi-replay.css">
<link rel="stylesheet" href="/assets/move-list.css">
<!-- ...and 16 more -->之后会运行一个检查:对每个页面,它收集标记里的 class 名称,再在该页面没有链接的样式表里,寻找能匹配这些名称的规则。只要找到一条,构建就会失败,并指出是哪个页面、哪个样式表。
// Rules in stylesheets the page does not link that could style it.
// A rule counts only if the page uses every class it names; rules
// that name no class at all are skipped.
function unlinkedMatchingRules(pageHtml, stylesheets) {
const linked = linkedStylesheets(pageHtml);
const used = classNamesIn(pageHtml);
const misses = [];
for (const [href, rules] of stylesheets) {
if (linked.has(href)) continue;
const matching = rules.filter((rule) =>
rule.classes.every((c) => used.has(c)),
);
if (matching.length) misses.push({ stylesheet: href, matching });
}
return misses;
}188 个页面全部通过。侧栏点击也不再重新加载页面,而是就地替换文章。
棋手页:写给搜索引擎的副本
一天后,棋手页以同样的方式闪烁。棋手页是服务器在每次请求时生成的,所以构建永远看不到它们。为了不运行脚本的爬虫,服务器会在页面里放一份内容的纯文本副本:列出的每位棋手,各带队伍、积分和一个链接。脚本启动时会绘制真正的页面并移除这份副本。在那之前,屏幕上显示的就是这份副本,完全没有页面的版面。
构建检查在两个层面上都漏掉了它。它读取的是构建时写出的文件,而这些页面是之后才组装的。而当我们把同样的逻辑用在线上页面时,它照样放行,因为它是拿标记里的 class 名称去比对样式表规则,而那份副本没有任何 class 名称。在上面的代码里,used 返回为空,所以没有一条规则能通过 every 判断,页面也就报告没有缺少任何东西。机器人页和每个分享的研习都带有同类副本,也以同样的方式闪烁。
修复,以及一个能同时抓到两者的检查
副本仍然留在 HTML 里,因为不运行脚本的爬虫仍然需要它。现在服务器把它包在一个元素里,由网站的基础样式表保持隐藏。如果四秒后脚本还没有取代它,一段 CSS 动画会把它显示出来,所以脚本始终没有运行的读者仍然能看到文字。如果页面启动失败,脚本会把副本放回去并立即显示。
.crawler-copy {
animation: crawler-copy-reveal 0s 4s both;
}
#app[data-server-render="restored"] .crawler-copy {
animation: none;
}
@keyframes crawler-copy-reveal {
from { visibility: hidden; }
to { visibility: visible; }
}第二个检查读取线上网站:网站地图里每种页面各取两个,再加上网站地图漏掉的页面。它会标出缺少其标记所需样式表的页面,以及内容既没有 class 名称、也没有隐藏外层的页面。它的测试会喂给它旧版的棋手页,它必须把这一页标出来。在修复之前对网站运行时,它在三种语言的棋手、队伍、机器人和研习页面中标出了 14 个页面。
// True when the page's #app holds visible text with no classes
// at all, and the server did not wrap it in .crawler-copy.
function paintsBareCopy(html) {
const app = html.slice(html.indexOf('<div id="app"'));
if (/^<div id="app"[^>]*>\s*<div class="crawler-copy">/.test(app)) {
return false;
}
const inner = app.replace(/^<div id="app"[^>]*>/, '');
if (/\bclass="/.test(inner)) return false;
return visibleText(inner).length > 0;
}第一个检查漏掉了棋手页,因为它问的是这些页面无法回答的问题:页面里的 class 是否都有样式。一个没有 class 的页面给不出任何答案,而检查把沉默当成了通过。第二个检查问的是两次闪烁的共同点:是否有任何内容比它的样式更早出现在屏幕上。