原文地址:https://juejin.cn/post/6979410699453726727 文章已经过作者许可转载
前言
经过许久的深思熟虑与探索,同时也借鉴了行业内不错的产品(如:有赞,H5-Dooring等),但跟列举的产品还是有区别的(先卖个关子,后面再讲有哪些区别)。其实这种功能在零售系统(目前我所在公司是零售行业的领头羊)和电商系统应该很常见,很多应用场景都会用到,像产品营销页面、企业/个人微官网、H5活动页面等移动端页面,通过可视化配置快速搭建H5页面,且提供丰富的页面组件,更方便的为使用者搭建更强大的H5页面。
PC端界面如下:
PC端界面
移动端(H5和小程序)界面如下:
技术方案
PC端 React 技术栈,移动端 UniApp 跨平台框架,功能的设计结构图如下:
装修页面前端设计模式.png
代码语言:javascript复制/*
* @description: DecoratePage Context交互
* @version: 分支号 20210629
* @author: xuchao
*/
import React, { PureComponent } from 'react';
import { withRouter, router } from 'umi';
import { Layout, Modal, Button } from 'antd';
import { isEmpty, findIndex, isArray, find, every, cloneDeep } from 'lodash';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { showMsg } from '@/global';
import Component from './components/Component';
import Preview from './components/Preview';
import Compiler from './components/Compiler';
import { DecorateContext, components } from './utilities';
import './style.less';
const { Header } = Layout;
export default class Decorate extends PureComponent {
state = {
compiler: 'PageSetting',
pagename: '页面标题',
selectIndex: 0,
previewData: [],
};
getChildContext() {
return {
...this.state,
...this.props,
setState: state => this.setState(state),
};
}
render() {
return (
<DecorateContext.Provider value={this.getChildContext()}>
<Layout className="decorate">
<Header className="header">
<span className="hand">
返回首页装修
</span>
<Button type="primary" className="fr">
发布
</Button>
<Button type="primary" className="fr mr10">
保存
</Button>
<Button className="fr mr10">
预览
</Button>
</Header>
<DndProvider backend={HTML5Backend}>
<Layout className="container">
<Component />
<Preview />
<Compiler />
</Layout>
</DndProvider>
</Layout>
</DecorateContext.Provider>
);
}
}
数据
前面说到与列举的产品有哪些区别,区别在于PC端与移动端的数据交互,它们都是通过 iframe 嵌套 H5 的页面,通过 postmessage API 来做数据交互,而是我没有这样做,原因是项目特别紧,加上人员分配问题,所以采用数据定义模式。
通过上面的设计结构图可以看出PC端最后会生成一份 schema 数据存储服务端,移动端从服务端获取到 schema 数据进行解析。数据格式如下:
代码语言:javascript复制// 图片广告
{
component: 'ImageTextAd',
options: {
template: 'image', // image:一行一个 carousel:轮播海报 slide:大图横向滑动 zone:绘制热区
image: [
{
id: '',
url: '',
title: '',
linkCode: '',
linkName: '',
// 热区
zones: [
{
x: 178,
y: 91,
width: 158,
height: 132,
code: '123',
text: '测试链接2',
}
],
},
{
id: '',
url: '',
title: '',
linkCode: '',
linkName: '',
// 热区
zones: [
{
x: 436,
y: 97,
width: 170,
height: 168,
code: '',
text: '',
}
],
},
],
indicator: 'dotted', // 指示器
style: {
boxShadow: 'none',
borderRadius: 'none',
padding: '0',
},
},
},
// 公告
{
component: 'Notice',
options: {
content: '公告内容',
style: {
background: 'rgb(255, 248, 233)',
color: 'rgb(100, 101, 102)',
},
},
},
// 图文导航
{
component: 'ImageTextNav',
options: {
template: 'image-nav', // image-nav:图片导航 text-nav:文字导航
images: [{
url: '',
title: '',
link: '',
}],
style: {
background: 'rgb(255, 248, 233)',
color: 'rgb(100, 101, 102)',
},
},
},
// 标题栏
{
component: 'Title',
options: {
style: {
textAlign: 'left',
background: '#FFFFFF',
},
title: {
text: '',
style: {
fontSize: '16px',
fontWeight: 'bold',
color: '#323233',
},
},
content: {
text: '',
style: {
fontSize: '12px',
fontWeight: '400',
color: '#969799',
},
},
},
},
// 文本模块
{
component: 'RichText',
options: {
content: '<html></html>',
style: {
backgroundColor: '#F9F9F9',
padding: '10px 10px 0',
},
},
},
// 辅助分割
{
component: 'DivideLine',
options: {
template: 'block', // block:辅助空白 line:辅助线
style: {
height: 30,
// borderTopWidth: '1px',
// borderTopStyle: 'dashed',
// borderTopColor: '#EBEDF0',
// margin: '10px 0 0',
},
},
},
// 商品搜索
{
component: 'GoodSearch',
options: {
style: {
backgroundColor: '#FFFFFF',
},
box: {
style: {
borderRadius: 'none',
textAlign: 'left',
height: 28,
backgroundColor: '#F7F8FA',
color: '#c8c9cc',
},
},
},
},
// 左右图文
{
component: 'LRImageText',
options: {
template: 'lr', // lr:左图右文 rl:左文右图
content: '', // 内容
image: {
url: '', // 图片地址
linkCode: '', // 跳转页面code
linkName: '', // 跳转页面name
style: {
boxShadow: 'none',
borderRadius: 'none',
},
},
},
},
// 图文导航
{
component: 'ImageTextNav',
options: {
template: 'image', // image:图片导航 text:文字导航
image: [
{
url: '',
title: '导航一',
linkCode: '',
linkName: '',
},
{
url: '',
title: '导航二',
linkCode: '',
linkName: '',
},
{
url: '',
title: '导航三',
linkCode: '',
linkName: '',
},
{
url: '',
title: '导航四',
linkCode: '',
linkName: '',
},
{
id: uuid(),
url: '',
title: '导航五',
linkCode: '',
linkName: '',
},
],
style: {
backgroundColor: '#FFFFFF',
color: '#333333',
},
},
},
// 魔方
{
component: 'Cube',
options: {
template: 'row-one', // row-one:一行一个 row-two:一行两个 row-four:一行四个 row-col:一大两小
image: [
{
url: '',
linkType: '',
linkName: '',
},
],
imageMargin: 0,
layoutMargin: 0,
},
},
// 定位菜单
{
component: 'PositionMenu',
data: [], // 分组信息
options: {
template: 'tab-style-one', // tab-style-one:样式1 tab-style-two:样式2 tab-style-three:样式3
data: [
{
id: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d',
code: '',
name: '',
menuName: '',
comsize: 6,
},
{
id: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6c',
code: '',
name: '',
menuName: '',
comsize: 6,
},
],
style: {
borderRadius: 'none',
fontWeight: '400',
paddingLeft: '5px',
paddingRight: '5px',
},
listStyle: 'row-one', // row-one:大图模式 row-two:一行两个 row-three:一行三个 row-col:详细列表
commodityStyle: 'no-border', // no-border:无边白底 shadow:卡片投影 stroke:描边白底 transparent:无边透明底
commodityName: true, // 商品名称
commodityDesc: true, // 商品描述
commodityPrice: true, // 商品价格
originalPrice: true, // 划线价格
buyButton: true, // 购买按钮
buyButtonStyle: 'style-1', // 购买按钮样式
buyButtonText: '马上抢', // 购买按钮文字
commoditySubscript: true, // 商品角标
commoditySubscriptStyle: 'new', // 商品角标样式
},
},
// 普通商品
{
component: 'Goods',
data: [], // 商品信息
options: {
template: 'large', // large:大图模式 small:一行两个 three:一行三个 list:详细列表
data: [], // 商品信息
style: {
borderRadius: 'none',
fontWeight: '400',
paddingLeft: '5px',
paddingRight: '5px',
},
listStyle: 'row-one', // row-one:大图模式 row-two:一行两个 row-three:一行三个 row-col:详细列表
commodityStyle: 'no-border', // no-border:无边白底 shadow:卡片投影 stroke:描边白底 transparent:无边透明底
commodityName: true, // 商品名称
commodityDesc: true, // 商品描述
commodityPrice: true, // 商品价格
originalPrice: true, // 划线价格
buyButton: true, // 购买按钮
buyButtonStyle: 'style-1', // 购买按钮样式
buyButtonText: '马上抢', // 购买按钮文字
commoditySubscript: true, // 商品角标
commoditySubscriptStyle: 'new', // 商品角标样式
},
},
// 限时折扣
{
template: 'row-one',
data: [],
style: {
borderRadius: 'none',
fontWeight: '400',
padding: '0',
margin: '0',
},
comsize: 10,
tag: '限时折扣',
commodityStyle: 'no-border',
commodityName: true,
commodityDesc: false,
commodityPrice: true,
originalPrice: true,
lastStock: true,
countdown: true,
progressBar: true,
buyButton: true,
buyButtonStyle: 'style-1',
buyButtonText: '即将开抢',
}
拖拽
拖拽依赖第三方库react-dnd,提供的Hooks Api特别方便,上面的设计结构图 Component组件(DragSource) 和 Preview组件(DropTarget) 用到了拖拽,Preview组件不仅要支持上下拖拽,而且需要配合Compiler组件联动。
代码语言:javascript复制/*
* @description: DragSource 拖动组件
* @version: 分支号 20210629
* @author: xuchao
*/
import React, { useContext } from 'react';
import { useDrag } from 'react-dnd';
import { findIndex, some, isUndefined, filter } from 'lodash';
import { v1 as uuid } from 'uuid';
import { DecorateContext } from '../../utilities';
import schema from '../Materials/schema';
export default ({ component, name, icon, max, componentType, fixedIndex }) => {
const { previewData = [], setState } = useContext(DecorateContext);
const number = filter(previewData, { component }).length;
const [, drag] = useDrag(
() => ({
type: 'component',
options: {
dropEffect: 'copy',
},
item: {
type: 'add',
component,
name,
max,
componentType,
fixedIndex,
},
end: (item, monitor) => {
const hasPh = some(previewData, { component: 'placeholder' });
const phIndex = findIndex(previewData, { component: 'placeholder' });
if (!hasPh) return;
// 组件放置已达上限
if (number === max) {
previewData.splice(phIndex, 1);
setState({ previewData: [...previewData] });
return;
}
if (monitor.didDrop()) {
// 判断拖拽放入Preview组件中,占位元素替换成组件元素
previewData.splice(phIndex, 1, {
id: uuid(),
component: item.component,
options: schema[component].defaultOptions,
});
} else {
// 判断拖拽没有放入Preview组件中,删除占位元素
previewData.splice(phIndex, 1);
}
setState({
previewData: [...previewData],
selectIndex: phIndex,
compiler: item.component,
});
},
}),
[previewData],
);
/**
* @description: 新增组件
* @author: xuchao
*/
const handleClick = () => {
if (number === max) return;
previewData.splice(!isUndefined(fixedIndex) ? fixedIndex : previewData.length, 0, {
id: uuid(),
component,
options: schema[component].defaultOptions,
});
setState({
previewData: [...previewData],
selectIndex: !isUndefined(fixedIndex) ? fixedIndex : previewData.length - 1,
compiler: component,
});
};
return (
<div ref={drag} className="item" onClick={handleClick}>
<i className={icon}></i>
<div className="name">{name}</div>
<div className="number">
{number}/{max}
</div>
</div>
);
};
/*
* @description: DropTarget 放置组件
* @version: 分支号 20210629
* @author: xuchao
*/
import React, { useContext, useCallback } from 'react';
import { useDrop } from 'react-dnd';
import { findIndex, some, isUndefined, filter } from 'lodash';
import update from 'immutability-helper';
import { DecorateContext } from '../../utilities';
import Item from './Item';
export default () => {
const { previewData = [], selectIndex, setState } = useContext(DecorateContext);
const [, drop] = useDrop(
() => ({
accept: 'component',
hover: item => {
const limit = filter(previewData, { component: item.component }).length;
const hasPh = some(previewData, { component: 'placeholder' });
const spliceIndex = !isUndefined(item.fixedIndex)
? item.fixedIndex
: previewData.length;
if (item.type === 'add' && !hasPh) {
// 判断占位符是否已经存在,若悬停空白处,插入占位符
previewData.splice(spliceIndex, 0, {
component: 'placeholder',
limit: item.max === limit ? true : false,
});
setState({ previewData: [...previewData] });
}
},
}),
[previewData],
);
/**
* @description: move callback
* @param {number} dragIndex
* @param {number} hoverIndex
* @param {object} item
* @author: xuchao
*/
const handleMove = useCallback(
(dragIndex, hoverIndex, item) => {
if (item.type === 'add' && !dragIndex) {
// 判断拖拽是 Component 的组件,则 dragIndex 为 undefined,修改占位符的位置即可
const limit = filter(previewData, { component: item.component }).length;
const hasPh = some(previewData, { component: 'placeholder' });
const spliceIndex = !isUndefined(item.fixedIndex) ? item.fixedIndex : hoverIndex;
// 判断占位符是否已经存在,不再重复插入
if (hasPh) {
const phIndex = findIndex(previewData, {
component: 'placeholder',
});
setState({
previewData: update(previewData, {
$splice: [
[phIndex, 1],
[
spliceIndex,
0,
{
component: 'placeholder',
limit: item.max === limit ? true : false,
},
],
],
}),
});
return;
}
setState({
previewData: update(previewData, {
$splice: [
[
spliceIndex,
0,
{
component: 'placeholder',
limit: item.max === limit ? true : false,
},
],
],
}),
});
} else {
// 判断拖拽是 Preview 的组件,则 dragIndex 不为 undefined,替换 dragIndex 和 hoverIndex 位置的元素即可
setState({
previewData: update(previewData, {
$splice: [
[dragIndex, 1],
[hoverIndex, 0, previewData[dragIndex]],
],
}),
selectIndex: dragIndex === selectIndex ? hoverIndex : dragIndex,
});
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[previewData],
);
/**
* description: delete callback
* param {object} event
* param {number} index
* author: xuchao
*/
const handleDelete = (event, index) => {
event.stopPropagation();
previewData.splice(index, 1);
setState({
previewData: [...previewData],
compiler: selectIndex === previewData.length ? undefined : previewData[index].compiler,
});
};
return (
<div ref={drop} className="content">
{previewData.map((item, index) => {
return (
<Item
key={item.id}
index={index}
selectIndex={selectIndex}
{...item}
onClick={() => setState({ selectIndex: index, compiler: item.component })}
onMove={handleMove}
onDelete={handleDelete}
/>
);
})}
</div>
);
};
总结
开发耗费时间比较长的地方是怎么设计与移动端同步数据和拖拽功能,最后还是迎刃而解。如果大家有什么疑问可以交流一下?