一、乱花迷人眼
我就是被迷的那双眼。有时候需求来了,用熟悉的套路进行开发,确实很节省时间也能保证功能的稳定,但是这些开发的惯性无形中阻碍了我对技术的探索。
我一直想改造详情页,解放重复功能开发的劳动力,但是详情页一眼望都是内容平铺,好像并没有什么可做的代码设计。
后来我拨开繁花,发现详情页的组件化不必想的过于复杂,后台系统风格统一即可。因为大部分的详情页面是内容的展示,偶尔会出现少量的操作功能。将风格统一的部分进行组件化处理,操作功能使用回调函数放回当前页面,避免组件里做过多的业务逻辑。看,这不就成了。
项目基于React框架开发的,所以代码写法是JSX语法,组件开发使用的hooks函数式组件,UI框架使用的是antd。
二、欲起高楼,先建地基
开发前进行功能设计是我逐渐养成的一个良好习惯,有时候急于开发,可能漏掉一些设计细节或者功能。这次的详情页设计主要包括四个部分,UI组件、模块划分、数据重组、操作回调。
设计的功能如下:
其中操作回调是为了实现功能性操作按钮的功能,比如取消操作、审核操作、查看等详情页常见的操作按钮。
三、设计实现
我捋了一下现有的业务,除了极个别的详情页设计的比较有自己的风格特点,其他基本都是包括2-n个模块展示数据,部分模块下会有操作按钮,某些模块下的某些数据项会有操作按钮,较长的页面会有快速定位导航等。
所以我会根据功能的复杂度递增,逐步的实现这个详情页UI组件。
注:前面功能实现我主要放关键代码,会把完整代码放在文章的末尾。
3.1 基础款详情页
纯展示,根据接口返回的字段,重组数据,之所以用重组 数据的方式是因为某些数据需要特殊处理,比如时间数据,需要将时间戳转成日期格式;枚举数据,需要将返回值展示为具体文字。
3.1.1 模块划分
假设当前详情页有四个模块:用户信息、订单信息、快递信息、支付信息。四个模块内容展示有相似有不同,但是依旧可以把展示方式分成两种:一排两个的平铺展示和Table表格展示。
模块划分完成之后,页面呈现在脑海中也有了大致的结构。第一个明确的设计点也就有了,既然模块展示具有相似性。我就可以把UI渲染设计成数组循环的方式。对于不同的展示方式,可以根据模块的key值去区分定义展示类型。
详情页
- 根据模块的划分,定义dataList数组对象,后续页面渲染是使用dataList进行渲染的;
- 设置contentType-展示形式分类变量,其值为row-平铺,table-表格。会根据contentType将模块展示成不同的形式;
- 订单列表因为是Table格式,它的表格列的配置描述维护在常量管理文件中;
/*** @description 详情页*/importReact, { useState, useEffect } from'react'; import { ORDER_COLUMNS } from'@/constants/detailBase'; constDetailBase= () => { /** @name 页面内容数组对象 */letdataListInit= [ { key: 'userInfo', // 模块key值name: '用户信息', //模块标题 }, { key: 'orderList', name: '订单信息', columns: _.cloneDeep(ORDER_COLUMNS), }, { key: 'postInfo', name: '快递信息', }, { key: 'payInfo', name: '支付信息', }, ]; // 列表数据重组dataListInit.map(item=> { item.list= []; // 模块展示内容数组item.contentType='row'; // 展示形式类型 row-平铺 table-表格// =>true: 订单信息 展示为表格if (item.key==='orderList') { item.contentType='table'; } }); let [dataList, setDataList] =useState(dataListInit); return<></>;}; exportdefaultDetailBase;
常量文件
对于常量管理,一般会放到常量文件中。(具体内容可以查看3.3.3,为了节省空间此处不再具体列出)
3.1.2 数据重组
- 请求详情数据,获得返回值。一般返回值都是嵌套对象的格式,所以可以将返回值的对象key值和设置的dataList中key一一对应;
- 根据模块设置模块的list值,最终页面渲染使用的是每个模块的list对象。contentType类型为table时,可以直接将返回值赋值给该模块的list变量;contentType类型为row时,需要进行数据的重组。(注:之所以需要重组数据是因为要特殊处理时间戳、枚举值等特殊返回值,比如时间戳要展示为日期格式,枚举值根据返回值展示文字描述等);
/** * @description 详情页 */importReact, { useState, useEffect } from'react'; constDetailBase= () => { /** * 用户信息-展示数据重组 * @param {Object} data 需要获取的项的对象 * @return {Object} 获得的值 */constgetUserData=data=> { letlist= [ { name: '姓名', value: data.name, }, { name: '年龄', value: data.age, }, { name: '电话', value: data.phone, }, { name: '收货地址', value: data.address, }, ]; returnlist; }; /** * 快递信息-展示数据重组 * @param {Object} data 需要获取的项的对象 * @return {Object} 获得的值 */constgetPostData=data=> { letlist= [ { name: '付款单号', value: data.postNum, }, { name: '付款公司', value: data.postName, }, ]; returnlist; }; /** * 支付信息-展示数据重组 * @param {Object} data 需要获取的项的对象 * @return {Object} 获得的值 */constgetPayData=data=> { letlist= [ { name: '付款时间', value: data.payAt?moment(data.payAt).format('YYYY-MM-DD HH:mm:ss') : '', }, { name: '付款金额', value: data.payMoney, }, { name: '操作时间', value: data.payOperateAt?moment(data.payOperateAt).format('YYYY-MM-DD HH:mm:ss') : '', }, ]; returnlist; }; /** * 获取列表项的实际值 * @param {Object} item 需要获取的项的对象 * @param {Object} res 接口请求数据 * @return {Object} 获得的值 */constgetItemList= (item, data) => { letobj= { userInfo: getUserData(data), postInfo: getPostData(data), payInfo: getPayData(data), }; returnobj[item.key]; }; /** * 初始化数据 */constinitData= () => { // 请求接口获取返回值letres= { userInfo: { name: '张三', age: 30, phone: '12345678912', address: '北京市朝阳区', }, payInfo: { payAt: 1641039600000, payMoney: 999, payOperateAt: 1641038400000, }, orderList: [ { name: '跑鞋·追光者', color: '白色', creatAt: 1641038400000, payAt: 1641039600000, haveFreight: 1, }, { name: '运动裤·逐梦', color: '黑色', creatAt: 1641038400000, payAt: 1641039600000, haveFreight: 1, }, { name: '外套·闪光者', color: '蓝色', creatAt: 1641038400000, payAt: 1641039600000, haveFreight: 1, }, ], postInfo: { postName: '顺丰', postNum: '1111', }, }; letlist=_.cloneDeep(dataListInit); // 数据重置list.map(item=> { if (item.contentType==='table') { item.list=res[item.key]; } else { letdata=res[item.key]; item.list=getItemList(item, data); } }); setDataList(list); }; useEffect(() => { initData(); }, []); return<></>;}; exportdefaultDetailBase;
3.1.3 详情组件
因为是根据业务进行的功能设计,所以我把详情组件放到了业务组件下面。
- 模块的展示,使用antd提供的Card卡片组件进行页面布局;Card卡片官网地址;
- row平布类型的展示,使用antd提供的Row、Col栅格组件进行页面布局;Row、Col栅格组件官网地址;
- table类型的展示,使用ante提供的Table组件进行页面布局;Table组件官网地址;
- 组件通信,props传参为dataList数据数组对象;
- 注:像边距mt/mb之类的样式设置,我们的项目里面是定义的全局样式,直接使用的。
/** * @description 公共业务组件-详情 */importReactfrom'react'; importPropTypesfrom'prop-types'; import { Card, Row, Col, Table } from'antd'; constCommonDetailBase= ({ props }) => { const { dataList } =props; /** * row类页面内容回显 * @param {Object} data 展示内容对象 * @return {Element} 展示内容 */constdataRowContent=data=> { constlist=data.list?data.list : []; return ( <> {list.map((rowItem, rowIndex) => { return ( <Colspan={12} key={rowIndex}><Cardsize='small'><div> {rowItem.name}:{rowItem.value} </div></Card></Col> ); })} </> ); }; /** * Table类页面内容回显 * @param {Object} item 展示内容对象 * @return {Node} 展示内容 */constdataTableContent=item=> { letlist=item.list?item.list : []; return<TabledataSource={list} columns={item.columns} rowKey={record=>record.id} pagination={false} size='small'/>; }; return ( <div><divclassName='view-content'> {dataList.map(item=> { return ( <Cardtype='inner'title={item.name} id={item.key} key={item.key} className='mb20'> {item.contentType==='row'?<Rowgutter={[12, 12]}>{dataRowContent(item)}</Row> : null} {item.contentType==='table'?dataTableContent(item) : null} {item.moduleBottomName? ( <Buttontype='primary'onClick={() =>item.moduleBottomView(item)} className='mt20'> {item.moduleBottomName} </Button> ) : null} </Card> ); })} </div></div> ); }; CommonDetailBase.propTypes= { dataList: PropTypes.array, // 页面展示数组对象}; CommonDetailBase.defaultProps= { dataList: [], }; exportdefaultCommonDetailBase;
页面使用
/** * @description 详情页 */importReact, { useState, useEffect } from'react'; // 引入组件import { CommonDetailBase } from'@/bundleComponents'; constDetailBase= () => { return<CommonDetailBasedataList={dataList} />; }; exportdefaultDetailBase;
3.2 升级款详情页
所谓升级款,即在原来的基础上功能更丰富。比如我们的业务需求,模块下面会跟着操作按钮,页面底部会有操作按钮,页面带导航条。以及如果我们想组件功能更强,需要支持的情况更多,可以支持某个模块自定义展示。这个时候需要在原来的基础上进行功能扩展.
3.2.1 详情组件
详情组件已开发好了,新增功能只需要在原来的基础上新增代码逻辑即可
- 导航条,使用antd提供的Affix固钉组件,Affix固钉官网地址;
- affixTabs:导航条数据对象,数组类型
- afffixIndex:当前选中导航变量,字符串类型
- 模块可以使用自定义展示,在模块代码中加入children变量的判断,如果存在,则展示children内容,如果不存在,则按照组件中的展示;
- 模块底部可以添加操作按钮,支持按钮组,根据moduleBottomList数组变量的值判断,如果有值,则循环展示按钮组,如果不存在,则不展示;
- 数据项可以使用自定义展示,在数据项代码中加入children变量的判断,如果存在,则展示children内容,如果不存在,则按照组件中的展示;
- 数据项左侧可以添加操作按钮,支持按钮组,根据colBtnList数组变量的值判断,如果有值,则循环展示按钮组,如果不存在,则不展示;
/** * @description 公共业务组件-详情 */importReact, { useState } from'react'; constCommonDetailBase= ({ props }) => { const { dataList, affixTabs } =props; /** @name 当前所在导航index值 */const [afffixIndex, setAfffixIndex] =useState(props.afffixIndex); /** @name 是否展示导航 */consthasAffixTabs=!!affixTabs; /** * 快速定位方法 * @param {string} id 定位到的id值 * @return {void} 无 */constfastGo=id=> { letelement=document.getElementById(id); letview=document.querySelector('.view'); view.scrollTo({ top: element.offsetTop-90, }); }; /** * 右侧锚点导航-切换 * @param {Object} item 选择的导航标签 * @return {void} 无 */consttabChange=item=> { setAfffixIndex(item.key); fastGo(item.key); }; /** * row类页面内容回显 * @param {Object} data 展示内容对象 * @return {Element} 展示内容 */constdataRowContent=data=> { constlist=data.list?data.list : []; return ( <> {list.map((rowItem, rowIndex) => { return ( <Colspan={12} key={rowIndex}><Cardsize='small'> {rowItem.children? ( <>{rowItem.children}</> ) : ( <div> {rowItem.name}:{rowItem.value} {rowItem.colBtnList&&rowItem.colBtnList.map((colBtnItem, colBtnIndex) => { return ( <ButtonclassName='ml10'type='primary'onClick={() =>colBtnItem.colBtnCallback(colBtnItem, rowItem)} key={colBtnIndex}> {colBtnItem.name} </Button> ); })} </div> )} </Card></Col> ); })} </> ); }; /** * Table类页面内容回显 * @param {Object} item 展示内容对象 * @return {Node} 展示内容 */constdataTableContent=item=> { letlist=item.list?item.list : []; return<TabledataSource={list} columns={item.columns} rowKey={record=>record.id} pagination={false} size='small'/>; }; return ( <divclassName={style['detail-base']}><divclassName='view-content'id='view'> {dataList.map(item=> { return ( <Cardtype='inner'title={item.name} id={item.key} key={item.key} className='mb20'> {item.children? ( <>{item.children}</> ) : ( <> {item.contentType==='row'?<Rowgutter={[12, 12]}>{dataRowContent(item)}</Row> : null} {item.contentType==='table'?dataTableContent(item) : null} </> )} {item.moduleBottomList&&item.moduleBottomList.map((moduleBtnItem, moduleBtnIndex) => { return ( <ButtonclassName='mr10 mt20'type='primary'onClick={() =>moduleBtnItem.moduleBtnCallback(moduleBtnItem, item)} key={moduleBtnIndex}> {moduleBtnItem.name} </Button> ); })} </Card> ); })} {/* 右侧锚点导航 */} {hasAffixTabs? ( <AffixoffsetTop={120} className='sider-affix'><ulclassName='affix'> {affixTabs.map((item, index) => ( <likey={index}><divclassName={classnames('affix-item', { current: afffixIndex===item.key })} onClick={() =>tabChange(item)}> {item.name} </div></li> ))} </ul></Affix> ) : null} </div></div> ); }; CommonDetailBase.propTypes= { dataList: PropTypes.array, // 页面展示数组对象affixTabs: PropTypes.array, // 导航数组对象afffixIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), // 导航默认定位}; CommonDetailBase.defaultProps= { dataList: [], }; exportdefaultCommonDetailBase;
3.2.2 导航条
- 导航条数据可以直接使用页面列表数据,因为定位的key和页面列表的key值做了一致性的处理;
- 通过设置afffixIndex的值,可以控制当前导航固定的位置;
- 当子组件的props传参比较复杂的时候,可以设置一个config对象,比如detailConfig包含了所有props,子组件使用时直接用...(扩展运算符)进行展开;
/** * @description 详情页 */importReact, { useState, useEffect } from'react'; import { CommonDetailBase } from'@/bundleComponents'; constDetailBase= () => { /** @name 页面导航 */constaffixTabs= []; // 列表数据重组dataListInit.map(item=> { // 设置导航条数据affixTabs.push(item); }); /** @name 详情组件配置项 */constdetailConfig= { afffixIndex: 'userInfo', affixTabs: affixTabs, dataList: dataList, }; return ( <div><CommonDetailBase {detailConfig} /></div> ); }; exportdefaultDetailBase;
3.2.3 模块下的操作按钮
- moduleBottomList:模块下的按钮组数组变量,控制操作按钮组是否展示,当它有值时按钮展示,没值时按钮不展示;
- moduleBottomCallback:操作按钮元素的操作回调函数,可以做一些操作处理;
/** * @description 详情页 */importReact, { useState, useEffect } from'react'; constDetailBase= () => { letdataListInit= [ { key: 'postInfo', name: '快递信息', }, ]; constinitData= () => { letlist=_.cloneDeep(dataListInit); list.map(item=> { // =>true: 快递信息 表格项处理if (item.key==='postInfo') { item.moduleBottomList= [ { name: '快递详情', moduleBtnCallback: (_, data) =>moduleBottomCallback(data, res), }, { name: '快递详情2', moduleBtnCallback: (_, data) =>moduleBottomCallback(data, res), }, ]; } }) } }; exportdefaultDetailBase;
3.2.4 数据项的操作按钮
- colBtnList:数据项的操作按钮组,控制操作按钮是否展示,当它有值时按钮展示,没值时按钮不展示;
- colBtnCallback:操作按钮元素的操作回调函数,可以做一些操作处理;
/** * @description 详情页 */importReact, { useState, useEffect } from'react'; constDetailBase= () => { constgetUserData=data=> { letlist= [ { name: '收货地址', value: data.address, colBtnList: [ { name: '地址详情', colBtnCallback: () => { window.open('https://juejin.cn/', '_blank'); }, }, ], }, . ]; returnlist; }; }; exportdefaultDetailBase;
3.2.5 模块为自定义展示
将需要自定义展示的模块对象的children值设置为需要展示的内容即可
/** * @description 详情页 */importReact, { useState, useEffect } from'react'; constDetailBase= () => { /** * 支付模块展示 * @param {Object} dafa 展示的数据对象 */constgetPayInfo=data=> { return ( <Rowgutter={[8, 8]}> {data.list.map((item, index) => { return ( <Colspan={24} key={index}> {item.name}:{item.value} </Col> ); })} </Row> ); }; constinitData= () => { letlist=_.cloneDeep(dataListInit); list.map(item=> { // =>true: 支付信息 自定义展示if (item.key==='payInfo') { item.children=getPayInfo(item); } }); setDataList(list); }; }; exportdefaultDetailBase;
3.2.6 数据项为自定义展示
将需要自定义展示的模块下的数据项对象的children值设置为需要展示的内容即可
/** * @description 详情页 */importReact, { useState, useEffect } from'react'; constDetailBase= () => { /** * 图片类型展示 * @param {Object} data 展示的数据对象 */constgetImageView=data=> { return ( <>头像:<Buttontype="link">编辑</Button><Rowgutter={(12, 12)}><Colspan={4}><imgstyle={{ width: '80px', height: '80px', display: 'block' }} src={data.headImage} /></Col></Row></> ); }; constgetUserData=data=> { letlist= [ { name: '头像', children: getImageView(data), }, ]; returnlist; }; }; exportdefaultDetailBase;
以上,一个功能相对全面的详情页组件就完成了。
3.3 完整代码
3.3.1 详情页组件
/** * @description 公共业务组件-详情 */importReact, { useState } from'react'; importPropTypesfrom'prop-types'; importclassnamesfrom'classnames'; import { Button, Card, Row, Col, Table, Affix } from'antd'; importstylefrom'./style'; constCommonDetailBase= ({ props }) => { const { dataList, affixTabs } =props; /** @name 当前所在导航index值 */const [afffixIndex, setAfffixIndex] =useState(props.afffixIndex); /** @name 是否展示导航 */consthasAffixTabs=!!affixTabs; /** * 快速定位方法 * @param {string} id 定位到的id值 * @return {void} 无 */constfastGo=id=> { letelement=document.getElementById(id); letview=document.querySelector('.view'); view.scrollTo({ top: element.offsetTop-90, }); }; /** * 右侧锚点导航-切换 * @param {Object} item 选择的导航标签 * @return {void} 无 */consttabChange=item=> { setAfffixIndex(item.key); fastGo(item.key); }; /** * row类页面内容回显 * @param {Object} data 展示内容对象 * @return {Element} 展示内容 */constdataRowContent=data=> { constlist=data.list?data.list : []; return ( <> {list.map((rowItem, rowIndex) => { return ( <Colspan={12} key={rowIndex}><Cardsize='small'> {rowItem.children? ( <>{rowItem.children}</> ) : ( <div> {rowItem.name}:{rowItem.value} {rowItem.colBtnList&&rowItem.colBtnList.map((colBtnItem, colBtnIndex) => { return ( <ButtonclassName='ml10'type='primary'onClick={() =>colBtnItem.colBtnCallback(colBtnItem, rowItem)} key={colBtnIndex}> {colBtnItem.name} </Button> ); })} </div> )} </Card></Col> ); })} </> ); }; /** * Table类页面内容回显 * @param {Object} item 展示内容对象 * @return {Node} 展示内容 */constdataTableContent=item=> { letlist=item.list?item.list : []; return<TabledataSource={list} columns={item.columns} rowKey={record=>record.id} pagination={false} size='small'/>; }; return ( <divclassName={style['detail-base']}><divclassName='view-content'id='view'> {dataList.map(item=> { return ( <Cardtype='inner'title={item.name} id={item.key} key={item.key} className='mb20'> {item.children? ( <>{item.children}</> ) : ( <> {item.contentType==='row'?<Rowgutter={[12, 12]}>{dataRowContent(item)}</Row> : null} {item.contentType==='table'?dataTableContent(item) : null} </> )} {item.moduleBottomList&&item.moduleBottomList.map((moduleBtnItem, moduleBtnIndex) => { return ( <ButtonclassName='mr10 mt20'type='primary'onClick={() =>moduleBtnItem.moduleBtnCallback(moduleBtnItem, item)} key={moduleBtnIndex}> {moduleBtnItem.name} </Button> ); })} </Card> ); })} {/* 右侧锚点导航 */} {hasAffixTabs? ( <AffixoffsetTop={120} className='sider-affix'><ulclassName='affix'> {affixTabs.map((item, index) => ( <likey={index}><divclassName={classnames('affix-item', { current: afffixIndex===item.key })} onClick={() =>tabChange(item)}> {item.name} </div></li> ))} </ul></Affix> ) : null} </div></div> ); }; CommonDetailBase.propTypes= { dataList: PropTypes.array, // 页面展示数组对象affixTabs: PropTypes.array, // 导航数组对象afffixIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), // 导航默认定位}; CommonDetailBase.defaultProps= { dataList: [], }; exportdefaultCommonDetailBase;
3.3.2 完整UI
四、总结
对后台系统代码简洁之路,仍在探索中,后续想实现列表页的操作表单项的设计,这样后台系统的基础的页面能快速完成搭建。
当然了,更好的方式是搭建低代码平台,但是现有的开发精力并不能支撑完成这种复杂的开发。所以先从基础出发,逐步升级自己的技能。