可插拔式架构---实现拖拽组件

103 阅读5分钟

可插拔式架构---实现拖拽组件

作为一个前端程序员,要想实现一个拖拽元素的效果,通常我们可以将其简单的分为以下几步:

  1. 监听触发 start 事件,记录下元素起始位置。
  2. 监听触发 move 事件,通过计算当前位置和初始位置之间的二维距离,将其应用于transform属性,实现元素的拖拽效果。
  3. 监听触发 end 事件,将最终获取的元素位置,设置为元素的当前位置,实现元素放置。

思路很简单,实现起来也不难。但是,考虑到可拓展性,实现更加可定制的功能,如支持多元素拖拽、绘制放置区域、拖拽中动画、拖拽排序等等,我们必须将各个核心功能解耦,构建出更灵活的代码架构。

下面,我将用 react 先来实现一个拖拽功能的简单架构。

Context 全局管理上下文

首先我想要通过Context来对所有可拖拽元素进行管理,并且记录下激活元素以及其状态。

export function DndContext({
  children,
}: DndContextProps) {
  // Avoid multiple contexts using the same state
  const [state, dispatch] = useReducer(reducer, undefined, initialState);
  const { manager, transform, activeId } = state;

  // activeNoede origin information on Start
  const activeRectRef = useRef<{ initOffset: Coordinate; clientRect: DOMRect } | null>(null);


  //...
  const initialContextValue: DndContextDescriptor = {
    ...state,
    dispatch,
    activator,
  };

  return (
    <Context.Provider value={initialContextValue}>
      {children}
    </Context.Provider>
  );
}

如上所述,一个全局管理 Context 可能包括一个管理中心 manager,当前移动的距离 transform,当前拖拽元素唯一标识 activeId。

其中,你的 manager 的类型结构可能是这样:

// 元素类型
export type DraggableNode = {
  id: UniqueIdentifier;
  // node position information
  clientRect?: MutableRefObject<DOMRect | undefined>;
  node?: React.MutableRefObject<HTMLElement>;
  // custom data:
  [x: string]: any;
};

// 全局管理中心
export default class Manager {
  private nodes: {
    draggables: DraggableNode[];
  };
  constructor() {
    this.nodes = {
      draggables: []
    };
  }
  /**注册拖拽元素 */
  push(node: DraggableNode) {
    this.nodes['draggables'].push(node);
  }
  /**获取拖拽元素 */
  getNode(id: UniqueIdentifier) {
    const nodes = this.nodes['draggables'];
    for (let index = 0; index < nodes.length; index++) {
      const node = nodes[index];
      if (node?.id === id) return node;
    }
    return null;
  }
  //......
}

其次最核心的功能就是事件监听器。这里为了考虑可插拔式的插件结构,我将单独的 Event 抽离出来一个类:

const EVENTS = {
  start: ['mousedown'],
  move: ['mousemove'],
  end: ['mouseup']
};

export class MouseSensor {
  static eventName = 'onMouseDown';

  constructor(private props: MouseSensorProps) {
    const { manager, listener } = props;
    this.manager = manager;
    // 这里等同于document.addEventListener(...)
    this.windowListeners = listener;
    this.reset();
    this.handleStart = this.handleStart.bind(this);
    this.handleMove = this.handleMove.bind(this);
    this.handleEnd = this.handleEnd.bind(this);
  }

  handleStart(event: Event, id: UniqueIdentifier) {
    // start hooks钩子函数
    const { onStart } = this.props;
    event.stopPropagation();
    // 记录当前的处于拖拽状态的元素id
    this.activeId = id;
    // Remove any text selection from the document
    this.removeTextSelection();
    // Prevent further text selection while dragging
    this.windowListeners.add('selectionchange', this.removeTextSelection);
    // Resolved cursor error when mouse moving over Safari
    this.windowListeners.add('selectstart', (e) => e.preventDefault());

    // 绑定接下来的move、end事件
    EVENTS.move.forEach((eventName) => {
      this.windowListeners.add(eventName, this.handleMove);
    });
    EVENTS.end.forEach((eventName) => {
      this.windowListeners.add(eventName, this.handleEnd);
    });

    const activeNodeDescriptor = this.manager.getNode(id);
    if (activeNodeDescriptor && activeNodeDescriptor.node.current) {
      // 每次开始的时候都要先获取一下,避免由于页面滚动造成的偏移
      this.clientRect = activeNodeDescriptor.node.current.getBoundingClientRect();
      // 通过mouse event获取元素起始位置
      this.initOffset = getEventCoordinates(event) as Coordinate;
      onStart();
    }
  }

  private handleMove(event: MouseEvent) {
    // 如果没有拖拽元素,则return
    if (!this.activeId) return;
    const { onMove } = this.props;
    // 获取并推算出移动的距离
    const currentCoordinates = getEventCoordinates(event)!;
    const transform = {
      x: currentCoordinates.x - this.initOffset.x,
      y: currentCoordinates.y - this.initOffset.y
    };
    this.transform = transform;
    onMove(transform, this.activeId, event);
  }
  private handleEnd(event: Event) {
    if (!this.activeId) return;
    const { onEnd } = this.props;

    // reset本次拖拽的数据状态
    this.windowListeners.remove('selectstart');
    this.windowListeners.removeAll();
    onEnd({ nativeEvent: event, delta: this.transform, id: this.activeId });
    this.reset();
  }
  private reset() {
    this.activeId = '';
    this.initOffset = undefined;
    this.clientRect = undefined;
    this.marginRect = undefined;
    this.transform = {
      x: 0,
      y: 0
    };
  }
  private removeTextSelection() {
    this.document.getSelection()?.removeAllRanges();
  }
}
  • handleStart方法用于处理鼠标按下事件,它接受一个事件对象和拖拽元素的唯一标识符id作为参数。在该方法内部,它执行了一系列操作,如阻止事件冒泡、清除文本选择、绑定移动和松开事件等。还通过manager获取了拖拽元素的初始位置,并记录下来。最后,调用了onStart函数,表示拖拽开始。

  • handleMove方法用于处理鼠标移动事件,它接受一个鼠标事件作为参数。在该方法内部,它计算当前位置与初始位置之间的距离,并通过调用onMove函数将移动距离和拖拽元素的唯一标识符传递出去。

  • handleEnd方法用于处理鼠标松开事件,它接受一个事件对象作为参数。在该方法内部,它执行一些清理操作,如移除事件监听、重置状态等。最后,调用onEnd函数,表示拖拽结束。

