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

<script data-swup-reload-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