本文将接着上回继续编写低代码编辑器框架(代码含详细注释)
前言
接下来主要实现物料区,画布区和属性区的逻辑,头部组件后续再接着完善,其中最核心的逻辑包括:
- 实现从物料区将组件拖拽到画布区并展示的效果,实质上就是维护一个json对象,每次拖拽添加组件,就是将其添加至json对象的某一层,再进行渲染
- 右侧的属性区,实质上就是添加,删除,修改,组件对象中的属性值和样式值
- 最后将json展示成树状图
进行components.tsx文件的编写
在编写前,先将之前写的mode状态变量和setMode方法先删除,后续会进行补充,现在开始先在仓库内创建json对象,以及在json对象中实现添加组件对象,删除组件对象,更新组件对象属性的方法
1. 引入并使用create方法创建useComponentsStore仓库
js
import { create } from 'zustand'
export const useComponentsStore = create<State & Action>((set, get) => ({}))
//State表示create函数内状态变量的类型,Action表示方法的类型
2. 添加json对象数组components[]
- 先限定create函数状态变量的类型为State(自己打造的类型)
- Component为components数组内对象的类型
- Component[]则为components数组的类型
js
export interface Component {
id: number,//组件id
name: string,//组件名字
props: any,//组件属性
desc: string,
children?: Component[],//组件的子组件(可有可无)
parentId?: number,//组件父容器的id(可有可无)
}
export interface State {
components: Component[]
}
- 设置固定的json对象中的数据(也就是最外层的json对象),后续添加都是用户手动拖拽组件进行添加
js
export const useComponentsStore = create<State & Action>((set, get) => ({
components: [//整个项目的json数据
{
id: 1,
name: 'Page',
props: {},
desc: '页面',
}
],
}))
3. 创建添加组件对象,删除组件对象,更新组件对象属性的方法
- 先规限定定create函数中方法的类型为Action(自己打造的类型)
js
export interface Action {
addComponent: (component: any, parentId: number) => void
//component表示要添加的组件,parentId表示父容器的id,知道子组件加到哪个父容器内,函数为void类型
deleteComponent: (componentId: number) => void
//componentId表示要删除的组件id,函数为void类型
updateComponentProps: (componentId: number, props: any) => void
//更新组件属性,props表示要更新的属性,函数为void类型
}
- 先打造一个getComponentById函数,用于通过组件id来获取对应组件对象(可以用来找父容器或己组件)
大体思路为,遍历components数组,在最外层结构中找不到所要的组件对象,则使用递归向里层继续找
js
export function getComponentById(id: number | null, components: Component[]): Component | null {//输入自己或父组件的id,找到自己或父组件
//如果id无值,则直接返回null
if (!id) return null
for (const component of components) {//遍历components数组
if (component.id === id) {//如果最外层可以找到所要的组件,则直接返回该组件
return component
}
//如果最外层找不到,则一步一步向内递归,从子组件中找
if (component.children && component.children.length > 0) {
//将最终找到的组件对象赋值给result
const result = getComponentById(id, component.children)
//如果result存在,则返回该组件对象
if (result) {
return result
}
}
}
return null//如果id在json对象中不存在,则直接返回null
}
- 打造addComponent方法,用于添加组件对象至json对象中
大体思路:第一步考虑的是有父容器的情况,先找到父容器,在将组件对象添加至父容器的children属性中,第二就是没有父容器的情况,则直接添加在component数组的最外层结构
- 打造deleteComponent方法,用于将json对象中对应的组件对象删除
大体思路:获取要删除的组件对象的父容器,然后使用filter方法将要删除的子组件过滤掉就行
- 打造updateComponentProps方法,用于更新组件对象中的props属性
大体思路:拿到更新属性的组件,将传入的属性和已有属性进行合并就行
至此components.tsx仓库内基础的数据和方法创建完毕
js
//stores/components.tsx完整代码
import { create } from 'zustand'
export interface Component {
id: number,//组件id
name: string,//组件名字
props: any,//组件属性
desc: string,
children?: Component[],//组件的子组件(可有可无)
parentId?: number,//组件父容器的id(可有可无)
}
export interface State {
components: Component[]
}
export interface Action {
addComponent: (component: Components, parentId: number) => void
//component表示要添加的组件,parentId表示父容器的id,知道子组件加到哪个父容器内,函数为void类型
deleteComponent: (componentId: number) => void
//componentId表示要删除的组件id,函数为void类型
updateComponentProps: (componentId: number, props: any) => void
//更新组件属性,props表示要更新的属性,函数为void类型
}
export const useComponentsStore = create<State & Action>((set, get) => ({//set:触发视图更新 get:获取当前状态(不会触发组件渲染)
components: [//整个项目的json数据
{
id: 1,
name: 'Page',
props: {},
desc: '页面',
}
],
//方法
addComponent: (component, parentId) => {//本质上就是将一个对象添加到另一个对象中(json对象)
set((state) => {
if (parentId) {//如果父容器id存在
//获取父级对象
//引用getComponentById函数,将父组件id和state.components(实际上就是刚刚定义的components[]数组)传入函数中,获取到父组件对象parentComponent
const parentComponent = getComponentById(parentId, state.components)
//如果父组件对象存在,执行以下三元运算符
if (parentComponent) {
parentComponent.children ? parentComponent.children.push(component) : parentComponent.children = [component]//(因为children属性的值为数组)
//如果父组件有children属性,直接push,没有则创建,并将component放到数组内后赋值
}
component.parentId = parentId//设置添加的组件对象的父组件id
return {
components: [...state.components]
}
}
return {
components: [...state.components, component]
//如果没有父级,则直接添加到仓库最外层:
// 1. 使用展开运算符将原state.components数组展开
// 2. 将新组件component追加到末尾
// 3. 组合成一个全新的数组
// 4. 赋值给components属性
}
})
},
deleteComponent: (componentId) => {//在整个json对象中找到某个子对象的id,然后删除
if (!componentId) return
//拿到要删除的子组件
//调用getComponentById方法就能拿到对应的组件对象(其实get().components就是state.components,目的都是拿到仓库中的components[])
const component = getComponentById(componentId, get().components)
//如果子组件有父组件的id
if (component?.parentId) {
//拿到要删除的子组件的父组件
const parentComponent = getComponentById(component.parentId, get().components)
if (parentComponent) {
//使用filter方法对父组件的children数组进行过滤,将id不等于要删除的子组件id的子组件对象组成一个新数组返回
parentComponent.children = parentComponent.children?.filter((item) => item.id !== componentId)
}
//引用set方法触发试图更新和重新渲染,将过滤之后的数组赋值给components[]
set({
components: [...get().components]
})
}
},
//更新组件属性
updateComponentProps: (componentId, props) => {//传入组件id和要添加的属性
set((state) => {
//获取要操作的组件
const component = getComponentById(componentId, state.components)
if (component) {//如果找到了要操作的组件对象
component.props = { ...component.props, ...props }//将旧属性和新属性合并
return {
//将更新后的conponents解构赋值为新的components,也就创建了一个新的引用,以达到视图更新的目的s
components: [...state.components]
}
}
//如果没有找到组件对象,则也创建了一个新的引用,将原来的components[]解构赋值给新的components,以达到视图更新的目的
return { components: [...state.components] }
})
},
}))
export function getComponentById(id: number | null, components: Component[]): Component | null {//输入自己或父组件的id,找到自己或父组件
//如果id无值,则直接返回null
if (!id) return null
for (const component of components) {//遍历components数组
if (component.id === id) {//如果最外层可以找到所要的组件,则直接返回该组件
return component
}
//如果最外层找不到,则一步一步向内递归,从子组件中找
if (component.children && component.children.length > 0) {
//将最终找到的组件对象赋值给result
const result = getComponentById(id, component.children)
//如果result存在,则返回该组件对象
if (result) {
return result
}
}
}
return null//如果id在json对象中不存在,则直接返回null
}
分别创建Page组件,Container组件和Button组件
先在editor文件夹下创建materials文件夹,用于盛放组件
在materials文件夹下分别创建Page、Container、Button文件夹,再分别在这三个文件夹下创建属于自己的index.tsx文件
1. 创建Page组件
js
import type { PropsWithChildren } from "react";
export default function Page({ children }:PropsWithChildren ) {
//{ children }参数使得该组件在使用时,内部也可以放子组件,并进行渲染(相当于一个容器组件)
return (
<div className=" p-[20px] h-[100%] box-border">
{children}
</div>
)
}
2. 创建Container组件
js
import type { PropsWithChildren } from "react";
//{ children }参数使得该组件在使用时,内部也可以放子组件,并进行渲染(相当于一个容器组件)
export default function Container({ children }:PropsWithChildren ) {
return (
<div className=" border-[1px] border-[#000] min-h-[100px] p-[20px]">
{children}
</div>
)
}
3. 创建Button组件
js
import { Button as AntdButton } from "antd"//引入antd库内的Button组件
import type {ButtonType} from 'antd/es/button'//引入antd自带的ButtonType类型
interface ButtonProps {//自定义类型,限制type,text属性的类型
type: ButtonType
text: string
}
export default function Button({type,text}:ButtonProps) {
//text和type父组件向子组件传的值,type表示按钮的类型(属于antd按钮的类型),text表示按钮的文本
return (
<AntdButton type={type}>{text}</AntdButton>
)
}
接下来需要创建一个仓库,使得可以通过组件名来拿到对应的组件(也叫组件的映射)
创建useComponentConfigStore仓库
1. 在stores文件夹下创建component-config.tsx文件
2. 引入刚刚创建的组件
js
import Container from "../materials/Container/index";
import Button from "../materials/Button/index";
import Page from "../materials/Page/index";
3. 引入并使用create方法创建useComponentConfigStore仓库
js
import { create } from 'zustand'
export const useComponentConfigStore= create<State & Action>((set, get) => ({}))
//State表示create函数内状态变量的类型,Action表示方法的类型
4. 添加状态变量componentConfig对象(用于盛放各个已创建的组件)
- 需先创建限定componentConfig对象的类型,以及对象内元素的类型
- 需先限定create函数状态变量的类型为State (自己打造的类型)
- ComponentConfig为componentConfig对象内值的类型
- State为componentConfig对象的类型
js
export interface ComponentConfig{
name:string;//组件名称
defaultProps:Record<string,any>;//组件默认属性
component:any// 组件实例
}
export interface State {
componentConfig: {
//表示componentConfig对象的类型,任何字符串类型的值都能做key,而值的类型限定为刚刚创建的ComponentConfig类型
[key: string]: ComponentConfig
}
}
- 将刚刚创建的组件实例添加进componentConfig对象内
js
export const useComponentConfigStore= create<State & Action>((set, get) => ({
componentConfig:{
Container:{
name:"Container",
defaultProps:{},
component:Container,
},
Button:{
name:"Button",
defaultProps:{
type:'primary',//按钮类型
text:'按钮'//按钮名称
},
component:Button,
},
Page:{
name:"Page",
defaultProps:{},
component:Page,
},
},
}))
5. 创建registerComponent方法(用于注册新组件)
- 先规限定定create函数中方法的类型为Action(自己打造的类型)
js
export interface Action{
registerComponent:(name:string,componentConfig:ComponentConfig)=>void//注册组件
}
- 添加registerComponent方法
js
registerComponent:(name,componentConfig)=>{//注册组件,自定义组件
//name表示注册的新组件的名字,componentConfig为新组件的属性参数
set((state)=>{
return{
componentConfig:{
...state.componentConfig,//保留原有的组件
[name]:componentConfig//添加新的组件
}
}
})
}
至此,前期的useComponentConfigStore仓库创建完毕
在EditArea/index.tsx文件中打造渲染函数(画布区)
主要思路:需要用到刚刚打造的useComponentsStore仓库,用于获取组件的名称,方便通过组件名称,从useComponentConfigStore仓库内拿到要渲染的组件实例,再使用React自带的createElement进行渲染,并可以在渲染后的DOM元素上添加属性,和递归渲染(因为json数据内部有children属性,一次渲染只能渲染最外层,所以要使用递归渲染)
1.先将所需要的仓库,类型,和模块引入
js
import type { Component } from '../../stores/components'//将components仓库内的Component类型引入
import { useComponentConfigStore } from '../../stores/component-config'
import React from 'react'
import { useComponentsStore } from '../../stores/components'
2.再获取useComponentsStore仓库内的components[]
- 用于遍历json数据,拿到最外层组件的名称,方便通过组件名称,从useComponentConfigStore仓库内拿到要渲染的组件实例
js
const { components } = useComponentsStore()
3.再获取useComponentConfigStore仓库内的componentConfig对象
- 用于通过组件名来获取组件实例
js
const { componentConfig } = useComponentConfigStore()
4.编写renderComponents渲染函数(代码在下面)
5.最后在return内的div容器中调用renderComponents函数即可
js
export default function EditArea() {
const { componentConfig } = useComponentConfigStore()
const { components } = useComponentsStore()
//组件递归渲染
function renderComponents(components: Component[]): React.ReactNode {//因为函数返回的是一个dom结构,所以使用React内置的ReactNode类型限制函数类型
return components.map((component: Component) => {//拿到json数据进行遍历
const config = componentConfig?.[component.name]//找到json数据中name在componentConfig中对应的组件
if (!config?.component) {//如果当前名字没有对应的组件
return null
}
//渲染组件
return React.createElement(config.component, {
key: component.id,//必须添加的key属性,表示该组件的唯一标识,属于react内部的特殊属性,在浏览器开发者工具上看不见,但是在react内部会使用这个属性来进行组件的识别和更新
id: component.id,
name: component.name,
...config.defaultProps,//组件的默认属性
...component.props,//组件的更新属性,会覆盖默认属性
},
renderComponents(component.children || [])//递归渲染整个json树,就是一直向下渲染它的子组件,知道没有为止
)
})
}
return (
<div>
{renderComponents(components )}
</div>
)
}
实现物料区组件的拖拽功能
1. 安装react-dnd ---用于跨组件传递数据,允许您定义可拖拽元素和放置目标,并在它们之间传递数据
css
npm i react-dnd
2. 安装react-dnd-html5-backend--用于实现组件拖拽效果
css
npm i react-dnd-html5-backend
3.在main.tsx文件中进行引入,认其全局生效
js
//main.txs中的部分代码
import {DndProvider} from 'react-dnd'
import {HTML5Backend} from 'react-dnd-html5-backend'
createRoot(document.getElementById('root')!).render(
<DndProvider backend={HTML5Backend}>
<App />
</DndProvider>
)
4.将useComponentConfigStore仓库内打造的组件实例名称展示在左边的取料区
大体思路:将useComponentConfigStore仓库内打造的组件实例的名称加个边框,放在物料区,这里并不需要放置组件实例,只要将其拖拽至画布区后,才需要放置实例至画布区
js
//Materail/index.tsx内完整代码
import { useComponentConfigStore } from '../../stores/component-config'
import { useMemo } from 'react'//用于缓存
export default function Materail() {
const { componentConfig } = useComponentConfigStore()//取出仓库内的组件配置
const components = useMemo(() => {//进行缓存,避免每次组件渲染都重新执行一遍
return Object.values(componentConfig)//得到一个包含已创建的组件配置实例的数组
}, [componentConfig])
//只有当依赖数组中的 componentConfig 发生变化时,才会重新执行箭头函数并计算新的 components 数组。
// 如果 componentConfig 没有变化,useMemo 会直接返回上一次缓存的结果,避免重复执行 Object.values().filter() 操作。
return (
<div>
{
components.map(item => (
<div
className='
border-dashed
border-[1px]
border-[#000]
py-[8px] px-[10px]
inline-block
bg-white
m-[10px]
cursor-pointer// 鼠标放到组件上,变为手型
hover:bg-[#ccc]//鼠标放到组件上,组件背景颜色变为灰色
'
key={item.name}
>
{item.name}//组件名称
</div>
))
}
</div>
)
}
效果图

总结:
今天完成了以下操作:
- 创建了useComponentsStore仓库,用于存储整个json树
- 创建了Page,Container,Button组件
- 创建了useComponentConfigStore仓库,用于存储组件实例
- 打造了renderComponents渲染函数,用于渲染json树
- 将已创建的组件实例展示在了物料区,为拖拽做准备