导读
商业FE部门分四个业务组,每个组内迭代着多个中后台应用,其中有跨组协作项目。这些项目的特点是UI、UE相似,有较多的组件、逻辑复用场景,技术栈统一为VUE,且项目在不断迭代中。实践微前端架构能提高业务复用性,让各团队更高效的分治项目。
认识
1、简述
微前端是一种方案并非技术,凡是方案都是为适应特定应用场景而生的。微前端的方案类似微服务。它是对大而复杂的应用进行分治,形成各自的单一体系并能通过一套规则联系在一起。
2、特点
微前端架构有以下特点:
a)每个应用都应该是一个完整的沙盒环境,具有独立的开发、测试、部署流程;
b) 多个应用间有相同依赖,可以是模块、可以是路由、可以是业务组件。
3、微前端的应用场景
什么场景可以考虑引用微前端架构?
a)App大且含有多条业逻辑;
b) App是对应到不同的组织架构中;
c)多App间复用逻辑较多且有组合入口出现;
d) 不断迭代的App。
4、范式图
范式图
基座式的微前端架构基本是这样一个简单逻辑组成,基座工程 + 子应用*n。
基于Vue的基座式微前端
1、运行时
有了对微前端的基本认识,我们梳理一下微前端项目在运行时基座与子应用的基本协作流程图:
运行流程图
运行时基座扮演组织者角色,因此基座应用应具备以下特点:
a)按需加载子应用静态资源。
b) 动态注册子应用,并注册子应用关键模块:router、store等。
2、部署时
我们强调子应用的自治,所以子应用部署应该是独立的。
部署流程图
在子应用部署测试和生产环境的时候,都应该将注册信息同步反映到注册表上。注册表模块是解耦基座和子应用的关键,它可以简单的是一个json文件,也可以复杂的是一个子应用管理服务,具体尺度根据业务来衡量。由于我们的子应用数量及部署更新频率还不高,所以先简化注册表模块,以文件加手动更新方式来实现。
注册表模块应该具备以下特点:
a)信息映射:维护全量子应用注册信息,部署时间、版本、静态资源、scopeID等。
b) 动态性,随着不断部署来更新注册表信息。
c)可读性,暴露一个可读的对象或返回对象的自执行函数。
我们期望降低子应用开发者的心智负担,因此后续会将注册表模块服务化并接入上线系统部署钩子,用来管理子应用的信息。
3、开发时
开发时基座与子应用的协作是相对复杂的,并且也包含了运行时的基本逻辑,下文将围绕开发状态下各技术点展开阐述。
注意:此处是给出的是实现思路,具体代码实现细节可自行实现。
1、基座运行环境
开发时基座的运行环境有以下两种方式。
a) 本地服务式 - 子应用开发者将基座拉取到本地,运行到本地服务中。
本地服务式是将基座和子应用放置于子应用开发者本地,由于同处于一个物理空间(磁盘)上,基座可以通过直接引用子应用的构建出口来解决引用热更新问题。这种做法解耦不是很彻底,需要子应用开发者本地启动基座服务,并且没法提供多个子应用同周期协调开发的环境。
b) 集中服务式 - 将基座维护在一个专供开发时应用的服务器容器中。
集中服务式开发体验较好且可以多方协调开发,但需要维护专供开发时的服务节点,并通过网络解决基座与子应用间的资源加载及热更新问题,设计难度较大,由于是网络传输资源所以耗时需要优化。
2、选型
为快速落地框架,我们选用本地服务式方案。下面主要介绍本地服务式各点实现。不过为了后续转集中服务式做准备,这里也会列出一部分实现思路。为提高代码可读性,我们命名基座为Voo。
3、本地服务式
开发时-本地服务式关系图
a) 子应用入口文件的包装
<code>export default (Voo) => { Voo.Vue.prototype[appName] =vuePrototypeExtension return { router, store, App }}释:子应用暴露一个方法并返回router、store、App模块,供基座注册调用和回传基座对象(Voo);原型扩展方法需要增加namespace,方便提供给子应用本身和其他应用复用。/<code>
b) 子应用路由、数据流的包装
<code>export default [ { 'path': '/', 'redirect': 'home' }, { 'path': 'home', 'name': 'Home', 'component': Home }]释:暴露一个routes数组,供基座动态插入,由于基座与子应用运行在同一个router对象中,所以遵循“以 / 开头的嵌套路径会被当作根路径。这可以让你充分的使用嵌套组件而无须设置嵌套的路径”的规则。export default {state, mutations, actions, modules }释:暴露一个store module对象,供基座动态注册。/<code>
c) 子应用脚手架的修改
<code>webpackConfig.output.library ='[name]'webpackConfig.output.libraryTarget= 'umd'释:修改输出目标为umd便于runtime、开发时基座的引用webpackConfig.output.jsonpFunction= appName释:如果在同一网页中使用了多个来自不同编译过程(compilation)的webpack runtime,则需要修改此选项。/<code>
<code>webpackConfig.plugins.push(newwebpack.BannerPlugin({ 'banner': '/* eslint-disable */', 'raw': true}))释:向bundle中追加eslint注释,防止基座eslint校验不通过。'devServer': { 'writeToDisk': true, before(app,server) { request.post('http://localhost:7777/__dev_subApp_register', { 'form': { 'id': [appName], 'resourcePath':path.resolve(__dirname, `dist/${[appName]}.js`) } }, (error, response, body) => { if (error) { console.error('[ error ]请先启动基座工程Voo') } else {console.log(body)} }) }}释:将构建目标写入到物理磁盘中,devServer默认是写入在虚拟内存中的,基座工程无法import。/<code>
在devServer启动前向基座工程的本地服务发送构建目标的物理路径,注意:此处路径是磁盘绝对路径,是可以通过import载入的。` 7777`是基座工程固定的端口,__dev_subApp_register是基座工程固定的子应用注册路由。
d) 基座脚手架的修改
<code>'devServer': { before(app, server) { app.post('/__dev_subApp_register',(req, res) => { const params =Object.assign(req.query, req.body) const devSubAppRegisterInfo = `/* eslint-disable */export default (regiestSubApp, opts) => {import('${params.resourcePath}').then((res) => { const subApp =res.default(opts) regiestSubApp({ id: '${params.id}', subApp })})}` fs.writeFileSync(`${__dirname}/__dev__subApp_register_info.js`,devSubAppRegisterInfo) res.json({ 'code': 0, 'message': '开发时注册成功' }) }) }}释:编写子应用注册接口,在接收到子应用的注册请求后,将基座引用逻辑写入到__dev__subApp_register_info.js。/<code>
4、集中服务式
集中服务式,设计中需要注意以下几个点:
a) 配置服务,将nginx反向代理到基座服务;
b) 基座服务提供与子应用交互的接口,此处我们选用webpack的devServer进行描述。
1、在基座根目录创建 /subApps。
2、在before编写注册接口。当注册请求进入后将子应用/dist文件写入到/subApps中,如果/dist文件太大,可以采用压缩解压,如果子应用文件夹存在则更新。
c) /subApps目录结构
├── App.vue
├── main.js
├── ...其他目录
├── subApps
├── appA
│ ├── appA.js
│ ├── ...各chunk
│ └── appA.css
├── appB
│ ├── appB.js
│ ├── ...各chunk
│ └── appB.css
├── ...其他subApp
└── index.js
d) 动态读取 /subApps下所有文件,暴露出去
<code>const requireSubApps =require.context('./', true, /\.js|.css$/)export defaultrequireSubApps.keys().map((fileName) => { return requireSubApps(fileName).default})/<code>
e) 热更新
由于基座入口引用的是基座服务本地文件,所以,我们只需要在子应用代码发生改变时触发基座注册接口就行。实现如下:
<code>before(app, server) {registe();app.post('/__dev_update', (req, res) => {registe()})}/<code>
由于需要监听main.js入口以内所有模块的变化,所以将监听逻辑放到main.div.js,代码实现如下:
<code>if (module.hot) { module.hot.accept('./main.js', ()=> { fetch('/__dev_update') });}同时修改dev和prod环境下的打包入口。configureWebpack(webpackConfig){ webpackConfig.entry =process.env.NODE_ENV === 'development' ? { [appName]: ['./main.dev.js'] } : { [appName]: ['./src/main.js'] }}/<code>
f) 优化
热更新时按需上传[hash].hot-updage.json,降低网络耗时。
4、联调|测试
联调阶段:需要关注的问题是mock、proxy,这两点都可以沿用spa应用原有的开发方案。
测试阶段:基座和子应用都是通过上线系统管理所以可以利用其提供的测试环境。关键需要注意一下注册表的测试环境提供。
5、关键点
1、子应用生命周期
由于子应用会被当做一个路由组件注册到基座中,所以子应用可以利用其root组件的vue生命周期。
2、沙盒化router、store、css、vue原型扩展
路由,将子应用注册到其ID为根的路由上,并将其暴露的路由注册到children上。
<code>Voo.$router.addRoutes([ {'path': `/${ subApp.id}`, 'children': subApp.router, 'component': subApp.App}])/<code>
Store module, 动态注册的store module本来就是具有作用域的,依照vuex文档即可。
<code>Voo.$store.registerModule(id,{ 'namespaced': true, ...subApp.store })。/<code>
Css module, 通过postcss给子应用追加作用空间。
<code>constpostcssNamespaceGlobal = postcss.plugin('postcss-namespace-global', ({namespace= ''}) => (root) => { root.walk((node) => { if(node.selector){ node.selector =(node.selector.split(',').map((selector) => { if(selector.match(/^(\s*)(html|body)(\s*)$/)) { return selector } return `${namespace}${selector}` }).join(',')) } })})module.exports ={'plugins': [postcssNamespaceGlobal({namespace: `.${appName}`})]}/<code>
Vue原型扩展,给子应用用到的原型扩展方法规定到其ID对应的对象中。
3、 复用层
复用层比较复杂。有着较多种类的使用场景。下面分析一下在微前端架构中会出现哪些复用的东西,怎么去选型及管理。总体来说复用层的内容可以分为两种:
a) 类性质。调用时创建实例,因此runtime时互不干扰。但要根据是否需要锁定版本来确定复用内容的管理方案。
需要锁定版本,采用npm scope,管理在公司内部的npm服务上。注意:要规范好子应用npm安装重复问题。
不需要锁定版本,采用全局注册,例如全局注册的业务组件。注意:子应用注册需要scope。
b) 函数性质。 调用和执行是同一组代码,这种复用内容性质是脆弱的。所以对设计者要求较高,且迭代应向下兼容。
4、 复用内容
a) UI组件库等第三方依赖:类性质,在基座中规定并回传给子应用。
b) ajax库及统一接口处理:函数性质,在基座中规定并回传给子应用。
c) 业务类组件:类、函数性质,采用全局注册回传给子应用。
d) 子应用特色业务组件:如chart、workflow等:类性质。托管npm。
5、 子应用拆分粒度
太粗、太细的子应用粒度都不利于App的维护,所以要根据业务和组织架构合理拆分。我们可以参考两个原则:与服务对应形成前端微服务化、与团队对应。
6、Q/A
Q: 在“开发时-本地服务式”中,既然是在本地启动基座和子应用,为什么基座不直接import子应用入口而是import子应用的bundle。
A: 因为框架的目的是尽量解耦应用,如果直接import子应用入口,那么子应用就相当于是在基座环境中构建的。这样即增加了子应用开发、生产两个环境的构建差异,又限定了子应用的开发依赖。
总结和展望
1、总体来说这一套架构解决了以下问题:
a) 解决业务繁多的项目分治;
b) 多团队协作开发,且团队内项目自治;
c) 对敏捷迭代的项目构建良好的基础;
d) 子应用开发者无需关注基座及其历史子应用业务,直接依赖基座预览开发效果,体验提升;
e) 子应用开发几乎无异于SPA,无学习门槛。
2、思考
此次是我们在微前端道路上的初探,输出也只是基于Vue技术栈的单一形态。所以围绕微前端概念我们还有很多事情要做。
在设计此架构前我也调研过很多应用微前端的文章,得出的结论竟然让我自己觉得有些矛盾。微前端的理念是为了解耦,但是往往很多使用者还希望通过微前端实现业务的高度复用。那么矛盾来了,复用就伴随着耦合。所以说没有银弹,我们要做的是解耦子应用的同时,尽可能的对复用层进行分类管理,结合业务场景定制化适合的微前端架构。
3、优势
a) 基于同技术栈的微前端,可以快速抽离复用层并无侵入性的投入使用;
b)基于vue,有效的利用了store、router动态注册特性,贴合58商业目前技术栈及存量项目;
c) 在本地式开发流程中子应用脚手架和基座脚手架之间的合作可以提供稳定的热更新方案。
4、规划
将目光放的再长远一些,那么我们还应该做以下规划。
d) 子应用跨技术栈,解耦更彻底,让微前端能应用到更大的聚合App上和组织架构中,当然复用层将变为一个挑战。
e) 为增加开发体验,基座采用集中服务化方案,例如:有子应用需求接入时就将基座部署到沙箱节点上,或者可以将基座应用设计为服务端渲染并提供一套开发专用带权限的接口。这样就可以解决专门为开发提供服务的问题,同时还可以封装注册表相关逻辑以管理子应用。
f) 注册表模块服务化,此项主要是为规范工程化管理。
作者简介:
张军,58集团前端工程师。
参考文献:
康威定律:http://www.melconway.com/Home/Conways_Law.html