共计 13376 个字符,预计需要花费 34 分钟才能阅读完成。
咱们是袋鼠云数栈 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