返回博客

xterm.js 多面板布局切换后终端只填半宽——PTY resize 是个坑

做了一个桌面应用内嵌的构建监控页面:单面板 / 双面板 / 四面板三种布局用 CSS Grid 切换,每个格子里跑一个 xterm.js 实例,实时跟踪后台进程的输出。功能跑通以后,发现了一个令人发狂的表现:从双面板切回单面板,宽大的单格子里终端内容只占左边一半,行还在按窄列数换行。term.cols 卡在了旧布局的值上。

现象

  • 切换布局后,仍然可见的那个面板 cols 没有更新,内容窄显在宽格子里。
  • 切走再切回,或者关掉再打开,终端才能恢复正常——也就是说靠「激活状态变化」触发的 re-fit 没覆盖到这种场景。
  • ResizeObserver 按理会在格子尺寸变化时触发,但布局切换过渡期容器可能瞬间报零尺寸,或者根本不再二次触发,导致坏掉的 cols 没有自愈(详见 xterm-fitaddon-stuck-cols-no-resize-self-heal)。

根因

布局切换对「一直可见的面板」来说不是任何已有 fit 触发器的触发条件。

现有的 fit 触发路径通常只有两条:

  1. 组件挂载时 fit 一次。
  2. active / visible prop 从 false → true 时再 fit(面板被激活)。

双面板 → 单面板,左侧面板全程 active,active prop 没变,路径 2 不触发;路径 1 只在挂载时跑一次。CSS 宽度确实变了,但 ResizeObserver 在过渡期间拿到的是过渡帧(甚至零尺寸),fit 之后 cols 依然是错的,而且不会再重试修正。

坑:想到了「同步 PTY 列数」来修这个 bug

看到 term.cols 停在旧值,第一反应是:直接在每次 fit 后把新 cols/rows 同步给后端 PTY,让生产者也按新宽度格式化。

这条路走不通——对只读监控来说是灾难性的。

MasterPty::resize() 会向进程发送 SIGWINCH。webpack / vite 的进度条用 \r 覆写的方式输出,收到 SIGWINCH 会重新 emit / reflow 进度行。每次布局切换就是一次 SIGWINCH,快速切几次就产出一批独立的进度行,把 xterm 5000 行 scrollback 刷满——真正的构建警告和报错全被顶掉了,用户看到的只有右移错位的进度条残骸。

症状很隐蔽:在没有 PTY resize 的版本上一切正常;Rust 侧的新 resize 命令上线后才复现,很容易把锅推给别的改动。

正解

修法纯在前端:显式订阅 layout 状态,在布局变化时主动 re-fit 可见面板。rAF 一帧等 Grid 完成重排再测量,fit 后还要把滚动条重新钉到底(reflow 之后 xterm 的 ydisp 和 DOM scroll position 会错位):

const layout = useStore((s) => s.layout)
const fitRef = useRef<FitAddon | null>(null)
const termRef = useRef<Terminal | null>(null)
const activeRef = useRef(false)  // 当前面板是否可见
 
const fitAndSync = useRef<() => void>(() => {})
fitAndSync.current = () => {
  const fit = fitRef.current, term = termRef.current
  if (!fit || !term) return
  const b = term.buffer.active
  const wasAtBottom = b.viewportY >= b.baseY   // 是否正在跟踪 tail?
  try { fit.fit() } catch { return }            // 容器零尺寸时忽略
  if (wasAtBottom) term.scrollToBottom()        // reflow 后重新钉底
}
 
// 关键:layout 变化时对可见面板主动 re-fit
useEffect(() => {
  if (!activeRef.current) return   // 隐藏面板在激活时自会 re-fit
  const id = requestAnimationFrame(() => fitAndSync.current())
  return () => cancelAnimationFrame(id)
}, [layout])

PTY 保持固定初始列数(如 cols: 120),永远不往后端发 resize。

正解:layout 变化 → rAF + fit.fit(),PTY 列数冻结
  • 只读监控不需要生产者按显示宽度格式化,xterm reflow 已够用。
  • 历史行里空格填充 / 光标寻址的输出(webpack 进度条)在任意宽度下都无法完美 reflow,这是终端的固有限制,换渲染器也解决不了。
  • 只在激活态的面板上触发 re-fitactiveRef.current 判断),隐藏面板等到它被激活时再 fit,避免白跑。

一句话外卖

多面板 xterm.js 里「PTY resize = 让终端更准确」是直觉陷阱;对只读监控,正确做法是前端订阅布局变化显式 re-fit,PTY 列数永远冻结。