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>
      </>
    );
  },
);
相关推荐
humors2217 分钟前
Deepseek工具:H5+Vue 项目转微信小程序报告生成工具
前端·vue.js·微信小程序·h5·工具·报告
方安乐7 分钟前
ESLint代码规范(二)
前端·javascript·代码规范
zzginfo13 分钟前
var、let、const、无申明 四种变量在赋值前,使用的情况
开发语言·前端·javascript
贺小涛17 分钟前
Vue介绍
前端·javascript·vue.js
cch891841 分钟前
React Hooks的支持
前端·javascript·react.js
鹏程十八少1 小时前
9. Android Shadow插件化如何解决资源冲突问题和实现tinker热修复资源(源码分析4)
android·前端·面试
蜡台1 小时前
vue.config.js 配置
前端·javascript·vue.js·webpack
qq_381338501 小时前
微前端架构下的状态管理与通信机制深度解析:从 qiankun 源码到性能优化实战
前端·状态模式
han_1 小时前
JavaScript设计模式(六):职责链模式实现与应用
前端·javascript·设计模式