xterm.js FitAddon 列数卡死,ResizeObserver 无法自愈的根因
在一个内嵌 Web 终端的桌面应用里,某些 pane 里的终端列数死锁在 10-12 个字符宽,webpack 的 \r 滚动进度条输出像走楼梯一样换行堆叠。用 ResizeObserver 监听容器尺寸变化、再调 fit() 的代码已经写了,却完全没用——除非手动拖一下 pane 宽度,列数才能恢复正常,然后切换 tab 又回到窄列的状态。
现象
- 终端容器明明足够宽,但终端只渲染 10-12 列
\r-based 输出(webpack 进度、 npm install 进度条)显示为阶梯形乱码- 部分 pane 正常,部分 pane 卡住——随机性,取决于初次渲染时机
- 已有
ResizeObserver绑fit(),但卡住的 pane 无法自愈 - 手动拖动 pane 边框或切换布局后,终端宽度立刻正确
根因
这里有两个层叠的陷阱:
陷阱一:fit() 改的是终端内部 cols,不改容器 box 尺寸。
FitAddon.fit() 根据当前容器的 clientWidth / clientHeight 计算出 cols / rows,然后调用 term.resize(cols, rows) 修改终端内部状态,同时调整内部 canvas 大小。但挂载 term.open(el) 的那个容器元素是 width: 100%; height: 100% 由父级 flex/grid 决定的——fit() 不会改变这个元素的 box size。
陷阱二:ResizeObserver 监听的是 box 尺寸变化,而 box 没变,所以永远不再触发。
容器 box 尺寸 未变 → ResizeObserver 不触发 → fit() 不再调用 → 错误 cols 被永久锁定
ResizeObserver 只能修正"容器真的变宽/变窄"的情况(比如用户拖拽窗口),它无法修正"容器宽度正确但初次测量时机不对"这类静态误差。
错误测量的来源:
初次挂载时同步调用 fit(),此时:
- 布局尚未稳定——多个 pane 批量挂载时,CSS flex/grid 计算还没完成
- 字体尚未加载——用等宽自定义字体时,font fallback 的字符宽度与实际字体不同,导致
cols计算偏小 - React StrictMode 双重挂载——第一次 mount 时布局未就绪,第二次才完成但
fit()已经用第一次的错误尺寸锁定了
把 ResizeObserver 当成"万能保险"是错的。fit() 不会触发 ResizeObserver,因为容器的 box 尺寸并没有变化——终端是在内部 canvas 层做了缩放。一次错误的初次测量会被永久锁死,直到有外力真正改变容器 box 尺寸为止。
正解
不要在 term.open() 后同步调 fit(),也不要只依赖 ResizeObserver。需要三层保障叠加:
term.open(el)
const fitSafely = () => {
try {
fitAddon.fit()
} catch {
// 容器尺寸为 0 时 fit() 会抛异常,忽略即可
}
}
// 1. 等布局稳定后再测量(等下一帧,flex/grid 已完成计算)
requestAnimationFrame(fitSafely)
// 2. 等字体加载完成后再修正(自定义等宽字体会改变字符宽度)
document.fonts?.ready.then(fitSafely)
// 3. 保留 ResizeObserver(处理真实的容器尺寸变化)
const ro = new ResizeObserver(fitSafely)
ro.observe(el)对于 tab 切换、pane 从 display: none 变为可见的场景,还需要在 visibility effect 里显式 refit:
useEffect(() => {
if (isVisible) {
// 切换为可见后,box 尺寸可能未变,ResizeObserver 不会触发
requestAnimationFrame(fitSafely)
}
}, [isVisible])如果可见时需要重放缓冲输出(比如进度条内容),务必先 fit 再写入,否则内容会按旧的窄列宽渲染,reflow 也不会修正。
requestAnimationFrame(fit) 等布局稳定,document.fonts.ready.then(fit) 等字体就绪,再配合 ResizeObserver 处理真实尺寸变化。三者缺一不可。
附:字体族名称陷阱
使用 @fontsource-variable/<font> 时,注册的 family 名是 "JetBrains Mono Variable",而不是 "JetBrains Mono"。在 Terminal options 里填错字体名,会静默 fallback 到系统等宽字体,字符宽度不同,cols 仍然会偏。
// 错误:会静默 fallback
new Terminal({ fontFamily: 'JetBrains Mono' })
// 正确:使用 variable font 的实际 family 名
new Terminal({ fontFamily: 'JetBrains Mono Variable' })一句话外卖
FitAddon.fit() 改的是终端 canvas,不改容器 box——ResizeObserver 看不见这次变化,永远无法自愈初次的错误测量;用 rAF + fonts.ready 双延迟才能兜住两类时机误差。