面对业务需求,多思考一下如何更好实现,不要成为麻木的前端npm调包侠

前言

前段时间,有一个业务需求,就是要实现类似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...

相关推荐
qczg_wxg5 小时前
React Native系统组件(二)
javascript·react native·react.js
ZZHow10246 小时前
React前端开发_Day12_极客园移动端项目
前端·笔记·react.js·前端框架·web
前端岳大宝6 小时前
Module Federation
react.js·前端框架·状态模式
安心不心安8 小时前
React Router 6 获取路由参数
前端·javascript·react.js
1024小神18 小时前
vue/react项目如何跳转到一个已经写好的html页面
vue.js·react.js·html
Maschera9621 小时前
扣子同款半固定输入模板的简单解决方案
前端·react.js
webKity21 小时前
React 的基本概念介绍
javascript·react.js
YuspTLstar1 天前
从工作原理入手理解React一:React核心内容和基本使用
react.js