【五渣小Tips】拖个预览面板,怎么就把页面拖成 PPT 了?

0 阅读12分钟

最近在工作台里修了一个拖拽卡顿的问题。

功能很常见:成果库左边是一张文件表格,右边是文件预览,中间放一根可以左右拖动的分隔线。用户拖动分隔线,就能调整预览面板的宽度。

当时觉得这个需求应该很简单,不就是监听一下鼠标移动,然后改个宽度嘛,我愁什么。

然后打开一个稍微长一点的文本文件,拖了一下。

好家伙,鼠标已经到终点了,预览面板还在后面慢慢追,硬生生把一个拖拽效果做成了 PPT。

最后,在相同测试场景下,一次拖拽从约 9.4 秒降到了 1.87 秒,耗时减少约 80%

这篇文章就完整记录一下:这么一个看起来普普通通的拖拽,为什么会卡,以及我是怎么一步步找到真正原因的。

一、先来看一下这个需求

页面布局大概是这样的:

  • 左侧展示成果文件表格;
  • 右侧展示文件预览;
  • 中间有一根拖拽手柄;
  • 手柄向左拖,预览区域变宽;
  • 手柄向右拖,预览区域变窄;
  • 松手后需要记住宽度,下次打开继续使用。

普通文件看起来还好,但是当左侧表格有多行数据,右侧又打开一个长文本预览时,拖拽就开始明显卡顿。

我一开始很自然地怀疑到了右侧预览。

毕竟文本预览有语法高亮,1200 行文本会生成很多 DOM 节点。DOM 可以简单理解为浏览器页面中的元素结构,节点越多,浏览器处理布局和绘制时需要考虑的东西也就越多。

但怀疑归怀疑,还是得先想办法稳定复现。

一次拖拽同时触发事件、渲染、动画、布局和存储转存失败,建议直接上传图片文件

看起来只是拖动了一根线,背后却可能同时触发事件处理、React 渲染、动画计算、表格布局和本地存储。

二、不要靠手感优化,先搞一个能反复执行的复现

性能问题最怕的就是:

改完以后自己拖两下,感觉好像顺了一点。

这种“好像”基本不太可靠。今天电脑负载低一点,或者自己心里觉得改过了,都会让拖拽看起来更流畅。

当时真实后端正好不可用,开发页面返回了 HTTP 502。为了继续排查,我临时启动了一个最小模拟后端,只提供这次复现需要的数据:

  • 20 条成果文件记录;
  • 一个包含 1200 行内容的文本文件;
  • 页面启动所需的几个基础接口。

这样数据虽然是模拟的,但是页面仍然使用真实的成果库表格、真实预览组件和真实拖拽逻辑。

然后固定测试动作:

  1. 打开成果库;
  2. 打开一个 1200 行文本预览;
  3. 使用 120 个连续拖拽点;
  4. 将分隔线移动 180px;
  5. 记录整个拖拽动作的耗时。

第一次跑出来的结果约为 9.4 秒

这个数据不是什么严格的浏览器跑分,但是已经足够回答我们最关心的问题:每次修改以后,到底是真的变快了,还是我觉得它变快了。

三、一条 mousemove 里面,到底塞了多少东西

先来看一下原来的拖拽逻辑,大概长这样:

const handleMouseMove = (event: MouseEvent) => {
  const nextWidth = calculateWidth(event.clientX)

  setWidth(nextWidth)
  onResize?.(nextWidth)
  writeStoredWidth(storageKey, nextWidth)
}

逻辑非常直白:

  1. 根据鼠标位置算出新宽度;
  2. 更新 React 状态;
  3. 通知调用方宽度变了;
  4. 把宽度写进本地存储。

单独看每一步,好像都没什么问题。

问题在于,mousemove 是一个高频事件。鼠标移动的时候,浏览器会在很短的时间内连续触发大量事件。

也就是说,用户每移动一点点,页面都可能执行一次:

  • React 状态更新;
  • 组件重新渲染;
  • 动画宽度计算;
  • 左侧表格布局重算;
  • localStorage 写入。

localStorage 是浏览器提供的本地键值存储。它的接口是同步的,写入完成之前,当前这段 JavaScript 不会继续执行。

一次写入确实很快,但是架不住我们把它塞进每一次鼠标移动里啊。

而且用户真正需要保存的是松手后的最终宽度,中间经过的 501px、502px、503px,根本没有保存价值。

四、先列几个嫌疑人

为了避免看到第一处可疑代码就直接开改,我先列了几个可以验证的猜想:

  1. 每次鼠标移动都触发 React 更新和动画计算,是主要原因;
  2. 每次鼠标移动都同步写 localStorage,进一步放大了卡顿;
  3. 左侧成果表格在每次宽度变化时都重新计算布局;
  4. 成果库里的 ResizeObserver 在横向拖拽时重复更新了状态。

ResizeObserver 是浏览器提供的尺寸变化监听器。被监听元素的宽度或者高度变化时,它就会执行回调。

