﻿---
title: PostBuild with ESBuild & Minify-HTML
date: 2025-12-19
tags:
  - FrontEnd
  - Performance
  - CICD
excerpt: "静态站点可在CI增加PostBuild环节，进行图片压缩、CSS/JS 压缩合并等流程,以优化网站性能表现。本文主要记录使用ESBuild & minify-html编写PostBuild脚本，实现以下目标："
updated: 2026-06-06 22:12:32
---

<script type="module" src="/js/components/tab.js"></script>

- 构建尽量**快**
- 压缩器能识别**现代**CSS/JS 语法
- 满足以上前提下，尽可能压缩产物体积
- 在CSS/JS文件名中添加Hash后缀，避免缓存版本混乱问题

> [!TIPS]- 静态网站部署的基本流程
>
> 对于SSG(Static Site Generation)[^1]用户来说，网站部署的一般流程是：
>
> **拉取源码⇒安装依赖⇒生成静态文件⇒上传部署**
>
> 以 Hexo 为例，在本地执行 `{shell} hexo generate` 会在 `public/` 目录下生成 `.html`、`.css`、`.js` 以及图片等静态资源文件。将这些文件直接上传到 EdgeOne Page、GitHub Pages 等托管服务，后面用户即可从全球边缘节点访问这些静态资源。
>
> 或者也可以使用它们的CI/CD服务，将上面的流程自动化。以EdgeOne（下文简称EO） Page为例，流程大致如下：
>
> - 关联源码仓库（GitHub / GitLab）
> - 检测到新提交后触发构建
> - 自动执行：
>   - 拉取代码
>   - 安装依赖（`{shell} bun install` / `{shell} npm install`）
>   - 生成静态文件（`{shell} hexo generate` / `{shell} hugo`）
> - 将输出目录（e.g. `public/`）部署到边缘节点

## HTML Minification

