返回博客

xterm.js FitAddon 列数卡死,ResizeObserver 无法自愈的根因

在一个内嵌 Web 终端的桌面应用里,某些 pane 里的终端列数死锁在 10-12 个字符宽,webpack 的 \r 滚动进度条输出像走楼梯一样换行堆叠。用 ResizeObserver 监听容器尺寸变化、再调 fit() 的代码已经写了,却完全没用——除非手动拖一下 pane 宽度,列数才能恢复正常,然后切换 tab 又回到窄列的状态。

现象

  • 终端容器明明足够宽,但终端只渲染 10-12 列
  • \r-based 输出(webpack 进度、 npm install 进度条)显示为阶梯形乱码
  • 部分 pane 正常,部分 pane 卡住——随机性,取决于初次渲染时机
  • 已有 ResizeObserverfit(),但卡住的 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(),此时:

  1. 布局尚未稳定——多个 pane 批量挂载时,CSS flex/grid 计算还没完成
  2. 字体尚未加载——用等宽自定义字体时,font fallback 的字符宽度与实际字体不同,导致 cols 计算偏小
  3. React StrictMode 双重挂载——第一次 mount 时布局未就绪,第二次才完成但 fit() 已经用第一次的错误尺寸锁定了
坑:ResizeObserver + 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 也不会修正。

正解:rAF + fonts.ready 双延迟,覆盖初次测量两类误差

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 双延迟才能兜住两类时机误差。