接下来我们一个一个来看。

五、先把一帧内的鼠标事件合并掉

浏览器通常每秒绘制大约 60 次画面,每次绘制周期一般叫作一帧。

假设浏览器在同一帧内收到了好几次 mousemove,页面其实没有必要把每一个中间宽度都渲染出来。

用户最终只能看到这一帧绘制出来的结果,所以我们只需要使用这一帧里最新的宽度。

这就可以用到 requestAnimationFrame

let animationFrame: number | null = null
let latestWidth = currentWidth

const commitLatestWidth = () => {
  animationFrame = null
  setWidth(latestWidth)
}

const handleMouseMove = (event: MouseEvent) => {
  latestWidth = calculateWidth(event.clientX)

  if (animationFrame === null) {
    animationFrame = requestAnimationFrame(commitLatestWidth)
  }
}

requestAnimationFrame 可以简单理解为:

浏览器准备画下一帧之前,帮我执行一下这个函数。

这样,同一帧里不管收到多少次鼠标移动事件,都只会提交一次最新宽度。

然后再把 localStorage 写入移动到 mouseup,也就是用户松开鼠标的时候:

const handleMouseUp = () => {
  setWidth(latestWidth)
  writeStoredWidth(storageKey, latestWidth)
  setIsDragging(false)
}

拖拽过程中只负责更新画面,松手以后再保存最终结果,这就合理多了。

优化前每个事件都触发完整链路,优化后按帧合并并只在结束时写入存储

左边是修改前,每个鼠标事件都会完整跑一遍。右边是按帧合并以后,一帧只处理一次,最后再写入一次存储。

六、怎么拖着拖着,事件监听器还反复注册起来了

继续往下看,又发现了一个问题。

拖拽事件是在 React Effect 里注册的,而 Effect 的依赖中包含两个回调:

[getContainerWidth, onResize, ...]

如果调用方每次渲染都会创建新的回调函数,React 就会认为依赖发生了变化。

于是,拖拽过程中每次更新宽度,都可能经历:

  1. 移除旧的 mousemovemouseup 监听器;
  2. 重新执行 Effect;
  3. 再注册一遍监听器。

这不就相当于一边拖拽,一边还在拆卸和安装拖拽逻辑嘛。

解决方式是使用 ref 保存最新回调:

const getContainerWidthRef = useRef(getContainerWidth)
const onResizeRef = useRef(onResize)

useEffect(() => {
  getContainerWidthRef.current = getContainerWidth
  onResizeRef.current = onResize
}, [getContainerWidth, onResize])

这里的 ref 可以理解为一个不会因为内容变化而触发组件重新渲染的容器。

拖拽期间,事件监听器可以一直保持稳定。真正需要执行回调时,再从 ref 里取出最新函数。

完成这轮修改以后,相同拖拽动作从约 9.4 秒降到了 2.26 秒

到这里其实已经快了很多,我拖了几下,体感上基本可以说修好了。

但是打开控制台一看:

Maximum update depth exceeded

怎么还有报错?

这条错误表示 React 检测到了一条持续的状态更新循环。虽然页面已经变快了,但是内部显然还有一条不太健康的更新链路。

那就继续找。

七、一个确实有问题,但不是最终根因的问题

成果库表格使用了 ResizeObserver,根据表格区域高度计算内部滚动区域。

原来的逻辑大概是:

const updateTableScrollY = () => {
  setTableScrollY(element.clientHeight - headerHeight)
}

拖拽改变的是元素宽度,但是 ResizeObserver 监听到宽度变化以后也会执行回调。

于是,每次横向拖动时,代码都重新读取了一次相同的高度,然后再设置一次只与高度有关的状态。

这就没什么必要了。

修改以后,先比较高度有没有真的发生变化:

let lastHeight = -1

const updateTableScrollY = () => {
  const height = element.clientHeight
  if (height === lastHeight) return

  lastHeight = height
  setTableScrollY(height - headerHeight)
}

这样,只有高度真的变化时,才更新表格滚动区域。纯宽度变化直接返回。

改完以后我又新启动了一份开发环境重新测试,结果控制台里的错误仍然存在。

所以,这里确实是一个问题,也应该修,但它不是最终根因。

八、duration: 0,真的等于没有动画吗?

继续往上追,最后看到了预览面板的动画逻辑。

面板打开和关闭时有一个宽度动画。为了让拖拽时不要有延迟,原来在拖拽状态下把动画时长设置成了 0

<motion.div
  animate={{ width }}
  transition={{ duration: isDragging ? 0 : 0.18 }}
/>

当时的想法也很自然:

动画时长都是 0 了,那不就相当于没有动画了吗?

实际上并不是。

duration: 0 只是表示动画不需要时间,但是每一次宽度更新仍然会进入动画引擎的处理流程。

高频拖拽的时候,动画组件会不断收到新的目标宽度,照样需要进行状态处理和调度,最后还形成了重复更新。

