从0到1开发可视化拖拽H5编辑器(React)

avatar
element3 @花果山

b站视频演示~

github代码

1614245026361

​ 年前年后比较闲,于是用React做了一个简单的lowcode平台,功能如上面动图所示。接下来按照完成功能点介绍下,主要包括:

  1. 编辑器
  2. 状态管理
  3. 自定义生成组件(目前完成文本、按钮、图片组件)
  4. 拖拽
  5. 组件属性编辑
  6. 放大、缩小
  7. 删除组件、调整图层层级
  8. 撤销、重做
  9. 动画
  10. 生成器

介绍

​ lowcode平台挺常见的,目前网上做的比较成熟且通用的有兔展、易企秀、码卡、图司机等,但是为了个性化的设置,比如要访问本公司的数据库,很多公司也都有自己的lowcode平台,比如阿里、百度、腾讯等等。lowcode平台其实就是通过拖拽或者点击预定义好的组件来生成页面,比较多的应用场景就是产品经理或者运维或者销售来自定义生成活动页,这样的操作简直不要太好,因为再也不需要一堆产品经理、交互、设计以及程序员开会才能完成一个活动页了,这期间包括设置下架、过期时间这些也不需要程序员插手。

​ 如果你还没有做过这样的项目,那接下来,我们就来捋一下应该怎么样才能做一个这样的项目。

这个项目可以分成两部分,一部分是编辑器,一部分是生成器。编辑器用于生成页面,其实就是生成一个包含页面所有组件信息的对象值,如下:

image-20210218115726982

然后可以把这个对象转成字符串存入数据库,对应一个id。

而生成器要做的事情就是根据id解析对应的字符串,然后把解析出来的对象渲染成组件呈现出来,就是我们之前在编辑器上创建的页面了。

组件数据结构

从上一个图看出来,canvas数据里有一个cmps数组,这个数组是所有的组件。

image-20210219152248091

  • 每个组件都有一个随机生成的onlyKey作为唯一标识,可以用于删除、查找、更新等。

  • desc与type则标识了组件类型,前者是汉字描述,可以用于页面展示,后者主要判断组件类型。

  • value在不同组件里定义不同,如文本组件或者按钮里表示显示的文本,图片组件里则用于记录图片地址。

  • style记录了组件的样式style

编辑器

首先,我们先来看下编辑器的布局,这里可以分成四个大模块:

image-20210225151516082

这个时候代码如下:

export default function App() {
  return (
    <div id="app" className={styles.main}>
      {/* 模块1:组件选择区 */}
      <Cmps />
      {/* 模块2和模块4:画布模块和画布操作模块 */}
      <Content />
      {/* 模块3:画布属性操作模块 */}
      <Edit />
    </div>
  );
}

状态管理

(关于状态管理,以下前部分是我的选择过程,不想看的可以直接跳到最后一段看我最终选择的方案。)

知道了我们要搭建一个怎么样的编辑器之后,接下来我们需要考虑一件重要的事情,就是画布数据放哪儿?首先要知道画布数据变更的时候,相关的组件也要更新,也就是模块234都要接到变更通知,这就是所谓的状态管理了。

关于这个状态管理我考虑过了以下几种方案:

  1. 把画布数据放到App的state中,考虑到修改画布数据逻辑复杂,App函数组件的话可以使用useReducer。
  2. 把画布数据使用redux管理,和方案1一样使用reducer函数定义画布数据修改规则。

同时,由于模块1234以及他们的子组件都要使用画布数据,这个时候就要考虑跨层级数据的传递了,当然这个可以使用Context,方案12都可以使用Context。

当我使用方案1的时候,因为画布数据太大了,再加上很多操作画布数据的增删改查函数,最后App组件就很臃肿,感觉View和Data层都黏在一起了,添加增删改查函数的时候非常费劲,不好不好。放弃方案1。

既然方案1的View和Data太黏,那我换用redux作为第三方来管理画布数据,起初小操作很好,但是因为涉及到组件数据里style、value等的修改,嵌套层级有点深,很多增删改查函数需要复用,但是使用reducer的话,很多修改逻辑要写在组件里,但是我不想组件过于臃肿,拆成工具函数又和View、Data分离了,不好维护。

