编写可维护的前端代码

4,380 阅读11分钟

关注微信公众号“依赖注入”订阅更新

以下是本人一年前在团队内部分享的整理和补充,水平有限,如有错误,请不吝赐教。

大家好,我叫王力国,目前是 RPA 前端团队负责人,过去一年我们从零构建了 RPA 前端平台,目前前端维护的代码行数在 13 万行左右,其中超过 92% 以上是 TypeScript 代码,主要有以下三个活跃迭代的代码仓库:

  1. 使用 TS3.5 + Angular8 + Rxjs 构建的管理后台(目前已经升级到 Angular9)
  2. 使用 TS3.7 + Electron5 + React16.8 + Redux + Mobx + Nodejs 构建的低代码开发平台
  3. 使用 TS3.7 + Electron8 + React16.8 + Mobx + Nodejs 构建的桌面应用

在这一段时间里经过大家的努力,应该来说目前看来以上三个仓库的代码是比较优雅,维护成本是比较低的(大家应该已经半年没有加班了,哈哈)。我本人在这段时间里做的主要工作是架构设计和代码评审,过程积累了一些经验,非常开心今天能够有机会同大家一起交流,今天的分享有 10 个部分,分别是:

image

在正式开始之前,我要跟大家先声明一下,以上 10 个部分仅是我个人认为优先级比较高的问题,单独整理出来跟大家一起探讨,大家如果想要深究到具体某一行代码如何编写的更加优雅的话,可以阅读市面上更多关于编码方法论的书,另外,今天这些内容仅适用于上层业务开发的项目,也就是说不适用于开源项目/基础库项目。

另外因为我们同时使用了 Angular 和 React,所以今天举例的时候可能会穿插两个框架的代码,不过不会涉及太多框架相关概念,不会影响大家理解。

1/10. 基本约定:

1. 目录结构

大部分情况下你的目录结构组织得好,你的可维护性就已经及格了,而一个好的目录结构应该是可以自解释的。

image

这里需要特别解释一下为什么在 Angular 项目里你最好还可以有一个 modules 目录,因为大家时间久了会发现,简单一个 shared 目录是很难满足需求的,有时候你仅仅依赖 shared 里的一个组件,却需要导入整个 shared,这是因为有时候一个路由模块对应的不仅仅是一个领域,在这种情况下,我非常建议你将 shared 目录按照领域模型拆分得更细,甚至移除 shared 改为 modules 目录,可以实现一个路由模块按需导入几个领域模块。

2. 命名风格

image

需要说明几点:

  1. 类型定义最好加上前缀,区别类型和值(这可以通过 TSlint 约束)

    // tslint.json
    {
      "rules": {
        "interface-name": [true, "always-prefix"],
      }
    }
    
  2. CSS Class 的命名也是可以使用 stylelint 约束的

    // .stylelintrc.json
    {
      "rules": {
        // example:aa-bb-cc,aa-bb-width120
        "selector-class-pattern": "^[a-z][a-z0-9]*((-[a-z0-9]+)*|[a-z0-9]*)$"
      }
    }
    

2/10. 类型安全

在早期团队还只有我一个人的时候为了快速开发选择使用 es6,后来产品被提升为公司战略级产品,团队也在爆发式扩大,工程师水平产生了梯度,类型约束的需求越来越大,于是我们非常果断决定把整个项目改造至 100% TS,当时为了降低迁移成本,代码仍然充斥着大量 'any',让人感觉沮丧的是,如果不能用好 TS 的类型系统的话,使用 TS 反倒抬高了心智负担降低了编码效率。 后来我们发起了一个学习 TS 的热潮,大家不断阅读文档,高级教程,学习市面上设计优雅的 TS 代码,尝试去建设一个比较标准TS体系。

image

很快项目里的 any 就在肉眼变少,我们目前已经在全链路 lint 检查中开启了两个重要约束:

  1. 错误级别的 -> 不允许 any
  2. 错误级别的 -> 不允许隐式 any

另外这里要提一点,人的自觉性总是无法被完全信任的,即使你使用了 pre-commit 钩子来跑 lint,也可以轻易被人绕过,我强烈建议你在云端 ci 流中加入 lint 检查,且强制约束未通过 lint 检查的分支无法被合并。

在早期开启全面禁用 any 之后,我们编码的效率非常低下,很多时候我们不得不编写大量的类型定义。

image

不过好在大部分情况下,我们使用的工具或者库已经导出了一些工具类型,我们需要自行编写的工具类型非常少,跟大家推荐几个资源可以帮助你更快地编写更安全的 TS 代码:

  1. TypeScript 内置工具类型
  2. 社区工具类型库
  3. React + TypeScript 最佳实践

3/10. 注释有罪

image

这种注释看起来很搞笑吧?但是我相信你的项目里一定有,而且还在源源不断产生这种注释。如何编写注释确实是一门艺术,写太多写太少都要被人骂。我的建议非常简单:永远不要注释,除非你有充分的理由。

image

大家想想什么情况下你会抱怨代码没有注释?

  1. 很难看懂或完全看不懂
  2. 以为看懂其实误解了(删除一段以为无用的代码导致了严重的问题)

那么这就很好理解我们什么时候需要注释了:

  1. 复杂的代码: 复杂业务/使用了难以理解的技术,取巧的实现方法
  2. 妥协的代码: 设计不好,但是为了实现业务又暂时没有其他更好选择
  3. 兼容性代码: 向下/平台兼容代码最好注释,避免误删

4/10 配置分离

