Files
terminal-lab/pxterm/mobile-scroll-optimization.md
2026-03-04 13:25:26 +08:00

6.6 KiB
Raw Permalink Blame History

移动端 Xterm.js 滚动优化总结 (Mobile Scroll Optimization)

1. 之前的问题现象 (The Problem)

在移动端(特别是 iOS Safari/WebKitxterm.js 终端界面中,用户上下滑动时经常会遇到 卡顿突然冻结。具体的表现是:手指在屏幕上持续滑动,但屏幕内容不再发生滚动。

2. 根本原因 (Root Cause)

通过在 window 级别捕获全局手指移动日志,我们发现了导致“屏幕结冰”的确切原因:

  1. 浏览器原生手势劫持 (Native Gesture Hijacking)移动端浏览器OS 级别)默认会接管屏幕上的 touch 事件,用于判断是否需要进行页面滚动、双击放大或边缘返回等原生手势。
  2. touchmove 事件被吞噬xterm.js 处于 DOM 渲染模式时,内部生成了大量的 spandiv 节点。当手指在这些复杂的层叠节点上滑动时,系统的手势识别引擎一旦认定这是系统的原生滚动行为,就会强制中断并停止派发 JS 层的 touchmove 事件(或者派发 touchcancel),导致我们原有的滚动计算逻辑中途得不到坐标更新,画面即刻冻结。

3. 演进与精细化方案 (Evolution & Refined Solution)

单纯使用“防劫持猛药”能解决滚动断流问题,但会导致一个严重的副作用:iOS 原生的长按文本选择功能(放大镜与高亮选区)会失效。 为此,我们抛弃了全局霸道的 touch-action: none 与过早的 preventDefault转而使用“动态拦截Dynamic Interception”组合拳

A. 撤销 CSS 暴力禁用,依赖 JS 动态阻止

不要在终端交互区强加 touch-action: none !important;。必须保留浏览器默认的触摸能力,否则底层 OS 根本不会触发“长按开启选区”的计时器。

B. Touch Start 阶段:坚决放行 (Allow to Timer)

touchstart 甚至 pointerdown 阶段,绝对不再调用 preventDefault()

  • 作用:确保手指刚摸到屏幕时,原生浏览器可以“观察”这次触屏是否可能变成一次“长按唤醒放大镜和选区”的操作。

C. Touch Move 阶段:精准拦截与驱动 (Precise Interception)

这是最核心的一步:在持续跟手的 touchmove 事件中,依靠 JS 来判断到底该阻止系统,还是让渡给系统。

  • 如果已经有系统选区 (hasActiveNativeSelectionInTerminal() === true) 说明用户正在拖拽选区的把手。此时代码不拦截,顺从原生的触摸动作,全权交由系统处理文本选取。
  • 如果没有选区 (false) 说明用户大概率在普通滑动。此时马上执行 event.preventDefault()强行掐断系统的原生滚动尝试,以防止 touchmove 被系统没收,并将 dy 直接交给我们的“自定义JS滚动”。

D. 自定义动量滚动 (Custom Momentum JS Scroll)

依赖于不被吞噬的连续 move 事件,我们实现了高性能的滚动接管:

  1. 跟手滑动:实时计算两次触屏事件的 dy (delta Y),直接映射 viewportScroller.scrollTop -= dy实现完全的1:1跟手。
  2. 惯性衰减 (Momentum):记录滑动撒手 (touchend) 瞬间的最后速度,将其转入 requestAnimationFrame 驱动的动画循环中进行指数级衰减,精美模拟原本仅有 OS 层才能给到的平滑阻尼滚动感受。

4. 最终达成的效果 (Final Results)

凭借这种高度克制且动态的干扰策略,我们实现了“鱼与熊掌兼得”:

  1. 完美原生选区:长按仍能顺畅唤起 iOS 文本放大镜与蓝色选框。
  2. 不断流的跟手滚动:只要没有进入选区模式,手指在密集的字符网格上狂滑也不会触发浏览器原生手势劫持,配合 60fps 的 JS 帧动画达到了极致丝滑。
  3. 软键盘完整兼容:没有使用全局焦点的强行剥夺,不阻碍日常的点击输入与键盘显隐。

5. 最后的幽灵 Bug触控目标为 SPAN 时滑动冻结 (The Phantom Bug: "SPAN" Target Blocking)

尽管我们实现了精密的动态拦截,但在实际使用中偶尔仍会触发严重的“屏幕冻结”。 通过全局侦听所有触摸和指针事件,我们抓到了极其关键的日志: 当手指正常滑动时:目标(Target) 是每一行的 DIVtouchmovepointermove 都能成对、连续触发,我们的 JS 动量接管运转完美。 当发生冻结时:目标(Target) 变成了渲染终端彩色字符的内联元素 SPAN,紧接着所有的 touchmove 事件就会离奇消失,只剩下 pointermove 在孤零零地触发。

为什么碰到 SPAN 就不动了?

这源于开启了 -webkit-user-select: textiOS Safari / WebKit 独有的文本触摸启发式逻辑 (Text Interaction Heuristic) 当手指触摸在一个内联文本节点(如 SPAN并且试图开始移动时iOS 极大概率会将其判定为“光标拖拽”或“准备框选”。为了不打断系统的文本交互iOS 会在非常底层的级别没收Swallow后续所有的 touchmove 事件。由于 touchmove 消失了,我们在 Vue 中原本用于拦截默认行为和计算 dy 滑动的代码根本没有执行的机会。于是表现为彻底断流结冰。

解决方案:视觉层穿透 (CSS Pointer-Events)

我们不能取消文本选择,否则长按复制功能就没了。但其实用户长按选中某行字,并不需要精确点击到那个细小的 SPAN,只要点击到包围它的块级元素 DIV 即可iOS 支持依靠容器进行坐标选取)。

因此,最有效的解法是对所有终端内容层的 SPAN 施加“触控隐身”:

/* 规避 iOS 触摸事件吞噬问题:
   当目标是 span 等特定内联元素时iOS 容易在滑动中没收 touchmove。
   将其鼠标事件穿透到 div (行) 上可以绕过引发吞没的 WebKit 内核特定文本触摸启发式逻辑
   同时并不影响基于坐标的文本选择。 */
.terminal-container.native-touch-selection .xterm-screen span {
  pointer-events: none;
}

影响评估:

  • 滑动修复:用户手指摸到的永远只有块级的 DIViOS 将其视为普通面板,从而持续派发我们亟需的 touchmove。彻底解决偶发冻结问题。
  • 选区保持:包围层 .xterm-rows > div 仍具有 user-select: text,系统原生长按动作(包括双端拖拽把手)依靠父级空间坐标寻找文本,完全不受影响。
  • xterm 功能xterm.js 其内部功能(如链接点击)通过捕捉整层的视口相对坐标映射到网格系来处理,也不受丢失底层 SPAN target 的影响。