1

React 拖拽作业组件设计

 2 years ago
source link: https://zhuanlan.zhihu.com/p/30104151
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

React 拖拽作业组件设计

去留无意,望天上云卷云舒

效率如何提升一直是业务研发中永恒的话题。类似于前端的拖拽式建站平台,在数据业务研发中,我们的后端、数据研发以及算法工程师同样期望利用可视化拖拽技术与表单配置来降低自己的开发成本与门槛,更快速灵活地执行业务策略。他们将自己的业务封装为下图所示的模块,可能是一个算法包,可能是一个数据源,也可能是一个计算组件,通过页面中简单的拖拽连线操作来快速实现业务搭建。因此,我们基于 React 封装了一套拖拽作业组件来面对不同的业务场景,来看看我们具体是如何实现的。

v2-e8dff12914d8f825bc94fe4bb5e80a34_b.jpg

组件思路

从组件设计角度,我们将作业组件分解为数据层、中间层与视图层。而从组件功能角度,我们将作业组件划分为模块选择栏(Elements),画布(Screen)和作业图(Map)三个模块。作业组件的核心在于模块节点、模块间连线以及画布的状态,我们将这些状态与操作状态的方法抽象出来,形成 FlowStore。

v2-ef6a51c4b86bb59133d6b769e54980b1_720w.jpg
class FlowStore {
  private nodes: INode[];
  private links: ILink[];
  private screen: IScreen;
  ...
  getData() { ... }
  setData() { ... }
  addNode(node: INode) { ... }
  ...
}

FlowStore 通过 createFlow 来建立,所返回的 FlowProvider 中包裹的视图层组件可以获取到 FlowStore。

function createFlow() {
  class FlowProvider extends React.Component {
    getChildContext() {
      // 初始化 FlowStore
      return {
        flowStore: new FlowStore()
      };
    }
    render() {
      return (
        <div className="flow">{this.props.children}</div>
      );
    }
  }
}

那么视图层组件具体是如何获取到最新的 FlowStore 及其方法的呢?我们增加了统一的 Decorator 作为中间层,监听 FlowStore 中的数据变化完成 View 的 reRender,同时将 FlowStore 类成员函数透传给 View。

export default function flowDecorator(mapFlowStoreToProps) {
  return function wrapWithFlowElements(WrappedComponent) {
    return class FlowDecorator extends React.Component {
      constructor(props, context) {
        super(props, context);
        this.flowStore = this.context.flowStore;
        this.state = this.flowStore.getData();
      }

      handleChange() {
        this.setState(this.flowStore.getData());
      }
      
      // 建立 FlowStore 监听
      componentDidMount() {
        this.unSubscribe = this.flowStore.subscribe(
          this.handleChange.bind(this)
        );
        this.handleChange();
      }
      
      // 取消 FlowStore 监听
      componentWillUnmount() {
        this.unSubscribe.apply(this.flowStore);
      }
      
      // 提取 FlowStore 的类成员函数
      getflowStoreFunc() {
        return Object.getOwnPropertyNames(Object.getPrototypeOf(this.flowStore))
          .reduce((pre, cur) => {
            return {
              ...pre,
              [cur]: this.flowStore[cur].bind(this.flowStore)
            };
          }, {});
      }

      render() {
        const finalProps = mapFlowStoreToProps(this.state, this.props);
        const flowStoreFunc = this.getflowStoreFunc();
        return <WrappedComponent {...finalProps} {...flowStoreFunc} />;
      }
    }
  };
}

我们针对 FlowStore 的数据变化进行了优化,防止 View 层频繁 reRender。除了在 FlowDecorator 中增加 shouldComponentUpdate 外,在作业组件相对定制的场景中,我们是知道哪些操作应该触发 reRender,而哪些并不需要。我们给相应方法增加了会触发更新的 Decorator,同时添加简易的 batchUpdate 机制,来保证性能更优。

function UpdateStore(target, propertyName, descriptor) {
  const oldValue = descriptor.value;

  descriptor.value = function() {
    oldValue.apply(this, arguments);
    if (!this.batchUpdate) {
      this.batchUpdate = true;
      // 通过 setTimeout 延迟完成批量更新
      setTimeout(() => {
        // 触发 FlowDecorator 的监听,从而触发 View reRender
        this._runListener();
        this.batchUpdate = false;
      }, 0);
    }
    return;
  };
  
  return descriptor;
}

在整个设计过程中,我们借鉴了 Redux 体系思想,进行些许简化。对数据层进行抽象的同时,保留了 View 层的灵活性和可扩展性,View 完全可由用户自行定义。

拖拽实现

为了满足相似场景下的复用,组件内置的 FlowElements,FlowMap 与 FlowScreen,实现了作业组件的拖拽功能。

在 React 中有两种较为常见的拖拽实现方案,一种基于 HTML5 的 Drag 与 Drop API,从模块选择栏拖入画布就应用了 react-dnd 配合 HTML5Backend,具体代码就不在赘述,可参考官网 Demo。而画布中的节点与连线的拖动则是另一种实现方式,通过 mousedown -> mousemove -> mouseup 完成拖动,这里为何舍弃 react-dnd 呢,后文会给你答案。

