Sapper

2021年04月28日

市域社会治理中台项目架构


项目技术栈

  • 框架:React
  • 状态管理:Dva + React Redux
  • React Hook 库:react-use
  • UI 组件库:antd 、@share/shareui 、@share/list 、 @share/shareui-form、@share/pro-table
  • 网络请求库:@share/network

目录结构

|- bin/                   命令行程序
|- public/                公共资源
|   |+ browserUpgrade/    提示浏览器升级页面
|   |- favicon.ico
|
|- src/
|   |+ assets/            存放资源文件,字体文件、图片
|   |+ components/        存放公共组件
|   |+ config/            存放公共配置文件
|   |+ layouts/           存放布局组件
|   |+ models/            存放 dva 定义文件
|   |+ pages/             存放页面
|   |+ services/          存放接口请求方法定义文件
|   |+ styles/            存放公共样式文件
|   |+ utils/             存放公共工具类
|   |- globals.d.ts       全局类型定义文件
|
|- templates/             存放用于创建文件的模板
|   |+ components/        组件模板
|   |+ model/             dva 定义文件模板
|   |+ mpa/               多页应用文件夹模板
|   |+ page/              页面文件模板
|
|- .editorconfig          给编译器识别的配置文件
|- .eslintignore          ESLint 忽略文件配置
|- .eslintrc              ESLint 配置
|- .gitignore             git 忽略文件配置
|- .npmrc                 npm 环境变量配置
|- application.js         项目构建配置
|- jsconfig.json          JS 配置
|- package.json
|- plopfile.js            plop 配置
|- README.md
|- webpack.config.js
|- yarn.lock

页面结构

页面文件结构

|- pages/
     |+ 单页应用1/
     |+ 单页应用2/
     |- 单页应用3
          |- routes/
          |    |+ 页面1/
          |    |+ 页面2/
          |    |+ 页面3/
          |- index.ejs
          |- index.js

整个项目采用多个单页应用的形式开发,所有应用都的页面都放在 pages 目录里,pages 下存放着一个个单页应用,每个单页应用之间互不影响,技术栈可以不一样。

单页应用至少包含两个文件:

  • index.js 单页应用入口文件
  • index.ejs 单页应用 html 文件

如果使用路由,页面组件都放在 routes 目录下。

而页面组件的结果如下:

|- 页面1/
    |- Home.js
    |- Home.css
    |- index.js
    |- README.md

除了基本的组件结构,还需加上 README.md 文件,文件内容例如:

## 首页

### 访问地址

http://localhost:4000/social-governance/#/

### 原型地址

http://192.168.0.235:8080/ZZPT/08RPPublish/%E5%B8%82%E5%9F%9F%E7%A4%BE%E4%BC%9A%E6%B2%BB%E7%90%86%E4%B8%AD%E5%8F%B0/%E9%97%A8%E6%88%B7%E7%BD%91%E7%AB%99/index.html#id=tuf5w8&p=%E9%A6%96%E9%A1%B5&g=1

描述一些基本信息:这是什么页面?访问路径是怎样的?原型地址在哪?

结构拆分原则

结构拆分就是将页面文件分散放到多个单页应用里。

不拆分的话会导致整个应用太大,而拆分过细又会导致开发体验和用户交互体验下降,所以需要定个拆分原则。

通常系统都有分主业务和分支业务:

  • 主业务页面是系统主要功能,页面直接可能存在频繁交互的。
  • 分支业务就是一些相对比较独立的业务,与主要功能很少交互的。

实际项目页面结构:

|- pages/
     |+ 89c5e1a8b94841729a8be7e17fdf6e6a-test-login/
     |+ 404/
     |- index/
     |   |- routes
     |   |    |- Home/
     |   |    |   |- Home.js
     |   |    |   |- Home.scss
     |   |    |   |- index.js
     |   |    |   |- README.md
     |   |    ...
     |   |
     |   |- index.ejs
     |   |- index.js
     |
     |+ login/

这边拆分为四个单页应用,从上到下依次是:模拟登录、404页、首页、登录页,其中模拟登录名字那么长是防止用户猜到地址而导致非法登录。

