1. Escreva na frente
Como um software PaaS que pode melhorar muito a eficiência do desenvolvimento, os editores de código baixo têm sido procurados por grandes empresas e investidores nos últimos anos. Para nossos desenvolvedores de front-end, o editor também é um dos poucos cenários de desenvolvimento com grande profundidade de tecnologia de front-end.
Por meio deste artigo, você pode aprender como criar o editor de código baixo mais simples com base na pilha de tecnologia React e como implementar algumas funções importantes.
O código de exemplo deste artigo foi aberto no GitHub, e amigos que precisam podem obtê-lo por conta própria: https://github.com/shadowings-zy/mini-editor
Basta visualizar a demonstração deste editor:
2. Diretório
- Divisão da função do editor
- Definição do formato de dados do editor
- estrutura do código do projeto
- Implementação da lógica principal (renderização da tela, vinculação de atributos, arrastar componentes)
- Interaja com o plano de fundo
- Pontos que também podem ser otimizados
3. Divisão da função do editor
Vamos começar com um diagrama de protótipo:
Para a maioria dos editores de código baixo, eles são compostos de três partes: "área de componentes", "área de tela" e "área de edição de propriedades".
- A área de componentes é responsável por exibir os componentes arrastáveis e o relacionamento hierárquico entre os componentes.
- A área da tela é responsável por renderizar os componentes arrastados e exibi-los visualmente.
- A área de edição de atributos é responsável por editar os atributos do componente selecionado.
Com base nas responsabilidades dessas três áreas, podemos facilmente projetar as funções que essas três áreas precisam realizar:
- Para a área do componente, precisamos garantir que os componentes sejam arrastáveis e que possam interagir com a área da tela
- Para a área da tela, precisamos primeiro abstrair um formato de dados para exibir "quais componentes estão na área da tela" e, em seguida, a área da tela pode renderizar os componentes correspondentes de acordo com esse formato de dados. Em segundo lugar, também precisamos realizar a interação entre o componente arrastado e a tela, e a interação com a área de edição de atributos após a seleção do componente.
- Para a área de edição de atributos, precisamos lidar com a lógica de ligação com o componente correspondente após a alteração do atributo.
4. Definição do formato de dados do editor
O formato dos dados na parte inferior do editor é a coisa mais importante para desenvolver um editor de baixo código. A área da tela renderizará a tela de acordo com esses dados, e arrastar e soltar os componentes e a configuração das propriedades dos componentes são, na verdade, alterações a estes dados.
E voltando ao nosso editor propriamente dito, podemos usar dados no formato json para abstrair o conteúdo da tela do editor, assim:
{
"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" // 容器上边距
}
]
}
Depois de definir a estrutura de dados, "edição de propriedade do componente" e "arrastar e soltar para adicionar componentes" são, na verdade, adicionando, excluindo e modificando o campo de dados nos dados json, e a área de tela também usará esse campo para renderizar os componentes em a tela.
5. Estrutura do código do projeto
A estrutura geral do código é a seguinte:
.
├── 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
6. Realização da Lógica Chave
Antes de classificar a lógica principal, temos que classificar quais dados nossos componentes do editor precisam manter.
- O primeiro são os dados do editor. A tela precisa renderizar o conteúdo de acordo com os dados do editor, e adicionar componentes e modificar propriedades são essencialmente alterações nesses dados.
- O segundo é o tipo do painel direito.A edição de diferentes componentes requer diferentes tipos de itens de edição.
- Além disso, existe o ID do componente selecionado no momento e as alterações no painel de propriedades à direita terão efeito no ID do componente atual.
Então mantemos esses dados sob o componente raiz e passamos para outros subcomponentes com props, o código é o seguinte:
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 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>
);
}
Depois de definir esses dados, vamos explicar a implementação da lógica da chave.
6-1. Renderização da tela
Primeiro, vamos dar uma olhada na implementação da lógica de renderização da tela:
Aqui, precisamos ajustar o layout da área da tela para position: relative
, e então definir o layout de cada componente para , para que possamos localizar a posição do componente na tela position: absolute
de acordo com left
as propriedades e .top
Em seguida, ele percorre os dados do editor e renderiza os componentes correspondentes na tela.
O código específico é o seguinte:
// 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
className="draw-panel"
ref={
drop}
style={
{
position: "relative",
}}
>
{
generateContent()}
</div>
);
}
6-2. Ligação de atributos
Em seguida, para realizar a vinculação de atributos, precisamos implementar o seguinte:
1. Adicione um evento de clique ao componente na tela para que ele possa definir o conteúdo do painel de edição de propriedade à direita quando for clicado.
2. Ao editar as propriedades do componente no painel de edição de propriedades à direita, é necessário modificar os dados correspondentes ao componente de destino nos dados do editor e, em seguida, a área da tela é renderizada de acordo com os novos dados do editor.
Para atingir o primeiro ponto, precisamos adicionar um evento click a cada componente renderizado no componente canvas, e usar setRightPanelType
e setRightPanelElementId
para definir o elemento selecionado correspondente, o código é o seguinte:
// 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
Para perceber que o painel direito pode editar dados em tempo real, primeiro precisamos percorrer os setDrawPanelData
dados de acordo com a entrada , obter o item a ser modificado e, em seguida, obter o valor de alteração do atributo correspondente e, finalmente, usá-lo para modificá-lo . O código específico é o seguinte:
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>;
}
6-3. Arraste e solte os componentes
Por fim, chega-se ao mais importante, como implementar arrastar e soltar, que é usado aqui react-dnd
, react
a biblioteca oficial de arrastar e soltar, a referência do documento aqui: https://react-dnd.github.io/react-dnd/ sobre
react-dnd
Em , drag
e drop
dois tipos de componentes são definidos, obviamente, os componentes que precisam ser arrastados no painel esquerdo são drag
componentes e a tela é drop
o componente.
Para os componentes que precisam ser arrastados à esquerda, usamos react-dnd
os useDrag
ganchos fornecidos para torná-los arrastáveis, o código é o seguinte:
// 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>
);
}
Para a tela, usamos useDrop
ganchos e getClientOffset
funções para obter a posição arrastada, calcular left
os top
valores e dos novos componentes e, em seguida, usar setData
os dados do editor de configurações, o código é o seguinte:
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`,
},
]);
},
}));
// ... 其他逻辑
}
Sete, interaja com o fundo
Depois de implementada a lógica do editor, a lógica de interação com o background é bem simples. Basta iniciar uma requisição ao background e salvar/recuperar os dados json do editor. Aqui, a lógica de salvar os dados para o background é simplesmente implementado. O código é o seguinte:
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>
);
}
Depois que o plano de fundo recebe os dados, eles podem ser armazenados no banco de dados.
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}`);
});
8. Pontos que podem ser explorados em profundidade
Através dos passos acima podemos implementar o editor low-code mais simples, mas ainda existem muitos pontos técnicos que podem ser explorados a fundo, vou listar alguns deles e as soluções abaixo.
8-1 Como implementar o aninhamento de componentes?
Para o aninhamento de componentes, precisamos modificar a lógica de operação dos dados do editor (ou seja, dados json
de formato ), da "inserção de array" original para uma operação para um determinado nível. Ao mesmo tempo, a lógica da travessia do componente também deve ser mudado.
8-2 Um componente de edição de atributo de nível superior pode ser abstraído?
A maioria dos editores na indústria realmente faz essa camada de abstração. Para diferentes componentes de edição de atributo, eles serão usados para schema
descrever os itens editáveis desse componente de edição e os dados que podem ser alterados correspondentes a esse item de edição. O componente de edição de atributo é realmente usado para consumir isso schema
para renderização.
8-3 Como enviar um arquivo relativamente grande como um vídeo para o servidor?
Isso envolve o upload de arquivos em partes e armazenamento combinado de back-end. Você pode ver este projeto que implementei antes: https://github.com/shadowings-zy/easy-file-uploader
8-4 Uma solução de gerenciamento de dados global melhor?
Como a implementação é relativamente simples, este artigo coloca todos os dados no componente raiz e os passa como props
parâmetros , mas, na verdade, um componente editor de código baixo complexo tem uma hierarquia profunda e props
não é realista usá-lo. Nesse caso, usar globais redux
como Uma biblioteca para gerenciamento de dados seria mais conveniente.