Happy DOM 和 jsdom:差异、选型与测试迁移
把 DOM 测试从 jsdom 切到 Happy DOM,通常只需要改一个配置项。但测试跑起来之后,原来预期的 "0px" 变成了 "0",弹窗 mock 找不到目标方法,还有一个 HTML 解析用例读不到正文里的耗时了。
这几种失败看起来都是「换库以后坏了」,原因却不同。有的是断言绑定了字符串格式,有的是外部依赖没有显式控制,还有的是模拟器对标准行为的实现有偏差。仅仅把 expected 改成新的输出,很容易把真正要保护的行为一起改掉。
对于在 Node.js 中执行的普通 DOM 和组件单测,我的默认选择是 Happy DOM。它能提供日常测试需要的节点、事件和浏览器式接口,也能直接接入 Vitest;比起再引入一个支持范围更小的 DOM 实现,统一测试环境更容易维护。
这个选择仍然需要接受具体的兼容成本。Happy DOM 是 jsdom 的独立替代实现,不是 jsdom 的升级版;它出现得更晚,也确实回应了当时 jsdom 没满足的一部分需求。两个项目的历史、其他候选的目标,以及同一输入下的实际行为,是判断这些成本的依据。
DOM 模拟器补了什么
Node.js 可以运行 JavaScript,却没有网页的节点树,也没有 document.createElement()。组件挂载、查询元素、派发点击事件这些测试,需要有人提供浏览器里的 DOM 接口。
jsdom 和 Happy DOM 都在 JavaScript 运行时里实现了一批 Web 标准接口。测试代码可以创建元素、修改属性、注册监听器,而不必为每个测试启动浏览器。
这里要把三件事分开:
- 测试框架负责发现和执行测试、提供断言与 mock,例如 Vitest。
- DOM 模拟器负责提供节点、事件、样式声明等接口,例如 Happy DOM 和 jsdom。
- 真实浏览器负责实际的页面布局、绘制、交互和浏览器原生能力。
这张图表示验证职责,不表示能力的严格包含关系。比如原生权限相关逻辑可以在单测里用 mock 覆盖分支,但最终仍要在真实运行环境验证。
有 getBoundingClientRect() 这个方法,不代表环境真的排过版;有 requestAnimationFrame(),也不代表屏幕上真的画过一帧。jsdom 明确说明自己没有布局和渲染能力,开启 pretendToBeVisual 也不会改变这一点。
两种模拟器的历史解释了实现路线,但旧版缺口不能直接当成当前版本的能力清单。完整内容见两种 DOM 实现的历史弹窗。
HTML 解析与组件测试需要的 DOM 语义不同。Browser Mode 可以补充真实平台行为,但需要启动浏览器,不属于 Node 内轻量 DOM 实现。完整内容见其他 DOM 工具与运行方式弹窗。
实现路线决定了差异会出现在哪里
jsdom 很多行为沿着标准接口和专门的基础库组织。例如,30.1.3 的 HTML 解析入口使用 parse5,并通过适配器把解析结果连接到 jsdom 的节点实现;依赖声明还列出了 URL、Cookie、XML、CSS 和 Web IDL 转换相关的库。
Happy DOM 自己实现了较多基础逻辑,例如 HTML 解析器、节点类型和样式声明。它还提供 Browser API,用于管理模拟的页面、上下文和异步任务。这里的 Browser 仍是模拟器对象,不是一个藏起来的 Chromium。
这些路线会影响启动开销、支持范围和细节行为,但不能直接推出「jsdom 总是更正确」或「Happy DOM 总是更快」。尤其不能把某个旧版本缺少的 API,当成整个库永久不支持的能力。
三种环境也没有共享同一份 DOM 实现。JavaScript 引擎负责执行脚本,HTML 树构建、CSS 解析与文档接口还需要另外实现。两个库运行在 Node.js 中,并不意味着它们会获得 Chromium 的这些实现;它们返回什么值,仍由各自的节点、解析器与属性处理代码决定。
| 比较点 | jsdom | Happy DOM |
|---|---|---|
| 常见使用方式 | new JSDOM(html) 获取 window |
new Window(),也可使用模拟的 Browser API |
| HTML 解析 | parse5 与节点适配器协作 | 自己的 HTML 解析实现 |
| 普通组件测试 | 提供 DOM、事件等基础能力 | 同样可用于组件与 DOM 交互测试 |
| 更细的标准行为 | 适合列出关键行为后逐项核对 | 也需要核对,不能只看接口是否存在 |
| 性能判断 | 测量自己的初始化与执行成本 | 通常是值得优先测试的性能候选 |
| 布局与视觉验收 | 不能替代真实浏览器 | 同样不能替代真实浏览器 |
Happy DOM 列出的能力已经包括 Custom Elements、Shadow DOM、MutationObserver、Fetch 和 Selection 等,不能把它理解成「只实现几个 DOM 方法的小 mock」。但「有这个接口」与「每个边界都和浏览器一致」仍是两回事。
把相同输入交给三个环境
下面的行为对照采样于 2026 年 10 月 19 日,运行平台是 Windows,Node.js 26.12.0,模拟器分别为 Happy DOM 21.0.3 和 jsdom 30.1.3。浏览器对照使用独立、无扩展的无头 Edge 155.0.4283.45,没有复用日常浏览器的登录态。
两个模拟器都使用默认设置,只显式指定 URL 为 https://example.test/;浏览器样本在 about:blank 中执行。样例不依赖远程资源,也不依赖跨域请求。完整采样结果及输入保留在文章资源中。它是一组具体行为对照,不是完整标准符合性测试。
Edge 在这里提供 Chromium 系浏览器的实际行为参照,而不是正确性的最终定义。判断是不是 bug,先核对标准要求,再看实现是否明确省略了这项能力,最后追到当前版本的源码与复现。jsdom 和 Edge 输出相同,只能先说明这个观察点一致,不能证明相邻行为也都完整。
对 CSS 简写、文档结构与 getter 的进一步采样,记录在诊断数据中。浏览器对照仍是 Edge;本次没有额外运行 Firefox 或 WebKit,不能把结果写成所有浏览器的共同实测。
颜色含义相同,不等于序列化都符合标准
对一个新建元素执行:
element.style.inset = '0';
element.style.color = '#a6a6a6';
再读取同一份行内样式:
| 读取项 | Happy DOM 21.0.3 | jsdom 30.1.3 | Edge 155 |
|---|---|---|---|
style.inset |
"0" |
"0px" |
"0px" |
style.color |
"#a6a6a6" |
"rgb(166, 166, 166)" |
"rgb(166, 166, 166)" |
在这个颜色样例中,#a6a6a6 与 rgb(166, 166, 166) 表达相同的颜色,但标准也约束了从接口读取时应得到什么形式。CSS Color 的序列化规则区分了保留声明关键字、CSS 颜色序列化与 Canvas 等场景的 HTML 兼容序列化,不能因为十六进制本身是合法颜色,就认为 style.color 原样返回它也一定符合规范。
根因在 Happy DOM 21.0.3 的 CSSValueFormatter.getColor()。它检查颜色是否匹配,处理部分函数写法里的空白后,十六进制分支直接返回输入,没有做对应的 RGB 序列化:
// happy-dom@21.0.3/packages/happy-dom/src/css/declaration/property-manager/utilities/CSSValueFormatter.ts:358
return value;
读取行内样式时,保存的值又从 getPropertyValue()返回,因此这不是 Edge 特有的视觉问题,也不是深浅色渲染造成的差异。jsdom 的 serializeColor()则会把十六进制的解析结果交给颜色解析器处理。
这个方向已有 Happy DOM 上游问题 #1829及修复提案 #2405。截至本次核对,PR 仍为 open、未合并,21.0.3 也仍能复现。讨论里写了实现方案,不能等同于已发布版本已经修好。
因此,它可以定性为本版本的 CSS 颜色序列化兼容缺陷。业务测试仍然可以只保护自己负责的语义:如果要求是「操作完成后,恢复用户原有的行内样式」,稳定的断言应该围绕恢复行为,而不是顺带测试模拟器的颜色格式:
const originalStyle = element.style.cssText;
applyTemporaryStyle(element);
restoreStyle(element);
expect(element.style.cssText).toBe(originalStyle);
这里 applyTemporaryStyle 和 restoreStyle 代表项目自己的被测操作。expected 来自操作前的状态,不来自被测操作的输出,因此仍能发现样式没有恢复的回归。
如果测试保护的是「必须设置成指定颜色」,则要先明确业务契约。只要求颜色含义时,可以把固定的 expected 颜色交给当前环境的独立样式声明规范化后比较;要求符合浏览器 CSSOM 序列化时,就应该在浏览器里按规范断言,不能把不同输出都当成正确。
inset 的差异不止是少了 px
Happy DOM 对 inset 的处理走了另一条路径。这个版本的属性分发没有为它提供专门处理,而是进入 CSSPropertyManager 的默认分支,再调用 getDefault()。默认分支会识别变量和全局关键字,否则直接保留值。
jsdom 的通用属性描述器会解析值并调用长度序列化;serializeLength()有一个明确分支,把没有单位的零写成 0px:
// jsdom@30.1.3/lib/jsdom/living/css/helpers/css-values.js:461-462
if (num === 0 && !unit) {
return `${num}px`;
}
继续检查同一项声明,能看到更重要的缺口。先设置 inset: 0,读取各方向,再尝试把它设成非法值 not-a-length:
| 观察点 | Happy DOM 21.0.3 | jsdom 30.1.3 | Edge 155 |
|---|---|---|---|
设置零后读取 style.inset |
"0" |
"0px" |
"0px" |
随后读取 style.top/right/bottom/left |
都是空字符串 | 都是空字符串 | 都是 "0px" |
再设置非法值后读取 style.inset |
"not-a-length" |
保留 "0px" |
保留 "0px" |
CSS 定位规范把 inset 定义为四个方向的简写,而 CSSOM 要求按属性语法解析赋值,失败时不应用这个值。这两项都是不需要计算页面布局就应成立的接口行为。
所以,Happy DOM 这里既有校验缺口,也没有完成简写展开。jsdom 虽然校验和零值规范化更完整,却同样缺少这个简写到四个方向的联动:它的专门简写处理表没有 inset,该属性仍走通用处理。两者都不能仅凭「不做布局」解释这个缺口。
这也说明,只比较 style.inset 的返回字符串不够。原来的字符串对照没有错误,但它只检查了比简写完整行为更窄的一层。
省略标签时,HTML 树可能真的不同
把同一个字符串交给 DOMParser.parseFromString(html, 'text/html'):
<title>title</title><body><p>hello</p></body>
| 读取项 | Happy DOM 21.0.3 | jsdom 30.1.3 | Edge 155 |
|---|---|---|---|
head.querySelector('title')?.textContent |
没有该元素 | "title" |
"title" |
body.textContent |
"titlehello" |
"hello" |
"hello" |
这次差异会影响业务结果:如果解析器只想读取正文,标题文本混进来就可能造成误判。它和上一节的字符串格式差异不能用同一种办法处理。
HTML 标准规定了 DOMParser 的文档解析流程,省略部分外层标签不意味着可以随意改变节点归属。本次样例中,jsdom 与 Edge 的结果一致,Happy DOM 的结果不同;这只能证明这个版本、这个输入的差异,不能推出两套解析器整体的正确率。
进一步跟踪 Happy DOM 的 parseEndOfStartTag(),会看到它把直接出现在文档根节点下的普通元素放进已有的 body。解析这个样例的第一个 title 时,当前节点是文档、结构层级仍为初始值,于是命中该分支:
{
"before": { "current": "#document", "next": "TITLE", "level": 0 },
"after": { "parent": "BODY", "level": 6 }
}
这是对原方法调用前后状态的日志,只增加观察,没有替换它的解析结果。把输入改成显式的 <head><title>…</title></head> 后,Happy DOM 就会将 title 放在 head;问题发生在省略 head 时的树构建分支,而不是 .querySelector() 查不到元素。
标准的树构建过程会在这个场景隐式建立 head,并按 head 内的规则处理 title。jsdom 通过 parse5 实现这条路径,Happy DOM 当前分支却直接选了 body。因此,这个样例可以定性为 HTML 解析兼容 bug,不只是两种同样有效的文档组织方式。
如果测试只关心业务如何读取完整文档,可以把 fixture 写成明确的 html/head/body 结构,让测试聚焦于自己的职责。如果项目本来就要接收省略标签的 HTML,那么这个边界必须保留,应由能够验证该行为的环境执行,或者先处理上游兼容问题。
两者都不会因为写了宽度就真的排版
创建一个元素,设置 width: 100px; height: 50px,插入文档后读取尺寸:
| 读取项 | Happy DOM 21.0.3 | jsdom 30.1.3 | Edge 155 |
|---|---|---|---|
offsetWidth |
0 |
0 |
100 |
getBoundingClientRect().width |
0 |
0 |
100 |
因此,单测里可以给尺寸 getter 一个受控值,验证「达到阈值后是否切换状态」,但这种测试没有验证阈值是否由真实布局正确算出。滚动、溢出、吸顶、字体换行、响应式布局等问题,应该留给真实浏览器。
源码也直接显示了这个边界。Happy DOM 的 Element 实现在 getBoundingClientRect() 里标着未完整实现,并返回一个空的 DOMRect;jsdom 的 Element 尺寸实现同样在这些几何接口中返回零值。这里是明确的占位实现,不应诊断成某个 CSS 没有应用成功。
为什么两个解析测试曾经需要 jsdom
迁移一组 HTML 解析测试时,两个文件暂时保留了 jsdom。它们共用了下面这种正文读取逻辑,示意代码省略了后续的业务字段解析:
const readText = (root: ParentNode) =>
root instanceof Document ? root.body?.textContent : root.textContent;
这种写法有一个理由:按照 DOM 标准的 textContent 定义,Document 本身的 textContent 是 null,要读正文就得继续取 body.textContent。
但本次采样里,Happy DOM 的文档没有通过这个类型检查:
| 判断 | Happy DOM 21.0.3 | jsdom 30.1.3 | Edge 155 |
|---|---|---|---|
window.document instanceof window.Document |
false |
true |
true |
DOMParser 创建的 HTML 文档 instanceof window.Document |
false |
true |
true |
文档正文里的内容仍然存在,只是代码走错了分支。读取 Document 自己的 textContent,在 Happy DOM 的这个版本中得到空字符串,而在另外两个环境中得到 null;无论哪一种,都读不到正文。
差异来自构造器的继承关系
Happy DOM 21.0.3 的 WindowContextClassExtender.ts:64-71分别扩展了两个基础实现,再将构造器挂到窗口上。下面只节选了类声明:
// happy-dom@21.0.3/packages/happy-dom/src/window/WindowContextClassExtender.ts:64、69
class Document extends DocumentImplementation {}
class HTMLDocument extends HTMLDocumentImplementation {}
基础的 HTMLDocumentImplementation 继承基础 Document,但窗口上新建的 HTMLDocument 子类并不继承窗口上新建的 Document 子类。DOMParser 又通过 new window.HTMLDocument() 创建 HTML 文档。因此,对基础实现成立的继承关系,不等于对窗口上的两个新构造器也成立。
这解释了为什么保留 jsdom 能让测试继续验证原本的业务预期,也说明问题不在于 Happy DOM 没有解析到那段 HTML。
同窗口的 HTML 文档不能通过 Document 检查,属于继承关系的兼容 bug。上游 #1781已经报告了同一机制,截至本次核对仍为 open。这与后面介绍的正常跨窗口 instanceof 差异要分开。
Document.textContent 返回空字符串则是另一处独立缺陷。Happy DOM 的 Document 类没有覆写这个 getter,因此沿用了 Node 的默认实现:
// happy-dom@21.0.3/packages/happy-dom/src/nodes/node/Node.ts:221-224
public get textContent(): string {
// Sub-classes should implement this method.
return '';
}
而 DOM 标准对 Document 要求的是 null。因此,不能因为两种返回值都读不到正文,就认为二者在接口契约上没有区别;本次业务失败主要由 instanceof 分支触发,getter 的标准偏差是额外查到的问题。
统一环境,不必伪造 Document
直接把测试里的 Document 构造器替换成某个基础类,可能让 instanceof 通过,却同时改变了全局环境的含义。把预期耗时改成 undefined 更不可取,那是在放弃原本的业务要求。
更合适的修改,是先问业务究竟需不需要检查构造器身份。读取一个已知 DOM 节点的正文,只需要知道它是不是文档节点,可以使用标准的节点类型:
// 接收已知 DOM 节点,不用于校验任意外部对象。
const readText = (root: ParentNode) =>
root.nodeType === Node.DOCUMENT_NODE
? (root as Document).body?.textContent
: root.textContent;
类型断言只帮助 TypeScript 理解这个分支,运行时依据仍是 nodeType。这段代码也没有修复模拟器的继承关系,只是去掉了读取正文时不需要的构造器身份依赖。
这个修改在真实浏览器中同样有价值。一次同源 iframe 对照得到:
{
"sameWindow": true,
"foreignInCurrent": false,
"foreignInOwn": true,
"nodeType": 9,
"documentNode": 9
}
这些字段分别对应:当前文档通过当前窗口的 Document 检查,iframe 文档无法通过父窗口的 Document 检查,但可以通过 iframe 自己的 Document 检查;两种文档的节点类型都是 9。
Realm 可以理解为一套独立的 JavaScript 全局环境及其内建对象。同源 iframe 仍有自己的窗口与构造器,因此
instanceof也会受对象来自哪个环境影响。跨窗口问题与 Happy DOM 本次的同窗口继承差异成因不同,只是都不适合让正文读取依赖当前窗口的构造器身份。
补上当前文档、DOMParser 独立文档、createHTMLDocument() 文档和普通元素的回归测试后,原来的业务预期可以保持不变,两个文件也就不再必须依赖 jsdom。这里实际包含了一处生产代码的健壮性调整,不能说成「只改了测试」。
旧测试通过,说明它覆盖的输入在当时环境里得到了预期结果;它没有证明所有 DOM 实现、所有窗口来源都等价。换环境后应该调查失败来源,既不能直接认定业务代码错了,也不能直接认定旧测试已经证明业务绝无问题。
弹窗、网络与时钟,要明确由谁提供
同样是「方法不存在」,有时来自模拟器,有时来自测试框架的环境注入。下面是独立创建窗口时的采样结果:
| 接口 | Happy DOM 21.0.3 | jsdom 30.1.3 | Edge 155 |
|---|---|---|---|
typeof window.alert |
"undefined" |
"function" |
"function" |
typeof window.confirm |
"undefined" |
"function" |
"function" |
typeof window.fetch |
"function" |
"undefined" |
"function" |
表中的 function 也不表示能力完整。jsdom 的 Window 实现把 alert、confirm 等方法接到了未实现提示函数,它不会真的弹出浏览器对话框让用户选择。
jsdom 是有方法但仅提供占位,Happy DOM 在这个版本中则没有暴露这两个方法。它们都是原生对话框能力的缺失,不能从 typeof === 'function' 推导出 jsdom 已经支持完整交互;这与一个已实现的解析分支给出错误结果,是不同的缺陷类型。
如果业务是「确认后提交、取消后不提交」,两种模拟环境里都应该显式控制确认结果。例如在对应测试的准备阶段设置:
vi.stubGlobal('confirm', vi.fn().mockReturnValue(false));
然后验证没有发生提交。另一个用例返回 true,验证提交发生;在 afterEach 中恢复全局变量。这样测试控制的是用户选择,不会把环境默认提供的空实现当成真正交互。
Node.js 的 fetch 不等于窗口的 fetch
Node.js 自带 Fetch API,但独立创建的 jsdom 窗口并不会因此自动拥有同一个 fetch。Vitest 5.0.5 的 Happy DOM 环境接入代码又会把模拟窗口的 Fetch 等接口填充到测试全局。
于是,只看 Node.js 版本,或者只在独立窗口里探测一次,都不足以判断测试实际调用的是谁。
这也不是「既然 Node.js 有 fetch,jsdom 的 window 没有就是继承坏了」。模拟窗口不是通过 JavaScript 原型继承宿主的 globalThis,它提供哪些属性由库的实现决定;测试框架还会再次选择、绑定或替换其中一些属性。独立 jsdom 窗口缺少 Fetch 是 API 覆盖边界,切换环境后意外联网则暴露了测试没有控制外部依赖。
迁移时遇到过一个典型后果:DOM 测试读取头像后调用了 fetch,切换环境后发出了原本没有预期的真实请求,错误指向测试环境的默认本地地址。请求失败被业务捕获了,测试仍可能显示通过,但副作用已经发生。
正确处理是显式 mock 这个边界。验证头像读取失败的分支就让 fetch reject;验证成功分支就给受控响应,并检查必要的 URL、方法和调用次数。不要靠换 DOM 库阻止联网,也不要用全局吞掉 console 错误的方式隐藏它。
等异步任务,不要混淆两套时钟
Happy DOM 的测试环境说明提醒,Vitest 默认不完全使用 Happy DOM 自己的计时器,因此 happyDOM.waitUntilComplete() 与 abort() 不能默认代表「测试里的所有异步任务都结束了」。
业务单测应等待自己负责的结果,例如 Promise 返回、组件更新、某个可观察状态出现。需要控制时间时使用测试框架的 fake timers,并在清理阶段恢复。不要为了等待一个条件改用固定 sleep,也不要看到官方 workaround 就在全仓替换 timer,这会同时影响 fake timers 与资源清理的假设。
这些差异分别属于什么问题
这里的 bug 指可以结合标准、版本源码和输入输出确认的行为偏差;未实现接口、明确不承担的布局能力,以及测试自身的副作用控制问题,分别列出。上游 issue 或 PR 的状态只代表核对时的进度,不替代已发布版本的实测。
| 现象 | 根因 | 本次判断 |
|---|---|---|
| Happy DOM 保留十六进制颜色 | 颜色处理没有完成对应的 CSS 序列化 | 序列化兼容 bug,有上游讨论与未合并 PR |
| inset 接受非法值、不能联动四个方向 | Happy DOM 走保留原值的默认处理;jsdom 也未接入该简写的完整展开 | Happy DOM 有校验缺口,两者都有简写行为缺口 |
| 省略 head 后 title 进入 body | Happy DOM 的文档根节点分支直接选择 body | 本版本 HTML 树构建 bug |
| 同窗口 document 不能通过 Document 检查 | 每个窗口分别扩展构造器,原型链没有相应连接 | 继承关系 bug,已有上游 #1781 |
Happy DOM 的 Document.textContent 为 "" |
继承了 Node 的默认空字符串 getter | 独立的标准行为 bug |
| 写了宽度,几何接口仍返回零 | 没有布局引擎,接口使用占位值 | 明确的能力边界,不能用来测布局 |
| alert、confirm 无法提供真实选择 | 方法缺失或只有未实现占位 | API 缺口,业务测试显式 mock |
| fetch、异步等待与旧环境不同 | API 覆盖和 runner 接入方式不同 | 先识别实现来源;未 mock 的网络副作用由测试处理 |
判定某处是库的 bug,不意味着当前项目必须自己修库。业务只需要稳定语义时,可以用不依赖该细节的正确写法;业务依赖的正是这项标准行为时,就需要上游修复、局部替代或真实浏览器测试,不能用弱化断言把缺口抹掉。
性能该怎么比较
Happy DOM 的性能表把导入、HTML 解析、序列化和查询分开比较;Vitest 的环境说明也把 Happy DOM 作为通常更快、但存在 API 缺口的选择。这些资料说明它值得测,不等于当前项目一定能得到表中的倍数。
对于组件测试,耗时可能分散在 TypeScript 转换、模块导入、DOM 环境初始化、组件挂载、异步等待和清理里。DOM 操作更快,整个测试任务却只快一点,是完全可能的。
做项目内对比时,我会固定 Node.js、Vitest、依赖锁文件、测试文件集合和并发设置,只切换 DOM 环境;将必要的兼容修复先做到两种环境下都能运行。然后分别测冷启动和缓存后的重复运行,交替执行多轮,记录总耗时、中位数、波动和失败情况。
还要检查两边是否真的完成了相同工作:测试数有没有变少,组件是否挂载成功,异步断言有没有等待,网络错误有没有被忽略。跳过一批测试得到的「提速」没有选型价值。
本次行为采样没有测性能,因此不提供 21.0.3 相对 30.1.3 快多少倍的结论。同时升级 Vitest、构建工具和 DOM 库后,前后两次全仓运行时间也不能单独归因到 Happy DOM。
选型建议落到测试职责上
默认选 Happy DOM,并不意味着已有 jsdom 项目都值得迁移。接口覆盖、执行成本和迁移风险要按实际测试判断;只做 HTML 提取的工具,也不必承担通用组件环境的成本。
| 场景 | 默认建议 | 判断理由 |
|---|---|---|
| 数据转换、URL 处理、状态模型 | Node.js 环境 | 没有 DOM 需求,就不必初始化模拟窗口 |
| 普通表单、组件状态、事件与节点增删 | Happy DOM | 默认统一环境,用代表性测试验证必要接口与兼容成本 |
| 已有大量稳定的 jsdom 用例,执行时间可接受 | 继续 jsdom | 迁移本身需要付出验证成本,版本新旧不是收益 |
| 依赖复杂 HTML 纠错、构造器身份或具体 CSSOM 行为 | 列出关键样例,再选 jsdom 或浏览器 | 这些差异会影响结果,应逐项核对,不能泛化成一个库永远正确 |
| 排版、尺寸、滚动、视觉、跨浏览器交互 | 真实浏览器 | DOM 模拟器无法给出完整的布局与绘制结果 |
还需要核对运行时约束。jsdom 30.1.3要求 Node.js ^22.22.2 || ^24.15.0 || >=26.0.0;Happy DOM 21.0.3要求 >=20.0.0。测试框架、构建工具可能有更高要求,最终要满足整个依赖链,而不是只满足模拟器。
如果目标是只留一种 DOM 库,优先让大多数业务单测统一到 Happy DOM。发现失败时,先区分业务回归、测试缺少 mock、非必要的实现耦合和真正的标准兼容缺陷;能用保持业务语义的正确写法解决,就不增加第二个模拟器。业务必须验证缺失的标准行为时,再选择上游修复、局部例外或真实浏览器验证,不维护一层掩盖问题的全局补丁。
迁移时保留测试的判断能力
在 Vitest 项目中,安装 Happy DOM 后,将配置里的 test.environment 改为 happy-dom 即可启用;Vitest 文档同时支持文件级环境注释。迁移时要一起检查旧注释和按项目拆分的配置,避免以为已经统一,实际仍有文件走 jsdom。
配置变更很小,失败分类才是迁移的主要工作。
迁移后,原来的业务断言仍应能发现回归:
- 先保留失败现场。记录具体输入、两边输出与原来的业务预期,先别批量更新 snapshot。
- 格式差异回到稳定契约。恢复样式就比较操作前后;要求标准序列化则保留浏览器验证。
- 外部副作用显式控制。网络、确认框、时间、尺寸都说明由谁提供,测试结束后恢复准确的原状态。
- 标准行为差异做最小复现。对照规范、当前版本源码和真实浏览器,再决定修测试、修业务判断、处理上游问题或保留环境例外。
- 最后移除旧依赖。确认用例数与关键断言没有减少,再运行类型检查、全部测试、生产构建和锁文件一致性检查。
测试运行在 Node.js 进程里,模拟出来的窗口也不等于安全沙箱。jsdom 对启用脚本执行的风险有明确说明;Happy DOM 的脚本和资源加载同样有独立设置。不要为了让抓取来的 HTML「更像网页」,随手开放不可信脚本执行。依赖升级、安全审计和不可信内容隔离,需要另外维护。
已确认的兼容缺陷要保留最小复现与版本信息,升级后再复核。依赖更新可以改变适合哪种环境的判断,但不应悄悄削弱业务断言。
总结
Happy DOM 和 jsdom 都是在 Node.js 中提供 Web 接口的独立实现,接口存在不代表行为完整,更不代表已经完成真实布局与绘制。普通 DOM 和组件单测默认选 Happy DOM,仍要接受具体的兼容验证;已有稳定 jsdom 测试没有必须迁移的理由,布局、滚动和关键浏览器行为则需要真实浏览器。
迁移中的失败要分别看:断言是否耦合了非必要格式,网络、确认框和时钟是否受控,还是输入确实触发了标准兼容缺陷。保持业务语义的调整可以去掉多余依赖,但不能靠修改 expected、伪造全局构造器或跳过用例来获得通过。本次对照只支持所列版本与输入下的行为判断,没有测出性能倍数,也没有覆盖所有浏览器。
两种 DOM 实现的历史
从跨平台 DOM 到 Web Components
DOM 模拟器回应的是 JavaScript 走出浏览器之后留下的缺口。运行时能够执行脚本,却没有网页的文档对象;而已有代码常常已经依赖节点、事件和选择器。重新提供这些接口,可以让一部分操作在服务端、脚本工具或测试进程中继续运行,不必把整个浏览器一起带进来。
图中的年份按公开仓库创建时间排列,不代表最早的代码编写日或 npm 首发日,也不表示后一个库继承或取代了前一个库。Domino 和 LinkeDOM 的具体取舍在后面的替代方案中展开。
jsdom:在浏览器之外提供熟悉的文档接口
jsdom 的仓库创建于 2010 年 1 月。同月的早期 README就把目标写成纯 JavaScript 的 DOM 实现,强调跨平台和尽量轻量。当时的定位已经是把文档接口从某个具体浏览器中独立出来,并非后来专为某个组件框架的测试而设计。
今天的 jsdom 继续面向测试与网页内容处理,但随着 Web 标准演进,需要补的东西也越来越多。一个看起来简单的 customElements.define(),背后还涉及构造元素的过程、注册表、生命周期回调和相关接口之间的协作。
这能解释老项目为什么会遇到演进成本,却不能用来断言某个后来者天然更正确。早期 jsdom 本身也追求轻量;项目变得复杂,既有历史实现的影响,也有承担更多兼容责任的原因。
Happy DOM:早期直接回应 Web Components 的缺口
Happy DOM 的仓库创建于 2019 年 9 月。2019 年 9 月的 README开头就把自己描述为支持 Web Components 服务端渲染的 jsdom 替代方案,随后列出 Custom Elements、Shadow DOM、MutationObserver 等能力。
更直接的动机来自作者 David Ortner。他在 jsdom 的 Web Components 讨论中于 2019 年 10 月留言:自己最初尝试做 PR,但 jsdom 当时的工作方式使他难以满足自身需求,于是另做了支持 Web Components 的 DOM 实现。
因此,可以说 Happy DOM 回应了 jsdom 当时的功能和实现约束,但不能把诞生原因缩写成「jsdom 太慢,所以重写一个」。早期文档与作者留言指向 Web Components 和服务端渲染;现在的项目说明则明确把性能作为重要方向。两者都是项目历史的一部分,证据对应的时间也不同。
Web Components 是自定义元素、Shadow DOM 等浏览器能力的组合;服务端渲染(SSR)是在服务端生成页面 HTML。对于这类组件,生成 HTML 时可能需要实际构建元素、触发生命周期,并处理封装在 Shadow DOM 里的内容,因此只解析标签文本还不够。
jsdom 也没有停留在 2019 年的状态。2020 年 2 月发布的 16.2.0加入了 Custom Elements 支持,发布说明提到这项工作经历了十个月,并涉及 jsdom 与相关包的架构前置改造。旧版本缺口能够解释 Happy DOM 为什么出现,却不能继续当成当前 jsdom 的能力清单。
其他 DOM 工具与运行方式
Node 内 DOM 单测还有哪些选择
这里比较的是不启动浏览器、直接在 Node.js 进程中提供 DOM 的实现。小工具可能只需要解析和查询 HTML,组件却可能同时依赖事件、表单、样式接口与异步更新;这两类需求不适合只按包体积排序。
LinkeDOM:适合文档操作,接受部分语义差异
LinkeDOM 的仓库始于 2020 年,用链式结构组织节点,主要服务于轻量 DOM 操作与 SSR。它也可以用于测试 HTML 提取、节点查找和结构转换,并非只能用于渲染页面。
但它主动放弃了一部分浏览器语义。官方 FAQ明确说明,不以完整浏览器模拟为目标,也不支持实时集合。例如缓存下来的 children、getElementsByClassName() 结果不会随着节点增删自动更新。
在浏览器中,保存 const children = parent.children 后删除一个子节点,children.length 会随之改变;在 LinkeDOM 中不能依赖这个行为。已有代码如果需要这种语义,换库反而引入了新的差异。因此,我会把它作为范围明确的文档处理候选,不作为通用组件单测的默认替代。
undom:只提供最基本的文档对象
undom追求最小可用 DOM,让依赖节点接口的代码能够在没有 DOM 的环境执行。它可以创建元素和文本、连接节点,但默认不包含 HTML 解析、序列化和 Web Components。
如果某个测试只检查节点如何挂载、删除,这样的实现可能足够;如果测试需要先塞入一段 HTML、挂载真实组件,再驱动交互,就得继续补能力。缩小实现范围能减少成本,却不会自动带来更完整的兼容性。
Stencil Mock Doc:围绕组件框架配套的实现
Stencil 的 Mock Doc也是轻量 DOM 实现,用于在 Node.js 中支持 Stencil 的渲染等工作。它说明 DOM 模拟并不只有两个实现,但能力目标围绕自身框架展开。
Stencil 旧测试运行器的 newSpecPage()可以用模拟文档测试组件,无须启动 Puppeteer;截至本次核对,这条测试运行器路线已在当前文档中标为弃用。对于普通 Vitest、Vue 或浏览器工具函数测试,我不会为了获得 DOM 环境引入这套框架配套实现。
Domino 与 HTML 处理工具
Domino 的仓库始于 2012 年,基于 Mozilla 的 dom.js,目标是在 Node.js 中提供服务端 DOM。它的 Speed over Compliance 说明明确偏向页面构建,不执行页面脚本,也不下载外部资源。它适合文档生成和转换,但没有理由仅凭这项目标就推断它比 Happy DOM 更适合通用交互单测。
Cheerio与 parse5还要单独看待。前者提供类似 jQuery 的 HTML/XML 查询与修改接口,后者负责 HTML 解析与序列化。它们可以直接用于解析类工具的单测,却不提供完整组件运行环境。
Testing Library 和 Vue Test Utils 则处在测试辅助层,提供查询、挂载、交互或断言支持。DOM Testing Library 的环境说明仍要求测试具备 DOM 环境;使用它不等于已经替换了底下的 jsdom 或 Happy DOM。
Browser Mode 是运行方式,不是测试层级
Vitest Browser Mode把测试代码放进真实浏览器。只创建几个元素、调用一个函数再断言,仍然可以是单元测试;加载完整应用、验证跨页面业务流程,才更接近端到端测试。测试范围与运行环境是两个维度。
因此,Browser Mode 可以补充模拟器难以验证的平台行为,但它需要启动浏览器,不属于这里要找的 Node 内 DOM 替代库。对于追求轻量、隔离和快速反馈的日常单测,我仍选 Happy DOM;需要真实布局、滚动或关键浏览器行为时,再单独安排浏览器测试。