// 鼠标位置的移动带动节点的移动,鼠标抬起时停止节点移动
toggleDragNode(isDraggingNode: Boolean) {
  if (isDraggingNode) {
    Event.on(window, 'mousemove', this.onDragNodeMouseMove.bind(this));
    Event.on(window, 'mouseup', this.onDragNodeMouseUp.bind(this));
  } else {
    Event.off(window, 'mousemove');
    Event.off(window, 'mouseup');
  }
}

// 鼠标按下时获取节点的当前位置
<g
  className="map-node"
  onMouseDown={this.onDragNodeMouseDown.bind(this, node)}
>
</g>

在拖拽的研发中,我们感觉到最复杂的并不是拖拽的实现,而是节点的位置确定与画布的动态扩展。

对于作业组件的画布而言,并非全量展示给用户,而会有一个可见区域。我们需要自行控制画布的 scrollTop 与 scrollLeft。注意,scroll 无法直接赋值给画布元素,只能通过 ref 拿到真实渲染后的 DOM 元素进行修改。同时监听画布的滚动事件,保持与 FlowStore 中的 scroll 数据同步。

由于节点移动到边界会带来画布的移动,鼠标也会超出组件可见区域,通过鼠标的相对位移来确定节点位置并不合适。因此我们采用鼠标的当前位置来作为节点的位置,确定节点在画布中的位置便尤为重要。

如上图所示,节点的 positionX = clientX - offsetLeft + scrollLeft - origin.x,positionY = clientY - offsetTop + scrollTop - origin.y。当节点处于画布最边缘时,即 scrollLeft = 0 or(width - visibleWidth)或 scrollTop = 0 or(height - visibleHeight)时,画布需要相对应扩展,其中 width / height 会自动进行增长,origin 作为坐标系的原点,也会随之调整。这里为方便读者理解,已经做了一定简化,实际组件还考虑了画布缩放(scale)与画布自适应(padding),会更加复杂。

而当鼠标超出组件可见区域时,节点的位置将不再随着鼠标的移动而移动,将会始终贴着可见区域的边缘,通过 requestAnimationFrame 不断移动节点与画布,直至贴合画布边缘。

踩过的坑

SVG 中 HTML5 Darg / Drop 不起作用?

SVG 不支持 HTML5 Darg 与 Drop,因此在画布中无法使用 React-Dnd 配合 HTML5Backend 完成拖拽,尽管可以配合 react-dnd-touch-backend,但会丧失中间过程,使拖拽效果很不理想。

两个 Context 冲突了?

在开发中出现两个 Context,一个来源于 React-Dnd 的 DragContext,另一个来源于 FlowStore 向子孙组件传递的 Context,不处理将会发生覆盖,我们选择在 createFlow 内完成聚合:

getChildContext() {
  return {
    flowStore: new FlowStore(),
    dragDropManager: this.context.dragDropManager
  };
}

首次渲染,画布无法居中?

本地开发首次渲染时,画布无法相对可视化区域居中。这是由于 dev 环境下 webpack 将样式从 js 中抽离出来,异步添加 <link href="localhost:xxxx/xxxx" /> 到 head 中。当组件 ComponentDidMount 时,css 可能尚未渲染完成,造成 ComponentDidMount 中对 DOM 元素计算出现问题。

Element offset 值不对?

HTMLElement.offsetLeft / offsetTop 是指当前元素相对于父元素节点的左边界/上边界的偏移量。因此要求当前元素相对于整个页面的偏移量,我们需要不断向父级元素迭代,累加相应的 offset 值。

function getOffset(domNode) {
  let offsetTop = 0;
  let offsetLeft = 0;
  let targetDomNode = domNode;
  while (targetDomNode !== window.document.body && targetDomNode != null) {
    offsetLeft += targetDomNode.offsetLeft;
    offsetTop += targetDomNode.offsetTop;
    targetDomNode = targetDomNode.offsetParent;
  }
  return {
    offsetTop,
    offsetLeft
  };
}

有读者提到,也可以直接使用 getBoundingClientRect

节点 onClick 无法触发?

点击节点时无法触发节点的 onClick 事件,是由于 onClick 事件可分解为 onMouseDown 与 onMouseUp,节点的 onMouseDown 事件使节点上方新渲染出一个 dragNode,那么 onMouseUp 事件自然触发在它上面,造成该节点的 onClick 未能成功触发。给 dragNode 添加 pointer-events: none 即可解决。

写在最后

在组件定义中,我们将本文所述的拖拽作业组件归为业务组件。今天我们的组件会被应用于算法模型的搭建,而明天则会被用于数据依赖关系的表达,那么如何让组件更好地适应不同的业务呢?我们抽象了数据层的点线模型与画布模型,而视图层提供更灵活的创建方式。与通用组件不同,业务组件需要面对某一类业务场景,这些场景的相同点是什么,不同点又是什么,是值得我们去推敲的。因此对于业务组件的研发而言,业务场景的提炼与抽象是很重要的,毕竟我们所看重的是组件更为快速复用的能力。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK