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>
// WithSortableElementcomponentDidMount() {    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时候的eventhandleSortMove = (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多平台公布