1. Escribe al frente
Como un software PaaS que puede mejorar en gran medida la eficiencia del desarrollo, las principales empresas e inversores han buscado editores de código bajo en los últimos años. Para nuestros desarrolladores front-end, el editor también es uno de los pocos escenarios de desarrollo con una gran profundidad en la tecnología front-end.
A través de este artículo, puede aprender cómo crear el editor de código bajo más simple basado en la pila de tecnología React y cómo implementar algunas funciones clave.
El código de muestra de este artículo se ha abierto en GitHub, y los amigos que lo necesiten pueden obtenerlo por sí mismos: https://github.com/shadowings-zy/mini-editor
Simplemente obtenga una vista previa de la demostración de este editor:
2. Directorio
- División de la función del editor
- Definición del formato de datos del editor
- estructura del código del proyecto
- Implementación de lógica clave (representación de lienzo, vinculación de atributos, arrastre de componentes)
- Interactuar con el fondo
- Puntos que también se pueden optimizar
3. División de funciones del editor
Comencemos con un diagrama prototipo:
para la mayoría de los editores de código bajo, se componen de tres partes: "área de componentes", "área de lienzo" y "área de edición de propiedades".
- El área de componentes es responsable de mostrar los componentes que se pueden arrastrar y la relación jerárquica entre los componentes.
- El área del lienzo es responsable de representar los componentes arrastrados y mostrarlos visualmente.
- El área de edición de atributos es responsable de editar los atributos del componente seleccionado.
Con base en las responsabilidades de estas tres áreas, podemos diseñar fácilmente las funciones que estas tres áreas deben cumplir:
- Para el área de componentes, debemos asegurarnos de que los componentes se puedan arrastrar y los componentes puedan interactuar con el área del lienzo.
- Para el área del lienzo, primero debemos abstraer un formato de datos para mostrar "qué componentes hay en el área del lienzo", y luego el área del lienzo puede representar los componentes correspondientes de acuerdo con este formato de datos. En segundo lugar, también debemos darnos cuenta de la interacción entre el componente arrastrado y el lienzo, y la interacción con el área de edición de atributos después de seleccionar el componente.
- Para el área de edición de atributos, necesitamos manejar la lógica de vinculación con el componente correspondiente después de cambiar el atributo.
4. Definición del formato de datos del editor
El formato de datos en la parte inferior del editor es lo más importante para desarrollar un editor de código bajo. El área del lienzo representará el lienzo de acuerdo con estos datos, y el arrastrar y soltar componentes y la configuración de las propiedades de los componentes son en realidad cambios. a estos datos.
Y volviendo a nuestro propio editor, podemos usar datos en formato json para abstraer el contenido del lienzo del editor, así:
{
"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" // 容器上边距
}
]
}
Después de definir la estructura de datos, "edición de propiedades del componente" y "arrastrar y soltar para agregar componentes" en realidad agregan, eliminan y modifican el campo de datos en los datos json, y el área del lienzo también usará este campo para representar los componentes en el lienzo.
5. Estructura del código del proyecto
La estructura general del código es la siguiente:
.
├── 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. Realización de Key Logic
Antes de ordenar la lógica clave, debemos determinar qué datos deben mantener nuestros componentes de editor.
- El primero son los datos del editor. El lienzo debe representar el contenido de acuerdo con los datos del editor, y agregar componentes y modificar propiedades son esencialmente cambios en estos datos.
- El segundo es el tipo del panel derecho.Editar diferentes componentes requiere diferentes tipos de elementos de edición.
- Además, está la identificación del componente seleccionado actualmente, y los cambios en el panel de propiedades de la derecha tendrán efecto en la identificación del componente actual.
Entonces mantenemos estos datos bajo el componente raíz y los pasamos a otros subcomponentes con props, el código es el siguiente:
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>
);
}
Después de definir estos datos, expliquemos la implementación de la lógica clave.
6-1 Representación del lienzo
Primero, echemos un vistazo a la implementación de la lógica de representación del lienzo:
Aquí tenemos que ajustar el diseño del área del lienzo position: relative
y luego establecer el diseño de cada componente en , para que podamos ubicar la posición del componente en el lienzo position: absolute
de acuerdo con left
las propiedades y .top
Luego, atraviesa los datos del editor y representa los componentes correspondientes en el lienzo.
El código específico es el siguiente:
// 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 Vinculación de atributos
A continuación, para realizar la vinculación de atributos, debemos implementar lo siguiente:
1. Agregue un evento de clic al componente en el lienzo para que pueda establecer el contenido del panel de edición de propiedades a la derecha cuando se hace clic en él.
2. Al editar las propiedades del componente en el panel de edición de propiedades de la derecha, es necesario poder modificar los datos correspondientes al componente de destino en los datos del editor, y luego el área del lienzo se representa de acuerdo con los nuevos datos del editor.
Para lograr el primer punto, debemos agregar un evento de clic a cada componente renderizado en el componente de lienzo, y usar setRightPanelType
y setRightPanelElementId
para establecer el elemento seleccionado correspondiente, el código es el siguiente:
// 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 darnos cuenta de que el panel derecho puede editar datos en tiempo real, primero debemos recorrer los setDrawPanelData
datos de acuerdo con la entrada , hacer que se modifique el elemento y luego obtener el valor de cambio de atributo correspondiente, y finalmente usar para modificarlo . El código específico es el siguiente:
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 Componentes de arrastrar y soltar
Finalmente, se trata de lo más importante, cómo implementar arrastrar y soltar, que se usa aquí react-dnd
, react
la biblioteca oficial de arrastrar y soltar, la referencia del documento aquí: https://react-dnd.github.io/react-dnd/ acerca de
react-dnd
En , drag
y drop
se definen dos tipos de componentes, obviamente, los componentes que deben arrastrarse en el panel izquierdo son drag
componentes, y el lienzo es drop
el componente.
Para los componentes que deben arrastrarse a la izquierda, usamos react-dnd
los ganchos provistos useDrag
para hacerlos arrastrables, el código es el siguiente:
// 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 el lienzo, usamos useDrop
ganchos y getClientOffset
funciones para obtener la posición arrastrada, calcular left
los top
valores de los nuevos componentes y luego usar setData
los datos del editor de configuración, el código es el siguiente:
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`,
},
]);
},
}));
// ... 其他逻辑
}
Siete, interactúa con el fondo.
Una vez que hemos implementado la lógica del editor, la lógica de interactuar con el fondo es bastante simple. Basta con iniciar una solicitud al fondo y guardar/recuperar los datos json del editor. Aquí, la lógica de guardar datos en el fondo se implementa simplemente.El código es el siguiente:
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>
);
}
Una vez que el fondo recibe los datos, se pueden almacenar en la base de datos.
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. Puntos que se pueden explorar en profundidad
A través de los pasos anteriores, podemos implementar el editor de código bajo más simple, pero todavía hay muchos puntos técnicos que se pueden explorar en profundidad, enumeraré algunos de ellos y las soluciones a continuación.
8-1 ¿Cómo implementar el anidamiento de componentes?
Para el anidamiento de componentes, necesitamos modificar la lógica de operación para los datos del editor (es decir, datos json
de formato ), desde la "inserción de matriz" original a una operación para un cierto nivel. Al mismo tiempo, la lógica de recorrido de componentes también debe ser cambió.
8-2 ¿Se puede abstraer un componente de edición de atributos de nivel superior?
La mayoría de los editores de la industria en realidad crean una capa de abstracción de este tipo.Para diferentes componentes de edición de atributos, se utilizarán para schema
describir los elementos editables de este componente de edición y los datos que se pueden cambiar correspondientes a este elemento de edición. El componente de edición de atributos en realidad se usa para consumir esto schema
para la representación.
8-3 ¿Cómo subir un archivo relativamente grande como un video al servidor?
Esto implica cargar archivos en partes y almacenamiento combinado de back-end. Puede ver este proyecto que implementé antes: https://github.com/shadowings-zy/easy-file-uploader
8-4 ¿Una mejor solución de gestión de datos globales?
Debido a que la implementación es relativamente simple, este artículo coloca todos los datos bajo el componente raíz y luego los pasa como props
parámetros , pero de hecho, un componente de editor de código bajo complejo tiene una jerarquía profunda y props
no es realista usarlo. En este caso, sería más conveniente usar globales redux
como una biblioteca para la gestión de datos.