作者:俞展弘
GrowingIO 前端开发工程师,主要负责智能运营团队前端开发、gio-design 开发
引言
GrowingIO 智能运营产品,是 GrowingIO 为客户运营团队提供的一站式、精细化运营管理与数据分析平台。
近期,GrowingIO 智能运营产品团队需要将站内触点等运营功能从 SaaS 平台移植到增长平台上,以支持私有化部署(On-Premise,简称 OP)方案在客户环境中的进行,以此为背景,GrowingIO 运营前端团队在微前端的实践上走了自己小小的一步。
1. 什么是微前端
虽然「微前端」三个字这两年已经在前端领域被大家所广泛熟知,但本着严谨写文章的精神还是需要在这里简单说明一下。
微前端的概念脱胎于服务端的「微服务」概念。最近几年前端世界的趋势就是构建一个功能丰富、强大的浏览器应用程序(SPA)。
一般一个前端应用通常会由多个独立的团队开发,随着时间的推移,前端应用的代码规模会不断增长,并且表现得越来越难以维护,成为一个谁看都头疼的巨石应用。
微前端出现的意义在于将这样一个单一的巨石应用,转换为多个较为小型的前端应用再聚合为一。各个小型应用可以独立开发、部署,乃至于独立选择自身的技术栈与依赖,而不影响其他兄弟应用与父应用。
在此基础上简单总结一下微前端需要做到(或者说带来的好处):
- 应用自治:只需要遵循统一的接口规范或者框架,各个应用可以集成到一起,同时相互之间是不存在依赖关系的。
- 单一职责:每个前端应用可以只关注于自己所需要完成的功能。
- 技术栈无关:可以在使用 Angular 的同时,又可以使用 React 和 Vue。
2. 为什么要拆分微前端
如同上一段所说,目前的 GrowingIO SaaS 主站前端应用也是一个伴随迭代变得越来越大的巨石应用,但对于运营的前端团队来说,除了公共方法平时不会触及到大多数不相关模块。
其次,在当前以及可预见的未来,在 SaaS 环境与 OP 环境中运营的业务场景在大部分情况下是相同的。但是对于前端团队来说,OP 与 SaaS 差别包括但不限于:
- 采用了全新脚手架
- 用 GraphQL 取代了 RESTful API 获取基础资源
- 基础组件版本与路由等基础设施与 SaaS 有破坏性更新
- OP 不需要全部的 SaaS 功能
如果采取最原始暴力的开发方式(copy),就需要开发维护两套运营平台前端代码,一套在 SaaS 前端仓库,另一套在 OP 前端仓库。
这样会导致同个功能需要维护两套 codebase,时间久了 OP 仓库的代码会离 SaaS 主干代码越来越远,导致维护困难,同时有概率会一个功能写两次,这对于一个团队来说是不可接受的。
从实际需求出发,我们当然希望能做到一套代码适配两个环境,对于同一个功能开发、bug 修复都只需要一次工作量,这样能大大提高人效。所以在功能移植前需要将前端项目进行微前端改造。
3. 智能运营产品微前端一小步
实现智能运营产品(下称 Marketing)的微前端应用过程大致划分为两步走:拆 → 装,与此同时处理各种冲突覆盖和兼容问题。
3.1 拆分
拆分的过程中我们需要对 Marketing 应用与 SaaS 应用进行解耦,并针对 OP 环境进行差异屏蔽。
OP 和 SaaS 当中,存在一些接口不相同,所以需要做一层兼容处理,这部分放在了 @gio-bootstrap 以及 resourceService 当中实现,目的是使下层业务对环境无感知。
@gio-bootstrap
从 SaaS 独立出来 Marketing 仓库的最初承袭 SaaS,权限、项目、用户等基础数据在 .ejs 模板文件中进行获取,不容易阅读与维护不说,且复用困难,并阻碍了 webpack 的升级优化。
因此我们抽取了一个统一的 bootstrap 方法实现基座应用不同时的基础资源请求:
type GioBootstrap = (platform: 'saas'|'op') => Promise
方法内根据与基座应用团队约定确认的资源列表以及接口进行每个资源的获取。针对单个资源,使用一个 requestGenerator 来进行资源请求函数的创建:
/**
* 单个资源请求前会遍历 dependencies 列表资源是否都已存在,针对未请求的资源进行请求获取,已请求过的资源会从 sessionStorage 中进行获取;
* 为了兼容现有代码,@gio-bootstrap 同时在 sesstionStorage 与 window 中存储了资源。待未来时机成熟可以从 window 这种全局污染比较严重的方式中切换出来,运营前端相对于基座应用进一步独立。
*/
interface IRequestGenerator {
// 资源名称
name: string;
// 请求默认使用的url列表
endpoints: (projectId: string, dependencies?: { [key: string]: any }) => string[];
// 默认方式外的额外请求方式
manual?: (projectId: string, dependencies?: { [key: string]: any }) => any;
// 根据环境决定是否使用 manual进行请求,将屏蔽 endpoints
useManual?: boolean;
// 前置依赖资源列表
dependencies?: TResource[];
// 资源在前端的持久化,不配置将默认同时存储在 sessionStorage 与 window 中
persistence?: (resource: any) => void;
// 请求后的手动操作
afterRequest?: (resource: any) => void;
// 是否缓存
noCache?: boolean;
}
resourceService 增强
resourceService 是 GrowingIO SaaS 前端应用中进行数据获取的一个统一封装,还包含了 Redux 注入的过程,因此我们在不影响原有功能的基础上,针对数据获取的每个暴露 API 新增了类似 @gio-bootstrap 中的 manual 字段,以进行对应资源在 OP 中的 GraphQL 或新 RESTful API 的请求。
3.2 集成
一般来说微前端实现架构是这样:
基座层作为主框架的核心成员,充当调度者的角色,由它来决定在不同的条件下激活不同的子应用。因此主框架的定位则仅仅是:导航路由+资源加载框架。
对应的 GrowingIO 实际情况是,Marketing 作为一个独立的子应用,需要嵌入 SaaS 和 OP 当中:
按照上图架构来做,则需要把 SaaS 和 OP 前端应用(暂不考虑整个拆成微前端架构)当做基座应用 。
因此在考虑集成方案时,我们做了一个集成方案对比:
在我们开始考虑集成的时候,两个基座应用的依赖情况举个例子就是:
SaaS Dependencies react: 16.8.3, antd: ^2.13.3
OP Dependencies react: 16.8.6, antd: ^3.20.5
antd PeerDependencies react: "~0.14.0 || >=15.0.0"
由于 Marketing 需要集成进两个平台,如果采用构建时集成,则会造成类似 SaaS 应用编译后过大,无法很好地兼容两个应用具有破坏性的依赖版本不一致等一系列令人头疼与困惑的问题。
同时,构建时集成所考虑的依赖 hoist(具体概念可以参考 lerna hoist 进行理解) 以及 npm 来解决依赖冲突的概念,与微前端所需要达成的「技术栈无关」、「应用自治」理念背道而驰。
「技术栈无关」应该是我们考虑实施微前端所首先要达成的目标。
综合上面的几点考虑,我们选择了采用运行时 HTML Entry 的集成方式,尽管牺牲了一部分打包加载优化,但换取了更加一致的开发体验以及无感独立发布的优点。
于是我们的微前端集成方案可以大概描述成这样:
由于我们目前项目及环境的特殊性,SaaS 和 OP 的入口文件会有所不同(分别打包各自所需模块代码),但是打包方式以及集成方式是一样的。
3.3 全局变量处理
微前端里微应用在很多情况下也会去修改全局变量,这时候就可能造成全局变量的命名冲突。可能的处理方式有如下:
综合考虑子应用的独立性、环境的纯洁性,以及方案的执行成本,约定 namespace 方案相对较好,减少犯错成本同时又不过于复杂。
由于历史遗留原因,SaaS 应用在 window 全局环境中存储或重新定义了很多东西,例如 window.fetch,window.can 方法以及大量的全局变量,并且在最初拆分运营前端仓库时候无法一次性全部处理掉。
因此这一部分问题只能是分步骤迭代,一步步将全局变量、方法收束为 namespace 下的变量或者子应用下的模块导出。
3.4 样式覆盖
GrowingIO SaaS 应用最早是在 antd2.X 的基础上重新封装,并且一直没有跟随社区升级;OP 应用使用了最新版本的 antd,这就使得 Marketing 在集成到 OP 上时会出现 antd 类名的冲突与覆盖。
同时 OP 和 SaaS 在我们自己的组件类名上也会有不同的设计语言,更进一步加剧了样式的冲突。
应对这样的样式冲突,其实只需要修改 webpack 打包时 style-loader的默认行为就可以。
目前 style-loader 默认将样式插入 head 当中,那么我们就在基座应用的子应用入口处自行制定插入 dom,将样式插入到自己的作用域下,随着应用的切换,顺带将样式卸载。
对于使用到的 styled-components 也利用库自己的 API 做同样处理。
不过就算这样处理了还是会偶尔发现样式不协调的地方,这方面的坑还需要再踩一踩才会有更新的体会。
4. 结语
从一个巨石应用中拆分微前端不是一个可以一蹴而就的事情,总有各种各样的问题会突然出现。
不同产品,不同项目,会遇到的问题不尽相同,但大体思路与解决办法基本一致,只要围绕最核心的「技术栈无关」,选择一个合适项目的方案,然后一步步做到依赖、样式、全局变量等的隔离与独立,最终达到能够独立打包发布又不影响基座应用与兄弟微应用。
关于 GrowingIO
GrowingIO 是国内领先的数据驱动增长解决方案供应商。为产品、运营、市场、数据团队及管理者提供客户数据平台、获客分析、产品分析、智能运营等产品和咨询服务,帮助企业在数据化升级的路上,提升数据驱动能力,实现更好的增长。
点击「此处」获取 GrowingIO 15 天免费试用!