为什么要进行开发设计

之前我司前端团队的开发设计是缺失的,原因也很简单,对于公司大部分产品,一个产品基本只需要一个前端,自己一个人开发,想到哪就写到哪了,何必去费力做开发设计呢?我以前也并不重视开发设计,直到后来参与了 code review 的工作,因为日常被 mr 折磨得很痛苦,绞尽脑汁琢磨怎么能让 mr 变得轻松高效一些,才开始重视开发设计。

在我看来,做开发设计,有以下这些好处:

对于需要对仓库代码质量负更多责任的 maintainer 来说,开发设计使 code review 的质量更高了——

  1. 开发设计可以使 mr 进行细粒度拆分,避免过于庞大的 mr 提交上来。mr 的篇幅应尽可能小,最好一个提交就提一个 mr,但将一个大的需求拆分成多个 mr,reviewer 又难以从宏观上了解这些提交之间的联系。面对这种情况,要么就降低 code review 质量,仅评审代码中的坏味道,要么就不要拆分 mr,一个 mr 中包含全量的代码,这两个选择都不是最优解。只有提前对接过开发设计,reviewer 才能每次只看到一小部份代码却能了解整个模块的实现思路,从而进行更高质量的代码评审。
  2. 开发设计可以让 reviewer 在代码评审时更容易理解读懂代码。如果开发设计的质量足够高,对于组件的拆分以及数据的流动都描述得很清晰的话,reviewer 对照着开发设计可以更容易地知道当前的代码是在干嘛。
  3. 开发设计将发现实现瑕疵的阶段从编码后(cr 时)提前到了编码前,降低改动成本。改文档肯定比改代码要容易很多,而且一般一个需求到了提交 mr 阶段,离 ddl 也就不远了,这个时候如果发现了比较严重的设计问题,要么延误工期,要么就先这么凑合提交上去,让烂代码合并到公共分支上去(其实这种情况也有个亡羊补牢的办法,那就是开个 jira 跟踪一下,如果 jira 都没有的话,这样的代码可能就真的要长久地存在了)。良好的开发设计可以杜绝这样的情况出现,将严重的设计问题在编码之前就发现并解决掉。
  4. 开发设计还能让代码评审双方保持好心情。自己辛苦写了好几天的代码,提交上去被提了一大堆修改意见,甚至可能还要重写,这种情况下 committer 心生怨念也实乃人之常情,而 reviewer 看到不太好的代码,可能也会无奈和吐槽。而如果提前做了开发设计的话,即使开发设计被毙掉,重写开发设计也比重写代码要容易太多了,这两种情况下,评审双方的心态是不同的。

可以说,如果没有开发设计文档,mr 更多只能承担一些对于代码坏味道的过滤作用,但是将良好的开发设计与 mr 结合,可以让 mr 不仅局限于细枝末节的“挑刺”,也能在更高维度上,对写出优雅、高效的代码设计做出贡献。

除了对 code review 有好处,开发设计对于开发者自己同样好处不小——

  1. 帮助开发者自己想清楚实现细节。面对简单需求时,如果开发者经验丰富,可能确实不需要开发设计;但如果是复杂需求,仅靠脑补是很难在一开始就做出合理的设计的,如果轻易开始编码,就有可能需求做到一半,才发现更合理的方案,可已经确定的实现却很难再改变了。这种时候就需要进行开发设计,在将自己的想法输出成图文时,空想难以梳理的细节也会渐渐变得清晰。
  2. 作为日后维护的参考文档。哪怕一个开发者水平很高,代码很优雅,注释也到位,没有整体的设计文档,这样的代码也很难让人第一时间从宏观上理解清楚。有了开发设计作为宏观的代码讲解,下一个接手这份代码的人可以以最快的时间搞明白一个模块的架构,了解每一个组件完成了什么事,每一个状态该如何变化。而这下一个接手的人,其实更可能是半年后的开发者自己。当然这也要求以后每当有新的需求变更或重构,也要记得更新设计文档。
  3. 锻炼开发者的表达能力。我认为对于程序员来说,一个很重要却不太被重视的软技能就是表达能力。在软件工程中,编码只是其中一小部分工作,程序员的工作离不开与人的沟通,写作就是很好的锻炼表达能力的方式。让别人都能理解你说的话,你才能更好地展开自己的工作。

综上所述,并不是只有多人协作的团队才需要进行开发设计,即使单人成军,开发设计也是很有必要的。

工作流