所以,最终我想要的其实是一个数据仓库来存储我的画布数据,并且这个仓库里还要提供很多增删改查的功能函数。

最后, 我选择了第三种方案,自己定义一个Canvas类来管理我的画布数据。

在这个类里我定义了以下几个数据:

this.canvas: object

存储所有的画布数据,即这些:

image-20210218115726982

然后再提供一些功能函数,如getCanvas可以获取this.canvas数据,主要用于最后提交发布更新到数据库中,还有updateCanvas用于更新画布数据,还有emptyCanvas用于清空画布数据,还有updateCanvasStyle用于更新画布的style样式。

this.listeners: array

监听函数。即如果this.canvas发生了改变,该做什么,其实这里就是this.canvas发生了改变,就更新整个App就行了。那这个时候只需要在App组件中加个订阅就行了:

export default function App() {
  const forceUpdate = useForceUpdate();

  // 所有组件
  const globalCanvas = useCanvas();

  useLayoutEffect(() => {
    const unsubscribe = globalCanvas.subscribe(() => {
      forceUpdate();
    });
    return () => {
      unsubscribe();
    };
  }, [globalCanvas, forceUpdate]);
  return (
    <div id="app" className={styles.main}>
      <CanvasContext.Provider value={globalCanvas}>
        <Cmps />
        <Content />
        <Edit />
      </CanvasContext.Provider>
    </div>
  );
}

Canvas类中的订阅函数如下:

  subscribe = (listener) => {
    this.listeners.push(listener);
    return () => {
      this.listeners = this.listeners.filter((lis) => lis !== listener);
    };
  };

啰嗦一句,尽管我以前也老强调,订阅与取消订阅一定要成对出现。

当然,如果你了解redux和Antd4 Form原理,会发现这里逻辑和他们非常相似。

this.selectedCmp:object

记录当前选中的组件。所有组件数据都存在this.canvas中,但是由于层级比较深,取值查找的话比较麻烦,所以每次更新选中组件的时候,更新thia.canvas的时候,也同步更新这个值就行了。

this.canvasChangeHistory: array 与this.canvasIndex:number

前者记录画布修改历史,用于顶部模块2里的撤销与重做用的。后者则是记录当前处于哪个修改历史中。每次画布更新都要记录当前的画布数据,如更新组件、清空画布等,记录画布数据修改历史的函数如下:

  recordCanvasChangeHistory = () => {
    this.canvasChangeHistory.push(this.canvas);
    this.canvasIndex = this.canvasChangeHistory.length - 1; //2;
  };

获取此类的操作函数

 // 返回画布数据的增删改查函数
  getCanvas = () => {
    const returnFuncs = [
      "getCanvasData",
      "recordCanvasChangeHistory",
      "goPrevCanvasHistory",
      "goNextCanvasHistory",
      "updateCanvas",
      "emptyCanvas",
      "getCanvasStyle",
      "updateCanvasStyle",
      "registerStoreChangeCmps",
      "registerCmpsEntity",
      "getCmp",
      "getCmps",
      "setCmps",
      "addCmp",
      "getSelectedCmp",
      "setSelectedCmp",
      "updateSelectedCmpStyle",
      "updateSelectedCmpValue",
      "deleteSelectedCmp",
      "changeCmpIndex",
      "subscribe",
    ];
    const obj = {};
    returnFuncs.forEach((func) => {
      obj[func] = this[func];
    });
    return obj;
  };

跨层级传递数据

Canvas类已经创建完成,接下来是需要实例化这个类,然后通过Context传递下去。

export default function App() {
  const forceUpdate = useForceUpdate();

  // 所有组件
  const globalCanvas = useCanvas();

  useLayoutEffect(() => {
    const unsubscribe = globalCanvas.subscribe(() => {
      forceUpdate();
    });
    return () => {
      unsubscribe();
    };
  }, [globalCanvas, forceUpdate]);
  return (
    <div id="app" className={styles.main}>
      <CanvasContext.Provider value={globalCanvas}>
        <Cmps />
        <Content />
        <Edit />
      </CanvasContext.Provider>
    </div>
  );
}

