You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React+FabricJS自定义滚动条:缩放时滚动滑块闪烁问题求助

FabricJS自定义滚动条缩放时滑块闪烁问题解决

问题描述

为FabricJS实现了自定义滚动条(原生无此功能),基础功能正常,但缩放时滑块会出现严重闪烁。已尝试防抖和记忆化处理更新逻辑,问题仍未解决。

问题原因分析

  1. 重复绑定事件监听:直接在组件渲染逻辑中调用activeEditor?.canvas.on("mouse:wheel"),每次组件重渲染都会重复绑定事件,导致同一事件被多次触发,引发频繁状态更新
  2. 高频状态更新:mouse:wheel事件触发频率极高,每次触发都调用setScrollThumbsPos更新状态,导致组件频繁重渲染
  3. 无意义的DOM查询:每次滚轮事件中都直接获取DOM元素的clientHeight/clientWidth,触发浏览器回流,加剧视觉闪烁
  4. 未优化回调函数:事件处理函数未用useCallback包裹,每次渲染都创建新函数,间接导致组件重渲染

解决方案

1. 正确绑定/解绑Canvas事件

使用useEffect管理Canvas事件的生命周期,避免重复绑定:

useEffect(() => {
  if (!activeEditor?.canvas) return;

  const handleMouseWheel = (opt) => {
    opt.e.preventDefault();
    // 滚轮事件逻辑...
  };

  activeEditor.canvas.on("mouse:wheel", handleMouseWheel);
  // 组件卸载时解绑事件
  return () => {
    activeEditor.canvas.off("mouse:wheel", handleMouseWheel);
  };
}, [activeEditor, scrollThumbHeight, scrollThumbWidth]);

2. 使用requestAnimationFrame批量更新状态

将滑块位置更新逻辑放入requestAnimationFrame,合并高频触发的更新,减少重绘次数:

const handleMouseWheel = useCallback((opt) => {
  opt.e.preventDefault();
  const evt = opt.e;

  requestAnimationFrame(() => {
    const pointer = activeEditor?.canvas.getPointer(evt);
    const editorHeight = activeEditor?.originalSize.height;
    const editorWidth = activeEditor?.originalSize.width;

    if (!editorHeight || !editorWidth || !boxYRef.current || !boxXRef.current) return;

    // 缓存DOM尺寸,避免重复查询
    const scrollContainerHeight = boxYRef.current.parentElement.clientHeight;
    const scrollContainerWidth = boxXRef.current.parentElement.clientWidth;

    const newTopPercentage = (pointer.y / editorHeight) * 100;
    const newTop = (newTopPercentage / 100) * (scrollContainerHeight - scrollThumbHeight);
    const clampedTop = Math.min(Math.max(newTop, 0), scrollContainerHeight - scrollThumbHeight);

    const newLeftPercentage = (pointer.x / editorWidth) * 100;
    const newLeft = (newLeftPercentage / 100) * (scrollContainerWidth - scrollThumbWidth);
    const clampedLeft = Math.min(Math.max(newLeft, 0), scrollContainerWidth - scrollThumbWidth);

    setScrollThumbsPos((prevPos) => ({
      ...prevPos,
      top: clampedTop,
      left: clampedLeft,
    }));
  });
}, [activeEditor, scrollThumbHeight, scrollThumbWidth]);

3. 优化回调函数与依赖

用useCallback包裹所有事件处理函数,减少不必要的重渲染:

const handleYDragStart = useCallback((e) => {
  // 原拖拽逻辑
}, []);

const handleYDrag = useCallback((e) => {
  // 原拖拽逻辑
}, []);

const handleYDragEnd = useCallback(() => {
  // 原拖拽逻辑
}, []);

// X轴拖拽函数同理

4. 缓存DOM尺寸(可选)

如果滚动容器尺寸不会频繁变化,可以用useRef缓存尺寸,仅在必要时更新:

const scrollContainerSizeRef = useRef({ height: 0, width: 0 });

useEffect(() => {
  if (!boxYRef.current || !boxXRef.current) return;
  scrollContainerSizeRef.current = {
    height: boxYRef.current.parentElement.clientHeight,
    width: boxXRef.current.parentElement.clientWidth,
  };

  // 监听窗口resize更新尺寸
  const handleResize = () => {
    scrollContainerSizeRef.current = {
      height: boxYRef.current.parentElement.clientHeight,
      width: boxXRef.current.parentElement.clientWidth,
    };
  };
  window.addEventListener("resize", handleResize);
  return () => window.removeEventListener("resize", handleResize);
}, [activeEditor]);

修改后的完整代码

import { useMemo, useRef, useState, useCallback, useEffect } from "react";
import { useAtomValue } from "jotai";
import { zoomAtom } from "../Atoms";
import useEditorService from "@/hooks/useEditorService";

