水族馆

为什么从 Hugo 迁移到 Gatsby

迁移之后博客构建构建时间长达10分钟,内存占用提升100倍

阅读时间 2 分钟


原本博客使用 Hugo 构建,1 秒内即可瞬间生成网站,Hugo 自身大小只有十几 M。之后使用 Gatsby 之后构建一次需要 10 分钟,需要消耗最高 2G 内存,依赖大小高达 700M。将 Hugo 换成 Gatsby 之后我觉得我还是做对了。

Hugo 做得很好

在最开始写博客的时候,我选择了 Hugo,它是非常棒的静态网站生成器。直到现在 Hugo 也是非常适合用来构建博客的方案,它分离了博客内容(Markdown)、构建代码和博客主题。换主题一般只要改几行代码,编者只需要专注于编写博客内容。

此外 Hugo 程序本身很小,只有十几 M,一次完整博客构建速度也在毫秒级别。构建出来的是静态 HTML 文件,只要把他们上传到类似 AWS S3 的存储,加上 CDN 就能有非常好的访问体验。相比于 WordPress 之类的动态网站,静态网站不需要拥有一台服务器支撑后端,也不用时刻担心数据库被入侵、安全更新之类的问题。

遇到的问题

累积布局偏移(CLS)

图文并茂的文章读起来通常更加轻松,而我喜欢在文字中间穿插许多图片。但图片多的文章会出现一个严重的问题:文字比图片先显示在屏幕上,随后图片出现,文字向下偏移。图片一多,文字偏移量甚至会超过一整个屏幕,用户阅读体验非常糟糕,谁能忍受文字看着看着就跑掉了呢。

Hugo 中解决这一问题的方法是:在主题模板中为图片设置单独的 CSS 样式,指定图片的宽度和高度,这样图片即使未加载也会占有一个位置,从而避免加载导致的布局移动。似乎大部分主题都没有这么做,你可能需要手动修改主题模板,图片位置多的话这个工作量可能比较大。

好消息是 Gatsby 能自动做到这点。

图片占位符

图片占位符 Placeholder 指的是在图片未加载出来前显示一个模糊的图片,或者是显示图片的主题色。模糊的图片通常非常小(大概 8x8 像素),并用 CSS Filter Blur 滤镜模糊处理。

实现原理就是在图片外层套一个 div 元素,并设置它的背景图片(通常是内联 base64 图片)

<div class="wrap-img" style="background-image: URL('data:image/webp...');">
  <img ... />
</div>

好消息是 Gatsby 能自动做到这点。

多种图片清晰度

<picture> 标签可以让浏览器自动根据屏幕分辨率、缩放大小、支持的格式自动选择合适分辨率的图片。实现起来类似

<picture>
  <source srcset="..." type="image/webp" />
  <source srcset="..." type="image/avif" />
  <img ... />
</picture>

手写这么长的图片候选列表肯定累死人

好消息是 Gatsby 能自动做到这点。

打包 JavaScript 资源

前端网站通常会将用到的 JavaScript 打包成单个文件以加快加载速度。虽然目前有前端方案提倡 unbundle your web,将符合 ES6 标准的 JavaScript 文件按照原本的结构单独发送到浏览器。他们声称这能更好利用 HTTP3 的特性并提高加载速度。这显然不符合国情,首先境外网络延迟超高,其次 HTTP3 依赖的 UDP 协议出海丢包严重。所以目前来看打包还是主流做法。

Hugo 如果要打包 JavaScript 资源,需要在模板里用 Pipeline 将引用到的 JavaScript 文件串联起来。规则是要手动编写的,一些主题的开发者就会忘了 bundle 或者 minify。

好消息是 Gatsby 能自动做到这点。

数学函数渲染离不开 JavaScript

数学函数在 Markdown 中使用两个美元符号标识,Hugo 将它原样输出到 HTML 中。浏览器依靠加载的 katex.js 将字符串渲染成数学公式。它看起来像这个样子

$$ y = \sum_{x=0}^{\pi} cos(x) $$

如果浏览器禁用了 JavaScript 那么用户就看不到数学公式了。此外,渲染数学公式也会导致 加载时布局偏移

可惜 Hugo 做不到构建博客时渲染数学公式,因为 Hugo 是 Golang 语言编写的,它无法理解和运行 JavaScript,JavaScript 对 Hugo 来说只是字符串模板中的组成部分。

