学习低代码编辑器第二天

本文将接着上回继续编写低代码编辑器框架(代码含详细注释)

前言

接下来主要实现物料区,画布区和属性区的逻辑,头部组件后续再接着完善,其中最核心的逻辑包括:

  1. 实现从物料区将组件拖拽到画布区并展示的效果,实质上就是维护一个json对象,每次拖拽添加组件,就是将其添加至json对象的某一层,再进行渲染
  2. 右侧的属性区,实质上就是添加,删除,修改,组件对象中的属性值和样式值
  3. 最后将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(自己打造的类型)
    1. Component为components数组内对象的类型
    2. 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 (自己打造的类型)
    1. ComponentConfig为componentConfig对象内值的类型
    2. 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>
  )
}

效果图

总结:

今天完成了以下操作:

  1. 创建了useComponentsStore仓库,用于存储整个json树
  2. 创建了Page,Container,Button组件
  3. 创建了useComponentConfigStore仓库,用于存储组件实例
  4. 打造了renderComponents渲染函数,用于渲染json树
  5. 将已创建的组件实例展示在了物料区,为拖拽做准备
相关推荐
崔庆才丨静觅2 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60613 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了3 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅3 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅3 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅4 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment4 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅4 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊4 小时前
jwt介绍
前端
爱敲代码的小鱼4 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax