這並不是一篇網絡上氾濫的“前端體積優化”文章。
百尺竿頭,更進一步!本文以我的博客為例,介紹極限控制頁面體積的奇技淫巧。
成果預覽
眼見為實,本人博客首頁 的網絡傳輸總體積為 2.6 KB。
- 本人的博客 Repo 在 kblog - GitHub,喜歡就給個
Star唄~
需求精簡
平淡無奇的頁面,體積再小,也不足為奇。我需要:
- 單頁面(SPA)。
- 使用 Material Design 質感設計風格。
- 快速構建與加載。
沒有代碼是最好的代碼。儘量削減需求,才能根本上減小體積。於是——
- 僅適配新版瀏覽器。
- 僅使用 Markdown 核心語法。
- 部分遵循 Material Design,捨棄複雜特性。
- 前端與生成器均不使用框架。
打包與壓縮
將 CSS、JS 等資源進行打包早已是常識,但我希望走得更遠一些,將所有資源(除頁面本身外)合併至單個文件。於是有 bundle.js:
let avatar = `/*{avatar}*/`;
document.head.insertAdjacentHTML("beforeend", `/*{head}*/`);
其中形似 /*{xxx}*/ 的標記,將被替換為需要嵌入的資源。而嵌入的內容中也可含有標記,不斷替換,直至所有資源嵌入完成。
例如,/*{head}*/ 將被替換為 head.html:
<link rel="icon" href="${avatar}" />
<style>
/*{style}*/
</style>
注意到,我在這裏將網頁圖標也嵌入了。但即便你不需要圖標,也應指定一個 <link ... href="data:"> 空白圖標,否則瀏覽器將自動向 /favicon.ico 發送多餘請求。
要嵌入圖像,我們通常會將其以 Base64 進行編碼。但我使用的是 SVG 圖標,為文本格式,因而將特殊字符使用 encodeURIComponent() 轉換後,就可直接直接寫作 data:image/svg+xml,<svg ... </svg>,從而避免 Base64 編碼所帶來的體積膨脹。
切記,引入 bundle.js 的 <script> 標籤不應有 defer 屬性,且必須在 <head> 中。這與大多數教程的推薦做法背道而馳,卻正是我想要的效果:在嵌入的 CSS 加載完成之前,不要渲染頁面。
由於請求數量少,再佐以 HTTP2 的服務端推送,阻塞渲染並不會明顯拖慢加載速度。
單頁面方案
通常,在靜態頁面實現 SPA,需分別生成靜態頁面和 JSON。框架輔佐下開箱即用,但有諸多缺點:
- 響應的 JSON 是未轉換的 Markdown,解析導致頁面卡頓(可改善)。
- 首次訪問加載時間較長(可使用 SSR 解決)。
- 體積大,構建慢(無解)。
還有一種方法是以 404 頁面為路由。易於實現(利用 GitHub API)但首屏加載緩慢,且極不利於 SEO。
而我的博客則選擇了另一條路——
得益於前文的資源打包,頁面中無效內容極少(只需引入 bundle.js 即可)。例如,某篇文章生成頁面如下:
<title>Hello - kkocdko's blog</title>
<script src="/bundle.js"></script>
<main>
<article>
<h1>Hello</h1>
<p>Hello world!</p>
</article>
</main>
實現頁內切換,首先要標記頁內鏈接。一般思路是使用 data-xxx 自定義屬性,但在這裏,我們約定:<a> 標籤 href 屬性以 /. 前綴,即為頁內鏈接,如 <a href="/./hello/">Hello</a>。眾所周知 . 代表當前目錄,因而此做法不會造成行為改變。
順便説一句:這種做法的好處,遠不止於摳出一些字節,更重要的是,這允許我們以原生 Markdown 語法在文章內寫出頁內鏈接 [關於](/./about/) 而不是突兀的 <a data-spa-link href="/./about/>關於</a>。
在鏈接被點擊後,直接 fetch 目標頁面,提取內容,更新到當前頁面上:
onpopstate = () =>
fetch(location) // location.toString() === location.href
.then((res) => res.text())
.then((text) => {
// 有些玄學的解構
[, document.title, , box.innerHTML] = text.split(/<\/?title>|<\/?main>/);
});
賦值給 onpopstate 是為了使得頁面在前進、後退時也能更新內容。
再實現一下監聽頁內鏈接(每次頁面更新後運行):
for (const element of document.querySelectorAll('a[href^="/."]'))
element.onclick = function (event) {
event.preventDefault(); // 避免直接跳轉
history.pushState(null, null, this.href); // 更新 URL
onpopstate(); // 因為 "pushState" 不會觸發 "popstate" 事件
};
至此,我們初步實現了單頁面支持。
簡潔的實現代碼
有很多技巧,能夠在實現等價功能的前提下,減少所需的代碼量,此處僅舉一例。當然,在生產項目中使用時需謹慎。
以本博客頁面中 <main> 的 CSS 為例。此元素是頁面主要內容的容器。需要實現的功能有:
- 在頂部、底部留白。
- 一代子元素(卡片)居中,圓角,投影效果,元素間留白。
- 寬度過低時(移動端)取消各處空白、陰影;子元素的間隙改為分隔線。
通常的實現如下,共 452 字符:
main {
display: grid;
grid-gap: 20px;
justify-content: center;
margin-top: 75px;
margin-bottom: 25px;
}
main > * {
width: 680px;
margin-top: 20px;
border-radius: 8px;
box-shadow: 0 1px 4px #aaaaaa;
}
@media screen and (max-width: 750px) {
main {
grid-gap: 0;
margin-top: 50px;
margin-bottom: 0;
}
main > * {
width: 100%;
border-bottom: 1px solid #aaa;
border-radius: unset;
box-shadow: none;
}
}
這裏有很多可優化的位點。
@media查詢中screen and是不必要的,匹配所有類型並沒有太大問題。- 有些屬性在
@media (max-width ...中被重置,可以改max-width為min-width,再將寬度過低 / 寬度正常的屬性調換,省去重置語句。 - Grid 和
justify-content是不必要的,我們可以對<main>固定寬度以約束子元素,再使用margin: auto居中。 - 上一條修改過後,
margin可以與頂部留白margin-top縮寫,原有的 4 行代碼,縮減為單行margin: 75px auto 25px。 - 子元素間隙用
margin-top實現。首個子元素的margin-top與容器的margin重疊,頂部空白保持正常。 - 使用
box-shadow向下偏移1px來替代border-bottom,減少幾個字節,同時省去@media塊中的重置語句。
應用上述技巧,實現如下:
main {
width: 100%;
min-height: 100vh;
margin: 50px 0 0;
}
main > * {
margin-top: 1px;
box-shadow: 0 1px #ddd;
}
@media (min-width: 750px) {
main {
width: 680px;
margin: 75px auto 25px;
}
main > * {
margin-top: 20px;
border-radius: 8px;
box-shadow: 0 1px 4px #aaa;
}
}
僅 309 字符,相較原來的 452 字符,減少了 32%,非常可觀。
收
看得開心麼~
這只是本人博客項目中所用技巧的一小部分。其他內容,限於篇幅,不再窮舉。若你想要深入瞭解,請見 kblog - GitHub。
附
- 測試用靜態服務器代碼(推薦使用 mkcert 管理證書):
const serve = require("http2").createSecureServer;
const read = require("fs").readFileSync;
const load = (p) => require("zlib").brotliCompressSync(read(p));
serve({ cert: read("cert.pem"), key: read("cert-key.pem") }, (_, res) => {
res.setHeader("content-type", "text/html;charset=utf8");
res.writeHead(200, { "content-encoding": "br" }).end(load("index.html"));
res.createPushResponse({ ":path": "/bundle.js" }, (_, r) => {
r.writeHead(200, { "content-encoding": "br" }).end(load("bundle.js"));
});
}).listen(4000, "127.0.0.1");