VLayout 是一个学习页面可视化搭建的项目, 使用了 React + TS 技术来开发。如果您有好的建议,欢迎提出,如果感觉帮助到了您,不妨点个赞或star。
功能设计
首先开始一个项目时,我们需要理清他有哪些功能。以下页面可视化搭建的基础功能列表
- 数据协议(JSON Schema)
- 自定义组件库
- 编辑器(画布)
- 可拖拽
- 放大缩小
- 组件属性编辑功能
- 图层管理
- 复制粘贴
- 持久化存储、预览
后续会在这篇文章的基础上添加如下功能:
- 事件绑定
- 支持动画
- …
有了上面的功能设计,我们可以通过画图来更清晰的理解流程
总的来说,我们需要一个自定义组件库,它需要支持我们定义的数据协议(JSON Schema)。而编辑器则是通过对一个组件的JSON Schema进行编辑,然后输出最终用于在页面中展示的数据, 用现在流行的前端框架的展示逻辑就是**UI = f(JSON Schema)**。
技术选型
- 项目管理工具: lerna
- 构建工具:
- 编辑器: vite
- 自定义组件库: rollup
- 前端框架/库: ts + react + redux-toolkit + redux-persist + react-router + react-dnd + antd
- 代码规范以及提交规范: eslint + prettier + husky + lint-stage + commitlint
- 单元测试: jest + react-testing-library
项目管理 (如果想看实现逻辑,可直接到下面的功能实现)
通过上面我会发现,一个可视化搭建项目至少会包含两个子项目:
- 自定义的组件库
- 用于可视化编辑的管理页面
上面的子项目 1 目前是只实现了 React 版本的组件库,但是我觉的一个公司业务规模比较大的时候可能不止一种技术栈。可能会有 Vue、微信小程序等
所以,为了项目的可扩展性,我们使用lerna来做 monorepo
monorepo不是框架也不是库,它是一种项目管理的概念。它表示将多个项目放在一个仓库中统一开发,便于管理,使用统一标准开发,当有多个依赖项目的时候也便于发布。而lerna是基于这个概念实现的项目管理工具。
lerna 简单介绍
全局安装
yarn global add lerna
或者npm install lerna -g
创建项目根目录
mkdir lerna-demo && lerna init
执行完 init 后会多出一个packages
目录和lerna.json
,并且会配置一个workspace
创建不同的子项目
然后我们可以在 packages 中创建不同的项目,具体代码可以查看lerna-demo
代码里有个需要注意的点就是打包配置中设置的打包方式要和你引入的方式是一致的,或者直接在打包配置中设置
esm
、cjs
和umd
三种方式,然后根据不同的规范去引入不同的代码。
这里我们有三个项目header
, footer
, website
webstite 就是我们的项目,其他两个是组件库。而我们需要在 website 中使用它们。那么就需要在 website 的 package.json 中导入,方法如下:
{
// ...
"dependencies": {
// ...
"header": "*",
"footer": "*"
}
}
这样就相当于告诉 lerna 去 link workspace 中的header
和footer
,就像npm install
了一样。
然后再yarn
执行一下命令
打包项目
如果需要打包所有的项目则直接运行lerna run build
lerna 会按照依赖顺序,先打包header
和footer
,最好再打包website
也可以使用--scope
配置 指定需要打包的项目lerna run build --scope header --scope footer
,这样,website 项目就不会被打包。
运行单元测试也同上。
项目运行
打包好了两个依赖项目后,就可以运行website
了,
lerna run dev --scope=website
也可以不加--scope
,因为其他两个项目中并没有dev
这个运行命令。
最后就可以直接访问了
功能实现
1. 数据协议(JSON Schema)
定义一个通用的数据协议,我们以一个 Button 组件为例,它接收的Schema 数据如下:
其他的自定义组件也都是接收这种格式的数据
{
id: '',
type: 'Button',
propValue: '点击', // 组件所使用的值
animations: [], // 动画列表
events: {}, // 事件列表
style: {
// 组件样式
boxSizing: 'border-box',
position: 'absolute',
left: 0,
top: 0,
width: 100,
height: 34,
borderWidth: 0,
borderColor: '',
borderStyle: '',
borderRadius: 0,
fontSize: '',
fontWeight: 400,
lineHeight: '',
letterSpacing: 0,
textAlign: '',
color: '',
backgroundColor: '',
},
};
你可以看到上面的数据中有一个type: Button字段,这个字段就是每个组件库中的唯一的字段,标识了是什么组件。
接着,进入自定义组件的入口处,我们使用的策略模式根据 schema 的 type 来判断加载哪个组件
// 懒加载
const ComponentMap = {
Button: React.lazy(() => import("./custom-components/Button/index")),
Text: React.lazy(() => import("./custom-components/Text/index")),
Image: React.lazy(() => import("./custom-components/Image/index")),
};
export type MaterialProps = {
[key in string]: any;
};
const Material: React.FC<MaterialProps> = (props) => {
const { schema } = props;
const Comp = ComponentMap[schema.type];
if (!Comp) {
return null;
}
return (
<Suspense fallback={<div>Component `{schema.type}` is loading!</div>}>
<Comp {...props} />
</Suspense>
);
};
2. 自定义组件
我们还是以实现一个Button组件为例,目前只需要将 style 渲染到组件中即可.
**PS: 只要修改组件库,就需要使用 lerna run build –scope [your-components]**来从新打包,不然依赖这个组件库的项目无法使用到你更改后的版本
export function Button({ schema, ...rest }: MaterialProps) {
const { propValue, style } = schema;
return (
<button {...rest} style={style}>
{propValue || "按钮"}
</button>
);
}
并且,每个组件都对应着一个初始的schema 和 template。 前者我们已经说过了,我们主要说一下后者。**template** 它相对比较简单,就是定义这个组件的一些基础信息,用于在编辑器中展现出一个可拖拽的组件列表
**PS: template 中的 type 必须要和 schema 中的 type 是一致的,因为我们后续需要通过这个 type 获取对应的 schema
const Button: T_Template = {
type: "Button",
h: 20,
icon: btn,
displayName: "按钮组件",
};
export default Button;
3. 编辑器(画布)
画布主要有两个部分
- 可拖拽的组件目标
- 用于放置拖拽目标的画布容器
有了上面的template,我们只需要实现一个通用的拖拽 Box 组件,用于传递默认的 schema 主要代码如下:
const Box = ({ tpl }: any) => {
// @ts-ignore
// 自定义组件库中默认的schmea
const cSchema = schema[tpl.type];
const [, drag] = useDrag(() => ({
type: ItemTypes.BOX,
item: { cSchema },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
handlerId: monitor.getHandlerId(),
}),
}));
return (
<div ref={drag}>
<img src="" alt="" />
<p>{tpl.displayName}</p>
</div>
);
};
然后,直接遍历template生成组件列表即可。
上面的代码中我们可以看到通过react-dnd的useDrag
传输了对应的默认 schema到item
属性中。这是组件能在画布中展示的核心逻辑,对应到画布容器中,有对应的useDrop
来接收这个item
代码实现如下:
// ...
const [, drop] = useDrop(() => ({
accept: ItemTypes.BOX,
drop: ({ cSchema }: any, monitor) => {
const { x, y } = monitor.getClientOffset() as { x: number; y: number };
// 拷贝一下schema数据,避免指针出错
addSchema(clone(cSchema), x, y);
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}));
// ...
获取到了Schema数据后,我们通过一个schemaList
来保存插入的数据。然后就是通过遍历schemaList
来生成对应的组件。这里的组件,就是我们在另一个子项目中的自定义组件库中的一个组件。下面是主要逻辑
{
schemaList.map((schema) => {
return (
<Material
onMouseDown={(e: MouseEvent) => handleMouseDown(e, schema.id)}
key={schema.id}
schema={schema}
/>
);
});
}
4. 可拖拽
上面只是简单的实现了将组件拖拽到了画布中。但是,我们希望的是在画布中也可以拖拽组件来完成布局。
因为我们采用的是定位的布局方式,那么实际需要的就是移动的元素位于画布的坐标
获取left
和top
主要实现逻辑大致如下:
- 监听
mousedown
事件 - 获取到画布位于浏览器的坐标
- 通过 1 获取到鼠标位于画布的坐标
- 通过 2 获取到鼠标位于当前元素的坐标
- 在
mousedown
中监听mousemove
事件 - 通过
mousemove
的事件对象和 画布位于浏览器的坐标计算出 鼠标在画布中移动的坐标 - 然后然后再通过 6 的坐标 减去 4 的坐标 获取到移动元素位于画布的坐标
- 处理超出画布的边界情况
- 在
mousedown
中监听mouseup
事件,并解绑mousemove
和mouseup
我们看核心代码实现:
// 处理画布内元素移动
const handleMouseDown = (e: MouseEvent, id: string) => {
e.preventDefault();
e.stopPropagation();
dispatch(setCurSchemaId(id));
if (e.button === 2) {
// 打开右击键
setMenuTag(ITEM_MENU_TAG);
dispatch(toggleRightClick(true));
return;
}
const schema = selectCurSchema(schemaList, id) as Schema;
// 计算鼠标位于画布中的坐标
const pointX = e.clientX - canvasInfo.x;
const pointY = e.clientY - canvasInfo.y;
// 获取鼠标位于当前元素的位置
const targetX = pointX - schema?.style.left;
const targetY = pointY - schema?.style.top;
const move = (moveEvent: MouseEvent) => {
moveEvent.preventDefault();
const moveX = moveEvent.clientX - canvasInfo.x;
const moveY = moveEvent.clientY - canvasInfo.y;
// 计算元素最后的坐标
let x = moveX - targetX;
let y = moveY - targetY;
// 处理超出画布的边界情况
const caclRes = calcPos(x, y, schema);
x = caclRes.x;
y = caclRes.y;
dispatch(updateSchemaPos({ x, y, id }));
};
const up = () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
};
5. 放大缩小
它的实现思路是通过在拖拽组件的外面包裹一个节点,然后再加上三个 point(圆点),通过当前组件的 schema 计算出包裹层的坐标,然后将三个 point 依据包裹层定位,再通过在 point 上添加对应的事件来监听即可,这里就不贴代码了。如果想看实现细节可直接到 PointWrapper 查看
6. 组件属性编辑
当我们选中画布中的一个组件时,右侧就会显示出当前组件对应的 schema,目前只实现了样式的编辑。
实现这个功能的核心就是需要定义与样式对应的 map
styleMap
export const styleMap: any = {
rotate: { label: "旋转角度", type: "number" },
width: { label: "宽", type: "number" },
height: { label: "高", type: "number" },
color: { label: "颜色", type: "color" },
backgroundColor: { label: "背景色", type: "color" },
borderWidth: { label: "边框宽度", type: "number" },
borderStyle: { label: "边框风格", type: "select" },
borderColor: { label: "边框颜色", type: "color" },
borderRadius: { label: "边框半径", type: "number" },
fontSize: { label: "字体大小", type: "number" },
fontWeight: { label: "字体粗细", type: "number" },
lineHeight: { label: "行高", type: "number" },
letterSpacing: { label: "字间距", type: "number" },
textAlign: { label: "左右对齐", type: "select" },
verticalAlign: { label: "上下对齐", type: "select" },
opacity: { label: "不透明度", type: "number" },
};
然后在组件中遍历这个map
,并通过不同的type
生成不同的输入框。然后将数据和功能绑定即可
const renderComp = (style: any, styleProp: string, type: string) => {
const value = style[styleProp];
if (type === "number") {
return (
<InputNumber
value={value}
onChange={(value) => handleChange(styleProp, value)}
/>
);
}
if (type === "text") {
return (
<Input
value={value}
onChange={(e) => handleChange(styleProp, e.target.value)}
/>
);
}
if (type === "select") {
return (
<Select
value={value}
onChange={(value) => handleChange(styleProp, value)}
options={optionMap[styleProp]}
/>
);
}
if (type === "color") {
return (
<ColorPicker
color={value}
onChange={(value: any) => handleChange(styleProp, value)}
/>
);
}
return null;
};
7. 图层管理
图层管理相对比较简单,因为我们是基于absolute
定位做的。且已经有了对应schemaList
数据,那么我们需要做的就是移动当前组件对应的schema
数据在schemaList
中的数据就行了
核心的实现就是一个[swapSchema](https://github.com/SaebaRyoo/VLayout/blob/main/packages/website/src/features/Editor/editor.slice.ts)
方法,通过数据的索引切换位置
swapSchema: (
state,
action: PayloadAction<{ curIdx: number; targetIdx: number }>
) => {
const { curIdx, targetIdx } = action.payload;
const temp = state.schemaList[curIdx];
state.schemaList[curIdx] = state.schemaList[targetIdx];
state.schemaList[targetIdx] = temp;
},
8. 复制粘贴
复制粘贴的实现需要屏蔽浏览器的默认右击事件,所以我在项目中写了一个RightClick 组件,实现细节我就不贴代码了。
主要逻辑还是复制一份当前操作的组件的schema
数据,然后粘贴的时候调用显示RightClick
组件的方法,通过RightClick
组件获取当前右击的鼠标位置,来确定粘贴后的组件的坐标。
9. 持久化存储和预览
持久化存储使用的是redux-presist
因为并没有接入后端,所以希望通过在 localStorage 中长期存储,防止刷新丢失数据。
预览页面的实现就是获取schemaList
数据,然后生成最终的展示页面即可。
10. 事件绑定
关于事件绑定,在schema
中,有一个events
对象,我的思路是在events
对象中存放如下结构
{
events: {
onClick: `window.location.href = 'http://www.baidu.com'`,
// ...
}
}
直接通过编辑开放画布,让用户直接通过代码去做一些交互,比如上面的就是添加了一个点击事件,后面就是需要执行点击逻辑,在自定义组件中通过new Function(string code)
构建一个 function。
然后给组件添加一个isEdit
属性,让组件来判断是否为编辑状态,编辑状态下取消所有事件
关于安全问题,后续也可以通过定义各种功能的代码块的方式,限制用户只能添加特定的 js 逻辑。这样也方便扩展。
发包
需要先npm login
登录
然后在根目录使用 lerna 命令lerna publish --force-publish
,
使用–force-publish的原因如下
总结
通过自己造轮子,确实收获颇多。当你独立去做一个项目的时候,你就会从整体上出发,思考项目的管理,技术的选型等等。最重要的是能够实践你的所学。
PS: 后续会继续迭代未完成的功能,如果您有好的建议,感谢您能提出。如果觉得有帮助可以帮我点个赞或者star