
前言
前两年在一家做电商的公司做了一个需求:鼠标框选商品卡片,开始拖拽的时候合成一个然后改变位置,页面上有几千个所以还要结合虚拟列表。当时不知道怎么做,就在 github 上到处找现成的库,最后找到了 react-selectable-fast,并结合 react-virtualized、react-sortable-hoc 完成了需求。虽然该库已经很久不维护了,但大致上能满足我的需求了,尽管它是以 dom 的方式,很不 react,但秉承着能用就行
的原则。意料之中,开发过程中遇到了 bug,最后只能 fork 一份修改源码后自己发了个 npm 包来使用。
项目介绍
前几个月在空闲时间突然来了兴致,自己找点事做,就想自己开发一个框选的库吧,万一也有人有这个需求不知道怎么办呢?写完后发到了 antd 的社区共建群里,有的人觉得不错也 star 了。先献上项目地址 react-selectable-box,文档完整,使用 dumi 编写。api 友好,支持自定义一些功能。
api 设计
一个组件在设计时,首先思考的应该是 api 如何去设计,最好符合大家平常的习惯,并具有一定的自定义和拓展能力。再加上了解 react-selectable-fast
这个库的缺点和痛点,对我的设计就更加有帮助了。大家在看下面的文章之前也可以思考一下,如果是你,你会怎么设计?这里只选取几个 api 来进行介绍。
主组件 Selectable
选中的值
defaultValue
和 value
,类型为 (string | number)[]
,每个方块一般都有一个唯一 id 来标识。
禁用
disabled
,大部分有值的组件应该都会有此属性,能直接禁用框选功能。
模式
mode
,类型为 "add" | "remove" | "reverse"
。模式,表明当前框选是增加、减少还是取反。这个 api 感觉是设计的最好的,用户会框选来选择目标,肯定也会需要删除已经框选的目标,可能是按住 shift 来删除等等之类的操作。用户可以自己编写自定义逻辑来修改 mode
的值来控制不同的行为,反观 react-selectable-fast
,则是提供了 deselectOnEsc
、allowAltClick
、allowCtrlClick
、allowMetaClick
、allowShiftClick
等多个 api。
可以进行框选的容器
dragContainer
,类型 () => HTMLElement
,例如你只希望某个卡片内才可以进行框选,不希望整个页面都可以进行框选,这个 api 就会起到作用了。
滚动的容器
scrollContainer
,类型 () => HTMLElement
,如果你的这些方块是在某个容器中并且可滚动,就需要传入这个属性,就可以在滚动的容器中进行框选操作了。
框选开始事件
onStart
,框选开始时,使用者可能需要做一些事情。
框选结束事件
onEnd
,类型为 (selectingValue: (string | number)[], { added: (string | number)[], removed: (string | number)[] }) => void
。selectingValue
为本次框选的值,added
为本次增加的值,removed
为本次删除的值。例如你想在每次框选后覆盖之前的操作,直接设置 selectingValue
成 value 即可。如果你想每次框选都是累加,加上 added
的值即可,这里就不再说明了。
方块可选 - useSelectable
怎么让方块可以被选择呢?并且一一绑定上对应的值?react-selectable-fast
则是提供 clickableClassName
api,传入可以被选择的目标的 class,这种方式太不 react 了。此时我的脑海里想到了 dnd-kit,我认为是 react 最好用的拖拽库,它是怎么让每个方块可以被拖拽的呢?优秀的东西应该借鉴,于是就有了 useSelectable
。
javascript
const {
setNodeRef, // 设置可框选元素
isSelected, // 是否已经选中
isAdding, // 当前是否正在添加
isRemoving, // 当前是否正在删除
isSelecting, // 当前是否被框选
isDragging // 是否正在进行框选操作
} = useSelectable({
value, // 每个元素的唯一值
disabled, // 这个元素是否禁用
rule, // "collision" | "inclusion",碰撞规则,碰到就算选中还是全部包裹住才算选中
});
如何使用?
javascript
const Item = ({ value }: { value: number }) => {
const { setNodeRef, isSelected, isAdding } = useSelectable({
value,
});
return (
<div
ref={setNodeRef}
style={{
width: 50,
height: 50,
borderRadius: 4,
border: isAdding ? '1px solid #1677ff' : undefined,
background: isSelected ? '#1677ff' : '#ccc',
}}
/>
);
};
实现
这里只简单讲一下思路,有兴趣的同学可以直接前往源码进行阅读。
主组件 Selectable
相当于一个 context,一些状态在这里进行保存,并掌管每个 useSelectable
,将其需要的值通过 context 传递过去。
在设置的可被框选的容器内监听鼠标 mousedown
事件,记录其坐标,根据 mousemove
画出框选矩形,再根据 setNodeRef
收集的元素和框选矩形根据碰撞检测函数计算出是否被框选了,并将值更新到 Selectable
中去,最后在 mouseup
时触发 onEnd
,将值处理完之后并丢出去。
遇到的坑
这里分享一下遇到的坑的其中之一:框选的过程中会选中文字,很影响体验,怎么让这些文字不能被框选呢?
方案1: 用 user-select: none
来控制文本不可被选中,但是这是在用户侧来做,比较麻烦。并且发现在 chorme
下设置此属性后,拖拽框选到浏览器边缘或容器边缘后不会自动滚动,其它浏览器则正常
方案2: 在 mousedown
时设置 e.preventDefault()
,这样选中时文字就不会被选中,但是拖拽框选到浏览器边缘或容器边缘后不会自动滚动,只能自己实现了滚动逻辑。后面又发现在移动端的 touchstart
设置时,会发现页面上的点击事件都失效了,查资料发现没法解决,只能另辟蹊径。
方案3: 在 mousemove
和 touchmove
时设置 e.preventDefault()
也是可以的,但也需要自己实现滚动逻辑。
最终也是采取了方案3。
总结
开发一个较为复杂的组件,可以提交自己的 api 设计能力和解决问题的能力,可以将平常所学习、所了解、所使用的东西取其精华运用起来。最后希望这个组件能帮助到有需要的人,如果觉得不错,帮忙点个 star 吧,地址 react-selectable-box