const CustomScroll = () => {
  const zoom = useAtomValue(zoomAtom);
  const { activeEditor } = useEditorService();

  const [editorPos, setEditorPos] = useState({ x: 0, y: 0 });
  const [scrollThumbsPos, setScrollThumbsPos] = useState({ top: 0, left: 0 });

  const boxYRef = useRef(null);
  const boxXRef = useRef(null);
  const scrollContainerSizeRef = useRef({ height: 0, width: 0 });

  useEffect(() => {
    if (!activeEditor) return;
    setEditorPos((p) => ({
      ...p,
      x: activeEditor.canvas.viewportTransform[4],
      y: activeEditor.canvas.viewportTransform[5],
    }));
  }, [activeEditor?.canvas?.viewportTransform]);

  // 缓存滚动容器尺寸
  useEffect(() => {
    if (!boxYRef.current || !boxXRef.current) return;
    const updateSize = () => {
      scrollContainerSizeRef.current = {
        height: boxYRef.current.parentElement.clientHeight,
        width: boxXRef.current.parentElement.clientWidth,
      };
    };
    updateSize();
    window.addEventListener("resize", updateSize);
    return () => window.removeEventListener("resize", updateSize);
  }, [activeEditor]);

  const minFitZoom = useMemo(
    () => activeEditor?.dimensions?.height / activeEditor?.originalSize?.height,
    [activeEditor?.dimensions, activeEditor?.originalSize]
  );

  const minHeight = 100;
  const scrollThumbHeight = useMemo(() => {
    return Math.max(
      ((5 - zoom) / 5) * (activeEditor?.dimensions.height || 0),
      minHeight
    );
  }, [zoom, activeEditor?.dimensions.height]);

  const customYScrollStyle = {
    display: minFitZoom < zoom ? "block" : "none",
  };

  const handleYDragStart = useCallback((e) => {
    // 原拖拽逻辑
  }, []);

  const handleYDrag = useCallback((e) => {
    // 原拖拽逻辑
  }, []);

  const handleYDragEnd = useCallback(() => {
    // 原拖拽逻辑
  }, []);

  const maxFitZoom = useMemo(
    () => activeEditor?.dimensions?.width / activeEditor?.originalSize?.width,
    [activeEditor?.dimensions, activeEditor?.originalSize]
  );

  const minWidth = 100;
  const scrollThumbWidth = useMemo(() => {
    return Math.max(
      ((5 - zoom) / 5) * (activeEditor?.dimensions.width || 0),
      minWidth
    );
  }, [zoom, activeEditor?.dimensions.width]);

  const customXScrollStyle = {
    display: maxFitZoom < zoom ? "block" : "none",
  };

  const handleXDragStart = useCallback((e) => {
    // 原拖拽逻辑
  }, []);

  const handleXDrag = useCallback((e) => {
    // 原拖拽逻辑
  }, []);

  const handleXDragEnd = useCallback(() => {
    // 原拖拽逻辑
  }, []);

  // 滚轮事件处理
  const handleMouseWheel = useCallback((opt) => {
    opt.e.preventDefault();
    const evt = opt.e;

    requestAnimationFrame(() => {
      if (!activeEditor?.canvas || !editorPos) return;
      const pointer = activeEditor.canvas.getPointer(evt);
      const editorHeight = activeEditor.originalSize.height;
      const editorWidth = activeEditor.originalSize.width;
      const { height: scrollContainerHeight, width: scrollContainerWidth } = scrollContainerSizeRef.current;

      if (!editorHeight || !editorWidth || !scrollContainerHeight || !scrollContainerWidth) return;

      const newTopPercentage = (pointer.y / editorHeight) * 100;
      const newTop = (newTopPercentage / 100) * (scrollContainerHeight - scrollThumbHeight);
      const clampedTop = Math.min(Math.max(newTop, 0), scrollContainerHeight - scrollThumbHeight);

      const newLeftPercentage = (pointer.x / editorWidth) * 100;
      const newLeft = (newLeftPercentage / 100) * (scrollContainerWidth - scrollThumbWidth);
      const clampedLeft = Math.min(Math.max(newLeft, 0), scrollContainerWidth - scrollThumbWidth);

      setScrollThumbsPos((prevPos) => ({
        ...prevPos,
        top: clampedTop,
        left: clampedLeft,
      }));
    });
  }, [activeEditor, scrollThumbHeight, scrollThumbWidth]);

  // 绑定/解绑Canvas滚轮事件
  useEffect(() => {
    if (!activeEditor?.canvas) return;
    activeEditor.canvas.on("mouse:wheel", handleMouseWheel);
    return () => {
      activeEditor.canvas.off("mouse:wheel", handleMouseWheel);
    };
  }, [activeEditor, handleMouseWheel]);

  return (
    <div>
      <div id="customYscroll" style={customYScrollStyle}>
        <div
          className="custom-y-scroll-thumb"
          style={{
            height: scrollThumbHeight,
            position: "relative",
            top: scrollThumbsPos.top,
          }}
          draggable="true"
          onDragStart={handleYDragStart}
          onDrag={handleYDrag}
          onDragEnd={handleYDragEnd}
          ref={boxYRef}
        ></div>
      </div>

      <div id="customXscroll" style={customXScrollStyle}>
        <div
          className="custom-x-scroll-thumb"
          style={{
            width: scrollThumbWidth,
            position: "relative",
            left: scrollThumbsPos.left,
          }}
          draggable="true"
          onDragStart={handleXDragStart}
          onDrag={handleXDrag}
          onDragEnd={handleXDragEnd}
          ref={boxXRef}
        ></div>
      </div>
    </div>
  );
};

export default CustomScroll;

额外优化建议

  • 给滑块元素添加will-change: top, leftCSS属性,提示浏览器提前优化这些属性的动画:
.custom-y-scroll-thumb, .custom-x-scroll-thumb {
  will-change: top, left;
  /* 其他样式 */
}
  • 如果滑块尺寸变化也导致闪烁,可以将尺寸计算也纳入requestAnimationFrame,避免同步更新样式引发的重绘。

内容的提问来源于stack exchange,提问作者Vansh

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.24 17:54:55