头图

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

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

成果预览

001

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

需求精简

平淡无奇的页面,体积再小,也不足为奇。我需要:

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

  • 仅适配新版浏览器。
  • 仅使用 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");

kkocdko
112 声望0 粉丝

[链接]