從零開始實現一個簡單的低代碼編輯器

來自 「趙越」 同學的內部分享,文章示例代碼:「https://github.com/shadowings-zy/mini-editor」,歡迎 Star。

一、寫在前面

低代碼編輯器作爲一種能夠極大地提升開發效率的 PaaS 軟件,近些年來一直收到各大公司以及各路投資方的追捧。而對於我們前端開發者來說,編輯器也是爲數不多的擁有較深前端技術深度的開發場景。

通過本篇文章,你能夠學會如何基於 React 技術棧搭建一個最簡單的低代碼編輯器,以及部分重點功能的實現方式。

本文示例代碼已經開源到 GitHub 上,有需要的朋友們可以自取:https://github.com/shadowings-zy/mini-editor 。簡單預覽下這個編輯器的 demo:

二、編輯器功能拆分

先上一張原型圖:

請添加圖片描述

對於大部分低代碼編輯器來說,都是由「組件區」、「畫布區」、「屬性編輯區」這三個部分組成的。

而針對這三個區域所負責的職責,我們能夠很容易地設計出這三個區域所需要實現的功能:

三、編輯器數據格式的定義

編輯器底層的數據格式是開發低代碼編輯器的重中之重,畫布區會根據這個數據去渲染畫布,而組件的拖拽,組件屬性的配置,實際上都是對這個數據的更改。

而回到我們編輯器本身,我們可以使用 json 格式的數據來抽象編輯器畫布的內容,像下面這樣:

{
  "projectId""xxx", // 項目 ID
  "projectName""xxx", // 項目名稱
  "author""xxx", // 項目作者
  "data"[
    // 畫布組件配置
    {
      "id""xxx", // 組件 ID
      "type""text", // 組件類型
      "data""xxxxxx", // 文字內容
      "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" // 容器上邊距
    }
  ]
}

定義好數據結構後,「組件屬性編輯」、「拖拽新增組件」實際上都是對這個 json 數據中的 data 字段進行增刪改,而畫布區也會使用這個字段進行畫布內組件的渲染。

四、項目代碼結構

整體代碼結構如下:

.
├── package
│   ├── client # 前端頁面
│   │   ├── build # webpack 打包配置
│   │   │   ├── webpack.base.js
│   │   │   ├── webpack.dev.js
│   │   │   └── webpack.prod.js
│   │   ├── components # 前端組件
│   │   │   ├── textComponent # 組件區中的「文字組件」
│   │   │   │   ├── index.tsx
│   │   │   │   └── style.css
│   │   │   └── textPanel # 「文字組件」對應的屬性編輯組件
│   │   │       ├── index.tsx
│   │   │       └── style.css
│   │   ├── constants # 一些常量
│   │   │   └── index.ts
│   │   ├── index.html
│   │   ├── index.tsx
│   │   ├── pages # 前端頁面
│   │   │   ├── app # 根組件
│   │   │   │   ├── index.tsx
│   │   │   │   └── style.css
│   │   │   ├── drawPanel # 畫布區
│   │   │   │   ├── index.tsx
│   │   │   │   └── style.css
│   │   │   ├── leftPanel # 左側組件區
│   │   │   │   ├── index.tsx
│   │   │   │   └── style.css
│   │   │   └── rightPanel # 右側屬性編輯區
│   │   │       ├── 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

五、重點邏輯的實現

在梳理重點邏輯之前,我們得先梳理下我們的編輯器組件需要維護哪些數據。

所以我們將這些數據維護在根組件下,並用 props 將它們傳遞給其他子組件,代碼如下:

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

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

  return (
    <div class>
      <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>
  );
}

定義好這些數據之後,我們就來講解一下重點邏輯的實現。

5-1、畫布渲染

首先我們來看一下畫布渲染邏輯的實現:

這裏先要將畫布區的佈局調整爲position: relative,然後將每個組件的佈局設置爲position: absolute,這樣我們就能根據編輯器數據中的lefttop屬性來定位組件在畫布的位置了。

然後就是遍歷編輯器數據,將對應的組件渲染到畫布上。具體代碼如下:

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

interface IDrawPanelProps {
  data: any; // 將編輯器數據作爲 props 傳入組件中
}

export default function DrawPanel(props: IDrawPanelProps) {
  const { data } = 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",
            }}
          >
            {item.data}
          </div>
        );
      }
    }

    return output;
  };

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

5-2、屬性聯動

接着,爲了實現屬性聯動,我們需要實現以下幾件事:

  1. 爲畫布上的組件添加點擊事件,使其能夠在點擊時設置右側屬性編輯面板的內容。

  2. 在右側屬性編輯面板編輯組件屬性時,要能對編輯器數據中對應目標組件的數據進行改動,然後畫布區根據新的編輯器數據進行渲染。

