基于JS实现一个可拖拽的容器布局组件
作者:Yikuns
1. 前言
某一天,产品经理给我提了这样一个需求:产品概览页是一个三列布局的结构,我希望用户能够自己拖动列与列之间的分割线,实现每列的宽度自定义,国际站用户就经常有这样的需求。效果类似这样:
就这?简单啊,不就是拖拽吗?使用开源拖拽库,回调里面给相关容器设置一下宽度即可,几行代码就搞定了。...不对,这是新同学才应该有的想法,但我是一个老前端啊,后来我又想了一下,如果我实现了上面的功能,那两列布局、三列布局、不管几列布局都应该可以拖拽啊,那页面左边的菜单,右边弹出的抽屉也可以让用户拖拽啊,嗯...那就做成一个组件吧,让我们来优雅的实现它。
2. 组件分析
我们先分析一下,不管是两列布局、三列布局、菜单、抽屉,最后拖拽的其实都是一根线,所以首先我们需要封装一个拖拽线条的组件,有了这个组件,再实现任何布局拖拽宽度自定义的功能就简单很多了:
使用开源库还是自己实现,也是我考虑的一个问题,我最终还是选择了自己实现,原因主要有两点:第一是现在的开源得三方包体积都比较大,我们的业务组件是项目必须引用的资源,资源当然是越小越好;第二是我们这个功能比较简单,自己实现代码可控,还可以实用一些新特性让性能做到最优。
3. DragLine
DragLine
组件主要包括哪些能力呢?
- 内置拖拽能力,可配置拖拽开始和结束的回调函数。
- 内置提示信息,可配置是否在第一次渲染时默认进行“可拖拽”的信息提示。
废话不多说,直接上拖拽线条组件DragLine
的代码:
// 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代码如下:
/* 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
组件的源码:
// 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;
对应样式文件如下:
/* 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
组件写个例子,使用简单,效果完美:
<DragContainer sceneKey="overview-page" contentList={[ <Card>111</Card>, <Card>222</Card>, <Card>333</Card>, ]} />
6. 总结
其实本文我最想要表达的是,当我们接到一个需求之后,先学会分析和过滤,如果是特定的业务需求,实现即可,如果是通用类需求,就要慢慢学会从组件开发的角度去思考,是否能够举一反三,通过组件开发去覆盖解决更多的场景和问题。另外是在功能的实现方面,主要总结以下几点:
- 要能够通过对比选择最合适自己的技术,比如简单的拖拽功能完全可以使用原生js来做,而不是引入一个超大的三方包。
- 容器宽度的改变可以直接修改css属性,而不是使用React状态,减少不必要的重复渲染。
- css variable技术,是打通js和css的一种手段。
- flex布局相关属性的熟练使用,可以以更优的方案来解决一些布局问题。
到此这篇关于基于JS实现一个可拖拽的容器布局组件的文章就介绍到这了,更多相关JS可拖拽容器布局组件内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!