需求背景
产品需要某个模块实现拖拉拽功能,方便用户可以针对界面,可以动态快速拉伸看到更多内容,提升用户体验度
期望解决
如何在不影响当前界面的情况下(不动业务组件代码),我们的前端大大同学们,可以快速且无缺陷的完成这个需求呢,我们的容器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>
</>
);
},
);