前言
前段时间,有一个业务需求,就是要实现类似excel单元格可以选中的需求,需求如下:
- ✅ 单击切换选中状态
- ✅ 按住Ctrl键进行拖拽多选
- ✅ 禁用项处理
- ✅ 右键清空所有选中
- ✅ 连续选中的视觉效果优化(边框合并处理)
效果图

线上预览地址:ashuai.site/reactExampl...
github地址:github.com/shuirongshu...
个人愚见
面对这样的需求,部分前端程序员可能会想着,去找一找有没有类似的npm包,能够拿来就直接用的。省的手动拆解需求,一步步实现。
对此,笔者表示,是否去选择一些包要具体情况具体分析:
- 首先,这个业务需求是否有十分契合的包(部分npm包,可能存在过多的功能,实际上我们的需求只是其中一小部分,为了一个小需求,引入一个大的npm包,打包定然会增大提交,是否划算?)
- 其次,业务需求是否是特殊定制化的,后续还会拓展延伸?(如果使用了一些npm包,而后业务需求迭代到2.0,此包不支持,改npm包源码,又很不稳妥或不好改)
- 然后,这个业务需求是否紧急,开发时间是否够用?(如果一个特殊的业务需求,给到的开发时间充足,那么我们完全可以拆解需求,自己手写,过程结果以及后续拓展可控,顺带也能不断精进自己的技术------自己写的过程中可以参考一些npm包的源码,常常会有意想不到的收获)
- 最后,我们思考为何有的程序员一年是三年,有的三年是一年呢?为何别人能够进步飞速呢?
上述需求倒是有一个类似功能的强大的库,但是功能繁多,本需求,无需使用,大家可以了解一下,
官方文档地址:handsontable.com/docs/javasc...
官方github地址: github.com/handsontabl...
代码实现
首先模拟单元格列表数据(实际数据是接口返回的)
js
const list = [
{ name: '孙悟空', id: '1' },
{ name: '猪八戒', id: '2' },
{ name: '沙和尚', id: '3', disabled: true },
{ name: '唐僧', id: '4' },
{ name: '白龙马', id: '5' },
{ name: '白骨精', id: '6' },
{ name: '玉兔精', id: '7' },
{ name: '嫦娥', id: '8' },
{ name: '二郎神', id: '9' },
]
1. 状态管理设计
竖向选中单元格,有以下数据状态需要记录,等,具体如下:
selectArr
:用数组存储所有选中的项目,便于增删查改isMouseDown
:追踪鼠标状态,确保只在拖拽时触发多选listBoxRef
:获取容器DOM,用于事件委托绑定isCtrlPressedRef
:使用ref而非state,避免闭包问题
js
const [selectArr, setSelectArr] = useState([]) // 存放选中的项
const [isMouseDown, setIsMouseDown] = useState(false) // 鼠标是否按下
const listBoxRef = useRef(null) // dom引用实例,用于绑定事件
const isCtrlPressedRef = useRef(false) // 是否按下ctrl键
2. 全局键盘事件监听
使用useEffect绑定键按下和抬起的事件,记录Ctrl键盘是否按下,这里搭配ref记录,防止引用变化问题(别忘了,在组件卸载时清理事件监听器)
js
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Control') {
isCtrlPressedRef.current = true
}
}
const handleKeyUp = (e) => {
if (e.key === 'Control') {
isCtrlPressedRef.current = false
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [])
3. 事件监听委托优化
- 使用事件委托,只在父容器上绑定一次事件
- 避免在每个列表项上都绑定事件,提升性能
- 通过
data-index
属性准确定位点击的项目
js
useEffect(() => {
const listBoxDom = listBoxRef.current
if (!listBoxDom) return
listBoxDom.addEventListener('mousedown', handleMouseDown)
listBoxDom.addEventListener('mousemove', handleMouseMove)
listBoxDom.addEventListener('mouseup', handleMouseUp)
listBoxDom.addEventListener('mouseleave', handleMouseLeave)
return () => {
listBoxDom.removeEventListener('mousedown', handleMouseDown)
listBoxDom.removeEventListener('mousemove', handleMouseMove)
listBoxDom.removeEventListener('mouseup', handleMouseUp)
listBoxDom.removeEventListener('mouseleave', handleMouseLeave)
}
}, [handleMouseDown, handleMouseMove, handleMouseUp, handleMouseLeave])
4. 鼠标事件处理核心逻辑
鼠标按下(mousedown)
js
const handleMouseDown = (e) => {
// 禁用项检查
if (e.target.dataset?.['disabled'] === 'true') {
console.warn('此单元格已经被禁用,不可使用')
return
}
// 必须按下Ctrl键才能进行多选
if (!isCtrlPressedRef.current) {
console.warn('若想进行多选操作,请按下ctrl键')
return
}
const whichIndex = e.target.dataset.index;
if (!whichIndex) return
const whichItem = list[Number(whichIndex)];
if (!whichItem) return
setIsMouseDown(true)
// 添加到选中数组(去重处理)
setSelectArr((prev) => {
const isExist = prev.some(item => item.id === whichItem.id)
if (!isExist) {
return [...prev, whichItem]
}
return prev
})
}
鼠标移动(mousemove)
js
const handleMouseMove = (e) => {
// 只有在鼠标按下且按住Ctrl键时才触发
if (!isMouseDown || !isCtrlPressedRef.current) return
const whichIndex = e.target.dataset.index;
if (!whichIndex) return
const whichItem = list[Number(whichIndex)];
if (!whichItem || whichItem.disabled) return
// 拖拽过程中只添加,不移除
setSelectArr((prev) => {
const isExist = prev.some(item => item.id === whichItem.id)
if (!isExist) {
return [...prev, whichItem]
}
return prev
})
}
鼠标抬起和移走
鼠标抬起和移走,把对应的变量给重置为初始状态就行了
js
const handleMouseUp = (e) => {
setIsMouseDown(false)
}
const handleMouseLeave = (e) => {
setIsMouseDown(false)
}
5. 边框样式处理
使用css控制单元格选中高亮的样式
css
/* 高亮选中项 */
.hl {
border-color: #409EFF;
color: #111;
}
/* 为选中项的下一项补上边框 */
.hl+.item {
border-top: 1px solid #409EFF;
}
/* 当前项和下一项都选中时,清除重复边框 */
.curAndNextSelected+.hl {
border-top-color: transparent;
}
因为如果当前项和下一项都被选中了(连续选中,需要合并中间的border),使用函数进行判断
js
// 是否选中,要看这一项中的id在不在选中数组中里面
const isCurSelected = (item) => selectArr.some((s) => s.id === item?.id)
// 判断当前项和下一项是否都被选中
const isCurAndNextBothSelected = (item, index) => {
if (index === list.length - 1) return false
if (!isCurSelected(item)) return false
const nextItem = list[index + 1]
return isCurSelected(nextItem)
}
// VerticalSelection代码返回
return (
<div>
<h3>竖向选中</h3>
<p style={{ fontSize: '13px' }}>已选中:{selectArr.map(s => s.name).join('、')}</p>
<div className={styles.listBox} onContextMenu={clearSelect} ref={listBoxRef}>
{list.map((item, index) =>
<div onClick={(e) => clickItem(e, item)}
className={`
${styles.item}
${item.disabled && styles.disabled}
${isCurSelected(item) ? styles.hl : ''}
${isCurAndNextBothSelected(item, index) ? styles.curAndNextSelected : ''}
`}
key={item.id}
data-disabled={item.disabled}
data-index={index}
>
{item.name}
</div>)
}
</div>
</div>
)
6. 用户体验细节
右键清空功能:
js
const clearSelect = (e) => {
e.preventDefault() // 阻止浏览器默认右键菜单
setSelectArr([])
}
// 在JSX中绑定
<div className={styles.listBox} onContextMenu={clearSelect} ref={listBoxRef}>
单击切换选中:
js
const clickItem = (e, item) => {
if (item.disabled) return
setSelectArr((prev) => {
const isExist = prev.some((pr) => pr.id === item.id)
if (!isExist) {
return [...prev, item] // 不存在则添加
} else {
return prev.filter((pr) => pr.id !== item.id) // 存在则移除
}
})
}
当然,这个需求代码还可以进一步拓展,比如:
- 添加键盘导航(上下箭头键)
- 支持全选/反选功能
完整代码
jsx
js
import { useState, useRef, useEffect } from 'react'
import styles from './VerticalSelection.module.css'
export default function VerticalSelection() {
const [selectArr, setSelectArr] = useState([]) // 存放选中的项
const [isMouseDown, setIsMouseDown] = useState(false) // 鼠标是否按下
const listBoxRef = useRef(null) // dom引用实例,用于绑定事件
const isCtrlPressedRef = useRef(false) // 是否按下ctrl键
// 监听全局Ctrl键是否按下
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Control') {
isCtrlPressedRef.current = true
}
}
const handleKeyUp = (e) => {
if (e.key === 'Control') {
isCtrlPressedRef.current = false
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [])
const handleMouseDown = (e) => {
if (e.target.dataset?.['disabled'] === 'true') {
console.warn('此单元格已经被禁用,不可使用')
return
}
if (!isCtrlPressedRef.current) {
console.warn('若想进行多选操作,请按下ctrl键')
return
}
const whichIndex = e.target.dataset.index;
if (!whichIndex) return
const whichItem = list[Number(whichIndex)];
if (!whichItem) return
setIsMouseDown(true)
setSelectArr((prev) => {
const isExist = prev.some(item => item.id === whichItem.id)
if (!isExist) { // 不存在就是新的项,就添加,若项存在则不操作
return [...prev, whichItem]
}
return prev
})
}
const handleMouseMove = (e) => {
// 需要满足按住Ctrl键后,鼠标按下才可以多选操作
if (!isMouseDown || !isCtrlPressedRef.current) return
const whichIndex = e.target.dataset.index;
if (!whichIndex) {
return
}
const whichItem = list[Number(whichIndex)];
if (!whichItem) {
return
}
if (whichItem.disabled) {
console.warn('此单元格已经被禁用,不可使用')
return
}
setSelectArr((prev) => {
// 多选只追加
const isExist = prev.some(item => item.id === whichItem.id)
if (!isExist) {
return [...prev, whichItem]
}
return prev
})
}
const handleMouseUp = (e) => {
setIsMouseDown(false)
}
const handleMouseLeave = (e) => {
setIsMouseDown(false)
}
useEffect(() => {
const listBoxDom = listBoxRef.current
if (!listBoxDom) return
listBoxDom.addEventListener('mousedown', handleMouseDown)
listBoxDom.addEventListener('mousemove', handleMouseMove)
listBoxDom.addEventListener('mouseup', handleMouseUp)
listBoxDom.addEventListener('mouseleave', handleMouseLeave)
return () => {
listBoxDom.removeEventListener('mousedown', handleMouseDown)
listBoxDom.removeEventListener('mousemove', handleMouseMove)
listBoxDom.removeEventListener('mouseup', handleMouseUp)
listBoxDom.removeEventListener('mouseleave', handleMouseLeave)
}
}, [handleMouseDown, handleMouseMove, handleMouseUp, handleMouseLeave])
const list = [
{ name: '孙悟空', id: '1' },
{ name: '猪八戒', id: '2' },
{ name: '沙和尚', id: '3', disabled: true },
{ name: '唐僧', id: '4' },
{ name: '白龙马', id: '5' },
{ name: '白骨精', id: '6' },
{ name: '玉兔精', id: '7' },
{ name: '嫦娥', id: '8' },
{ name: '二郎神', id: '9' },
]
const clickItem = (e, item) => {
if (item.disabled) return
setSelectArr((prev) => {
// 不存在则追加,存在则去掉
const isExist = prev.some((pr) => pr.id === item.id)
if (!isExist) {
return [...prev, item]
} else {
return prev.filter((pr) => pr.id !== item.id)
}
})
}
const clearSelect = (e) => {
e.preventDefault() // 右键清空所有选中
setSelectArr([])
}
// 是否选中,要看这一项中的id在不在选中数组中里面
const isCurSelected = (item) => selectArr.some((s) => s.id === item?.id)
// 是否当前项和下一项,同时被选中
const isCurAndNextBothSelected = (item, index) => {
if (index === list.length - 1) {
return false
}
if (!isCurSelected(item)) {
return false
} else {
const nextItem = list[index + 1]
return isCurSelected(nextItem)
}
}
return (
<div>
<h3>竖向选中</h3>
<p style={{ fontSize: '13px' }}>已选中:{selectArr.map(s => s.name).join('、')}</p>
<div className={styles.listBox} onContextMenu={clearSelect} ref={listBoxRef}>
{list.map((item, index) =>
<div onClick={(e) => clickItem(e, item)}
className={`
${styles.item}
${item.disabled && styles.disabled}
${isCurSelected(item) ? styles.hl : ''}
${isCurAndNextBothSelected(item, index) ? styles.curAndNextSelected : ''}
`}
key={item.id}
data-disabled={item.disabled}
data-index={index}
>
{item.name}
</div>)
}
</div>
</div>
)
}
css
css
.listBox {
padding: 24px;
width: fit-content;
box-sizing: border-box;
}
.item {
box-sizing: border-box;
width: 120px;
padding: 6px 12px;
border: 1px solid #e9e9e9;
border-bottom: none;
cursor: cell;
user-select: none;
transition: all 0.3s;
color: #555;
}
.disabled {
cursor: not-allowed;
color: #ccc;
}
.item:last-child {
border-bottom: 1px solid #e9e9e9;
}
/* 高亮 */
.hl {
border-color: #409EFF;
color: #111;
}
/* 为选中的高亮项的下一项,补上一个上方的边框 */
.hl+.item {
border-top: 1px solid #409EFF;
}
/* 最后一个选中的高亮项,补上一个底部的边框 */
.hl:last-child {
border-bottom: 1px solid #409EFF;
}
/* 若是当前项和下一项都选中了,就把下一项的border-top清除掉即可 */
.curAndNextSelected+.hl {
/* 障眼法,透明色清除,毕竟边框有一个像素的高度 */
border-top-color: transparent;
}
A good memory is better than a bad pen. Record it down...