低代码 - 可视化拖拽技术分析(1)

9,462 阅读9分钟

前言

最近在公司有涉及到低代码相关工作,对 LowCode 可视化拖拽技术进行了调研,特此使用一个系列文章记录这一过程。

起初参考珠峰架构 - 姜文老师 低代码可视化搭建平台 公开课直播分享,来入门可视化拖拽技术,在此提及感谢。

系列文章将使用 React 技术栈,来搭建一个可视化低代码平台,以及一些拖拽编辑器主要功能点的实现。

低代码 - 可视化拖拽技术分析(1)- 拖拽编辑器基础功能实现
低代码 - 可视化拖拽技术分析(2)- 拖拽辅助线、吸附和放大缩小
低代码 - 可视化拖拽技术分析(3)- 撤销与重做
低代码 - 可视化拖拽技术分析(4)- Settings 设置器实现

本篇,我们从项目搭建开始,去完成以下功能:

  1. 编辑器布局;
  2. 自定义物料组件;
  3. 拖拽物料组件;
  4. 编辑器画布元素获取焦点;
  5. 编辑器画布元素拖动/移动。

本节实现效果图如下:

截屏2022-04-28 下午9.05.09.png

一、关键字概念

系列文章内会时常提起到以下几个概念,先在这里简单提及:

  • material 代表左侧物料区可拖动的物料组件;
  • block 代表中间画布区域内的元素(本篇重点);
  • setter 代表右侧属性设置区内的属性设置器。

二、项目搭建

我们采用 create-react-app 快速生成一个 React 开发环境:

npx create-react-app low-code-example

简单优化一下目录结构,其中 Editor 将作为我们的应用的主要文件:

1650887330767.jpg

三、编辑器布局

我们对页面结构改造如下:

function Editor() {
  return (
    <div className="editor-wrapper">
      <div className="editor-header">顶部工具栏</div>
      <div className="editor-main">
        <div className="editor-left">左侧物料区</div>
        <div className="editor-container">
          <div id="canvas-container">编辑区 - 画布</div>
        </div>
        <div className="editor-right">右侧属性区</div>
      </div>
    </div>
  );
}

其中 id="canvas-container" 元素将作为拖拽画布区域。下面我们为画布设置宽高尺寸。

import React from 'react';
import SchemaJSON from './schema.json';

function Editor() {
  const [schema, setSchema] = useState(SchemaJSON);
  
  // ... 省略其他 DOM 节点
  <div id="canvas-container" style={{ ...schema.container }}>编辑区 - 画布</div>
}

schema,一种 JSON 数据结构,用于描述编辑器画布信息,以及画布中拖拽添加的组件元素信息。这里我们使用到了画布的宽高信息,在 schema JSON 中结构如下:

// src/schema.json
{
  "container": {
    "width": 375, // iPhone 6/7/8 尺寸(具体按照设计稿尺寸)
    "height": 667
  },
  "blocks": [
    // 画布中拖拽的元素集合,下面介绍
  ]
}

至此,我们的基本架子就搭建出来了;下面我们定义一些物料组件,用于后续拖拽至画布使用。

四、自定义物料组件

在编辑器左侧区存放了我们定义的一个个物料组件,也就是我们日常开发使用到的 UI 组件。我们使用config 文件来管理物料组件的注册。

const createEditorConfig = () => {
  const componentList = []; // 自定义组件(物料)
  const componentMap = {}; // 组件和画布中元素的渲染映射

  return {
    componentList,
    componentMap,
    register: (component) => {
      componentList.push(component);
      componentMap[component.type] = component;
    }
  }
}

const registerConfig = createEditorConfig();

registerConfig.register({
  label: '文本',
  preview: () => '预览文本',
  render: () => '渲染文本',
  type: 'text'
});

registerConfig.register({
  label: '按钮',
  preview: () => <button>预览按钮</button>,
  render: () => <button>渲染按钮</button>,
  type: 'button'
});

