直接用 DOMParser 去解析一个 50 MB 的 HTML 文件,结果内存溢出(OOM),这在技术圈里已经不是什么新鲜事了。问题的根本原因其实很简单——DOMParser 需要把整个 DOM 树完整构建起来,V8 堆内存瞬间就会被撑爆。这并非换个配置就能解决,而是它设计上就存在边界限制。网关层本来就不负责渲染,只做结构识别,强行使用全量解析显然不合适。尤其是碰到那些老系统导出的页面,里面嵌套着上千层的 ,递归下降解析器直接栈溢出,抛出 RangeError: Maximum call stack size exceeded 错误。

具体怎么干?核心思路就这几条:
- 禁用
xmlMode:解析 HTML 文档时一定要将xmlMode设为false,否则像这种自闭合标签会被解析器误解,导致处理逻辑全乱了。 - 使用
htmlparser2.Parser+ 自定义回调:只监听opentag和closetag事件,跳过文本拼接和 DOM 树构建,最大限度减少内存占用。 - 用栈记录标签深度:在
opentag时压栈(比如保存["h1", "section", "article"]),closetag时弹出,避免深度递归导致栈溢出。 - 文本内容用
Buffer或Uint8Array暂存:不把整个文本内容转成字符串,而是用二进制流式暂存,按需截断处理。
如何让浏览器边收边渲染但不卡死
关键不在于“发得快”,而在于每一块发送出去的内容在语法上都是完整的。浏览器的解析器不是拼图游戏,不会等下一块数据来补全标签。一个未闭合的 ,在 Safari 上可能直接卡住不动,在 Chrome 内也可能延迟渲染,直到超时。
实战中的几个关键点:
- 每块
res.write()必须输出最小可插入 DOM 的闭合结构:比如、文字
,这是最基本的要求。标题
内容
- 绝对不能切在属性值中间:
res.write('。 - 首块必须包含完整的启动帧:
,这个框架不能少。 - 响应头显式设置
charset=utf-8:如果不设,含 BOM 的模板文件传过去就会乱码,用户体验很差。
切片后怎么保证锚点跳转和上下文还原
把 30 MB 的 HTML 拆成两百个 直接转发,下游就傻眼了——它没法知道原始位置在哪。用户点击 跳转,结果收回来的是个没有 ID 的纯片段,跳转直接失效。
这里有个实用的方案:在每个切片前注入轻量元数据注释。
- 注入
chunk-meta注释:格式类似,内容要包含原始文件信息。 offset字段用于反查:下游可以通过这个偏移量反查出原始文件中的具体位置,方便日志定位和调试。- 根据下游场景提取信息:如果是 SSR 服务,提取
id构建客户端跳转链接;如果是向量化服务,headers数组可以直接作为 embedding 模型的上下文提示。 - 元数据必须用 HTML 注释包裹:这样能确保不干扰 CSS 或 Ja vaScript 的正常执行。
流式分发时内存堆积怎么防
网关的角色不是中转站,而是流量调节阀。把 50 MB HTML 拆成 64KB 块直接用 pipe() 往下游推,一旦后端消费慢了(比如 Ja va Spring Boot 默认 servlet 缓冲区只有 8KB),没读走的 chunk 就会堆积在 Node.js 的 Writable 内部缓冲区,慢慢地就把内存撑上去了。
这里有几个控制手段:
- 显式设置
highWaterMark:比如把WritableStream的highWaterMark设成64 * 1024,控制缓冲区上限。 - 监听
drain事件:缓冲区快满时就暂停写入,等下游消费完了再恢复,形成背压控制。 - 分块逻辑不要下放到 CDN 边缘:CDN 不理解语义块,也没法注入
chunk-meta信息,这事只能在网关层做。 - 禁用
Content-Length,改用Transfer-Encoding: chunked:依靠底层的分块机制驱动,不要手动去设置这个 header。
真正棘手的地方不在切片本身,而是切完之后下游还能知道“这是哪一段”。chunk-meta 注释看着不起眼,但它要同时对齐原始文件偏移、兼容 SSR 跳转、还能作为 embedding 模型的输入。这三者字段含义和使用方式完全不同,漏掉任何一个环节,流式传输就退化为纯粹的 HTTP 分块,失去了语义价值。