对于组件级别的差异,很多时候 Props 就够了,如果是多个组件组成的一个模块需要差异化的话,你可能会使用多级参数透传或者是静态变量的方式,不过我想告诉你大多数时候可能 Provider 更合适,尤其是你希望同时在一个应用生命周期里使用几种配置方案。

image

其实大家对 Provider 并不陌生,不管是 Angular 或者是 React 实际上都可以轻松使用 Provider ,如下

image

这里要特别说一下,大家在 Angular项目中编写复用模块时,最好养成暴露 InjectToken 的习惯,即使它看起来暂时还不需要配置。

5/10 状态管理

市面上关于全局状态管理方案已经比较成熟了,比如 Rxjs,Mobx,Redux... 今天我们不会再过多探讨全局状态管理,更多想要跟大家探讨一下局部状态管理。

首先问大家一个问题:局部状态管理可以被复用么?或者说应该复用局部状态管理代码么?🤔

再问一个问题:如果可以被复用,那么我们应该使用组合还是使用继承呢?🤔

image

我的建议是:可以复用,但最好不要使用继承,可以考虑组合。

image

实际上在 Angular 里,你只需要简单的从组件级别注入服务就可以复用局部状态管理代码了。是不是好简单?实际上这就是 MVP 架构的实现,这里的组件级别 Providers 充当的就是 Presenter 层。

而在 16.8 版本 React 开始,你可以使用 Hooks 来组合局部状态管理,相信大家应该都有听说过,我们实际用下来的感受是:好用是真的好用,坑也是真的多。

我们在过去使用过程中也沉淀了自己的 Hooks 库(@bixi/hooks),大家有兴趣可以拿去玩玩。

image

6/10 性能优化

我对性能优化的一贯看法是:最好的时机就是项目立项的时候,其次是现在。

大家最好还是能够养成编写高性能代码的习惯,以下是我们在开发过程中经常会使用的一些性能优化手段,不细说,大家可以自己挨个去研究。

image

7/10 版本管理

这里版本管理有两个部分:

  1. 依赖库的版本管理:
    1. 务必锁定第三方依赖(yarn.lock)
  2. 业务代码的版本管理
    1. 至少向下兼容一个主版本
    2. 兼容代码打上标记,做好备注
    3. 定时清理兼容代码

image

8/10 适度封装

说到封装,我们先看下面这个代码段变化过程

  1. 你需要写一段代码,监听四个组件的值变动,同步到本地 State 中,你写了左边这样的代码,觉得代码很简洁,提交代码下班
  2. 需求发生变动,a 和 b 组件值变动时候会带来一些副作用,于是你将代码调整成右边这样,代码似乎在慢慢朝着不可控制的方向演进
  3. 你开始抱怨是因为需求很乱才导致代码很糟糕....

image

我在过去的代码评审过程中发现很多同学会很喜欢编写上面这种代码,他们都能给我一个很好的理由:函数拆得细好复用啊

可是,你的代码真的有被复用么?

实际上,在业务代码开发过程中,很多时候过度封装反倒会提高复杂度降低可维护性,因此我经常跟大家说的一句话是 “你把它搞复杂了”。

我们可以试试用最笨最简单的方式改造一下上面这个代码如下

image

大家会发现,这段代码行数看起来变多了,但是维护起来变得特别简单,我在处理 a 逻辑时候我才不关心会不会影响到 b/c/d...

9/10 组件设计

image

我们来看下面这个组件设计需求,这是我们在应用里的一个真实组件,它有以下四个主要特性:

image

有些同学的组件拆解原则是:管它三七二十一,先拆到它不能再拆为止。

那么在遇到上面这个组件设计需求的时候它会拆解成下面左边这样,甚至粒度更细,那么拆成这样有什么问题呢?

  1. 1/2/3/4 的样式是耦合的,拆解会导致样式编写困难

  2. 5/6/7 组件是没有过多复用价值的,拆解只会提升复杂度

很明显,不是组件拆得越细就越好,因此们更推荐你像右图这样拆解成两个组件就够了(当然如果你的代码复杂的话,可以再适当拆解)。

image

我们再看下面这个简易的 Input 组件,看看它有几宗罪:

image

  1. 疑似同其它组件的样式有依赖关系

    input{
    	border-right: none;
    }
    
  2. 组件不具备复用性,产生了一个searchTasks 事件

  3. 组件暴露了实现细节,需要依赖外部的 validate 方法

  4. 组件在初始化时拷贝了 value 副本,之后并没有继续监听外部 value 变化刷新副本,导致不是数据驱动的,不是幂等的

10/10 防御式编程

image

编写更可靠的代码当然应该是我们的长期追求,但一些不好的编程习惯可能会让你的代码问题变得难以追踪,你的错误监控工具(我们使用 sentry 监控代码错误)可能会变得形同虚设,比如上图中的代码:

// bad
if (this.editor) {
  this.editor.destory();
}

// bad,等价于上面代码
this.editor?.destory();

之所以说它不好,因为我们知道在这个组件挂载之后,this.editor 是应该必然存在的,那么我们在卸载的时候如果不存在我们应该及时抛出错误,而不是悄无声息吞噬掉,更好的处理方式应该是下面这种方式:

// good,我们断定它一定存在
(this.ediotor as Editor).destory();

总得来说,你最好还是谨慎使用 lodash 或者 optional chaining,该抛错的地方就让它及时抛出来。

当然为了不让你的应用奔溃得太难看,你还是需要做好错误收集以及 UI 降级。

image

image

谢谢大家。

关注微信公众号“依赖注入”订阅更新