在我负责的产品中,我所制订的工作流是这样的——

dev-design-1.jpeg

重点是两处紫色虚线的位置——

  1. 代码中要注释 issue 的链接,gitlab issue 的链接是可以具体到每个标题的锚点的,每个在开发设计中提到的模块,其注释都要具体到对应模块的设计。
  2. mr 与 issue 之间要有双向的链接。使用 gitlab 的 #! 可以方便地引用 issue 和 mr。

如何做好开发设计

首先要明确一个核心观点——开发设计应尽可能详细,尽可能能消除未知的可能性,看到开发设计就能知道代码只可能写成什么样。

当然在开发设计文档中事无巨细地介绍也没什么必要。如果可以在团队内制订一套开发规范,比如该用什么请求库,什么状态管理方案,什么 linter 规则等等,并在团队内严格执行,那开发设计文档的内容可以精炼很多。

一份好的开发设计,需要包含以下几个部分:

需求文档与后端开发设计文档

任何需求都需要产品提供文档,大需求 confluence,小需求 jira,不能口头需求。而后端开发设计文档中通常包含了相关接口的调整,也有必要引入进来。

需求分析

需求分析是对需求文档的提炼,从比较偏业务描述的需求文档中提炼出水面之下暗含的功能点,也是下面具体设计的大纲。比如这个需求涉及到了哪些组件,哪些逻辑需要废弃,哪些逻辑需要调整,要新增什么组件和逻辑,等等。

另外再提一嘴,其实很多做得不太好的开发设计,仅仅只到需求分析的程度而已。

开发设计

我最看重的设计有两部份——一是组件拆分,二是数据流。这两个问题解决了,在架构设计上基本就没什么问题了,返工重写的可能性也就基本不存在了,剩下的问题更多是一些细节性的代码的坏味道。

组件拆分

对于组件的拆分,比如我要设计一个布局组件,可以画一张简单的原型图来说明——

dev-design-2.jpeg

上面是使用 excalidraw 绘制的原型图,一图胜千言,通过一张原型图,就可以清晰地表达一个 Layout 组件会被拆分成哪些子组件。

然后再辅以一些必要的文字描述,比如上图中的 <MainBody /> 组件,它其实不仅是网站主题内容的容器,当出现如 404 等错误的时候,它会渲染兜底的 Fallback 页面,这是需要被指明的。

数据流

数据流的设计会麻烦很多,从前端组件被首次渲染,请求到后端的数据开始,到前端将数据返回给后端,组件被卸载结束,这期间数据是如何流动的?数据被存储在哪里,简单的 useState,还是状态管理库,还是 context + reducer?数据结构将在何时发生怎样的变化?这些变化是如何触发的,所有的事件该如何设计?数据的变化将如何影响 UI?等等……这些都需要在开发设计中体现出来。

以上这么多的细节,只用文字描述起来并不容易,评审者也未必能轻松看懂,所以还是建议使用画图的方式。

首先我们要有一个整体的设计。因为有时会有好几个组件都读或写同一个全局状态,这时就需要先从整体的角度讲明白各组件间的关系。把这次开发设计针对的模块里的全局状态列出来,将每个状态会在什么地方被更改描述清楚。这种整体设计也很适合画图,将数据的流向用箭头和图形表达出来。

整体设计描述清楚后,就是各个子模块的具体设计了,以上图中右上角的 UserProfile 组件举例,UserProfile 组件用于切换用户,不同的用户,在相同的路由下看到的页面数据也是不同的,而且在切换用户时,还需要对下一个用户进行鉴权,如果没有对应路由的权限,则返回到用户的首页。

比如我们要设计 UserProfile 中切换用户的功能——

dev-design-3.png

在 UserProfile 中请求用户列表,选择用户列表时触发一个将 userId 传入 store 中的 action。userId 变更,全局所有将 userId 作为请求参数的接口,只要还在页面上的,都会重新请求最新的数据,这样,切换后的新用户就能看到属于他的数据了。InitProvider 这个组件也有依赖了 userId 的接口,该组件负责的就是获取用户信息、对用户的鉴权与重定向的功能。

接下来就是对 InitProvider 的设计。

实际情况可能会很复杂,要考虑各种边界情况,比如我所负责的产品中投入生产的 InitProvider,其设计是这样的——

dev-design-4.png

由于内容可能涉密,所以我对其进行了模糊处理。如果之前没有画过流程图,要进行复杂的设计可能会觉得无从下手,但熟练以后,画出这样,甚至是更复杂的设计图也并不是什么难事。

