React版 容器拖拉拽UI组件,可嵌套任何节点

需求背景

产品需要某个模块实现拖拉拽功能,方便用户可以针对界面,可以动态快速拉伸看到更多内容,提升用户体验度

期望解决

如何在不影响当前界面的情况下(不动业务组件代码),我们的前端大大同学们,可以快速且无缺陷的完成这个需求呢,我们的容器UI组件应运而生, 实现极小成本的针对某个模块,针对节点扩展拖拉拽功能

前端痛点

需要实现产品的配置化功能,我们只需套用公共UI组件,即可实现,多处调用,只维护一个组件,来达到高内聚低耦合

实现思路

内部实现针对节点的拖拉拽事件,组件功能集成在hooks中,方便外部消费,一个方法,专注一个功能,尽量保证代码的干净、健壮、可维护、可扩展 外部只需传入children,我们内部,根据在这个 children 的右侧增加一个拖拽节点,来达到整个节点可以拖拽偏移的效果,以及一个容器节点,包裹住整个children,方便我们内部组件识别宽度,以及一些定位操作 活不多说,我们直接上代码 例子

js 复制代码
 <ResizableContainer minWidth={220} maxWidth={400} initialWidth={250}>
 xxxx      
 </ResizableContainer>
js 复制代码
import { throttle } from 'lodash';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';

type TCustomWidth<N = number> = Partial<{
  // 最小宽度
  minWidth: N;
  // 最大宽度
  maxWidth: N;
  // 初始化宽度
  initialWidth: N | undefined;
  // 是否开启拖拽遮罩, 默认为真
  isShowMask: boolean;
  // 是否实时更新宽度, 默认为真
  realtimeUpdate: boolean;
}>;

type TUseSizeProps = Partial<TFilterContainerProps> & TCustomWidth;

type TFilterContainerProps = {
  // 传入的拖拽元素节点
  children: React.ReactNode;
  // 是否开启拖拽
  isDraggable: boolean;
  // 拖拽列宽回调
  handleOnChangeFilterWidth?: (width: string | number) => void | undefined;
};

// 定义一个子组件可以接收的props类型
type TChildWithWidthProps = {
  newWidth?: number;
  [key: string]: any; // 允许其他任意属性
}

export const useResizableWidth = ({
  minWidth = 100,
  maxWidth = 800,
  handleOnChangeFilterWidth,
}: TUseSizeProps) => {
  const resizableRef = useRef<HTMLDivElement>(null);
  const resizeContainerRef = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState<number | undefined>();
  const [isResizing, setIsResizing] = useState(false);
  const [showMask, setShowMask] = useState(false);
  const [visualWidth, setVisualWidth] = useState<number | undefined>(); // 新增视觉宽度状态

  // 使用 ref 存储拖拽相关数据
  const dragInfo = useRef({
    startX: 0,
    startWidth: 0,
    lastUpdateTime: 0,
  });

  // 实时更新视觉宽度,但不触发回调
  const handleResize = useCallback(
    throttle((event: MouseEvent) => {
      if (!isResizing || !resizeContainerRef.current) return;

      const currentTime = Date.now();
      if (currentTime - dragInfo.current.lastUpdateTime < 16) {
        return;
      }

      const { left } = resizeContainerRef.current.getBoundingClientRect();
      const newWidth = Math.max(minWidth, Math.min(maxWidth, event.clientX - left));

      // 更新视觉宽度(实时变化)
      setVisualWidth(newWidth);
      dragInfo.current.lastUpdateTime = currentTime;
    }, 16),
    [isResizing, minWidth, maxWidth, width],
  );

  const handleResizeStart = useCallback((event: MouseEvent) => {
    event.preventDefault();
    event.stopPropagation();

    if (!resizeContainerRef.current) return;

    const rect = resizeContainerRef.current.getBoundingClientRect();
    dragInfo.current = {
      startX: event.clientX,
      startWidth: rect.width,
      lastUpdateTime: Date.now(),
    };

    // 初始化视觉宽度
    setVisualWidth(rect.width);
    setIsResizing(true);
    setShowMask(true);
    document.body.style.cursor = 'col-resize';
    document.body.style.userSelect = 'none';
  }, []);

  const handleResizeEnd = useCallback(() => {
    setIsResizing(false);
    setShowMask(false);
    document.body.style.cursor = '';
    document.body.style.userSelect = '';

    // 拖拽结束时更新实际宽度并触发回调
    if (resizeContainerRef.current && visualWidth) {
      setWidth(visualWidth);
      handleOnChangeFilterWidth?.(visualWidth);
    }
    setVisualWidth(undefined); // 重置视觉宽度
  }, [visualWidth, handleOnChangeFilterWidth]);

  useEffect(() => {
    const resizableElement = resizableRef.current;
    if (!resizableElement) return;

    resizableElement.addEventListener('mousedown', handleResizeStart);

    if (isResizing) {
      document.addEventListener('mousemove', handleResize);
      document.addEventListener('mouseup', handleResizeEnd);
      document.addEventListener('mouseleave', handleResizeEnd);
    }

    return () => {
      resizableElement.removeEventListener('mousedown', handleResizeStart);
      document.removeEventListener('mousemove', handleResize);
      document.removeEventListener('mouseup', handleResizeEnd);
      document.removeEventListener('mouseleave', handleResizeEnd);
      handleResize.cancel();
    };
  }, [isResizing, handleResize, handleResizeStart, handleResizeEnd]);

  return {
    ref: resizableRef,
    width,
    resizeContainerRef,
    showMask,
    visualWidth,
  };
};

