React+FabricJS自定义滚动条:缩放时滚动滑块闪烁问题求助
FabricJS自定义滚动条缩放时滑块闪烁问题解决
问题描述
为FabricJS实现了自定义滚动条(原生无此功能),基础功能正常,但缩放时滑块会出现严重闪烁。已尝试防抖和记忆化处理更新逻辑,问题仍未解决。
问题原因分析
- 重复绑定事件监听:直接在组件渲染逻辑中调用
activeEditor?.canvas.on("mouse:wheel"),每次组件重渲染都会重复绑定事件,导致同一事件被多次触发,引发频繁状态更新 - 高频状态更新:
mouse:wheel事件触发频率极高,每次触发都调用setScrollThumbsPos更新状态,导致组件频繁重渲染 - 无意义的DOM查询:每次滚轮事件中都直接获取DOM元素的
clientHeight/clientWidth,触发浏览器回流,加剧视觉闪烁 - 未优化回调函数:事件处理函数未用
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
相关产品推荐
相关产品推荐

