本篇是总结一个旧项目,项目在 2019 年 Q3 开发并上线运营,经历两个月迭代后目前已交接给其他团队维护,整理个人草稿箱发现这边还没有完成的总结,补充一些内容更新至此,内容应该是顺着回想起细节,后面再补充。

关键词:中后台系统、从零到壹

项目背景

交易指挥中心是中台基础基础组件化向智能基础组件化升级的战略项目,同时驱动中台核心业务中心的平台化搭建。一期建成优惠监控、库存、订单、商品四大指挥中心系统以及门户,实现中台系统的从零到一的系统建设。

对于我们的前端团队意义在于,积累面向 B 端中后台系统开发经验,沉淀中后组件,配合中后台系统可视化构建平台完成。

技术选型

  • React + TypeScript + MobX
  • 构建工具 jdwcli(webpack + koa dev server)
  • UI(LEGAO React + 部分 Ant Design)
  • 图表框架 HightCharts

部分方案设计

菜单路由

由于中后台系统,页面功能及数据信息需要一定的访问权限,除了在数据返回之前控制以外,前端展示的权限菜单控制就很重要。

权限控制与菜单

动态菜单及路由

中后台系统其页面功能及数据信息比较敏感,在面向 C 端用户的基础上,只要控制当前基于数据安全方面的考虑,页面的访问控制非常重要。首先,在服务端接口数据保证权限校验的前提下,用户在前台访问页面所看到的菜单也需要进行权限控制。

出于上面的考虑,针对当前系统的菜单设计时可以前端架构采用动态菜单设计。用户有权限访问到的菜单通过权限接口返回,再由前端渲染。(这里没有将权限限制逻辑放在前台有两点考虑:1、HTTP 接口容易被抓去伪造,非权限菜单容易暴露;2、当前系统是基于 ERP 等方式单点登录,集成了 ERP 系统的角色控制,前台和后台没有必要做重复的权限判断逻辑;)

笔者曾在一家专注线上协同办公(OA)的软件公司,负责过一段组织模型与菜单角色模块,经历过基于 RBAC 模式的控制权限改造,从数据库设计、服务开发以及到前台实现。

前端这里要做的就是,需要同时支持两套方案

  • 第一类:前端固定路由信息(针对不敏感菜单)
  • 第二类:通过数据接口按照权限获取相关路由

上面提到的两种方案的实现,遵循统一的一套数据结构就可以实现。

实现上面的设计,依赖于 jdwcli 创建的项目模板中页面工厂 PageFactory这个类,即采用工厂模式将菜单数据缓存,通过 React.lazy 实现动态引用,这样即达到分包的特点减小初始包的大小,又可以实现菜单控制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
[
{
"key": "home",
"page": "home",
"path": "/home",
"show": true,
"title": "首页"
},
{
"key": "xxxyyy",
"page": "xxxyyy",
"path": "/xxxyyy",
"show": true,
"title": "XXXYYY一级菜单",
"children": [
{
"key": "yyy",
"page": "yyy",
"path": "/yyy",
"show": true,
"title": "yyy系统介绍"
},
{
"iframe": true,
"iframesrc": "//xxx.yyy.com",
"key": "xxxIndex",
"page": "xxxIndex",
"path": "/xxxIndex",
"show": true,
"title": "xxx首页"
}
]
}
]
迁移旧系统和页面

零售中台经过多年的沉淀,内部有很多的功能系统存在,在开发初期就基本确认无法通过段时间内一一重新开发实现,迁移旧页面这个功能也需要被考虑在内。

目前比较合适的方案有两种:

  1. 采用 IFRAME 内嵌;
  2. 通过菜单或链接跳转离开;

那么为了满足这两种方案,在前面设计菜单的同时,在数据结构中就要增加针对内嵌和跳转离开的标示,考虑前端路由当前页面是一个 IFRAME 内嵌系统,还是一个需要点击跳转离开的菜单。

完成这点以后,要做的就是开发一个统一接受 IFRAME 链接的 Component 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@withRouter
export default class IFrameCont extends React.Component<IFrameContProps> {
render() {
const { iframeSrc } = this.props;
if (!iframeSrc) return null;
return (
<>
<iframe
title={iframeSrc}
src={iframeSrc}
width="100%"
height="100%"
></iframe>
</>
);
}
}

BUT,上面的实现,我们似乎忽略了一个问题,我们只考虑到了用户点击菜单路由跳转过来的页面,由于我们使用的是 HASH 路由,并没有考虑如果用户在当前页面刷新或通过页面 URL 直接访问指定路由时,当前 Component 无法拿到 src 的 props 的。这里我采取的办法是:同时将 Store 中菜单信息监听到当前组件中,与当前页面 URL 中的 pathname 进行一次匹配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
+ @inject((stores: GlobalStores) => ({
+ menu: stores[PAGE_STORE].menu
+ }))
+ @observer
@withRouter
export default class IFrameCont extends React.Component<IFrameContProps> {
render() {
let iframeSrc = "";
+ if (!this.props.location.state) {
+ try {
+ iframeSrc = Array.from(this.getAllMenus(this.props.menu)).filter(menuItem => {
+ return menuItem.path === this.props.location.pathname;
+ })[0].iframesrc;
+ } catch (error) {
+ console.error("error iframe src");
+ return null;
+ }
+ } else {
+ const { menu } = this.props.location.state;
+ iframeSrc = menu.iframesrc;
+ }

if (!iframeSrc) {
return null;
}
return (
<>
<iframe title={`${Math.random()}`} src={iframeSrc} width="100%" height="100%"></iframe>
</>
);
}

+ private getAllMenus(pagedata: PageStore.Domain.PageType[], map?: Set<PageType>) {
+ return pagedata.reduce(
+ (prev, next) => {
+ prev.add(next);
+ if (next.children && next.children.length > 0) {
+ this.getAllMenus(next.children, prev);
+ }
+ return prev;
+ },
+ map ? map : new Set<PageType>()
+ );
+ }
}

