编辑器双栏滚动同步实战:从比例同步到行块锚定(鸿蒙 ArkWeb 预览闪烁根治)

摘要:本文复盘 MarkPin 编辑器双栏滚动同步的三场硬仗:① 跟随——比例同步因两侧内容高度不等价必然错位,改为按内容行块锚定、完全可见即不动;② 对齐——点右栏定位左栏但不夺焦点,并用一次性抑制标志解决回环;③ 闪烁——ArkWeb 特有、Chrome 复现不出的每字符闪跳,经十五个版本取证(monkey-patch 抓写入栈、读数对照、状态快照)锁定为全量重建的中间态帧,最终以增量同步通道根治。文末附能力边界表与三条心得。

双栏是所见即所得编辑器的标配:左边源码编辑,右边实时预览。用户的期望也很朴素——左边编辑时右边跟着走,点右边时左边对得上,纯滚动时两边互不打扰。实现这三句话,MarkPin 打了三场硬仗,其中第三场(每字符闪烁)从立项到闭环横跨十五个取证版本。这篇按仗讲。

先交代一个有意思的背景:问题日志里这个功能最早有一条"滚动联动已实现"的记录。排查时全源码检索,零命中——那条实现已经消亡了(大概率在早期重构中被丢弃但没更新记录)。教训顺带说一句:文档里"已实现"三个字,要以代码为准。

一、第一仗:跟随——滚动比例同步是死路

分析定位

最初的方案是行业直觉:按滚动比例同步——左边滚到 30%,右边也滚到 30%。但做一个长文档实验就能证伪它:两侧内容高度不同(渲染块的占高远大于源码行高,表格/代码卡片/图片都是"膨胀块"),比例同步在两侧总高不一致时必然错位。左侧 30% 对应的标题,右侧 30% 可能是另一个章节。

正确抽象是按"内容行块"锚定:编辑器光标在某个源码行,就保证右侧对应的渲染块可见。跟随的触发判定还迭代过一版:最初用"视口上下各留 15% 边缘带,光标行块进入边缘带就滚"——实测连续输入时反复触发滚动,用户感觉"位置不固定"。修订为完全可见即不动(含 2px 容差):光标行块完整在视口内就一步不动,移出视口才按规则滚(普通块滚到居中,超高块顶部对齐)。

修复代码

// editor-build/src/mode/modeController.ts(真实代码,节选)
const FOLLOW_VISIBLE_TOLERANCE_PX = 2;

function followMirrorCursor(pos: number): void {
  const mirror = mirrorView;
  if (mirror === undefined || currentMode !== 'dual') return;
  mirror.requestMeasure({
    read: (): boolean => {
      const coords = mirror.coordsAtPos(pos);
      if (coords === null) return true;
      const box = mirror.scrollDOM.getBoundingClientRect();
      const vpTop = coords.top - box.top;
      const vpBottom = coords.bottom - box.top;
      // 完全可见即不动(2px 容差);出视口才需要滚
      return vpTop < -FOLLOW_VISIBLE_TOLERANCE_PX || vpBottom > mirror.scrollDOM.clientHeight + FOLLOW_VISIBLE_TOLERANCE_PX;
    },
    write: (needScroll: boolean): void => {
      if (!needScroll) return;
      // write 处于渲染框架的度量流程内,此阶段禁止派发更新(实测连续报错)——推迟到下一帧
      requestAnimationFrame((): void => {
        const block = mirror.lineBlockAt(pos);
        const tall = block.bottom - block.top > mirror.scrollDOM.clientHeight * FOLLOW_TALL_BLOCK_RATIO;
        mirror.dispatch({
          effects: EditorView.scrollIntoView(pos, { y: tall ? 'start' : 'center' })
        });
      });
    }
  });
}

两个实现细节值得划线:读和写分相——"要不要滚"在度量阶段读几何,"执行滚动"推迟到下一帧,因为度量流程内派发更新会被引擎拒绝(实测连续报错);超高块顶对齐——占高超过视口 70% 的块(大表格/长代码卡片)如果按居中滚,用户看到的是块的中间,上下文全丢,所以顶对齐留出边缘带。

二、第二仗:对齐——点右边,左边要对得上

分析定位

反向联动(点右栏 → 左栏定位)的设计决策点不在技术在产品:点击右侧后,焦点要不要夺过来?夺了焦点,用户就没法继续在右栏拖选复制;不夺,就要保证后续左栏输入直接落在定位处。最终拍板:定位但不夺焦点,与复选框点击等既有跨栏交互保持同一通路语义。

