
鸿蒙 PC Markdown 编辑器分栏阅读编辑区与预览区双向同步滚动Markdown 分栏模式把源码与渲染结果同时放在桌面窗口中。用户在左侧修改某一段右侧应尽量保持在同一阅读位置用户在右侧检查排版时左侧源码也应跟随。没有同步滚动的分栏只是两块相邻视图长文档中很快失去上下文。同步算法不稳定则更糟两边滚动事件互相触发页面会抖动、回弹甚至无法停在用户选择的位置。本文基于鸿蒙 PC Markdown 编辑器 OhMarkdown 的实现分析如何在 ArkWeb 内部完成双向比例同步用帧级锁阻止递归反馈并通过 ArkUI 开关把控制权交给用户。代码来自公开仓库 https://gitcode.com/VON-/codex_md_oh为什么同步逻辑放在 ArkWeb 内部OhMarkdown 的工具栏和工作台外壳使用 ArkUI源码编辑器和预览 DOM 位于同一个 ArkWeb 页面。滚动事件频率可能接近屏幕刷新率触控板惯性滚动还会在手指离开后持续产生事件。如果每个事件都通过 JavaScript Bridge 传到 ArkTS再由 ArkTS 调回另一个 DOM跨运行时调度会放大延迟也会增加事件乱序的机会。源码和预览既然共享同一个页面同步应该直接在 Web 内核中闭环。原生层只需要设置一个低频配置同步是否开启。这个边界符合一条通用原则高频连续交互应留在拥有实际视图的运行时原生业务层只管理用户意图和持久状态。Web 层只维护两个布尔值letsyncScrollEnabledtrue;letsynchronizingScrollfalse;syncScrollEnabled是产品配置决定是否同步。synchronizingScroll是瞬时重入锁表示当前目标视图的滚动由程序触发不应再次反向传播。两个状态不能合并关闭同步是用户长期选择重入锁只持续一帧。比例同步的数学模型源码与预览的总高度通常不同。Markdown 标题、段落间距、表格、图片和代码块会改变渲染高度因此不能直接把左侧scrollTop赋给右侧。OhMarkdown 使用可滚动范围的比例functionsynchronizeScroll(source:HTMLElement,target:HTMLElement):void{if(!syncScrollEnabled||currentMode!split||synchronizingScroll){return;}constsourceRangesource.scrollHeight-source.clientHeight;consttargetRangetarget.scrollHeight-target.clientHeight;if(sourceRange0||targetRange0){return;}synchronizingScrolltrue;target.scrollToptargetRange*(source.scrollTop/sourceRange);window.requestAnimationFrame((){synchronizingScrollfalse;});}设源码当前滚动位置为S源码可滚动范围为Rs预览可滚动范围为Rt目标位置就是Rt * S / Rs。可滚动范围不是scrollHeight而是scrollHeight - clientHeight。当滚动到底部时scrollTop最大只能到总高度减去视口高度若直接用总高度计算目标底部会永远差一个视口。比例法有几个优点。它与文档长度无关不需要把每个 Markdown 节点映射回源码行源码和预览都能从顶部连续移动到底部计算只包含常数次 DOM 读取和一次写入适合高频滚动。它也有明确缺点标题、图片或长代码块会造成局部高度差百分之五十的源码位置不一定等于语义上的第五十个百分位。Alpha 阶段先提供稳定比例同步后续再根据真实使用数据决定是否引入语义锚点。边界检查防止无意义计算同步函数首先检查三个条件。同步开关关闭时立即返回。纯源码和纯预览模式也不需要同步所以要求currentMode split。最后检查重入锁防止程序滚动再次触发反向同步。随后计算两个范围只要其中一个小于等于零就返回。短文档可能完全放进源码视口sourceRange为零预览也可能只有一行。此时除法会产生无效比例而用户也没有可同步的滚动。显式返回比依赖浏览器把NaN写入scrollTop更容易理解和测试。还可以进一步对比例做Math.min(1, Math.max(0, ratio))限制。正常浏览器会把scrollTop控制在有效范围当前实现不需要额外夹紧但若未来引入平滑动画、过度滚动或平台特有弹性效果显式限制能防止负值和超过一的瞬时比例进入另一侧。双向监听为什么会递归事件绑定非常直接editor.scrollDOM.addEventListener(scroll,()synchronizeScroll(editor.scrollDOM,preview),{passive:true});preview.addEventListener(scroll,()synchronizeScroll(preview,editor.scrollDOM),{passive:true});用户滚动源码时监听器设置预览scrollTop浏览器随后为预览派发scroll预览监听器又尝试设置源码。两边高度和像素取整不同反向计算后的源码位置可能比原值差一个像素于是再次触发事件。若没有重入控制这种反馈可能持续数轮在触控板高频事件下表现为抖动。OhMarkdown 在写目标位置前把synchronizingScroll设为true等下一次动画帧再释放。在同一渲染帧中由程序写入引发的目标滚动事件会看到锁并返回。为什么不是设置后马上恢复因为scroll事件不保证在赋值语句内部同步派发立即恢复可能让异步事件错过锁。为什么不是固定延迟一百毫秒固定延迟会吞掉用户紧接着在另一侧进行的真实滚动手感迟钝。requestAnimationFrame把锁生命周期与浏览器渲染节奏对齐是更合适的折中。监听器使用{ passive: true }表明不会在回调中调用preventDefault。浏览器可以更放心地处理滚动不必等待 JavaScript 判断是否阻止。对于桌面触控板和滚轮高频路径中的这类小约束会直接影响流畅度。用户滚动和程序滚动的竞争帧级布尔锁解决了最基本的递归却不是所有同步滚动问题的终点。假设用户在左侧滚动的同一帧立刻把指针移到右侧并开始滚动右侧第一个事件可能因为锁仍为true被忽略。这个窗口只有一帧通常不可察觉但它说明同步算法本质上在调解两个输入源。更复杂实现可以记录activeScrollSource、最近用户事件时间和指针所在区域让最后主动操作的一侧成为主控。也可以在wheel、触控板手势开始时切换主控滚动结束后释放。当前产品先使用更小的状态机因为模拟器与自动化路径没有出现明显竞争问题。工程上应先证明简单方案不足再引入多事件源仲裁。另一个细节是预览内容可能在滚动过程中重排。用户输入导致 Markdown 重新渲染右侧scrollHeight变化相同比例对应的新位置也会变化。当前编辑流程会重新生成预览下一次滚动事件再使用新范围。若需要在无滚动输入时也保持语义锚点可以在渲染前记录当前比例渲染后恢复图片异步加载则需要监听尺寸变化。当前离线预览禁止在线图片访问减少了一类异步布局漂移但本地图片仍可能影响高度。同步开关属于原生产品状态Web 内核暴露一个受限方法window.OhMarkdownEditor{// 省略其他接口setSyncScroll:(enabled){syncScrollEnabledenabled;}};ArkUI 侧将状态编码为 JavaScript 字面量privatesetEditorSyncScroll():void{this.runEditorScript(window.OhMarkdownEditor?.setSyncScroll(${JSON.stringify(this.syncScrollEnabled)}));}虽然布尔值手工拼接也不难统一使用JSON.stringify能保持 Bridge 参数编码规则一致。当页面onReady时原生层会重新发送当前开关ArkWeb 因系统回收而重建后不会悄悄回到默认值。分栏模式下工具栏显示按钮式 ToggleToggle({type:ToggleType.Button,isOn:this.syncScrollEnabled}){Text($r(app.string.sync_scroll))}.accessibilityText($r(app.string.sync_scroll)).onChange((isOn:boolean){this.syncScrollEnabledisOn;this.setEditorSyncScroll();})这个控件只在分栏模式出现因为纯源码或纯预览时开关没有即时效果。使用 Toggle 而不是普通命令按钮能明确表达持续的开关状态。设置了无障碍文本后读屏也能理解控件用途。关闭同步后两栏完全独立应用不会在用户明确关闭后仍做“智能”跟随。当前开关是工作台级状态不随文档会话切换。这样用户对浏览方式的选择在标签之间保持一致。如果未来把它持久化到应用首选项需要决定启动默认值、配置迁移和多窗口共享范围而不能简单写一个全局文件。分栏布局需要稳定的滚动容器同步算法依赖编辑器scrollDOM与预览#preview分别拥有独立滚动范围。CSS 使用 Grid 建立两列并对每个子项设置min-width: 0和min-height: 0#workspace[data-modesplit]{grid-template-columns:minmax(0,1fr)minmax(0,1fr);}#editor, #preview{min-width:0;min-height:0;}#preview{display:none;overflow:auto;}#workspace[data-modesplit] #preview{display:block;}Grid 子项默认最小尺寸可能受内容影响长代码行会把列撑宽导致页面级滚动或预览被挤出窗口。minmax(0, 1fr)与min-width: 0允许两列在可用空间内真正收缩确保滚动发生在预期容器而不是整个 ArkWeb 页面。窄窗口切换为上下两行media(max-width:760px){#workspace[data-modesplit]{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)minmax(0,1fr);}}同步算法不关心横向还是纵向排列因为它只读取两个容器的垂直范围。自动化测试把视口设置为 720×800断言分栏变为一列两行且两侧都可见。鸿蒙 PC 自由窗口可能被用户缩到较窄宽度布局断点不是手机适配附属项而是桌面窗口能力的一部分。鸿蒙 PC 模拟器中的分栏同步下图来自 MateBook Pro 2in1 模拟器。左侧源码与右侧预览同时显示标题Title和Target工具栏的Sync处于开启状态。截图中的搜索面板仍可使用说明同步滚动不是独占模式而是与查找、标题和多标签共同工作的基础交互。视觉一致并不能单独证明双向同步。自动化测试构造 120 个章节让两侧都产生足够滚动范围然后把源码滚到底部并主动派发事件awaitpage.locator(.cm-scroller).evaluate((element){element.scrollTopelement.scrollHeight;element.dispatchEvent(newEvent(scroll));});awaitpage.waitForTimeout(100);expect(awaitpage.locator(#preview).evaluate((element)element.scrollTop0)).toBe(true);随后关闭同步把预览滚回顶部再断言源码仍保持大于零的位置awaitpage.evaluate(()(windowasunknownasEditorTestWindow).OhMarkdownEditor.setSyncScroll(false));awaitpage.locator(#preview).evaluate((element){element.scrollTop0;element.dispatchEvent(newEvent(scroll));});consteditorScrollTopawaitpage.locator(.cm-scroller).evaluate((element)element.scrollTop);expect(editorScrollTop).toBeGreaterThan(0);这组断言同时覆盖了正向同步与开关关闭语义。反向同步可用镜像步骤验证。真实模拟器测试还应使用触控板惯性滚动、滚轮、小幅拖动滚动条和快速换边操作因为合成scroll事件无法完全模拟输入设备节奏。比例同步和语义同步如何选择比例同步适合先建立稳定基线但渲染高度差大的文档会出现局部偏差。例如源码中的一行图片语法在预览中可能占几百像素图片之前比例仍接近图片之后会突然变化。语义同步通常把源码行映射到预览块节点在滚动时找到当前可见锚点再在另一侧插值。语义同步的代价包括渲染器要为节点保留源码行映射列表、引用、表格等嵌套块需要定义锚点源码中间位置落在一个大代码块内时要计算块内比例每次编辑后映射会变化虚拟滚动或大文档模式还会改变可见节点。对于 Alpha 产品如果没有证据表明比例法阻碍主要写作任务直接实现复杂语义映射可能得不偿失。可行的演进路径是混合策略普通滚动使用比例遇到可映射标题时吸附到最近标题锚点或者只在用户停止滚动后进行一次低频校正。还可以统计两个视图中当前标题是否一致以数据判断偏差是否值得优化。重要的是保留setSyncScroll接口和独立同步函数使算法可以替换而不影响 ArkUI 工具栏与编辑器其他能力。大文档与性能边界OhMarkdown 对五兆字符以上文档启用大文档保护模式并强制停留在源码视图。原因不是同步乘除法昂贵而是 Markdown 全量渲染、DOM 数量和预览更新可能造成明显内存压力。没有预览就没有同步滚动函数通过currentMode ! split自然退出。在普通文档中每个滚动事件读取两组scrollHeight/clientHeight并写一次scrollTop。读取布局属性可能触发布局计算尤其当 DOM 同时发生变化时。后续性能分析应在长表格、代码块和图片文档上记录帧时间而不是只测空白文本。若出现布局抖动可以在 ResizeObserver 中缓存范围在动画帧中合并多次滚动事件或采用requestAnimationFrame调度实际写入。当前实现已用帧锁避免反馈但没有显式节流源事件。浏览器通常会把滚动与渲染帧协调是否增加节流应以鸿蒙 PC 真机数据为准。过早加入定时器可能让预览落后于触控板破坏跟手感。结语双向同步滚动不是一句“监听 scroll 再赋值”就结束。可靠实现需要选择正确的运行时边界使用可滚动范围而非总高度计算比例处理短文档除零阻止程序滚动反向反馈保留用户关闭同步的权利并确保分栏布局拥有稳定的独立滚动容器。OhMarkdown 当前使用很小的算法完成了可测试闭环源码到预览、预览到源码、同步开关和窄窗口布局都有明确行为。它没有假装比例同步能解决全部语义对齐但为后续锚点映射保留了清晰替换点。对于鸿蒙 PC 桌面编辑器这种先把高频交互做稳、再根据真实文档改进精度的路线比一次性堆叠复杂映射更可控。