registerConfig.register({
  label: '输入框',
  preview: () => <input placeholder="预览输入框" />,
  render: () => <input placeholder="渲染输入框" />,
  type: 'input'
});

export default registerConfig;

这里,我们注册了三个物料组件:文本、按钮、输入框,并且对外暴露了两个关键变量:

  • componentList: 存储了一个个物料组件的集合,用于渲染编辑器左侧物料的数据源;
  • componentMap: 是一个 map 映射关系,根据 type 字段进行映射,有了它,就可以在画布中通过 Schema 数据去匹配物料组件来渲染画布视图。

有了数据,我们就可以通过 map 渲染左侧区物料组件:

import config from './config';

function Editor() {
  // ... 省略其他 DOM 节点
  <div className="editor-left">
    {config.componentList.map(component => (
      <div key={component.type} className="editor-left-item">
        <span>{component.label}</span>
        <div>{component.preview()}</div>
      </div>
    ))}
  </div>
}

接下来,我们让物料组件能够自由拖动。

五、拖拽物料组件至画布

这里我们采用 HTML5 拖拽特性,为 DOM 节点添加 draggable 属性后即可自由拖动:

<div draggable key={component.type} className="editor-left-item">...</div>

我们还需要通过拖拽事件:当拖拽物料组件至编辑器画布中并松开鼠标时,向 Schema 中追加数据,并将拖拽组件渲染至视图。

首先,我们监听物料组件拖动时,记录当前拖动的组件。通过 onDragStart 事件监听开始拖动,useRef 保存拖拽组件。

import { useRef } from 'react';

const currentMaterial = useRef(); // 记录当前拖拽的物料组件

const handleDragStart = (component) => {
  currentMaterial.current = component;
}

<div 
  key={component.type} 
  draggable
  onDragStart={() => handleDragStart(component)}
  className="editor-left-item">
  <span>{component.label}</span>
  <div>{component.preview()}</div>
</div>

其次,监听画布区域的拖放事件,当拖动物料组件至画布上,并松开鼠标时,将物料组件渲染至画布中。

这里涉及到四个拖拽事件:

  • onDragEnter:拖拽元素移动到画布时,触发此事件;比如可以改变拖拽光标手势;
  • onDragOver:拖拽元素停留在画布时,会持续触发此事件;需要通过它来取消默认事件,保证元素正常触发 onDrop 事件;
  • onDragLeave:拖拽元素离开画布时,触发此事件;
  • onDrop:对于画布来说,最重要的一个拖拽事件,当拖拽元素在画布上放置(松开鼠标)时触发此事件。

于是,我们很快可以写出:

const handleDragEnter = event => event.dataTransfer.dropEffect = 'move';

const handleDragOver = event => event.preventDefault();

const handleDragLeave = event => event.dataTransfer.dropEffect = 'none';

const handleDrop = event => {
  ...
}

<div className="editor-container">
  <div
    onDragEnter={handleDragEnter}
    onDragOver={handleDragOver}
    onDragLeave={handleDragLeave}
    onDrop={handleDrop}
    id="canvas-container" style={{ ...schema.container }}>
    编辑区 - 画布
  </div>
</div>

handleDrop 中,我们会将拖拽的物料组件信息,添加到上文提到的 schema.blocks 中,blocks 中有了数据就可以渲染画布。

const handleDrop = event => {
  const { offsetX, offsetY } = event.nativeEvent;
  schema.blocks.push({
    type: currentMaterial.current.type,
    alignCenter: true, // 表示拖拽到画布后,基于鼠标位置居中展示
    focus: false,
    style: {
      width: undefined,
      height: undefined,
      left: offsetX,
      top: offsetY,
      zIndex: 1,
    },
  });
  setSchema(clone(schema));
  currentMaterial.current = null;
}

