如何构建高质量前端工程?

在过去,与大多数工程师一样,我认为前端代码的设计水平大多与工程师的能力有直接关系。但随着接手了几个多人协作的大型前端项目,我开始意识到,这种认知对短生命周期的小型项目可能适用,但对真正的大型项目,仅靠提升工程师质量有时并不能直接提升代码的质量。

本文将结合自己的一些实际经验,来阐述一个观点,即:构建大型高质量前端工程,合理的代码约束与正确的团队运转机制可能更为重要。

什么是高质量的工程代码?

高质量的工程代码,并不等价于性能最优,技术最新,复用性最强的技术选型。回顾几年前的前端领域:JQuery 时代,虽然要手动操作 DOM,但其实在那时, Google Closure 和 Ext.js 团队就已经提供了完整的组件化概念,甚至 Ext.js 还提供了组件冒泡这样的创新事件机制。那时用 Zepto 维护的代码,编码速度甚至比现在写一些 React 项目还要快。不同的技术只是工具,怎么用工具,能把工具用到什么程度,最终取决于开发者自身,所以高质量的工程代码,更多应该从业务和工程的角度考虑问题,而非技术选型

举个例子,当整个公司都在使用 React 开发时,虽然我们知道 Vue 使用可能会更简单便捷,但我们一定不会去用,因为这个时候,虽然看起来写代码更简单了,但其他人在 React 方向沉淀的经验,你无法复用,整个团队还需要额外去学习一套全新的技术,这样的工程设计,在这个背景下,显然是不合理的。

Thenewstack 做过 2019 年的开发者数据统计,开发者 32% 的时间用于业务开发,19% 的时间在维护代码。也就是工程师真正能投入到研发中的时间也只有工作时间的一半。对于开发者来说,这个时候通过合理的代码设计,提升代码的可扩展性,可维护性,降低开发和维护代码的时间,才是最强的诉求。

所以,高质量的工程代码应该是结合业务与团队情况,真正能够提升研发效率,降低项目维护成本的代码。

谁决定了工程代码的质量?

这里可以用木桶理论来类比:木桶中的水位,不取决于最高的木板,而取决于最低的木板。同理,前端工程的质量,不取决于团队的平均能力,而取决于团队经验较少的技术同学的能力。在工作压力比较大的情况下,这些同学由于经验不足,短期又要完成需求,所以很多时候,并没有考虑过工程上的问题,而是直接面向实现功能编程,基本上我们现在面对的难以维护的代码,都是在这种条件下产生的。

我们当然可以寄希望于经验较少的同学通过不断的成长来提升项目的工程质量,但实践下来,这并不可行。原因在于,工程能力的积累需要大量的编码经验,缺少实践经验的问题并不是短期就能够迅速解决的,任何好的工程师都是在不断犯错学习的过程中成长起来的。同时,工程开发过程中很可能会遭遇人员变动,一个团队的成员不可能永远全部都是能力很强的。

木桶理论
木桶理论

那么我们就需要换一个策略来保障我们的代码质量,我们可以换个角度思考:是否可以通过一些规则,流程,编码上的约束,让编码能力不同的工程师,尽量写出质量相对较高的一致性代码。

通过约束提升工程质量

工作没有约束,工作中我们就难以形成共识,也无法判断工作做的好与坏。写代码也是一样的,没有约束,那么我们也无法判断代码是否合理。在流行的库和框架中,其实到处都是约束的影子,这里拿 Redux 和 React 的设计来举例:

Redux
Redux

Redux 给出了单一数据源,State 只读,使用纯函数来执行修改这三个基本原则,同时要求通过 Action 的方式触发 Reducer 的执行,这是 Redux 的约束;React 也给出了单向数据流这样的约束概念。

框架之所以是能够复用,能够得到推广,就是因为它们进行了封装,仅仅提供有限的约束能力供大家使用,这样大家才能形成一致的理念,编写互相能够读得懂的代码。理解了这一点,我们再来看业务工程的代码,实际上要提高开发效率和扩展性,无非也是要提供合理的约束。

工程代码的约束,更多带有一定的工程属性,如:

  • 规定相同的请求地址只允许在 API 层出现一次(项目接口数目多,可减少代码冗余)
  • 不使用超过 100 行以上的 Hook 代码(强化逻辑拆分,避免过度复杂的逻辑)
  • 在复用性和可维护性上做选择时,优先选择可维护性(避免错误封装,封装代码中耦合大量逻辑判断)
  • 业务代码注释覆盖率必须超过 10%(提升代码可读性,方便自动化生成文档)
  • 项目中跨组件通信必须通过 Redux (降低组件传值代码的团队理解成本)
  • 相同功能的 npm 包不允许安装多个(避免无用依赖安装,造成维护成本增加)

这些业务的约束,并不等同于 Eslint,不同的业务对代码的要求有可能千差万别,所以业务上的约束,需要研发人员充分的沟通交流,碰撞探讨,以及坚决执行。不同团队的同学,可能讨论出的结果完全不同,但约束的结论是什么本身不重要,重要的是形成一致的开发共识。

通过机制实现约束的落地

约束本身并不难制定,对于工程侧的设计,工程师通过讨论比较容易形成博奕后的结论。但机制的落地是相对困难的一环。这里分享几个可执行的保障机制:

  • CodeReview(每次 CodeReview,除了对业务进行逻辑分析,也需要将是否遵循约束作为审核的一环)
  • 通过工具自动生成部分代码(比如使用脚手架生成工程代码中的某个模块,类似 Angular CLI 中 ng g component header 这样的指令,就可以帮你约束组件创建的代码结构)
  • 配置化生成代码(通过配置,生成逻辑或者表单代码,建立配置项标准)
  • 零代码 / PaaS 平台(通过平台生成代码,直接将用户与编码隔离,由平台保障生成代码的质量)
  • 负责人机制(约束落地直接与绩效相关联,成为跟进明确指标)
  • 沉淀文档(通过文档,沉淀约束机制)

