PostBuild With ESBuild & Minify-HTML

静态站点可在CI增加PostBuild环节,进行图片压缩、CSS/JS 压缩合并等流程,以优化网站性能表现。本文主要记录使用ESBuild & minify-html编写PostBuild脚本,实现以下目标:

  • 构建尽量
  • 压缩器能识别现代CSS/JS 语法
  • 满足以上前提下,尽可能压缩产物体积
  • 在CSS/JS文件名中添加Hash后缀,避免缓存版本混乱问题
静态网站部署的基本流程

对于SSG(Static Site Generation)[1]用户来说,网站部署的一般流程是:

拉取源码⇒安装依赖⇒生成静态文件⇒上传部署

以 Hexo 为例,在本地执行 hexo generate 会在 public/ 目录下生成 .html.css.js 以及图片等静态资源文件。将这些文件直接上传到 EdgeOne Page、GitHub Pages 等托管服务,后面用户即可从全球边缘节点访问这些静态资源。

或者也可以使用它们的CI/CD服务,将上面的流程自动化。以EdgeOne(下文简称EO) Page为例,流程大致如下:

  • 关联源码仓库(GitHub / GitLab)

  • 检测到新提交后触发构建

  • 自动执行:

    • 拉取代码
    • 安装依赖(bun install / npm install
    • 生成静态文件(hexo generate / hugo
  • 将输出目录(e.g. public/)部署到边缘节点

HTML Minification

wilsonzlin/minify-html,一个用Rust编写的高性能HTML压缩器。

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

  • 使用 fg() 扫描 public/ 目录下的所有 HTML 文件
  • 使用 fs.readFile() 读取HTML文件内容,交由 minify-html 进行压缩
  • 使用 fs.writeFile() 将压缩后的内容写回原文件
JS
build.js
import { minify } from "@minify-html/node";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) => {      let html = await fs.readFile(file, "utf8");      const minified = minify(Buffer.from(html), {        keep_comments: false, // 移除注释        keep_spaces_between_attributes: false, // 移除属性间的空格        minify_css: true, // Minify CSS in `<style>` tags and `style` attributes using https://github.com/parcel-bundler/lightningcss        minify_js: true, // Minify JavaScript in `<script>` tags using minify-js      }).toString();      await fs.writeFile(file, minified);    }),  );  console.log(" HTML minified");}minifyHTML().catch(console.error);

CSS & JS Minification

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

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

JS
build.js
import esbuild from "esbuild";await esbuild.build({  entryPoints: ['./algo/fibonacci.js'],  minify: true, // 压缩文件体积  outdir: "dist", // 压缩后的文件存放到"dist"目录,否则默认输出到stdout})
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: 3console.log("Fibonacci(8): " + fibonacci(8)); // Output: 13
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));

拥抱现代 CSS🤗

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

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

以前个人使用 hexo-all_minifier 插件,其底层依赖 clean-css,该压缩器不支持解析嵌套规则,导致CSS样式丢失。但现在,esbuild 开箱即支持解析较新的 CSS 语法。

在脚本中:

JAVASCRIPT
const result = await esbuild.build({  entryPoints: [file],  minify: true,  // target: ['chrome88', 'safari14'], // 你甚至可以指定目标浏览器  // ...});

ESBuild 会自动识别这些现代语法。如果你配置了 target(目标浏览器版本),它甚至能自动将现代语法降级(Transpile)为旧浏览器可识别的 CSS。不过在我设备上没有问题,暂时不启用语法降级

Hash Postfix

Why Add Hash Postfix?

关于添加Hash后缀的原因,站内已经有佬友开帖说明,详见 【互联网老饕小知识】为什么文件要添加一个 hash 后缀?

概括来讲,浏览器与 CDN 都大量依赖缓存机制。当你修改了 CSS 或 JS,但文件名不变时,用户很可能继续使用旧缓存,从而出现样式错乱或逻辑异常

结合个人踩坑经历来讲,前段时间个人对博客页脚的布局结构进行了调整,并在CSS里添加.footer-grid的相应样式。

HTML
<html>    <head>        <link rel="stylesheet" href="/css/default.N54CKX4A.css" />    </head>    <body>        <footer>            <div class="footer-column xxx">yyy</div>            <div class="footer-column xxx">yyy</div>            <div class="footer-column xxx">yyy</div>            <div class="footer-grid">                <div class="footer-column xxx">yyy</div>                <div class="footer-column xxx">yyy</div>                <div class="footer-column xxx">yyy</div>            </div>        </footer>    </body></html>

在网页部署上线后,发现HTML中已经有了 .footer-grid 这一层级,但是请求的CSS文件仍然是旧版本,导致页面样式错乱。刷新CDN缓存/浏览器缓存也可以解决,但给资源文件名添加基于内容的Hash可以从根本上解决这个问题,确保用户访问 HTML,拿到的永远是相应版本的资源引用

HTML
<html>   <head>       <link rel="stylesheet" href="/css/default.d4c3b2a1.css" />       <link rel="stylesheet" href="/css/default.a1b2c3d4.css" />   </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.VHPHZPIQ.js"],  minify: true,  outbase: ROOT,  outdir: ROOT,  entryNames: "[dir]/[name].[hash]", // 为输出文件添加基于内容的 hash 后缀  metafile: true, // 生成输入/输出映射});

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

为文件名添加 Hash 后缀后,HTML 中对这些文件的引用路径也需要相应更新。前面将build中的metafile选项设置为true,这样ESBuild会生成一个包含输入输出映射关系的元文件,我们可以利用这个元文件来更新 HTML 中的资源路径。

JS
build.js
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);
SHELL
$ bun build.js{	"/js/main.VHPHZPIQ.js": "/js/main.O3LCB73S.js"}

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

在HTML压缩环节,利用上一步生成的映射表,对 HTML 内容中的资源路径进行替换。

JS
build.js
async function minifyHTML(rewriteMap) {  const files = await fg(`${ROOT}/**/*.html`);  await Promise.all(    files.map(async (file) => {      let html = await fs.readFile(file, "utf8");      // 替换资源路径      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)");}

TLDR

安装依赖

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

Copy&Paste

JS
build.js
import esbuild from "esbuild";import fg from "fast-glob";import fs from "fs/promises";import path from "path";import { minify } from "@minify-html/node";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

在你的CI/CD配置文件中,添加对上述脚本的调用。例如,在EdgeOne Page的构建命令中,可以将构建命令修改为 hexo gen && bun ./build.js。不过更推荐在 package.json 里配置 scripts,然后CI平台构建命令替换为 bun run build

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

总结

起初使用 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 ↩︎

  3. 参考 https://esbuild.github.io/api/#entry-names ↩︎

PostBuild With ESBuild & Minify-HTML

https://vluv.space/post-build/

Author

GnixAij

Posted on

2025-12-19

Updated on

2025-12-19

License under