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>
      </>
    );
  },
);
相关推荐
TimelessHaze2 分钟前
🔥 一文掌握 JavaScript 数组方法(2025 全面指南):分类解析 × 业务场景 × 易错点
前端·javascript·trae
jvxiao34 分钟前
搭建个人博客系列--(4) 利用Github Actions自动构建博客
前端
袁煦丞1 小时前
SimpleMindMap私有部署团队脑力风暴:cpolar内网穿透实验室第401个成功挑战
前端·程序员·远程工作
li理1 小时前
鸿蒙 Next 布局开发实战:6 大核心布局组件全解析
前端
EndingCoder1 小时前
React 19 与 Next.js:利用最新 React 功能
前端·javascript·后端·react.js·前端框架·全栈·next.js
li理1 小时前
鸿蒙 Next 布局大师课:从像素级控制到多端适配的实战指南
前端
前端赵哈哈1 小时前
Vite 图片压缩的 4 种有效方法
前端·vue.js·vite
Nicholas681 小时前
flutter滚动视图之ScrollView源码解析(五)
前端
电商API大数据接口开发Cris1 小时前
Go 语言并发采集淘宝商品数据:利用 API 实现高性能抓取
前端·数据挖掘·api
风中凌乱的L1 小时前
vue 一键打包上传
前端·javascript·vue.js