[wilsonzlin/minify-html](https://github.com/wilsonzlin/minify-html)，一个用Rust编写的高性能HTML压缩器。

其API非常直观易用, 参考以下步骤即可实现一个简易HTML压缩脚本：

- 使用 `{js} fg()` 扫描 `public/` 目录下的所有 HTML 文件
- 使用 `{js} fs.readFile()` 读取HTML文件内容，交由 `minify-html` 进行压缩
- 使用 `{js} fs.writeFile()` 将压缩后的内容写回原文件

```js build.js
import { minify } from "@minify-html/node"; // [!code highlight]
import fg from "fast-glob";
import fs from "fs/promises";

const ROOT = "public";

async function minifyHTML() {
  const files = await fg(`${ROOT}/**/*.html`);
  await Promise.all(
    files.map(async (file) => {
      const html = await fs.readFile(file, "utf8");
      const minified = minify(Buffer.from(html), {
        // [!code highlight]
        keep_comments: false, // [!code highlight]
        keep_spaces_between_attributes: false, // [!code highlight]
        minify_css: true, // [!code highlight] Minify CSS in `<style>` tags and `style` attributes using https://github.com/parcel-bundler/lightningcss
        minify_js: true, // [!code highlight] Minify JavaScript in `<script>` tags using minify-js
      }).toString(); // [!code highlight]

      await fs.writeFile(file, minified);
    }),
  );
  console.log("✓ HTML minified");
}

minifyHTML().catch(console.error);
```

minify可传入的参数，参考[Cfg in minify_html - Rust](https://docs.rs/minify-html/latest/minify_html/struct.Cfg.html)

> [!WARNING]- 空格敏感场景
>
> 对于 `{html} <pre>`、`{html} <textarea>` 等原生空格敏感标签，minify-html 会自动识别并保留其内部的空格，不会进行压缩移除；
>
> 使用 `mermaid.js` 渲染流程图时，其流程图的定义代码依赖空格和缩进实现语法解析，为此需要保证 `mermaid` 的定义代码使用 `{html} <pre>` 或 `{html} <code>` 等标签包裹。

## CSS & JS Minification

[ESBuild](https://esbuild.github.io/) 速度快，支持现代 CSS 语法，已经满足个人对压缩JS，CSS的需求。ESBuild提供了丰富的API，支持使用CLI/JavaScript/GoLang进行访问，下面先看一个简单的Minify Demo。

假设我们在当前目录下有一个 `algo/fibonacci.js` ，希望对其进行最小化，可以这样做👇

<x-tabs>

<x-tab title="例程" active>

```js build.js
import esbuild from "esbuild";

await esbuild.build({
  entryPoints: ["./algo/fibonacci.js"],
  minify: true, // [!code ++] 压缩文件体积
  outdir: "dist", // [!code ++] 压缩后的文件存放到"dist"目录，否则默认输出到stdout
});
```

</x-tab>

<x-tab title="原文件内容">

```js
function fibonacci(num) {
  let num1 = 0;
  let num2 = 1;
  let sum;
  if (num === 1) {
    return num1;
  } else if (num === 2) {
    return num2;
  } else {
    for (let i = 3; i <= num; i++) {
      sum = num1 + num2;
      num1 = num2;
      num2 = sum;
    }
    return num2;
  }
}
console.log("Fibonacci(5): " + fibonacci(5)); // Output: 3
console.log("Fibonacci(8): " + fibonacci(8)); // Output: 13
```

</x-tab>

<x-tab title="压缩后结果">

```shell
function fibonacci(o){let i=0,e=1,l;if(o===1)return i;if(o===2)return e;for(let n=3;n<=o;n++)l=i+e,i=e,e=l;return e}console.log("Fibonacci(5): "+fibonacci(5)),console.log("Fibonacci(8): "+fibonacci(8));
```

</x-tab>

</x-tabs>

### 拥抱现代 CSS🤗

写博客时，我们希望 CSS 易于维护，因此可能会用到 **Native CSS Nesting**[^2] 等现代语法。

```css
/* 现代 CSS 写法 */
.content {
  padding: 1rem;
  & > h1 {
    color: #333;
  }
}
```

[[Hexo_Perf_Optmize#Minify HTML/JS/CSS|之前]]个人使用 `hexo-all_minifier` 插件，其底层依赖 `clean-css`，该压缩器不支持解析嵌套规则，导致CSS样式丢失。但现在，`esbuild` 开箱即支持解析较新的 CSS 语法。

如果你配置了 `target`，则会自动将现代语法降级（Transpile）为旧浏览器可识别的 CSS。~~不过在我设备上没有问题，暂时不启用语法降级~~

```javascript
const result = await esbuild.build({
  entryPoints: [file],
  minify: true,
  // target: ['chrome88', 'safari14'],
});
```

## Hash Postfix

> [!question]- Why Add Hash Postfix?
>
> 关于添加Hash后缀的原因，站内已经有佬友开帖说明，详见 [【互联网老饕小知识】为什么文件要添加一个 hash 后缀？](https://linux.do/t/topic/1261014)
>
> 概括来讲，浏览器与 CDN 都大量依赖缓存机制。当你修改了 CSS 或 JS，但文件名不变时，用户很可能继续使用旧缓存，从而出现**样式错乱或逻辑异常**。
>
> 结合个人踩坑经历来讲，前段时间个人对博客页脚的布局结构进行了调整，并在CSS里添加`.footer-grid`的相应样式。
>
> ```html
> <html>
>   <head>
>     <link rel="stylesheet" href="/css/default.css" />
>     // [!code warning]
>   </head>
>   <body>
>     <footer>
>       <div class="footer-column xxx">yyy</div>
>       // [!code --]
>       <div class="footer-column xxx">yyy</div>
>       // [!code --]
>       <div class="footer-column xxx">yyy</div>
>       // [!code --]
>       <div class="footer-grid">
>         // [!code ++]
>         <div class="footer-column xxx">yyy</div>
>         // [!code ++]
>         <div class="footer-column xxx">yyy</div>
>         // [!code ++]
>         <div class="footer-column xxx">yyy</div>
>         // [!code ++]
>       </div>
>       // [!code ++]
>     </footer>
>   </body>
> </html>
> ```
>
> 在网页部署上线后，发现HTML中已经有了 `.footer-grid` 这一层级，但是请求的CSS文件仍然是旧版本，导致页面样式错乱。刷新CDN缓存/浏览器缓存也可以解决，但给资源文件名添加基于内容的Hash可以从根本上解决这个问题，确保用户访问 HTML，拿到的永远是相应版本的资源引用
>
> ```html
> <html>
>   <head>
>     <link rel="stylesheet" href="/css/default.d4c3b2a1.css" />
>     // [!code --]
>     <link rel="stylesheet" href="/css/default.a1b2c3d4.css" />
>     // [!code ++]
>   </head>
> </html>
> ```

### How?

#### Step 1: 为输出文件添加 Hash 后缀

ESBuild可以使用 `entryNames` [^3]选项来指定输出的文件名格式，其中 `[hash]` 占位符表示基于文件内容计算的哈希值。我们可以利用这一特性来实现为输出文件添加Hash后缀。

```js
import esbuild from "esbuild";

const ROOT = "public";

const result = await esbuild.build({
  entryPoints: ["./public/js/main.js"],
  minify: true,
  outbase: ROOT,
  outdir: ROOT,
  entryNames: "[dir]/[name].[hash]", // [!code ++] 为输出文件添加基于内容的 hash 后缀
  metafile: true, // [!code ++] 生成输入/输出映射
});
```

压缩后的文件会被写入到 `public/js/` 目录下，文件名形如 `main.O3LCB73S.js`，其中 `O3LCB73S` 是根据文件内容计算得出的哈希值。

为文件名添加 Hash 后缀后，HTML 中对这些文件的引用路径也需要相应更新。在`build`方法中将`metafile`参数设置为`true`，这样ESBuild会生成一个包含输入输出映射表👇

~~这里得感谢Gemini, 不然要翻老半天ESBuild文档~~

<x-tabs>

<x-tab title="Code" active>

```js build.js
// [!code word:result]
const manifest = {};
// 生成重写清单（从原始路径到哈希后的路径）
const outputs = result.metafile?.outputs || {};
for (const [outPath, meta] of Object.entries(outputs)) {
  const entry = meta.entryPoint;
  manifest[`/${path.relative(ROOT, entry)}`] =
    `/${path.relative(ROOT, outPath)}`;
}

console.log(manifest);
```

</x-tab>

<x-tab title="Output">

```shell
$ bun build.js
{
	"/js/main.js": "/js/main.O3LCB73S.js"
}
```

</x-tab>

</x-tabs>

#### Step 3: 替换 HTML 中的资源路径

调整下前文中的HTML压缩函数，结合Step2里生成的映射表，对 HTML 进行字符串替换就可以了；参数`rewriteMap`即为Step2中的`manifest`变量

```js build.js
async function minifyHTML(rewriteMap) {
  // [!code warning]
  const files = await fg(`${ROOT}/**/*.html`);

  await Promise.all(
    files.map(async (file) => {
      let html = await fs.readFile(file, "utf8");
      // 替换资源路径 // [!code ++]
      for (const [from, to] of Object.entries(rewriteMap)) {
        // [!code ++]
        html = html.replaceAll(from, to); // [!code ++]
      } // [!code ++]
      const minified = minify(Buffer.from(html), {
        keep_comments: false,
        keep_spaces_between_attributes: false,
        minify_css: true,
        minify_js: true,
      }).toString();
      await fs.writeFile(file, minified);
    }),
  );
  console.log("✓ HTML minified (fast)");
}
```

## TLDR

### 安装依赖

```shell
bun add -d esbuild fast-glob @minify-html/node
```

### Copy&Paste

```js build.js
import { minify } from "@minify-html/node";
import esbuild from "esbuild";
import fg from "fast-glob";
import fs from "fs/promises";
import path from "path";

const ROOT = "public";

async function getJSAndCSSFiles() {
  const js_files = await fg(`${ROOT}/js/**/*.js`, {
    ignore: ["**/*.min.js"],
  });

  const css_files = await fg(`${ROOT}/css/**/*.css`, {
    ignore: ["**/*.min.css"],
  });

  return [...js_files, ...css_files];
}

/* ---------------- JS & CSS ---------------- */
async function minifyAssets(files) {
  const manifest = {};

  const result = await esbuild.build({
    entryPoints: files,
    minify: true,
    bundle: false, // 纯静态
    outdir: ROOT, // 输出到 ROOT 目录下
    entryNames: "[dir]/[name].[hash]", // 使用内置哈希占位符
    metafile: true, // 生成输入/输出映射
  });

  // 生成重写清单（从原始路径到哈希后的路径）
  const outputs = result.metafile?.outputs || {};
  for (const [outPath, meta] of Object.entries(outputs)) {
    const entry = meta.entryPoint;
    manifest[`/${path.relative(ROOT, entry)}`] =
      `/${path.relative(ROOT, outPath)}`;
  }

  // 删除原始文件
  await Promise.all(files.map((f) => fs.rm(f)));
  console.log(`✓ Assets minified`);
  return manifest;
}

/* ---------------- HTML ---------------- */
async function minifyHTML(rewriteMap) {
  const files = await fg(`${ROOT}/**/*.html`);

  await Promise.all(
    files.map(async (file) => {
      let html = await fs.readFile(file, "utf8");

      // hash 路径替换
      for (const [from, to] of Object.entries(rewriteMap)) {
        html = html.replaceAll(from, to);
      }
      const minified = minify(Buffer.from(html), {
        keep_comments: false,
        keep_spaces_between_attributes: false,
        minify_css: true,
        minify_js: true,
      }).toString();

      await fs.writeFile(file, minified);
    }),
  );

  console.log("✓ HTML minified (fast)");
}
/* ---------------- build ---------------- */
async function build() {
  console.log("⚙️  Building...");

  const files = await getJSAndCSSFiles();

  if (files.length === 0) {
    console.log("No JS or CSS files found to minify.");
    await minifyHTML({});
    return;
  }

  const assetsMap = await minifyAssets(files);

  await minifyHTML(assetsMap);

  console.log("🎉 Done");
}

build().catch(console.error);
```

### CI Setup

以EdgeOne Page为例，可以将构建命令修改为 `{shell} hexo gen && bun ./build.js`。不过更推荐在 `package.json` 里配置 `scripts`，然后构建命令可以设置为 `{shell} bun run build`。

```json
{
  "name": "hexo-site",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "build": "hexo gen && bun build.js", // [!code ++]
    "rebuild": "hexo clean && hexo gen && bun build.js",
    "dev": "hexo server",
  },
  "hexo": {
    "version": "8.1.1"
  },
  "dependencies": {
    "hexo": "8.1.1",
    ...
  },
  "devDependencies": {  // [!code ++]
    "@minify-html/node": "^0.18.1",  // [!code ++]
    "esbuild": "^0.27.2",  // [!code ++]
    "fast-glob": "^3.3.3"  // [!code ++]
  }  // [!code ++]
}

```

## 总结

起初使用 `hexo-all_minifier` 插件进行静态资源压缩，但由于其底层压缩器对现代 CSS 语法支持不佳，导致样式丢失。

转而使用 `ESBuild` 进行 JS/CSS 压缩，不仅解决了现代语法支持问题，还显著提升了构建速度。

在体验过的几家Page Host服务中，EdgeOne Page当属第一慢，但现在通过上述优化，构建时间有了明显改善。平均构建时间由$100s$降低至$50s$

[^1]: Static Site Generation，静态网站生成器，例如 Hexo、Hugo、Jekyll 等，它们在构建时将 Markdown 等内容预渲染为静态 HTML 文件，用户访问时直接获取这些静态文件，加载速度通常更快。

[^2]: [Using CSS nesting - CSS | MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Nesting/Using)

[^3]: 参考 https://esbuild.github.io/api/#entry-names
