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()将压缩后的内容写回原文件
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 ,希望对其进行最小化,可以这样做👇
import esbuild from "esbuild";await esbuild.build({ entryPoints: ['./algo/fibonacci.js'], minify: true, // 压缩文件体积 outdir: "dist", // 压缩后的文件存放到"dist"目录,否则默认输出到stdout})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: 13function 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 写法 */.content { padding: 1rem; & > h1 { color: #333; }}
以前个人使用 hexo-all_minifier 插件,其底层依赖 clean-css,该压缩器不支持解析嵌套规则,导致CSS样式丢失。但现在,esbuild 开箱即支持解析较新的 CSS 语法。
在脚本中: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> <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> <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后缀。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 中的资源路径。
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);$ bun build.js{ "/js/main.VHPHZPIQ.js": "/js/main.O3LCB73S.js"}Step 3: 替换 HTML 中的资源路径
在HTML压缩环节,利用上一步生成的映射表,对 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"); // 替换资源路径 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
安装依赖
bun add -d esbuild fast-glob @minify-html/nodeCopy&Paste
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。{ "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当属第一慢,但现在通过上述优化,构建时间有了明显改善。平均构建时间由
Static Site Generation,静态网站生成器,例如 Hexo、Hugo、Jekyll 等,它们在构建时将 Markdown 等内容预渲染为静态 HTML 文件,用户访问时直接获取这些静态文件,加载速度通常更快。 ↩︎
PostBuild With ESBuild & Minify-HTML