2022年末了,react拖拽组件和最牛的代码调试技巧一起学!

2022 年末了,react 拖拽组件和最牛的代码调试技巧一起学!

前言

最近刷到了利用 H5dragdropapi 进行拖拽组件实现的代码示例,于是想学习一下业界知名的一些拖拽组件。于是想从学习成本较低的react-sortable-hoc开始看起。那么对于一个学习者而言,我们应该如何地去优雅地学习第三方库呢?

当然是「调试」啦。

调试

首先第一步,我们随便创建一个 react 项目,并且按照react-sortable-hoc的最简单的案例编写后准备调试。

import {
  SortableContainer,
  SortableElement,
  SortableHandle,
  arrayMove,
} from 'react-sortable-hoc';
import { Component } from 'react';

const DragHandle = SortableHandle(() => (
  
));

const SortableItem = SortableElement(({ value }) => (
  
  • {value}
  • )); const MySortableContainer = SortableContainer(({ children }) => { return
      {children}
    ; }); export default class Sort extends Component { state = { items: ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5', 'Item 6'], }; onSortEnd = ({ oldIndex, newIndex }) => { this.setState(({ items }) => ({ items: arrayMove(items, oldIndex, newIndex), })); }; render() { const { items } = this.state; return ( {items.map((value, index) => ( ))} ); } }

    比如说我们想看看SortableHandler里面的具体实现,我们给它打个断点,并且创建一个 vscode debug 配置:

    {
      "version": "0.2.0",
      "configurations": [
        {
          "type": "chrome",
          "request": "launch", // 选择launch表示会同时启动debug client和debug server
          "name": "Launch Chrome against localhost",
          // 这里选择监听webpack dev server启动的地址
          "url": "http://localhost:8080"
        }
      ]
    }

    F5开启调试后我们进入SortableHandler中,看到的却是经过打包后的产物:

    这显然非常不利于去读懂代码。那么我们该如何将它变成我们能看得懂的源码呢?答案就是sourcemap

    sourcemap 就是用于表示打包后代码和源码的映射关系。因此我们只需要开启 sourcemap 就可以进行 debug 的源码的映射。

    我们将react-sortable-hoc项目 clone 下来(这里只拉取一层 commit、一个 master 分支):

    git clone --depth=1 --single-branch https://github.com/clauderic/react-sortable-hoc.git

    我们可以发现整个项目是使用rollup进行打包的,我们只需要配置一下 sourcemap 开启:

    类似:
    ...
    output: {
        name: 'SortableHOC',
        file: minify ? pkg["umd:main"].replace('.js', '.min.js') : pkg["umd:main"],
        format: 'umd',
        sourcemap: true,
        ...
      },

    然后执行npm run build,将打包好的 dist 文件夹替换至node_modules/react-sortable-hoc/dist目录下。接着在我们测试项目中将其引入路径改为:

    import {
      SortableContainer,
      SortableElement,
      SortableHandle,
      arrayMove,
    } from 'react-sortable-hoc/dist/react-sortable-hoc';

    然后我们再来运行一下 debug 试试看:

    瞧!这是不是非常熟悉呢?利用调试我们可以随时随地打断点,知道变量的运行时,读起源码来是不是非常轻松呢?

    【注】有的小伙伴可能会发现在调试的时候,打开的源码文件是只读模式,这是为什么呢?

    我们可以在 vscode 左侧的CALL STACK中找到当前文件映射到的目录。

    如果是node_modules/react-sortable-hoc/src/.../xxx.js,就证明你映射到的只是node_modules中的路径,是无法更改的。

    这时候,你可以点击该文件对应的.js.map文件,将其中的../src/xxx.js路径改成你克隆下来的react-sortable-hoc的路径。这样的话,映射到的目录就是你本地的文件,就可以编辑啦!!~

    我们修改过node_modules下的文件但又不想被覆盖,可以使用patch-package这个包。

    npx patch-package react-sortable-hoc 可以生成一个 diff 文件,上传至 GitHub 上,别人 clone 后只需要运行npx patch-package即可将 diff 覆盖到node_modules

    源码阅读

    组件的初始化

    我们首先来梳理一下示例代码的组件嵌套:

    SortableContainer >> SortableElement >> SortableHandler

    我们先从组件的初始化入手,从外到内一层一层解析:

    SortableContainer
    // WithSortableContainer.
    // 注意这两个events不一样!!!!!
    events = {
      end: ['touchend', 'touchcancel', 'mouseup'],
      move:['touchmove', 'mousemove'],
      start:['touchstart', 'mousedown']
    }
    
    // Class部分
    constructor(props) {
      super(props);
      const manager = new Manager();
      this.manager = manager;
      this.wrappedInstance = React.createRef();
      this.sortableContextValue = {manager};
      this.events = {
        end: this.handleEnd,
        move: this.handleMove,
        start: this.handleStart,
      };
    }
    componentDidMount() {
          const {useWindowAsScrollContainer} = this.props;
          const container = this.getContainer();
          Promise.resolve(container).then((containerNode) => {
          // ========== 获取本身node节点、document、window对象
            this.container = containerNode;
            this.document = this.container.ownerDocument || document;
            const contentWindow =
              this.props.contentWindow || this.document.defaultView || window;
            this.contentWindow =
              typeof contentWindow === 'function' ? contentWindow() : contentWindow;
          // ========== 默认的滚动容器是本身
            this.scrollContainer = useWindowAsScrollContainer
              ? this.document.scrollingElement || this.document.documentElement
              : getScrollingParent(this.container) || this.container;
          // ========== 绑定事件 兼容h5和移动端
            Object.keys(this.events).forEach((key) =>
              events[key].forEach((eventName) =>
                this.container.addEventListener(eventName, this.events[key], false),
              ),
            );
          });
        }

    可以发现SortableContainer来初始化的时候,获取了各种 dom 结构以及绑定好了事件。

    除此之外,它 new 了一个Manager作为总的拖拽管理中心。其主要功能如下:「注册并储存可拖拽的子节点」「记录当前激活节点的 index」「根据 index 进行 sort」

    // 总的结构如下:~~
    // Manager {
    //   refs: {
    //     collection: [node {sortableInfo {index}}]
    //   },
    //   active: {index, collection}
    // }
    export default class Manager {
      refs = {};
      isActive() {
        return this.active;
      }
      getActive() {
        return this.refs[this.active.collection].find(
          // eslint-disable-next-line eqeqeq
          ({ node }) => node.sortableInfo.index == this.active.index,
        );
      }
      getOrderedRefs(collection = this.active.collection) {
        return this.refs[collection].sort(sortByIndex);
      }
      ... ...
    }
    
    function sortByIndex(
      {
        node: {
          sortableInfo: { index: index1 },
        },
      },
      {
        node: {
          sortableInfo: { index: index2 },
        },
      },
    ) {
      return index1 - index2;
    }
    

    最后,它渲染函数是这样的:

    render() {
          return (
            
              
            
          );
        }

    即通过Provider将全局 Manager 对象传递给了子组件。

    SortableElement
    // WithSortableElement
    componentDidMount() {
        this.register();
    }
    register() {
      const {collection, disabled, index} = this.props;
      // 找到当前node节点
      const node = reactDom.findDOMNode(this);
      // sortableInfo结构
      node.sortableInfo = {
        collection,
        disabled,
        index,
        manager: this.context.manager,
      };
      this.node = node;
      this.ref = {node};
      this.context.manager.add(collection, this.ref);
    }

    我们可以看到,其实SortableElement的初始化只是将自身节点以及一些属性信息注册到了全局Manager对象中。

    SortableHandle

    SortableHandle的代码就更简单了,只是在自身 dom 上添加了一个sortableHandle的标识,用于判断用户当前点击的节点是否是SortableHandle。这部分逻辑我们在下面就可以看到~

    事件触发

    了解了各个组件的初始化流程之后,我们可以开始调试拖拽的整个过程的实现逻辑了~

    首先我们要知道,所有的事件都是注册在SortableContainer中的,因此我们只需要对其进行调试即可。

    拖拽触发事件顺序如下图:

    下面让我们来看一下各种事件的逻辑吧:

    handleStart

     handleStart = (event) => {
          const {distance, shouldCancelStart} = this.props;
          // 如果是右键或者是input等默认标签则不触发
          if (event.button === 2 || shouldCancelStart(event)) {
            return;
          }
          this.touched = true;
          this.position = getPosition(event);
        // 寻找被激活拖拽的子节点
        // 条件:SortableElment 而且 当前没有别的激活节点
          const node = closest(event.target, (el) => el.sortableInfo != null);
          if (
            node &&
            node.sortableInfo &&
            this.nodeIsChild(node) &&
            !this.state.sorting
          ) {
            const {useDragHandle} = this.props;
            const {index, collection, disabled} = node.sortableInfo;
            // ...
    
            // 如果声明了useDragHandle但是没有激活drag Handler则不生效
            if (useDragHandle && !closest(event.target, isSortableHandle)) {
              return;
            }
            this.manager.active = {collection, index};
            if (!distance) {
              if (this.props.pressDelay === 0) {
                this.handlePress(event);
              } else {
                this.pressTimer = setTimeout(
                  () => this.handlePress(event),
                  this.props.pressDelay,
                );
              }
            }
          }
        };

    handleStart的这个回调函数中,我们可以发现它主要做了一下事情:

      1. e.target向上寻找到可拖拽节点,并且记录其信息(index等)
      1. 记录各种信息,比如设置touched为 true,设置当前激活节点
      1. 最后触发handlePress回调函数

    handlePress

    handlePress = async (event) => {
          const active = this.manager.getActive();
          if (active) {
            const {
              axis,
              getHelperDimensions,
              helperClass,
              hideSortableGhost,
              updateBeforeSortStart,
              onSortStart,
              useWindowAsScrollContainer,
            } = this.props;
            const {node, collection} = active;
            const {isKeySorting} = this.manager;
           // ...
    
           // 计算当前激活元素以及container的图形指标(长宽高、坐标、边距等)
           // ...
            const {index} = node.sortableInfo;
           // ...
    
           // 默认是body,即在body插入一个激活节点的克隆节点,并为其插入计算好的属性!!
            this.initialOffset = getPosition(event); // 一开始点击时的初始偏移
            this.helper = this.helperContainer.appendChild(cloneNode(node));
            setInlineStyles(this.helper, {
              boxSizing: 'border-box',
              height: `${this.height}px`,
              left: `${this.boundingClientRect.left - margin.left}px`,
              pointerEvents: 'none',
              position: 'fixed',
              top: `${this.boundingClientRect.top - margin.top}px`,
              width: `${this.width}px`,
            });
          // 计算激活节点可拖拽的距离
            if (this.axis.x) {
              this.minTranslate.x =
                (useWindowAsScrollContainer ? 0 : containerBoundingRect.left) -
                this.boundingClientRect.left -
                this.width / 2;
              this.maxTranslate.x =
                (useWindowAsScrollContainer
                  ? this.contentWindow.innerWidth
                  : containerBoundingRect.left + containerBoundingRect.width) -
                this.boundingClientRect.left -
                this.width / 2;
            }
            if (this.axis.y) {
              this.minTranslate.y =
                (useWindowAsScrollContainer ? 0 : containerBoundingRect.top) -
                this.boundingClientRect.top -
                this.height / 2;
              this.maxTranslate.y =
                (useWindowAsScrollContainer
                  ? this.contentWindow.innerHeight
                  : containerBoundingRect.top + containerBoundingRect.height) -
                this.boundingClientRect.top -
                this.height / 2;
            }
            this.listenerNode = event.touches ? event.target : this.contentWindow;
            events.move.forEach((eventName) =>
              this.listenerNode.addEventListener(
                eventName,
                this.handleSortMove,
                false,
              ),
            );
            events.end.forEach((eventName) =>
              this.listenerNode.addEventListener(
                eventName,
                this.handleSortEnd,
                false,
              ),
            );
    
            this.setState({
              sorting: true,
              sortingIndex: index,
            });
        };

    注意看,这个函数有一个比较关键的思想:就是利用克隆节点来模拟正在拖拽的节点。计算并记录好所需要的图形指标并且赋值到新节点上,并且设置position:fixed

    最后在绑定上move事件的监听----handleSortMove.

    handleSortMove

    // 注意,这里是move时候的event
    handleSortMove = (event) => {
          const {onSortMove} = this.props;
          // Prevent scrolling on mobile
          if (typeof event.preventDefault === 'function' && event.cancelable) {
            event.preventDefault();
          }
    
          this.updateHelperPosition(event);
          this.animateNodes();
          this.autoscroll();
    };

    函数本身很简洁,首先是updateHelperPosition

    updateHelperPosition

    updateHelperPosition(event) {
      const offset = getPosition(event);
        const translate = {
          x: offset.x - this.initialOffset.x,
          y: offset.y - this.initialOffset.y,
        };
      // css translate3d
      setTranslate3d(this.helper, translate);
    }

    updateHelperPosition的代码经过清理后,核心就在于对克隆元素设置translate,来模拟拖拽的过程。

    其次就是最重要的animateNodes函数了。

      animateNodes() {
          const nodes = this.manager.getOrderedRefs();
         // ...
          for (let i = 0, len = nodes.length; i < len; i++) {
            const {node} = nodes[i];
            const {index} = node.sortableInfo;
            const width = node.offsetWidth;
            const height = node.offsetHeight;
            const offset = {
              height: this.height > height ? height / 2 : this.height / 2,
              width: this.width > width ? width / 2 : this.width / 2,
            };
    
            const translate = {
              x: 0,
              y: 0,
            };
            let {edgeOffset} = nodes[i];
    
            // If we haven't cached the node's offsetTop / offsetLeft value
            // getEdgeOffset获取当前元素基于页面的偏移值
            if (!edgeOffset) {
              edgeOffset = getEdgeOffset(node, this.container);
              nodes[i].edgeOffset = edgeOffset;
            }
    
            // Get a reference to the next and previous node
            const nextNode = i < nodes.length - 1 && nodes[i + 1];
            const prevNode = i > 0 && nodes[i - 1];
    
            // Also cache the next node's edge offset if needed.
            // We need this for calculating the animation in a grid setup
            if (nextNode && !nextNode.edgeOffset) {
              nextNode.edgeOffset = getEdgeOffset(nextNode.node, this.container);
            }
    
            // If the node is the one we're currently animating, skip it
            if (index === this.index) {
              if (hideSortableGhost) {
                /*
                 * With windowing libraries such as `react-virtualized`, the sortableGhost
                 * node may change while scrolling down and then back up (or vice-versa),
                 * so we need to update the reference to the new node just to be safe.
                 */
                this.sortableGhost = node;
    
                setInlineStyles(node, {
                  opacity: 0,
                  visibility: 'hidden',
                });
              }
              continue;
            }
    
            if (transitionDuration) {
              setTransitionDuration(node, transitionDuration);
            }
    
       if ((index > this.index &&
       // 拖拽下移:
       // 激活元素偏移值 + (scroll) + 自身元素高度 >= 当前遍历元素的偏移值
      sortingOffset.top + windowScrollDelta.top + offset.height >= edgeOffset.top))
            {
              translate.y = -(this.height + this.marginOffset.y);
              this.newIndex = index;
            } else if (
              (index < this.index &&
        // 拖拽上移:
        // 激活元素偏移值 + (scroll) <= 当前遍历元素的偏移值 + 自身元素的高度
                sortingOffset.top + windowScrollDelta.top <=
                  edgeOffset.top + offset.height)
            ) {
              translate.y = this.height + this.marginOffset.y;
              if (this.newIndex == null) {
                this.newIndex = index;
              }
            }
    
            setTranslate3d(node, translate);
            nodes[i].translate = translate;
          }
        }

    这里包含了拖拽排序最核心的节点移动逻辑。核心思想如下:

    遍历所有sortableElement,如果是当前激活节点,则把原有节点透明化。(因为有克隆节点了);如果不是,则判断激活节点的坐标以及当前遍历元素的坐标的大小,依此来进行translate3d的动画。

    handleSortEnd

    最后,当拖拽结束后,触发handleSortEnd。主要逻辑是做一些善后处理,清理各种事件监听器,全局 Manager 的变化,本身被拖拽元素恢复透明度等。。

     handleSortEnd = (event) => {
          const { hideSortableGhost, onSortEnd } = this.props;
          const {
            active: { collection },
            isKeySorting,
          } = this.manager;
          const nodes = this.manager.getOrderedRefs();
          // 清除绑定的事件监听器
          if (this.listenerNode) {
            events.move.forEach((eventName) =>
              this.listenerNode.removeEventListener(
                eventName,
                this.handleSortMove,
              ),
            );
            events.end.forEach((eventName) =>
              this.listenerNode.removeEventListener(
                eventName,
                this.handleSortEnd,
              ),
            );
          }
    
          // Remove the helper from the DOM
          this.helper.parentNode.removeChild(this.helper);
          // 当前元素恢复透明度
          if (hideSortableGhost && this.sortableGhost) {
            setInlineStyles(this.sortableGhost, {
              opacity: '',
              visibility: '',
            });
          }
    
          for (let i = 0, len = nodes.length; i < len; i++) {
          // 清除节点的自定义属性
            const node = nodes[i];
            const el = node.node;
            // Clear the cached offset/boundingClientRect
            node.edgeOffset = null;
            node.boundingClientRect = null;
            // Remove the transforms / transitions
            setTranslate3d(el, null);
            setTransitionDuration(el, null);
            node.translate = null;
          }
          // Update manager state
          this.manager.active = null;
          this.manager.isKeySorting = false;
          this.setState({
            sorting: false,
            sortingIndex: null,
          });
        // 这里的newIndex和oldIndex指的是激活元素变化前后的索引
          if (typeof onSortEnd === 'function') {
            onSortEnd(
              {
                collection,
                newIndex: this.newIndex,
                oldIndex: this.index,
                isKeySorting,
                nodes,
              },
              event,
            );
          }
          this.touched = false;
        };

    总结

    到这里,整个react-sortable-hoc实现的大致思想就全部介绍完毕啦。它并没有利用 h5 的dragapi,而是利用mousemovetouchmove之类的事件实现 h5 和移动端的兼容。利用 css3 的动画来实现 sort 效果。

    但实现过程中也有一些缺点。

    比如reactDom.findDomNodeapi,react 并不推荐使用它来去获取 dom,可以换成ref

    比如只能在react类组件中使用。

    其他

    觉得封装的比较好的工具函数用于学习记录:

    1. 判断是否可以滚动
    function isScrollable(el) {
      const computedStyle = window.getComputedStyle(el);
      const overflowRegex = /(auto|scroll)/;
      const properties = ['overflow', 'overflowX', 'overflowY'];
      return properties.find((property) =>
        overflowRegex.test(computedStyle[property]),
      );
    }
    1. 获取当前元素距离窗口的偏移值(也可以使用elm.getBoundingClientRect()
    export function getEdgeOffset(node, parent, offset = {left: 0, top: 0}) {
      if (!node) {
        return undefined;
      }
      // Get the actual offsetTop / offsetLeft value, no matter how deep the node is nested
      const nodeOffset = {
        left: offset.left + node.offsetLeft,
        top: offset.top + node.offsetTop,
      };
    
      if (node.parentNode === parent) {
        return nodeOffset;
      }
    
      return getEdgeOffset(node.parentNode, parent, nodeOffset);
    }
    1. 移动数组内元素
    export function arrayMove(array, from, to) {
      array = array.slice();
      array.splice(to < 0 ? array.length + to : to, 0, array.splice(from, 1)[0]);
      return array;
    }
    1. 过滤对象某些属性
    export function omit(obj, keysToOmit) {
      return Object.keys(obj).reduce((acc, key) => {
        if (keysToOmit.indexOf(key) === -1) {
          acc[key] = obj[key];
        }
    
        return acc;
      }, {});
    }

    本文由mdnice多平台发布

    你可能感兴趣的:(前端)