網站首頁 編程語言 正文
正文
React DnD 是一個專注于數據變更的 React 拖拽庫,通俗的將,你拖拽改變的不是頁面視圖,而是數據。React DnD 不提供炫酷的拖動體驗,而是通過幫助我們管理拖拽中的數據變化,再由我們根據這些數據進行渲染。我們可能需要寫額外的視圖層來完成想要的效果,但是這種拖拽管理方式非常的通用,可以在任何場景下使用。初次使用可能感覺并不是那么方便,但是如果場景比較復雜,或者是需要高度定制,React DnD 一定是首選。
React DnD 的使用說明可以參見官方文檔。本文分析 React DnD 的源碼,更深層次的了解這個庫。以下的代碼來源于 react-dnd 14.0.4。
代碼結構
React-DnD 是單個代碼倉庫,但是打了多個包。這種方式也表示了 React DnD 的三層結構。
___________ ___________ _______________
| | | | | |
| | | | | backend-html |
| react-dnd | | dnd-core | | |
| | | | | backend-touch |
|___________| |___________| |_______________|
react-dnd 是 React 版本的 Drag and Drop 的實現。它定義了 DragSource, DropTarget, DragDropContext 等高階組件,以及 useDrag,useDrop 等 hook。我們可以簡單的理解為這是一個接入層。
dnd-core 是整個拖拽庫的核心,它實現了一個和框架無關的拖放管理器,定義了拖放的交互,根據 dnd-core 中定義的規則,我們完全可以根據它自己實現一個 vue-dnd。dnd-core 中使用 redux 做狀態管理。
backend 是 React DnD 抽象了后端的概念,這里是 DOM 事件轉換為 redux action 的地方。如果是 H5 應用,backend-html,如果是移動端,使用 backend-touch。也支持用戶自定義。
DndProvider
如果想要使用 React DnD,首先需要在外層元素上加一個 DndProvider。
import { HTML5Backend } from 'react-dnd-html5-backend';
import { DndProvider } from 'react-dnd';
<DndProvider backend={HTML5Backend}>
<TutorialApp />
</DndProvider>
DndProvider 的本質是一個由 React.createContext 創建一個上下文的容器(組件),用于控制拖拽的行為,數據的共享。DndProvider 的入參是一個 Backend。Backend 是什么呢?React DnD 將 DOM 事件相關的代碼獨立出來,將拖拽事件轉換為 React DnD 內部的 redux action。由于拖拽發生在 H5 的時候是 ondrag,發生在移動設備的時候是由 touch 模擬,React DnD 將這部分單獨抽出來,方便后續的擴展,這部分就叫做 Backend。它是 DnD 在 Dom 層的實現。
以下是 DndProvider 的核心代碼,通過入參生成一個 manager,這個 manager 用于控制拖拽行為。這個 manager 放到 Provider 中,子節點都可以訪問這個 manager。
export const DndProvider: FC<DndProviderProps<unknown, unknown>> = memo(
function DndProvider({ children, ...props }) {
const [manager, isGlobalInstance] = getDndContextValue(props)
...
return <DndContext.Provider value={manager}>{children}</DndContext.Provider>
},
)
DragDropManager
DndProvider 將 DndProvider 放到了 context 中,這個 manager 非常關鍵,后續的拖動都依賴于 manager,如下是它的創建過程。
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
}
首先看下 store 的創建過程,manager 中 store 的創建使用了 redux 的 createStore 方法,store 是用來以存放應用中所有的 state 的。它的第一個參數 reducer 接收兩個參數,分別是當前的 state 樹和要處理的 action,返回新的 state 樹。
function makeStoreInstance(): Store<State> {
return createStore(reduce)
}
manager 中的 store 管理著如下 state,每個 state 都有對應的方法進行更新。
export interface State {
dirtyHandlerIds: DirtyHandlerIdsState
dragOffset: DragOffsetState
refCount: RefCountState
dragOperation: DragOperationState
stateId: StateIdState
}
標準的 redux 更新數據的方法是 dispatch action 的方式。如下是 dragOffset 更新方法,判斷當前 action 的類型,從 payload 中獲得需要的參數,然后返回新的 state。
export function reduce(
state: State = initialState,
action: Action<{
sourceClientOffset: XYCoord
clientOffset: XYCoord
}>,
): State {
const { payload } = action
switch (action.type) {
case INIT_COORDS:
case BEGIN_DRAG:
return {
initialSourceClientOffset: payload.sourceClientOffset,
initialClientOffset: payload.clientOffset,
clientOffset: payload.clientOffset,
}
case HOVER:
...
case END_DRAG:
case DROP:
return initialState
default:
return state
}
}
接下來看 monitor,已知 store 表示的是拖拽過程中的數據,那么我們可以根據這些數據計算出當前的一些狀態,比如某個物體是否可以被拖動,某個物體是否正在懸空等等。monitor 提供了一些方法來訪問這些數據,不僅如此,monitor 最大的作用是用來監聽這些數據的,我們可以為 monitor 添加一些監聽器,這樣在數據變動之后就能及時響應。
如下列出了一些 monitor 中的方法。
export interface DragDropMonitor {
subscribeToStateChange(
listener: Listener,
options?: {
handlerIds: Identifier[] | undefined
},
): Unsubscribe
subscribeToOffsetChange(listener: Listener): Unsubscribe
canDragSource(sourceId: Identifier | undefined): boolean
canDropOnTarget(targetId: Identifier | undefined): boolean
isDragging(): boolean
isDraggingSource(sourceId: Identifier | undefined): boolean
getItemType(): Identifier | null
getItem(): any
getSourceId(): Identifier | null
getTargetIds(): Identifier[]
getDropResult(): any
didDrop(): boolean
...
}
subscribeToStateChange 就是添加監聽函數的方法,其原理是使用了 redux 的 subscribe 方法。
public subscribeToStateChange(
listener: Listener,
options: { handlerIds: string[] | undefined } = { handlerIds: undefined },
): Unsubscribe {
...
return this.store.subscribe(handleChange)
}
要注意的是,DragDropMonitor 是一個全局的 monitor,它監聽的范圍是 DndProvider 下所有可拖拽的元素,也就是 monitor 中會存在多個對象,這些拖拽對象有全局唯一性的 ID 標識(從 0 自增的 ID)。這也是 monitor 中的發部分方法都需要傳一個 Identifier 的原因。還有一點就是,最好不要存在多個 DndProvider,除非你確定不同 DndProvider 下拖拽元素一定不會交互。
我們在 DndProvider 傳入了一個參數 backend,其實它是個工廠方法,執行之后會生成真正的 backend。
manager 比較簡單,它包含了之前生成的 monitor, store, backend,還在初始化的時候為 store 添加了一個監聽器。它監聽 state 中的 refCount 方法, refCount 表示當前標記為可拖拽的對象,如果 refCount 大于 0,初始化 backend,否則,銷毀 backend。
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)
}
...
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
}
}
}
}
manager 創建完成,表示此時我們有了一個 store 來管理拖拽中的數據,有了 monitor 來監聽數據和控制行為,能通過 manager 進行注冊,可以通過 backend 將 Dom 事件轉換為 action。接下來就能使用 useDrag 來創建一個真正的可拖拽對象了。
useDrag
一個元素想要被拖拽,Hooks 的寫法如下,使用 useDrag 實現。useDrag 的入參和返回值可以參考官方文檔,這里不加贅述。
import { DragPreviewImage, useDrag } from 'react-dnd';
export const Knight: FC = () => {
const [{ isDragging }, drag, preview] = useDrag(
() => ({
type: ItemTypes.KNIGHT,
collect: (monitor) => ({
isDragging: !!monitor.isDragging()
})
}),
[]
);
return (
<>
<DragPreviewImage connect={preview} src={knightImage} />
<div
ref={drag}
>
?
</div>
</>
);
};
在 使用 useDrag 的時候,我們配置了入參,是一個函數,這個函數的返回值就是配置參數,useOptionalFactory 就是使用 useMemo 將這個方法包了一層,避免重復調用。
export function useDrag<DragObject, DropResult, CollectedProps>(
specArg: FactoryOrInstance<
DragSourceHookSpec<DragObject, DropResult, CollectedProps>
>,
deps?: unknown[],
): [CollectedProps, ConnectDragSource, ConnectDragPreview] {
// 獲得配置參數
const spec = useOptionalFactory(specArg, deps)
// 獲得 manager 中的 monitor 的包裝對象(DragSourceMonitor)
const monitor = useDragSourceMonitor<DragObject, DropResult>()
// 連接 dom 以及 redux
const connector = useDragSourceConnector(spec.options, spec.previewOptions)
// 生成唯一 id,封裝 DragSource 對象
useRegisteredDragSource(spec, monitor, connector)
return [
useCollectedProps(spec.collect, monitor, connector),
useConnectDragSource(connector),
useConnectDragPreview(connector),
]
}
原先在 manager 中的 monitor 類型是 DragDropMonitor,看名字就知道,該 monitor 中的方法是結合了 Drag 和 Drop 兩種行為的,目前只是使用 Drag,因此將 monitor 包裝一下,屏蔽 Drop 的行為。使其類型變為 DragSourceMonitor。 這就是 useDragSourceMonitor 做的事情,
export function useDragSourceMonitor<O, R>(): DragSourceMonitor<O, R> {
const manager = useDragDropManager()
return useMemo(() => new DragSourceMonitorImpl(manager), [manager])
}
以上,我們有 Backend 控制 Dom 層級的行為,Store 和 Monitor 控制數據層的變化,那如何讓 Monitor 知道現在要監聽到底是哪個節點,還需要將這兩者連接起來,才能真正的讓 Dom 層和數據層保持一致,React DnD 中使用 connector 來連接著兩者。
useDragSourceConnector 方法中會 new 一個 SourceConnector 的實例,該實例會接受 backend 作為入參,SourceConnector 實現了 Connector 接口。Connector 中成員變量不多,最重要就是 hooks 對象,該對象用于處理 ref 的邏輯。
export interface Connector {
// 獲得 ref 指向的 Dom
hooks: any
// 獲得 dragSource
connectTarget: any
// dragSource 唯一 Id
receiveHandlerId(handlerId: Identifier | null): void
// 重新連接 dragSource 和 dom
reconnect(): void
}
我們在例子中將 ref 屬性給到了一個 useDrag 的返回值。該返回值其實就是 hooks 中的 dragSource 方法。
export function useConnectDragSource(connector: SourceConnector) {
return useMemo(() => connector.hooks.dragSource(), [connector])
}
從 dragSource 方法可以看出,connector 中將這個 Dom 節點維護在了 dragSourceNode 屬性上。
export class SourceConnector implements Connector {
// wrapConnectorHooks 判斷 ref 節點是否是合法的 ReactElement,是的話,執行回調方法
public hooks = wrapConnectorHooks({
dragSource: (
node: Element | ReactElement | Ref<any>,
options?: DragSourceOptions,
) => {
// dragSourceRef 和 dragSourceNode 賦值 null
this.clearDragSource()
this.dragSourceOptions = options || null
if (isRef(node)) {
this.dragSourceRef = node as RefObject<any>
} else {
this.dragSourceNode = node
}
this.reconnectDragSource()
},
...
})
...
}
獲得節點后,調用 this.reconnectDragSource(),該方法中,backend 調用 connectDragSource 方法為該節點添加事件監聽,后續會分析 backend。
private reconnectDragSource() {
const dragSource = this.dragSource
...
if (didChange) {
...
this.dragSourceUnsubscribe = this.backend.connectDragSource(
this.handlerId,
dragSource,
this.dragSourceOptions,
)
}
}
現在還需要對 Dom 進行抽象,生成唯一ID, 封裝為 DragSource 注冊到 monitor 上。
export function useRegisteredDragSource<O, R, P>(
spec: DragSourceHookSpec<O, R, P>,
monitor: DragSourceMonitor<O, R>,
connector: SourceConnector,
): void {
const manager = useDragDropManager()
// 生成 DragSource
const handler = useDragSource(spec, monitor, connector)
const itemType = useDragType(spec)
// useLayoutEffect
useIsomorphicLayoutEffect(
function registerDragSource() {
if (itemType != null) {
// DragSource 注冊到 monitor
const [handlerId, unregister] = registerSource(
itemType,
handler,
manager,
)
// 更新唯一 ID,觸發 reconnect 邏輯
monitor.receiveHandlerId(handlerId)
connector.receiveHandlerId(handlerId)
return unregister
}
},
[manager, monitor, connector, handler, itemType],
)
}
DragSource 實現以下幾個方法,這個幾個方法我們使用 useDarg 的時候可以配置同名函數,這些配置的方法會被以下方法調用。
export interface DragSource {
beginDrag(monitor: DragDropMonitor, targetId: Identifier): void
endDrag(monitor: DragDropMonitor, targetId: Identifier): void
canDrag(monitor: DragDropMonitor, targetId: Identifier): boolean
isDragging(monitor: DragDropMonitor, targetId: Identifier): boolean
}
總結下 useDarg 做的事情,首先就是支持一些配置參數,這是最基礎的,然后獲得 Provider 中的 managre,對其中的一些對象進行包裝,屏蔽一些方法,增加一些參數。最重要的就是創建 connector,在界面加載完畢后,connector 通過 ref 的方式獲得 Dom 節點的實例,為該節點添加拖拽屬性和拖拽事件。同時根據配置參數和 connector 封裝 DragSource 對象,將其注冊到 monitor 中。
useDrop 和 useDrag 的流程大同小異,大家可以自己看。
HTML5Backend
之前為 DndProvider 注入的參數 HTML5Backend,其實是個工程方法,我們在 DndProvider 除了可以配置 backend 外,還可以配置 backend 的一些參數,當然,backend 的實現不同,傳參也不同。DragDropManager 會根據這些參數初始化真正的 backend。
export const HTML5Backend: BackendFactory = function createBackend(
manager: DragDropManager,
context?: HTML5BackendContext,
options?: HTML5BackendOptions,
): HTML5BackendImpl {
return new HTML5BackendImpl(manager, context, options)
}
如下是 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 銷毀方法。上文提到過,setup 和 teardown 是在 handleRefCountChange 中執行的。React DnD 會在我們第一個使用 useDrag 或是 useDrop 的時候,執行 setup 方法,而在它檢測到沒有任何地方在使用拖拽功能的時候,執行 teardown 方法。
HTML5BackendImpl 的 setup 方法中執行如下方法,target 默認狀態下指的是 window。這里監聽了所有的拖拽事件。這是典型的事件委托的方式,統一將拖拽事件的回調函數都綁定在 window 上,不僅能提高性能,而且極大的降低了事件銷毀的難度。
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, true)
target.addEventListener('drop', this.handleTopDrop as EventListener)
target.addEventListener(
'drop',
this.handleTopDropCapture as EventListener,
true,
)
}
HTML5Backend 拖拽的監聽函數就是獲得拖拽事件的對象,拿到相應的參數。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 方法。該方法用于將某個 Node 節點轉換為可拖拽節點,并且添加監聽事件。
HTML5Backend 使用 HTML5 拖放 API 實現。 首先:為了把一個元素設置為可拖放,把 draggable 屬性設置為 true。然后監聽 ondragstart 事件,該事件在用戶開始拖動元素時觸發。至于 selectstart,不用關心,是用來處理一些 IE 特殊情況的。
public connectDragSource(
sourceId: string,
node: Element,
options: any,
): Unsubscribe {
...
// 設置 draggable 屬性
node.setAttribute('draggable', 'true')
// 添加 dragstart 監聽
node.addEventListener('dragstart', handleDragStart)
// 添加 selectstart 監聽
node.addEventListener('selectstart', handleSelectStart)
...
}
Node 上綁定的 dragstart 事件很簡單,就是更新了下 sourceId。負責的邏輯綁定在了 window 上。
public handleDragStart(e: DragEvent, sourceId: string): void {
if (e.defaultPrevented) {
return
}
if (!this.dragStartSourceIds) {
this.dragStartSourceIds = []
}
this.dragStartSourceIds.unshift(sourceId)
}
綜上,HTML5Backend 在初始化的時候在 window 上綁定拖拽事件的監聽函數,處理拖拽中的坐標數據,狀態數據,并將其轉換為 action 交由上層的 store 處理。完成由 Dom 事件到數據的轉變。元素上綁定的監聽只負責更新 sourceId。
TouchBackend
最后簡單的看下 TouchBackend,與 HTML5Backend 相比,TouchBackend 的使用場景更加廣泛,因為它不依賴于 H5 的 API,兼容性很好,既能用于瀏覽器端,又能用在移動端。
TouchBackend 使用簡單的事件來模擬拖放行為。比如在瀏覽器端,使用的是 mousedown,mousemove,mouseup。移動端使用 touchstart,touchmove,touchend。
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 使用了分層設計的方式,react-dnd 是接入層,它為準備了高階組件和 Hooks 兩種方式。dnd-core 是核心,它定義了拖拽接口,管理方式,數據流向。backend 中將 DOM 事件轉換為 redux action 的地方,該層用于屏蔽設備之間的差異性。
dnd-core 使用了 redux 管理數據,這些數據通過 dispatch action 進行修改,使用 monitor 進行數據的監控,使用 connector 連接 dom 和 store。最終拖拽實現依賴于 backend,為節點添加了監聽事件,然后將事件轉化為 action。
整體上看,React-DnD 的核心思路就是將事件轉換為數據,設計上參考了 redux 的單一數據流的方式(畢竟一個作者寫的),這樣我們在處理拖拽的時候就可以關注于數據方面的變化,而不用費心去維護拖拽中的一些中間狀態,更不用自己去添加,移除事件,是非常好的一種設計。
原文鏈接:https://zhuanlan.zhihu.com/p/429986799
相關推薦
- 2022-02-28 ./node_modules/taro-ui/dist/weapp/index.ts Module
- 2022-03-17 C#表達式樹Expression基礎講解_C#教程
- 2022-07-28 Golang配置管理庫?Viper的教程詳解_Golang
- 2023-11-20 python設置matplotlib.plot的坐標刻度和坐標范圍
- 2022-07-28 聊聊docker跨主機之間容器通信問題_docker
- 2022-12-04 python?使用enumerate()函數詳解_python
- 2022-04-18 uniapp中使用拷貝,復制粘貼功能,uniapp,隱藏軟鍵盤
- 2022-06-11 C#把EXCEL數據轉換成DataTable_C#教程
- 最近更新
-
- window11 系統安裝 yarn
- 超詳細win安裝深度學習環境2025年最新版(
- Linux 中運行的top命令 怎么退出?
- MySQL 中decimal 的用法? 存儲小
- get 、set 、toString 方法的使
- @Resource和 @Autowired注解
- Java基礎操作-- 運算符,流程控制 Flo
- 1. Int 和Integer 的區別,Jav
- spring @retryable不生效的一種
- Spring Security之認證信息的處理
- Spring Security之認證過濾器
- Spring Security概述快速入門
- Spring Security之配置體系
- 【SpringBoot】SpringCache
- Spring Security之基于方法配置權
- redisson分布式鎖中waittime的設
- maven:解決release錯誤:Artif
- restTemplate使用總結
- Spring Security之安全異常處理
- MybatisPlus優雅實現加密?
- Spring ioc容器與Bean的生命周期。
- 【探索SpringCloud】服務發現-Nac
- Spring Security之基于HttpR
- Redis 底層數據結構-簡單動態字符串(SD
- arthas操作spring被代理目標對象命令
- Spring中的單例模式應用詳解
- 聊聊消息隊列,發送消息的4種方式
- bootspring第三方資源配置管理
- GIT同步修改后的遠程分支