我如何将博客首页体积降至 3 KB

这并不是一篇网络上泛滥的“前端体积优化”文章。

百尺竿头,更进一步!本文以我的博客为例,介绍极限控制页面体积的奇技淫巧。

成果预览

眼见为实,本博客 首页 的网络传输总体积为 2.6 KB

需求精简

若是简陋的页面,体积再小,也不足为奇。我需要:

没有代码是最好的代码。尽量削减需求,才能根本上减小体积。于是——

打包与压缩

资源打包早已是常识,但我希望走得更远一些,将所有资源(除页面本身外)合并至单个文件。这不仅能省去请求头体积,还可降低信息熵,使压缩后的总体积更小。

于是有 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。框架辅佐下开箱即用,但有诸多缺点:

还有一种方法是 以 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="/./hi">Hi</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;
  }
}

这里有很多可优化的位点。

应用上述技巧,实现如下:

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

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("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);