1. 前言
某一天,产品经理给我提了这样一个需求:产品概览页是一个三列布局的结构,我希望用户能够自己拖动列与列之间的分割线,实现每列的宽度自定义,国际站用户就经常有这样的需求。效果类似这样:
就这?简单啊,不就是拖拽吗?使用开源拖拽库,回调里面给相关容器设置一下宽度即可,几行代码就搞定了。...不对,这是新同学才应该有的想法,但我是一个老前端啊,后来我又想了一下,如果我实现了上面的功能,那两列布局、三列布局、不管几列布局都应该可以拖拽啊,那页面左边的菜单,右边弹出的抽屉也可以让用户拖拽啊,嗯...那就做成一个组件吧,让我们来优雅的实现它。
2. 组件分析
我们先分析一下,不管是两列布局、三列布局、菜单、抽屉,最后拖拽的其实都是一根线,所以首先我们需要封装一个拖拽线条的组件,有了这个组件,再实现任何布局拖拽宽度自定义的功能就简单很多了:
使用开源库还是自己实现,也是我考虑的一个问题,我最终还是选择了自己实现,原因主要有两点:第一是现在的开源得三方包体积都比较大,我们的业务组件是项目必须引用的资源,资源当然是越小越好;第二是我们这个功能比较简单,自己实现代码可控,还可以实用一些新特性让性能做到最优。
3. DragLine
DragLine
组件主要包括哪些能力呢?
- 内置拖拽能力,可配置拖拽开始和结束的回调函数。
- 内置提示信息,可配置是否在第一次渲染时默认进行"可拖拽"的信息提示。
废话不多说,直接上拖拽线条组件DragLine
的代码:
js
// DragLine.js
import React, { useEffect, useRef, useState } from 'react';
import { Button, Tooltip } from 'antd';
import './index.scss';
const DragLine = ((props) => {
const {
gap = 16,
onMouseMove,
onMouseUp,
style = {},
tipKey,
defaultShowTip = false,
...rest
} = props;
const [visible, setVisible] = useState(defaultShowTip);
const ref = useRef(null);
const eventRef = useRef({});
const closeNavTips = () => {
localStorage.setItem(tipKey, 'true'); // 设置标记
setVisible(false); // 关闭弹窗
};
// 拖拽结束
const handleMouseUp = (e) => {
document.body.classList.remove('dragging');
onMouseUp && onMouseUp(e, ref.current);
document.removeEventListener('mousemove', eventRef.current.mouseMoveHandler, false);
document.removeEventListener('mouseup', eventRef.current.mouseUpHandler, false);
};
// 拖拽中
const handleMouseMove = (e) => {
onMouseMove && onMouseMove(e, ref.current);
};
// 开始拖拽
const handleMouseDown = () => {
closeNavTips();// 关闭拖拽提示框
document.body.classList.add('dragging');
eventRef.current.mouseMoveHandler = (e) => handleMouseMove(e);
eventRef.current.mouseUpHandler = (e) => handleMouseUp(e);
document.addEventListener('mousemove', eventRef.current.mouseMoveHandler, false);
document.addEventListener('mouseup', eventRef.current.mouseUpHandler, false);
};
const line = (
<div
ref={ref}
style={{
'--drag-gap': `${gap}px`,
...style,
}}
className={`drag-line ${visible ? 'active' : ''}`}
onMouseDown={handleMouseDown}
{...rest}
/>
);
return visible ? (
<Tooltip
open
placement="rightTop"
title={(
<div>
<div style={{ marginBottom: 4 }}>拖动这根线试试~</div>
<Button size="small" onClick={closeNavTips}>关闭</Button>
</div>
)}
>
{line}
</Tooltip>
) : line;
});
export default DragLine;
对应的css代码如下:
css
/* index.scss */
.drag-line {
width: 2px;
margin: 0 calc((var(--drag-gap, 16px) - 2px) / 2);
background: transparent;
cursor: col-resize;
&.active, &:hover {
background: blue;
}
}
.dragging {
user-select: none; // 内容不可选择
}
上述代码,拷贝后可以直接运行,我简单说明其中几点:
- js文件53行,使用到了css变量,对应css文件第4行,并通过
calc
函数可以实现很多复杂功能。 - js文件42行,拖拽时给body增加类名,对应css文件第14行,设置拖拽时body内容不可选中,不然用户会在拖拽时无意选中很多内容,从而造成困惑。
- 组件代码非常简单,并且内部已经封装好了拖拽能力,以及弹出的提示框,只是抛出了几个简单的API给业务方使用即可,我们还可以根据实际需求进一部分封装,比如线条的宽度、提示的内容和位置等等。
4. DragContainer
有了 DragLine
这个基础组件后,我们就可以很容易的去扩展任何需要拖拽的上层组件了,比如我们来实现一个可拖拽的多列布局容器组件,直接上DragContainer
组件的源码:
js
// DragContainer.js
import React, { useRef } from 'react';
import DragLine from '../DragLine';
import classnames from 'classnames';
import './index.scss';
const DragContainer = (props) => {
const {
className,
sceneKey,
minChildWidth = 150,
contentList = [],
gap = 16,
} = props;
const cls = classnames('drag-container', className);
const ref = useRef(null);
// 拖拽结束时,保存宽度信息
const onMouseUp = () => {
const widthList = contentList.map((_, i) => {
const child = ref.current.querySelector(`.item${i}`);
return `${child?.offsetWidth}px`;
});
localStorage.setItem(sceneKey, widthList.join('#'));
};
const onMouseMove = (event, node) => {
const index = parseInt(node.getAttribute('data-index'));
const leftElement = ref.current.querySelector(`.item${index}`);
const rightElement = ref.current.querySelector(`.item${index + 1}`);
// 拖动距离 = 分割线的位置 - 鼠标的位置
const dragOffset = node.getBoundingClientRect().left - event.clientX;
const newLeftChildWidth = leftElement.offsetWidth - dragOffset;
const newRightChildWidth = rightElement.offsetWidth + dragOffset;
if (newLeftChildWidth >= minChildWidth && newRightChildWidth >= minChildWidth) {
ref.current.style.setProperty(`--drag-childWidth-${sceneKey}-${index}`, `${newLeftChildWidth}px`);
ref.current.style.setProperty(`--drag-childWidth-${sceneKey}-${index + 1}`, `${newRightChildWidth}px`);
}
};
const contentData = [];
const localWidthList = localStorage.getItem(sceneKey)?.split('#') || []; // 获取本地已经保存的宽度信息
contentList.forEach((d, i) => {
contentData.push(
<div
key={`${sceneKey}_${i}`}
className={`container-item item${i}`}
style={{ flexBasis: `var(--drag-childWidth-${sceneKey}-${i}, ${localWidthList[i]})` }}
>{d}
</div>,
);
if (i < contentList.length - 1) {
contentData.push(
<DragLine
key={`${sceneKey}_dragline_${i}`}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
tipKey="draggableContainerFlag"
data-index={i}
defaultShowTip={i === 0}
gap={gap}
/>,
);
}
});
return (
<div ref={ref} className={cls}>
{contentData}
</div>
);
};
export default DragContainer;
对应样式文件如下:
css
/* index.scss */
.drag-container {
display: flex;
align-items: stretch;
width: 100%;
.container-item {
height: 100%;
overflow: hidden;
flex: 1; // 同比例放大缩小
}
}
DragContainer
组件的实现逻辑也比较简单,基本思路如下:
- 根据传入的contentList进行一个循环,如果不是最后一个child,则多渲染一个
DragLine
,用以拖拽。 - 在拖拽线条的回调函数里,进行一个拖拽偏移和左右子元素新宽度的计算,再设置到css变量中,从而实现拖拽宽度实时变化的效果。并且代码中没有用到任何
React State
,不需要重复渲染整个组件,改变宽度直接使用css实现,性能也比较好。 - css文件第10行,对flex布局的子元素设置
flex: 1
,意思是当我们拖动浏览器窗口大小时,子元素的宽度会同比例放大缩小,就能实现宽度自适应了,但这里有个前提是,子元素宽度不要写死,而是配合js文件第53行的flexBasis
属性一起使用。 - 上述代码拷贝后也是可以直接运行的,需要的同学可以直接试试。
5. 使用效果
我们在业务代码中使用DragContainer
组件写个例子,使用简单,效果完美:
js
<DragContainer
sceneKey="overview-page"
contentList={[
<Card>111</Card>,
<Card>222</Card>,
<Card>333</Card>,
]}
/>
6. 总结
其实本文我最想要表达的是,当我们接到一个需求之后,先学会分析和过滤,如果是特定的业务需求,实现即可,如果是通用类需求,就要慢慢学会从组件开发的角度去思考,是否能够举一反三,通过组件开发去覆盖解决更多的场景和问题。另外是在功能的实现方面,主要总结以下几点:
- 要能够通过对比选择最合适自己的技术,比如简单的拖拽功能完全可以使用原生js来做,而不是引入一个超大的三方包。
- 容器宽度的改变可以直接修改css属性,而不是使用React状态,减少不必要的重复渲染。
- css variable技术,是打通js和css的一种手段。
- flex布局相关属性的熟练使用,可以以更优的方案来解决一些布局问题。
这些都是在平常繁琐的业务开发中,主动要求和训练自己,慢慢形成的一种专业嗅觉。