所以最终方案是把两种场景彻底分开:

<motion.div
  animate={isDragging ? undefined : {
    width: panelVisible ? width : 0,
  }}
  style={{
    width: isDragging ? width : undefined,
  }}
  transition={{ duration: 0.18 }}
/>

现在逻辑变成了:

  • 打开、关闭面板是低频操作,继续使用动画;
  • 用户拖拽是高频操作,直接通过普通样式修改宽度,不再经过动画引擎。

这才是真正解决问题的关键一步。

低频开合继续经过动画系统,高频拖拽绕过动画引擎直接更新宽度转存失败,建议直接上传图片文件

上面保留打开和关闭动画;下面的高频拖拽绕过动画引擎,松手后再提交最终状态。

九、最后的执行链路

flowchart LR
    A[高频 mousemove] --> B[记录最新宽度]
    B --> C{本帧是否已安排更新}
    C -- 是 --> D[等待下一帧]
    C -- 否 --> E[requestAnimationFrame]
    E --> F[直接更新拖拽视觉宽度]
    F --> G[表格只在高度变化时更新滚动区域]
    H[mouseup] --> I[提交最终 React 状态]
    I --> J[写入一次 localStorage]

最后,同样的 120 个拖拽点、移动 180px:

修改前:约 9.4 秒
第一轮优化后:约 2.26 秒
最终修复后:约 1.87 秒

整体耗时减少约 80%

更重要的是,在全新启动的开发实例里,拖拽过程中也不再出现 Maximum update depth exceeded

十、改快了还不算完,行为也不能变

性能修复最怕的就是:

确实不卡了,因为功能也坏了。

所以最后又完整验证了一遍:

  • 拖拽方向正确;
  • 最小和最大宽度限制仍然生效;
  • 松手后会保存最终宽度;
  • 刷新页面后可以恢复上次宽度;
  • 面板打开和关闭仍然有动画;
  • 拖拽过程中不再进入动画引擎;
  • 成果库表格不会因为纯宽度变化反复更新高度状态;
  • ESLint、TypeScript 构建和相关回归测试全部通过。

同时增加了几条回归测试,把这次修复的关键约束固定下来:

  • mousemove 中必须使用 requestAnimationFrame 合并更新;
  • mousemove 中不能写入 localStorage
  • 最终宽度只能在松手时持久化;
  • 拖拽期间预览面板不能使用动画目标宽度;
  • 成果库应该忽略只有宽度变化的尺寸监听事件。

十一、这次排查记录下来的几个小经验

1. 高频事件里面,小操作也会变成大问题

单独调用一次 setState,或者写一次 localStorage,可能都很快。

但是如果它们出现在 mousemovescroll 这种高频事件里,执行次数很快就会被放大。

所以判断一段代码贵不贵,不能只看单次执行成本,还得看它一天要上多少次班。

2. duration: 0 不代表绕过动画系统

这个算是这次最值得记录的一点。

动画时长为 0,只代表它立刻完成,不代表更新不会进入动画组件。

拖拽、缩放这类高频交互,更适合直接更新视觉样式。打开、关闭、展开、收起这种低频状态切换,再交给动画系统。

3. 页面变快以后,别急着宣布胜利

第一轮修改以后,耗时已经从 9.4 秒降到了 2.26 秒,体感上确实已经流畅很多。

但控制台里的更新深度错误还在,这说明我们只是让一条不健康的更新链路跑得更快了。

报错没有消失,排查就还没有结束。

4. 关键结论最好在全新实例里再验证一次

开发环境中的热更新会保留部分组件状态,有时还会带来只在热更新期间出现的 Hook 顺序错误。

所以每次准备确认根因时,我都会重新启动一个全新开发实例,再完整执行一次测试流程。

这样才能区分:到底是代码真的有问题,还是开发环境留下来的历史现场。

5. 先想办法稳定复现,再开始猜

这次真正有用的并不是某一个 React 技巧,而是先固定了测试数据和拖拽路径。

有了稳定的反馈,才能看清楚:

  • 哪个修改真的有效;
  • 哪个修改只是顺手解决了附近的问题;
  • 哪个错误仍然存在;
  • 最终修复有没有解决用户最开始描述的卡顿。

总结

这么一个简单的拖拽,当时也算是遇到了几道坎。

一开始怀疑重型预览,后来发现高频事件里塞了太多工作;把耗时降下来以后,又发现动画引擎里还藏着一条重复更新链路。

最后的方案其实并不复杂:

  • 同一帧只提交一次更新;
  • 松手以后再持久化最终宽度;
  • 高频拖拽绕过动画引擎;
  • 只在业务真正关心的尺寸发生变化时更新状态。

不是什么多深奥的技术,也不是手写 React、实现动画引擎这种大题,就是工作中碰到的一个性能小问题,排查过程还挺有意思,记录一下。

就到这吧,我也该去看看还有哪个拖拽组件在偷偷写 localStorage 了。