动态

详情 返回 返回

3 KB 的博客首頁,我是如何做到的? - 动态 详情

這並不是一篇網絡上氾濫的“前端體積優化”文章。

百尺竿頭,更進一步!本文以我的博客為例,介紹極限控制頁面體積的奇技淫巧。

成果預覽

001

眼見為實,本人博客首頁 的網絡傳輸總體積為 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-widthmin-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");

Add a new 评论

Some HTML is okay.