咱们是袋鼠云数栈 UED 团队,致力于打造优良的一站式数据中台产品。咱们始终保持工匠精力,摸索前端路线,为社区积攒并流传教训价值。。
本文作者:霁明
一、背景
1、业务背景
业务中会有一些须要实现拖拽的场景,尤其是偏视觉方向以及挪动端较多。拖拽在肯定水平上能让交互更加便捷,能大大晋升用户体验。以业务核心子产品配置性能为例,产品模块通过拖拽来调整程序,确实会更加不便一些。
2、React DnD 介绍
援用官网介绍:
React DnD 是一组 React 实用程序,可帮忙您构建简单的拖放界面,同时放弃组件拆散。 它非常适合 Trello 和 Storify 等应用程序,在应用程序的不同局部之间拖动能够传输数据,组件会依据拖放事件更改其外观和应用程序状态。
React-DnD 特点:
- 应用包裹及注入的形式使组件实现拖拽
- 可用于构建简单的拖放界面,同时放弃组件拆散
- 采纳单向数据流
- 抹平了不同浏览器平台的差别
- 可扩大可测试
反对触屏操作
二、应用形式
1、装置
装置 react-dnd, react-dnd-html5-backend
npm install react-dnd react-dnd-html5-backend
2、DndProvider
将须要拖拽的组件应用
DndProvider
进行包裹import { DndProvider } from 'react-dnd';import { HTML5Backend } from 'react-dnd-html5-backend';import Container from '../components/container';export default function App() {return ( <DndProvider backend={HTML5Backend}> <Container /> </DndProvider>);}
看下
Container
组件,次要是治理数据,并渲染Card
列表function Container() {// ...return ( <div style={{ width: 400 }}> {cards.map((card, index) => ( <Card key={card.id} index={index} id={card.id} text={card.text} moveCard={moveCard} /> ))} </div>);}
3、useDrag和useDrop
接下来看下
Card
组件,import { useRef } from 'react';import { useDrag, useDrop } from 'react-dnd';import styles from '../styles/home.module.css';function Card({ id, text, index, moveCard }: ICardProps) {const ref = useRef<HTMLDivElement>(null);const [{ handlerId }, drop] = useDrop({ accept: CARD, collect(monitor) { return { handlerId: monitor.getHandlerId(), }; }, hover(item: IDragItem, monitor) { if (!ref.current) { return; } const dragIndex = item.index; const hoverIndex = index; // ... // 更新元素的地位 moveCard(dragIndex, hoverIndex); // ... },});const [{ isDragging }, drag] = useDrag({ type: CARD, item: { id, index }, collect: (monitor: any) => ({ isDragging: monitor.isDragging(), }),});drag(drop(ref));const opacity = isDragging ? 0 : 1;return ( <div ref={ref} className={styles.card} style={{ opacity }} data-handler-id={handlerId} > {text} </div>);}
至此一个简略的拖拽排序列表就实现了,实现的成果相似于React DnD官网的这个示例:https://react-dnd.github.io/react-dnd/examples/sortable/simple,接下来咱们来看看实现原理。
三、原理解析
1、总体架构
次要代码代码目录构造
外围代码次要分三个局部:
- dnd-core:外围逻辑,定义了拖拽接口、治理形式、数据流向
- backend:形象进去的后端概念,次要解决DOM事件
- react-dnd:封装React组件,提供api,相当于接入层
外围实现原理:
dnd-core向backend提供数据的更新办法,backend在拖拽时更新dnd-core中的数据,dnd-core通过react-dnd更新业务组件。
2、DndProvider
先看一下源码
/** * A React component that provides the React-DnD context */export const DndProvider: FC<DndProviderProps<unknown, unknown>> = memo( function DndProvider({ children, ...props }) { const [manager, isGlobalInstance] = getDndContextValue(props) // memoized from props // ... return <DndContext.Provider value={manager}>{children}</DndContext.Provider> },)
从以上代码能够看出,生成了一个manager
,并将其放到DndContext.Provider
中。先看下DndContext
的代码:
import { createContext } from 'react'// ...export const DndContext = createContext<DndContextType>({ dragDropManager: undefined,})
就是应用 React 的createContext
创立的上下文容器组件。
接下来看下这个manager,次要是用来管制拖拽行为,通过Provider让子节点也能够拜访。咱们看下创立manager的getDndContextValue
办法:
import type { BackendFactory, DragDropManager } from 'dnd-core'import { createDragDropManager } from 'dnd-core'// ...function getDndContextValue(props: DndProviderProps<unknown, unknown>) { if ('manager' in props) { const manager = { dragDropManager: props.manager } return [manager, false] } const manager = createSingletonDndContext( props.backend, props.context, props.options, props.debugMode, ) const isGlobalInstance = !props.context return [manager, isGlobalInstance]}function createSingletonDndContext<BackendContext, BackendOptions>( backend: BackendFactory, context: BackendContext = getGlobalContext(), options: BackendOptions, debugMode?: boolean,) { const ctx = context as any if (!ctx[INSTANCE_SYM]) { ctx[INSTANCE_SYM] = { dragDropManager: createDragDropManager( backend, context, options, debugMode, ), } } return ctx[INSTANCE_SYM]}
从以上代码能够看出,getDndContextValue
办法又调用了createSingletonDndContext
办法,并传入了backend、context、options、debugMode这几个属性,而后通过dnd-core中的createDragDropManager
来创立manager。
3、DragDropManager
看下createDragDropManager.js中的次要代码
import type { Store } from 'redux'import { createStore } from 'redux'// ...import { reduce } from './reducers/index.js'export function createDragDropManager( backendFactory: BackendFactory, globalContext: unknown = undefined, backendOptions: unknown = {}, debugMode = false,): DragDropManager { const store = makeStoreInstance(debugMode) const monitor = new DragDropMonitorImpl(store, new HandlerRegistryImpl(store)) const manager = new DragDropManagerImpl(store, monitor) const backend = backendFactory(manager, globalContext, backendOptions) manager.receiveBackend(backend) return manager}function makeStoreInstance(debugMode: boolean): Store<State> { // ... return createStore( reduce, debugMode && reduxDevTools && reduxDevTools({ name: 'dnd-core', instanceId: 'dnd-core', }), )}
能够看到应用了redux的createStore创立了store,并创立了monitor和manager实例,通过backendFactory创立backend后端实例并装置到manager总实例。
看一下DragDropManagerImpl的次要代码
export class DragDropManagerImpl implements DragDropManager { private store: Store<State> private monitor: DragDropMonitor private backend: Backend | undefined private isSetUp = false public constructor(store: Store<State>, monitor: DragDropMonitor) { this.store = store this.monitor = monitor store.subscribe(this.handleRefCountChange) } // ... public getActions(): DragDropActions { /* eslint-disable-next-line @typescript-eslint/no-this-alias */ const manager = this const { dispatch } = this.store function bindActionCreator(actionCreator: ActionCreator<any>) { return (...args: any[]) => { const action = actionCreator.apply(manager, args as any) if (typeof action !== 'undefined') { dispatch(action) } } } const actions = createDragDropActions(this) return Object.keys(actions).reduce( (boundActions: DragDropActions, key: string) => { const action: ActionCreator<any> = (actions as any)[ key ] as ActionCreator<any> ;(boundActions as any)[key] = bindActionCreator(action) return boundActions }, {} as DragDropActions, ) } public dispatch(action: Action<any>): void { this.store.dispatch(action) } private handleRefCountChange = (): void => { const shouldSetUp = this.store.getState().refCount > 0 if (this.backend) { if (shouldSetUp && !this.isSetUp) { this.backend.setup() this.isSetUp = true } else if (!shouldSetUp && this.isSetUp) { this.backend.teardown() this.isSetUp = false } } }}
先说一下这个handleRefCountChange办法,在构造函数里通过store进行订阅,在第一次应用useDrop或useDrag时会执行setup办法初始化backend,在拖拽源和搁置源都被卸载时则会执行teardown销毁backend。
接下来看一下createDragDropActions办法
export function createDragDropActions( manager: DragDropManager,): DragDropActions { return { beginDrag: createBeginDrag(manager), publishDragSource: createPublishDragSource(manager), hover: createHover(manager), drop: createDrop(manager), endDrag: createEndDrag(manager), }}
能够看到绑定一些action:
- beginDrag(开始拖动)
- publishDragSource(公布以后拖动源)
- hover(是否通过)
- drop(落下动作)
- endDrag(拖拽完结)
manager蕴含了之前生成的 monitor、store、backend,manager 创立实现,示意此时咱们有了一个 store 来治理拖拽中的数据,有了 monitor 来监听数据和管制行为,能通过 manager 进行注册,能够通过 backend 将 DOM 事件转换为 action。接下来便能够注册拖拽源和搁置源了。
4、useDrag
/** * useDragSource hook * @param sourceSpec The drag source specification (object or function, function preferred) * @param deps The memoization deps array to use when evaluating spec changes */export function useDrag< DragObject = unknown, DropResult = unknown, CollectedProps = unknown,>( specArg: FactoryOrInstance< DragSourceHookSpec<DragObject, DropResult, CollectedProps> >, deps?: unknown[],): [CollectedProps, ConnectDragSource, ConnectDragPreview] { const spec = useOptionalFactory(specArg, deps) invariant( !(spec as any).begin, 'useDrag::spec.begin was deprecated in v14. Replace spec.begin() with spec.item(). (see more here - https://react-dnd.github.io/react-dnd/docs/api/use-drag)', ) const monitor = useDragSourceMonitor<DragObject, DropResult>() const connector = useDragSourceConnector(spec.options, spec.previewOptions) useRegisteredDragSource(spec, monitor, connector) return [ useCollectedProps(spec.collect, monitor, connector), useConnectDragSource(connector), useConnectDragPreview(connector), ]}
能够看到useDrag
办法返回了一个蕴含3个元素的数组,CollectedProps(collect办法返回的对象)、ConnectDragSource(拖拽源连接器)、ConnectDragPreview(拖拽源预览)。
monitor是从后面Provider中的manager中获取的,次要看下connector
export function useDragSourceConnector( dragSourceOptions: DragSourceOptions | undefined, dragPreviewOptions: DragPreviewOptions | undefined,): SourceConnector { const manager = useDragDropManager() const connector = useMemo( () => new SourceConnector(manager.getBackend()), [manager], ) // ... return connector}
能够看到connector获取了manager.getBackend后端的数据。
useRegisteredDragSource办法会对拖动源进行注册,会保留拖动源实例,并记录注册的数量。
5、useDrop
看下useDrop源码
/** * useDropTarget Hook * @param spec The drop target specification (object or function, function preferred) * @param deps The memoization deps array to use when evaluating spec changes */export function useDrop< DragObject = unknown, DropResult = unknown, CollectedProps = unknown,>( specArg: FactoryOrInstance< DropTargetHookSpec<DragObject, DropResult, CollectedProps> >, deps?: unknown[],): [CollectedProps, ConnectDropTarget] { const spec = useOptionalFactory(specArg, deps) const monitor = useDropTargetMonitor<DragObject, DropResult>() const connector = useDropTargetConnector(spec.options) useRegisteredDropTarget(spec, monitor, connector) return [ useCollectedProps(spec.collect, monitor, connector), useConnectDropTarget(connector), ]}
useDrop返回了一个蕴含2个元素的数组,CollectedProps(collect办法返回的对象), ConnectDropTarget(搁置源连接器),monitor和connector的获取都和useDrag相似。
6、HTML5Backend
HTML5Backend应用了HTML5 拖放 API,先理解下HTML拖拽事件:
一个简略拖拽操作过程,会顺次触发拖拽事件:dragstart -> drag -> dragenter -> dragover (-> dragleave) -> drop -> dragend。
drag事件会在dragstar触发后继续触发,直至drop。
dragleave事件会在拖拽元素来到一个可开释指标时触发。
接下来介绍一下HTML5Backend,是React DnD 次要反对的后端,应用HTML5 拖放 API,它会截取拖动的 DOM 节点并将其用作开箱即用的“拖动预览”。React DnD 中以可插入的形式实现 HTML5 拖放反对,能够依据触摸事件、鼠标事件或其余齐全不同的事件编写不同的实现,这种可插入的实现在 React DnD 中称为后端。官网提供了HTML5Backend和TouchBackend,别离用来反对web端和挪动端。
后端负责与 React 的合成事件零碎相似的角色:它们形象出浏览器差别并解决原生DOM 事件。只管有相似之处,但 React DnD 后端并不依赖于 React 或其合成事件零碎。在后盾,后端所做的就是将 DOM 事件转换为 React DnD 能够解决的外部 Redux 操作。
后面给DndProvider传递的HTML5backend,看一下其代码实现:
export const HTML5Backend: BackendFactory = function createBackend( manager: DragDropManager, context?: HTML5BackendContext, options?: HTML5BackendOptions,): HTML5BackendImpl { return new HTML5BackendImpl(manager, context, options)}
能够看到其实是个返回HTML5BackendImpl
实例的函数,在创立manager实例时会执行createBackend办法创立真正的backend。
如下是 Backend 须要被实现的办法:
export interface Backend { setup(): void teardown(): void connectDragSource(sourceId: any, node?: any, options?: any): Unsubscribe connectDragPreview(sourceId: any, node?: any, options?: any): Unsubscribe connectDropTarget(targetId: any, node?: any, options?: any): Unsubscribe profile(): Record<string, number>}
setup 是 backend 的初始化办法,teardown 是 backend 销毁办法。connectDragSource办法将元素转换为可拖拽元素,并增加监听事件。connectDropTarget办法会给元素增加监听事件,connectDragPreview办法会将preview元素保留以供监听函数应用,profile办法用于返回一些简要的统计信息。
以上这几个办法都在HTML5BackendImpl中,咱们先看一下setup办法:
public setup(): void { const root = this.rootElement as RootNode | undefined if (root === undefined) { return } if (root.__isReactDndBackendSetUp) { throw new Error('Cannot have two HTML5 backends at the same time.') } root.__isReactDndBackendSetUp = true this.addEventListeners(root)}
root默认是windows,通过addEventListeners办法把监听事件都绑定到windows上,这进步了性能也升高了事件销毁的难度。
看下addEventListeners办法:
private addEventListeners(target: Node) { if (!target.addEventListener) { return } target.addEventListener( 'dragstart', this.handleTopDragStart as EventListener, ) target.addEventListener('dragstart', this.handleTopDragStartCapture, true) target.addEventListener('dragend', this.handleTopDragEndCapture, true) target.addEventListener( 'dragenter', this.handleTopDragEnter as EventListener, ) target.addEventListener( 'dragenter', this.handleTopDragEnterCapture as EventListener, true, ) target.addEventListener( 'dragleave', this.handleTopDragLeaveCapture as EventListener, true, ) target.addEventListener('dragover', this.handleTopDragOver as EventListener) target.addEventListener( 'dragover', this.handleTopDragOverCapture as EventListener, true, ) target.addEventListener('drop', this.handleTopDrop as EventListener) target.addEventListener( 'drop', this.handleTopDropCapture as EventListener, true, )}
以上代码中监听了一些拖拽事件,这些监听函数会取得拖拽事件的对象、拿到相应的参数,并执行相应的action办法。HTML5Backend 通过 manager 拿到一个 DragDropActions 的实例,执行其中的办法。DragDropActions 实质就是依据参数将其封装为一个 action,最终通过 redux 的 dispatch 将 action 散发,扭转 store 中的数据。
export interface DragDropActions { beginDrag( sourceIds?: Identifier[], options?: any, ): Action<BeginDragPayload> | undefined publishDragSource(): SentinelAction | undefined hover(targetIds: Identifier[], options?: any): Action<HoverPayload> drop(options?: any): void endDrag(): SentinelAction}
最初咱们再看下connectDragSource办法:
public connectDragSource( sourceId: string, node: Element, options: any,): Unsubscribe { // ... node.setAttribute('draggable', 'true') node.addEventListener('dragstart', handleDragStart) node.addEventListener('selectstart', handleSelectStart) return (): void => { // ... node.removeEventListener('dragstart', handleDragStart) node.removeEventListener('selectstart', handleSelectStart) node.setAttribute('draggable', 'false') }}
能够看到次要是把节点的draggable属性设置为true,并增加监听事件,返回一个Unsubscribe函数用于执行销毁。
综上,HTML5Backend 在初始化的时候在 window 对象上绑定拖拽事件的监听函数,拖拽事件触发时执行对应action,更新 store 中的数据,实现由 Dom 事件到数据的转变。
7、TouchBackend
HTML5 后端不反对触摸事件,因而它不适用于平板电脑和挪动设施。能够应用react-dnd-touch-backend
来反对触摸设施,简略看下ToucheBackend。
ToucheBackend次要是为了反对挪动端,也反对web端,在web端能够应用 mousedown、mousemove、mouseup,在挪动端则应用 touchstart、touchmove、touchend,上面是ToucheBackend中对事件的定义:
const eventNames: Record<ListenerType, EventName> = { [ListenerType.mouse]: { start: 'mousedown', move: 'mousemove', end: 'mouseup', contextmenu: 'contextmenu', }, [ListenerType.touch]: { start: 'touchstart', move: 'touchmove', end: 'touchend', }, [ListenerType.keyboard]: { keydown: 'keydown', },}
8、次要拖拽过程
四、总结
React-DnD 采纳了分层设计,react-dnd充当接入层,dnd-core实现拖拽接口、定义拖拽行为、治理数据流向,backend将DOM事件通过redux action转换为数据。
应用可插入的形式引入backend,使拖拽的实现可扩大且更加灵便。
应用了单向数据流,在拖拽时不必解决中间状态,不必额定对DOM事件进行解决,只需专一于数据的变动。
React-DnD对backend的实现形式、数据的治理形式,以及整体的设计都值得借鉴。
五、参考链接:
- https://github.com/react-dnd/react-dnd/
- https://react-dnd.github.io/react-dnd
- https://developer.mozilla.org/zh-CN/docs/Web/API/HTML_Drag_and_Drop_API
- https://zhuanlan.zhihu.com/p/429986799
- https://juejin.cn/post/6885511137236877325
最初
欢送关注【袋鼠云数栈UED团队】~
袋鼠云数栈UED团队继续为宽广开发者分享技术成绩,相继参加开源了欢送star
- 大数据分布式任务调度零碎——Taier
- 轻量级的 Web IDE UI 框架——Molecule
- 针对大数据畛域的 SQL Parser 我的项目——dt-sql-parser
- 袋鼠云数栈前端团队代码评审工程实际文档——code-review-practices
- 一个速度更快、配置更灵便、应用更简略的模块打包器——ko