if 我是前端 Leader, 前端业务开发做不做设计?

if 我是前端 Leader 系列已经好久没更新了,我这两三年都去哪了?

Twitter

😂 有可能掉进了一个黑洞。不是 Byte Dance,现在国内大小公司都卷,整体行业的已经被带偏了,还有向其他行业蔓延的趋势… 真是好的不学


那我现在怎么又开始写文章了?

因为现在我不卷工作了,公司也开始的考勤打卡,我觉得挺好了,一切按规矩办事,到点就弹射下班。

工作只是生活的一部分而已,工作的目的本来就是为了生活过得更好不是吗?这才应该是正常的人生形态,你说是不是? 2023 年了,梦也该醒了


老贼




💥 另外,我这边也想挪坑了,Base 珠海、远程也可以,有坑位推荐的可以私信我,感激不尽





回到正题,做业务前端开发要不要做设计呢?我觉得大部分情况不需要,简单的增删改查业务,没有必要浪费时间去做这些,只要在产品侧描述清楚就行了。

如果业务比较复杂、涉及到多人分工和共识建立、而且项目预留的充裕的时间给开发者做预研和设计,那么做一下设计还是有必要的。

那怎么做呢?本文就介绍一下我在这方面的探索,希望能给读者提供一些借鉴。




0. 为什么需要设计?

一次做好

  • 敏捷 DoD 有个设计环节,后端通常有设计和评审环节,这个阶段前端应该有什么产出?
  • 前端专业性体现在哪里呢?
  • 和 DDD 一样。 在开始开发之前,把设计工作做好,开发就是照葫芦画瓢,我们的工作更容易预测,没有惊喜。很多问题在 Code Review 阶段发现有可能已经晚了,何况我们 Code Review 还没做好?
  • 设计不是一个人的事情,我们要利用集体智慧,把事情做好。 以后前端也会有一个技术评审会议。这是除 Code Review 之外,难得一次技术上的沟通和知识互换。
  • 写完代码之后呢?文档呢?听说过文档驱动开发吗?设计阶段的产出就是我们的文档。






1. 画好业务流程图

设计的第一步是梳理业务。这个不是产品的责任吗?产品会提供 PRD、原型、用户故事等需求输入,但是下游的开发、测试还需要进一步消化,因为职责的不同,立场和关注点也是有差异的。

因此,笔者设计了一套适合前端的业务流程图绘制规范。关注点在于:

  • 用户与 UI 的交互流程
  • 页面或模块的拆分
  • 页面或模块之间的数据流
  • 以及页面或模块的状态流转




1.1 图例和要点

示例

图例


图例




要点

  • 流程图要表示业务流程的闭环,而不是前端的局部交互
  • 和传统业务流程图不太一样的是,我们的业务流程图也会关注用户的交互流程
  • 尽量使用业务语言,而不是技术语言
  • 使用泳道来表示业务环节由什么容器来承载。
  • 梳理流程图时不要过度关注技术实现细节

通过流程图可以提供什么信息?

  • 熟悉业务规则,比如业务的边界条件、业务主体状态的流转规则、流程数据(通信规则)。
  • 分析模块之间的依赖关系。
  • 页面的状态(有限状态机)。

无法提供什么信息?

  • 无法体现技术设计细节
  • 无法体现视图的呈现细节

→ 这部分由概要设计来弥补






1.2 案例

统一使用 draw.io 来绘制流程图。将流程图保存在项目根目录下的 docs 下,跟随代码一起存储和更新。

推荐 VSCode draw.io 插件


案例 1: 营销拼团

拼团.jpg

要点:

  • 使用不同的泳道来表示页面
  • 不是该领域的流程放在 其他领域 或者 外部域 , 这些不是该业务域的核心问题。通常也不是由该业务域来实现。
  • 使用子视图分离团长和团员的不同角色的业务
  • 使用黄色标记跨泳道之间的流程,用蓝色标记角色的业务发起点。








案例 2: 优惠券

优惠券

要点:

  • 如果多个页面的业务存在重复,可以在泳道上进行一些合并,例如
    合并子视图










案例 3: 活动预约

活动预约流程图






2. 做好概要设计

业务流程图可以梳理待开发的业务流程、业务主体状态、依赖关系等等。这里并没有包含太多前端技术设计细节,概要设计就是为了弥补这块的空白。

我在 if 我是前端团队 Leader,怎么做好概要设计 讲过类似的话题,可以结合一起看吧。






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 # 通用注册路由,引用业务域的页面




输出案例


# 优惠券

## 页面设计

所属分包: member


页面 路径 命名 params data backMessage




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



