本文将接着上回继续编写低代码编辑器框架(代码含详细注释)
前言
在上文中,我们已经完成了:
- useComponentsStore仓库(用于存放整个json树以及其对应的方法)
- Page组件、Button组件、Container组件
- useComponentConfigStore仓库(用于存放所有以及创建好的组件实例,以及注册组件的方法)
- renderComponents渲染函数(将json树中所有的组件对象渲染到画布区)
- 将已经打造好的组件名称展示在了物料区
本文将接着上文实现从左边物料区拖拽组件,并放置在中间的物料区进行展示
创建MaterailItem/index.tsx子组件
在Materail/index.tsx中,之前是通过map方法将useComponentConfigStore仓库被组件实例的名称获取并创建一个div容器进行包裹,那么现在为了更好的实现拖拽效果,我们将div包裹的内容封装成一个子组件,在Materail/index.tsx中调用该子组件即可
1. 在src/editor/components文件夹下创建MaterailItem文件夹,并在其下创建index.tsx文件

2. 将Materail/index.tsx中的div剪切到MaterailItem/index.tsx子组件中
3. 将子组件引入Materail/index.tsx中
js
//Materail/index.tsx完整代码
import { useComponentConfigStore } from '../../stores/component-config'
import { useMemo } from 'react'//用于缓存
import MaterialItem from '../MaterialItem'
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, index) => {
return (
//key属性为React框架自用属性,不参与propos传递,只传递name属性
<MaterialItem key={item.name + index} name={item.name} />
)
})
}
</div>
)
}
4. 实现物料区组件的拖拽效果
- 引入之前安装的React DnD库中的 useDrag 钩子实现了组件的拖拽功能
js
import { useDrag } from "react-dnd"//提供拖拽功能
- 使用useDrag钩子函数生成拖拽引用dragRef
js
const [_, dragRef] = useDrag(() => ({//dragRef表示拖拽引用,绑定到DOM元素上使其成为拖拽目标
//表示拖拽类型表示符,确保不同物料项有不同的拖拽类型,作用是:可将其引入父容器中,明确父容器可盛放该组件
type: props.name,
item: { //被拖动的类型信息,父容器可通过monitor.getItem()获取此对象,用于处理拖拽逻辑
type: props.name
}
})
)
- 绑定到div容器上,使其成为拖拽目标
js
//MaterailItem/index.tsx完整代码
import { useDrag } from "react-dnd"//提供拖拽功能
export interface MaterialItemProps {//自己定义的props的参数类型
name: string
}
export default function MaterialItem(props: MaterialItemProps) {
//props为父组件传给子组件的参数,类型MaterialItemProps为我们自己定义的接口
//_表示忽略第一个返回值
const [_, dragRef] = useDrag(() => ({//dragRef表示拖拽引用,绑定到DOM元素上使其成为拖拽目标
//type: 是拖拽源与放置目标之间的 "匹配标识"------ 放置目标通过 `accept` 数组声明可接收的 `type`,只有两者匹配时,放置目标才会响应拖拽行为
type: props.name,
item: { //被拖动的类型信息,父容器可通过monitor.getItem()获取此对象,用于处理拖拽逻辑
type: props.name
}
})
)
return (
<div
ref={dragRef as any}//将dragRef绑定在包裹每个组件实例名称的div上使其成为拖拽目标
className='
border-dashed
border-[1px]
border-[#000]
py-[8px] px-[10px]
inline-block
bg-white
m-[10px]
cursor-pointer
hover:bg-[#ccc]
'
key={props.name}>
{props.name}//展示的组件名称
</div>
)
}
至此,拖拽效果实现,接下来进行Page和Container容器组件的配置,使其能够盛放子组件,从而达到将物料区组件拖拽并放置在画布区内的效果
在此之前,需做一些准备
- 先修改一点小样式,使得json树最外层定死的Page组件的高度和页面一样高,修改src/editor/components/EditArea/index.tsx文件中的div容器的样式,将个h-[100%]
js
return (
<div className=' h-[100%]'>
{renderComponents(components )}
</div>
)
- 在editor文件夹下创建一个公共接口interface.ts,用于定义一个公共类型(作为基础组件属性接口,统一规范不同组件的公共属性)
js
import type {PropsWithChildren} from 'react'
////PropsWithChildren为React内置泛型接口,提供 children?: ReactNode 属性,允许组件接收子元素
//CommonComponentProps通过 extends PropsWithChildren 使接口拥有接收子元素的能力
export interface CommonComponentProps extends PropsWithChildren {
id: number
name: string
[key:string]:any
//[key:string] :支持任意字符串类型的属性名
//any :属性值可以是任意类型
//作用:提供灵活性,允许组件接收未预先定义的动态属性
}
- 在useComponentConfigStore仓库中的组件对象中再添加一个desc属性
在component-config.tsx文件内,要修改的代码如下
js
export interface ComponentConfig {
name: string;//组件名称
defaultProps: Record<string, any>;//组件默认属性
component: any// 实际的 React 组件
desc:string//组件描述
}
componentConfig: {
Container: {
name: "Container",
defaultProps: {},
component: Container,
desc:'容器',
},
Button: {
name: "Button",
defaultProps: {
type: 'primary',//按钮类型
text: '按钮'//按钮名称
},
component: Button,
desc:'按钮',
},
Page: {
name: "Page",
defaultProps: {},
component: Page,
desc:'页面'
},
},
自创建一个钩子函数useMaterialDrop,可被Page和container容器使用,使他们能够放置拖拽过来的组件,并能够限定可放置组件的类型
1. 在editor文件夹下创建hooks文件夹,hook文件夹内再创建useMaterialDrop.ts文件
2. 完成钩子函数useMaterialDrop的实现逻辑
大体思路 :useMaterialDrop函数需接收两个参数,一个是accept: string[] ,用于限制放置组件的类型,另一个是使用该钩子函数的容器id ,用于在添加拖拽项到json树中时设置的父组件id。useMaterialDrop函数中主要使用了React DnD库内的放置目标钩子函数useDrop(),实现了:
- 限制放置组件的类型accept
- 使用Ant Design消息提示组件弹出添加成功信息
- 调用useComponentsStore仓库中的addComponent方法将拖拽项添加至json树中
- 收集拖拽状态canDrop(供组件样式使用)
最后函数返回三个参数 :
canDrop :是否允许放置(用于条件渲染样式)
dropRef : 放置区域DOM引用(需绑定到容器元素,使其可放置组件)
contextHolder:Ant Design消息组件的上下文(需在组件中渲染)
js
useMaterialDrop.ts完整代码
import { useDrop } from 'react-dnd'// React DnD库的放置目标钩子
import { useComponentsStore } from '../stores/components' // 组件状态管理仓库
import { useComponentConfigStore } from '../stores/component-config'// 组件配置管理仓库
import { message } from 'antd'// Ant Design消息提示组件
//accept: string[] :允许接收的拖拽类型数组(与拖拽源的 type 匹配),就是刚刚设置的type: props.name
//id: number :当前容器组件的 ID,用于给新添加的组件设置父组件 ID
export function useMaterialDrop(accept: string[], id: number) {
const { addComponent } = useComponentsStore()//从useComponentsStore获取addComponent方法
const { componentConfig } = useComponentConfigStore()//从useComponentConfigStore获取componentConfig对象
const [messageApi, contextHolder] = message.useMessage();
//useDrop是React DnD的核心钩子,用于定义放置目标行为,返回: canDrop: 是否允许放置(由collect函数计算)
const [{ canDrop }, dropRef] = useDrop(() => {
return {
accept, // 允许接收的拖拽类型(与参数accept一致,所以就简写成一个)
// 拖拽完成时触发的回调函数
drop: (item: { type: string }, monitor) => {//item为从拖拽源传过来的对象,里面放的就是拖拽源组件的名称
// 检查是否已在其他目标放置(避免重复处理)
//monitor.didDrop()默认结果为false,如果在page容器内添加了一个container组件,那么monitor.didDrop()就会变为true,
// 再想在container容器内放一个button,这时候就不会误将button也加入到page中,而是只会加在container容器内
const didDrop = monitor.didDrop()
if (didDrop) return//如果didDrop为true,说明已经在其他目标放置了,就直接返回
// 显示成功消息
messageApi.success(`${item.type}添加成功`)
// 从组件配置仓库中获取默认属性和描述,item.type为对应的组件名称
const props = componentConfig?.[item.type]?.defaultProps
const desc = componentConfig?.[item.type]?.desc
// 调用useComponentsStore仓库内的添加组件对象到json树的方法,将拖拽组件添加到json树中,然后会触发渲染函数的执行
addComponent({
id: new Date().getTime(), // 使用时间戳作为唯一ID
name: item.type, // 组件类型名称
props: props, // 默认属性
desc: desc // 组件描述
}, id) // id为父容器ID,指定组件添加位置
},
// 收集拖拽状态(供组件使用)
collect: (monitor) => ({
// 当前是否可以放置拖拽项,会被利用到容器样式内,如果是可以放置的拖拽项,则组件边框变为设置好的颜色
canDrop: monitor.canDrop()
})
}
})
return {
canDrop,// 是否允许放置(用于条件渲染样式)
dropRef,// 放置区域DOM引用(需绑定到容器元素,使其可放置组件)
contextHolder// Ant Design消息组件的上下文(需在组件中渲染)
}
}
3.将钩子函数引入到contain组件中
关键步骤:解构父组件传过来的id和name属性,并将参数类型改为CommonComponentProps公共接口类型
调用useMaterialDrop函数,传入需要限定的拖拽项的类型,以及组件的id,分别拿到canDrop, dropRef, contextHolder三个参数
将contextHolder加入到DOM元素中实现添加成功消息提示
将dropRef绑定DOM元素,使其能够放置子组件
将canDrop添加到样式中,定义可放置项和不可放置项的不同样式
js
//Container/index.tsx完整代码
import type { CommonComponentProps } from '../../interface'//引入公共接口类型
import { useMaterialDrop } from '../../hooks/useMaterialDrop'//引入自建钩子函数,用于处理拖拽放置逻辑
//id为渲染组件时添加的id属性,表示当前组件的id,此id就是父传子参数中获取的
//children使得该组件在父组件内也可以在该组件内放子结构体(相当于一个容器组件)
export default function Container({ id, name, children }: CommonComponentProps) {
//['Container', 'Button']表示该容器组件可以放入的组件类型
//id参数用于钩子函数内添加拖拽项时设置该容器为父组件id
const { canDrop, dropRef, contextHolder } = useMaterialDrop(['Container', 'Button'], id)
return (
<>
{/* Ant Design消息组件的上下文,用于在容器组件内显示消息 */}
{contextHolder}
<div
ref={dropRef as any}//将DOM元素注册为React DnD的放置目标,可放置拖拽项
className={`min-h-[100px] p-[20px]
${canDrop ? 'border-[2px] border-[blue]' : 'border-[1px] border-[#000] '}`}>
{/* canDrop === true:拖拽项可放置时,显示2px蓝色边框(视觉反馈) canDrop === false:默认显示1px黑色边框 */}
{children}
</div>
</>
)
}
4.将钩子函数引入到Page组件中(注释同上)
js
//Page/index.tsx完整代码
import type { CommonComponentProps } from '../../interface'
import { useMaterialDrop } from '../../hooks/useMaterialDrop'
export default function Page({ id, name, children }: CommonComponentProps) {//使得该组件在父组件内也可以在该组件内放子结构体(相当于一个容器组件)
const { canDrop, dropRef, contextHolder } = useMaterialDrop(['Container', 'Button'], id)
return (
<>
{contextHolder}
<div
ref={dropRef as any}
className=" p-[20px] h-[100%] box-border"
style={{ border: canDrop ? '2px solid red' : 'none'}}
>
{children}
</div>
</>
)
}
效果图
总结
今天实现了物料区组件的拖拽功能,并将其放置在中间画布区的容器内,且完成了展示
下期将实现鼠标移动到画布区的某个组件上时,该组件则显示一层浅紫色蒙层