可視化搭建移動端店鋪解決方案
原文地址:https://juejin.cn/post/6979410699453726727 文章已經過作者許可轉載
關注并將「趣談前端」設為星標
每早08:30按時推送技術干貨/優(yōu)秀開源/技術思維
前言
經過許久的深思熟慮與探索,同時也借鑒了行業(yè)內不錯的產品(如:有贊,H5-Dooring等),但跟列舉的產品還是有區(qū)別的(先賣個關子,后面再講有哪些區(qū)別)。其實這種功能在零售系統(tǒng)(目前我所在公司是零售行業(yè)的領頭羊)和電商系統(tǒng)應該很常見,很多應用場景都會用到,像產品營銷頁面、企業(yè)/個人微官網、H5活動頁面等移動端頁面,通過可視化配置快速搭建H5頁面,且提供豐富的頁面組件,更方便的為使用者搭建更強大的H5頁面。
PC端界面如下:

移動端(H5和小程序)界面如下:


技術方案
PC端 React 技術棧,移動端 UniApp 跨平臺框架,功能的設計結構圖如下:

/*
* @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">
發(fā)布
</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>
);
}
}
數據
前面說到與列舉的產品有哪些區(qū)別,區(qū)別在于PC端與移動端的數據交互,它們都是通過 iframe 嵌套 H5 的頁面,通過 postmessage API 來做數據交互,而是我沒有這樣做,原因是項目特別緊,加上人員分配問題,所以采用數據定義模式。
通過上面的設計結構圖可以看出PC端最后會生成一份 schema 數據存儲服務端,移動端從服務端獲取到 schema 數據進行解析。數據格式如下:
// 圖片廣告
{
component: 'ImageTextAd',
options: {
template: 'image', // image:一行一個 carousel:輪播海報 slide:大圖橫向滑動 zone:繪制熱區(qū)
image: [
{
id: '',
url: '',
title: '',
linkCode: '',
linkName: '',
// 熱區(qū)
zones: [
{
x: 178,
y: 91,
width: 158,
height: 132,
code: '123',
text: '測試鏈接2',
}
],
},
{
id: '',
url: '',
title: '',
linkCode: '',
linkName: '',
// 熱區(qū)
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組件聯(lián)動。
/*
* @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) {
// 判斷占位符是否已經存在,若懸??瞻滋帲迦胝嘉环?br> 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>
);
};
總結
開發(fā)耗費時間比較長的地方是怎么設計與移動端同步數據和拖拽功能,最后還是迎刃而解。如果大家有什么疑問可以交流一下??
?? 看完三件事
如果你覺得這篇內容對你挺有啟發(fā),我想邀請你幫我三個小忙:
點個【在看】,或者分享轉發(fā),讓更多的人也能看到這篇內容
關注公眾號【趣談前端】,定期分享 工程化 / 可視化 / 低代碼 / 優(yōu)秀開源。

基于Koa + React + TS從零開發(fā)全棧文檔編輯器(進階實戰(zhàn))
點個在看你最好看

