前端架构重构 - 设计文档
框架选型:
- React - 写 React 的感觉就是在写 JavaScript。但 React 的缺点是冗余,它只是个 View 层,也只处理 View 层。
- Vue - Vue 的定位是一个渐进式的 MVVM 框架。渐进式,就是学习曲线比较平缓,没那么陡峭。
但这并不意味着 Vue 比 React 简单,React 就比 Vue 复杂。Vue 在框架层面封装了一部分复杂性,对开发者来说开发体验更简单友好。React 则繁琐一些,它把一些常用的优化丢给了开发者,并提供了一些 API。比如 useMemo 和 useCallback,但 React 的好处是设计上的先进性。无论是早期的 JSX,还是近年来的 Fiber 架构和 Hooks ,都是业界标杆。
从生态上来说,React 社区也是其他社区的许多倍。许多知名的 UI 框架也都使用 React 编写,知名的大公司也使用 React 统一作为技术栈,比如:Facebook, Alibaba, ByteDance, Airbnb 等。选择 React 当然是有原因的,React 更适合大型项目大型团队,Vue 适合中小型项目和团队。理由有很多,除了生态上和设计上的先进性,我更认可的一点是偏向于团队管理的:
之前团队的老大 Jimmy 跟我说过这样一句话:“JavaScript 什么都能写,很灵活。但正是因为它太灵活,导致每个人写的代码都不一样,很难规范管理。这一点,Java 就很不一样”。类推到 React 和 Vue 上,因为它的灵活导致每个人写出来的 Vue组件都不一样。在 React 中,状态只有一种,就是 State。没有 computed,没有 watcher,没有 filter,没有 directive。
考虑到项目的长期发展,团队代码风格的一致性,长期编码的可维护性,社区生态的广泛性。我推荐 React。
Vue 很好,快速上手,快速开发,快速实现。灵活性对于小团队更友好,对于人数比较多的团队,或者比较大的项目来说,更多是维护不一致风格的代码带来的不友好体验。
从团队技术的适配度来说,大家也更熟悉 Vue,但是对于写过 Vue3 的各位同学而言,其编码上几乎没有很大差别,都是 TypeScript & JSX(Template?)。其实现上的差别是 React setState pull 式的状态更新和 Vue reactive push 式的状态更新。
工程工具
- Vite - 之前是 ESBuild + Rollup,现在的架构演进是 swc 插件
- Rspack/Rsbuild - ByteDance 使用 Rust 重写的兼容 Webpack 生态的构建工具
- Turbopack - next.js 团队专为 RSC(React Server Components) 打造的基于 rust 的构建工具
UI 组件库
- Ant Design - 推荐
- pro-components - 推荐,专为中后台打造的重型组件,大幅提升开发效率。
- 自带 ProLayout 适配中后台各种布局
- 自带 ProPageContainer 适配中后台各种页面
- 自带 ProTable 适配中后台各种 table 用法场景
- 自导 ProForm 适配中后台各种 Form 场景
- 自导 ProField 适配中后台各种 Field 字段的数据类型
- 其他组件 ProCard, ProList, ProStatics 等
- pro-components - 推荐,专为中后台打造的重型组件,大幅提升开发效率。
Element UI- Material UI
Semi DesignArco DesignFusion Design- Naive UI - 业界 Vue 3 的知名 UI 库。
- ... 待补充
CSS 技术
- Tailwind CSS - 推荐,将 CSS 语句原子类化,使用各种 class 集成各种样式。
- 优点:再也没有 CSS 命名烦恼,更不会有 CSS 命名空间冲突。
- 缺点:上述心智负担,转移成记住 tailwind 各种原子类名,但这是一本万利的买卖。
- CSS in JS
- BEM CSS - 各种组件库的命名规范。
- ... 待补充
规范文档
- 文档规范
- 代码优化 - from @Liam
-
魔法数字
// bad if (index === 0) {} else if (index === 1) {} else if (index === 2) {} // good if (index === SelectEnum.PROVINCE) {} else if (index === SelectEnum.CITY) {} else if (index === SelectEnum.COUNTRY) {}
-
代码语义化
// bad new Date().getTime() - (30 * 24 * 3600 * 1000) // good new Date().getTime() - (1000 * 60 * 60 * 24 * 30) // 秒 分钟 小时 天 月
-
代码健壮性
// bad const { code, data } = awai request() if (code === 200) { this.data = data.map(item => ({ ...item, xx: 'xx', })) } // good const { code, data } = awai request() if (code === 200) { this.data = data?.map(item => ({ ...(item ?? {}), xx: 'xx', })) ?? [] }
-
Early Return
// bad if (type) { res = (num / str) } else { res = (num * str) } return res // good if(type) return num / str return num * str
-
- 约定规范
- 内部规则
- 不要把所有的相关功能行为都放进一个全局的文件维护
- 这是反模式的设计,所有的设计模式都是为了高内聚,低耦合。把各个模块的 service 耦合到一个大的 allApi.js 是并不明智的做法
- 这样多人并行开发时极容易冲突,因为大家都会改到同一个文件,而解决 git 的冲突是一件耗时费力且无意义的工作,最好不要有任何冲突
- 所有的功能和逻辑,只跟模块相关的,都应该放在该模块下,模块之间保持独立性,互相独立,互不关心,完全解耦。
- 全局性功能 - 放置到全局文件目录,使用
@/{moduleName}.{featName}.{ts|js}
引入。意义即:全局公用,全局共有,对外暴露
- 全局组件 - /components
- 全局常量 - /constants
- 全局服务 - /services
- 全局工具 - /utils
- 全局接口 - /interfaces
- 全局钩子 - /hooks
- 全局主题 - /themes
- 全局 Context - /contexts
- ...
- 业务性功能 - 放置到模块内部文件,并使用 _{moduleName} 声明。意义即:模块内部私有,不对外暴露,非本模块内部的外部最好不要使用
- 模块组件 - `_AuthRoleSelector.tsx` 仅服务于当前业务的组件,不对外暴露,不对外使用
- 模块服务 - `_AuthRoleApi.service.ts` 仅服务于当前业务的服务,可以是 API,也可以是其他功能定义
- 模块钩子 - `_AuthRole.hook.ts` 仅服务于当前业务的钩子
- 模块 Context - `_AuthRole.context.ts` 仅服务于当前业务的数据
- ...
- 全局性功能 - 放置到全局文件目录,使用
- 不要把所有的相关功能行为都放进一个全局的文件维护
- 内部约定
- 所有的 table 的操作列,使用 fixed=“right” 固定到右侧,方便用户操作
- 所有的请求函数,使用 async/await + try catch finally 格式
- 在 try 中的 await 之后判断 code 状态码是否正常
- 在 finally 中处理 loading = false
- 在 catch 中处理 error (通常拦截器会统一处理异常,但某些特例 code 的情况下需要手动处理)
- 内部用法
- 内部 SDK
- 内部规则
- 命名规范 - 常量使用
PascalCase,变量使用 c
amelCase,数据库字段使用 snake_case。
- 格式
-
camelCase:小驼峰命名法,单词之间用大写字母分隔,第一个单词的首字母小写。例如:
firstName
,loginButton
,userProfile
. -
PascalCase:大驼峰命名法,单词之间用大写字母分隔,每个单词的首字母都大写。通常用于类名、构造函数、组件名等。例如:
Person
,LoginForm
,HomePage
. -
snake_case:下划线命名法,单词之间用下划线
_
分隔,所有字母小写。常见于变量、函数、数据库字段等。例如:first_name
,login_button
,user_profile
. -
UPPER_CASE:全大写命名法,单词之间用下划线
_
分隔,所有字母大写。通常用于常量或枚举值。例如:MAX_SIZE
,ERROR_CODE
. -
kebab-case:短横线命名法,单词之间用短横线
-
分隔,所有字母小写。常见于 URL、文件名等。例如:page-title
,user-avatar
.
-
- 目录 - 参见上文中的 内部规则 - 全局性功能 - 全局文件目录
- constants - 放置常量、枚举,命名规范:
PascalCase.const.{js|ts}
-
SystemConfig.const.ts - 系统配置常量
-
import packageJson from '../../package.json' export const APP_NAME = packageJson.name export const APP_VERSION = packageJson.version
-
- UserStatus.enum.js - 用户状态枚举常量
- 枚举定义,是一组值的集合。类似于 Python 中的 Dictionary + Tuple,具名 Key + 不可变 Value.
- 枚举名称 使用 PascalCase,枚举字段同使用 PascalCase 声明。比如说 TypeScript 中的枚举定义:
enum Color { Red, Green, Yellow }
-
enum UserStatusEnum { Normal = 0, Banned = 1 }
/** * 用户状态 枚举定义 */ export const UserStatusEnum = { /** 0 - 正常 */ Normal: 0, /** 1 - 禁用 */ Banned: 1 }
- 枚举名称 使用 PascalCase,枚举字段同使用 PascalCase 声明。比如说 TypeScript 中的枚举定义:
- 枚举对象,是对枚举定义的补充。
-
枚举映射,是对枚举值的意义的解释的补充。比如说:
- 在代码中获取对应枚举值的 label or value 用于 if 或 swith 等相关的条件判断
-
在代码中通过编写
UserStatus.Normal.value
这样的代码提升代码的可读性和可维护性/** * 用户状态 枚举对象 */ export const UserStatus = { Normal: { label: '正常', value: UserStatusEnum.Normal, }, Banned: { label: '禁用', value: UserStatusEnum.Banned, }, }
-
- 枚举列表的定义,很多业态需要遍历枚举对象的值。比如:Select, Checkbox, RadioGroup:
UserStatusList = Object.values(UserStatus)
- 枚举定义,是一组值的集合。类似于 Python 中的 Dictionary + Tuple,具名 Key + 不可变 Value.
-
- services - 放置公共服务,命名规范:
PascalCase.service.{js|ts}
,通常暴露出来一个 service class,用以被实例化或者上下文注入。- Service 用例及场景:
- 作为一个职责单一的纯函数方法被其他逻辑方法依赖使用,便于代码细粒度的拆分和组合。
- UserApi.service.js - 用户相关 API 接口的服务,其他任意模块都可以通过
UserApi.fetchUserById(userId: number)
获取用户信息数据。
- UserApi.service.js - 用户相关 API 接口的服务,其他任意模块都可以通过
- 作为一个职责单一的高内聚的功能实现被其他模块耦合,其他模块依赖该 Service 的抽象,便于代码的依赖。
- Plugin.service.js - 插件可以有各种各样的实现,但它们必须实现
IPlugin.interface.ts
定义的基础接口。
- Plugin.service.js - 插件可以有各种各样的实现,但它们必须实现
- 作为一个职责单一的纯函数方法被其他逻辑方法依赖使用,便于代码细粒度的拆分和组合。
- CacheStorage.service.ts - 支持特定缓存时间后过期的 storage 的服务,暴露出去一个实例。
- MemoeryStorage.service.ts - 运行时内存 storage 服务,暴露出去一个实例。
- ClientStorage.service.ts - 客户端本地存储的 stoage 服务,暴露出去两个实例。
-
clientLocalStorage
export const clientLocalStorage = new ClientStorage( typeof localStorage !== 'undefined' ? localStorage : ({} as any as Storage) )
-
clientSessionStorage
export const clientSessionStorage = new ClientStorage( typeof sessionStorage !== 'undefined' ? sessionStorage : ({} as any as Storage) )
-
- Service 用例及场景:
- components - 公共组件,用于放置全局使用的公共组件。命名规范:
PascalCase/index.{js|ts}
- CashConverter/index.ts - 金钱转换组件
- CoinConverter/index.ts - 金币转换组件
- utils - 公共工具方法,camelCase.util.ts
- formatter.util.ts - 格式化工具
- logger.util.ts - 日志打印工具
- converter.util.ts - 转换工具库
- themes - 全局主题,camelCase.theme.ts
- light.theme.ts - 白天主题
- dark.theme.ts - 黑夜主题
- gold.theme.ts - 土豪金?!
- interfaces - 公共接口定义 interface,在编写代码之间,先声明 interface 定义 API 接口,是很好的习惯。设计为先。
- Storage.interface.ts - 储存接口定义
- Plugin.interface.ts - 插件接口定义
- hooks - 公共钩子 hook,camelCase.hooks.ts
- install.hook.ts - 安装相关 hook
- pwa.hook.ts - pwa 相关 hook
- contexts - react context 集合目录,命名规范:
PascalCase.context.{js|ts}
- User.context.ts - 用于存放用户相关的数据,使用 provider 提供给所有的嵌套子组件消费 consumer。(生产消费模式)
- Config.context.ts - 用于存放系统配置相关的数据。
- langs/locales - 语言目录,不同的国际化对目录或文件对命名要求不一致。取决于上下文。
- zh.{ts|js|json} - 中文
- vi - 越南语
- id - 印尼语
- br - 巴西语
- mx - 墨西哥语
- th - 泰语
- main.js/index.js - 入口文件
- *.setup.js - 启动配置文件
- db.setup.js - 启动 mongoDB 配置的文件
- router.setup.js - 启动 router 配置的文件
- ...
- constants - 放置常量、枚举,命名规范:
- 文件 - {moduleName}.{featName}.{extension}: moduleName - 当前文件的主要功能或模块,featName - 当前文件的功能/角色/定位,extension - 文件扩展名
- UserStatusEnum.const.js
- formatter.util.ts
- Config.context.ts
- 组件名 - 使用
PascalCase 命名。web component 和 vue 推荐 kebab-case,React社区推荐 PascalCase/index.tsx。
GooglePage.server.tsx - 对应 nextjs 中的服务端组件
AppSupport.client.tsx - 对应 nextjs 中的客户端组件
CashConverter/index.tsx
- 推荐统一使用目录放置 index.tsx
。不推荐 CashConverter.tsx 这种单文件组件,不利于扩展、新增文件和组件。
- 方法名 - verb 动词 + noun 名词 + desc 描述,提升代码可读性,增强可维护性。
- handleClickMenu - 处理点击菜单
- convertIPAddress - 转换 IP 地址
-
getGoogleConfigByPkg - 通过 pkg 获取 Google 配置
-
genServiceUrlByVisitorId - 通过 VisitorId 生成 service 地址
- 常量名 - 常量的编写,通常采用 UPPER_CASE,noun 名词 + desc 描述。
- APP_VERSION - 应用版本
- SYSTEM_TITLE - 系统标题
- 枚举名 - 枚举跟常量类似,不同在于枚举会新增和删除 option。因此枚举区别于常量,使用
PascalCase
的方式命名。- RechargeType.enum.ts - 动态枚举
- RechargeTypeEnum.const.ts - 常量枚举
- 布尔值名 - 常见的命名格式即 isXXxx, shouldXXxx, hasXXxx 用于表示状态、行为、存在。
- isInstalledPWA - 是否已安装过 PWA 应用,is + verb/ed (动词或动词过去式) + noun(名词)表示 noun 是否已执行 verb/ed 动作。
- isSupportsPWA - 是否支持 PWA 模式,is + verb + noun 表示是否 noun 是否存在 verb 状态 。
- shouldInstallPWA - 是否应该安装 PWA 应用, should + verb + noun 表示 noun 是否应该执行 verb 行为。
- hasCheckedStatus - 是否已检查过 status,has + verb/ed + noun 表示是否已经执行过 verb 行为。
- 格式
-
- 变量名 - 最常见的命名,变量名应该尽可能的体现它的功能/角色/意义。通常格式 noun 名词 + desc 描述,使用
camelCase
。-
nodeEnv -
名如其义 options.map(option => option.value)
- 尽量使用有意义的命名,不要使用 `items.map(x => x.label)` 这种命名,编译时工程会自动 minifiy,不用节约变量命名的字符串长度物理空间。-
DefaultAdminApi
-
- Class 类名 - 使用
PascalCase
命名,命名规范同变量名 noun 名词 + desc 描述。 在早期的 ES 中,区分构造函数和普通函数就已经使用这样的命名规范。-
export class CacheStorage extends ClientStorage {} - 类继承
-
export default class StatisticsService {} - 默认导出 Class
-
class AdjustGenerator {} - Adjust 生成器 Class
-
- Interface 接口名 - 使用
PascalCase 命名,但约定是大写字母
I
+ InterfaceName 用于让人一眼理解当前的用法是 interface, 而非 class.-
IContentBlockProps - 最常见的 React 组件的 props interface 声明
-
IStorageInterface - Storage.interface.ts 储存接口的设计定义
-
- 写法顺序
- 在 Vue 中有规范和 lint 工具约定 props, components, computed, data, watcher, beforeMount, mounted 等 options api 的编写顺序。
- 在 React 中因为 hooks 在框架级的强约定(不能放入条件表达式、强制先后顺序),所以不用考虑声明等顺序问题。
- 写法规范 - 参见项目的模版代码
- eslint 规则 - 使用脚手架默认配置
- commit lint 规则 - 使用脚手架默认配置
- stylelint 规则 - 使用脚手架默认配置
- … 待补充
- 变量名 - 最常见的命名,变量名应该尽可能的体现它的功能/角色/意义。通常格式 noun 名词 + desc 描述,使用
- 测试用例
- 端到端测试(E2E Test):
- 端到端测试是从用户的角度出发,模拟真实的用户场景和业务流程,验证整个系统的端到端行为是否符合预期。
- 它测试的是整个应用程序的完整流程,包括UI交互、数据交互、第三方系统集成等。
- 端到端测试通常使用自动化测试工具(如Selenium、Cypress等)来模拟用户操作,并验证最终结果。
- 端到端测试的目的是确保整个应用程序在各个组件协同工作时,能够正常运行并满足业务需求。
- 端到端测试通常运行速度较慢,因为它需要启动整个应用程序,并模拟完整的用户场景。
- 单元测试(Unit Test):
- 单元测试是针对最小可测试单元(如函数、方法或模块)进行验证,确保它们的行为符合预期。
- 它测试的是独立的代码单元,与其他单元没有耦合关系。
- 单元测试通常使用测试框架(如Jest、Mocha等)来编写和运行测试用例。
- 单元测试的目的是确保每个代码单元的内部实现是正确的,并满足设计要求。
- 单元测试通常运行速度较快,因为它只关注单个代码单元,不需要启动整个应用程序。
- 端到端测试(E2E Test):
架构设计
- 微前端
- 微应用
- 单体应用 - 推荐
- 多应用
现有中后台业务
管理后台
- 产品推广 - 推广的品牌和渠道商配置,
- 游戏参数设置 - 包括但不限于游戏道具、游戏接口、游戏活动等
- 游戏用户相关 - 包括但不限于用户的游戏日志、充提流水等数据
- 系统权限配置 - 系统角色、菜单、用户等配置
客服后台
给外部运维人员使用的系统。主要处理日常运营的玩家相关功能,比如充提审核、订单审核、日志查询等
数据后台
给外部推广或公司内部使用的系统。主要功能是查询各种数据。
重合功能
每当这些重合的功能涉及到需求改动时,都会重复开发/发版/测试,是无意义劳动和耗时。
系统/功能 | 系统权限 | 用户信息 | 游戏日志 | 操作记录 | 公共布局/组件/工具 |
---|---|---|---|---|---|
管理后台 | x | x | x | x | |
客服后台 | x | x | x | x | x |
数据后台 | x | x | x | x | x |
金币金额转换
类型/国家/算法 |
印尼 ID |
尼日利亚 NG |
墨西哥 MX |
巴西 BR |
金钱(法币) Cash |
*/ 1000 |
*/ 1000 |
*/ 1000 |
*/ 1000 |
金币(游戏币) Coin |
- |
*/ 1000 |
*/ 1000 |
*/ 1000 |
技术架构
系统/技术 | 技术栈 | 前端工程化工具 | UI 组件库 | 状态管理 | 路由管理 | CSS 预处理器 | 国际化多语言 | 国际化多时区 |
---|---|---|---|---|---|---|---|---|
管理后台 | Vue v2 | Vite | element-ui, umy-ui | VueX, global module | VueRouter v2 + 全局全量注册 | LESS |
Vue-i18n, 并未使用 | dayjs, utc + timezone |
客服后台 | Vue v2 | Webpack | element-ui, umy-ui | VueX, namespaced module | VueRouter v2 + 动态增量注册 | LESS , SASS |
Vue-i18n | dayjs, utc + timezone |
数据后台 | Vue v2 | Webpack | element-ui, umy-ui | VueX, global module | VueRouter v2 + 全局全量注册 | LESS |
Vue-i18n, 并未使用 | dayjs, utc + timezone |
扩展能力
系统/技术 | Icon 图标 | Excel 数据 | Chart 图表 | Editor 编辑器 | Utils 工具库 | Drag & Drop 拖放 | Request 请求 | 安全 |
---|---|---|---|---|---|---|---|---|
管理后台 |
|
|
echarts |
|
lodash |
vuedraggable |
自定义 HttpRequest class 结合 axios + interceptor 拦截器模式 |
|
客服后台 | iconify |
|
echarts |
- |
|
|
axios.create 工厂方法创建实例 + interceptor 拦截器模式 |
|
数据后台 | iconify |
exceljs , xlsx , pikaz-excel-js |
echarts |
quill , vue-quill-editor |
lodash |
|
自定义 HttpRequest class 结合 axios + interceptor 拦截器模式 |
解决办法
使用单体应用架构,加之更合理的产品设计。无论是引入系统切换的 tab,还是引入新的系统角色。都能降低重复劳动,节约开发耗时,提升开发效率。
角色区分 | 菜单区分 | 其他 | |
---|---|---|---|
统一管理平台 |
系统管理员 | 管理系统 |
|
客服人员 | 客服系统 | ||
数据统计人员 | 数据系统 |
应用生命周期设计 Lifecycle Hooks
-
应用初始化阶段 - beforeCreate/create:
- 应用初始化:在应用启动时进行一些全局的初始化设置,例如创建根组件、设置路由等。
- 模块加载:根据需要异步加载应用所需的模块、组件和资源。
-
应用路由导航:
- 路由导航守卫:在进行路由导航之前或之后执行一些操作,例如身份验证、权限检查等。
- 路由解析:解析路由参数、查询参数等,准备要渲染的组件所需的数据。
-
组件生命周期:
- 组件创建:在组件创建时执行一些初始化操作,例如设置初始状态、订阅事件等。
- 组件渲染:将组件的模板渲染到页面上,显示相应的视图。
- 组件更新:在组件状态或属性变化时,重新渲染组件。
- 组件销毁:在组件被销毁之前执行一些清理操作,例如取消订阅、清除定时器等。
-
数据管理:
- 数据获取:从后端或其他数据源获取数据,并进行相应的处理和转换。
- 数据更新:在数据发生变化时,通知相关的组件进行更新。
-
事件处理:
- 事件绑定:将事件处理函数与相应的 DOM 元素或组件进行绑定。
- 事件触发:在用户交互或其他触发条件下,触发相应的事件处理函数。
-
错误处理:
- 异常捕获:捕获应用中的异常或错误,并进行相应的处理和报告。
- 错误 SDK:
- Sentry
-
应用销毁 - beforeUnmount/unmount:
-
取消订阅和事件解绑:在应用销毁前,确保取消所有的订阅和解绑所有绑定的事件处理函数,以避免潜在的内存泄漏问题。
-
断开连接和清理资源:如果应用与后端服务建立了连接或使用了其他资源,应在销毁阶段断开连接并清理相应的资源,以释放占用的资源和确保正确的资源管理。
-
清理定时器和计时器:如果应用中使用了定时器或计时器,应在销毁阶段清理它们,以防止在应用销毁后继续运行并导致潜在的问题。
-
清理缓存和状态:根据应用的需求,可以在销毁阶段清除应用使用的缓存数据或状态,以确保下次启动应用时处于初始状态。
-
多语言 & 国际化 i18n languages
使用对应 UI 框架的配置即可,但目前的迁移成本比较高。只有客服后台实现了 i18n 国际化翻译。
系统/功能 | 中文 ZH | 印尼语 ID | EN 英语 | BR 葡萄牙语 |
---|---|---|---|---|
管理后台 | - | - | - | - |
客服后台 | x | x | x | x |
数据后台 | - | - | - | - |
应用配置与枚举 Configurations and Enums
所有的异步资源,在设计接口时使用 async/await 异步 + Memory Cache 的模式。如果数据未请求过,则发起 Http 请求。若请求过,则从缓存中读取。
系统配置 system configurations
系统设置 ,是初始化系统时的前置依赖,不同的环境有不同的配置。通常的设计是后端架构师使用 Apollo 定义配置后通过接口 /api/system/config
返回给前端:
interface SystemConfig { /* CDN 静态资源地址 */ CDN_URL: string /* 资源上传地址 */ uploadURL: string /* 资源下载地址 */ downloadURL: string /* 图标下载地址 - 系统 icons,通常是维护的 iconfont url */ iconfontURL: string /* API 请求前缀 */ API_BASE_URL: string /* 当前版本 */ version: string | number /* 其他系统三方依赖的配置 */ systemTitle: string systemLogo: string systemEnumAPI: string /* 等等其他配置项 */ } |
系统枚举 system enums
在开发业务系统时,会定义大量的枚举。枚举的定义,多数由后端同学在开发后端逻辑时定义。比如 Java 的 EnumClass
,TypeScript 的 Enum
对象等。这些枚举在我们的系统中被大量使用,而被循环渲染的过程中有多种业态,比如:
- select 下拉框
- radio 单选框
- checkbox 多选框
因此,保证枚举的及时性和一致性至关重要。枚举的一致性影响我们的开发体验,枚举的及时性影响我们的用户体验。常见的场景是:
- 后端维护后端枚举
- 前端维护前端枚举
- 共同枚举的管理 ⚠️
- 前端改了,跟后端接口返回不一致
- 后端改了,前端无感知
- 后端枚举更新了
- 前端必须 follow 更改
- 前端必须发版本
- 才能触达用户
因此枚举不适合也不应该在前端维护。枚举应该通过接口统一返回,此时可以根据业务的使用密度来划分枚举接口的密度。比如,在初始化业务系统时,被即时使用到枚举,可以在系统初始化时通过接口 /api/system/enums 返回:
interface Enum { /* 枚举名 */ label: string /* 枚举值 */ value: string | number /* 摘自 @ant-design/pro-components */ status: 'Success' | 'Error' | 'Processing' | 'Warning' | 'Default'; } // 推荐使用 键值对 的方式,方便前端做判断 type EnumMap = Record<string, Enum> // 使用 List 的方式,方便前端做 for 循环渲染 type EnumList = Enum[] |
使用 EnumMap 的好处在于,可以直接通过 EnumMap[key] 定位到具体的 option。举例 GET /api/system/enum/userStatus:
{ code: 0, success: true, data: { UserStatus: { Online: { label: '在线', value: 1, status: 'Success' }, Offline: { label: '离线', value: 0, status: 'Warning' }, Deleted: { label: '已删除', value: -1, status: 'Error' }, }, // other enums } } |
在代码中判断很好使用:
// 如果用户状态为在线 if (row.userStatus === UserStatus.Online.value) { // code here } |
当作为 select 筛选时:
const userStatusList = Object.values(UserStatus) |
枚举的国际化
- 后端实现,后端在返回 `
/api/enum/*`
时根据 request-headers 中的 accept-language 传回对应的翻译后的语言版本。 - 前端实现,不推荐这么做。前端实现,则带来了开发体验上的断层。
身份认证与权限 Permission & Auth
"Auth"(身份验证)和 "Permission"(权限)是在软件开发中常用的两个概念,它们在用户认证和授权方面起着不同的作用:
-
Auth(身份验证):
身份验证(Auth)是确认用户身份的过程。它用于验证用户是否是系统中的合法用户,并且具有适当的凭据来访问系统资源。身份验证通常涉及用户提供凭据(如用户名和密码)以验证其身份。认证成功后,系统会授予用户一个身份标识(如令牌或会话),以便在后续的请求中验证用户的身份。
身份验证的目的是确保只有经过验证的用户可以访问系统的受保护资源。它通常用于用户登录和访问控制的过程中。例如,用户在网站上登录时需要进行身份验证,以便系统可以验证其身份并授予相应的访问权限。
-
Permission(权限):
权限(Permission)是指授予用户或用户组对系统资源执行特定操作的权力。权限控制决定了哪些用户可以执行哪些操作,并限制了他们对资源的访问。权限通常与用户的角色、组织结构或其他属性相关联。
权限控制的目的是确保用户仅能访问其被授权的资源,并限制他们对系统中敏感或保密信息的访问。例如,一个系统管理员可能具有更高级的权限,可以执行敏感操作,而普通用户只能执行有限的操作,受到访问限制。
总结来说,身份验证(Auth)用于验证用户的身份,并为其颁发身份标识,而权限(Permission)用于控制用户对系统资源的访问和操作。身份验证确保用户是合法用户,而权限控制决定了用户可以做什么以及对哪些资源具有访问权限。
用户校验 Auth
-
用户登录 User login - 在系统初始化的生命周期中判断用户是否登录,并根据用户登录后的角色与权限执行后续逻辑。若未登录,则重定向到登录页面。
/login?redirect=prevPage
type Role = string | number type FunctionKey = string | number interface BaseEntity { createdTime: Date createdUser: User['id'] updatedTime: Date updatedUser: User['id'] }
import React, { useEffect, PropsWithChildren } from 'react' // user.context.ts interface User extends BaseEntity { id: string name: string /* 角色 */ roles: Role[] /* 权限点 */ functionKeys: FunctionKey[] } const UserContext = createContext<User>(null | null) const UserProvider: React.FC<PropsWithChildren> = ({ children }) => { const [user, setUser] = useState<User | null>(null); const history = useHistory(); useEffect(() => { const checkUserLogin = async () => { const isLoggedIn = checkIfUserIsLoggedIn(); // 自定义函数,用于判断用户是否登录
if (isLoggedIn) { const user = await getUserData(); // 自定义函数,用于获取用户数据 setUser(user); } else { const redirectUrl = window.location.href; // 获取当前页面 URL history.push(`/login?redirect=${encodeURIComponent(redirectUrl)}`); } }; checkUserLogin(); }, [history]); return <UserContext.Provider value={user}>{children}</UserContext.Provider>; }; export { UserContext, UserProvider };
鉴权 Permission
-
用户身份角色 User Roles - 通常的设计是,路由
Route['meta']
需要指定权限点,再判断用户的权限点中是否拥有该权限,如果拥有则放入有效路由表中,并动态装载到路由表中。interface Route { path: string name: string component: any children?: Route[] meta: { title: string roles: Role[] cache: boolean } }
- 路由菜单权限 Page Route Menu - 根据用户角色权限渲染指定路由,仅渲染有权限的路由,否则跳转 404。
const geneNewRoutes = (allRoutes: Route[]) => { const newRoutes = [] allRoutes.forEach((route) => { if (user.roles.includes(route.name)) { if (route?.children?.length) { route.children = geneNewRoutes(route.children) } newRoutes.push(route) } }) return newRoutes }
-
用户权限点 Function Keys - 判断用户的权限点是否拥有该权限,拥有则渲染,反之不渲染。
type FunctionKeys = string[]
- 按钮模块功能权限 Button/Module Feature - 根据用户权限点判断用户是否有某个指定权限,并根据条件渲染特定功能模块。
import React, { PropsWithChildren } from 'react' import UserContext from '@/contexts/user.context' interface IAccessProps { functionKeys: string[] fallback?: React.ReactNode } const Access: React.FC<PropsWithChildren<IAccessProps>> = ({ functionKeys = [], fallback = null, children }) => { const user = useContext(UserContext) const functions = user.functionKeys const accessible = functionKeys.every(key => functions.includes(key)) return accessible ? children : fallback }
应用缓存 Application Storage Cache
- Memory(内存)存储:
- 将数据存储在内存中,例如使用变量、数组或对象等数据结构。
- 当页面刷新或关闭时,内存中的数据会丢失。
- 对于临时数据或者在单个页面会话中传递数据的场景很有用。
- 不受存储空间限制,但是占用内存可能会影响性能。
- Session(会话)存储:
- 使用
sessionStorage
对象存储数据,数据存储在浏览器的会话期间。 - 当浏览器或标签页关闭时,存储的数据会丢失。
- 用于在页面会话期间存储临时数据,例如表单输入或页面设置。
- 存储空间一般在 5-10MB 之间,因浏览器而异。
- 使用
- Local(本地)存储:
- 使用
localStorage
对象存储数据,数据存储在浏览器的持久存储中。 - 即使浏览器关闭或刷新,存储的数据依然存在,除非用户清除浏览器缓存或主动删除。
- 用于存储持久性数据,例如用户设置、应用配置或缓存数据。
- 存储空间一般在 5-10MB 之间,因浏览器而异。
- 使用
- IndexedDB 存储:
- 客户端非关系型数据库技术,提供结构化数据存储和事务处理。
- 支持键值对和对象存储,可以处理复杂查询和大量数据。
- 适用于需要本地处理大量数据的 Web 应用程序。
- 存储空间受用户设备和浏览器设置限制,可达数百兆甚至更多。
- 是现代 Web 开发中推荐的客户端数据库技术。
- 其他储存
- CookieStorage - 略
- CacheStorage - 增加 expireTime 在读写时处理过期的数据
表单原子性 From & Field 与表单校验 Validator
组件:
- Form - 表单控件,中后台 CRUD 高频控件。
- Field - 字段控件,其内置了各种数据类型的渲染。
- currency
- number
- text
- percent
- progress
- date
- datetime
- time
- 等常见类型,全部类型参见 ProField 的 ValueType 枚举。
功能库:
- async-validator - 阿里前端开发的异步验证库,是 ant design 和 element ui 的验证库。
- joi - The most powerful data validation library for JS.
常见副作用 Common side effects
- 事件监听
- Window 全局级事件 https://developer.mozilla.org/en-US/docs/Web/API/Window
- DOMLoaded
- load
- unload
- unhandledRejection
- BOM 事件
- Mouse event
- Keyboard event
- focus/blur/scroll
- ...
- Window 全局级事件 https://developer.mozilla.org/en-US/docs/Web/API/Window
- 异步回调
- 事件回调
- 网络请求
- 定时器
- setTimeout
- setInterval
- process.nextTick => only for Nodejs
- 文件 I/O
- ...
资源处理 Resource processing
加载方式
- 动态资源
- XHR
-
请求功能:
- 类型适配 - adapter(适配器模式)
- 数据处理 - transformData(代理模式)
- 数据拦截 - interceptors(拦截器模式)
- 异常处理 - errorHandler
请求库对比:
- axios - 经典 XHR 请求框架,据说作者面试 Google 翻车后去了 Apple。
- useRquest - 阿里出品的 ahooks 库中的 request hook,同下面的 useSWR,但更简单好用?!能快速配合 react + ant design 生态。
- useSWR - React 社区很流行的一个 request library。
- react-query 相比swr feature更多,但是打包体积也更大。
-
- JSONP
- XHR
- 静态资源
- JS/CSS/HTML
- Image/Video/Audio
- ...
- 加载状态
- 加载中 - loading
- 加载完成 - success
- 加载失败 - failed
- 请求代码
- 使用 async/await + try catch finally 格式。
- 在 await 之后判断 code 状态码是否正常
- finally 中处理 loading = false
- catch 中处理 error (通常拦截器会统一处理异常,但某些特例 code 的情况下需要手动处理)
- 使用 async/await + try catch finally 格式。
数据类型:
- JSON
- XML
- Text
- HTML
- FormData
- Binary
- GraphQL
- ...
加载方式
- 同步加载
- 异步加载 - 懒加载/按需加载
提效工具 Tools
- Github Copilot
- Codeium
- VSCode Snippets
工作流工具 CI/CD
开发阶段:
- stylelint for css
- prettier for IDE
- eslint for typescript
- commitlint
- loglint? - changlog
- .editorconfig
部署阶段:
- Gitlab pipeline
- Jenkins web-hook
- Docker deploy
PS: 文章有我和另一位同事 @Liam 合作完成。