模型层对应的行为触发时,也可以对状态进行断言检查(assert, 或者转换守卫 guard):

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}`)
}
}
}

当然,对于复杂的页面,状态不会像上述的那么单一, 比如:

  • 有可能存在多个业务主体(可以理解为业务的参与角色,比如拼图有团长、团员),且不同业务主体有不同状态和转换逻辑
  • 甚至状态还可以嵌套子状态(复合状态 Compound states)、并行状态(Parallel states)
  • 状态流转规则复杂等等。




就拿发起拼团这个例子来说:

多个嵌套状态,可以由多个状态变量来控制。

多个嵌套状态,可以由多个状态变量来控制。

如上所示,一个复杂业务流程会涉及很多子状态,在设计阶段我们需要将 不同的主体的状态 识别出来。后期就围绕着这些状态进行开发。






好在我们在梳理业务流程图时,已经将相关规则梳理清楚了。识别这些状态并不难。更重要的是,这是一种业务建模思维的转变。

如果你想要深入学习和理解状态机, 或者在项目中严谨应用状态机,不妨试一下更专业的 XState







2.2.2 模型设计






模型(Model) 是一个核心对象,它承载了核心的业务逻辑。模型类中应该包含哪些内容呢?

模型内容

  • 业务状态。即我们在上一节中识别出来的业务状态。在模型层中会为不同’主体‘创建一个状态变量,用于存放当前的状态。

  • 业务数据。例如活动详情、当前选中数据、活动列表等等。

  • 计算数据/衍生数据。在业务数据的基础上计算出来。我们建议你不要去直接修改业务数据,而是优先基于业务数据去推断、计算你想要的数据。

  • 行为。模型就是是 数据+行为。通常行为可以总结为以下集中

    • 状态变更、流转。比如下单、发起拼团,触发业务状态之间的流转。
    • 业务数据变更。比如修改选中的商品、删除列表项。
    • 数据持久化。调用持久化层相关接口,对业务数据/状态进行持久化。
  • 事件。事件是模块解耦、实现扩展的一种重要手段。通常模型会抛出下列事件:

    • 业务状态变更。
    • 异常情况。
    • 考虑扩展点

  • 模型生命周期。使用依赖注入框架之后,需要关心这个问题,决定单例还是非单例
    原则是如果你的模型需要在整个应用生命周期中存在,则使用单例,例如登录、会员信息这些。大部分场景都应该使用非单例,跟随页面释放而释放。






2.2.3 输出案例


以登录 SDK 为例:

  • 业务状态:



    Untitled

    • 登录状态
      • 初始化:创建会话
      • 登录中
      • 登录成功:重新登录、更新用户信息、退出登录
      • 登录失败:再次登录

  • 业务数据

    • 会话信息
    • 失败信息
    • 重新登录的次数
  • 衍生数据:这些信息都从会话信息中提取出来
    • 已登录?
    • 已注册?
    • 会话 id
    • 用户信息
  • 行为
    • 创建会话
    • 重新登录
    • 退出登录
    • 等待登录成功
    • 更新用户信息
  • 事件
    • 缓存会话恢复
    • 登录前
    • 初次登录成功
    • 登录成功
    • 登录失败
    • 会话刷新
    • 退出登录
    • 用户信息更新
  • 模型生命周期:单例




2.3 视图设计:组件拆分和设计

组件的拆分和设计是前端设计的重头戏,合理拆分组件,可以提高代码复用率和后期的可维护性。关于如何拆分和设计组件见 组件设计指南 、以及 React 组件设计实践相关文章




组件设计


案例:

NoticeBar 滚动公告栏

NoticeBar 原型

属性

属性 说明 类型 默认值
mode 通知栏模式,可选值为 'closeable' / 'link' string ‘’
text 通知文本内容 string ‘’
color 通知文本颜色 string #f60
background 滚动条背景 string #fff7cc
leftIcon 左侧图标名称或图片链接 string -
rightIcon 右侧图标名称或图片链接 string -
delay 动画延迟时间 (s) number string 1
speed 滚动速率 (px/s) number string 60
scrollable 是否开启滚动播放,内容长度溢出时默认开启 boolean -
wrap 是否开启文本换行,只在禁用滚动时生效 boolean false




事件

事件名 说明
onClick 单击通告栏时触发
onClose 关闭通告栏时触发




插槽

名称 说明
children 通知栏内显示内容
leftIcon 自定义左边图标内容
rightIcon 自定义右侧图标内容






2.4 扩展点设计

如果你开发的是 SDK (即面向其他开发者),那就需要考虑扩展性问题,你的程序需要考虑各种场景的使用,比如对于 ToB 行业, 需要考虑二开、项目交付时,对你的程序进行各种粒度的定制。我在 2B or not 2B: 多业态下的前端大泥球 这篇文章也讨论过相关的背景。。




扩展点实现方式:

  • 使用依赖注入形式。依赖注入点可以由外部进行重新定义
  • 事件/回调。




案例:

登录 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; | 鉴权失败 |






4. 评审




当汽车到达一定的速度时,大部分的能耗用在了克服空气阻力(比如当到达 120km/h 时,大于 60%,随着速度的提升,这个比例会越来越高)。

这个适用于软件开发,随着团队规模的扩大,我们会花费大量时间用于“达成共识”。

这包括面对面的会议、电子邮件、即时消息、编写和阅读文档等各种形式。这是因为软件开发不仅仅是编写代码,更是需要理解业务需求、解决问题、协调任务、分享知识等。

软件开发中有很多工具 和方法论,可以帮助提升“达成共识”的效率,近些年最为出名的应该是 DDD 了,比如它强调引入领域专家来指导软件的设计、划分边界上下文、统一语言等等。

我们进行软件设计,也是出于此目的。因此一定要有评审,在这个过程中进行碰撞、纠错、最后达成共识。






总结

上文给做前端业务开发怎么做设计打了个样,主要脉络是:


脉络

  • 业务梳理:我们定义了流程图的绘制规范。通过流程图来分析用户与 UI 的交互流程,凸显页面/模块之间的关系、状态的流转。
  • 模型设计:设计视图无关的状态和行为。这里引入了状态机的概念
  • 视图设计:页面、组件的拆分以及输入/输出的设计。


这些规范和观点可能并不完全适合你们的团队。为此,你们需要找出自身所面临的问题,然后采取行动,来构建出符合你们需求的设计规范。接着,在不断的迭代过程中,逐步完善和优化这些规范。


系列文章:


💥 另外,我这边也想挪坑了,Base 珠海、远程也可以,有坑位推荐的可以私信我,感激不尽