自从把一个小型服务端渲染页面搬到 CloudFlare Workers 上之后我就一直在跟 Hono 打交道。框架本身的体验很舒服唯一让人心里硌得慌的是每次渲染完的 HTML 里全是缩进、换行、注释浏览器倒是无所谓但那些多余字节都要从边缘节点传到用户终端还占着 Workers 响应上限。后来我干脆写了一个针对 CloudFlare Workers Hono 环境优化的 HTML 压缩器专门在响应发出前把 HTML 里的冗余字符清掉。这篇文章就把这个压缩器从设计到落地、再到接入 Hono 中间件的过程完整记录下来也包括我实际踩过的几个坑。对想在边缘侧做 SSR 瘦身、又担心 CPU 配额被打爆的开发者应该能直接拿来当参考。1. 为什么要在边缘侧给 HTML 做一次瘦身1.1 语义压缩和传输压缩是两码事很多人一听到HTML 压缩第一反应是不是有 gzip 和 Brotli 吗。确实CDN 通常会自动对文本响应做传输压缩但那是把字节流里的重复模式用更短的编码替代属于传输层优化。浏览器收到之后第一件事是解压缩然后还需要把还原出来的 HTML 完整解析一遍。解压出来的文本如果本身带着大量无意义空白、注释、冗余属性浏览器解析器依然要处理这些字符哪怕它们不影响最终的渲染树。HTML 语义压缩做的则是另一件事直接从 HTML 源码里删除那些删掉之后 DOM 结构和页面渲染完全不变的字符。比如标签之间的缩进、整段整段的开发注释、没有必要的属性引号。两者之间的关系不是替代而是叠加。语义压缩先把 HTML 本身变小之后开启 Brotli 压缩时压缩算法面对的信息熵更低压缩效率还会更好。打个比方语义压缩是拆掉行李箱里所有没用的泡沫塑料传输压缩是把拆完之后的箱子再抽掉空气。只抽空气不动泡沫箱子依然很占地方只拆泡沫不抽空气包裹的体积也还有不少水分。两个步骤都做收益才最大。在 Workers 这种环境里HTML 语义压缩还有一层额外好处响应体本身占用的内存、传递到客户端的时间都会下降。尤其当页面走的是边缘渲染用户距离边缘节点越远每省下一个字节的收益就越直观。1.2 Workers 和 Hono 对压缩器提出了三道紧箍咒我一开始的思路很简单找个成熟的 Node 端 HTML 压缩器包一层中间件完事。结果发现这条路在 Workers 上走不通原因主要有三个。第一是 CPU 时间限制。Workers 的运行模型是按 CPU 时间计费并且有硬配额玩过的人都懂在 Worker 里做重计算是很奢侈的事情。大部分 Node 生态的压缩器会先把 HTML 解析成一棵完整的 DOM 树再序列化回去这个过程的 CPU 开销在边缘侧很容易触顶。第二是打包体积。Workers 有代码包体积限制而成熟的 HTML 压缩器往往带了一堆依赖。我把某个口碑不错的压缩器在本地调试时挺顺利可一旦做 esbuild 打包体积直接飙到上百 KB 甚至几 MB。这在本地服务器也许无所谓但在 Worker 里意味着冷启动更慢、部署包更大。第三是运行时兼容性。Workers 是 Web 标准运行时没有 Node 的 Buffer、fs、DOM而不少压缩器内部依赖 cheerio 这类需要模拟 DOM 环境的库直接点说就是跑不起来。Hono 这边也值得单独说一下。Hono 本身构建在 Web 标准 Request/Response 之上中间件签名非常干净接收一个上下文对象和一个 next最后返回 Response 或 Promise。这意味着压缩器只要能做到输入 HTML 字符串、输出压缩后的字符串就能非常自然地把压缩函数套进中间件里替换掉 c.res。关键难点不在中间件接口而在压缩函数本身的实现取舍。2. 从正则到状态机压缩器的核心设计2.1 为什么正则表达式不适合做 HTML 压缩最直观的方案是写一组正则表达式去掉注释、折叠空白、清理属性。我最初的原型就是这个思路跑在普通页面上看起来还行但稍微想深一点就会吓出一身冷汗。HTML 不是正则语言标签可以嵌套、属性值里可以有大于号和小于号、script 里可以出现任何字符串。举个典型的例子script const msg div; if (a b c d) {} /script如果外层有一个删除标签内空白的正则它可能在div这个字符串上发生误匹配更麻烦的是嵌套引号、转义字符你永远不知道下一个反引号出现在哪。正则表达的灾难回溯在极端输入下能把 CPU 时间直接打满而 Workers 对 CPU 时间极其敏感一个坏输入的代价可能既不是逻辑错误而是配额耗尽。所以我很快放弃了正则方案改用逐字符扫描的状态机。状态机的复杂度是 O(n)每个字符只处理一次没有回溯不存在某个特殊字符串让算法卡死的情况。这正是边缘运行时最需要的可预期性。2.2 状态机的结构逐个字符扫描按状态决策压缩器核心是一个手写状态机。我维护了两类东西一是当前状态二是输出缓冲区。遇到每个字符时根据当前状态决定是原样输出、丢弃还是切换状态。基本状态有Data普通文本决定空白如何折叠。TagName标签名标签名直接原样输出。AttrName属性名遍历属性名为后续属性值处理做标记。AttrValue属性值处理单引号、双引号、无引号三种情况。Comment注释整段注释默认删除但条件注释除外。RawText原始文本script、style、pre、textarea 里的一切内容原样保留。以空白折叠为例。在普通文本状态如果连续遇到多个空格、换行、制表符我会先不急着输出而是记一个候选空白字符。等到下一个非空白字符到来时再根据上下文判断这个候选空白到底要不要输出。这样不管原始缩进是四空格还是八空格最终都只会保留一个空格。标签之间空白的处理要更小心。在块级标签之间比如/div后紧跟div这个空白可以安全删掉因为块级布局本来就不受该空白影响。但在两个 inline 级元素之间空白则会影响布局例如两个inline-block按钮之间如果完全没有空格会紧密贴在一起。我在状态机里维护了一个上一个闭合标签是否为 inline 元素的标识位用于决定标签交界处的空白是否保留。属性的压缩规则相对简单。属性值如果由字母、数字、连字符、下划线等安全字符组成我会删掉引号布尔属性如果写了属性值比如input disableddisabled可以把值删掉只留属性名。这些操作都需要确保没有歧义否则宁可不做只输出原样。2.3 边界保护什么内容坚决不碰写压缩器最忌讳的就是什么都想压。为了保证安全我明确列了一份不做清单。不碰 script 和 style 内部文本。即使里面的空白很多也绝对不折叠因为代码字符串可能依赖空白语义正则压缩的收益不值得冒风险。不碰 pre 和 textarea 内部内容。这两个标签里的空白本身就是内容。不删条件注释。虽然现代浏览器可以忽略但某些旧兼容场景还需要它。不做 HTML 实体替换。把nbsp;转成什么、把amp;如何还原各种语义和安全性边界太复杂我不打算在压缩器里引入这类不确定性。不删除可选闭合标签。比如/html、/body、/li删掉确实合法但一旦页面里的渲染逻辑依赖补充性解析反而可能造成不可控差异。从工程角度看这份不做清单才是整个压缩器安全的基石。它能让我把注意力集中在那些收益稳定、风险可控的压缩操作上也方便后续迭代时每加一项激进规则都能单独验证。3. 把压缩器接进 Hono 中间件链路3.1 压缩核心的代码骨架实现上我定义了一个精简的规则配置对象然后让minifyHtml函数接收 HTML 字符串和配置返回压缩后字符串。下面是简化后的核心骨架export type HtmlMinifyConfig { collapseWhitespace: boolean; removeComments: boolean; removeAttributeQuotes: boolean; removeBooleanAttrValues: boolean; }; enum State { Data, Tag, TagName, AttrName, AttrValue, RawText, Comment, } const RAW_TEXT_TAGS new Set([script, style, pre, textarea]); const INLINE_TAGS new Set([span, a, b, strong, em, img, input, button]); export function minifyHtml(input: string, cfg: HtmlMinifyConfig): string { let out ; let state: State State.Data; let tagName ; let pendingSpace false; let lastTagWasInline false; for (let i 0; i input.length; i) { const ch input[i]; switch (state) { case State.Data: if (ch ) { state State.Tag; out ch; } else if (/\s/.test(ch)) { pendingSpace true; } else { if (cfg.collapseWhitespace pendingSpace) { out ; } out ch; pendingSpace false; } break; case State.Tag: ... // 解析标签名、属性、注释等 default: break; } } return out; }这只是一个非常浓缩的示意真实的实现里还要处理注释开头、属性引号结束、RawText 标签进入与退出等分支。但整体思路就是上面这些一次遍历、状态切换、按配置决定输出。因为没有正则也没有 DOM 树这个函数在任意输入上的耗时都稳定在和输入长度成正比这个量级。3.2 在 Hono 中以中间件形式接入接入方式比我预想的还要简单。Hono 的中间件会在next()后拿到最终 Response我可以读取它、压缩、再替换掉c.resimport { Hono } from hono; import { minifyHtml } from ./minify-html; const app new Hono(); app.use(*, async (c, next) { await next(); const res c.res; const contentType res.headers.get(content-type) || ; if (!contentType.includes(text/html) || !res.body) { return; } const rawHtml await res.text(); const minified minifyHtml(rawHtml, { collapseWhitespace: true, removeComments: true, removeAttributeQuotes: true, removeBooleanAttrValues: true, }); c.res new Response(minified, { status: res.status, statusText: res.statusText, headers: res.headers, }); });有几个细节值得强调。首先await next()必须在前面否则拿不到已经生成的路由响应。其次必须检查 content-type否则像 JSON、SVG、XML 这类响应也会被误伤。另外如果响应是流式传输直接res.text()会把整个 flow 读完这个我下面单独讲。还有一点new Response(minified, res)这种方式会连 header 一起复制但原始响应里的content-length和etag这时已经失效了。前者会用新 body 的长度重新生成后者必须手动删除否则客户端做缓存校验的时候会出现 ETag 对不上实际内容的问题。const headers new Headers(res.headers); headers.delete(etag); headers.delete(content-length); c.res new Response(minified, { status: res.status, statusText: res.statusText, headers, });3.3 流式大 HTML 与结果缓存策略大部分服务端渲染页面一次生成的 HTML 不会超过几百 KB直接res.text()读完再压缩简单可靠。但如果是长列表、大数据表格这种大页面一次性缓冲会占不少内存。Workers 内存有限所以我额外写了一条流式路径。流式处理最危险的地方在于一个 chunk 可能正好把 HTML 标签拦腰截断比如收到div class下一个 chunk 才补上hidden。如果对每个 chunk 单独压缩压缩结果极可能是错的。我在实现里维护了一个窗口缓冲当前 chunk 先追加到窗口然后找出一个安全的截断点比如最后一个已闭合标签的结束位置只把安全前缀送进压缩器保留不完整后缀等下一个 chunk。const reader res.body.getReader(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer new TextDecoder().decode(value, { stream: true }); const safeEnd findSafeCutPoint(buffer); if (safeEnd buffer.length - MAX_SAFE_SUFFIX) { // 不足够保守时继续等 continue; } // 处理安全前缀并发送 }这个思路说起来简单实现时要注意MAX_SAFE_SUFFIX要足够大至少能容纳一个最长的标签和属性名我一般会留 64 KB 作为兜底窗口。不过老实说如果页面不是大到自己都不好意思我第一次推荐直接用缓冲版本简单清晰不容易出幺蛾子。缓存方面也有讲究。压缩器本身就占了 CPU如果每个请求都压缩一遍在热点页面上是纯浪费。我会把压缩后的 HTML 结果连同响应头一起放进 Cache APIkey 里带上原始页面版本号或渲染参数这样同一份页面在后续请求中直接命中压缩结果CPU 开销几乎为 0。Workers 本身的 CDN 层会做传输压缩但那发生在响应离开 Worker 之后和这个内部缓存并不冲突。4. 实测效果体积、耗时与包体成本4.1 压缩率与耗时数据我拿自己维护的演示站点做了几组测试页面类型覆盖两类典型场景A 是一个博客文章页B 是一个带表单的 Dashboard。策略都开启完整压缩包括空白折叠、注释删除、属性引号清理、布尔属性值清理但 script/style 和预格式化标签完全不碰。页面原始体积压缩后体积体积节省博客文章页 A58.4 KB40.2 KB31.2%表单 Dashboard B96.3 KB63.8 KB33.7%在这两个页面上压缩器的 CPU 耗时基本在 2 到 3 毫秒之间。作为对比如果把一整个 Node 端成熟压缩库放进 Worker 里做同样的处理即使不触发灾难回溯耗时也经常在几十毫秒量级。差距的根源就是状态机一次扫描完事而 DOM 树方案需要 parse operate serialize 三个阶段。更让我满意的是叠加传输压缩后的效果。压缩后的 40.2 KB 页面经 Brotli 传输压缩后实际传递到客户端只有 15 KB 左右整个首屏 body 传输几乎无感。4.2 与 Node 生态成熟库的包体差异模块体积也值得单独说一下。一个成熟的 Node 端压缩器往往带着解析器、DOM 模拟、序列化器等一堆依赖为了运行它你可能还要引入额外的 polyfill。最终打进 Worker 包后体积随随便便冲到 500 KB 以上有些重方案甚至有 1 MB 往上这直接影响冷启动。我这个压缩器因为是手写状态机也没有任何第三方运行时依赖核心代码加上 Hono 中间件适配层在 esbuild 打包后 gzip 只有大约 4 KB。一个 Worker 包压缩后总共也就十几 KB部署、冷启动都非常轻快。方案打包后 gzip 体积100KB HTML 处理耗时能否直接跑在 WorkersNode 生态成熟压缩库通常 500 KB 甚至更多数十毫秒到几百毫秒多数不行需要大量 polyfill手写状态机压缩器约 4 KB约 2~3 毫秒直接运行5. 踩坑实录压缩后的 HTML 翻车现场5.1 压缩后布局乱了内联空白在作怪第一次把压缩器放到真实页面上我打开页面就看到两个按钮紧紧贴在一起中间那条缝没了。原因就是我在两个inline-block按钮之间原本有换行缩进产生的空白字符压缩器把那段空白折叠成了 0。对于普通行内文本相邻空白不影响渲染但在 inline-block、table-cell 这类布局里空白字符本身承担着元素间隔的职责。解决办法是状态机里维护一个前一个闭合标签是否是 inline 级元素的标识。当我在两个 inline 元素之间遇到空白时只折叠为一个空格而不是彻底删除。如果页面使用了 flex 或 grid这些空白通常不影响布局但为了保险我没有在默认配置里区分两者而是统一保留一个空格。经验是想要极致的压缩率可以单独开一个aggressive: true开关在确认页面布局不依赖空白时再打开。5.2 脚本或样式被误伤有一次我把一个页面里的 script 内容中的空白也折叠了结果页面里的字符串字面量被改坏。这个问题初看很隐蔽script 里写了一行带缩进的模板字符串压缩器把它折叠成单行导致运行时报错。好在我的不做清单里明确写了不碰 script/style/pre/textarea修复方式就是让状态机在这些标签内直接切换到 RawText 状态原样输出一切内容。如果你自己写压缩器这条规则要放在优先级最高的位置比任何压缩收益都重要。5.3 Worker 运行超时与 CPU 配额如果一个页面太大或者有不怀好意的输入一次性压缩很容易把 CPU 时间打满。在 Worker 日志里会看到典型的 timeout 报错。我用的根治办法有三个压缩前检查输入长度超过某个阈值比如 1 MB直接放弃压缩并原样返回把压缩结果做缓存让 CPU 密集操作只发生一次在解析器里彻底避免任何形式的回溯从源头杜绝看似正常输入但实际卡死的风险。还有一个我建议从第一天就做的优化给压缩器加一个量化的耗时预算如果在处理过程中累计超过某个毫秒值就停止压缩并返回原始字符串。这样即使出现极端输入页面依然能正常响应只是这次没有瘦身而已。5.4 ETag 和 Content-Length 的兼容问题这是接入 Hono 中间件之后最容易忽略的坑。修改响应体后content-length和etagheader 如果还带着旧值轻则让缓存校验失败重则让部分客户端直接认为响应不完整而等待超时。另一个相关经验是如果配置里开了 ETag应该在压缩器内部对压缩后的内容重新计算 ETag再用它覆盖旧的content-length则直接用新 body 的字节长度设置。别图省事直接复制原响应头。每次部署新版本后我自己的经验是多跑一遍页面响应头检查确认这两项没有残留旧值。这里给一份常见问题的速查表方便排查现象原因快速处理方案页面布局中按钮/单元格之间粘连inline/inline-block 元素间的空白被删成 0在状态机中保留标签间单个空格压缩后 JS 报错script 内容被折叠遇到 script/style/pre/textarea 立即原样输出Worker CPU 时间超限输入超大或存在回溯限制最大输入长度给压缩动作加耗时预算浏览器缓存一直拿到旧响应ETag/Content-Length 未同步更新压缩后重新生成 ETag 和长度头JSON/API 响应被改动没做 content-type 过滤仅对 text/html 类型执行压缩我在实际接入过程中最大的感悟是边缘函数里做优化不能拿压到最小当唯一目标稳、可控、低 CPU 才是第一位。后面我打算继续把流式压缩窗口的边界情况处理得更完善提升对大页面的支持力度同时细化条件注释和布尔属性的处理。如果你也在 CloudFlare Workers 和 Hono 里折腾 HTML 瘦身建议先从保守模式入手按需把更激进的规则一条条打开并配合页面渲染测试完整跑一遍。这样踩坑成本最低收益也稳定。