乐趣区

关于前端:2022年末了react拖拽组件和最牛的代码调试技巧一起学

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

前言

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

当然是 <span style=”color:#d2568c;font-weight:bold;”>「调试」</span> 啦。

调试

首先第一步,咱们轻易创立一个 react 我的项目,并且依照 react-sortable-hoc 的最简略的案例编写后筹备调试。

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

const DragHandle = SortableHandle(() => (<span></span>));

const SortableItem = SortableElement(({value}) => (
  <li>
    <DragHandle />
    {value}
  </li>
));

const MySortableContainer = SortableContainer(({children}) => {return <ul>{children}</ul>;
});

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 (<MySortableContainer onSortEnd={this.onSortEnd} useDragHandle>
        {items.map((value, index) => (<SortableItem key={`item-${value}`} index={index} value={value} />
        ))}
      </MySortableContainer>
    );
  }
}

比如说咱们想看看 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 左侧的 <span style=”color:#d2568c;font-weight:bold;”>CALL STACK</span> 中找到以后文件映射到的目录。

如果是 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

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

<span style=”color:#2b4490″>SortableContainer</span>
// 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 作为总的拖拽管理中心。其次要性能如下:<span style=”color:#d2568c”>「注册并贮存可拖拽的子节点」</span>,<span style=”color:#d2568c”>「记录以后激活节点的 index」</span>,<span style=”color:#d2568c”>「依据 index 进行 sort」</span>:

// 总的构造如下:~~
// 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 (<SortableContext.Provider value={this.sortableContextValue}>
          <WrappedComponent {...omit(this.props, omittedProps)} />
        </SortableContext.Provider>
      );
    }

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

<span style=”color:#2b4490″>SortableElement</span>
// 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 对象中。

<span style=”color:#2b4490″>SortableHandle</span>

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 多平台公布

退出移动版