目錄:
- 重點邏輯的實現(xiàn)(畫布渲染、屬性聯(lián)動、拖拽組件)
一、寫在前面
低代碼編輯器作為一種能夠極大地提升開發(fā)效率的PaaS軟件,近些年來一直收到各大公司以及各路投資方的追捧。而對于我們前端開發(fā)者來說,編輯器也是為數(shù)不多的擁有較深前端技術深度的開發(fā)場景。
通過本篇文章,你能夠學會如何基于 React 技術棧搭建一個最簡單的低代碼編輯器,以及部分重點功能的實現(xiàn)方式。
本文示例代碼已經開源到 GitHub 上,有需要的朋友們可以自?。篽ttps://github.com/shadowings-zy/mini-editor 。簡單預覽下這個編輯器的 demo:
二、編輯器功能拆分
先上一張原型圖:
請?zhí)砑訄D片描述對于大部分低代碼編輯器來說,都是由「組件區(qū)」、「畫布區(qū)」、「屬性編輯區(qū)」這三個部分組成的。
- 組件區(qū)負責展示可拖拽的組件和組件間的層級關系。
- 畫布區(qū)負責渲染拖拽的組件,并對其進行可視化展示。
而針對這三個區(qū)域所負責的職責,我們能夠很容易地設計出這三個區(qū)域所需要實現(xiàn)的功能:
- 對于組件區(qū),我們需要保證組件是可拖拽的,并且組件能與畫布區(qū)交互
- 對于畫布區(qū),我們需要首先抽象出一個用于展現(xiàn)「畫布區(qū)有哪些組件」的數(shù)據格式,然后畫布區(qū)能夠根據這個數(shù)據格式渲染對應的組件。其次,我們還需要實現(xiàn)拖拽后的組件與畫布的交互,以及選中組件后與屬性編輯區(qū)的交互。
- 對于屬性編輯區(qū),我們需要處理屬性變更后,與對應組件的聯(lián)動邏輯。
三、編輯器數(shù)據格式的定義
編輯器底層的數(shù)據格式是開發(fā)低代碼編輯器的重中之重,畫布區(qū)會根據這個數(shù)據去渲染畫布,而組件的拖拽,組件屬性的配置,實際上都是對這個數(shù)據的更改。
而回到我們編輯器本身,我們可以使用 json 格式的數(shù)據來抽象編輯器畫布的內容,像下面這樣:
{
"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" // 容器上邊距
}
]
}
定義好數(shù)據結構后,「組件屬性編輯」、「拖拽新增組件」實際上都是對這個 json 數(shù)據中的 data 字段進行增刪改,而畫布區(qū)也會使用這個字段進行畫布內組件的渲染。
四、項目代碼結構
整體代碼結構如下:
.
├── package
│ ├── client # 前端頁面
│ │ ├── build # webpack 打包配置
│ │ │ ├── webpack.base.js
│ │ │ ├── webpack.dev.js
│ │ │ └── webpack.prod.js
│ │ ├── components # 前端組件
│ │ │ ├── textComponent # 組件區(qū)中的「文字組件」
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.css
│ │ │ └── textPanel # 「文字組件」對應的屬性編輯組件
│ │ │ ├── index.tsx
│ │ │ └── style.css
│ │ ├── constants # 一些常量
│ │ │ └── index.ts
│ │ ├── index.html
│ │ ├── index.tsx
│ │ ├── pages # 前端頁面
│ │ │ ├── app # 根組件
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.css
│ │ │ ├── drawPanel # 畫布區(qū)
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.css
│ │ │ ├── leftPanel # 左側組件區(qū)
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.css
│ │ │ └── rightPanel # 右側屬性編輯區(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
五、重點邏輯的實現(xiàn)
在梳理重點邏輯之前,我們得先梳理下我們的編輯器組件需要維護哪些數(shù)據。
- 首先是編輯器數(shù)據,畫布需要根據編輯器數(shù)據來渲染內容,而添加組件和屬性修改本質上都是對這個數(shù)據的改動。
- 其次是右側面板的類型,編輯不同組件要有不同類型的編輯項。
- 另外還有當前選中的組件 id,右側屬性面板的改動都是對當前組件 id 生效的。
所以我們將這些數(shù)據維護在根組件下,并用 props 將它們傳遞給其他子組件,代碼如下:
import DrawPanel from "../drawPanel"; // 畫布
import LeftPanel from "../leftPanel"; // 左側組件面板
import RightPanel from "../rightPanel"; // 右側屬性編輯面板
export default function App() {
const [drawPanelData, setDrawPanelData] = useState([]); // 編輯器數(shù)據
const [rightPanelType, setRightPanelType] = useState(RIGHT_PANEL_TYPE.NONE); // 右側屬性面板類型
const [rightPanelElementId, setRightPanelElementId] = useState(""); // 右側屬性面板編輯的 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ù)據之后,我們就來講解一下重點邏輯的實現(xiàn)。
5-1、畫布渲染
首先我們來看一下畫布渲染邏輯的實現(xiàn):
這里先要將畫布區(qū)的布局調整為position: relative,然后將每個組件的布局設置為position: absolute,這樣我們就能根據編輯器數(shù)據中的left和top屬性來定位組件在畫布的位置了。
然后就是遍歷編輯器數(shù)據,將對應的組件渲染到畫布上。具體代碼如下:
// package/client/pages/drawPanel/index.tsx
interface IDrawPanelProps {
data: any; // 將編輯器數(shù)據作為 props 傳入組件中
}
export default function DrawPanel(props: IDrawPanelProps) {
const { data } = props;
const generateContent = () => {
const output = [];
// 遍歷編輯器數(shù)據并渲染畫布
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)動
接著,為了實現(xiàn)屬性聯(lián)動,我們需要實現(xiàn)以下幾件事:
- 為畫布上的組件添加點擊事件,使其能夠在點擊時設置右側屬性編輯面板的內容。
- 在右側屬性編輯面板編輯組件屬性時,要能對編輯器數(shù)據中對應目標組件的數(shù)據進行改動,然后畫布區(qū)根據新的編輯器數(shù)據進行渲染。
為了實現(xiàn)第一點,我們就要在畫布組件中為每一個渲染出的組件添加點擊事件了,使用 props 傳入的setRightPanelType和setRightPanelElementId來設置對應選中的元素,代碼如下:
// 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;
};
// ... 其他邏輯
}
而為了實現(xiàn)右側面板能夠實時編輯數(shù)據,我們首先需要根據傳入的elementId,遍歷編輯器數(shù)據,拿到要修改的項,然后獲取對應的屬性改動值,最后使用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 className="flex-row-space-between text-config-item">
<div>文字內容:</div>
<input
defaultValue={elementData.data}
ref={(element) => {
inputDomObject[0] = element;
}}
type="text"
></input>
</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"
></input>
</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"
></input>
</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"
></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 className="right-panel">{generateRightPanel()}</div>;
}
5-3、拖拽組件
最后就到了重中之重,如何實現(xiàn)拖拽,這里使用了react-dnd,react官方實現(xiàn)的拖拽庫,文檔參考這里:https://react-dnd./react-dnd/about 。
在react-dnd中,定義了drag和drop兩種類型的組件,那么很顯然,左側面板中需要拖拽的是drag組件,畫布是drop組件。
對于左側需要拖拽的組件,我們使用react-dnd提供的useDrag鉤子來使其可拖拽,代碼如下:
// 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>
);
}
對于畫布,我們使用useDrop鉤子,并用getClientOffset函數(shù)拿到拖拽的位置,計算出新增組件的left和top值,然后使用setData設置編輯器數(shù)據即可,代碼如下:
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`,
},
]);
},
}));
// ... 其他邏輯
}
六、與后臺交互
當我們實現(xiàn)了編輯器的邏輯后,與后臺交互的邏輯就相當簡單了,向后臺發(fā)起請求,存/取這個編輯器 json 數(shù)據就可以了,這里簡單實現(xiàn)了向后臺存數(shù)據的邏輯,代碼如下:
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);
});
}}
>
保存到后臺
</button>
</div>
);
}
后臺接收到數(shù)據后,就可以將其存儲到數(shù)據庫中了。
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);
// ...儲存到數(shù)據庫
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}`);
});
七、可以深入挖掘的點
通過上面這些步驟,我們就能實現(xiàn)一個最簡單的低代碼編輯器了,但是可以深入挖掘的技術點還有很多,下面我會列出來其中的一些點以及解決的思路。
7-1、組件嵌套如何實現(xiàn)?
對于組件嵌套,我們需要修改對于編輯器數(shù)據(就是json格式數(shù)據)的操作邏輯,從原先的「數(shù)組插入」,改變?yōu)獒槍δ硞€層級的操作,同時,組件遍歷的邏輯也要有所更改。
7-2、能否抽象出一個更高層級的屬性編輯組件?
大部分工業(yè)界的編輯器實際上都做了這樣一層抽象,對于不同屬性編輯組件,會使用schema來描述這個編輯組件可編輯的項,以及這個編輯項所對應的可以更改的數(shù)據。而屬性編輯組件實際上就是來消費這個schema進行渲染的。
7-3、像視頻這種體積比較大的文件,如何上傳到服務器?
這個涉及到文件分片上傳,后端組合存儲,可以看我之前實現(xiàn)的這個項目:https://github.com/shadowings-zy/easy-file-uploader
7-4、更好的全局數(shù)據管理方案?
本文因為實現(xiàn)的比較簡單,把所有數(shù)據都放到了根組件下,然后作為props傳參,但實際上,一個復雜的低代碼編輯器組件層級很深,使用props不太現(xiàn)實,這種情況下使用redux等全局數(shù)據管理的庫會比較方便。