前端设计文档规范
为什么需要设计?
- 敏捷 Story DoD 有个设计环节,这个阶段前端应该有什么产出?
- 前端专业性体现在哪里呢?
- 和DDD 一样。 在开始开发之前,把设计工作做好,开发就是照葫芦画瓢,我们的工作更容易预测,没有惊喜。很多问题在 Code Review 阶段发现有可能已经晚了,何况我们 Code Review 还没做好?
- 设计不是一个人的事情,我们要利用集体智慧,把事情做好。 以后前端也会有一个技术评审会议。这是除 Code Review 之外,难得一次技术上的沟通和知识互换。
- 写完代码之后呢?文档呢?听说过文档驱动开发吗?设计阶段的产出就是我们的文档。
看一下这个项目开发流程图,当项目启动之后,产品的同事会先来一轮需求宣讲,告知大家本期大致做什么,产品的同事会整合各方的发言在完善一轮功能。
全部功能确定之后,会进入需求讨论,这个时候,开发会明确这一期需求中,到底要做哪些功能。也就进入了今天的正题,如何做开发设计。
为什么推荐写设计文档
我们先来回想一下最近的一次需求,下面几个问题,能回忆起几个:
- 哪些功能是相似的?相似功能有没有做通用处理?
- 哪个功能实现起来有难度?最后是怎么解决困难点的?
- 主业务流程是什么?有几条不同的业务流程?业务代码设计的可扩展性怎么样?
- 新增了几个公共组件?用途分别是什么?
- 老代码有改动吗?新老代码的兼容性是怎么处理的?
- 有没有全新的、以前从没接触过的业务概念和体系,是如何应对全新业务的?
- 有没有连带功能?连带功能的覆盖范围是哪些?
产品视角的需求文档,需要在哪些地方做什么样的功能提供给用户。
开发视角的需求设计,不但要梳理清楚具体功能、功能所在的位置,还要考虑代码的质量。程序开发,不是单纯的代码堆砌。所以技术论坛了,我们看到很多优秀的怎么实现某类功能的文章。
设计文档是帮开发梳理业务功能,呈现优质的开发思维的载体。另外,当开发思路逐渐丰富,开发速度也就提上来了。
设计文档写什么?
上面的几个问题,也侧面的描述了设计文档中要写哪些内容。主要包括业务流程梳理,功能点梳理,功能的封装、通用、可扩展、兼容性设计,重点和难点归纳和实现方案,新业务概念和体系的应对方案,连带功能的覆盖范围总结。
1. 业务流程图
1.1 图例和要点
图例:
- 泳道
泳道是业务承载的容器。比如页面、模块、业务主体、某些特殊终端(比如短信、消息通知)、组件。
- 子视图
泳道里也可以进一步拆分子视图,用于分离面向不同目标的业务。比如一个页面有多个角色参与,就可以为这些角色单独创建一个子视图。
- 条件
业务分支
- 动作
可以是业务流程的某个环节,或者由用户、外部触发的事件。
动作也可以由状态关联,表示该状态下支持的动作。例如:
状态关联的动作,表示业务主体处于该状态时,支持这些动作
- 状态
业务主体当前的状态。
状态通常也作为流程的结束节点
- 数据流
用于表示不同业务环节之间流转、不同业务主体之间交互,所需要的数据。
- 角色
参与业务的角色
- 起始节点
起始节点通常是角色发起的动作。如果是泳道内部的流程其实节点,可以使用这个图例。
- 注释
用于详细描述业务环节、规则和状态
要点:
- 流程图要表示业务流程的
闭环
,而不是前端的局部交互
。 - 和传统业务流程图不太一样的是,我们的业务流程图也会关注
用户的交互流程
。 - 尽量使用业务语言,而不是技术语言。
- 使用泳道来表示业务环节由什么容器来承载。
- 梳理流程图时不要过渡关注
技术实现细节
。
通过流程图可以提供什么信息?
- 熟悉业务规则,比如业务的边界条件、业务主体状态的流转规则、流程数据(通信规则)
- 分析模块之间的依赖关系。
- 页面的状态
无法提供什么信息?
- 无法体现技术设计细节
- 无法体现视图的呈现细节
→ 这部分由概要设计来弥补
1.2 案例
要点:
- 使用不同的泳道来表示页面
- 不是该领域的流程放在
其他领域
或者外部域
, 这些不是该业务域的核心问题。通常也不是由该业务域来实现。 - 使用子视图分离
团长
和团员的不同角色的业务 - 使用黄色标记跨泳道之间的流程,用蓝色标记角色的业务发起点。
2. 概要设计
业务流程图可以梳理待开发的业务流程图、业务主体状态、依赖关系等等。这里并没有包含太多前端技术设计细节,概要设计就是为了弥补这块的空白。
2.1 页面/模块拆分
根据业务需求以及产品原型对业务域内的页面进行拆分。页面拆分是前端设计中最简单的一个环节,主要涉及:
页面路由定义。
- 页面命名。我们推荐使用别名导航,而不是路径导航。因为路径的可读性较差、变动的频率也更高。
- 页面路径。
- 分包规划。在小程序中,分包直接影响页面路径,以及后期发布。能不放在主包的就不放在主包。
页面通信协议设计。
- 路由参数(params)。设计携带在页面 URL 上的关键参数(查询字符串)。例如商品详情页面,id 表示商品 id。
- 通信协议。如果路由参数无法满足需要,需要在页面之间
传递大量数据
或者引用类型值
, 则需要用到内存通信
。- 输入(data)。
- 输出(backMessage)。页面返回参数。
⚠️ 大部分场景我们不推荐使用内存通信,因为这会造成页面之间的耦合、丧失独立运行能力、且无法分享到外部。因此在审核设计时要考虑有没有必要用内存通信。
目录规划。原则是按业务聚合而不是职能聚合。我们推荐将同一个业务域下的组件、API、模型、页面都聚在一起,而不是按照功能分散在程序多处。
# ❌ 按职能聚合
/components
/a
/b
/c
/pages
/page-a
/page-c
/api
/utils
# ✅ 按业务域聚合
/modules
domain-a/ # 业务模块
components/
/a
/b
page-a.tsx
api.ts
utils.ts
domain-b/ # 业务模块
components/
/c
page-b.tsx
api.ts
routes.ts # 通用注册路由,引用业务域的页面
2.2 模型拆分和设计
模型用于放置业务逻辑和业务状态。
2.2.1 业务状态机/业务主体生命周期
通过上面的流程图,我们可以发现很多业务就是一个状态机
,而前端页面无非在不同的状态下,支部不同的呈现和操作。
例如拼团详情页状态机:
我们可以从上图抽象出三个状态(等待拼团、拼团过期、拼团成功、拼团取消),以及挂靠在不同状态下的不同动作。
最简单的实现是用一个状态枚举来表达它:
enum GroupStatus {
Pending = '等待',
OutDated = '过期',
Success = '成功',
Cancelled = '取消'
}
在视图层,我们可以给这些状态区分不同的呈现:
status === GroupStatus.Pending ?
<ButtonGroup>
<Button>取消拼团</Button>
<Button>分享拼团</Button>
</ButtonGroup>
: status === GroupStatus.OutDated || status === GroupStatus.Cancelled ?
<ButtonGroup>
<Button>再次拼团</Button>
</ButtonGroup>
: status === GroupStatus.Success ?
<ButtonGroup>
<Button>查看订单</Button>
<Button>再次拼团</Button>
</ButtonGroup>
: null
如果不同状态下视图有较大差异,可以将每个状态抽离成单独的组件。
模型层对应的行为触发时,也可以对状态进行检查:
class GroupModel {
status: GroupStatus
// ...
/**
* 取消拼团
*/
cancel() {
// 状态检查
this.assertStatus(GroupStatus.Pending, '取消拼团')
await this.repo.cancel(this.id)
// 状态流转
this.status = GroupStatus.Cancelled
}
/**
* 状态检查
*/
assertStatus(status: GroupStatus, message: string) {
if (this.status !== status) {
throw new Error(`程序异常:只能在 ${status} 状态下,才能 ${message}`)
}
}
}
对于复杂的页面,状态不会像上述的那么单一,有可能存在多个业务主体,且不同业务主体有不同状态,甚至状态还可以嵌套子状态、状态流转规则复杂等等。
就拿发起拼团这个例子来说:
如上所示,一个复杂业务流程会涉及很多状态,在设计阶段我们要将 不同的主体的状态 识别出来。后期就围绕着这些状态进行开发。
状态机学习资料:
- 产品之术:一目了然的状态机图
- 如何绘画状态机来描述业务的变化
2.2.2 模型设计
模型(Model) 是一个核心对象,它承载了核心的业务逻辑。模型类中应该包含哪些内容呢?
业务状态。即我们在上一节中识别出来的业务状态。在模型层中会为不同’主体‘创建一个状态变量,用于存放当前的状态。
业务数据。例如活动详情、当前选中数据、活动列表等等。
计算数据/衍生数据。在业务数据的基础上计算出来。我们建议你不要去直接修改业务数据,而是优先基于业务数据去推断、计算你想要的数据。
行为。模型就是是 数据+行为。通常行为可以总结为以下集中
- 状态变更、流转。比如下单、发起拼团,触发业务状态之间的流转。
- 业务数据变更。比如修改选中的商品、删除列表项。
- 数据持久化。调用持久化层相关接口,对业务数据/状态进行持久化。
事件。事件是模块解耦、实现扩展的一种重要手段。通常模型会抛出下列事件:
- 业务状态变更。
- 异常情况。
- 扩展点
TIP
不过不是所有业务状态变更事件都应该抛出来,因为:
- 不是所有业务状态变更事件都能在前端捕获到。前端只是业务流程的局部,能被前端捕获的往往是由页面在界面触发的。
- 不是所有事件抛出去都有意义。结合实际场景来看,比如需要在这个事件触发时进行埋点。
模型生命周期。使用依赖注入框架之后,需要关心这个问题,决定单例还是非单例?
原则是如果你的模型需要在整个应用生命周期中存在,则使用单例,例如登录、会员信息这些。大部分场景都应该使用非单例,跟随页面释放而释放。
2.2.3 输出案例
以登录 SDK 为例:
- 业务状态:
登录状态
- 初始化:创建会话
- 登录中
- 登录成功:重新登录、更新用户信息、退出登录
- 登录失败:再次登录
业务数据:
- 会话信息
- 失败信息
- 重新登录的次数
**衍生数据:**这些信息都从会话信息中提取出来
- 已登录?
- 已注册?
- 会话 id
- 用户信息
行为:
- 创建会话
- 重新登录
- 退出登录
- 等待登录成功
- 更新用户信息
事件:
- 缓存会话恢复
- 登录前
- 初次登录成功
- 登录成功
- 登录失败
- 会话刷新
- 退出登录
- 用户信息更新
模型生命周期:单例
2.3 组件拆分和设计
组件的拆分和设计是前端设计的重头戏,合理拆分组件,可以提高代码复用率和后期的可维护性。
输出案例:
NoticeBar 滚动公告栏
属性
属性 | 说明 | 类型 | 默认值 |
---|---|---|---|
mode | 通知栏模式 | string | - |
text | 通知文本内容 | string | "" |
color | 通知文本颜色 | string | |
background | 滚动条背景 | string |
事件
事件名 | 说明 |
---|---|
onClick | 单击通告栏时触发 |
onClose | 关闭通告栏时触发 |
插槽
名称 | 说明 |
---|---|
children | 通知栏内显示内容 |
leftIcon | 自定义左边图标内容 |
rightIcon | 自定义右侧图标内容 |
2.4 扩展点设计(SDK 开发)
如果你开发的是 SDK,那就需要考虑扩展性问题,你的程序需要考虑各种场景的使用,考虑行业团团、项目交付时,对你的程序进行各种粒度的定制。
扩展点实现方式:
- 使用依赖注入形式。依赖注入点可以由外部进行重新定义
- 事件。
输入案例:
登录 SDK 扩展点
## 暴露的扩展点
| 名称 | 说明 | 单例 |
| ------------------------------------------------- | -------------------------------------------------------------- | ---- |
| 'DI.login.SUPPORT_QUICK_PHONE_AUTH': boolean; | 是否支持快捷手机号码授权, 默认 true |
| 'DI.login.SUPPORT_QUICK_USER_INFO_AUTH': boolean; | 是否支持快捷用户授权,默认 true |
| 'DI.login.QUICK_PHONE_AUTH_TEXT': string; | 手机号码快捷登录文案, 默认为手机号码快捷登录 |
| 'DI.login.QUICK_USER_INFO_TEXT': string; | 快捷用户信息获取, 默认为 允许授权 |
| 'DI.login.ROUTE_PROTOCOL_DETAIL': string; | 服务协议详情页面, 默认为 protocolDetail(命名路由) |
| 'DI.login.MAX_RELOGIN_COUNT': number; | 最大重新登录次数, 默认为 10 |
| 'DI.login.VERIFY_TIMEOUT': number; | 发送验证码超时时间, 默认 60 秒 |
| 'DI.login.LOGIN_API': string; | 登录接口路径, 默认 /login_v3/login_v3 |
| 'DI.login.USER_RULE_API': string; | 用户服务协议列表接口路径, 默认 /wk-base/c/agreement/queryList |
| 'DI.login.REGISTER_API': string; | 注册用户接口路径, 默认 /cs/auth/user/register/v3 |
| 'DI.login.UPDATE_USER_API': string; | 更新用户信息接口路径, 默认 /cs/auth/vip/user/update_user |
| 'DI.login.SEND_PHONE_VERIFICATION_API': string; | 发送验证码接口路径, 默认 /cs/auth/user/send_register_code |
| 'DI.login.PLATFORM': PlatformType; | 当前平台 |
| 'DI.login.Implement': ImplementProtocol; | 平台适配实现 | yes |
| 'DI.login.LoginRepo': LoginRepo; | 登录接口实现 | yes |
| 'DI.login.LoginModel': LoginModel; | 登录模型 | yes |
| 'DI.login.RegisterModel': RegisterModel; | 注册模型 | yes |
| 'DI.login.PhoneVerifyModel': PhoneVerifyModel; | 手机验证码模型 |
<br>
<br>
## 暴露的事件
| 标识符 | 描述 |
| --------------------------------------------------------------------- | ---------------------------- |
| 'Event.login.onRecover': SessionInfo; | 从缓存中恢复 |
| 'Event.login.onBeforeLogin': undefined; | 登录前 |
| 'Event.login.onSetup': SessionInfo; | 首次登录完成 |
| 'Event.login.onLogined': SessionInfo; | 已鉴权,鉴权成功 |
| 'Event.login.onLoginFailed': Error; | 鉴权失败 |
| 'Event.login.onLoginComplete': { info?: SessionInfo; error?: Error }; | 登录完成,可能成功,可能失败 |
| 'Event.login.onRefreshed': SessionInfo; | 会话刷新成功 |
| 'Event.login.onRefreshFailed': Error; | 会话刷新失败 |
| 'Event.login.onLogout': never; | 退出登录 |
| 'Event.login.onUpdateInfo': SessionInfo; | 更新信息成功 |
| 'Event.login.onUpdatedUser': UserInfo; | 更新用户信息 |
| 'Event.login.onUpdatedUserFailed': Error; | 更新用户信息失败 |
| 'Event.login.onBeforeRegister': RegisterOptions; | 注册前 |
| 'Event.login.onRegistered': UserInfo; | 注册成功 |
| 'Event.login.onRegisterFailed': Error; | 鉴权失败 |
3. 设计输出模板
业务流程图
页面拆分
业务状态
模型设计
组件拆分
扩展点