在这里插入图片描述

鸿蒙 PC Markdown 编辑器滚动稳定性:避免双向同步递归抖动

双向同步滚动最大的风险不是目标位置稍有误差,而是左右两侧互相触发。源码滚动设置预览 scrollTop,预览产生 scroll事件又设置源码;两边高度和像素取整不同,反馈可能持续,表现为抖动、回弹和触控板失控。

本文基于 OhMarkdown,聚焦同步滚动的重入锁、动画帧释放、程序滚动与用户滚动竞争、模式和开关边界。代码位于 https://gitcode.com/VON-/codex_md_oh

反馈环如何产生

两个监听器:

editor.scrollDOM.addEventListener(
  'scroll',
  () => synchronizeScroll(
    editor.scrollDOM,
    preview
  ),
  { passive: true }
);

preview.addEventListener(
  'scroll',
  () => synchronizeScroll(
    preview,
    editor.scrollDOM
  ),
  { passive: true }
);

用户滚动 A,回调写 B;浏览器为 B派发事件,回调再写 A。若转换完全可逆且浏览器不派发相同值事件,可能很快停止,但不能依赖。高度比例乘除、浮点和整数像素都会产生差值。

一个布尔重入锁

let synchronizingScroll = false;

function synchronizeScroll(
  source: HTMLElement,
  target: HTMLElement
): void {
  if (!syncScrollEnabled ||
    currentMode !== 'split' ||
    synchronizingScroll) {
    return;
  }
  // 计算范围
  synchronizingScroll = true;
  target.scrollTop = targetRange *
    (source.scrollTop / sourceRange);
  window.requestAnimationFrame(() => {
    synchronizingScroll = false;
  });
}

源事件进入时锁为 false,写目标前设 true。目标产生的回调看到 true立即返回。锁不区分方向,因为同一帧只允许一侧作为源。

锁是 Web页面级状态,不通过 Bridge。scroll可能每帧多次发生,跨 ArkTS会增加延迟和乱序。

为什么不能立即释放

错误写法:

synchronizingScroll = true;
target.scrollTop = next;
synchronizingScroll = false;

scroll事件可能在赋值后的任务或渲染阶段派发,立即释放时目标回调已经看不到锁。requestAnimationFrame让锁保持到下一次绘制,覆盖同一帧的程序滚动反馈。

固定 setTimeout 100ms又太长,会吞掉用户快速转到另一侧的真实操作。动画帧约束与视觉更新节奏一致,是更小干预。

为什么监听器是 passive

{ passive: true }声明不会 preventDefault。浏览器处理滚轮和触控板时无需等待 JavaScript决定是否阻止滚动。同步只观察结果并设置另一容器,不需要取消源输入。

若未来要建立主控侧并拦截某些手势,不能随意改为非 passive;应在 wheel和 scroll职责之间分层,避免高频路径阻塞。

比例计算的边界

const sourceRange =
  source.scrollHeight - source.clientHeight;
const targetRange =
  target.scrollHeight - target.clientHeight;
if (sourceRange <= 0 || targetRange <= 0) {
  return;
}
target.scrollTop = targetRange *
  (source.scrollTop / sourceRange);

使用可滚动范围而非总高度,底部才能对应底部。任一范围为零说明短内容无可滚动位置,避免除零和 NaN。

比例结果可能为小数,浏览器内部取整或保存亚像素;反向计算不一定回原值,正是需要锁的原因。可额外夹紧0到1,但正常 scrollTop已被浏览器约束。

模式边界防止隐藏视图参与

只有 currentMode === 'split'同步。源码模式预览隐藏,预览模式编辑器隐藏;隐藏容器尺寸可能为零,参与同步没有意义。模式切换后旧 scroll事件若到达,也会被条件拒绝。

大文档保护强制 source,因此自动退出同步。同步乘法本身便宜,昂贵的是大文档预览 DOM,不能因为有同步功能而绕过保护。

用户开关优先

syncScrollEnabled=false立即返回。ArkUI Toggle通过 setSyncScroll设置:

setSyncScroll: (enabled) => {
  syncScrollEnabled = enabled;
}

关闭后两侧独立,程序不做“智能恢复”。用户可能要对照文档不同章节,开关是持续偏好而非一次命令。

重新开启时当前实现不立即对齐,要等下一次滚动。这样不会突然跳动;若产品希望即时对齐,应明确以哪侧为源。

用户快速换边的竞争

帧锁可能忽略同一帧内用户在目标侧的第一个事件。通常小于一帧不可察觉,但说明布尔锁不是完整输入仲裁。触控板惯性滚动还可能在用户开始另一侧时继续产生旧侧事件。

更强方案记录 activeSource与最近 wheel时间。wheel事件代表用户意图,程序只同步另一侧;一段静默后释放主控。也可根据鼠标所在容器选择源。引入前必须用真机证明当前锁不足,否则复杂状态机会制造新卡顿。

渲染重排与滚动

Markdown输入会重建预览 DOM,scrollHeight改变。若重排恰好发生在滚动帧,比例基于新旧高度组合可能跳动。当前 renderPreview同步执行,后续 scroll读取完成后的布局值;图片异步加载仍可能再改变高度。

在线图片被禁用,减少不确定重排。本地图片、字体和表格仍可能变化。可用 ResizeObserver在预览尺寸变化时记录并恢复比例,但恢复写入同样要使用重入锁。