通过这样的一些机制,保障约束有效的落地,那么我们就可以抹平团队成员技术能力的差异,形成一致性的编码风格。虽然这种约束下的代码并不一定是最优雅的代码,但至少工程质量不会差。所以这里我认为,约束实际上帮助我们保障的是工程质量的下限,那么接着我们来谈如何通过技术创新,提升工程质量的上限。

在约束之上寻求创新

大家可能会有这样的问题:“项目的约束,会不会限制技术的创新”。针对短生命周期的小型项目,这可能是对的,这种项目,使用更多的新技术进行探索突破可能会带来更多的团队技术储备;但对于大型项目来说,我们每天所做的代码设计决策,都可能会影响到明天业务系统的发展进程,任何技术升级都一定要慎重,这时候,我们不应该把约束当作创新的阻碍,而应该把约束当作创新的练兵场。

如果你在大型项目中,想突破约束,使用新技术,进行技术革新,那么一定意味着你要做到以下几件事情:

  1. 对过去约束限制的背景有充分了解:背景没有改变,新技术是否能解决约束所解决的问题,同时不会带来新的问题
  2. 能够充分表述新技术所能够带来的价值:在形成共识的问题上,新技术是否能对性能,稳定性,体验,研发效率,业务提效有明显作用
  3. 能够给出技术升级的整体方案:在确认要进行技术升级时,你是否考虑到历史技术方案如何优雅的实现替换
  4. 能够说服团队认可新的技术升级方案:在当前已有技术的基础上,你是否能说服团队成员和你一同推进技术创新
  5. 能够带领团队或者自己将技术方案落地:你是否具备能力将新技术或者创新点完成落地

很多时候,我们做的技术创新,其实只是技术栈的更新,并没有为团队和业务侧带来任何的价值,但当我们想清楚这些问题,能够有信服力的证明新技术或者创新点是有价值的时候,关于系统的升级可能才是真正有价值的。

在约束上的创新,可以让工程师结合业务有更多的思考,产出真正有价值的创新。而这些有质量的思考和创新,决定了工程质量的上限,同时也会培养出更多优秀的工程师。

如何提升已有工程质量?

对于一个全新的大型项目,我们可以通过上述的方式,分阶段进行架构设计和优化。但是,大多数情况下,我们接手的项目,可能在接手时就会发现其工程质量较低,那么我们应该如何对已有代码进行改良呢?

判断你的系统是否需要改良

一个系统的生命周期,可以总结为三个阶段:

  • 发展期:业务发展迅速
  • 稳定期:业务情况稳定
  • 衰退期:业务逐渐关停并转

对于发展期的系统和稳定期的系统来说,合理的工程设计未来能带来的性能,稳定性等方面的收益十分明显,这个时候,我们可以考虑对系统进行技术升级。而对于衰退期的系统,虽然短期开发维护效率不高,但无法看到未来系统的发展潜力,这时候,继续维护老系统可能是一个更好的选择。并不是每一个系统都必须要改良,精益求精固然好,但是否要做还是要回归到对业务价值的判断上。

如何进行工程改进

大型项目的工程改良,可以分为两种方式,自上而下,和自下而上。对于大型项目来说,自上而下的全部重构,成本很大,除非你对系统特别了解,否则并不推荐采用这种方法。相反,目前的主流框架,React, Vue 都是可以对局部 DOM 进行托管的,所以自下而上的逐步升级可能是更好的策略,这种方法有两个优势:成本低,风险小。举个自己工程中的例子,我们需要把 JQuery 升级至 React,采用了这种方式,逐层向上的对 JQuery 中的 Backbone 代码进行替换:

export default View.extend({
  componentName: 'AuctionDetailContainer',
  initialize(options) {
    const { dataSchemaV1, pageSchema } = options;
    this.ref = React.createRef();
    this.dataSchemaV1 = dataSchemaV1;
    this.children = pageSchema.getChildren()[0];
    this.attributes = pageSchema.getAttributes() || {};
  },

  render() {
    ReactDOM.render((
      <AuctionDetailContainerWithRef
        ref={this.ref}
        taskFields={this.dataSchemaV1}
        attributes={this.attributes}
        crossTableData={this.children}
      />
    ), this.$el[0]);
    return this;
  },
});

每一次替换,我们只要测试替换部分的逻辑即可,不会影响外部的其他逻辑,这样逐层替换,在保障稳定性和系统升级的双向要求下,做到了很好的平衡。同时,在接手新项目的时候,这种升级的方法还可以逐步帮你梳理清楚业务的逻辑,了解业务。

在这样的逐步替换过程中,结合之前说到的编码约束,我们就可以将系统的代码质量逐步完成提升。而之后,则可以通过创新的方式,进一步对项目优化完善,从而完成整个重构过程。

在这个过程中,有一些工具也可以帮助到我们,举几个例子:

  • CommintLint + SemVer 语义化版本号控制规范:帮助团队明确重构可能带来的风险,节约沟通成本
  • 前端自动化测试工具:通过单元测试保障工程质量,降低回归错误产生概率
  • Chrome Coverage:代码执行情况分析工具,帮助你找到无用代码,梳理项目逻辑

结语

本文详细阐述了如何构建高质量前端工程涉及的要点,具体代码相关的内容不多,我希望可以从另一个角度给你带来一些工程方面的启发和思考,欢迎大家沟通讨论。

分享