编辑器双栏滚动同步实战:从比例同步到行块锚定(鸿蒙 ArkWeb 预览闪烁根治)
文章目录
编辑器双栏滚动同步实战:从比例同步到行块锚定(鸿蒙 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 特有。
分析定位
这场仗的方法论含量最高,把系列前文的取证手段全用上了:
- monkey-patch 抓写入栈:给滚动容器的 scrollTop 打补丁,谁在写滚动值、写了多少,全被记录——靠它抓到渲染框架内部的滚动锚定在偷偷补偿(一次 +818px);
- 读数对照:同一行的几何,内部高度表报 3088px、DOM 实测 349.5px,差 2738px——视口外的行用的是估计高度,任何基于估计几何的滚动都会发散;
- 状态快照:全量同步的瞬间,右侧 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 复现不出来的问题,取证基建决定破案速度 |
| 增量同步重构 | 一次成型,三断言全绿 | 与单栏输入路径同构 = 借用了久经考验的路径 |
| 产品语义(可见不动/出视口才滚) | 用户预期先行,技术跟随 | 交互的"稳定感"是需求,不是附带效果 |
五、三条心得
- 同步机制的选择要跟着"内容等价性"走:两侧高度不等价时,一切比例式同步都是错的;按内容单元锚定才对齐用户心智;
- 渲染引擎不是中立管道:它会锚定、会钳制、会有中间态帧——先摸清它的"自作主张",再谈自己的同步逻辑;
- 取证基建是长跑的本钱:插桩日志、写入栈补丁、DOM 身份标记,这些手段凑齐后,十五个版本的暗问题三天收口。
如果你在做编辑器或双栏应用,或者想看 MarkPin 后续,关注专栏。
更多推荐




所有评论(0)