微前端实践

阅读数:23 2020 年 1 月 17 日 18:08

微前端实践

2018 年底 2019 年初,我在国双内部前端技术沙龙上分享了《微前端实践》。一年过去,微前端在前端技术圈逐步被更多人所认识和接受。回顾自分享以后这一年来的继续探索与实践,可以说收获满满。它启发并奠定了微应用架构的基础,助力实现分布式、共生式、插件式的多租户、多应用的 Web 开发平台( https://developer.engage-all.com/ ),为我司后续各产品线的融合、客户定制化乃至混合部署(SaaS + 本地化)打下了坚实的技术基础。在此,略作修改并通过 InfoQ 平台再次分享此文章,期待能给更多人带来一点思考和帮助!
在 ToB 业务的前端开发工作中,我们往往会遇到如下问题:

  1. 功能越来越多,工程越来越大,打包越来越慢
  2. 团队人员众多,代码冲突频繁,改动影响难测
  3. 内心想做 SaaS 标准产品,客户却要本地化、定制化,代码管理走向混乱的边缘
  4. 客户并不想要所有功能,无法很好地拆分售卖
    不同的团队可能有不同的方式去解决以上问题。在国双,结合最新的前端技术以及自己的实践,我们进行了一种新的尝试——微前端。

什么是微前端

那什么是微前端?微前端主要是借鉴后端微服务的概念。简单地说,就是将一个巨无霸(Monolith)的前端工程拆分成一个一个的小工程。别小看这些小工程,它们也是“麻雀虽小,五脏俱全”,完全具备独立的开发、运行能力。整个系统就将由这些小工程协同合作,实现所有页面、控件的展示与交互。
对比微服务,我们可以这么去看:

微服务 微前端
一个微服务就是由一组接口构成,接口地址一般是 URL。当微服务收到一个接口的请求时,会进行路由找到相应的方法,输出响应内容。 一个微前端则是由一组页面构成,页面地址也是 URL。当微前端收到一个页面 URL 的请求时,会进行路由找到相应的组件,渲染页面内容。

后端微服务会有一个网关,作为单一入口接收所有的客户端接口请求,根据接口 URL 与服务的匹配关系,路由到对应的服务。 微前端则会有一个加载器,作为单一入口接收所有页面 URL 的访问,根据页面 URL 与微前端的匹配关系,选择加载对应的微前端,由该微前端进行进行路由响应 URL。

这里要注意跟 iframe 实现页面嵌入机制的区别。微前端没有用到 iframe,它很纯粹地利用 JavaScript、MVVM 等技术来实现页面加载。后面我们将介绍相关的技术实现。

为什么要用微前端

在介绍具体的改造方式之前,我想跟大家先说明下我们当时面临的具体问题,以及改造后的效果对比。这里主要从打包速度、页面加载速度、多人多地协作、SaaS 产品定制化、产品拆分这几个角度来说一下。
首先是打包速度。在 6 个月前(注:2018 年 6 月份),我们的 B 端那会儿还是一个工程。当时已经有 20 多个依赖、60 多个公共组件、200 多个页面,对接 700 多个接口。我们使用了 Webpack 2,并启用 DLL Plugin、HappyPack 4。在我的个人 Mac 主机上使用 4 线程编译,大概要 5 分钟。而如果不拆分,算下来现在(注:2018 年 12 月份)我们已经有近 400 个页面,对接 1000 多个接口。

这个时间意味着什么?它不仅会耽误我们开发人员的时间,还会影响整个团队的效率。上线时,在 Docker、CI 等环境下,耗时还会被延长。如果部署后出几个 Bug,要线上立即修复,那就不知道要熬到几点了。

在使用微前端改造后,目前我们已经有 26 个微前端工程,平均打包时间在 30-45 秒之间(注意,这里还没有应用 DLL + HappyPack)。
页面加载速度其实影响倒并不是很大,因为经过 CDN、gzip 后,资源的大小还能接受。这里只是给大家看一些直观的数据变化。6 个月前,打包生成的 app.js 有 5MB(gzip 后 1MB),vendor.js 有 2MB(gzip 后 700KB),app.css 有 1.5MB(gzip 后 250KB)。这样首屏大概要传输 2MB 的内容。拆分后,目前首屏只需要传输 800KB 左右。
在协作上,我们在全国有三个地方的前端团队,这么多人在同一个工程里开发,遭遇代码冲突的概率会很频繁,而且冲突的影响面比较大。如果代码中出现问题,导致 CI 失败,所有其他人的代码提交与更新也都会被阻塞。使用微前端后,这样的风险就平摊到各个工程上去了。
再者就是定制化了。我们做的是一款 ToB 的产品,做成 SaaS 标准版产品大概是所有从业者的愿望。但整体市场环境与产品功能所限,经常要面临一些客户要求做本地化与定制化的要求。本地化就会有代码安全方面的考虑,最好是不给客户源代码,最差则是只给客户所购买功能的源代码。而定制化从易到难则可以分为独立新模块、改造现有模块、替换现有模块。

通过微前端技术,我们可以很容易达到本地化代码安全的下限——只给客户他所购买的模块的前端源码。定制化里最简单的独立新模块也变得简单:交付团队增加一个新的微前端工程即可,不需要揉进现有研发工程中,不占用研发团队资源。而定制化中的改造现有模块也可以比较好地实现:比如说某个标准版的页面中需要增加一个面板,则可以通过一个新的微前端工程,同样响应该页面的 URL(当然要控制好顺序),在页面的恰当位置插入一个新的 DOM 节点即可。
最后就是产品拆分方面的考虑了。我们的产品比较大,有几块功能比较独立、有特色。如果说将来需要独立成一个子产品,有微前端拆分作为铺垫,腾挪组合也会变得更加容易些。

其他目标

有了以上的一些原因与诉求,在决定进行微前端改造前,还需要设定一些额外的小目标:
• 不能对现有的前端开发方式带来太大变化,至少要有平滑过渡的机制。
• 每个为前端工程都要求可以独立运行,至少在本地开发时要能做到。
• 微前端在加载时,要实现预加载,并可以自由调整预加载顺序,甚至是根据用户的偏好来实现智能化、个性化的加载顺序。

如何改造现有工程

“Talk is cheap,show me the code“。下面就让我们一起来看看具体的改造吧!我们的微前端工程可以划分为 portal 工程、业务工程、common 工程这几类。
portal 工程
portal,顾名思义,就是入口。这也就是上面所说的微前端加载器。当用户打开浏览器,首次进入我们的页面时,不管是什么 URL,首先加载的就是 portal。portal 里会配置所有业务工程的地址、匹配哪些 URL、需要加载哪些资源。如:

复制代码
{
// 业务工程的名称
order: {
// URL Hash 匹配模式(正则)
matchUrlHash: ['^/order'],
// 微前端 index.html 的地址,用于获取所有资源(JS、CSS)的路径
indexHtml: 'http://localhost:8101/mfe-order/index.html',
// 资源匹配模式(正则)
resourcePatterns: ['/app.*.css$', '/vendor.*.css$', '/manifest.*.js$', '/vendor.*.js$', '/app.*.js$'],
},
// ....
}

portal 会定时、异步、并发地下载业务工程的资源,并将它们进行注册,此时并不会加载这些业务工程。这里之所以要业务工程微前端 index.html 的地址、资源(resourcePatterns),是为了加载时确定地知道其所包含的 app.js、vendor.js、app.css 等资源的路径。因为业务工程每次有变更,app.js 等资源路径上都会带有新的文件内容哈希值(Hash)(如 app.436e74094d4d555b1c81.js),导致路径不可预测。而它的 index.html 的路径是固定的。我们读取该 HTML,解析其内容,通过正则就能匹配到 app.js 等资源的路径。
portal 在运行时,会监听 URL 变化。目前我们只支持 URL Hash(如 #/order)(注:后来我们自己重写了底层,现在能支持 History 模式了,但是整体原理还是一致的)。当 Hash 发生变更时,匹配到业务工程,然后执行业务工程的卸载、加载。这个机制主要是利用 single-spa 来实现,但原理就是这么简单。

复制代码
import { registerApplication } from 'single-spa';
registerApplication('order',
// 下载微前端工程,获取三个函数钩子:bootstrap、mount、unmount
async () => {
const html = await fetch(mfeConfig.target);
const {cssUrls, jsUrls} = matchResources(html, mfeConfig.resourcePatterns);
await loadCss(cssUrls); // 动态创建 link 标签
await loadJs(jsUrls); // 动态创建 script 标签
return windows['mfe:order']; // 包含 bootstrap、mount、unmount 三个函数,见下方 module.exports 与 webpack 配置
},
// 对当前浏览器 URL Hash 进行匹配,如果匹配(返回 true),则加载该微前端(调用 mount);否则卸载(调用 unmount)
() => {
return matchUrl(window.location.hash, mfeConfig.matchUrlHash);
},
mfeConfig.customProps
);

在初次分享时,又拆分了 navs、common 工程。在后来我们的实践中,把这三个工程合在了一起,这样尽最大地优化开发体验,降低维护难度。
一般产品的页面结构分为导航栏、内容区两大块。导航栏可能包括顶部栏、侧边栏或者两者都有。在页面跳转过程中,导航部分基本上保持不变(不用全局重新渲染)。所以可以将它们也集成到 portal 工程中。这个时候,要注意调整内容区的锚点 DOM(#app)的位置,它将会用来挂载(mount)所有的业务工程(见下方描述)。
公共依赖、公共组件的处理见下方内容。

业务工程

业务工程就是普通的微前端工程,一般一个模块一个工程。以 Vue 工程为例,在微前端改造之前,我们使用 new Vue({el: ‘#app’}) 来启动、渲染页面。

复制代码
new Vue({
el: '#app',
i18n,
router,
store,
template: '<App/>',
components: { App }
});

而当以微前端的方式集成时,则是利用 UMD 方式输出几个钩子函数,即初始化、加载、卸载。

复制代码
var instance = null;
module.exports = {
bootstrap(){ // 注册时执行
},
mount(customProps){ // 加载时执行
return Promise.resolve().then(()=>{
instance = new Vue({...}); // new Vue 在这里了,参数还是一样的
})
},
unmount(){ // 卸载时执行
return Promise.resolve().then(()=>{
instance.$destroy()
})
}
}
Webpack 配置:
{
output: {
libraryTarget: "umd",
library: 'mfe:order'
}
}

为了支持本地多个工程同时开发,我们需要为每个微前端工程指定一个确定的、独占的端口号。比如从 8100 开始,逐一递增。同时,为了支持线上部署,我们还需要给每个微前端工程指定一个确定的、独占的基础路径(前缀)。这样相同域名下可以用不同路径进行独立访问。路径统一以 /mfe- 开头,如 /mfe-order。这也就是上面 portal 里业务工程的 indexHtml 配置示例里所展现的那样。
如果还需要本地独立开发业务工程(即不启动本地 portal 工程),则还需要在业务工程的 index.html 文件中引入 portal 工程的资源,以模拟线上环境用户访问时先加载 portal 后加载业务工程的方式。具体方式堵着可以自行摸索一下。

公共依赖处理

大部分的业务工程可能都会有一些共同的依赖,比如 Vue、moment、lodash 等。如果将这些内容都打包到各自业务工程的 vendor.js 里,则势必会导致代码冗余太多,浪费带宽,还可能导致浏览器运行内存压力增大。我们可以把这些公共依赖、公共组件、CSS、Fonts 等都放到 portal 工程里,将依赖、组件 export,并以 UMD 的方式注入到全局。

复制代码
main.js:
import Vue from 'vue'; // 公共依赖
import VueRouter from 'vue-router';
import VueI18n from 'vue-i18n';
import '@/css/icon-font/iconfont.css';
import ContentSelector from '@/components/ContentSelector'; // 公共组件
Vue.use(VueI18n); // 公共逻辑
module.exports = {
'vue': Vue,
'vue-router': VueRouter,
'content-selector': ContentSelector,
};
Webpack 配置:
output: {
libraryTarget: "umd",
library: 'mfe:portal'
}

业务工程则通过 Webpack 外部依赖(external)的方式引入到工程中。这样业务工程打包时就不会包含这些公共代码了。
var externalModules = [‘vue’, ‘vue-router’, ‘content-selector’];

复制代码
module.exports = { // webpack 配置项
// ...
externals: (context, request, callback)=>{
if(externalModules.includes(request)){
callback(null, 'root window["mfe:portal"]["'+request+'"]')
} else {
callback();
}
},
}

结语

以上就是我们微前端改造与实践方面的一些经验。前路漫漫,这里面还存在很多待完善的地方,如 History 模式支持、i18n 更好地集成、各个业务工程的加载顺序优化及个性化等。除了这些纯粹技术上的探索,在拥有微前端、微服务这些架构的基础上,团队也可以考虑进行垂直拆分:一个小组独立负责一块业务,它有自己的微前端工程和微服务工程。从技术管理到人员管理,将它们糅合在一起统一考虑,这也是我们软件工程的探索方向。期待这些能够对大家带来一些思考和帮助!
从 2019 年 3 月份开始,我们逐步解决了此处展望中的所有问题与期望。我们构建了一套全新的多应用、多租户的平台,也同时遇到了一些新的问题。感兴趣的读者可以打开 https://developer.engage-all.com/ 简单了解一下。从微前端的实践开始,到现在微应用的实践,这一条路并不是很轻松,还有很多问题也亟待去优化、去解决。期待更多人在这些方面提供更好的解决方案。

评论

发布