export const ResizableContainer = memo(
  ({
    children,
    isDraggable = true,
    handleOnChangeFilterWidth,
    minWidth,
    maxWidth,
    initialWidth,
    isShowMask = true,
    realtimeUpdate = false,
  }: TUseSizeProps) => {
    const { width, ref, resizeContainerRef, showMask, visualWidth } = useResizableWidth({
      minWidth,
      maxWidth,
      handleOnChangeFilterWidth,
    });

    return (
      <>
        {/* 主容器 - 保持相对定位 */}
        <div
          style={{
            position: 'relative',
            height: '100%',
            width: `${
              realtimeUpdate ? visualWidth || width || initialWidth : width || initialWidth
            }px`,
          }}
        >
          {/* 可调整大小的内容区域 */}
          <div
            ref={resizeContainerRef}
            style={{
              width: `${visualWidth || width || initialWidth}px`,
              position: 'relative',
              top: 0,
              left: 0,
              background: '#fff',
              zIndex: 10, 
              height: '100%',
            }}
          >
            {React.Children.map(children, child =>
              React.isValidElement<TChildWithWidthProps>(child)
                ? React.cloneElement(child, {
                    newWidth: width,
                  })
                : null,
            )}

            {/* 拖拽手柄 */}
            {isDraggable && (
              <div
                ref={ref}
                style={{
                  width: '5px',
                  height: '100%',
                  backgroundColor: 'transparent',
                  position: 'absolute',
                  top: 0,
                  right: '-4px',
                  cursor: 'col-resize',
                  zIndex: 20,
                  touchAction: 'none',
                }}
              />
            )}
          </div>
          {/* 遮罩层  */}
          {isShowMask && showMask && (
            <div
              style={{
                position: 'fixed',
                top: 0,
                left: 0, // 覆盖整个视口
                right: 0,
                bottom: 0,
                backgroundColor: 'rgba(0, 0, 0, 0.4)',
                zIndex: 5,
              }}
            />
          )}
        </div>
      </>
    );
  },
);
相关推荐
肥肥呀呀呀28 分钟前
在Flutter上如何实现按钮的拖拽效果
前端·javascript·flutter
Zero10171344 分钟前
【React的useMemo钩子详解】
前端·react.js·前端框架
养军博客1 小时前
spring boot3.0自定义校验注解:文章状态校验示例
java·前端·spring boot
uperficialyu1 小时前
2025年01月10日浙江鑫越系统科技前端面试
前端·科技·面试
付朝鲜1 小时前
用自写的jQuery库+Ajax实现了省市联动
java·前端·javascript·ajax·jquery
coderYYY1 小时前
多个el-form-item两列布局排齐且el-select/el-input组件宽度撑满
前端·javascript·vue.js·elementui·前端框架
荔枝吖2 小时前
项目中会出现的css样式
前端·css·html
Dontla2 小时前
何时需要import css文件?怎么知道需要导入哪些css文件?为什么webpack不提示CSS导入?(导入css导入规则、css导入规范)
前端·css·webpack
小堃学编程2 小时前
前端学习(2)—— CSS详解与使用
前端·css·学习
蓝婷儿2 小时前
第一章:HTML基石·现实的骨架
前端·html