好消息是 Gatsby 是 JavaScript 写的,理论上浏览器能能显示的它都能预先在服务端渲染出来,这样用户不依赖 JavaScript 即可看到数学公式。

一些杂碎的客户端渲染内容

这里的客户端渲染内容指的是 Client-side Rendering,相对于服务端渲染 Server-side Rendering,客户端依赖 JavaScript 根据条件动态渲染出显示内容。

比如本文顶部的 本文撰写于 (xxx 时间) UTC+? 就是动态渲染内容。如果您禁用了 JavaScript 会看到 UTC+0 的时间,这是因为构建博客的容器默认设置的时区是 UTC+0。当浏览器加载完 JavaScript 后根据您的系统时区,显示您的当地时间。

再比如本博客中一些老的文章,顶部会提示 该文章最后更新于 xx 天前,部分内容可能已经失效,请自行判断。也是同样的原理。

明明是静态网站,却能根据当前的日期和时间显示动态内容,这超炫酷的好吗。

浏览器先显示服务端预渲染的 HTML。等待 JavaScript 加载并执行后,由 React 接管网页的互动操作,这一步叫 React hydrate,优点包括

缺点包括

Gatsby 的缺点

说了这么多 Gatsby 解决的问题,是时候数落一下它的缺点了。

超大的 NodeJS 依赖

安装 gatsby 和相关 Markdown 的依赖后,node_modules 大小达到了恐怖的 700M。

国内情况使用 npm install 还不一定能装上,因为其中著名用于图片预处理的包 sharp 需要连接 github 下载预编译的二进制包,国内直连 githubusercontent 不是 timeout 就是 connection reset。

使用 npm config 设置镜像也没用,因为 sharp 从 github 拖二进制包这一步是写在安装脚本里的,不关 npm 事。

使用 cnpm 倒是有黑魔法 hook 掉从 github 下载这一步,但不知道有什么依赖出了问题导致博客在构建的时候会 core dump。毕竟这是黑魔法,我也不想知道为什么(

我的解决方案是使用 xz -9node_modules 压缩,并放到 docker 镜像中,Dockerfile 类似如下

FROM node:19

COPY ./node_modules.tar.xz /node_modules.tar.xz

使用时先进入容器,同时挂载当前目录。然后解压,开始构建

docker run -it --rm --net host -v $(pwd):/src aquarium39-moe-gatsby:1.1.0 bash
cd /src
rm -rf node_modules
tar -Jxvf /node_modules.tar.xz
npm run clean
npm run build

超大的内存消耗

Gatsby 一次构建需要十多分钟,吃掉 2G 左右的内存,如果是多核并行处理需要消耗更多内存。我使用 Drone 和 Gitea 实现推送代码到 git 仓库自动构建博客。构建任务第一次下发到我阿里云的 1C1G 的小机器时,直接撑爆内存,swap 吃满一半,跑了整整三个小时,随后被超时终止。

可惜这没有很有效的解决方案,Gatsby 官方有篇教程指导如何应对 OOM (Out of Memory) 导致的构建失败。但里面大多方案都是杯水车薪。

虽然算不上解决,但我的方案是利用 Drone 的 autoscaler 功能,有构建任务下发时自动在 AWS 创建一台 c6a.large 规格的机器(2 核 4G)。构建结束后将产物同步到 AWS S3 对象存储,最后使用 AWS CLI 工具调用 AWS CloudFront CDN invalidation 无效化 CDN 缓存。所有步骤结束后 autoscaler 自动销毁创建的机器。这样下来一次构建需要 3 毛钱左右。

kind: pipeline
type: docker
name: default

clone:
  depth: 1

when:
  branch:
    - master

steps:
  - name: build
    image: public.ecr.aws/heimoshuiyu/aquarium39-moe-gatsby:1.1.0
    commands:
      - tar -Jxf /node_modules.tar.xz
      - npm run build

  - name: sync
    image: plugins/s3-sync:1
    settings:
      access_key:
        from_secret: access_key
      secret_key:
        from_secret: secret_key
      region: ap-southeast-1
      bucket: aquarium39.moe
      source: public
      target: /

  - name: cdn-invalidation
    image: amazon/aws-cli
    environment:
      AWS_ACCESS_KEY_ID:
        from_secret: access_key
      AWS_SECRET_ACCESS_KEY:
        from_secret: secret_key
    commands:
      - aws cloudfront create-invalidation --distribution-id E3DKGHABSDPJUE --paths '/*'