【纯干货】从零到一开发可视化低代码大屏编辑器(一)保姆级教程,真正的从零到一开始,开发功能齐全的低代码可视化大屏编辑平台
【纯干货】从零到一开发可视化低代码大屏编辑器(一)
在线体验地址:easy-charts-three.vercel.app/
Github项目地址:github.com/windlil/eas… (如果感觉这个项目或文章对你有帮助的话,star🌟支持一下吧)
一、本系列文章介绍
本篇文章将围绕我近期开发的EasyCharts项目来进行核心功能的开发介绍,带大家从零到一开发一款功能齐全的可视化大屏编辑器,这会是一个系列专栏,我会从从最基础的地方讲起,保证通俗易懂,努力让大家都能搞懂每个知识点。
项目实现的功能:
- 物料组件创建
- 拖拽功能实现(从物料区拖入,画布内拖动组件)
- 画布渲染、画布拖动、画布放大缩小
- 属性修改
- 网络请求
- 辅助线,磁吸功能
- 标尺功能实现
- 撤销恢复功能
- 组件属性修改
- ...
跟着本系列的文章一步步来进行开发,最后也能实现上面的功能,可以先点进在线项目里看看我们最终能实现的大致效果。
主要使用到的框架:
- React18
- Vite
- Typescript
- Tailwindcss
- Zustand
- ant design charts
- ant design
这些都是我个人平常开发的时候用的最多的技术栈,也是目前比较流行的React技术栈,如果对其中一些不熟悉的话也没关系,我会在项目里用到的地方把它们的使用方法说清楚。
跟着本篇来进行开发,最后就可以实现下面的效果(物料创建、物料区渲染、画布渲染、物料拖拽):
二、初始化项目
2.1 安装 Vite
我们将采用Vite来作为项目的构建框架,首先使用下面的命令来进行初始化:
pnpm create vite@latest
按照提示一步步来进行选择,我们需要选择 React 和 Typescript,如果还不熟练Typescript的话也可以选择使用JS来进行开发,这一步看大家个人习惯就好。
2.2 安装配置 Tailwindcss
一开始大家不熟悉 tailwindcss 的话,用起来可能会比较费劲,需要一边用一边搜文档,但当我们用习惯之后就不想去使用常规的CSS样式定义了。
但如果不想在这个项目里使用它的话,也没有关系,这只是用来进行样式定义的库,你也可以选择Less或Scss...
安装 tailwindcss:
pnpm install -D tailwindcss postcss autoprefixer
// 安装完后运行命令,进行初始化
npx tailwindcss init -p
初始化后,我们项目的根目录会有一个 tailwind.config.js 文件:
/** @type {import('tailwindcss').Config} */
export default {
// 添加下面内容
content: [
"./src/**/*.{js,ts,jsx,tsx}",
],
}
在 src 目录创建一个全局样式文件,并在 main.ts 中引入
@tailwind base;
@tailwind components;
@tailwind utilities;
这时候我们已经可以使用 tailwindcss 样式。
2.3 路由定义
首先我们需要先安装 react-router
pnpm i react-router-dom
创建路由
// 实现路由懒加载的方法
function createLazyElement(load: () => Promise<{ default: React.ComponentType<any> }>) {
return (
<Suspense fallback=''>
{createElement(lazy(load))}
</Suspense>
)
}
const router = createBrowserRouter([
{
path: '/editor',
element: createLazyElement(() => import('@/pages/editor/index'))
},
])
export default router
editor 页面和路由就是我们编辑器开发的主页面。
三、编辑器布局开发
大屏编辑器本质上也是低代码平台,所以它的布局也是常见的左侧物料区(组件区)、中间画布区、右边属性区。
const Editor = () => {
return (
<div className="w-full h-full flex text-[#ffffff]">
<div className="w-64 border-r border-neutral-600 bg-[#222325]">
left
</div>
<div className="flex-1 bg-[#272727]">
canvas
</div>
<div className="w-64 border-l border-neutral-600 bg-[#222325]">
right
</div>
</div>
)
}
export default Editor
通过上面的代码,我们打开网页后可以看到下面的布局:
我们后续整体的架构也会按照这个布局从左到右来慢慢实现。
四、schema定义
物料的schema如何定义是后续如何来创建渲染器并根据物料属性进行渲染的核心关键
4.1 是什么
schema实际上就是一个数据结构,可以用它来定义我们物料的一些属性。
因为我们采用的是图表组件库来进行图表的创建生成,我们并不需要自己去定义很多属性,因此我们将采用下面这个结构来定义我们的物料组件:
export type ComponentItem = {
// 组件唯一标识
id: string
// 组件名称
name: string
// 组件配置
config: any
}
这是一个非常简单的 schema 结构,得益于图表库对于数据的预定义。
但我们并不能直接拿图表库的组件来进行渲染,还需要对其进行封装后才能符合我们的要求。
五、创建物料组件
我们需要对每一个需要使用到的图表组件进行二次封装。
我们将采用@ant-design/charts作为图表库,首先我们需要先安装:
pnpm add @ant-design/charts
我们首先尝试来封装一个基础面积图,封装的方式也很简单,只需要让组件能接收一个 config 参数即可:
import { Area } from '@ant-design/charts'
import { FC } from 'react'
const BaseArea: FC<{
config: any
}> = ({ config }) => {
return <Area {...config}/>
}
export default BaseArea
六、实现渲染
在有了物料组件后,我们就可以着手来进行渲染器的开发了,这部分还需要结合我们前面定义的schema来进行。
创建一个渲染函数,其核心就是使用react的 createElement 方法,让我们可以实现动态组件创建。
6.1 创建一份schema数据
我们先模拟一份schema数据:
const data = [
{ 'name': 'area1', 'value': 80.24 },
{ 'name': 'area2', 'value': 30.35 },
{ 'name': 'area3', 'value': 50.84 },
{ 'name': 'area4', 'value': 49.92 },
{ 'name': 'area5', 'value': 70.8 },
]
// 通用配置
export const CommonConfig = {
x: 0,
y: 0,
z: 0,
width: 300,
height: 300,
animate: false,
theme: 'classicDark'
}
// 模拟出来的待渲染数据
const componentList: ComponentItem[] = [
{
name: 'BaseArea',
id: '1',
// 这里的config就是我们需要通过prop来传递给组件的属性,也是图表能够展现的关键属性值
config: {
name: '基础面积图',
componentType: 'chart',
data,
xField: 'name',
yField: 'value',
...CommonConfig,
}
}
]
有了组件对应的Schema数据后,我们还需要让其能够和我们的实际创建的组件来实现互相映射:
import BaseArea from '@/components/BaseArea/index.tsx'
const componentsMap = {
'BaseArea': BaseArea
}
export default componentsMap
6.2 渲染函数实现
通过上面的定义我们已经可以实现schema和组件的映射,后续通过获取到schema.name就可以去创建组件了。
接下来,我们就可以来着手实现我们的渲染器,这是组件如何被创建到画布的关键步骤:
const h = (componentList: ComponentItem[]) => {
return componentList.map(component => {
const { id, name, config } = component
return createElement(componentsMap[name], { config, key: `${nanoid()}` })
})
}
这只是前期的简略实现,后面我们会慢慢丰富组件创建的步骤。
有了渲染器后,我们借助我们在上面创建的schema数据就可以在画布组件中去调用来生成一个基础面积图组件。
const Editor = () => {
return (
<div className="w-full h-full flex text-[#ffffff]">
<div className="w-64 border-r border-neutral-600 bg-[#222325]">
left
</div>
<div className="flex-1 bg-[#272727]">
{h(componentList)}
</div>
<div className="w-64 border-l border-neutral-600 bg-[#222325]">
right
</div>
</div>
)
}
export default Editor
可以看到我们前面定义的基础面积图组件已经被渲染到画布当中了:
七、状态管理
在进一步的改善功能之前,我们应该要有一个全局状态管理器来对我们的组件数据来进行统一管理,这里将采用 zustand 来作为全局状态管理器。
pnpm i zustand immer
然后我们创建一个store来进行存储:
interface Store {
componentList: ComponentItem[]
addComponent: (component: ComponentItem) => void
}
const useComponentStore = create<Store>()(immer((set) => ({
componentList: [],
addComponent(component) {
set(state => {
state.componentList.push(component)
})
},
})))
八、拖拽放置实现
8.1 物料渲染
在进行拖拽功能开发前,我们还需要对物料区的内容进行渲染,毕竟如果物料区没有内容的话,我们也无法实现拖拽。
首先来创建一个公共组件:
import { FC } from "react"
const Material:FC<{
name: string
img: string
id: string
}> = ({ name, img, id }) => {
return (
<div className='flex flex-col justify-center items-center w-full h-[120px] bg-[#1b1b1b] mb-4 text-xs'>
<span className='w-full p-1 text-gray-400'>
{name}
</span>
<div className='h-[95px] w-[90%]'>
<img draggable={false} src={img} className='w-full h-full' />
</div>
</div>
)
}
export default Material
要先搞清楚的是这个组件并不是画布渲染组件,他只是作为渲染组件在物料区的展示,因此需要接收一个名字和对应的图片。
const materialList = [
{
name: '基础面积图',
img: '/components/BaseArea.png',
}
]
const Editor = () => {
return (
<div className="w-full h-full flex text-[#ffffff]">
<div className="w-64 border-r border-neutral-600 bg-[#222325] p-4">
// 进行物料区渲染
{materialList.map(comp => <Material key={comp.name} title={comp.title} name={comp.name} img={comp.img} />)}
</div>
<div className="flex-1 bg-[#272727]">
{/* {h(componentList)} */}
</div>
<div className="w-64 border-l border-neutral-600 bg-[#222325]">
right
</div>
</div>
)
}
export default Editor
此时,我们就可以在物料区看到我们的展示组件:
8.2 拖拽放置
在最左侧的物料区已经出现了基础面积图的物料组件,我们都知道低代码平台的一大核心功能就是能够通过拖拽操作将物料区的组件拖入放置到画布区,接下来我们就可以着手来实现这一核心功能。
要实现该功能,我们需要安装react-dnd拖拽库和对应的依赖:
pnpm add react-dnd react-dnd-html5-backend
提供上下文对象:
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
const Editor = () => {
return (
<DndProvider backend={HTML5Backend}>
...
</DndProvider>
)
}
export default Editor
创建一个config映射的对象 configMap,用于映射组件和定义的config的关系:
export const BaseAreaDefaultConfig = {
name: '基础面积图',
componentType: 'chart',
data,
xField: 'name',
yField: 'value',
...CommonConfig,
}
const configMap = {
BaseArea: BaseAreaDefaultConfig
}
使物料组件变得可拖动:
import { FC } from "react"
import { useDrag } from "react-dnd"
const Material:FC<{
name: string
img: string
title: string
}> = ({ name, img, title }) => {
const [, drag] = useDrag(() => ({
type: 'component',
// 放置的时候可以接收到该组件名称
item: {
name,
},
}))
return (
<div ref={drag} className='flex flex-col justify-center items-center w-full h-[120px] bg-[#1b1b1b] mb-4 text-xs'>
<span className='w-full p-1 text-gray-400'>
{title}
</span>
<div className='h-[95px] w-[90%]'>
<img draggable={false} src={img} className='w-full h-full' />
</div>
</div>
)
}
export default Material
从上面的效果已经可以发现我们已经能够实现拖拽放置的功能,但是我们还没有去处理拖拽放置的位置,只是将组件放入到 componentList 数组中进行依次渲染,接下来,我们就要处理它在画布当中的位置了。
对画布组件进行改造,使它可以获取我们的放置位置:
import configMap from "@/core/configMap"
import h from "@/core/h"
import useComponentStore from "@/stores/useComponents"
import { nanoid } from "nanoid"
import { useRef } from "react"
import { useDrop } from "react-dnd"
const positionToFixed = (position: number) => {
return Number(position.toFixed(0))
}
const Canvas = () => {
const componentList = useComponentStore(state => state.componentList)
const addComponent = useComponentStore(state => state.addComponent)
const canvasRef = useRef<HTMLDivElement>(null)
const [,drop] = useDrop(() => ({
accept: 'component',
drop(item: any, monitor: any) {
const { top, left } = canvasRef.current!.getBoundingClientRect()
const offsetX = monitor.getClientOffset()!.x - left
const offsetY = monitor.getClientOffset()!.y - top
const component: {
id: string
name: string
config: any
} = {
id: `${nanoid()}`,
name: item.name,
config: {
...configMap[item.name],
x: positionToFixed(offsetX),
y: positionToFixed(offsetY)
}
}
addComponent(component)
}
}))
return (
<div ref={drop}>
<div ref={canvasRef} className="w-full h-full">
{h(componentList)}
</div>
</div>
)
}
export default Canvas
上面的x和y就是我们的真实的落点位置,有了这个位置后,然后要把这个位置信息传递给我们的渲染组件,此时就要对我们前面写的渲染方法进行改造了。
import { createElement, CSSProperties, FC, useMemo } from "react"
import { ComponentItem } from "@/types/component"
import componentsMap from "./componentsMap"
import { nanoid } from "nanoid"
const Component:FC<{
comp: ComponentItem
}> = ({comp}) => {
const { name, config } = comp
const styles: CSSProperties = useMemo(() => {
return {
transform: `translate(${config.x}px,${config.y}px)`,
}
}, [config])
return (
<div style={styles}>
{createElement((componentsMap as any)[name], { config, key: `${nanoid()}` })}
</div>
)
}
const h = (componentList: ComponentItem[]) => {
return componentList.map(component => {
return <Component comp={component} />
})
}
export default h
通过 h 函数,我们并没有使用 createElement 来直接进行组件创建,而是使用一个通用组件来进行封装,这个 Component 组件,我们在以后还会不断进行完善,使其能够支持画布内拖动放大,可点击等功能。
此时,我们已经可以将组件放置到我们鼠标的位置了:
九、总结
通过上面的步骤,我们已经能够实现将物料区的组件拖入到画布区的指定位置,但是在画布区还不能对组件进行选中拖拽,这些内容的讲解将在后面的文章中进行讲解。
转载自:https://juejin.cn/post/7419688489409150986