動態

詳情 返回 返回

面對業務需求,多思考一下如何更好實現,不要成為麻木的前端npm調包俠 - 動態 詳情

前言

前段時間,有一個業務需求,就是要實現類似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...
user avatar anchen_5c17815319fb5 頭像 xiaolei_599661330c0cb 頭像 nqbefgvs 頭像 itwhat 頭像 lin494910940 頭像 chenwendeshuanggang 頭像 bukenengdeshi 頭像 liuyue_5e7eb6745e089 頭像 wdllmh 頭像 moyuzai_zxp616510038 頭像 biubiu_5deda9568bbf1 頭像 sugar_coffee 頭像
點贊 14 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.