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

66 lines
6.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 移动端 Xterm.js 滚动优化总结 (Mobile Scroll Optimization)
## 1. 之前的问题现象 (The Problem)
在移动端(特别是 iOS Safari/WebKit`xterm.js` 终端界面中,用户上下滑动时经常会遇到 **卡顿****突然冻结**。具体的表现是:手指在屏幕上持续滑动,但屏幕内容不再发生滚动。
## 2. 根本原因 (Root Cause)
通过在 `window` 级别捕获全局手指移动日志,我们发现了导致“屏幕结冰”的确切原因:
1. **浏览器原生手势劫持 (Native Gesture Hijacking)**移动端浏览器OS 级别)默认会接管屏幕上的 `touch` 事件,用于判断是否需要进行页面滚动、双击放大或边缘返回等原生手势。
2. **`touchmove` 事件被吞噬**`xterm.js` 处于 DOM 渲染模式时,内部生成了大量的 `span``div` 节点。当手指在这些复杂的层叠节点上滑动时,系统的手势识别引擎一旦认定这是系统的原生滚动行为,就会**强制中断并停止派发 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) 是每一行的 `DIV``touchmove``pointermove` 都能成对、连续触发,我们的 JS 动量接管运转完美。
**当发生冻结时**:目标(Target) 变成了渲染终端彩色字符的内联元素 `SPAN`,紧接着**所有的 `touchmove` 事件就会离奇消失**,只剩下 `pointermove` 在孤零零地触发。
### 为什么碰到 `SPAN` 就不动了?
这源于开启了 `-webkit-user-select: text`iOS Safari / WebKit 独有的**文本触摸启发式逻辑 (Text Interaction Heuristic)**
当手指触摸在一个内联文本节点(如 `SPAN`并且试图开始移动时iOS 极大概率会将其判定为“光标拖拽”或“准备框选”。为了不打断系统的文本交互iOS 会在非常底层的级别**没收Swallow后续所有的 `touchmove` 事件**。由于 `touchmove` 消失了,我们在 Vue 中原本用于拦截默认行为和计算 `dy` 滑动的代码根本没有执行的机会。于是表现为彻底断流结冰。
### 解决方案:视觉层穿透 (CSS Pointer-Events)
我们不能取消文本选择,否则长按复制功能就没了。但其实用户长按选中某行字,并不需要精确点击到那个细小的 `SPAN`,只要点击到包围它的块级元素 `DIV` 即可iOS 支持依靠容器进行坐标选取)。
因此,最有效的解法是对所有终端内容层的 `SPAN` 施加“触控隐身”:
```css
/* 规避 iOS 触摸事件吞噬问题:
当目标是 span 等特定内联元素时iOS 容易在滑动中没收 touchmove。
将其鼠标事件穿透到 div (行) 上可以绕过引发吞没的 WebKit 内核特定文本触摸启发式逻辑
同时并不影响基于坐标的文本选择。 */
.terminal-container.native-touch-selection .xterm-screen span {
pointer-events: none;
}
```
**影响评估:**
- **滑动修复**:用户手指摸到的永远只有块级的 `DIV`iOS 将其视为普通面板,从而持续派发我们亟需的 `touchmove`。彻底解决偶发冻结问题。
- **选区保持**:包围层 `.xterm-rows > div` 仍具有 `user-select: text`,系统原生长按动作(包括双端拖拽把手)依靠父级空间坐标寻找文本,完全不受影响。
- **xterm 功能**xterm.js 其内部功能(如链接点击)通过捕捉整层的视口相对坐标映射到网格系来处理,也不受丢失底层 `SPAN` `target` 的影响。