useCanvas里实例化自定义Canvas类,返回getCanvas方法。

export function useCanvas(canvas) {
  const canvasRef = useRef();

  if (!canvasRef.current) {
    if (canvas) {
      canvasRef.current = canvas;
    } else {
      const globalCanvas = new Canvas();
      canvasRef.current = globalCanvas.getCanvas();
    }
  }
  return canvasRef.current;
}

自定义生成组件

模块1就是自定义生成组件的部分,这类需要考虑两件事情:

  1. 获取所有自定义组件以及初始值,这个数组如下:

  1. 第二件事情就是新增组件到画布的时候,有两种方式:
    • 点击新增组件,默认位置是画布的左上角,即top和left都是0
    • 拖拽组件到画布,与点击不同,拖拽需要记录拖拽的位置,并赋值给新增的组件style属性

模块1代码如下:

export default function Cmps(props) {
  const globalCanvas = useContext(CanvasContext);

  const [list, setList] = useState(null);
  const handleDragStart = (e, cmp) => {
    if (cmp.data.type === isImgComponent) {
      return;
    }
    e.dataTransfer.setData("add-component", JSON.stringify(cmp));
  };

  const handleClick = (e, cmp) => {
    e.preventDefault();
    e.stopPropagation();
    if (
      cmp.data.type === isTextComponent ||
      cmp.data.type === isButtonComponent
    ) {
      globalCanvas.addCmp(cmp);
      return;
    }
    // 图片组件
    if (list) {
      setList(null);
    } else {
      let l = null;
      switch (cmp.data.type) {
        case isImgComponent:
          l = <Img baseCmp={cmp} />;
          break;
        default:
          l = null;
      }
      setList(l);
    }
  };

  return (
    <div id="cmps" className={styles.main}>
      <div className={styles.cmpList}>
        {menus.map((item) => (
          <div
            key={item.desc}
            className={styles.cmp}
            draggable={item.data.type !== isImgComponent}
            onDragStart={(e) => handleDragStart(e, item)}
            onClick={(e) => handleClick(e, item)}>
            {item.desc}
          </div>
        ))}
      </div>
      {list && (
        <button
          className={classnames("iconfont icon-close", styles.close)}
          onClick={() => setList(null)}></button>
      )}
      {list && <ul className={styles.detailList}> {list}</ul>}
    </div>
  );
}

组件属性编辑

新增组件2

新增组件到画布之后,组件默认是选中状态,这个时候右边编辑模块需要显示组件的属性,并且是可编辑状态。

拖拽组件

画布上的组件需要是可拖拽的,通过拖拽控制位置,这个时候其实就是获取x与y轴上的移动距离,那么只需要用这次位置减去初始值位置就可以了。另外需要注意的是,由于拖拽会频繁修改画布数据,由于之前设置的监听的函数,那也就需要频繁更新组件,但是这个其实没必要每次移动都要更新组件,可以通过节流的方式提高性能,比如每500ms更新一次,事件代码如下:

记录初始位置:

handleDragStart = (e) => {
    this.setActive(e);
    let pageX = e.pageX;
    let pageY = e.pageY;
    e.dataTransfer.setData("startPos", JSON.stringify({pageX, pageY}));
  };

画布上的drop事件,这个时候需要判断是新增还是已有组件拖拽变化为止,

  const handleDrop = (e) => {
    e.preventDefault();
    e.stopPropagation();

    // 新增的组件
    let addingCmp = e.dataTransfer.getData("add-component");

    if (addingCmp) {
      // 拖拽进来新增的组件
      addingCmp = JSON.parse(addingCmp);
      const top = e.pageY - canvasPos.top - 15;
      const left = e.pageX - canvasPos.left - 40;
      let resData = {
        ...addingCmp,
        data: {
          ...addingCmp.data,
          style: {
            ...addingCmp.data.style,
            top,
            left,
          },
        },
      };
      globalCanvas.addCmp(resData);
    } else {
      // 拖拽画布内的组件
      let startPos = e.dataTransfer.getData("startPos");
      startPos = JSON.parse(startPos);

      let disX = e.pageX - startPos.pageX;
      let disY = e.pageY - startPos.pageY;

      // 获取当前选中的组件的最新信息
      const selectedCmp = globalCanvas.getSelectedCmp();

      const top = selectedCmp.data.style.top + disY;
      const left = selectedCmp.data.style.left + disX;
      globalCanvas.updateSelectedCmpStyle({top, left});
    }
  };

放大、缩小

放大缩小

画布上的组件还应该是可以往八个方向放大缩小的,和拖拽相似,只需要记录鼠标的移动距离就行了,然后修改width、height、top、left就行了,需要注意的是组件的位置是根据top和left定位的,那么往下、右、右下的时候不需要修改top和left,因为这个时候左上角坐标没有改变,事件代码如下:

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

    const cmp = this.context.getCmp(this.props.index);

    let startX = e.pageX;
    let startY = e.pageY;

    const move = (e) => {
      let x = e.pageX;
      let y = e.pageY;

      let disX = x - startX;
      let disY = y - startY;
      let newStyle = {};

      if (direction) {
        if (direction.indexOf("top") >= 0) {
          disY = 0 - disY;
          newStyle.top = cmp.data.style.top - disY;
        }

        if (direction.indexOf("left") >= 0) {
          disX = 0 - disX;
          newStyle.left = cmp.data.style.left - disX;
        }
      }

      // 特别频繁改变,加上一个标记,
      debounce(
        this.context.updateSelectedCmpStyle(
          {
            ...newStyle,
            width: cmp.data.style.width + disX,
            height: cmp.data.style.height + disY,
          },
          "frequently"
        )
      );
    };

    const up = () => {
      document.removeEventListener("mousemove", move);
      document.removeEventListener("mouseup", up);
      this.context.recordCanvasChangeHistory();
    };

    document.addEventListener("mousemove", move);
    document.addEventListener("mouseup", up);
  };

旋转

和拖拽相似,旋转组件其实就是记录鼠标移动的x与y轴距离,然后计算出鼠标的移动角度,更新组件的transform的rotate值就可以了。代码如下:

handleMouseDownofRotate = (e) => {
    e.stopPropagation();
    e.preventDefault();

    const {getCmp, updateSelectedCmpStyle} = this.context;

    const cmp = getCmp(this.props.index);

    let startX = e.pageX;
    let startY = e.pageY;

    const move = (e) => {
      let x = e.pageX;
      let y = e.pageY;

      let disX = x - startX;
      let disY = y - startY;

      const deg = (360 * Math.atan2(disY, disX)) / (2 * Math.PI);

      // 特别频繁改变,加上一个标记,
      debounce(
        updateSelectedCmpStyle(
          {
            transform: `rotate(${deg}deg)`,
          },
          "frequently"
        )
      );
    };

    const up = () => {
      document.removeEventListener("mousemove", move);
      document.removeEventListener("mouseup", up);
      this.context.recordCanvasChangeHistory();
    };

    document.addEventListener("mousemove", move);
    document.addEventListener("mouseup", up);
  };

右键菜单

image-20210225173640073

选中组件,单击右键,需要呈现一个菜单,显示组件的复制、删除、置顶与展示所有组件的功能。这个组件的展示与否通过一个状态值判断,选中组件,单击右键则显示,点击别的区域则隐藏这个菜单:

        {showContextMenu && (
          <ContextMenu
            index={index}
            pos={{top: style.top - 80, left: style.left + 60}}
            cmp={cmp}
          />
        )}

复制

复制其实就是复制一份选中组件的数据,然后新增就可以了。

  const copy = () => {
    globalCanvas.addCmp(cmp);
  };

而在Canvas中有一个新增组件的函数,需要注意的是,onlyKey需要重新生成,同时更新选中的组件为新复制的组件:

  addCmp = (_cmp) => {
    this.selectedCmp = {
      ..._cmp,
      onlyKey: getOnlyKey(),
    };
    const cmps = this.getCmps();
    this.updateCmps([...cmps, this.selectedCmp]);
  };

删除