数据可视化

中后台系统中,许多数据需要具有更丰富的展现形式,那么图与表的结合就是比较好的方案。

本项目中图采用 HighCharts 作为图库,考虑有两个原因:

  1. 有以往使用的经验,项目紧急时期可以快速实现,并且公司内拥有采购 HighCharts 的授权(Wiki 中翻到了历史采购清单文件);
  2. 对比同类型 BizCharts、ECharts、Chartjs、D3.js 等其它图表库,HighCharts 具备官方文档丰富且详细、DEMO 实例丰富、图表种类多,页面渲染依靠 SVG 效率较高、拥有官方支持 React 版本(highcharts-react-official)、可快速产品原型中的图例、行业老大位置等特点;

这是一篇迟来的总结文章,其实如果时间允许,可以选择的方案还有很多,今天还刚刚看到 Airbnb 团队公布开源了一个新的可视化组件库。https://airbnb.io/visx/

内部实现的有 双轴图、堆叠图、百分比堆叠图、饼图、坐标系图(散点图的延伸)实现细节以及部分问题总结详见另外一篇撰写中总结。

表单实现

对于中后台,健壮表单功能应该是必不可少的一环,由于当前项目都是比较简单的表单,目前的项目经验,大致分为两种:

  • 查询,带有复杂条件的关联查询,后面跟随查询结果
  • 传统 Form 提交,用户填充数据提交

这里提一个问题:当你看到 Form 表单会联想到什么?下面是目前我能想到的内容:

  • 输入框校验问题(正则、服务端校验、输入转义防止注入攻击)
  • 当前表单的状态保存,以及重置(关于 Store 的控制)
  • 交互体验(输入和可选框之间的联动,提交重置按钮出现的位置等等)
  • 表单的某一条或某些条目需要作为动态内容可以添加、删除和修改

那么这些表单能否有通过可视化拖拽,自动生成吗?答案是肯定的,同组其他的同事就在调研实现这个问题,找机会深入讨论一下。

性能优化点

级联组件性能

优惠指挥中心系统中价格力系数查询页面,品牌级联,级联菜单由于存在一个 3K 左右个的数据由后端一次性返回,需要前端在前台把数据组装成树接口分级,再传递到级联组件中,这对于浏览器中内存计算耗时,以及组件初始化大量数据的性能都造成很大影响,在开发初期没有发现,Mock 数据只有几十个。

解决方案:就要和后端商量将查询接口调整为分级查询,逐级进行查询,避免一次数量过大。这里还可以在继续深入优化的点,就是当前二级或三级级联数据被 load 过一次以后,前端缓存在当前页面内,鼠标划回父级数据时直接拿缓存数据。(当然,缓存永远是一把双刃剑,要考虑缓存什么时间失效,什么时间生效就要具体问题具体分析。)

缓存

又是一个经常被讨论到的问题,各种缓存策略,网络上可以找到很多优秀详细的文章,这里简单介绍一下本项目使用的一些缓存策略。

  1. 将工具、UI 库等比如 React、React-DOM、MobX,HighCharts 等,按需在其页面内静态引入,并且提前上线到 CDN ,将固定引用链接利用客户端缓存,不在更新;
  2. 页面 Nginx 响应头设置缓存时间;
  3. 配合后端检查,固定类变化特别小的数据请求,可以适当增大缓存时间,同时缓存到客户端本地;

开发环境与生产环境隔离

环境的隔离,应该是工程化中比较简单且常见的问题,在从零搭建系统过程中,也不免存在这个问题,我自己的解决方案是,利用头尾系统(内部系统)建立两套文件,分别对应预发测试和正式环境。预发测试环境需要经常更新,缓存时间设置也非常短,且请求的也是预发测试接口,这在上线前需要及时更换。

这点在项目构建打包时,可以根据设置环境来进行操作。

比如:利用 Babel 编译中移除项目内冗余的 console.log,线上仅保留 error 或者其他,那就可以在项目中的 .babelrc 进行如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"env": {
"dev": {
"plugins": [
[
"transform-remove-console",
{ "exclude": ["log", "debug", "error", "warn"] }
]
]
},
"prod": {
"plugins": [["transform-remove-console", { "exclude": ["error"] }]]
}
}
}

这样在 npm script 中配置 BABEL_ENV=dev xxxx 依次类推可以根据需要配置,对打包进行区分。

注意: babelrc 中的 dev 是为了举例说明,配置成 development 和 production 当然最好,因为默认会读 NODE_ENV 环境变量的配置。

多人协作

这里不提太多,总结了一个方便的项目模板,利用 VSCode 或者 WebStorm 自身来实现保存时自动按照 Prettier + ESLint -fix 来格式化和自动修复部分代码,同时还可以结合 husky 在提交之前再进行一次格式化,可以一定程度地约束项目代码。

Git 提交 message ,可以利用 commitizen + cz-conventional-changelog 来呈现更统一的提交信息。

小结

中后台系统具有很强的业务属性,只要找到其中规律,可以抽象沉淀出一些通用的业务组件,这样一来可以为可视化搭建中后台系统积累物料,二来对于仅有的统一风格的 UI 组件库来的更有意义。