規則頁:樣式表跟著腳本一起來
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 的頁面給不出任何答案,而檢查把沉默當成了通過。第二個檢查問的是兩次閃爍的共同點:是否有任何內容比它的樣式更早出現在螢幕上。