主业务放在 index 里,其他都是分支业务。

  • 模拟登录用户不会访问的;
  • 404 是地址输错才展示,正常不会访问到;
  • 登录页只会访问一次,而且与主业务没有数据上的交互,相对比较独立的功能块。

这系统业务比较单一,拆分的比较简单,业务如果比较多元化的话就会拆得比较细。就比如数据开放平台,有个商业选址和开放指数,这块虽然是从主业务跳转过去的,但业务是比较独立的,与主业务没有业务上的交集。

总的来说拆分的原则就是业务相对独立的功能块。如果无法判断是否是独立的话就考虑是否满足以下条件:

  • 很少会访问到的页面
  • 没有数据上的交互

规范约定

组件目录结构

每个组件都要有单独的文件夹来管理,且文件夹名称与组件同名,每个文件夹下都要有个 index.js 用来映射组件。如下:

- Example
   |- Example.js
   |- index.js

以上是组件基本格式。

index.js 的内容只是简单的一句:

export { default } from './Example';

如果组件里还拆分出子组件,就在组件所在目录建一个 components 文件夹存放,子组件同样要遵守组件规范。如果子组件是很多组件公用的,就提到公共组件(/src/component)里面。组件内需要用到样式,就建一个与组件同名的 SCSS 文件,最终的结构如下:

Example/
|- components/
|    |- Children1/
|    |     |- Children1.js
|    |     |- Children1.scss
|    |     |- index.js
|    |- Children2/
|          |- Children2.js
|          |- index.js
|- Example.js
|- Example.scss
|- index.js

组件内可能需要抽离出逻辑代码,那就创建一个 hooks.js 文件专门用来放逻辑代码:

Example/
|+ components/
|- hooks.js
|- Example.js
|- Example.scss
|- index.js

如果组件不只是需要抽离 hook 方法,还存在一些非公共的工具方法、数据文件等,那就建一个 logic 文件夹存放:

Example/
|+ components/
|- logic/
|    |- config.js
|    |- utils.js
|    |- hooks.js
|    |- data.js
|- Example.js
|- Example.scss
|- index.js

代码规范

