离木 發表於 2023-6-9 11:55:00

深入解析React DnD拖拽原理,轻松掌握拖放技巧!

<blockquote>
<p>我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。。</p>
</blockquote>
<blockquote>
<p>本文作者:霁明</p>
</blockquote>
<h1 id="一背景">一、背景</h1>
<h2 id="1业务背景">1、业务背景</h2>
<p>业务中会有一些需要实现拖拽的场景,尤其是偏视觉方向以及移动端较多。拖拽在一定程度上能让交互更加便捷,能大大提升用户体验。以业务中心子产品配置功能为例,产品模块通过拖拽来调整顺序,的确会更加方便一些。</p>
<p><img src="https://img2023.cnblogs.com/other/2332333/202306/2332333-20230609115551682-1619305521.png"></p>
<h2 id="2react-dnd-介绍">2、React DnD 介绍</h2>
<p>引用官网介绍:<br>
React DnD 是一组 React 实用程序,可帮助您构建复杂的拖放界面,同时保持组件分离。 它非常适合 Trello 和 Storify 等应用程序,在应用程序的不同部分之间拖动可以传输数据,组件会根据拖放事件更改其外观和应用程序状态。<br>
React-DnD 特点:</p>
<ul>
<li>使用包裹及注入的方式使组件实现拖拽</li>
<li>可用于构建复杂的拖放界面,同时保持组件分离</li>
<li>采用单向数据流</li>
<li>抹平了不同浏览器平台的差异</li>
<li>可扩展可测试</li>
<li>支持触屏操作</li>
</ul>
<h1 id="二使用方式">二、使用方式</h1>
<h2 id="1安装">1、安装</h2>
<p>安装 react-dnd, react-dnd-html5-backend</p>
<pre><code class="language-shell">npm install react-dnd react-dnd-html5-backend
</code></pre>
<h2 id="2dndprovider">2、DndProvider</h2>
<p>将需要拖拽的组件使用<code>DndProvider</code>进行包裹</p>
<pre><code class="language-tsx">import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import Container from '../components/container';

export default function App() {
return (
    &lt;DndProvider backend={HTML5Backend}&gt;
      &lt;Container /&gt;
    &lt;/DndProvider&gt;
);
}

</code></pre>
<p>看下<code>Container</code>组件,主要是管理数据,并渲染<code>Card</code>列表</p>
<pre><code class="language-tsx">function Container() {
// ...
return (
    &lt;div style={{ width: 400 }}&gt;
      {cards.map((card, index) =&gt; (
      &lt;Card
          key={card.id}
          index={index}
          id={card.id}
          text={card.text}
          moveCard={moveCard}
      /&gt;
      ))}
    &lt;/div&gt;
);
}
</code></pre>
<h2 id="3usedrag和usedrop">3、useDrag和useDrop</h2>
<p>接下来看下<code>Card</code>组件,</p>
<pre><code class="language-tsx">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&lt;HTMLDivElement&gt;(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) =&gt; ({
      isDragging: monitor.isDragging(),
    }),
});

drag(drop(ref));
const opacity = isDragging ? 0 : 1;

return (
    &lt;div
      ref={ref}
      className={styles.card}
      style={{ opacity }}
      data-handler-id={handlerId}
    &gt;
      {text}
    &lt;/div&gt;
);
}
</code></pre>
<p>至此一个简单的拖拽排序列表就实现了,实现的效果类似于React DnD官网的这个示例:https://react-dnd.github.io/react-dnd/examples/sortable/simple,接下来我们来看看实现原理。</p>
<h1 id="三原理解析">三、原理解析</h1>
<h2 id="1总体架构">1、总体架构</h2>
<p>主要代码代码目录结构</p>
<p><img src="https://img2023.cnblogs.com/other/2332333/202306/2332333-20230609115552020-86481906.png"></p>
<p>核心代码主要分三个部分:</p>
<ul>
<li>dnd-core:核心逻辑,定义了拖拽接口、管理方式、数据流向</li>
<li>backend:抽象出来的后端概念,主要处理DOM事件</li>
<li>react-dnd:封装React组件,提供api,相当于接入层</li>
</ul>
<p>核心实现原理:<br>
dnd-core向backend提供数据的更新方法,backend在拖拽时更新dnd-core中的数据,dnd-core通过react-dnd更新业务组件。</p>
<p><img src="https://img2023.cnblogs.com/other/2332333/202306/2332333-20230609115552463-172441983.png"></p>
<h2 id="2dndprovider-1">2、DndProvider</h2>
<p>先看一下源码</p>
<pre><code class="language-tsx">/**
* A React component that provides the React-DnD context
*/
export const DndProvider: FC&lt;DndProviderProps&lt;unknown, unknown&gt;&gt; = memo(
function DndProvider({ children, ...props }) {
    const = getDndContextValue(props) // memoized from props
    // ...
    return &lt;DndContext.Provider value={manager}&gt;{children}&lt;/DndContext.Provider&gt;
},
)
</code></pre>
<p>从以上代码可以看出,生成了一个<code>manager</code>,并将其放到<code>DndContext.Provider</code>中。先看下<code>DndContext</code>的代码:</p>
<pre><code class="language-tsx">import { createContext } from 'react'
// ...
export const DndContext = createContext&lt;DndContextType&gt;({
dragDropManager: undefined,
})
</code></pre>
<p>就是使用 React 的<code>createContext</code>创建的上下文容器组件。</p>
<p>接下来看下这个manager,主要是用来控制拖拽行为,通过Provider让子节点也可以访问。我们看下创建manager的<code>getDndContextValue</code>方法:</p>
<pre><code class="language-tsx">import type { BackendFactory, DragDropManager } from 'dnd-core'
import { createDragDropManager } from 'dnd-core'
// ...
function getDndContextValue(props: DndProviderProps&lt;unknown, unknown&gt;) {
if ('manager' in props) {
   const manager = { dragDropManager: props.manager }
   return
}

   const manager = createSingletonDndContext(
   props.backend,
   props.context,
   props.options,
   props.debugMode,
    )
   const isGlobalInstance = !props.context

   return
}

function createSingletonDndContext&lt;BackendContext, BackendOptions&gt;(
   backend: BackendFactory,
   context: BackendContext = getGlobalContext(),
   options: BackendOptions,
   debugMode?: boolean,
) {
   const ctx = context as any
   if (!ctx) {
   ctx = {
       dragDropManager: createDragDropManager(
      backend,
        context,
        options,
        debugMode,
       ),
   }
   }
   return ctx
}
</code></pre>
<p>从以上代码可以看出,<code>getDndContextValue</code>方法又调用了<code>createSingletonDndContext</code>方法,并传入了backend、context、options、debugMode这几个属性,然后通过dnd-core中的<code>createDragDropManager</code>来创建manager。</p>
<h2 id="3dragdropmanager">3、DragDropManager</h2>
<p>看下createDragDropManager.js中的主要代码</p>
<pre><code class="language-tsx">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&lt;State&gt; {
// ...
return createStore(
    reduce,
    debugMode &amp;&amp;
    reduxDevTools &amp;&amp;
    reduxDevTools({
      name: 'dnd-core',
      instanceId: 'dnd-core',
    }),
)
}

</code></pre>
<p>可以看到使用了redux的createStore创建了store,并创建了monitor和manager实例,通过backendFactory创建backend后端实例并安装到manager总实例。</p>
<p>看一下DragDropManagerImpl的主要代码</p>
<pre><code class="language-tsx">export class DragDropManagerImpl implements DragDropManager {
private store: Store&lt;State&gt;
private monitor: DragDropMonitor
private backend: Backend | undefined
private isSetUp = false

public constructor(store: Store&lt;State&gt;, 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&lt;any&gt;) {
      return (...args: any[]) =&gt; {
      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) =&gt; {
      const action: ActionCreator&lt;any&gt; = (actions as any)[
      key
      ] as ActionCreator&lt;any&gt;
      ;(boundActions as any) = bindActionCreator(action)
      return boundActions
      },
      {} as DragDropActions,
    )
}

public dispatch(action: Action&lt;any&gt;): void {
    this.store.dispatch(action)
}

private handleRefCountChange = (): void =&gt; {
    const shouldSetUp = this.store.getState().refCount &gt; 0
    if (this.backend) {
      if (shouldSetUp &amp;&amp; !this.isSetUp) {
        this.backend.setup()
        this.isSetUp = true
      } else if (!shouldSetUp &amp;&amp; this.isSetUp) {
        this.backend.teardown()
        this.isSetUp = false
      }
    }
}
}
</code></pre>
<p>先说一下这个handleRefCountChange方法,在构造函数里通过store进行订阅,在第一次使用useDrop或useDrag时会执行setup方法初始化backend,在拖拽源和放置源都被卸载时则会执行teardown销毁backend。</p>
<p>接下来看一下createDragDropActions方法</p>
<pre><code class="language-tsx">export function createDragDropActions(
manager: DragDropManager,
): DragDropActions {
return {
    beginDrag: createBeginDrag(manager),
    publishDragSource: createPublishDragSource(manager),
    hover: createHover(manager),
    drop: createDrop(manager),
    endDrag: createEndDrag(manager),
}
}

</code></pre>
<p>可以看到绑定一些action:</p>
<ul>
<li>beginDrag(开始拖动)</li>
<li>publishDragSource(发布当前拖动源)</li>
<li>hover(是否经过)</li>
<li>drop(落下动作)</li>
<li>endDrag(拖拽结束)</li>
</ul>
<p>manager包含了之前生成的 monitor、store、backend,manager 创建完成,表示此时我们有了一个 store 来管理拖拽中的数据,有了 monitor 来监听数据和控制行为,能通过 manager 进行注册,可以通过 backend 将 DOM 事件转换为 action。接下来便可以注册拖拽源和放置源了。</p>
<h2 id="4usedrag">4、useDrag</h2>
<pre><code class="language-tsx">/**
* 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&lt;
DragObject = unknown,
DropResult = unknown,
CollectedProps = unknown,
&gt;(
specArg: FactoryOrInstance&lt;
    DragSourceHookSpec&lt;DragObject, DropResult, CollectedProps&gt;
&gt;,
deps?: unknown[],
): {
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&lt;DragObject, DropResult&gt;()
const connector = useDragSourceConnector(spec.options, spec.previewOptions)
useRegisteredDragSource(spec, monitor, connector)

return [
    useCollectedProps(spec.collect, monitor, connector),
    useConnectDragSource(connector),
    useConnectDragPreview(connector),
]
}
</code></pre>
<p>可以看到<code>useDrag</code>方法返回了一个包含3个元素的数组,CollectedProps(collect方法返回的对象)、ConnectDragSource(拖拽源连接器)、ConnectDragPreview(拖拽源预览)。</p>
<p>monitor是从前面Provider中的manager中获取的,主要看下connector</p>
<pre><code class="language-tsx">export function useDragSourceConnector(
dragSourceOptions: DragSourceOptions | undefined,
dragPreviewOptions: DragPreviewOptions | undefined,
): SourceConnector {
const manager = useDragDropManager()
const connector = useMemo(
    () =&gt; new SourceConnector(manager.getBackend()),
    ,
)
// ...
return connector
}
</code></pre>
<p>可以看到connector获取了manager.getBackend后端的数据。</p>
<p>useRegisteredDragSource方法会对拖动源进行注册,会保存拖动源实例,并记录注册的数量。</p>
<h2 id="5usedrop">5、useDrop</h2>
<p>看下useDrop源码</p>
<pre><code class="language-tsx">/**
* 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&lt;
DragObject = unknown,
DropResult = unknown,
CollectedProps = unknown,
&gt;(
specArg: FactoryOrInstance&lt;
    DropTargetHookSpec&lt;DragObject, DropResult, CollectedProps&gt;
&gt;,
deps?: unknown[],
): {
const spec = useOptionalFactory(specArg, deps)
const monitor = useDropTargetMonitor&lt;DragObject, DropResult&gt;()
const connector = useDropTargetConnector(spec.options)
useRegisteredDropTarget(spec, monitor, connector)

return [
    useCollectedProps(spec.collect, monitor, connector),
    useConnectDropTarget(connector),
]
}

</code></pre>
<p>useDrop返回了一个包含2个元素的数组,CollectedProps(collect方法返回的对象), ConnectDropTarget(放置源连接器),monitor和connector的获取都和useDrag类似。</p>
<h2 id="6html5backend">6、HTML5Backend</h2>
<p>HTML5Backend使用了HTML5 拖放 API,先了解下HTML拖拽事件:</p>
<p><img src="https://img2023.cnblogs.com/other/2332333/202306/2332333-20230609115552847-1323028790.png"></p>
<p>一个简单拖拽操作过程,会依次触发拖拽事件:dragstart -&gt; drag -&gt;dragenter -&gt; dragover (-&gt; dragleave) -&gt; drop -&gt; dragend。</p>
<p>drag事件会在dragstar触发后持续触发,直至drop。</p>
<p>dragleave事件会在拖拽元素离开一个可释放目标时触发。</p>
<p>接下来介绍一下HTML5Backend,是React DnD 主要支持的后端,使用HTML5 拖放 API,它会截取拖动的 DOM 节点并将其用作开箱即用的“拖动预览”。React DnD 中以可插入的方式实现 HTML5 拖放支持,可以根据触摸事件、鼠标事件或其他完全不同的事件编写不同的实现,这种可插入的实现在 React DnD 中称为后端。官网提供了HTML5Backend和TouchBackend,分别用来支持web端和移动端。</p>
<p>后端担任与 React 的合成事件系统类似的角色:它们抽象出浏览器差异并处理原生DOM 事件。尽管有相似之处,但 React DnD 后端并不依赖于 React 或其合成事件系统。在后台,后端所做的就是将 DOM 事件转换为 React DnD 可以处理的内部 Redux 操作。</p>
<p>前面给DndProvider传递的HTML5backend,看一下其代码实现:</p>
<pre><code class="language-tsx">export const HTML5Backend: BackendFactory = function createBackend(
manager: DragDropManager,
context?: HTML5BackendContext,
options?: HTML5BackendOptions,
): HTML5BackendImpl {
return new HTML5BackendImpl(manager, context, options)
}
</code></pre>
<p>可以看到其实是个返回<code>HTML5BackendImpl</code>实例的函数,在创建manager实例时会执行createBackend方法创建真正的backend。</p>
<p>如下是 Backend 需要被实现的方法:</p>
<pre><code class="language-tsx">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&lt;string, number&gt;
}
</code></pre>
<p>setup 是 backend 的初始化方法,teardown 是 backend 销毁方法。connectDragSource方法将元素转换为可拖拽元素,并添加监听事件。connectDropTarget方法会给元素添加监听事件,connectDragPreview方法会将preview元素保存以供监听函数使用,profile方法用于返回一些简要的统计信息。</p>
<p>以上这几个方法都在HTML5BackendImpl中,我们先看一下setup方法:</p>
<pre><code class="language-typescript">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)
}
</code></pre>
<p>root默认是windows,通过addEventListeners方法把监听事件都绑定到windows上,这提高了性能也降低了事件销毁的难度。</p>
<p>看下addEventListeners方法:</p>
<pre><code class="language-typescript">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,
)
}

</code></pre>
<p>以上代码中监听了一些拖拽事件,这些监听函数会获得拖拽事件的对象、拿到相应的参数,并执行相应的action方法。HTML5Backend 通过 manager 拿到一个 DragDropActions 的实例,执行其中的方法。DragDropActions 本质就是根据参数将其封装为一个 action,最终通过 redux 的 dispatch 将 action 分发,改变 store 中的数据。</p>
<pre><code class="language-typescript">export interface DragDropActions {
beginDrag(
    sourceIds?: Identifier[],
    options?: any,
): Action&lt;BeginDragPayload&gt; | undefined
    publishDragSource(): SentinelAction | undefined
    hover(targetIds: Identifier[], options?: any): Action&lt;HoverPayload&gt;
    drop(options?: any): void
    endDrag(): SentinelAction
}
</code></pre>
<p>最后我们再看下connectDragSource方法:</p>
<pre><code class="language-typescript">public connectDragSource(
sourceId: string,
node: Element,
options: any,
): Unsubscribe {
// ...
node.setAttribute('draggable', 'true')
node.addEventListener('dragstart', handleDragStart)
node.addEventListener('selectstart', handleSelectStart)

return (): void =&gt; {
    // ...
    node.removeEventListener('dragstart', handleDragStart)
    node.removeEventListener('selectstart', handleSelectStart)
    node.setAttribute('draggable', 'false')
}
}
</code></pre>
<p>可以看到主要是把节点的draggable属性设置为true,并添加监听事件,返回一个Unsubscribe函数用于执行销毁。</p>
<p>综上,HTML5Backend 在初始化的时候在 window 对象上绑定拖拽事件的监听函数,拖拽事件触发时执行对应action,更新 store 中的数据,完成由 Dom 事件到数据的转变。</p>
<h2 id="7touchbackend">7、TouchBackend</h2>
<p>HTML5 后端不支持触摸事件,因此它不适用于平板电脑和移动设备。可以使用<code>react-dnd-touch-backend</code>来支持触摸设备,简单看下ToucheBackend。</p>
<p>ToucheBackend主要是为了支持移动端,也支持web端,在web端可以使用 mousedown、mousemove、mouseup,在移动端则使用 touchstart、touchmove、touchend,下面是ToucheBackend中对事件的定义:</p>
<pre><code class="language-typescript">const eventNames: Record&lt;ListenerType, EventName&gt; = {
: {
    start: 'mousedown',
    move: 'mousemove',
    end: 'mouseup',
    contextmenu: 'contextmenu',
},
: {
    start: 'touchstart',
    move: 'touchmove',
    end: 'touchend',
},
: {
    keydown: 'keydown',
},
}
</code></pre>
<h2 id="8主要拖拽过程">8、主要拖拽过程</h2>
<p><img src="https://img2023.cnblogs.com/other/2332333/202306/2332333-20230609115553230-418676441.png"></p>
<h1 id="四总结">四、总结</h1>
<p>React-DnD 采用了分层设计,react-dnd充当接入层,dnd-core实现拖拽接口、定义拖拽行为、管理数据流向,backend将DOM事件通过redux action转换为数据。</p>
<p>使用可插入的方式引入backend,使拖拽的实现可扩展且更加灵活。</p>
<p>使用了单向数据流,在拖拽时不用处理中间状态,不用额外对DOM事件进行处理,只需专注于数据的变化。<br>
React-DnD对backend的实现方式、数据的管理方式,以及整体的设计都值得借鉴。</p>
<h1 id="五参考链接">五、参考链接:</h1>
<ul>
<li>https://github.com/react-dnd/react-dnd/</li>
<li>https://react-dnd.github.io/react-dnd</li>
<li>https://developer.mozilla.org/zh-CN/docs/Web/API/HTML_Drag_and_Drop_API</li>
<li>https://zhuanlan.zhihu.com/p/429986799</li>
<li>https://juejin.cn/post/6885511137236877325</li>
</ul>
<hr>
<h1 id="最后">最后</h1>
<p>欢迎关注【袋鼠云数栈UED团队】~<br>
袋鼠云数栈UED团队持续为广大开发者分享技术成果,相继参与开源了欢迎star</p>
<ul>
<li><strong>大数据分布式任务调度系统——Taier</strong></li>
<li><strong>轻量级的 Web IDE UI 框架——Molecule</strong></li>
<li><strong>针对大数据领域的 SQL Parser 项目——dt-sql-parser</strong></li>
<li><strong>袋鼠云数栈前端团队代码评审工程实践文档——code-review-practices</strong></li>
<li><strong>一个速度更快、配置更灵活、使用更简单的模块打包器——ko</strong></li>
</ul><br><br>
来源:https://www.cnblogs.com/dtux/p/17468866.html
頁: [1]
查看完整版本: 深入解析React DnD拖拽原理,轻松掌握拖放技巧!