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

16,959 阅读15分钟

《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

页面路径命名paramsdatabackMessage


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 为例:

  • 业务状态:


    状态


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

    • 会话信息
    • 失败信息
    • 重新登录的次数
  • 衍生数据:这些信息都从会话信息中提取出来

    • 已登录?
    • 已注册?
    • 会话 id
    • 用户信息
  • 行为

    • 创建会话
    • 重新登录
    • 退出登录
    • 等待登录成功
    • 更新用户信息
  • 事件

    • 缓存会话恢复
    • 登录前
    • 初次登录成功
    • 登录成功
    • 登录失败
    • 会话刷新
    • 退出登录
    • 用户信息更新
  • 模型生命周期:单例



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

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



组件设计


案例:

NoticeBar 滚动公告栏

NoticeBar 原型

属性

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


事件

事件名说明
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 珠海、远程也可以,有坑位推荐的可以私信我,感激不尽