最重要的,在onMove钩子中,我们进行dispatchtransform状态更新至 Context 中。

// DndContext
useEffect(() => {
  sensorRef.current = new Sensor({
    manager,
    listener: listenerRef.current,
    onMove: useEvent((transform, id, event) => {
      // something to do ...
      if (id && activeRectRef.current) {
        dispatch({
          type: DragActionEnum.TRANSFORM,
          payload: {
            transform
          }
        });
      }
    })
  });
  // set activate event to binding with clicked element
  setActivator({
    eventName: Sensor.eventName,
    handler: sensorRef.current.handleStart
  });
}, []);

这里需要注意的是,事件插件的初始化只需要初始化一次,而钩子函数中的逻辑可能需要当前 Context 中的某些 state。考虑到useEffect的特性,这里需要使用useEvent Hooks 来包装一下。

/**
 * 用于定义一个函数,该函数具有两个特性
 * 1. 在组件多次render的时候也会保持一致
 * 2. 能够获取到当前组件最新的props和state
 * @param fn
 * @returns memorizeFn
 */
export const useEvent = (fn: (...args: any[]) => void) => {
  const eventRef = useRef<((...args: any[]) => void) | null>(null);
  // 组件重新渲染时更新函数本身
  useLayoutEffect(() => {
    eventRef.current = fn;
  });
  // 保证函数引用地址
  return useCallback((...args: any[]) => {
    return eventRef.current && eventRef.current(...args);
  }, []);
};

利用 Hooks 绑定元素

在实现了 Context 之后,我们需要消费 Context 中的状态,去实现具体的拖拽效果。

为了最大程度的减少使用者的代码开支,我编写了一个 hooks 去自动将用户的元素变成可拖拽的元素。

/**接受元素唯一id */
export const useDraggable = ({ id }) => {
  const { activeId, transform, dispatch, activator, manager } = useDndContext();

  const rect = useRef<DOMRect>();
  // 当前元素是否处于拖拽状态
  const isActive = activeId == id;

  const dragNode = useRef<HTMLElement>();
  // 处理Context中下发的activator,将其变成以下形式:
  // ```js
  // {
  //    onMouseDown:()=>{},
  // }
  //
  const listener = useSyntheticListeners(activator, id);

  const setDragNodeRef = useCallback((currentNode: HTMLElement) => {
    dragNode.current = currentNode;
    if (currentNode) {
      rect.current = currentNode.getBoundingClientRect(); // initialize draggables position
    }
  }, []);

  // 将transform处理成CSS Transform的形式
  const attributes = {
    ...(isActive ? setTransform(transform) : {})
  };

  useEffect(() => {
    dispatch({
      type: DragActionEnum.PUSH_NODE,
      payload: {
        node: {
          id,
          node: dragNode,
          clientRect: rect
        }
      }
    });

    return () => {
      dispatch({
        type: DragActionEnum.REMOVE_NODE,
        payload: { id }
      });
    };
  }, [dispatch, id, manager, dragNode]);

  return {
    isActive,
    dragNode,
    attributes,
    listener,
    setDragNode: setDragNodeRef
  };
};

可以发现,useDraggable主要所作的工作:

  • 去注册元素到 Context Manager 中
  • 获取事件以绑定在元素上
  • 获取 transform 并转化为 CSS Style 的形式
  • 返回最关键的,处理后的事件对象和 CSS Style 对象、是否处于拖拽状态的布尔值等等。

最终,利用useDraggable就可以将任意元素转化为可拖拽元素啦~

function DraggableElement({ id, className }) {
  const { isActive, setDragNode, listener, attributes } = useDraggable({
    id
  });
  return (
    <>
      <div
        ref={setDragNode}
        className={classnames(className, {
          [`__${prefix}_dragging`]: isActive
        })}
        style={{ ...attributes }}
        {...listener}
      >
        {children}
      </div>
    </>
  );
}

结语

上述代码也许不能完全运行起来,因为我省略了很多细节,以便读者可以通过思路启发来自行实现一个拖拽组件。

通过使用 React 的 Context 和自定义 Hooks,我们成功地实现了一个可定制的拖拽功能架构。这个架构允许我们管理和操作拖拽元素的状态,并通过拆分事件监听器和管理中心的方式,实现了更好的代码组织和可拓展性。无论是简单的元素拖拽,还是更复杂的功能,我们都可以通过这个架构轻松地构建出符合需求的拖拽功能。

该架构也只是抛砖引玉,未来希望能构建出更灵活更丰富的组件出来。

我是「盐焗乳鸽还要香锅」,喜欢我的文章欢迎关注噢

  • github 链接github.com/1360151219
  • 掘金账号、知乎账号、简书《盐焗乳鸽还要香锅》
  • 思否账号《天天摸鱼真的爽》