我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。。
本文作者:霽明
業務中會有一些需要實現拖拽的場景,尤其是偏視覺方向以及行動端較多。拖拽在一定程度上能讓互動更加便捷,能大大提升使用者體驗。以業務中心子產品設定功能為例,產品模組通過拖拽來調整順序,的確會更加方便一些。
參照官網介紹:
React DnD 是一組 React 實用程式,可幫助您構建複雜的拖放介面,同時保持元件分離。 它非常適合 Trello 和 Storify 等應用程式,在應用程式的不同部分之間拖動可以傳輸資料,元件會根據拖放事件更改其外觀和應用程式狀態。
React-DnD 特點:
安裝 react-dnd, react-dnd-html5-backend
npm install react-dnd react-dnd-html5-backend
將需要拖拽的元件使用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>
);
}
接下來看下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,接下來我們來看看實現原理。
主要程式碼程式碼目錄結構
核心程式碼主要分三個部分:
核心實現原理:
dnd-core向backend提供資料的更新方法,backend在拖拽時更新dnd-core中的資料,dnd-core通過react-dnd更新業務元件。
先看一下原始碼
/**
* 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。
看下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:
manager包含了之前生成的 monitor、store、backend,manager 建立完成,表示此時我們有了一個 store 來管理拖拽中的資料,有了 monitor 來監聽資料和控制行為,能通過 manager 進行註冊,可以通過 backend 將 DOM 事件轉換為 action。接下來便可以註冊拖拽源和放置源了。
/**
* 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方法會對拖動源進行註冊,會儲存拖動源範例,並記錄註冊的數量。
看下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類似。
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 事件到資料的轉變。
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',
},
}
React-DnD 採用了分層設計,react-dnd充當接入層,dnd-core實現拖拽介面、定義拖拽行為、管理資料流向,backend將DOM事件通過redux action轉換為資料。
使用可插入的方式引入backend,使拖拽的實現可延伸且更加靈活。
使用了單向資料流,在拖拽時不用處理中間狀態,不用額外對DOM事件進行處理,只需專注於資料的變化。
React-DnD對backend的實現方式、資料的管理方式,以及整體的設計都值得借鑑。
歡迎關注【袋鼠雲數棧UED團隊】~
袋鼠雲數棧UED團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎star