电竞比分网-中国电竞赛事及体育赛事平台

分享

低代碼編輯器不難,實(shí)現(xiàn)一個(gè)!

 風(fēng)聲之家 2022-06-15 發(fā)布于江蘇

前端從進(jìn)階到入院 2022-06-06 08:30 發(fā)表于上海

以下文章來(lái)源于Tecvan ,作者趙越

Tecvan

All or nothing, now or never ??

來(lái)自 「趙越」 同學(xué)的內(nèi)部分享,文章示例代碼:「https://github.com/shadowings-zy/mini-editor」,歡迎 Star。

前端從進(jìn)階到入院

我是 ssh,只想用最簡(jiǎn)單的方式把原理講明白。wx:sshsunlight,分享前端的前沿趨勢(shì)和一些有趣的事情。

78篇原創(chuàng)內(nèi)容

公眾號(hào)

目錄:

  • 編輯器功能拆分
  • 編輯器數(shù)據(jù)格式的定義
  • 項(xiàng)目代碼結(jié)構(gòu)
  • 重點(diǎn)邏輯的實(shí)現(xiàn)(畫布渲染、屬性聯(lián)動(dòng)、拖拽組件)
  • 與后臺(tái)交互
  • 還可以優(yōu)化的點(diǎn)

一、寫在前面

低代碼編輯器作為一種能夠極大地提升開發(fā)效率的PaaS軟件,近些年來(lái)一直收到各大公司以及各路投資方的追捧。而對(duì)于我們前端開發(fā)者來(lái)說(shuō),編輯器也是為數(shù)不多的擁有較深前端技術(shù)深度的開發(fā)場(chǎng)景。

通過(guò)本篇文章,你能夠?qū)W會(huì)如何基于 React 技術(shù)棧搭建一個(gè)最簡(jiǎn)單的低代碼編輯器,以及部分重點(diǎn)功能的實(shí)現(xiàn)方式。

本文示例代碼已經(jīng)開源到 GitHub 上,有需要的朋友們可以自取:https://github.com/shadowings-zy/mini-editor 。簡(jiǎn)單預(yù)覽下這個(gè)編輯器的 demo:圖片

二、編輯器功能拆分

先上一張?jiān)蛨D:

圖片
請(qǐng)?zhí)砑訄D片描述

對(duì)于大部分低代碼編輯器來(lái)說(shuō),都是由「組件區(qū)」、「畫布區(qū)」、「屬性編輯區(qū)」這三個(gè)部分組成的。

  • 組件區(qū)負(fù)責(zé)展示可拖拽的組件和組件間的層級(jí)關(guān)系。
  • 畫布區(qū)負(fù)責(zé)渲染拖拽的組件,并對(duì)其進(jìn)行可視化展示。
  • 屬性編輯區(qū)負(fù)責(zé)編輯選中組件的屬性。

而針對(duì)這三個(gè)區(qū)域所負(fù)責(zé)的職責(zé),我們能夠很容易地設(shè)計(jì)出這三個(gè)區(qū)域所需要實(shí)現(xiàn)的功能:

  • 對(duì)于組件區(qū),我們需要保證組件是可拖拽的,并且組件能與畫布區(qū)交互
  • 對(duì)于畫布區(qū),我們需要首先抽象出一個(gè)用于展現(xiàn)「畫布區(qū)有哪些組件」的數(shù)據(jù)格式,然后畫布區(qū)能夠根據(jù)這個(gè)數(shù)據(jù)格式渲染對(duì)應(yīng)的組件。其次,我們還需要實(shí)現(xiàn)拖拽后的組件與畫布的交互,以及選中組件后與屬性編輯區(qū)的交互。
  • 對(duì)于屬性編輯區(qū),我們需要處理屬性變更后,與對(duì)應(yīng)組件的聯(lián)動(dòng)邏輯。

三、編輯器數(shù)據(jù)格式的定義

編輯器底層的數(shù)據(jù)格式是開發(fā)低代碼編輯器的重中之重,畫布區(qū)會(huì)根據(jù)這個(gè)數(shù)據(jù)去渲染畫布,而組件的拖拽,組件屬性的配置,實(shí)際上都是對(duì)這個(gè)數(shù)據(jù)的更改。

而回到我們編輯器本身,我們可以使用 json 格式的數(shù)據(jù)來(lái)抽象編輯器畫布的內(nèi)容,像下面這樣:

{
  "projectId""xxx"// 項(xiàng)目 ID
  "projectName""xxx"// 項(xiàng)目名稱
  "author""xxx"// 項(xiàng)目作者
  "data": [
    // 畫布組件配置
    {
      "id""xxx"// 組件 ID
      "type""text"// 組件類型
      "data""xxxxxx"// 文字內(nèi)容
      "color""#000000"// 文字顏色
      "size""12px"// 文字大小
      "width""100px"// 容器寬度
      "height""100px"// 容器高度
      "left""100px"// 容器左邊距
      "top""100px" // 容器上邊距
    },
    {
      "id""xxx"// 組件 ID
      "type""image"// 組件類型
      "data""http://xxxxxxx"// 圖片 url
      "width""100px"// 容器寬度
      "height""100px"// 容器高度
      "left""100px"// 容器左邊距
      "top""100px" // 容器上邊距
    },
    {
      "id""xxx"// 組件 ID
      "type""video"// 組件類型
      "data""http://xxxxxxx"// 視頻 url
      "width""100px"// 容器寬度
      "height""100px"// 容器高度
      "left""100px"// 容器左邊距
      "top""100px" // 容器上邊距
    }
  ]
}

定義好數(shù)據(jù)結(jié)構(gòu)后,「組件屬性編輯」、「拖拽新增組件」實(shí)際上都是對(duì)這個(gè) json 數(shù)據(jù)中的 data 字段進(jìn)行增刪改,而畫布區(qū)也會(huì)使用這個(gè)字段進(jìn)行畫布內(nèi)組件的渲染。

四、項(xiàng)目代碼結(jié)構(gòu)

整體代碼結(jié)構(gòu)如下:

.
├── package
│   ├── client # 前端頁(yè)面
│   │   ├── build # webpack 打包配置
│   │   │   ├── webpack.base.js
│   │   │   ├── webpack.dev.js
│   │   │   └── webpack.prod.js
│   │   ├── components # 前端組件
│   │   │   ├── textComponent # 組件區(qū)中的「文字組件」
│   │   │   │   ├── index.tsx
│   │   │   │   └── style.css
│   │   │   └── textPanel # 「文字組件」對(duì)應(yīng)的屬性編輯組件
│   │   │       ├── index.tsx
│   │   │       └── style.css
│   │   ├── constants # 一些常量
│   │   │   └── index.ts
│   │   ├── index.html
│   │   ├── index.tsx
│   │   ├── pages # 前端頁(yè)面
│   │   │   ├── app # 根組件
│   │   │   │   ├── index.tsx
│   │   │   │   └── style.css
│   │   │   ├── drawPanel # 畫布區(qū)
│   │   │   │   ├── index.tsx
│   │   │   │   └── style.css
│   │   │   ├── leftPanel # 左側(cè)組件區(qū)
│   │   │   │   ├── index.tsx
│   │   │   │   └── style.css
│   │   │   └── rightPanel # 右側(cè)屬性編輯區(qū)
│   │   │       ├── index.tsx
│   │   │       └── style.css
│   │   ├── style.css
│   │   └── tsconfig.json
│   └── server # 后端代碼
│       ├── app.ts # 后端邏輯
│       ├── config # 后端配置
│       │   ├── dev.ts
│       │   ├── index.ts
│       │   └── prod.ts
│       ├── constants.ts # 一些常量
│       └── tsconfig.json
├── package.json
├── pnpm-lock.yaml
└── tsconfig.json

五、重點(diǎn)邏輯的實(shí)現(xiàn)

在梳理重點(diǎn)邏輯之前,我們得先梳理下我們的編輯器組件需要維護(hù)哪些數(shù)據(jù)。

  • 首先是編輯器數(shù)據(jù),畫布需要根據(jù)編輯器數(shù)據(jù)來(lái)渲染內(nèi)容,而添加組件和屬性修改本質(zhì)上都是對(duì)這個(gè)數(shù)據(jù)的改動(dòng)。
  • 其次是右側(cè)面板的類型,編輯不同組件要有不同類型的編輯項(xiàng)。
  • 另外還有當(dāng)前選中的組件 id,右側(cè)屬性面板的改動(dòng)都是對(duì)當(dāng)前組件 id 生效的。

所以我們將這些數(shù)據(jù)維護(hù)在根組件下,并用 props 將它們傳遞給其他子組件,代碼如下:

import DrawPanel from "../drawPanel"// 畫布
import LeftPanel from "../leftPanel"// 左側(cè)組件面板
import RightPanel from "../rightPanel"// 右側(cè)屬性編輯面板

export default function App({
  const [drawPanelData, setDrawPanelData] = useState([]); // 編輯器數(shù)據(jù)
  const [rightPanelType, setRightPanelType] = useState(RIGHT_PANEL_TYPE.NONE); // 右側(cè)屬性面板類型
  const [rightPanelElementId, setRightPanelElementId] = useState(""); // 右側(cè)屬性面板編輯的 id

  return (
    <div className="flex-row-space-between app">
      <LeftPanel data={drawPanelData}></LeftPanel>
      <DrawPanel
        data={drawPanelData}
        setData={setDrawPanelData}
        setRightPanelType={setRightPanelType}
        setRightPanelElementId={setRightPanelElementId}
      ></
DrawPanel>
      <RightPanel
        type={rightPanelType}
        data={drawPanelData}
        elementId={rightPanelElementId}
        setDrawPanelData={setDrawPanelData}
      ></RightPanel>
    </
div>
  );
}

定義好這些數(shù)據(jù)之后,我們就來(lái)講解一下重點(diǎn)邏輯的實(shí)現(xiàn)。

5-1、畫布渲染

首先我們來(lái)看一下畫布渲染邏輯的實(shí)現(xiàn):

這里先要將畫布區(qū)的布局調(diào)整為position: relative,然后將每個(gè)組件的布局設(shè)置為position: absolute,這樣我們就能根據(jù)編輯器數(shù)據(jù)中的lefttop屬性來(lái)定位組件在畫布的位置了。

然后就是遍歷編輯器數(shù)據(jù),將對(duì)應(yīng)的組件渲染到畫布上。具體代碼如下:

// package/client/pages/drawPanel/index.tsx

interface IDrawPanelProps {
  data: any// 將編輯器數(shù)據(jù)作為 props 傳入組件中
}

export default function DrawPanel(props: IDrawPanelProps{
  const { data } = props;

  const generateContent = () => {
    const output = [];
    // 遍歷編輯器數(shù)據(jù)并渲染畫布
    for (const item of data) {
      if (item.type === COMPONENT_TYPE.TEXT) {
        output.push(
          <div
            key={item.id}
            style={{
              color: item.color,
              fontSize: item.size,
              width: item.width,
              height: item.height,
              left: item.left,
              top: item.top,
              position: "absolute",
              backgroundColor: "#bbbbbb",
            }}
          >
            {item.data}
          </div>
        );
      }
    }

    return output;
  };

  return (
    <div
      className="draw-panel"
      ref={drop}
      style={{
        position: "relative",
      }}
    >
      {generateContent()}
    </
div>
  );
}

5-2、屬性聯(lián)動(dòng)

接著,為了實(shí)現(xiàn)屬性聯(lián)動(dòng),我們需要實(shí)現(xiàn)以下幾件事:

  1. 為畫布上的組件添加點(diǎn)擊事件,使其能夠在點(diǎn)擊時(shí)設(shè)置右側(cè)屬性編輯面板的內(nèi)容。
  2. 在右側(cè)屬性編輯面板編輯組件屬性時(shí),要能對(duì)編輯器數(shù)據(jù)中對(duì)應(yīng)目標(biāo)組件的數(shù)據(jù)進(jìn)行改動(dòng),然后畫布區(qū)根據(jù)新的編輯器數(shù)據(jù)進(jìn)行渲染。

為了實(shí)現(xiàn)第一點(diǎn),我們就要在畫布組件中為每一個(gè)渲染出的組件添加點(diǎn)擊事件了,使用 props 傳入的setRightPanelTypesetRightPanelElementId來(lái)設(shè)置對(duì)應(yīng)選中的元素,代碼如下:

// package/client/pages/drawPanel/index.tsx

export default function DrawPanel(props: IDrawPanelProps{
  const { data, setRightPanelType, setRightPanelElementId } = props;

  const generateContent = () => {
    const output = [];
    for (const item of data) {
      if (item.type === COMPONENT_TYPE.TEXT) {
        output.push(
          <div
            key={item.id}
            style={{
              color: item.color,
              fontSize: item.size,
              width: item.width,
              height: item.height,
              left: item.left,
              top: item.top,
              position: 'absolute',
              backgroundColor: '#bbbbbb'
            }}

+           // 在這里添加點(diǎn)擊事件
+           onClick={() => {
+             setRightPanelType(RIGHT_PANEL_TYPE.TEXT);
+               setRightPanelElementId(item.id);
+           }}
          >
            {item.data}
          </div>
        );
      }
    }

    return output;
  };

  /
/ ... 其他邏輯
}

而為了實(shí)現(xiàn)右側(cè)面板能夠?qū)崟r(shí)編輯數(shù)據(jù),我們首先需要根據(jù)傳入的elementId,遍歷編輯器數(shù)據(jù),拿到要修改的項(xiàng),然后獲取對(duì)應(yīng)的屬性改動(dòng)值,最后使用setDrawPanelData進(jìn)行修改。具體代碼如下:

interface IRigthPanelProps {
  type: RIGHT_PANEL_TYPE;
  data: any;
  elementId: string;
  setDrawPanelData: Function;
}

export default function RightPanel(props: IRigthPanelProps{
  const { type, data, elementId, setDrawPanelData } = props;

  const findCurrentElement = (id: string) => {
    for (const item of data) {
      if (item.id === id) {
        return item;
      }
    }
    return undefined;
  };

  const findCurrentElementAndChangeData = (
    id: string,
    key: string,
    changedData: any
  ) => {
    for (let item of data) {
      if (item.id === id) {
        item[key] = changedData;
      }
    }
    setDrawPanelData([...data]);
  };

  const generateRightPanel = () => {
    if (type === RIGHT_PANEL_TYPE.NONE) {
      return <div>未選中元素</div>;
    } else if (type === RIGHT_PANEL_TYPE.TEXT) {
      const elementData = findCurrentElement(elementId);
      const inputDomObject = [];

      return (
        <div key={elementId}>
          <div>文字元素</
div>
          <br />
          <div className="flex-row-space-between text-config-item">
            <div>文字內(nèi)容:</div>
            <input
              defaultValue={elementData.data}
              ref={(element) => {
                inputDomObject[0] = element;
              }}
              type="text"
            ></i
nput>
          </div>
          <div className="flex-row-space-between text-config-item">
            <div>文字顏色:</
div>
            <input
              defaultValue={elementData.color}
              ref={(element) => {
                inputDomObject[1] = element;
              }}
              type="text"
            ></input>
          </
div>
          <div className="flex-row-space-between text-config-item">
            <div>文字大小:</div>
            <input
              defaultValue={elementData.size}
              ref={(element) => {
                inputDomObject[2] = element;
              }}
              type="text"
            ></i
nput>
          </div>
          <div className="flex-row-space-between text-config-item">
            <div>width:</
div>
            <input
              defaultValue={elementData.width}
              ref={(element) => {
                inputDomObject[3] = element;
              }}
              type="text"
            ></input>
          </
div>
          <div className="flex-row-space-between text-config-item">
            <div>height:</div>
            <input
              defaultValue={elementData.height}
              ref={(element) => {
                inputDomObject[4] = element;
              }}
              type="text"
            ></i
nput>
          </div>
          <div className="flex-row-space-between text-config-item">
            <div>top:</
div>
            <input
              defaultValue={elementData.top}
              ref={(element) => {
                inputDomObject[5] = element;
              }}
              type="text"
            ></input>
          </
div>
          <div className="flex-row-space-between text-config-item">
            <div>left:</div>
            <input
              defaultValue={elementData.left}
              ref={(element) => {
                inputDomObject[6] = element;
              }}
              type="text"
            ></i
nput>
          </div>
          <br /
>
          <button
            onClick={() => {
              findCurrentElementAndChangeData(
                elementId,
                "data",
                inputDomObject[0].value
              );
              findCurrentElementAndChangeData(
                elementId,
                "color",
                inputDomObject[1].value
              );
              findCurrentElementAndChangeData(
                elementId,
                "size",
                inputDomObject[2].value
              );
              findCurrentElementAndChangeData(
                elementId,
                "width",
                inputDomObject[3].value
              );
              findCurrentElementAndChangeData(
                elementId,
                "height",
                inputDomObject[4].value
              );
              findCurrentElementAndChangeData(
                elementId,
                "top",
                inputDomObject[5].value
              );
              findCurrentElementAndChangeData(
                elementId,
                "left",
                inputDomObject[6].value
              );
            }}
          >
            確定
          </button>
        </
div>
      );
    }
  };

  return <div className="right-panel">{generateRightPanel()}</div>;
}

5-3、拖拽組件

最后就到了重中之重,如何實(shí)現(xiàn)拖拽,這里使用了react-dnd,react官方實(shí)現(xiàn)的拖拽庫(kù),文檔參考這里:https://react-dnd./react-dnd/about 。

react-dnd中,定義了dragdrop兩種類型的組件,那么很顯然,左側(cè)面板中需要拖拽的是drag組件,畫布是drop組件。

對(duì)于左側(cè)需要拖拽的組件,我們使用react-dnd提供的useDrag鉤子來(lái)使其可拖拽,代碼如下:

// package/client/components/textComponent/index.tsx

export default function TextComponent({
  const [_, drag] = useDrag(() => ({
    type: COMPONENT_TYPE.TEXT,
  }));

  return (
    <div className="text-component" ref={drag}>
      文字組件
    </div>
  );
}

對(duì)于畫布,我們使用useDrop鉤子,并用getClientOffset函數(shù)拿到拖拽的位置,計(jì)算出新增組件的lefttop值,然后使用setData設(shè)置編輯器數(shù)據(jù)即可,代碼如下:

export default function DrawPanel(props: IDrawPanelProps{
  const { data, setRightPanelType, setRightPanelElementId, setData } = props;

  const [, drop] = useDrop(() => ({
    accept: COMPONENT_TYPE.TEXT,
    drop: (_, monitor) => {
      const { x, y } = monitor.getClientOffset();
      const currentX = x - 310;
      const currentY = y - 20;

      setData([
        ...data,
        {
          id: `text-${data.length + 1}`,
          type"text",
          data: "我是新建的文字",
          color: "#000000",
          size: "12px",
          width: "100px",
          height: "20px",
          left: `${currentX}px`,
          top: `${currentY}px`,
        },
      ]);
    },
  }));

  // ... 其他邏輯
}

六、與后臺(tái)交互

當(dāng)我們實(shí)現(xiàn)了編輯器的邏輯后,與后臺(tái)交互的邏輯就相當(dāng)簡(jiǎn)單了,向后臺(tái)發(fā)起請(qǐng)求,存/取這個(gè)編輯器 json 數(shù)據(jù)就可以了,這里簡(jiǎn)單實(shí)現(xiàn)了向后臺(tái)存數(shù)據(jù)的邏輯,代碼如下:

import axios from "axios";

export default function LeftPanel(props: ILeftPanelProps{
  const { data } = props;

  return (
    <div className="left-panel">
      <div className="component-list">
        <TextComponent></TextComponent>
      </
div>
      <button
        className="save-button"
        onClick={() => {
          console.log("save:", data);
          axios
            .post("/api/save", { drawPanelData: data })
            .then((res) => {
              console.log("res:", res);
            })
            .catch((err) => {
              console.log("err:", err);
            });
        }}
      >
        保存到后臺(tái)
      </button>
    </
div>
  );
}

后臺(tái)接收到數(shù)據(jù)后,就可以將其存儲(chǔ)到數(shù)據(jù)庫(kù)中了。

import Koa from "koa";
import Router from "koa-router";
import koaStatic from "koa-static";
import koaBody from "koa-body";
import { config } from "./config";
import { PORT } from "./constants";

const app = new Koa();

app.use(koaBody());

const router = new Router();

router.get("/api"async (ctx, next) => {
  ctx.body = { message: "Hello World" };
  await next();
});

router.post("/api/save"async (ctx, next) => {
  console.log("save:", ctx.request.body);
  // ...儲(chǔ)存到數(shù)據(jù)庫(kù)
  ctx.body = {
    message: "Save data successful",
    receivedData: ctx.request.body,
  };
  await next();
});

app.use(router.routes());
app.use(router.allowedMethods());

app.use(koaStatic(config.staticFilePath));

app.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
});

七、可以深入挖掘的點(diǎn)

通過(guò)上面這些步驟,我們就能實(shí)現(xiàn)一個(gè)最簡(jiǎn)單的低代碼編輯器了,但是可以深入挖掘的技術(shù)點(diǎn)還有很多,下面我會(huì)列出來(lái)其中的一些點(diǎn)以及解決的思路。

7-1、組件嵌套如何實(shí)現(xiàn)?

對(duì)于組件嵌套,我們需要修改對(duì)于編輯器數(shù)據(jù)(就是json格式數(shù)據(jù))的操作邏輯,從原先的「數(shù)組插入」,改變?yōu)獒槍?duì)某個(gè)層級(jí)的操作,同時(shí),組件遍歷的邏輯也要有所更改。

7-2、能否抽象出一個(gè)更高層級(jí)的屬性編輯組件?

大部分工業(yè)界的編輯器實(shí)際上都做了這樣一層抽象,對(duì)于不同屬性編輯組件,會(huì)使用schema來(lái)描述這個(gè)編輯組件可編輯的項(xiàng),以及這個(gè)編輯項(xiàng)所對(duì)應(yīng)的可以更改的數(shù)據(jù)。而屬性編輯組件實(shí)際上就是來(lái)消費(fèi)這個(gè)schema進(jìn)行渲染的。

7-3、像視頻這種體積比較大的文件,如何上傳到服務(wù)器?

這個(gè)涉及到文件分片上傳,后端組合存儲(chǔ),可以看我之前實(shí)現(xiàn)的這個(gè)項(xiàng)目:https://github.com/shadowings-zy/easy-file-uploader

7-4、更好的全局?jǐn)?shù)據(jù)管理方案?

本文因?yàn)閷?shí)現(xiàn)的比較簡(jiǎn)單,把所有數(shù)據(jù)都放到了根組件下,然后作為props傳參,但實(shí)際上,一個(gè)復(fù)雜的低代碼編輯器組件層級(jí)很深,使用props不太現(xiàn)實(shí),這種情況下使用redux等全局?jǐn)?shù)據(jù)管理的庫(kù)會(huì)比較方便。

確定

  • 不看此公眾號(hào)

    本站是提供個(gè)人知識(shí)管理的網(wǎng)絡(luò)存儲(chǔ)空間,所有內(nèi)容均由用戶發(fā)布,不代表本站觀點(diǎn)。請(qǐng)注意甄別內(nèi)容中的聯(lián)系方式、誘導(dǎo)購(gòu)買等信息,謹(jǐn)防詐騙。如發(fā)現(xiàn)有害或侵權(quán)內(nèi)容,請(qǐng)點(diǎn)擊一鍵舉報(bào)。
    轉(zhuǎn)藏 分享 獻(xiàn)花(0

    0條評(píng)論

    發(fā)表

    請(qǐng)遵守用戶 評(píng)論公約

    類似文章 更多