一. 背景
1.1 痛点
1.1.1 如何提高代码复用性
随着业务的发展,前端需要处理的逻辑越来越多,导致前端工程的体积也越来越大。同时,不同的系统之间,或者系统的不同模块之间,也有一些功能类似或者相同的模块,所以如果这些相似功能对应的代码逻辑可以复用将会大大节省开发成本,因此,提高代码的复用性就成为了前端优化的一个重要方向。
1.1.2 如何实现模块的跨技术栈引用
前端技术栈发展迅速,不可避免的会出现不同技术栈项目同时进行维护的情况。一般来说,相同功能的模块,如果存在于不同技术栈项目中,则需要分别进行开发和维护,这对开发资源来说是一种浪费。如果能够实现模块的跨技术栈引用,达到“一处开发,多处使用”的效果,将能够极大的节省开发成本,从而实现降本增效。
1.1.3 如何提高代码的部署效率
对于大部分互联网公司来说,需求迭代很快,因此,敏捷开发模式被广泛应用,这就需要频繁的进行工程部署,因此,提升部署效率,也是提升前端整体开发效率的一部分。
1.2 现状及解决方案
以服务开发团队的项目为例,部分业务具有以下特点:
1. 新老项目并存,业务功能类似且迭代频繁。因此需要高效的代码复用方案,且代码能够运行在尽可能多的版本上。
2. 工程体积大,重构需要占用大量整块开发资源,导致排期困难。故需要手段将现有业务进行拆解,从而进行小步快跑的迭代。
因此我们结合自己的需要,分析了目前前端代码主流的复用方案,并总结了其各自的适用场景与存在的问题。
npm包是最主流的前端模块复用方案之一,适用于封装业务中组件级别的代码。npm包技术成熟、社区资源丰富、易于使用和封装,都是其优点。然而如果要将其作为多个工程中共享代码的手段,则存在开发调试困难、部署效率不佳等问题。
由webpack团队提出的Module Federation(模块联邦)在动态加载模块和模块共享方面进行了优化,但其在webpack5中首次支持该特性,因此,对项目的兼容性要求较高。同时,引入Module Federation需要对工程进行改造,会增加代码的复杂性和维护成本。
除此之外,近几年流行的微前端(以qiankun框架为代表)则更适合于页面,工程级别的模块化拆解及代码复用。对于已经上线的大型前端应用来说,需要对工程本身进行一定程度的改造,改造成本较大,不适合工程的部分迁移。
低代码开发模式较微前端在开发体验上更为灵活,并且对技术要求更低,易于上手。但其同样是页面级别接入,无法对逻辑复杂的页面进行渐进式的改造。
| 方案 | 适用场景 | 存在的问题 |
|---|---|---|
| npm | 模块、方法、插件级的复用 | 调试困难、不易于部署和维护,且无法跨栈使用 |
| Module Federation | 应用间模块共享 | 接入成本高、增加工程复杂度 |
| 微前端 | 页面、工程级别的复用 | 工程改造成本大,不适用于渐进迁移 |
| 低代码 | 页面开发,快速搭建,容易上手 | 页面级别接入,无法部分改造 |
二. 设计与实现
基于以上原因,我们需要提供一个方便接入、能够最大化复用现有业务逻辑的手段,用来拆解现有大型应用,使其分割成便于维护和更新,高复用性的功能单元。因此,我们决定自研微模块框架,用来解决以上项目开发过程中遇到的痛点。
2.1 设计目标
| 特性 | 解释 |
|---|---|
| 低入侵性 | 不需要改变原有工程的编码方式和构建方式,即可获得微模块能力 |
| 动态加载 | 模块在应用加载时,会动态获取最新版本信息并引入,无需随应用进行整体构建 |
| 快速部署 | 模块可一次部署,多处更新。回滚通过接口实现,可以做到秒级回滚 |
| 独立开发 | 不依赖接入工程的环境,模块有自己单独的依赖,单独开发、部署、维护 |
| 模块文档 | 基于dumi的模块文档,提供友好的API实例与代码实例,方便开发者快速接入 |
2.2 实现原理
2.2.1 微模块的整体架构
微模块最核心的实现在于模块本身提供的服务及嵌入业务的基座,整个模块代码的数据流通路径如下图所示:
首先,模块在开发完成后在 portal 上进行发布。发布时,发布机器会执行特定的 rollup 脚本和 babel 插件对模块源码进行编译打包,然后部署到 CDN 上。
发布完成后,会通过 webhook 来通知模块服务,模块服务是一个 Node 与mysql 实现的微模块后台核心,主要作用是版本控制,存储模块相关信息,提供版本控制接口。对于具体业务来说,只需要安装上以 npm 包格式的基座插件,即可拥有微模块能力,能从服务上拉取模块信息加载并渲染。除此之外,微模块 Node 服务中还提供了模块文档页面与后台控制页面。模块文档基于 dumi 实现,给模块使用者提供了一个交互性强的模块预览及使用文档,后台控制页面提供了模块发布记录、模块信息与模块回滚操作的功能。
2.2.2 基座部分
利用浏览器支持 ES Module 的特性,动态引入和加载模块。2015 年 ES6 规范中,ES Module 被正式引入,为 JavaScript 带来了官方的标准化模块系统。ES Module 支持通过链接的方式动态引入 js 文件,用法如下:
<script type="module">
import react from 'http://www.xxx.com/react.js ';
...
</script>
基于这种特性,基座脚本会将引入的第三方库文件以及业务模块动态引入,并推入 React 的 render 序列,从而实现模块的动态加载。
2.2.3 微模块开发与模块打包发布
微模块工程是一个普通前端静态资源工程,包含了模块代码及 dumi 文档,在发布时,通过发布脚本对代码进行一系列压缩混淆和语法处理。
还包括了一些特殊定制的 babel 插件处理源码。另外还将依赖转换成了 ESM 格式供页面使用。所有模块打包好后,会根据代码变动生成一个新的版本文件,这个版本文件中包含了模块的所有信息。
版本文件会在发布后同步到 Node 服务中,并储存到数据库上。后面根据这些模块与版本的数据,可以提供一系列版本控制、模块相关的接口与 API,为后续模块维护提供了能力。
另外,版本信息除了会在发布时同步到 Node 上,也会有一份部署在 CDN 上,作为 NODE 的容灾备份。
2.2.4 数据库与版本系统设计!
首先,在源代码中每个模块在初始化时,有一个 JSON 配置文件,包含了模块基本信息。
在发布阶段,会根据打包好的文件提取一个 hash 作为文件指纹,用来跟数据库中的历史版本 hash 做 diff,只有 hash 发生变更时,模块的版本号才会进行迭代并同步给数据库。
另外根据发布环境的不同,版本号也是不同的,用于做各环境的区分。
最后,更新完的所有模块,会在发布任务的末尾,合并所有配置文件,得到一个版本文件集合,用于浏览器端根据需要的模块获取对应版本的模块静态资源,并且保证实时更新。
2.2.5 微模块的监控与容灾
为了观察微模块在线上的运行情况与运行质量,我们以模块加载和运行的顺序为生命周期,在其关键节点设置了监控。
整体监控流程如下图所示:
微模块分别会针对基座的加载异常,版本信息的拉取异常以及模块是否成功渲染做监控,一旦发生异常便会上传异常到监控。
在容灾方面,考虑到 Node 服务一旦存在无法访问的情况,基座会自动跳过 Node,直接去 CDN 上拉取模块版本信息,确保使用微模块的页面加载正常。
2.3 接入方式
2.3.1 开发
开发一个新模块时,在微模块项目中的 src 目录下创建对应的文件夹,文件目录为:
项目中,提供了创建模块指令 npm run create 执行指令即可生成空模块的文件目录。
文件目录创建完成后,即可使用 React 语法进行模块具体逻辑的编写。
2.3.2 调试
目前,调试方式分为两种,一种是模块功能独立,可单独运行;另一种是模块需要与页面中的其他部分进行交互。下面分别介绍下这两种情况的调试方式。
第一种:模块单独调试
在微模块项目中,执行 npm run start 指令,项目会启动 dumi 程序,借助dumi 的能力,可以实现模块的实时预览和热更新。同时,启动后,可以当做文档查看使用方法和详细的 API 信息,效果如下图:
第二种:嵌入业务中进行调试
执行 npm run dev 指令启动微模块项目,此时,项目会启动一个 server 程序,实时编译开发的模块,并动态的生成模块对应的js文件。
然后,在微模块组件的配置项中,通过 commonConfig.qzzPath 配置项指定对应微模块的文件路径。
2.3.3 接入项目
微模块的接入也分为两种方式,一种是在 React 工程中接入,另一种是在非React 工程中接入,两种场景的接入方式稍微有所区别,接下来分别介绍一下。
第一种:React工程中接入
第一步:执行 npm install @qnpm/unitter 指令,安装微模块基座插件
第二步:引入微模块
API说明:
unitterConfig
| 参数 | 说明 | 类型 | 默认值 | 是否必填 |
|---|---|---|---|---|
| name | 模块名称 | string | - | true |
| qzzPath | 本地调试时,获取模块的文件路径 | string | - | false |
commonConfig
| 参数 | 说明 | 类型 | 默认值 | 是否必填 |
|---|---|---|---|---|
| versionsUrl | 用于获取模块版本信息的接口地址 | string | 线上 | false |
| vendorDomain | 模块依赖的接口路径 | string | 线上 | false |
第二种:非React工程中接入
在非 React 工程中,引入微模块需要传入对应的微模块配置,传入的方式分为显式传入和隐式传入,具体方式分别如下所示:
显式传入方式:
页面的 HTML 文件中插入配置的 script 标签
API说明:
commonConfig
| 参数 | 说明 | 类型 | 默认值 | 是否必填 |
|---|---|---|---|---|
| versionsUrl | 用于获取模块版本信息的接口地址 | string | 线上 | false |
| vendorDomain | 模块依赖的接口路径 | string | 线上 | false |
| qzzPath | 本地调试时,获取模块的文件路径 | string | 线上 | false |
modulesConfig
| 参数 | 说明 | 类型 | 默认值 | 是否必填 |
|---|---|---|---|---|
| name | 模块名称 | string | - | true |
| el | 模块挂载节点 | domNode | - | true |
| props | 初始传入模块的参数 | object | - | false |
2.4 遇到的问题
2.4.1 React 多版本兼容
微模块本身在开发或调试过程中使用了高版本的 react(目前推荐18.0)。
然而将模块应用到宿主中,宿主也有可能使用 react,且版本是不可控的。因此如果不对微模块的 react 运行环境与宿主进行隔离,可能会发生以下报错:
原因在于 React-DOM 渲染时,需要与宿主版本相同。
解决方案为,在基座中实现动态引入 react 较高版本(如 18.0),使用该版本的 react 对微模块文件进行解析,并挂载到指定位置。微模块的整个生命周期仅依赖于动态引入的 react,不受宿主工程影响。
2.4.2 发布打包速度
随着迁移规模的增大,我们还遇到了部署慢与时常出现的内存溢出问题,会导致部署失败。
我们观察了数次 portal 的发布日志,发现在全量打包的情况下,随着模块增多,总体代码打包时间有着显著的增长,随着代码量多到一定尺度( 2w 行左右),不仅打包时间过长(>=5 分钟),且默认参数 4G 内存也不够打包,通常会发生内存溢出导致的发布失败。
因此,为了避免代码无限增长导致的打包时间不可控问题,需要控制打包时间。
在优化前,与常规前端应用一样是全量打包,因此大部分时间会浪费在未发生变更的模块上,因此需要一种机制只打包改动的部分,来减少编译机器的压力。
而目前发布系统并没有专门的针对部分文件打包的现成机制,于是我们设计了以下方案来在发布阶段让发布机器感知到哪些文件发生了变化,从而对变化的文件进行针对性打包:
这个方案的本质是一个 git 的 pre-commit 脚本,在用户提交代码时触发。
脚本最终产物是一个标记着打包范围的 updates 文件。
脚本首先删除历史的 updates,避免干扰,然后会去源代码文件夹下面去逐级找符合要求的模块目录
检测到模块的配置文件,认为是一个模块目录,进入待比较列表
比较前,会拉取 master 分支代码一次,然后开始比较每个目录的 diff 变更。如果 git 检测到了目录变更那么标记这个目录为有修改的目录,记入 updates 文件中。最终,updates 会得到所以变更的模块
在编译时,编译机器会读取这个文件做部分打包。经过这个优化 代码发布时间有了显著的减少,从之前的约 11 分钟,降至了 3 分钟以内,减少了 70%。
三、 微模块使用收益
3.1 改造并复用老旧代码,减少开发成本
目前微模块已落地的一大应用即 Qunar 服务平台模块化页面组件迁移。
背景是原有的多个模块业务代码存在于同一工程的同一页面中,需要将整个工程进行模块化切分,将独立模块复用在不同的页面上。
整个工程庞大且代码上线时间较久,直接重构的成本过大,需要大量的开发测试资源持续支持,难以推进。
相比于彻底将该工程重构,使用微模块可以逐一将原业务以模块为单位,保留原代码样式和逻辑层,进行模块与页面的解偶(主要是数据交互层面),即可将其抽离成独立模块。
以我们实际迁移为例,目前已迁移代码涵盖 5 个工程 8 个页面及 18 个模块,累计代码约 5w+行。
相比于完全重构之前的代码,对模块进行改造累计节省了 70% 以上的工作量。
3.2 提升部署及回滚效率
以下是相比较多个宿主工程使用 npm 包以及微模块集成业务模块时,部署及回滚时间的对比:
| 部署时间 | 回滚时间 | |
|---|---|---|
| npm包 | n*15min | n*2min |
| 微模块 | 1*3min | 30s |
注:其中n为单一模块在不同宿主业务中复用的次数
其中,相比于常规宿主工程的平均打包时间,由于微模块单次打包时间短,单次发布平均节省 10min 以上。
另外随着模块复用次数的增多,相比起 npm 需要在每个复用宿主工程上部署或回滚一次,微模块只用单一部署或回滚,所有引用该模块的宿主即可立即更新。
由此可见,相比于传统 npm 提取业务封装模块的方法,在部署效率上,微模块在多处复用时,可以成倍的节省部署与回滚时间,进而提升发布效率。
四. 未来规划
4.1 移动端兼容
目前微模块应用场景主要在 PC 浏览器端,用于 B 端页面。而在对 ESM 的 API 支持更好的移动浏览器端,理论上会有更好的微模块兼容性。
因此下一步我们也会讲微模块引入移动端,探索其灵活组合的能力对移动端开发带来的赋能。
4.2 老旧浏览器兼容
由于微模块依赖一些原生 ESM 特性如 importmap、异步加载等,其对浏览器版本有一定的要求。
在一些老旧浏览器上缺乏这些属性,需要将关键的 ESM 特性 ployfill 才能得到微模块的完整功能。
因此接下来我们会借助现有的 ployfill 方案结合一定的定制开发,将微模块的完整功能在更广泛的浏览器版本上实现。
4.3 加载与渲染速度优化
目前在首次渲染时,需要根据模块的不同依赖分别加载多个独立的模块依赖静态资源文件。
如加载一个常用的后台操作模块常需要加载 react、react-dom、mobx、axios、antd 等依赖。
目前这些依赖都是单独加载的,偶尔会出现某个资源加载时长异常进而拖累整体加载速度。
接下来我们计划从后台获取到模块调用记录,分析常见的模块加载的依赖组合,将其自动整合在一起,并能够缓存在浏览器端,以此降低加载 CDN 文件对页面渲染性能带来的影响。
五. 总结
微模块的核心思路是将复杂的业务模块拆解成小而可维护的单元,并通过基座的能力实现统一的订阅与使用。
它适用于频繁更新的业务模块、老旧项目的逐步迁移以及一般模块的业务隔离,以实现快速发布与更新、小步迭代和独立维护的目的。
以上就是本次分享的所有内容,最后为大家带来一个内推信息,欢迎优秀的你加入驼厂~
【内推链接】:app.mokahr.com/recommendat…