我们来分析一下这里的逻辑:

  • 位置信息:offsetX、offsetY 表示鼠标相对于事件源(画布)的 x 和 y 轴坐标,用于渲染物料组件在画布中的位置;
  • 层级信息:zIndex 用于今后实现 置顶、置顶 功能时使用;
  • type:组件的类型,用于在画布渲染时,依据它从左侧物料区拿到对应的渲染组件;
  • alignCenter:在我们初次将物料组件拖拽至画布上时,以当前鼠标的位置进行居中放置;
  • focus:当画布中的元素被选中时,focus 值为 true;
  • clone:是一个简单实现的深拷贝方法,用于帮助 state 更新视图,具体实现这里不做讲述。

Tip:由于 React 合成事件并未暴露 offsetX、offsetY 信息,所以需要定位到 event.nativeEvent 中读取位置信息。

有了 schema.blocks 数据源,我们就可以在画布上进行绘制了:

<div
  ...
  id="canvas-container" style={{ ...schema.container }}>
  {schema.blocks.map((block, index) => (
    <Block key={index} block={block}></Block>
  ))}
</div>

Block 组件接收 block 作为 prop 实现画布元素渲染,我们看下具体实现:

import React, { useContext, useRef, useEffect, useReducer } from 'react';
import './style/block.scss';
import EditorContext from './context';

const Block = (props) => {
  const [, forceUpdate] = useReducer(v => v + 1, 0);
  const { config } = useContext(EditorContext);
  const blockRef = useRef();
  const { block, ...otherProps } = props;

  useEffect(() => {
    const { offsetWidth, offsetHeight } = blockRef.current;
    const { style } = block;
    if (block.alignCenter) {
      style.left = style.left - offsetWidth / 2;
      style.top = style.top - offsetHeight / 2;
      delete block.alignCenter; // 删除,一次性的属性
      forceUpdate();
    }
  }, [block]);
  
  const blockStyle = {
    top: block.style.top,
    left: block.style.left,
    zIndex: block.style.zIndex,
  };

  const component = config.componentMap[block.type];
  const RenderComponent = component.render();

  return (
    <div 
      className={`editor-block`} 
      style={blockStyle} 
      ref={blockRef}
      {...otherProps}>
      {RenderComponent}
    </div>
  )
}

export default Block;
  • block.alignCenter:借助 useEffect、useRef、useReducer 三个 React Hooks, 实现初次拖拽后画布元素居中展示;
  • block.style:每个 editor-block 都是 absolute 元素,因此 top、left、zIndex 决定了画布元素的位置和层级;
  • block.type:根据左侧物料区 componentMap 查找对应画布组件。

这里我们使用到了 config 中注册的物料组件信息,通过 useContextEditorContext 中读取;为了便于一些数据在组件之间流动,采用 ReactContext

我们需要创建一个 ReactContext context 对象,新建 src/context.js 文件:

import React from 'react';
const EditorContext = React.createContext(null);

export const Provider = EditorContext.Provider;
export default EditorContext;

Editor.js 中改造一下:

import { Provider } from './context';

function Editor() {
  ...

  return (
    <Provider value={{ config }}>
      <div className="editor-wrapper">
        ...
      </div>
    </Provider>
  );
}

六、画布元素获取焦点

画布元素被点击时,需要在 View 上设定一个形态,比如可以是一个边框,表示点击选中了此元素。

我们可以在 onClick 点击事件上做文章,但考虑到:按住元素进行拖动场景,决定统一使用 onMouseDown 事件。

const blocksFocusInfo = useCallback(() => {
  let focus = [], unfocused = [];
  schema.blocks.forEach(block => (block.focus ? focus : unfocused).push(block));
  return { focus, unfocused };
}, [schema]);

const cleanBlocksFocus = (refresh) => {
  schema.blocks.forEach(block => block.focus = false);
  refresh && setSchema(clone(schema));
}

const handleMouseDown = (e, block) => {
  e.preventDefault();
  e.stopPropagation();

  if (e.shiftKey) {
    const { focus } = blocksFocusInfo();
    // 当前只有一个被选中时,按住 shift 键不会切换 focus 状态
    block.focus = focus.length <= 1 ? true : !block.focus;
  } else {
    if (!block.focus) {
      cleanBlocksFocus();
      block.focus = true;
    }
  }
  
  setSchema(clone(schema));
}

