ArchSummit全球架构师峰会门票9折倒计时中~ 了解详情
写点什么

微前端实践

  • 2020 年 1 月 17 日
  • 本文字数:5241 字

    阅读完需:约 17 分钟

微前端实践

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


在 ToB 业务的前端开发工作中,我们往往会遇到如下问题:


  1. 功能越来越多,工程越来越大,打包越来越慢

  2. 团队人员众多,代码冲突频繁,改动影响难测

  3. 内心想做 SaaS 标准产品,客户却要本地化、定制化,代码管理走向混乱的边缘

  4. 客户并不想要所有功能,无法很好地拆分售卖

  5. 不同的团队可能有不同的方式去解决以上问题。在国双,结合最新的前端技术以及自己的实践,我们进行了一种新的尝试——微前端。


什么是微前端

那什么是微前端?微前端主要是借鉴后端微服务的概念。简单地说,就是将一个巨无霸(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/ 简单了解一下。从微前端的实践开始,到现在微应用的实践,这一条路并不是很轻松,还有很多问题也亟待去优化、去解决。期待更多人在这些方面提供更好的解决方案。


2020 年 1 月 17 日 18:081062

评论

发布
暂无评论
发现更多内容

模块五课后作业-设计微博系统中评论架构

断水风春

架构实战营

服务器宕机了,除了坐等,我还能做点什么?

JavaEdge

JVM 签约计划第二季

我的Spring AOP没生效,我该如何排查?

JavaEdge

Spring Framework 签约计划第二季

模块五作业

panxiaochun

架构实战营

架构训练营 Week1 作业

红莲疾风

「架构实战营」

数据也需要滴血认亲?

Justin

大数据 数据治理 28天写作

架构训练营 - 模块五作业

VegetableBird

架构实战营 架构师实战营 「架构实战营」

我的应用 OOM 了,我该做点什么?

JavaEdge

JVM 签约计划第二季

Rust 元宇宙 12 —— 序列化和存储

Miracle

rust 元宇宙

自定义规则删除过期文件(linux)

liuzhen007

28天写作 12月日更

1.《重学JAVA》开篇

杨鹏Geek

Java Java 25 周年 28天写作 12月日更

从deadline和被狗追说起(2/28)

赵新龙

28天写作

Java本地事务失效了,线上应该如何排查?

JavaEdge

Spring Framework 签约计划第二季

大厂算法面试之leetcode精讲18.队列

全栈潇晨

算法 LeetCode

x

Nydia

微博评论的高性能高可用架构设计

zjluoyue

在线火星文转换器工具

入门小站

工具

Prometheus Exporter (十四)MySQL Server Exporter

耳东@Erdong

MySQL Prometheus 28天写作 12月日更

博客?Newsletter?付费订阅?Papyrus 都帮你搞定!

遇见

程序员 博客 Blog newsletter

架构实战营第五课作业微博评论高性能高可用架构

Geek_99eefd

架构实战营 「架构实战营」

有人问你什么是CAP?你就把这篇文章发给他

李子捌

redis CAP理论 28天写作 12月日更

面试官:重写 equals 时为什么一定要重写 hashCode?

王磊

react源码解析11.生命周期调用顺序

buchila11

React React Hooks

听说过python协程没?听说过 asyncio 库没?都在这一篇博客了

梦想橡皮擦

12月日更

DDD 领域驱动设计落地实践系列:微服务拆分之道

慕枫技术笔记

后端 签约计划第二季

模块五作业

小鹿

微信小程序开发:新建文件报错Error:pages/xxx/xxx.json Expecting ’STRING’,’NUMBER’,’NULL’,’TRUE’

三掌柜

28天写作 12月日更

生产环境的线程池出问题了,我到底该如何正确使用线程池?

JavaEdge

Java java 并发 签约计划第二季

大厂算法面试之leetcode精讲17.栈

全栈潇晨

LeetCode 算法面试

AOP+MybatisPlus 优化特殊的日志模块

4ye

Java spring 程序员 后端 签约计划第二季

react源码解析12.状态更新流程

buchila11

React React Hooks

AI在游戏反外挂中的应用与实践

AI在游戏反外挂中的应用与实践

微前端实践_文化 & 方法_国双田健_InfoQ精选文章