程序跳转与同步的关系

点击大纲调用 CodeMirror scrollIntoView,会产生编辑器 scroll事件。在分栏且同步开启时,预览会跟随到相同比例,这是合理默认。但标题跳转若未来直接滚动两侧,就会与通用监听重复,需要标记程序来源。

搜索定位也会滚动源码并带动预览。用户可能希望预览跟随当前匹配。所有程序滚动复用相同抑制机制,不能各自写一个局部布尔导致互相不知道。

自动化验证关闭语义

测试构造120个章节,让两边都有滚动范围。把源码滚到底并派发 scroll,等待一帧后断言预览大于零。随后关闭同步,把预览设为0,断言源码位置仍大于零。

await page.evaluate(() =>
  host.OhMarkdownEditor.setSyncScroll(false)
);
await page.locator('#preview').evaluate((element) => {
  element.scrollTop = 0;
  element.dispatchEvent(new Event('scroll'));
});
expect(await page.locator('.cm-scroller')
  .evaluate((element) => element.scrollTop))
  .toBeGreaterThan(0);

还应统计事件次数,确保一次源滚动不会产生长反馈链;用 Playwright暴露计数器或开发构建诊断钩子,比肉眼判断抖动更稳定。

鸿蒙 PC 触控板验证

下图来自 MateBook Pro 2in1模拟器,分栏和 Sync开关处于开启状态。真实验证需要分别滚动源码、预览,快速换边并关闭开关。

在这里插入图片描述

模拟器鼠标滚轮不能完全代表物理触控板惯性。最终应在鸿蒙 PC真机采集长文档滚动帧、事件次数和主观跟手感,尤其测试高刷新率屏幕。

语义同步不会消除递归

将比例算法升级为标题锚点或源码行映射,可以提高局部准确度,但目标赋值仍会触发 scroll。无论算法多智能,都需要来源抑制。语义映射还可能在锚点切换时产生不连续跳变,更需要把用户源和程序目标区分。

因此重入控制应与位置算法解耦。synchronizeScroll未来可替换目标计算,锁和开关边界继续复用。

性能诊断

高频回调读取 scrollHeight/clientHeight可能触发布局。若性能数据出现问题,可在 ResizeObserver缓存范围,在 requestAnimationFrame中合并多次源事件,只应用最后位置。不能用长防抖,因为滚动会明显落后。

指标包括每秒 scroll回调、每帧目标写次数、长任务、掉帧、源目标位置误差。日志不涉及正文,可在开发构建采集。

当前边界

布尔锁没有区分用户与程序源;没有主动侧仲裁;比例同步存在局部语义偏差;图片重排未校正;真机触控板压力尚未完成。当前自动化与模拟器已覆盖双向、关闭和基本稳定性。

建议的压力语料

同步滚动不能只测纯文本重复行。技术文档应混合短段落、长段落、一级到六级标题、表格、引用、任务列表、超宽代码块和不同高度本地图片。源码中的一行图片语法会在预览占据数百像素,最容易暴露比例偏差;长代码块则能观察独立内部横向滚动是否误触发外层同步。

还要准备快速变化语料:持续输入让预览高度增加,在惯性滚动未停止时切换标签,在窗口缩放过程中滚动,从分栏切到源码再切回,开启和关闭 Sync后立刻换边。这些组合比一次从顶部拖到底部更接近真实桌面使用。

测试记录应包含源 scrollTop、源范围、目标 scrollTop、目标范围、锁状态和时间戳,但不记录正文。通过事件序列可以判断是比例误差、布局变化还是反馈重入,不必靠录屏逐帧猜测。

无障碍与减少动态效果

同步滚动是自动视图移动,对部分用户可能造成不适。应用提供显式 Toggle是必要基础;未来还可读取系统减少动态效果偏好,默认关闭平滑滚动或同步动画。当前实现直接设置位置,没有额外缓动,响应明确且减少持续动画。

键盘 PageUp/PageDown、Home/End和大纲跳转同样会触发同步。焦点始终留在用户操作侧,目标侧只是视觉跟随,不能因为程序滚动抢走焦点。读屏用户关闭同步后,两侧应完全独立,不产生隐藏区域位置变化导致意外朗读。

与窗口自由缩放的组合

鸿蒙 PC窗口缩放会改变两侧 clientHeight和布局断点。宽度低于760像素时 Web分栏变成上下布局,两个可滚动范围会重新计算。锁只保护事件递归,不缓存旧范围,所以缩放后的下一次滚动使用新尺寸。

如果缩放本身触发 scroll事件,当前条件仍会同步。后续可在 ResizeObserver回调中短暂标记布局调整来源,避免用户没有滚动时位置来回变化;但不能长期锁住,否则缩放结束后的第一个真实事件会被忽略。窗口测试至少覆盖断点两侧反复跨越,而不是只在固定720宽启动一次。

结语

同步滚动稳定性的核心是一条反馈控制规则:程序写目标时锁住反向事件,并在下一动画帧释放。再配合 passive监听、可滚动范围、短内容检查、split模式和用户开关,简单比例算法才能稳定运行。

鸿蒙 PC编辑器可以以后改进语义对齐,但不能牺牲停止响应和用户控制。先消除递归抖动,再谈更聪明的同步,是高频桌面交互的正确工程顺序。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