<Block key={index} block={block} onMouseDown={e => handleMouseDown(e, block)}></Block>
  • 单个场景:当元素处于未选中状态,点击后重置其他元素的选中状态,并让当前点击元素设置为选中状态;
  • 多个场景:当用户按住了 shift 键时,允许画布中存在多个选中的 block。

有了 focus 选中状态,就可以为 Block 元素添加 focus className

// src/block.js
<div 
  className={`editor-block ${block.focus ? 'editor-block-focus' : ''}`} 
  ...
</div>

// src/style/block.scss
.editor-block-focus{
  user-select: none;
  &::after{
    border: 1px solid #1890ff;
  }
}

到这里,我们完成了画布元素的选中/取消选中操作。不过我们可以再考虑全面一些:当点击画布空白区域时,将所有元素的选中状态置为 false

const handleClickCanvas = event => {
  event.stopPropagation();
  cleanBlocksFocus(true);
}

<div className="editor-container">
  <div 
    onMouseDown={handleClickCanvas}
    ...
  </div>
</div>

七、画布元素拖动 / 移动

移动这里很好理解:

  • 首先移动逻辑发生在按住 block 元素时,因此可以在 Block onMouseDown 中做处理;
  • 移动前需要记录当前鼠标的位置信息,以及所有 focus block 的当前所处位置;
  • 通过 document 监听移动事件,计算每次移动的新位置,去改变 focus block 的 top、left 位置。

具体实现如下:

function Editor() {
  ...
  
  const dragState = useRef({ 
    startX: 0, // 移动前 x 轴位置
    startY: 0, // 移动前 y 轴位置
    startPos: [] // 移动前 所有 focus block 的位置存储
  });
  
  const handleMouseDown = (e, block) => {
    ...
    // 进行移动
    handleBlockMove(e);
  }

  const handleBlockMove = (e) => {
    const { focus } = blocksFocusInfo();
    // 1、记录鼠标拖动前的位置信息,以及所有选中元素的位置信息
    dragState.current = {
      startX: e.clientX,
      startY: e.clientY,
      startPos: focus.map(({ style: { top, left } }) => ({ top, left })),
    }

    const blockMouseMove = (e) => {
      const { clientX: moveX, clientY: moveY } = e;
      const durX = moveX - dragState.current.startX;
      const durY = moveY - dragState.current.startY;

      focus.forEach((block, index) => {
        block.style.top = dragState.current.startPos[index].top + durY;
        block.style.left = dragState.current.startPos[index].left + durX;
      })
      
      setSchema(clone(schema));
    }

    const blockMouseUp = () => {
      document.removeEventListener('mousemove', blockMouseMove);
      document.removeEventListener('mouseup', blockMouseUp);
    }

    // 2、通过 document 监听移动事件,计算每次移动的新位置,去改变 focus block 的 top 和 left
    document.addEventListener('mousemove', blockMouseMove);
    document.addEventListener('mouseup', blockMouseUp);
  }

  ...
}

八、优化一下

细心的同学都发现了:每次都是执行 setSchema 去更新视图,但这个需要 clone 一份新的 schema 引用地址,setSchema 才会触发重渲染,这个消耗是比较大的;

其实更新的目录就是使得 Editor 组件进行重渲染,我们可以利用 React Hook useReducer 实现函数组件的 forceUpdate

const [, forceUpdate] = useReducer(v => v + 1, 0);

需要更新视图时,直接这样使用:

forceUpdate();

最后

到这里,我们的程序搭建及编辑器画布的基础操作已经完成了,下节我们实现拖拽辅助线以及吸附功能,来一步步扩展编辑器功能。

低代码 - 可视化拖拽技术分析(2)- 拖拽辅助线和吸附

如有不足之处,欢迎指正 👏 。