以下是基于公司前端开发规范做了一些补充,开发前请检先查下编译器有没有把 eslint 打开。

  1. 一个文件里只能有一个组件,且文件名与组件名一致。

  2. 文件导入顺序

     // scss
     import styles from './Example.scss';
    
     // react系列
     import React, { useState, useEffect } from 'react';
     import { connect } from 'dva';
     import { withRouter } from 'dva/router';
    
     // 组件,第三方组件 > 公司私库 > 布局组件 > 最外层公共组件 > 模块公共组件 > 子组件
     import { Spin } from 'antd';
     import { Button } from '@share/shareui';
     import BasicLayout from '@/layout/BasicLayout';
     import Header from '@/Header';
     import Children from './components/Children';
    
     // 网络请求方法
     import { findProjectList } from '@/server/safeOpen';
    
     // 配置文件
     import { PROJECT_STATUS } from '@/config/status';
     import { mapProjectStatus } from '@/config/typeMap';
    
     // 工具类,第三方库 > 公共工具类 > 局部工具类
     import _ from 'lodash';
     import cx from 'classnames';
     import { getUrlSearch } from '@/utils';
    
     // 逻辑代码
     import { defaultFormData, useLogic } from './logic';
    
     // 资源文件
     import logoImg from '@/assets/images/logo.png';
     import bannerImg from './images/banner.png';
  3. 组件需先赋值给一个变量,再导出这个变量。

     // 坏的
     export default () => {
         return 'hello world';
     }
     // 好的
     const Example = () => {
         return 'hello world';
     }
    
     export default Example;
  4. 条件状态判断时不允许与数值直接做判断。

     // 坏的
     if (data.projectStatus === '20') {    // ...}
     // 好的
     import { PROJECT_STATUS } from '@/config/status';
    
     // ...
     if (data.projectStatus === PROJECT_STATUS.ACCEPTED) {
         // ...
     }
  5. 暴露给外部使用的句柄以 on* 格式命名,使用句柄以 handle* 命名

     // 组件定义
     const MyInput = ({ onUpdate }) => (
         <Input onChange={onUpdate} />
     );
     // 组件使用
     const Page = () => {
         const handleUpdate = () => {};
         return (
             <MyInput onUpdate={handleUpdate} />
         );
     }

文件命名规范

类型 例子
camelCase projectApply
snakeCase project_apply
dashCase project-apply
properCase ProjectApply
constantCase PROJECT_APPLY

通常变量和文件命名使用 camelCase,以下是特殊情况:

  1. 组件文件和组件样式文件使用 properCase
  2. CSS全局类名(使用 :global 包起来的)使用 dashCase
  3. 单页应用根目录命名使用 dashCase

开发流程

主要流程:

  1. 创建单页应用
  2. 创建页面
  3. 启动项目
  4. 开发页面
  5. 发包

下列详细讲解各个部分包含的东西。

创建单页应用

之前的说法是创建多页应用

创建单页应用和页面使用 plop 创建,模板文件放在 templates 目录下,根据根目录下的配置文件 plopfile.js 来创建。通过执行 yarn run create 来触发程序:

$ yarn run create
? [PLOP] Please choose a generator. (Use arrow keys)
  page - 创建页面
  component - 创建公共组件
> mpa - 创建多页应用
  model - 创建 model
? 请填写文件夹名称:test-app
? 请填写多页模块的标题:测试标题

执行后会在 pages 下创建一个文件夹 test-app ,目录结构如下:

- pages/
    |- test-app/
        |- routes/     存放页面组件,初始空目录
        |- index.ejs   html入口文件
        |- index.js    js入口文件

index.ejs

文件内容:

<%= require('@/config/htmlEntryTpl.ejs')({
    title: '测试标题'
})%>

直接引用了 @/config/htmlEntryTpl.ejs 文件,所有单页应用共用此页面,传递标题参数过去。

htmlEntryTpl.ejs 除了 html 基础内容,还设置了:

  • 如果是互联网项目,需要设置 keyworddescription
  • 判断浏览器版本信息,如果 IE < 10 则跳转到浏览器升级提示页面
  • 页面初始展示加载中样式
  • 如果是互联网项目,生产环境下加入百度统计

index.js

文件内容:

import '@/config/global';
import setRouter from '@/config/router';

setRouter(module.id);
  • @/config/global 每个应用都会引入,所以也算是全局入口文件,在里面做一些样式和字体引入的操作;
  • 设置路由配置,传入当前模块 id(module.id 是 webpack 提供的 API),路由自动化会根据模块 id,找到对应的路由配置进行设置。setRouter 做的事情是:
    • 根据路由配置文件创建路由配置
    • 设置首页和 404
    • 设置错误边界
    • 设置布局组件
    • 初始化 dva、引入 model 文件

setRouter 也可传入对象:

setRouter({
    moduleId: module.id,
    indexPath: '/list',    // 首页地址,访问 / 会重定向到 /list 页面
    routes: [],            // 额外的路由配置
    Layout: CustomLayout   // 自定义布局组件
})

创建页面

$ yarn run create
? [PLOP] Please choose a generator. (Use arrow keys)
> page - 创建页面
  component - 创建公共组件
  mpa - 创建多页应用
  model - 创建 model
? 请选择所属多页模块: (Use arrow keys)
> index
? 请填写页面组件名称: Demo
? 请填写页面标题: 测试页面
? 请填写页面原型地址: xxx

完成后在 src/pages/index/routes 下会创建一个文件夹 Demo ,目录结构如下:

- Demo/
   |- Demo.js
   |- Demo.scss
   |- index.js
   |- README.md

除了一般的组件结构外,还多了一个 README.md 文件:

## 测试页面

### 访问地址

http://localhost:7000/social-governance/#/demo

### 原型地址

xxx

创建页面时输入的标题和原型地址将写在这文件里,同时根据组件名称计算出页面访问地址。

页面组件与一般的组件有些不同,以创建出来的 Demo.js 文件为例:

import styles from './Demo.scss';
import React from 'react';

const Demo = () => {
    return (
        <div className={styles.demo}>
            测试页面
        </div>
    );
};

Demo.title = '测试页面';

export default Demo;

与一般的组件相比,多了一行 Demo.title = '测试页面' ,这是在设置布局属性,也就是传给布局组件的数据,后面讲到布局组件的时候再细说。

启动项目

使用 yarn 管理项目

# 安装依赖
$ yarn install

# 启动项目
$ yarn start

下列讲解执行 yarn start 具体做了什么事情。

项目的 npm scripts 定义(隐藏了一些无关的)是:

{
    "scripts": {
        "start": "run-p server router-watch",
        "server": "sharekit server",
        "router-watch": "node bin/router-watch"
    }
}

执行 yarn start 实际上是执行了以下命令行:

$ run-p server router-watch

run-p 是安装 npm-run-all 模块提供的命令行,表示并行运行后面的命令,也就是同时运行 serverrouter-watch 命令。而 server 命令指向 sharekit serverrouter-watch 指向 node bin/router-watch

  • sharekit server 这个是脚手架自带的原本用来启动项目的命令,功能不变,就不讲了。
  • node bin/router-watch 用于监听路由变化,是路由自动化的启动命令

总的来说这边是在启动项目的同时,加入路由自动化。

开发页面

这边主要讲解开发过程中要遵守的一些业务开发规范。

表码管理

开发表单页或者做列表查询的时候经常会用到下拉框、复选框、单选框,这时候就需要 options 数据,而大多数 options 数据是从表码获取的。项目提供了一个 hook 方法用来获取表码数据,在 src/utils/dvaHooks.js 文件里的 useBmList,用法如下:

import { useBmList } from '@/utils/dvaHooks';

const Page = () => {
    // 单个
    const bmList = useBmList('BM_PROVIDE_DEPT_FULL');

    // 多个
    const bmList = useBmList([ 
       'BM_PROVIDE_DEPT_FULL',
        'OPEN_DATA_FORMAT_CNAME'
    ]);

    // 使用
    console.log(bmList.BM_PROVIDE_DEPT_FULL)

    return (
        // ...
    );
}

表码数据存在 dva 里面 ,在同一个单页应用里面共享表码数据。第一次请求会把数据存起来,第二次直接取之前存的数据。需要注意的是,如果是第一次访问,首次渲染 bmList.BM_PROVIDE_DEPT_FULL 返回 undefined

常量管理

一般做业务条件判断,大多是对数据状态做判断,例如判断能力资源是否是待审批状态。这个状态数据统一放在 src/config/status.js 文件里:

// 能力资源审核状态
export const ABILITY_STATUS = {
    DRAFT: '00',         // 草稿
    WAIT_AUDIT: '10',    // 待审核
    SUCCESS: '20',       // 审核通过
    FAIL: '30',          // 审核不通过
    DEL: '70',           // 已删除
};

有时在列表里展示数据时,需要根据不同的状态展示不同的图标或颜色,这样的映射数据统一放在 src/config/tyoeMap.js 文件里:

// 应用审批状态
export const mapAbilityStatus = {
    [ABILITY_STATUS.DRAFT]:      { color: '#0bd', text: '草稿' },
    [ABILITY_STATUS.WAIT_AUDIT]: { color: '#0bd', text: '待审批' },
    [ABILITY_STATUS.SUCCESS]:    { color: '#0b8', text: '审批通过' },
    [ABILITY_STATUS.FAIL]:       { color: '#f65', text: '审批不通过' },
};

网络请求

这个主要讲解接口请求方法的定义规范。

所有的接口请求都需要定义在方法里,而不是直接在页面上调用。而接口请求方法都需要定义在 src/services 文件夹里,根据功能模块拆分存放,例如:

services/
|- apps.js       应用模块
|- catalog.js    数据目录模块
|- common.js     公共模块
...

每个文件都要准守统一的方法定义格式,例如 apps.js

import network from '@/utils/network';

/**
 * 获取开放应用列表
 * YApi地址:http://192.168.0.62:3000/project/218/interface/api/35842
 * @returns {Promise}
 */
export function findAppOpenList(data) {
    return network.post('/app/openList', data);
}

/**
 * 获取应用查询排序列表
 * YApi地址:http://192.168.0.62:3000/project/218/interface/api/35866
 * @returns {Promise}
 */
export function getAppOrderList() {
    return network.get('/appOrder/list');
}

// ...

每个方法需要写 JSDoc,写明接口作用和 YApi 地址就好,想写得更完善点的话可以把入参类型和出参类型写进去。方法内容不需要写多复杂,GET 还是 POST 请求,请求地址是什么,做到这一步就足够,剩下的就是业务的问题。

发包

通常是到 “开发部署平台” 发包。

不过有的项目在开发部署平台上找不到前端模块,目前记得的是开放指数和可视化大屏项目。这种需要经过以下步骤:

  1. 本地打包,
  2. 将编译文件复制到对应后端工程的 src/main/resources/static 目录下进行覆盖
  3. 提交编译代码
  4. 去开发部署平台发包

路由自动化

在启动项目的同时会开启路由自动化程序,路由自动化会实时的监听 src/pages 文件夹下的文件变更,一旦变更就会重新生成 src/config/mapRoutes.js 文件,而路由配置就是根据这个文件来构建路由。整个开发的过程不需要自己去写路由配置,能根据文件结构实时自动生成对应的路由。

路由自动化程序是使用 NodeJS 开发的,代码放在 bin 目录里面,相关文件结构如下:

bin/
|- model/              模型对象
|    |- Map.js         单页应用信息(以前叫它多页信息)
|    |- Spa.js         页面信息(以前叫它单页信息)
|    |- Router.js      路由信息
|    |- Generator.js   生成器,用于创建文件
|- router-watch.js     启动入口文件

入口文件 router-watch.js 代码如下:

const Router = require('./model/Router');
const PAGE_ROOT = './src/pages';                    // 页面根路径
const OUTPUT_PATH = './src/config/mapRoutes.js';    // 配置文件输出路径
const router = new Router(PAGE_ROOT, OUTPUT_PATH);

router.watch();

其实就只是创建了 Router 实例,然后调用了它的 watch 方法。

watch 方法的大致流程如下:

  1. 打印更新日志
  2. 获取当前配置
    • 读取 src/pages 下的所有文件夹名称
    • 根据这些文件夹名称创建 Mpa 实例
    • Mpa 实例内部再创建 Spa 实例
    • 遍历所有 Mpa 里的所有 Spa 实例,调用 getPageData
    • 数据组装
  3. 对比新旧配置,如果有变更才往下走
  4. 对配置数据做格式转化
  5. 将数据写入到 src/config/mapRoutes.js 文件里

生成的文件代码如下:

export default {
    [require.resolve('../pages/index/index.js')]: [
        {
            path: '/app',
            component: () => import('@/pages/index/routes/App'),
            exact: true,
            title: '应用超市',
            description: 'Application Center'
        },
        // ...
    ]
}

require.resolve() 用来获取 module.id,也就是以 module.id 作为 key ,使用的时候通过 key 值获取对应路由配置。

路由配置除了常规的 pathcomponentexact ,还多了 titledescription ,这两个属性是通过解析布局属性获取,所以这个页面组件代码应该是这样定义的:

import React from 'react';

const App = () => { /* ... */ }

App.title = '应用超市';
App.description = 'Application Center';

export default App;

这边会将所有页面组件的静态属性(代码位置固定写在以上位置)解析到路由配置上。

注意:这边只接收静态数据,如果传了一个变量进来是无法识别的。

有了这个 src/config/mapRoutes.js 文件后,路由就可以通用同一个组件,只要把 module.id 传进来就能自动配置对应的路由。

注意:这个 module.id 只能在单页应用的入口文件 index.js 传入,不然找不到对应的路由配置。

布局组件

布局组件就是专门用来开发公共布局的组件,例如头部、底部、banner,组件存放在 src/layouts 目录下。

组件的 props 接收来自路由配置传来的布局属性,当前基础布局组件里除了开发公共布局,还做了以下事情:

  • 获取用户信息,期间页面处于加载中状态,接口响应回来后才展示页面
  • 浏览器滚动条置顶
  • 权限判断,如果此页面需要登录,就会判断是否未登录,未登录展示登录提示页

后续需求有调整的话可以基于这个组件再封装一个其他布局组件。


Maxi Ferreira

你好!我是诀死行者,一个专注于研究诀死 (JS) 功法的修行者。很高兴在修行的路上有你的陪伴, 你可以到 GitHub 观摩我的修行成果, 也可以到我的网站查阅我的修行笔记。