删除最简单,根据当前组件的onlyKey去this.canvas的cmps中找到这个组件数据删除就行了,不要忘记把this.seletedCmp置null,因为编辑组件的区域是根据this.seletedCmp显示的,然后更新画布与编辑区域组件就可以了。

  // 点击组件,右键删除组件
  deleteSelectedCmp = (_cmp) => {
    this.setSelectedCmp(null);

    const cmps = this.getCmps();
    this.updateCmps(cmps.filter((cmp) => cmp.onlyKey !== _cmp.onlyKey));
  };

置顶与置底

这里所有组件的层级关系通过z-index控制,而z-index的取值则是组件在cmps数组中的下标,所以调整层级关系则通过更新组件在数组中的顺序就行了。那么置顶则是交换cmps中最后一个组件和选中组件的位置就行了,置底则是交换cmps中第0个组件和选中组件的位置。

  const beTop = (e) => {
    globalCanvas.changeCmpIndex(index);
  };

  const beBottom = (e) => {
    globalCanvas.changeCmpIndex(index, 0);
  };

展示所有组件

展示所有

右键菜单还有一个功能就是展示所有组件,因为组件太多的时候,有些组件会被覆盖掉,那么但从画布上就没法选中被覆盖掉的组件,这个时候可以通过右键出现的菜单查看所有组件,鼠标停留,则会显示对应的组件,点击的话则有选中的功能。事件代码如下:

  const cmps = globalCanvas.getCmps();

  const mouseOver = (e, _cmp) => {
    let cmpTarget = document.getElementById("cmp" + _cmp.onlyKey);
    let prevClassName = cmpTarget.className;
    if (prevClassName.indexOf("hover") === -1) {
      cmpTarget.setAttribute("class", prevClassName + " hover");
    }
  };

  const mouseLeave = (e, _cmp) => {
    let cmpTarget = document.getElementById("cmp" + _cmp.onlyKey);
    let prevClassName = cmpTarget.className;

    if (prevClassName.indexOf("hover") > -1) {
      cmpTarget.setAttribute("class", prevClassName.slice(0, -6));
    }
  };

  const selectCmp = (e, cmp) => {
    globalCanvas.setSelectedCmp(cmp);
  };

上一步、下一步

其实就是个时光机,想回到某一时刻,那么你需要记录下自己的修改历史。这个时候需要两个值this.canvasChangeHistory与this.canvasIndex。在修改画布数据以及组件数据的时候执行this.recordCanvasChangeHistory()函数记录下历史即可。点击到上一步,则获取this.canvasChangeHistory中this.canvasIndex的上一个值,下一步则获取下一个。注意下第0个和最后一个检验边界值就行了。

动画

动画

组件可以添加一些动画属性,这里从兔展拷贝了三个动画,可以修改一动画的单次持续时长、重复次数以及延迟时间。

选择模板

模板

每次都从0创建太慢,可以做一些预设模板,然后用数据填充画布就可以了。

export default function Tpl({openOrCloseTpl, globalCanvas}) {
  const updateCmps = (cmps) => {
    globalCanvas.updateCanvas(JSON.parse(cmps));
    openOrCloseTpl(false);
  };

  return (
    <ul className={styles.main}>
      <li className={styles.close} onClick={openOrCloseTpl}>
        <i className="iconfont icon-close"></i>
      </li>
      {tplData.map((item) => (
        <li
          key={item.id}
          className={styles.item}
          onClick={() => updateCmps(item.cmps)}>
          <div className={styles.name}>{item.name}</div>
          <div className={styles.thumbnail}>
            <img src={item.img} />
          </div>
        </li>
      ))}
    </ul>
  );
}

生成器

编辑器做完之后,可以把画布那部分拿出来,再做一个生成器项目,

