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

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



所有评论(0)