爲了實現第一點,我們就要在畫布組件中爲每一個渲染出的組件添加點擊事件了,使用 props 傳入的setRightPanelTypesetRightPanelElementId來設置對應選中的元素,代碼如下:

// 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'
            }}

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

    return output;
  };

  // ... 其他邏輯
}

而爲了實現右側面板能夠實時編輯數據,我們首先需要根據傳入的elementId,遍歷編輯器數據,拿到要修改的項,然後獲取對應的屬性改動值,最後使用setDrawPanelData進行修改。具體代碼如下:

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 class>
            <div>文字內容:</div>
            <input
              defaultValue={elementData.data}
              ref={(element) ={
                inputDomObject[0] = element;
              }}
              type="text"
            ></input>
          </div>
          <div class>
            <div>文字顏色:</div>
            <input
              defaultValue={elementData.color}
              ref={(element) ={
                inputDomObject[1] = element;
              }}
              type="text"
            ></input>
          </div>
          <div class>
            <div>文字大小:</div>
            <input
              defaultValue={elementData.size}
              ref={(element) ={
                inputDomObject[2] = element;
              }}
              type="text"
            ></input>
          </div>
          <div class>
            <div>width:</div>
            <input
              defaultValue={elementData.width}
              ref={(element) ={
                inputDomObject[3] = element;
              }}
              type="text"
            ></input>
          </div>
          <div class>
            <div>height:</div>
            <input
              defaultValue={elementData.height}
              ref={(element) ={
                inputDomObject[4] = element;
              }}
              type="text"
            ></input>
          </div>
          <div class>
            <div>top:</div>
            <input
              defaultValue={elementData.top}
              ref={(element) ={
                inputDomObject[5] = element;
              }}
              type="text"
            ></input>
          </div>
          <div class>
            <div>left:</div>
            <input
              defaultValue={elementData.left}
              ref={(element) ={
                inputDomObject[6] = element;
              }}
              type="text"
            ></input>
          </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 class>{generateRightPanel()}</div>;
}

5-3、拖拽組件

最後就到了重中之重,如何實現拖拽,這裏使用了react-dndreact官方實現的拖拽庫,文檔參考這裏:https://react-dnd.github.io/react-dnd/about 。

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

對於左側需要拖拽的組件,我們使用react-dnd提供的useDrag鉤子來使其可拖拽,代碼如下:

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

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

  return (
    <div class ref={drag}>
      文字組件
    </div>
  );
}

對於畫布,我們使用useDrop鉤子,並用getClientOffset函數拿到拖拽的位置,計算出新增組件的lefttop值,然後使用setData設置編輯器數據即可,代碼如下:

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`,
        },
      ]);
    },
  }));

  // ... 其他邏輯
}

六、與後臺交互

當我們實現了編輯器的邏輯後,與後臺交互的邏輯就相當簡單了,向後臺發起請求,存 / 取這個編輯器 json 數據就可以了,這裏簡單實現了向後臺存數據的邏輯,代碼如下:

import axios from "axios";

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

  return (
    <div class>
      <div class>
        <TextComponent></TextComponent>
      </div>
      <button
        class
        onClick={() ={
          console.log("save:", data);
          axios
            .post("/api/save"{ drawPanelData: data })
            .then((res) ={
              console.log("res:", res);
            })
            .catch((err) ={
              console.log("err:", err);
            });
        }}
      >
        保存到後臺
      </button>
    </div>
  );
}

後臺接收到數據後,就可以將其存儲到數據庫中了。

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);
  // ...儲存到數據庫
  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}`);
});

七、可以深入挖掘的點

通過上面這些步驟,我們就能實現一個最簡單的低代碼編輯器了,但是可以深入挖掘的技術點還有很多,下面我會列出來其中的一些點以及解決的思路。

7-1、組件嵌套如何實現?

對於組件嵌套,我們需要修改對於編輯器數據(就是json格式數據)的操作邏輯,從原先的「數組插入」,改變爲針對某個層級的操作,同時,組件遍歷的邏輯也要有所更改。

7-2、能否抽象出一個更高層級的屬性編輯組件?

大部分工業界的編輯器實際上都做了這樣一層抽象,對於不同屬性編輯組件,會使用schema來描述這個編輯組件可編輯的項,以及這個編輯項所對應的可以更改的數據。而屬性編輯組件實際上就是來消費這個schema進行渲染的。

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

這個涉及到文件分片上傳,後端組合存儲,可以看我之前實現的這個項目:https://github.com/shadowings-zy/easy-file-uploader

7-4、更好的全局數據管理方案?

本文因爲實現的比較簡單,把所有數據都放到了根組件下,然後作爲props傳參,但實際上,一個複雜的低代碼編輯器組件層級很深,使用props不太現實,這種情況下使用redux等全局數據管理的庫會比較方便。


本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/llLKRCOLvZSjSO4WfzBgPg