function App() {
  const [canvas, setCanvas] = useState(null);

  const {cmps, style} = canvas || {};

  useEffect(() => {
    let cc = JSON.parse(
      // ! 元宵节
            '{"style":{"width":320,"height":568,"backgroundColor":"#fc0000ff","backgroundImage":"https://img.tusij.com/ips_asset/16/11/10/44/54/a5/a57d2950001941a5e65fc3ac73fe8cb8.png!l800_i_w?auth_key=1639324800-0-0-d94f8946bfa0f7eca8fc8094a1516003","backgroundPosition":"center","backgroundSize":"cover","backgroundRepeat":"no-repeat","boxSizing":"content-box"},"cmps":[{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/54/a5/a57d2950001941a5e65fc3ac73fe8cb8.png!l800_i_w?auth_key=1639324800-0-0-d94f8946bfa0f7eca8fc8094a1516003","style":{"top":-1,"left":-1,"width":321,"height":153,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.27364639468523455},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/54/a5/a57d2950001941a5e65fc3ac73fe8cb8.png!l800_i_w?auth_key=1639324800-0-0-d94f8946bfa0f7eca8fc8094a1516003","style":{"top":155,"left":-3,"width":321,"height":153,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.7545885469950053},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/54/a5/a57d2950001941a5e65fc3ac73fe8cb8.png!l800_i_w?auth_key=1639324800-0-0-d94f8946bfa0f7eca8fc8094a1516003","style":{"top":420,"left":-3,"width":321,"height":153,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.7590306166672274},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/53/ca/ca7ebd1a9683109e61f374e75e87fc85.png!l800_i_w?auth_key=1639324800-0-0-04d5239353f80379a2430dc74d1ac11a","style":{"top":18,"left":211,"width":89,"height":81,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.14191580299167428},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/54/70/70913bd41742596a4a0dd68b088e6551.png!l800_i_w?auth_key=1639324800-0-0-2a8cd9567a9d2a9aa2ddd8acc4a24450","style":{"top":460,"left":0,"width":320,"height":110,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.5399342806341869},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/53/9a/9a353760e02b49cbdd2706f5c452291b.png!l800_i_w?auth_key=1639324800-0-0-8825104eb9f4bd5ca42b9ff8c3690c9c","style":{"top":403,"left":10,"width":121,"height":50,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.27065004352847866},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/53/69/6917ec339fa98e4cb97cf596cc9179df.png!l800_i_w?auth_key=1639324800-0-0-31958bfca526c4f4f87f4363b8b16b61","style":{"top":461,"left":28,"width":97,"height":49,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.3396974553981347},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/53/e7/e722646ec5596c852c8b193b2ef09db9.png!l800_i_w?auth_key=1639324800-0-0-0e5dcd8e08ad1e7f0de72c2dad23419c","style":{"top":439,"left":158,"width":100,"height":47,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.02766075271613433},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/16/11/10/44/54/09/09917bf7e35711c91d353fd7aebf2a38.png!l800_i_w?auth_key=1639324800-0-0-bd838424e74c24b3f0787ae4c4fb11d6","style":{"top":215,"left":116,"width":114,"height":154,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.6929555070607207},{"desc":"图片","data":{"type":2,"value":"https://tva1.sinaimg.cn/large/008eGmZEly1gnqdhx1eprj303m03mjrm.jpg","style":{"top":388,"left":245,"width":41,"height":58,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff","animationName":"wobble","animationDelay":0,"animationDuration":"8","animationIterationCount":"infinite"}},"onlyKey":0.7708575276016363},{"desc":"图片","data":{"type":2,"value":"https://img.tusij.com/ips_asset/15/48/39/56/74/56/564896077cb72510ff3b920732d8c53c.png!l800_i_w?auth_key=1639152000-0-0-456d31b72cda757ae3945425296bd646","style":{"top":173,"left":248,"width":51,"height":58,"borderRadius":"0%","borderStyle":"none","borderWidth":"0","borderColor":"#fff"}},"onlyKey":0.540328523257599}]}'
    );
    setCanvas(cc);
  }, []);

  return canvas ? (
    <div
      className={styles.main}
      style={{
        ...formatStyle(style),
        backgroundImage: `url(${style.backgroundImage})`,
      }}>
      {cmps.map((cmp, index) => (
        <Draggable
          key={cmp.onlyKey}
          cmp={cmp}
          index={index}
          canvasWidth={style.width}
          canvasHeight={style.height}
        />
      ))}
    </div>
  ) : (
    <div>
      <i className="iconfont icon-loading"></i>
    </div>
  );
}

完结~


别忘了给文章点赞 也欢迎关注公众号【花果山前端】和我的B站,后续会上传更多的原创视频