我如何将博客首页体积降至 3 KB
这并不是一篇网络上泛滥的“前端体积优化”文章。
百尺竿头,更进一步!本文以我的博客为例,介绍极限控制页面体积的奇技淫巧。
成果预览
眼见为实,本博客 首页 的网络传输总体积为 2.6 KB
。
需求精简
若是简陋的页面,体积再小,也不足为奇。我需要:
- 单页面(SPA)。
- 使用 Material Design 质感设计风格。
- 快速构建与加载。
没有代码是最好的代码。尽量削减需求,才能根本上减小体积。于是——
- 仅适配新版浏览器。
- 仅使用 Markdown 核心语法。
- 部分遵循 Material Design,舍弃复杂特性。
- 前端与生成器均不使用框架。
打包与压缩
资源打包早已是常识,但我希望走得更远一些,将所有资源(除页面本身外)合并至单个文件。这不仅能省去请求头体积,还可降低信息熵,使压缩后的总体积更小。
于是有 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。框架辅佐下开箱即用,但有诸多缺点:
- 响应内容是未转换的 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="/./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;
}
}
这里有很多可优化的位点。
@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("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);