前言
前段時間,有一個業務需求,就是要實現類似excel單元格可以選中的需求,需求如下:
- ✅ 單擊切換選中狀態
- ✅ 按住Ctrl鍵進行拖拽多選
- ✅ 禁用項處理
- ✅ 右鍵清空所有選中
- ✅ 連續選中的視覺效果優化(邊框合併處理)
效果圖
線上預覽地址:https://ashuai.site/reactExamples/verticalSelection
github地址:https://github.com/shuirongshuifu/react-examples
個人愚見
面對這樣的需求,部分前端程序員可能會想着,去找一找有沒有類似的npm包,能夠拿來就直接用的。省的手動拆解需求,一步步實現。
對此,筆者表示,是否去選擇一些包要具體情況具體分析:
- 首先,這個業務需求是否有十分契合的包(部分npm包,可能存在過多的功能,實際上我們的需求只是其中一小部分,為了一個小需求,引入一個大的npm包,打包定然會增大提交,是否划算?)
- 其次,業務需求是否是特殊定製化的,後續還會拓展延伸?(如果使用了一些npm包,而後業務需求迭代到2.0,此包不支持,改npm包源碼,又很不穩妥或不好改)
- 然後,這個業務需求是否緊急,開發時間是否夠用?(如果一個特殊的業務需求,給到的開發時間充足,那麼我們完全可以拆解需求,自己手寫,過程結果以及後續拓展可控,順帶也能不斷精進自己的技術——自己寫的過程中可以參考一些npm包的源碼,常常會有意想不到的收穫)
- 最後,我們思考為何有的程序員一年是三年,有的三年是一年呢?為何別人能夠進步飛速呢?
上述需求倒是有一個類似功能的強大的庫,但是功能繁多,本需求,無需使用,大家可以瞭解一下,
官方文檔地址:https://handsontable.com/docs/javascript-data-grid/
官方github地址: https://github.com/handsontable/handsontable
代碼實現
首先模擬單元格列表數據(實際數據是接口返回的)
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,避免閉包問題
const [selectArr, setSelectArr] = useState([]) // 存放選中的項
const [isMouseDown, setIsMouseDown] = useState(false) // 鼠標是否按下
const listBoxRef = useRef(null) // dom引用實例,用於綁定事件
const isCtrlPressedRef = useRef(false) // 是否按下ctrl鍵
2. 全局鍵盤事件監聽
使用useEffect綁定鍵按下和抬起的事件,記錄Ctrl鍵盤是否按下,這裏搭配ref記錄,防止引用變化問題(別忘了,在組件卸載時清理事件監聽器)
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屬性準確定位點擊的項目
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)
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)
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
})
}
鼠標抬起和移走
鼠標抬起和移走,把對應的變量給重置為初始狀態就行了
const handleMouseUp = (e) => {
setIsMouseDown(false)
}
const handleMouseLeave = (e) => {
setIsMouseDown(false)
}
5. 邊框樣式處理
使用css控制單元格選中高亮的樣式
/* 高亮選中項 */
.hl {
border-color: #409EFF;
color: #111;
}
/* 為選中項的下一項補上邊框 */
.hl+.item {
border-top: 1px solid #409EFF;
}
/* 當前項和下一項都選中時,清除重複邊框 */
.curAndNextSelected+.hl {
border-top-color: transparent;
}
因為如果當前項和下一項都被選中了(連續選中,需要合併中間的border),使用函數進行判斷
// 是否選中,要看這一項中的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. 用户體驗細節
右鍵清空功能:
const clearSelect = (e) => {
e.preventDefault() // 阻止瀏覽器默認右鍵菜單
setSelectArr([])
}
// 在JSX中綁定
<div className={styles.listBox} onContextMenu={clearSelect} ref={listBoxRef}>
單擊切換選中:
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
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
.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...