跳到正文

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。
  • 真实浏览器负责实际的页面布局、绘制、交互和浏览器原生能力。

Node.js、DOM 模拟器与真实浏览器适合验证的不同职责

这张图表示验证职责,不表示能力的严格包含关系。比如原生权限相关逻辑可以在单测里用 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。

配置变更很小,失败分类才是迁移的主要工作。

测试迁移失败时,分别检查格式差异、外部副作用与标准行为差异

迁移后,原来的业务断言仍应能发现回归:

  1. 先保留失败现场。记录具体输入、两边输出与原来的业务预期,先别批量更新 snapshot。
  2. 格式差异回到稳定契约。恢复样式就比较操作前后;要求标准序列化则保留浏览器验证。
  3. 外部副作用显式控制。网络、确认框、时间、尺寸都说明由谁提供,测试结束后恢复准确的原状态。
  4. 标准行为差异做最小复现。对照规范、当前版本源码和真实浏览器,再决定修测试、修业务判断、处理上游问题或保留环境例外。
  5. 最后移除旧依赖。确认用例数与关键断言没有减少,再运行类型检查、全部测试、生产构建和锁文件一致性检查。

测试运行在 Node.js 进程里,模拟出来的窗口也不等于安全沙箱。jsdom 对启用脚本执行的风险有明确说明;Happy DOM 的脚本和资源加载同样有独立设置。不要为了让抓取来的 HTML「更像网页」,随手开放不可信脚本执行。依赖升级、安全审计和不可信内容隔离,需要另外维护。

已确认的兼容缺陷要保留最小复现与版本信息,升级后再复核。依赖更新可以改变适合哪种环境的判断,但不应悄悄削弱业务断言。

总结

Happy DOM 和 jsdom 都是在 Node.js 中提供 Web 接口的独立实现,接口存在不代表行为完整,更不代表已经完成真实布局与绘制。普通 DOM 和组件单测默认选 Happy DOM,仍要接受具体的兼容验证;已有稳定 jsdom 测试没有必须迁移的理由,布局、滚动和关键浏览器行为则需要真实浏览器。

迁移中的失败要分别看:断言是否耦合了非必要格式,网络、确认框和时钟是否受控,还是输入确实触发了标准兼容缺陷。保持业务语义的调整可以去掉多余依赖,但不能靠修改 expected、伪造全局构造器或跳过用例来获得通过。本次对照只支持所列版本与输入下的行为判断,没有测出性能倍数,也没有覆盖所有浏览器。

两种 DOM 实现的历史

从跨平台 DOM 到 Web Components

DOM 模拟器回应的是 JavaScript 走出浏览器之后留下的缺口。运行时能够执行脚本,却没有网页的文档对象;而已有代码常常已经依赖节点、事件和选择器。重新提供这些接口,可以让一部分操作在服务端、脚本工具或测试进程中继续运行,不必把整个浏览器一起带进来。

不同 DOM 实现的公开仓库创建年份与关注方向

图中的年份按公开仓库创建时间排列,不代表最早的代码编写日或 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;需要真实布局、滚动或关键浏览器行为时,再单独安排浏览器测试。