微前端是一种利用微件拆分来达到工程拆分治理的方案,能够解决工程膨胀、开发维护困难等问题。随着前端业务场景愈来愈复杂,微前端这个概念最近被提起得愈来愈多,业界也有不少团队开始探索实践并在业务中进行了落地。能够看到,不少团队也遇到了各类各样的问题,但各自也都有着不一样的处理方案。诚然,任何技术的实现都要依托业务场景才会变得有意义,因此在阐述美团外卖广告团队的微前端实践以前,咱们先来简单介绍一下外卖商家广告端的业务形态。目前,咱们开发和维护的系统主要包括三端:前端
如上图所示,原始解决方案的三端由各自独立开发和维护,各自包含全部的业务线,而咱们的业务开发状况是:react
在这种特殊的业务场景下,就会出现一个有关开发效率的抉择问题。即咱们但愿能复用的部分只开发一次,而不是三次。那么接下来,就有两个问题摆在咱们面前:webpack
咱们这里重点看一下物理层面的复用,即:如何在物理空间上使得各自独立的三端系统(不一样仓库)引入咱们的复用层?咱们尝试了NPM包、Git subtree等类“共享文件”的方式后发现,最有效率的复用方式是把三个系统放在一个仓库里,去消除物理空间上的隔离,而不是去链接不一样的物理空间。固然,咱们三端系统的技术栈是一致的,因此就进行了以下图的改造:ios
能够看到,当咱们把三端系统放在一个仓库中时,经过common文件夹提供了物理层面可复用的土壤,再也不须要“共享文件”式地进行频繁地拉取操做,直接引用复用便可。不过,在带来物理层面复用效率提高的同时,也加速了整个工程出现了爆炸式发展的问题,随着产品线从最初的几个发展到如今的几十个之多,工程管理成本也在迅速增加。具体来讲,包括以下四个方面:web
以下图所示,具体地说明了原有架构存在的问题。为了要解决这些问题,咱们意识到须要拆分这些应用,即进行工程优化的常规手段进行“分治”。那么要怎么拆呢?天然而然地咱们就想到了微前端的概念。也从这个概念出发,咱们参考业界优秀方案,同时也深度结合了广告端实际业务的开发状况,对现有工程进行了微前端的实践与落地。redux
结合现有工程的情况,咱们进行了深度的分析。不过,在进行微前端方案肯定前,咱们先肯定了需求点及指望收益,以下表所示:axios
需求点 | 收益与要求 |
---|---|
拆分解耦 | (1)按业务领域拆分红不一样的仓库进行维护,不一样业务线的开发者更加独立,不一样业务线之间互不影响。(2)物理层面拆分,加速寻址,新增功能修改Bug更加迅速。(3)逻辑层面拆分,杜绝引用混乱,不会出现A业务线引用B业务线组件的状况。 |
加速体验 | (1)开发环境急速启动,提升开发体验。(2)业务线按需打包,急速部署上线。 |
侵入性低 | 微前端方案改动原有代码的侵入性降到最小,无需大规模改造,减小甚至消除回归测试的成本。 |
学习成本低 | 开发人员无需感知拆分的存在,保持单页应用的开发体验,不须要学习额外的规则。 |
统一技术栈 | 为了统一共建与技术沉淀,团队内工程已经统一到了React技术栈,禁止使用不一样的技术栈进行开发。 |
通过以上的需求分析,咱们调研了业界及公司周边的微前端方案,并总结了如下几种方案以及它们各自主要的特色:缓存
经过对各个方案特色进行分析,咱们将重点关注项进行了对比,以下表所示:网络
方案 | 技术栈是否能统一 | 单独打包 | 单独部署 | 打包部署速度 | 单页应用体验 | 子工程切换速度 | 工程间通讯难度 | 现有工程侵入性 | 学习成本 |
---|---|---|---|---|---|---|---|---|---|
NPM式 | 是(不强制) | 否 | 否 | 慢 | 是 | 快 | 正常 | 高 | 高 |
iframe式 | 是(不强制) | 是 | 是 | 正常 | 否 | 慢 | 高 | 高 | 低 |
通用中心路由基座式 | 是(不强制) | 是 | 是 | 正常 | 是 | 慢 | 高 | 高 | 高 |
特定中心路由基座式 | 是(强制) | 是 | 是 | 快 | 是 | 快 | 正常 | 低 | 低 |
通过上面的调研对比以后,咱们肯定采用了特定中心路由基座式的开发方案,并命名为:基于React的中心路由基座式微前端。这种方案的优势包括如下几个方面:react-router
经过对方案的分析及技术方向上的梳理,咱们肯定了微前端的总体方案,以下图所示:
能够看到,整个方案很是简单明确,即按照业务线进行了路由级别的拆分。整个系统可分为两个部分:
基座工程和子工程联系起来的桥梁则是子工程的入口文件地址和路由地址的映射信息。这些映射信息可让基座工程准确地发现子工程资源的路径从而进行加载。
通过微前端实践的改造,咱们的业务在结构上发生了以下的变化:
如上图所示,咱们进行了微前端式的业务线拆分:
新的拆分使得子工程可以按照业务线进行划分,独立维护。在解决复用层的同时保证了子工程大小可控,即子工程只有单个业务线的代码。而单个业务线的复杂度并不高,也下降了工程维护的复杂度。
采用微前端拆分的方案,使得咱们的业务不只在纵向上保有了复用的能力,更重要的是拥有了横向扩展的能力,不管产品业务线如何膨胀,咱们均可以更轻松地应对。那么为了实现以上的能力,咱们作了哪些工做呢?下文咱们会详细进行说明。
微前端拆分的方案,咱们命名为:基于React技术栈的中心路由基座式微前端。在具体实现上,咱们会分为动态化方案、路由配置信息设计、子工程接口设计、复用方案设计和流程方案设计等几个模块来逐一进行说明。
首先,咱们须要路由的管理方案,使得子工程之间有能力互通切换。其次,咱们须要Store层的方案,让子工程有能力使用全局Store。而且,咱们还须要CSS的加载方案,来加载子工程的样式布局。下面来详细说明这三个方案。
动态路由
动态路由方案是想要进行路由级别的拆分,首先咱们要肯定用什么来管理路由?不少实现方案倾向于使用特制路由来管理模块。例如开源框架Single-Spa,实现了本身的一套路由监听来切换子工程,而且须要子工程实现特定的注册、挂载、卸载等接口来完成子工程和基座工程的动态对接,还须要特定的模块管理系统,例如systemjs来辅助完成这一过程。毋庸置疑,这对咱们原有工程的改形成本很大,还须要添加额外库,进而形成包体积大小上的开销。而且子工程的开发者须要熟悉这些特定的接口,学习成本也比较高。显然,这对于咱们的业务场景和需求来讲很不划算。
那么,咱们选择什么来作路由管理呢?最终咱们使用了React-Router,这样可以保持咱们原来的技术栈不变,同时对于工程的侵入也是最低,几乎能够忽略不计。此外,React-Router能彻底能够知足咱们的需求,并且自动会帮助咱们管理页面的加载与卸载,而不是每次切换路由都从新初始化整个子应用,因此在加载速度体验上也是最优的,跟单页应用体验一致。
实现上也很简单,以下图:
上面这个流程图,展现了咱们在基座工程中切换到子工程路由时,加载子工程并进行展现的过程。这里的重点步骤是加载子工程入口文件,并动态注册子工程路由的过程。因为咱们使用的是React-Router,显然要使用其提供的动态能力来完成。这一过程也很是轻量,因为React-Router从版本4开始有了“破坏级”的升级,因而咱们就调研了两种方式进行动态加载路由(目前咱们使用的是React-Router版本5),以下表所示:
React-Router 版本 | 动态加载方式 |
---|---|
3 | 利用Route的getChildRoutes的API异步加载路由。 |
4及以上 | 版本4及以上,React-Router在实现思路上有了很是大的变更,即再也不以提早注册路由的集中式路由为设计理念,转变成路由即组件的思路。对于动态加载路由来讲,就是动态加载组件,使得咱们的动态加载更加容易实现,无须依赖任何API,只需写一个异步组件便可。 |
React-Router版本3中,实现的基本代码思路以下:
// react-router V3 用于接收子工程的路由
export default () => (
<Route path="/subapp" getChildRoutes={(location: any, cb: any) => { const { pathname } = location.location; // 取路径中标识子工程前缀的部分, 例如 '/subapp/xxx/index' 其中xxx即路由惟一前缀 const id = pathname.split('/')[2]; const subappModule = (subAppMapInfo as any)[id]; if (subappModule) { if (subappRoutes[id]) { // 若是已经加载过该子工程的模块,则再也不加载,直接取缓存的routes cb(null, [subappRoutes[id]]); return; } // 若是能匹配上前缀则加载相应子工程模块 currentPrefix = id; loadAsyncSubapp(subappModule.js) .then(() => { // 加载子工程完成 cb(null, [subappRoutes[id]]); }) .catch(() => { // 若是加载失败 console.log('loading failed'); }); } else { // 能够重定向到首页去 goBackToIndex(); } }} /> ); 复制代码
而在React-Router版本4中,实现的基本代码思路以下:
export const AyncComponent: React.FC<{ hotReload?: number; } & RouteComponentProps> = ({ location, hotReload }) => {
// 子工程资源是否加载完成
const [ayncLoaded, setAyncLoaded] = useState(false);
// 子工程url配置信息是否加载完成
const [subAppMapInfoLoaded, setSubAppMapInfoLoaded] = useState(false);
const [ayncComponent, setAyncComponent] = useState(null);
const { pathname } = location;
// 取路径中标识子工程前缀的部分, 例如 '/subapp/xxx/index' 其中xxx即路由惟一前缀
const id = pathname.split('/')[2];
useEffect(() => {
// 若是没有子工程配置信息, 则请求
if (!subAppMapInfoLoaded) {
fetchSubappUrlPath(id).then((data) => {
subAppMapInfo = data;
setSubAppMapInfoLoaded(true);
}).catch((url: any) => {
// 失败处理
goBackToIndex();
});
return;
}
const subappModule = (subAppMapInfo as any)[id];
if (subappModule) {
if (subappRoutes[id]) {
// 若是已经加载过该子工程的模块,则再也不加载,直接取缓存的routes
setAyncLoaded(true);
setAyncComponent(subappRoutes[id]);
return;
}
// 若是能匹配上前缀则加载相应子工程模块
// 若是请求成功,则触发JSONP钩子window.wmadSubapp
currentPrefix = id;
setAyncLoaded(false);
const jsUrl = subappModule.js;
loadAsyncSubapp(jsUrl)
.then(() => {
// 加载子工程完成
setAyncComponent(subappRoutes[id]);
setAyncLoaded(true);
})
.catch((urlList) => {
// 若是加载失败
setAyncLoaded(false);
console.log('loading failed...');
});
} else {
// 能够重定向到首页去
goBackToIndex();
}
}, [id, subAppMapInfoLoaded, hotReload]);
return ayncLoaded ? ayncComponent : null;
};
复制代码
能够看到,这种方式实现起来很是简单,不须要额外依赖,同时知足了咱们“拆分”的诉求。
动态Store
对于Store层,咱们原工程使用的是Redux,子工程经过路由动态注册进来自然就能够访问到全局Store,因此对于Store的访问可以自动支持。那么,若是子工程想要注册本身的全局Store该怎么办呢?并且咱们还用了redux-saga来做为异步处理方案。redux-saga如何动态注册呢?仍是利用它们各自的API就能够达到咱们的目的?从下图中能够看到,支持动态Store也是花费很小的改形成本就能够完成。
动态CSS
一样的对应子工程的样式布局,咱们也须要经过某种途径加载到基座工程中来。这个很天然地用异步加载CSS文件经过style标签注入来完成,不过这里须要注意两个问题:
一个问题是,加载子工程的JS入口文件和CSS文件能够同时发起请求,可是须要保证CSS文件加载完成后再进行JS入口文件的路由注册。由于若是路由先注册了页面就会显示出来,若是这时CSS文件尚未加载完毕,就会出现页面样式闪动的问题。咱们经过先加载CSS再加载JS的策略来避免这个问题的发生。
另外一个问题是,怎么保证子工程的CSS不会和其余子工程冲突。咱们利用PostCSS插件在编译子工程时,按照分配给子工程的惟一业务线标识,为每一组CSS规则生成了命名空间来解决这个问题。而子业务线开发者是没有感知的,能够没有“心智负担”地书写子工程的样式。
在动态加载方案肯定以后,基座工程怎么才能知道子工程的资源路径,进而加载对应的JS和CSS资源呢?咱们须要一组映射信息。以下图所示,业务线惟一标识为Key,相应的静态资源地址为Value。这样的话,当基座工程切换到子工程时就能够拉取这个配置信息,在路由切换时准确地找到对应的子工程,进而进行后续的资源加载过程。这里可能会遇到的一个问题,即若是JS和CSS过大,是否能进行拆分?
根据咱们业务的实际状况,目前静态资源的大小是可控的,无需注册多个,单一入口地址彻底可以知足咱们的业务需求,而且因为咱们的改造彻底基于现有技术栈。若是业务很复杂,彻底能够在子工程中经过webpack的动态import进行路由懒加载,也就是说,子工程彻底能够按照路由再次切分红chunks来减小JS的包体积。至于CSS自己就很小,长期也不会有进行切分的须要。
子工程须要暴露它要注册给基座工程的对象,来进行基座工程加载子工程的过程。在子工程入口文件中定义registerApp来传递注册的对象,主要代码以下:
import reducers from 'common/store/labor/reducer';
import sagas from 'common/store/labor/saga';
import routes from './routes/index';
function registerApp(dep: any = {}): any {
return {
routes, // 子工程路由组件
reducers, // 子工程Redux的reducer
sagas, // 子工程的Redux反作用处理saga
};
}
export default registerApp
复制代码
咱们这里暴露了子工程的三个对象:这里最重要的就是routes路由组件,就是在写React-Router(版本4及以上)的路由。子工程开发者只须要配置routes对象便可,没有任何学习成本,其代码以下:
/** * 子工程路由注册说明 * 如注册的路由以下: * path: 'index' * 路由前缀会被追加上,路由前缀规则见变量urlPrefix * 在主工程的访问路劲为:/subapp/${工程注册名称}/index */
const urlPrefix = `/subapp/${microConfig.name}/`;
const routes = [
{
path: 'index',
component: IndexPage,
},
];
const AppRoutes = () => (
<Switch>
{
routes.map(item => (
<Route
key={item.path}
exact
path={`${urlPrefix}${item.path}`}
component={item.component}
/>
))
}
<Redirect to="/" />
</Switch>
);
export default AppRoutes;
复制代码
除了上方的routes对象,还剩下两个接口对象是:reducers和sagas,用于动态注册全局Store相关的数据和反作用处理。这两个接口咱们在子工程中暂时没有开放,由于按照业务线拆分事后,因为业务线间独立性很强,全局Store的意义就不大了。咱们但愿子工程能够自行处理本身的Store,即每一个业务线维护本身的Store,这里就再也不展开进行说明了。
基座工程除了路由管理以外,还做为共享层共享全局的基建,例如框架基本库、业务组件等。这样作的目的是,子业务线间若是有相同的依赖,切换的时候就不会出现重复加载的问题。例以下面的代码,咱们把React相关库都以全局的方式导出,而子工程加载的时候就会以external的形式加载这些库,这样子工程的开发者不须要额外的第三方模块加载器,直接引用便可,和平时开发React应用一致,没有任何学习成本。而和各个业务都相关的公用组件等,咱们会放到wmadMicro的全局命名空间下进行管理。主要代码以下:
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as ReactRouterDOM from 'react-router-dom';
import * as Axios from 'axios';
import * as History from 'history';
import * as ReactRedux from 'react-redux';
import * as Immutable from 'immutable';
import * as ReduxSagaEffects from 'redux-saga/effects';
import Echarts from 'echarts';
import ReactSlick from 'react-slick';
function registerGlobal(root: any, deps: any) {
Object.keys(deps).forEach((key) => {
root[key] = deps[key];
});
}
registerGlobal(window, {
// 在这里注册暴露给子工程的全局变量
React,
ReactDOM,
ReactRouterDOM,
Axios,
History,
ReactRedux,
Immutable,
ReduxSagaEffects,
Echarts,
ReactSlick,
});
export default registerGlobal;
复制代码
在肯定了程序拆分运行的总体衔接以后,咱们还要肯定开发方案、部署方案以及回滚方案。咱们如何开始开发一个子工程?以及咱们如何部署咱们的子工程?
开发流程
有两种开发方案能够知足独立开发的目的:第一种是提供一个基座工程的Dev环境,子工程在本地启动后在Dev环境进行开发,这种开发方式要求有一套基座工程的更新机制,例如基座工程更新后要同步部署到Dev环境。第二种是子工程开发者拉取基座工程到本地并启动本地开发环境,而后拉取子工程到本地,再启动子工程本地开发环境进行开发,这种开发方式是目前咱们使用的方式。以下图所示,咱们提供了子工程脚手架来快速建立子工程,开发者无需作任何配置和额外学习成本,就能够像开发React应用同样进行开发。
热更新
在开发过程当中,咱们但愿咱们的开发体验和开发单页应用的体验一致,也要支持热更新。因为咱们的拆分,实际上有两个服务,即基座和子工程,因此咱们以上图的方式完成了热更新的支持:在子工程的module.hot中经过再次触发基座工程中的JSONP钩子来通知基座工程,来再次触发renderApp达到子工程更新代码则页面热刷新的目的。主要代码以下:
// 在子工程入口文件
import routes from './routes/index';
function registerApp(dep: any = {}): any {
return {
routes,
};
}
if ((module as any).hot) {
(module as any).hot.accept('./routes/index', (): any => {
window.wmadSubapp(registerApp, true); // 支持子工程热加载的信息传递
});
}
export default registerApp
复制代码
Mock数据
子工程目前Mock数据的方式有三种:一是在基座本地Mock,这种Mock方式自然支持,由于基座工程基于外卖工程化Nine脚手架进行开发,自己支持本地Mock。二是支持子工程本地Mock。三是使用公共Mock服务YAPI。目前子工程开发的Mock功能结合第一种方式和第三种方式进行。
最后是部署方案,咱们达成了独立部署上线的目的,即子工程发布不须要基座工程的参与。以前全部子业务线都在一个工程中,打包速度随着业务线的膨胀愈来愈慢,而以下的方案使得子工程的开发和部署彻底独立,单个业务线的打包速度会很是快,从以前的分钟级别降到了秒级别。以下图所示,能够看到,子工程部署只须要把子工程打包,并在上传CDN以后,把配置信息更新便可,由于配置信息中有子工程新的资源地址,这样就达到了发布上线的目的。
整个部署过程咱们是托管到Talos(美团内部自研的部署工具)上的,配置信息咱们是托管到Portm(美团内部自研的文件存储)上的(经过咱们开发的Talos的插件UpdatePubInfo-To-Portm来更新咱们的配置信息)。在静态资源上传到CDN以后,就能够更新配置信息,供主工程调用,也就完成了子工程上线的过程。利用美团现有服务,咱们很迅速地完成了子工程单独部署上线的整个流程。
在部署方案中,咱们经过Talos进行部署,它自己就带有回滚功能。得益于子工程的发布和普通工程的发布并没什么本质不一样,都是将静态资源放置到CDN上,经过静态资源的的contenthash值来区分不一样版本,因此回滚的时候,Talos取到上个版本(或者某个前版本)的静态资源,再经过Portm更新咱们的配置信息便可完成。整个过程和普通工程没有区别,发版人员只需简单地点下回滚按钮便可。
改变了原有的开发模式后,咱们还对几个关键节点进行了监控报警的埋点。利用美团CAT(已经在GitHub上开源)和天网(美团内部的监控系统),咱们分别在子工程的配置信息、静态资源加载等节点上进行了埋点上报,统计子工程加载成功率,及时发现可能出现的子工程切换问题。具体状况以下图所示:
上方左图是按照端维度进行统计的示例,上方右图是PC端按照产品线统计加载成功数的示例。默认都是统计当天的数据,显示‘-’的代表当前没有数据。对资源加载的监控目前有三种类型:JSON、JS和CSS,资源加载失败的统计也包含这三种类型。天网的监控按照分钟级进行,每分钟内若是有加载失败就会发出报警,偶尔的报警多是用户网络的问题,若是出现大批量的报警就要引发重视了。
以上就是微前端在外卖商家广告端的实践过程。总的来讲,咱们完成了如下的目标:
张啸、魏潇、天尧,均为美团外卖前端团队研发工程师。
美团外卖广告前端团队诚招高级前端开发、前端开发专家。咱们为商家提供变现服务平台,为用户提供优质广告体验,是外卖商业变现中的重要环节。欢迎各位小伙伴的加入,共同打造极致广告产品。感兴趣的同窗可投递简历至:tech@meituan.com(邮件标题注明:美团外卖广告前端团队)