另一个必须处理的细节是回环:右→左联动会改变左栏光标,而左栏光标变化又会触发第一仗的"左→右跟随"——右栏会被再次滚动一次(点击处可能落在边缘带内)。解法是一次性抑制标志:由镜像联动引发的左栏事务,跳过一次反向跟随。

修复代码

// editor-build/src/mode/modeController.ts(真实代码,节选)
const suppressMirrorFollow = { value: false };  // 一次性抑制(示意,源码为模块级标志)

function followMainTo(pos: number): void {
  const main = mainView;
  if (main === undefined || currentMode !== 'dual') return;
  suppressMirrorFollow.value = true;                    // 前置回环抑制
  main.dispatch({ selection: { anchor: pos } });        // 光标定位(不调 focus,不夺焦点)
  main.dispatch({ effects: EditorView.scrollIntoView(pos, { y: 'center' }) });
}

三、第三仗:闪烁——十五个版本的取证长跑

现象

双栏编辑时,每打一个字,右栏闪跳一次,位置不固定。Chrome 上复现不出来——又是 ArkWeb 特有。

分析定位

这场仗的方法论含量最高,把系列前文的取证手段全用上了:

  1. monkey-patch 抓写入栈:给滚动容器的 scrollTop 打补丁,谁在写滚动值、写了多少,全被记录——靠它抓到渲染框架内部的滚动锚定在偷偷补偿(一次 +818px);
  2. 读数对照:同一行的几何,内部高度表报 3088px、DOM 实测 349.5px,差 2738px——视口外的行用的是估计高度,任何基于估计几何的滚动都会发散;
  3. 状态快照:全量同步的瞬间,右侧 DOM 高度塌缩(3104.5 → 2704.5px),被浏览器钳制滚动位置——这就是闪烁的中间态来源。

结论链条:每字符全量重建右栏(setState 派生新状态)→ DOM 全量重建存在中间态渲染帧 → 高度塌缩被浏览器钳制 + 内部滚动锚定补偿 → 闪烁与漂移

修复代码

根治方案是同步通道增量化:不再每字符全量派生重建,而是把左侧的变更增量转发给右栏——只重建变化的行,与单栏模式的输入路径同构(该路径长期无闪烁,本身就是佐证):

// editor-build/src/mode/modeController.ts(增量同步,节选示意,真实逻辑)
// §3.37 修订:镜像同步通道增量化——dispatch changes 只重建变化行
if (update.docChanged) {
  try {
    mirror.dispatch({
      changes: update.changes,                    // 增量变更转发
      selection: keepMappedSelection(),           // 镜像选区经映射保留
      scrollIntoView: false
    });
  } catch (e) {
    fullResyncMirror();                           // 异常降级全量派生自愈
  }
}

配套两笔:全量派生退化为"仅镜像创建时 + 异常自愈"两个调用点;切入双栏时按左栏光标对齐一次(消除切换后的残值位置)。跟随判定同步改为"完全可见即不动"(第一仗的修订)。

验证

增量性断言很有说服力:65 行文档连续输入 5 个字符,右栏 65/65 行的 DOM 身份全部保持(用弱引用集合标记验证,重建数 = 0),滚动位置全程恒定——不重建,就不闪。加上光标跳文档头时一步滚达、可见时连续输入粘性不动,三断言全绿,装机确认闭环。

四、能力边界表

事项AI 表现我的结论
方案选型(比例同步 vs 行块锚定)初版直觉是比例同步,实验证伪后转向"两侧总高不同"是可实验证伪的假设,先做实验再定方案
内部滚动锚定等暗行为靠 monkey-patch 抓到引擎会"好心"补偿你的滚动,防御式写回要与它博弈
闪烁根因(全量重建中间态)十五个版本取证收敛Chrome 复现不出来的问题,取证基建决定破案速度
增量同步重构一次成型,三断言全绿与单栏输入路径同构 = 借用了久经考验的路径
产品语义(可见不动/出视口才滚)用户预期先行,技术跟随交互的"稳定感"是需求,不是附带效果

五、三条心得

  1. 同步机制的选择要跟着"内容等价性"走:两侧高度不等价时,一切比例式同步都是错的;按内容单元锚定才对齐用户心智;
  2. 渲染引擎不是中立管道:它会锚定、会钳制、会有中间态帧——先摸清它的"自作主张",再谈自己的同步逻辑;
  3. 取证基建是长跑的本钱:插桩日志、写入栈补丁、DOM 身份标记,这些手段凑齐后,十五个版本的暗问题三天收口。

如果你在做编辑器或双栏应用,或者想看 MarkPin 后续,关注专栏。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