关于前端: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多平台公布

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理