还是那句话,一图胜千言,如果想仅靠文字把这样复杂的设计描述清楚,恐怕得写一篇作文出来,而使用图片配合文字,会极大降低描述与阅读的难度。

画图的技巧

要想画出合理的设计图,还需要一些技巧——

  1. 对于全局状态,只要对其有 set 操作,就要标明可能 get 引用它的地方。更直白一点说,对于全局状态,只要有指向它们的箭头,那就也一定要有从它们指向下游的箭头。比如上面对于 UserProfile 组件的设计,在组件中切换用户,会触发 store 中 userId 的变更,这个变更又会有什么样的影响,需要表达出来。这样其它看到设计文档的人就能知道,userId 变化会导致 InitProvider 触发些什么行为,然后他就会去看 InitProvider 的设计与实现。如果不表达出这样的影响,其它人可能不会知道 InitProvider 与 UserProfile 之间还存在这样的关联。这也是在对每个具体组件做设计之前,先要进行整体设计的原因,总之要尽可能地让其他人更直观地看到状态是如何变化的
  2. 有明确的边界。还是拿上面的 UserProfile 举例,UserProfile 的流程设计图中,指明其会影响到 InitProvider 就够了,不宜再做过多深入。因为 InitProvider 有自己单独的设计图,在 UserProfile 中表述过多,会让重点变得模糊,组件间的设计图分离度不够。
  3. 形成自己的规范,并持续迭代。在我的设计图中,所有流程图的起点都用红色椭圆表示,所有组件都用无色矩形,所有的事件都用无色椭圆,所有的 action 都用绿色矩形…等等。当一个团队整体都建立起了这样的规范,大家看到一张设计图就可以很容易地抓住重点。规范形成以后,并不是一成不变的,如果以后发现了更高效合理的方式,就可以确定为新的规范。
  4. 不必拘泥于设计图的形式。比如上面对于 UserProfile 的设计,看起来很像流程图,但也不完全是,其中还穿插了简单的组件原型,通过更直观的方式表达出用户被选择时会发生什么事。再比如你要重构一个组件,那其实可以把 props、state 以及方法的变化用 diff 的方式描述出来。不需要拘泥于画图的形式,只要能更容易地表达出你的想法,并能让他人轻松理解,什么样的设计图都是值得尝试的。
  5. 使用合适的工具。在 iPad 上我喜欢使用概念画板,而在桌面端我推荐 excalidraw.com,它的 PWA 体验非常好,手绘风格很有特色,而且是开源可自建的。你也可以使用 visio,或其它任何合适的画图工具。

拆分任务项

面对复杂的需求,将其拆解成若干个子任务是很有必要的。这样可以保证提交和 mr 的粒度足够细,更有利于团队其他成员掌握进度,不至于好多天没有声音,临近 ddl 时才突然给 reviewer 一个“大惊喜”。细粒度的提交对于日后维护、debug 也很有好处。

在 issue 中将这次的需求拆分成若干任务项,列出一个 todo list,每个任务项都对应一个 mr,而不是整体作为一个 mr 提交。mr 与 issue 的任务项之间要有双向链接,方便他人追溯。

其它

除了以上几点,还有一些事项是可能需要表现在开发设计中的。

  1. 技术选型。如果你所在团队或项目的技术栈并不唯一,那技术选型也是有必要说明的。像上文中 UserProfile 设计图里提到的,userId 变更,所有依赖该字段的接口都重新发起请求,就是依赖了 react-query 提供的能力,如果 react-query 并不是项目中唯一的网络请求库,那是有必要在开发设计中说明的。
  2. 数据结构。如果这次的需求涉及到数据结构的频繁变化,比如要开发一个表单页面,那要对完整的数据结构及改变它的事件做详细描述。如果只涉及到数据结构的 get 操作,则视情况决定是否要描述完整的数据结构,一般情况下,在各个设计图中指明读取了数据结构中的哪部分就可以了。
  3. 前因后果。一段没头没尾的设计会让看它的人摸不着头脑,因此要把前因后果表达清楚。怎么才算“表达清楚”呢?想象自己是完全不了解这块需求的人,将前因后果描述到自己不看代码,就能看懂设计的程度。通常来说,简单的一两句话的背景介绍是不会达到“表达清楚”的程度的。

原创作品自问世起即受到版权保护,欢迎前往 github 交流,请勿抄袭❤