或许你会感到疑惑,怎样的项目算是大型前端项目呢?我自己的理解是,项目的开发人员数量较多(10 人以上?)、项目模块数量/代码量较多的项目,都可以理解为大型前端项目了。
在前端业务领域中,除了大型开源项目(热门框架、VsCode、Atom 等)以外,协同编辑类应用(比如在线文档)、复杂交互类应用(比如大型游戏)等,都可以称得上是大型前端项目。对于这样的大型前端项目,我们在开发中常常遇到的问题包括:
- 项目代码量大,不管是编译、构建,还是浏览器加载,耗时都较多、性能也较差。
- 各个模块间耦合严重,功能开发、技术优化、重构工作等均难以开展。
- 项目交互逻辑复杂,问题定位、BUG 修复等过程效率很低,需要耗费不少精力。
- 项目规模太大,每个人只了解其中一部分,需求改动到不熟悉的模块时常常出问题。
其实大家也能看到,大型前端项目中主要的问题便是“管理混乱”。所以我个人觉得,对于代码管理得很混乱的项目,你也可以认为是“大型”前端项目(笑)。
# 问题 1:项目代码量过大
对于代码量过大(比如高达 30W 行)的项目,如果不做任何优化直接全量跑在浏览器中,不管是加载耗时增加导致用户等待时间过久,还是内存占用过高导致用户交互卡顿,都会给用户带来不好的体验。
性能优化的解决方案在《前端性能优化--归纳篇》一文中也有介绍。其中,对于代码量、文件过多这样的性能优化,可以总结为两个字:
- 拆:拆模块、拆公共库、拆组件库
- 分:分流程、分步骤
项目代码量过大不仅仅会影响用户体验,对于开发来说,代码开发过程中同样存在糟糕的体验:由于代码量过大,开发的本地构建、编译都变得很慢,甚至去打水 + 上厕所回来之后,代码还没编译完。
从维护角度来看,一个项目的代码量过大,对开发、编译、构建、部署、发布流程都会同样带来不少的压力。因此除了浏览器加载过程中的代码拆分,对项目代码也可以进行拆分,一般来说有两种方式:
1. multirepo,多仓库模块管理,通过工作流从各个仓库拉取代码并进行编译、打包。
- 优点:模块可根据需要灵活选择各自的编译、构建工具;每个仓库的代码量较小,方便维护
- 缺点:项目代码分散在各个仓库,问题定位困难(使用
npm link
有奇效);模块变动后,需要更新相关仓库的依赖配置(使用一致的版本控制和管理方式可减少这样的问题)
2. monorepo,单仓库模块管理,可使用 lerna 进行包管理。
- 优点:项目代码可集中进行管理,使用统一的构建工具;模块间调试方便、问题定位和修复相对容易
- 缺点:仓库体积大,对构建工具和机器性能要求较高;对项目文件结构和管理、代码可测试和维护性要求较高;为了保证代码质量,对版本控制和 Git 工作流要求更高
两种包管理模式各有优劣,一般来说一个项目只会采用其中一种,但也可以根据具体需要进行调整,比如统一的 UI 组件库进行分仓库管理、核心业务逻辑在主仓库内进行拆包管理。
题外话:很多人常常在争论到底是单仓好还是多仓好,个人认为只要能解决开发实际痛点的仓,都是好仓,有时候过多的理论也需要实践来验证。
# 问题 2:模块耦合严重
不同的模块需要进行分工和配合,因此相互之间必然会产生耦合。在大型项目中,由于模块数量很多(很多时候也是因为代码量过多),常常会遇到模块耦合过于严重的问题:
- 模块职责和边界定义不清晰,导致模糊的工作可能存在多个模块内。
- 各个模块没有统一管理,导致模块在状态变更时需要手动通知相关模块。
- 模块间的通信方式设计不合理,导致全局事件满天飞、A 模块内直接调用 B 模块等问题,隐藏的引用和事件可能导致内存泄露。
对于模块耦合严重的模块,常见的解耦方案比如:
- 使用事件驱动的方式,通过事件来进行模块间通信
- 使用依赖倒置进行依赖解耦
# 事件驱动进行模块解耦
使用事件驱动的方式,可以快速又简单地实现模块间的解耦,但它常常又带来了更多的问题,比如:
- 全局事件满天飞,不知道某个事件来自哪里,被多少地方监听了
- 无法进行事件订阅的销毁管理,容易存在内存泄露的问题
- 事件维护困难,增加和调整参数影响面广,容易触发 bug
# 依赖倒置进行模块解耦
我们还可以使用依赖倒置进行依赖解耦。依赖倒置原则有两个,包括:
- 高层次的模块不应该依赖于低层次的模块,两者都应该依赖于抽象接口。
- 抽象接口不应该依赖于具体实现,而具体实现则应该依赖于抽象接口。
使用以上方式进行设计的模块,不会依赖具体的模块和细节,只按照约定依赖抽象的接口。
如果项目中有完善的依赖注入框架,则可以使用项目中的依赖注入体系,像 Angular 框架便自带依赖注入体系。依赖注入在大型项目中比较常见,对于各个模块间的依赖关系管理很实用,比如 VsCode 中就有使用到依赖注入。
# VsCode:结合事件驱动与依赖倒置进行模块解耦
在 VsCode 中,我们也可以看到使用了依赖注入框架和标准化的Event/Emitter
事件监听的方式,来对各个模块进行解耦(可参考《VSCode 源码解读:事件系统设计》):
- 各个模块的生命周期(初始化、销毁)统一由框架进行管理:通过提供通用类
Disposable
,统一管理相关资源的注册和销毁 - 模块间不直接引入和调用,而是通过声明依赖的方式,从框架中获取相应的服务并使用
- 不直接使用全局事件进行通信,而是通过订阅具体服务的方式来处理:通过使用同样的方式
this._register()
注册事件和订阅事件,将事件相关资源的处理统一挂载到dispose()
方法中
使用依赖注入框架的好处在于,各个模块之间不会再有直接联系。模块以服务的方式进行注册,通过声明依赖的方式来获取需要使用的服务,框架会对模块间依赖关系进行分析,判断某个服务是否需要初始化和销毁,从而避免了不必要的服务被加载。
在对模块进行了解耦之后,每个模块都可以专注于自身的功能开发、技术优化,甚至可以在保持对外接口不变的情况下,进行模块重构。
实际上,在进行代码编程过程中,有许多设计模式和理念可以参考,其中有不少的内容对于解耦模块间的依赖很有帮助,比如接口隔离原则、最少的知识原则/迪米特原则等。
除了解决问题,还要思考如何避免问题的发生。对于模块耦合严重这个问题,要怎么避免出现这样的情况呢?其实很依赖项目管理的主动意识和规范落地,比如:
- 项目规模调整后,对现有架构设计进行分析,如果不再合适则需要进行及时的调整和优化。
- 使用模块解耦的技术方案,将各个模块统一交由框架处理。
- 梳理各个模块的职责,明确每个模块负责的工作和提供的功能,确定各个模块间的边界和调用方式。
# 问题 3:问题定位效率低
在对模块进行拆分和解耦、使用了模块负责人机制、进行包拆分管理之后,虽然开发同学可以更加专注于自身负责模块的开发和维护,但有些时候依然无法避免地要接触到其它模块。
对于这样大型的项目,维护过程(熟悉代码、定位问题、性能优化等)由于代码量太多、各个函数的调用链路太长,以及函数执行情况黑盒等问题,导致问题定位异常困难。要是遇到代码稍微复杂点,比如事件反复横跳的,即使使用断点调试也能看到眼花,蒸汽眼罩都得多买一些(真的贵啊)。
对于这些问题,其实可以有两个优化方式:
- 维护模块指引文档,方便新人熟悉现有逻辑。文档主要介绍每个模块的职责、设计、相关需求,以及如何调试、场景的坑等。
- 尝试将问题定位过程进行自动化实现,比如模块负责人对自身模块执行的关键点进行标记(使用日志或者特定的断点工具),其他开发可根据日志或是通过开启断点的方式来直接定位问题
这个过程,其实是将模块负责人的知识通过工具的方式授予其他开发,大家可以快速找到某个模块经常出问题的地方、模块执行的关键点,根据建议和提示进行问题定位,可极大地提升问题定位的效率。
除了问题定位以外,各个模块和函数的调用关系、调用耗时也可以作为系统功能和性能是否有异常的参考。之前这块我也有简单研究过,可以参考《大型前端项目要怎么跟踪和分析函数调用链》。
因此,我们还可以通过将调用堆栈收集过程自动化、接入流水线,在每次发布前合入代码时执行相关的任务,对比以往的数据进行分析,生成系统性能和功能的风险报告,提前在发布前发现风险。
# 问题 4:项目复杂熟悉成本过高
即使在项目代码量大、项目模块过多、耦合严重的情况下,项目还在不断地进行迭代和优化。遇到这样的项目,基本上没有一个人能熟悉所有模块的所有细节,这会带来一些问题:
- 对于新需求、新功能,开发无法完整地评估技术方案是否可以实现、会不会带来新的问题
- 需求开发时需要改动不熟悉的代码,无法评估是否存在风险
- 架构级别的优化工作,难以确定是否可以真正落地
- 一些模块遗留的历史债务,由于工作进行过多次交接,相关逻辑已无人熟悉,无法进行处理
导致这些问题的根本原因有两个:
- 开发无法专注于某个模块开发
- 同一个模块可能被多个人调整和变更
对于这种情况,可以使用模块负责人的机制来对模块进行所有权分配,进行管理和维护:
- 每个开发都认领(或分配)一个或多个模块,并要求完全熟悉和掌握模块的细节,且维护文档进行说明。
- 对于需求开发、BUG 修复、技术优化过程中涉及到非自身的模块,需要找到对应模块的负责人进行风险评估和代码 Review。
- 模块的负责人负责自身模块的技术优化方案,包括性能优化、自动化测试覆盖、代码规范调整等工作。
- 对于较核心/复杂的模块,可由多个负责人共同维护,协商技术细节。
通过模块负责人机制,每个模块都有了对应的开发进行维护和优化,开发也可以专注于自身的某些模块进行功能开发。在人员离职和工作内容交接的时候,也可以通过文档 + 负责人权限的方式进行模块交接。
# 结束语
大型项目的这些痛点,其实只是我们工作中痛点的缩影。技术上能解决的问题都是小事,管理和沟通上的事情才更让人头疼。
除此之外,在我们的日常工作中,通常也会局限于某块功能的实现和某个领域的开发。如果这些内容并没有足够的深度可以挖掘,对个人的成长发展也可能会有限制。在这种情况下,我们还可以主动去了解和学习其它领域的知识,也可以主动承担起更多的工作内容。
← 8.元素与事件 我所理解的前端工程化 →