盐焗乳鸽还要香锅

V1

2022/12/15阅读:83主题:默认主题

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(() => (
  <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 左侧的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 = {
    endthis.handleEnd,
    movethis.handleMove,
    startthis.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 (
        <SortableContext.Provider value={this.sortableContextValue}>
          <WrappedComponent {...omit(this.propsomittedProps)} />
        </SortableContext.Provider>

      );
    }

即通过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,
    managerthis.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({
          sortingtrue,
          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 = {
          heightthis.height > height ? height / 2 : this.height / 2,
          widththis.width > width ? width / 2 : this.width / 2,
        };

        const translate = {
          x0,
          y0,
        };
        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, {
              opacity0,
              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({
        sortingfalse,
        sortingIndexnull,
      });
    // 这里的newIndex和oldIndex指的是激活元素变化前后的索引
      if (typeof onSortEnd === 'function') {
        onSortEnd(
          {
            collection,
            newIndexthis.newIndex,
            oldIndexthis.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(from1)[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;
  }, {});
}

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

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

分类:

前端

标签:

前端

作者介绍

盐焗乳鸽还要香锅
V1