*原文地址:落地微前端 qiankun 理论与实践指北*
一、前言
“一千个人眼里有一千个哈姆雷特” 本文仅是作者这段时间对微前端的思考与感悟,文笔拙劣,多多海涵。微前端的实现方式有很多种,但是微前端并不完美。只有适合自己、适合团队的才是最佳实践。
由于本文较长(PS:本人懒得分篇),目录一至目录七偏理论知识,目录七之后为实践操作与思考,大家可以适当的跳跃来看
微前端是什么?
- 微前端不是特指某一项技术,而是一种思想。是由2016年 ThoughtWorks Technology Radar 中提出的,借鉴后端微服务的架构模式,将 Web 应用由单一的单体应用转变为多个小型前端应用,聚合为一的应用。
- 所以微前端不是指具体的库,不是指具体的框架,不是指具体的工具,而是一种理想与架构模式。
- 微前端的核心三大原则就是:独立运行、独立部署、独立开发 所以满足这些的最佳人选就是 “iframe”!!!
image.png
微前端能解决我们什么问题?
举例: 一个持续多年的应用,经历几年的业务的更新迭代,当项目发展到一定程度的时候就会遇到以下问题
- 业务模块之间不断的堆叠,交错引用,业务耦合如何治理?
- 老技术、老代码不敢动,新技术、新架构又想用?
- 万年技术债?既要跟随业务敏捷迭代,又要保证代码库向好发展,旧的框架类库如何平稳升级?
- 一个项目多个团队开发,你冲突我,我冲突你,如何解决并行开发的冲突?
- 代码库持续膨胀,难以维护的项目代码,是屎上雕花?还是从头再来?
有没有一种可以分解复杂度,提升协作效率,支持灵活扩展的架构模式?微前端应运而生—— “更友好的iframe” 将一个巨无霸应用拆解为一个个独立的微应用应用,而用户又是无感知的!
微前端核心原则:
- 技术栈无关: 主应用不限制子应用接入的技术栈,每个应用的技术栈选型可以配合业务情景选择。
- 独立开发、独立部署:既可以组合运行,也可以单独运行。
- 环境隔离:应用之间 JavaScript、CSS 隔离避免互相影响
- 消息通信:统一的通信方式,降低使用通信的成本
- 依赖复用:解决依赖、公共逻辑需要重复维护的问题
这意味着我们可以循序渐进的进行巨石应用的拆解,去技术升级、去架构尝试、去业务拆解等等。以低成本、低风险的进行,为项目带来更多可能性
我们的项目适不适合改造成微前端项目模式?
看我们的项目满足不满足微前端化,先看能不能满足以下几点即可。
- 是否有明确的业务边界,业务是否高度集中。
- 业务是否高度耦合、项目是否足够庞大到需要拆分。
- 团队中存在多个技术栈并且无法统一,需要接入同一套主系统。
- 技术老旧,扩展困难,维护吃力不讨好。
- 开发协同、部署维护等工作,效率低下,一着不慎,满盘皆输。
注意:没有迫切的需求接入微前端,只会带来额外的负担,我们要知道我们使用微前端是为了什么?
二、微前端技术选型
微前端实现方案对比
技术方案 |
描述 |
技术栈 |
优点 |
缺点 |
单独构建 / 部署 |
构建速度 |
SPA 体验 |
项目侵入性 |
学习成本 |
通信难度 |
iframe |
每个微应用独立开发部署,通过 iframe的方式将这些应用嵌入到父应用系统中 |
无限制 |
1. 技术栈无关,子应用独立构建部署 2. 实现简单,子应用之间自带沙箱,天然隔离,互不影响 |
体验差、路由无法记忆、页面适配困难、无法监控、依赖无法复用,兼容性等都具有局限性,资源开销巨大,通信困难 |
支持 |
正常 |
不支持 |
高 |
低 |
高 |
Nginx 路由转发 |
通过Nginx配置实现不同路径映射到不同应用 |
无限制 |
简单、快速、易配置 |
在切换应用时触发发页面刷新,通信不易 |
支持 |
正常 |
不支持 |
正常 |
低 |
高 |
Npm 集成 |
将微应用抽离成包的方式,发布Npm中,由父应用依赖的方式使用,构建时候集成进项目中 |
无限制 |
1. 编译阶段的应用,在项目运行阶段无需加载,体验流畅 2.开发与接入成本低,容易理解 |
1. 影响主应用编译速度和打包后的体积 2. 不支持动态下发,npm包更新后,需要重新更新包,主应用需要重新发布部署 |
不支持 |
慢 |
支持 |
高 |
高 |
正常 |
通用中心路由基座式 |
微应用可以使用不同技术栈;微应用之间完全独立,互不依赖。统一由基座工程进行管理,按照DOM节点的注册、挂载、卸载来完成。 |
无限制 |
子应用独立构建,用户体验好,可控性强,适应快速迭代 |
学习与实现的成本比较高,需要额外处理依赖复用 |
支持 |
正常 |
支持 |
高 |
高 |
正常 |
特定中心路由基座式 |
微应用业务线之间使用相同技术栈;基座工程和微应用可以单独开发单独部署;微应用有能力复用基座工程的公共基建。 |
统一技术栈 |
子应用独立构建,用户体验好,可控性强,适应快速迭代 |
学习与实现的成本比较高,需要额外处理依赖复用 |
支持 |
正常 |
支持 |
高 |
高 |
正常 |
webpack5 模块联邦 |
webpack5 模块联邦 去中心模式、脱离基座模式。每个应用是单独部署在各自的服务器,每个应用都可以引用其他应用,也能被其他应用所引用 |
统一技术栈 |
基于webpack5,无需引入新框架,学习成本低,像引入第三方库一样方便,各个应用的资源都可以相互共享应用间松耦合,各应用平行的关系 |
需要升级Webpack5技术栈必须保持一致改造旧项目难度大 |
支持 |
正常 |
支持 |
低 |
低 |
正常 |
对于选择困难同学来说,可以参考以下纬度进行方案技术的选型
参考纬度 |
是否能支持未来的迭代 |
稳定性 |
该方案是否经历了社区的考验,有较多的成熟案例,同时保持较高的 活跃性 |
可拓展性 |
支持定制化开发,提供较高的可拓展能力,同时成本可以在接受范围内 |
可控性 |
发生问题后,能够在第一时间内进行问题排查,以最快的响应速度来处理问题,修复的方案是否会依赖于外部环境 |
市面框架对比:
- magic-microservices 一款基于 Web Components 的轻量级的微前端工厂函数。
- icestark 阿里出品,是一个面向大型系统的微前端解决方案
- single-spa 是一个将多个单页面应用聚合为一个整体应用的JavaScript 微前端框架
- qiankun 蚂蚁金服出品,基于 single-spa 在 single-spa 的基础上封装
- EMP YY出品,基于Webpack5 Module Federation 除了具备微前端的能力外,还实现了跨应用状态共享、跨框架组件调用的能力
- MicroApp 京东出品,一款基于WebComponent的思想,轻量、高效、功能强大的微前端框架
综合以上方案对比之后,我们确定采用了 qiankun
特定中心路由基座式的开发方案,原因如下:
- 保证技术栈统一 Vue、微应用之间完全独立,互不影响。
- 友好的“微前端方案“,与技术栈无关接入简单、像iframe一样简单
- 改造成本低,对现有工程侵入度、业务线迁移成本也较低。
- 和原有开发模式基本没有不同,开发人员学习成本较低。
- qiankun 的微前端有 3 年使用场景以及 Issue 问题解决积累,社区也比较活跃,在踩坑的路上更容易自救~
三、你需要明确的
微前端并不是万能的”解药“,没有正确治理,所有的 codebase 的归宿都是”屎山”
- qiankun不是一个完整的微前端解决方案!
- qiankun不是一个完整的微前端解决方案!!
- qiankun不是一个完整的微前端解决方案!!!
1.微前端的运行时容器
- qiankun 所帮你解决的这一块实际上是微前端的运行时容器,这是整个微前端工程化里面其中一个环节
- 从这个角度来讲 qiankun 不算是一个完整的微前端解决方案,而是微前端运行时容器的一个完整解决方案,当你用了 qiankun 之后,你几乎能解决所有的微前端运行时容器的问题,但是更多的一些涉及工程和平台的问题,则需要我们去思考与处理。
- 我们的版本管控、配置下发、监控发布,安全检测、等等这些怎么做,都不是 qiankun 作为一个库所能解答的,这些问题得根据具体情况,来选择适合自己的解决方案2. 迁移成本
- 对于老旧项目的接入,很难做到零成本迁移,在开发的时候要预留足够的踩坑,魔改代码的时间。如果是已经维持几年堆叠的屎山需要做好因为不规范编码,所产生的各种奇怪的兼容性问题,这个时候你甚至会怀疑,“微前端是否真的有必要?”3. 技术栈的选择
- 微前端的核心不是多技术共存,而是分解复杂度,提升协作效率,支持灵活扩展,能把“一堆复杂的事情”变成“简单的一件事情”,但是也不是无脑使用的,广东话来说“多个香炉多只鬼”,每多一个技术栈都会增加:维护成本,兼容成本,资源开销成本,这些都会无形的拖累生产力。
- 基座应用与微应用之间,强烈推荐使用相同的技术栈,相同的技术栈可以实现公共依赖库、UI库等抽离,减少资源开销,提升加载速度,最重要的是:“减少冲突的最好方式就是统一”,通过约束技术栈可以尽可能的减少项目之间的冲突,减少工作量与维护成本。
4. 微前端初尝试
- 对于微前端的接入最好的时候就是,刚开始不久或重要性不是特别强的项目,一方面项目具备兼容微前端的工程能力,另一方面项目使用微前端方案的成本最低,不需要改太多代码
- 对于老旧项目的接入建议还是从边缘简单的模版入手,逐步分解。
7. 标准化才能提升生产力
- 混乱的项目会拖累生产效率,同时混乱的微前端也会加剧内耗,所以只有标准化才能提升生产力。
- 解决微前端的接入问题是最简单的,但是微前端接入后的:工程化,应用监控,应用规范,应用管理才是微前端中困难的地方,如果你只是想简单的嵌入一个应用,我推荐你的使用 ”iframe“
9. qiankun 不支持 Vite !!!
- 🚀 Link微应用的拆与合思考:拆的是系统复杂度,合的是系统复用度 核心原则:高内聚,低耦合 github 未来是否考虑支持 vite
- 不建议尝试去改变目前的 qiankun,Vite的改造成本真的太高了,虽然webpack 比Vite慢,但是经过拆分的应用内容已经很小了,不会对项目有太大的拖累。
10. qiankun并不难
- 对于qiankun的学习其实大家不用很担心,好像一听微前端就很难的样子。因为 qiankun 真的很简单满打满算 10个API 都没有,接下来让我们一起走进qiankun的世界吧~~
- 🚀 Link qiankun 官网文档
四、微应用拆分规则
微应用的拆与合思考:拆的是系统复杂度,合的是系统复用度 核心原则:高内聚,低耦合
微应用的拆解没有具体规则,但是以下规则应该可以给你在进行系统拆分时提供一些依据。
- “尽量减少彼此的通信和依赖“,微前端的通信交互、链接跳转等操作所带来等成本其实是很大的,所以在拆分的时候尽量“完全独立,互不依赖”
- 微应用的拆分的时候切忌“盲目细致拆分”,过度拆分会导致 “做的很牛逼,但是没有用的困局”,微应用的拆分并不是一步到位的,我们要根据实际情况逐步拆分。如果一开始不知道应该划分多细,可以先粗粒度划分,然后随着需求的发展,逐步拆分。
- 如:现在有一个售后管理系统,我们按业务线拆分为:客服管理,库存管理,物流管理,未来客服管理需求功能持续庞大再拆解为:智能客服、电话客服、在线客服。而这些客服,又可以嵌入供应商管理中心,商品管理中心 等项目使用。
- 在拆分的时候我们应该尽量考虑未来场景:渐变式技术栈迁移,前端应用聚合、多系统业务复用,如何做业务解耦和代码复用。
- 应用之间应该尽量解耦,子应用的事情就应该由子应用来做。
- 如:子应用的一些标识,如:路由前缀,应用名称,根节点容器名称,依赖库的使用
- 需要明确什么是子应用应该维护的,什么是父应用应该维护的,如果什么资源都一股脑的使用父应用下发,则会导致应用之间耦合严重。
建议按照业务域来做拆分
- 保持核心业务的独立性,把无关的子业务拆分解耦。业务之间开发互不影响,业务之间可拆解微应用,单独打包,单独部署。
- 业务关联紧密的功能单元应该做成一个微应用、反之关联不紧密的可以考虑拆分成多个微应用,判断业务关联是否紧密的标准:看这个微应用与其他微应用是否有频繁的通信需求。
- 如果有可能说明这两个微应用本身就是服务于同一个业务场景,合并成一个微应用可能会更合适。
- 分析平台差异,平台差异大可以根据平台特性拆分
- 分析页面结构,如果结构清晰,可以根据结构拆分
- 分析产品业务,将产品逻辑耦合度高的功能合并到一起
五、引入qiankun - 在主应用中注册微应用
选择基座的模式?
- 通用中心路由基座式:只有公共功能的主应用(菜单栏、登录、退出…)不包含任何业务逻辑
- 特定中心路由基座式:一个含业务代码的项目作为基座,所有新功能作为子应用引入
以下案例是以Vue技术栈作为应用技术栈,建议应用之间还是统一技术栈,降低维护、上手、学习成本。越是不同技术、不同库的版本不同需要做的处理就越更多。
qiankun 注册微应用的方式:
💫自动模式:使用 registerMicroApps + start,路由变化加载微应用
- 当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配
- 首次load应用,创建子应用实例,渲染。
- 切到其他子应用后切回,会重新创建新的子应用实例并渲染。
- 之前的子应用实例 qiankun 直接不要了,即使你没有手动销毁实例。
- 采用这种模式的话 一定要在子应用暴露的 unmount 钩子里手动销毁实例,不然会导致内存泄漏。
- activeRule -
string | (location: Location) => boolean | Array<string | (location: Location) => boolean>
必选,微应用的激活规则。
- 支持直接配置字符串或字符串数组,如
activeRule: '/app1'
或 activeRule: ['/app1', '/app2']
,当配置为字符串时会直接跟 url 中的路径部分做前缀匹配,匹配成功表明当前应用会被激活。
- 支持配置一个 active function 函数或一组 active function。函数会传入当前 location 作为参数,函数返回 true 时表明当前微应用会被激活。如
location => location.pathname.startsWith('/app1')
自动挂载:registerMicroApps + start
yarn add qiankun
import { registerMicroApps, start } from 'qiankun';
const MICRO_CONFIG = [
{
name: 'vue app',
entry: '//localhost:7100',
container: '#yourContainer',
activeRule: '/yourActiveRule',
props: {
xxxx: '/'
}
}
]
registerMicroApps(MICRO_CONFIG)
start()
*activeRule 规则示例:此处拿官网的举例~**ctiveRule:'/app1'
activeRule:'/users/:userId/profile'
activeRule:'/pathname/#/hash'
activeRule:['/pathname/#/hash', '/app1']
手动模式:使用 loadMicroApp 手动注册微应用
- 每个子应用都有一个唯一的实例ID,reload时会复用之前的实例
- 如果需要卸载则需要手动卸载 xxxMicroApp.unmount()
由于registerMicroApps的特性,会导致路由的keep alive 失效,故本文使用 loadMicroAp + router.beforeEach 进行来达到自动注册的目的。
如果微应用不是直接跟路由关联的时候,你可以选择手动加载微应用的方式会更加灵活。
手动挂载: loadMicroApps
import { loadMicroApp } from 'qiankun';
this.microApp = loadMicroApp({
name: 'vue app',
entry: '//localhost:7100',
container: '#yourContainer',
activeRule: '/yourActiveRule',
props: {
xxxx: '/'
}
})
this.microApp.unmount()
六、微应用挂载节点
微应用可以挂载在页面的任意位置,微应用、微项目、微页面、微组件,一切皆有可能。
第一种:路由页内挂载,把子应用内嵌页入使用
// 主应用/src/views/About.vue
<template>
<div class="about">
<div id="sub-app-container"></div>
</div>
</template>
第二种:根DOM中与主应用同级挂载,切换的时候隐藏应用,显示当前应用
// 主应用/scr/App.vue
<template>
<div id="app">
<!-- 不同的微应用 -->
<div v-show="location.hash.startsWith('#/operation')" id="sub-operation-container"></div>
<div v-show="location.hash.startsWith('#/inventory')" id="sub-inventory-container"></div>
</div>
</template>
七、应用加载解析流程图
简易的图示了qiankun是如何通过 import-html-entry 加载微应用的
简易流程:
- qiankun 会用 原生fetch方法,请求微应用的 entry 获取微应用资源,然后通过 response.text 把获取内容转为字符串。
- 将 HTML 字符串传入 processTpl 函数,进行 HTML 模板解析,通过正则匹配 HTML 中对应的 javaScript(内联、外联)、css(内联、外联)、代码注释、entry、ignore 收集并替换,去除
html/head/body
等标签,其他资源保持原样
- 将收集的
styles
外链URL对象通过 fetch 获取 css,并将 css 内容以 <style>
的方式替换到原来 link标签的位置
- 收集 script 外链对象,对于异步执行的 JavaScript 资源会打上
async
标识 ,会使用 requestIdleCallback 方法延迟执行。
- 接下来会创建一个匿名自执行函数包裹住获取到的 js 字符串,最后通过 eval 去创建一个执行上下文执行 js 代码,通过传入 proxy 改变 window 指向,完成 JavaScript 沙箱隔离。源码位置。
- 由于 qiankun 是自执行函数执行微应用的 JavaScript,因此在加载后的微应用中是看不到 JavaScript 资源引用的,只有一个资源被执行替换的标识。
- 当一切准备就绪的时候,执行微应用的 JavaScript 代码,渲染出微应用
八、微应用接入三步走
第一步:微应用的入口文件 修改 *webpack_public_path*
- 在
src
目录新增 public-path.js
webpack
默认的 publicPath
为 ""
空字符串,会基于当前路径来加载资源。但是我们在主应用中加载微应用资源的时候会导致资源丢失,所以需要重新设置 __webpack_public_path__
的值
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
第二步:微应用webpack 新增配置
- webpack 配置修改 PS: 什么是 umd 模块?
const { name } = require('./package.json')
module.exports = {
devServer: {
port: 8081,
disableHostCheck: true,
headers: {
'Access-Control-Allow-Origin': '*'
}
},
configureWebpack: {
output: {
library: `${name}-[name]`,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${name}`
}
}
}
第三步:微应用添加生命周期
微应用需要在自己的入口文件,添加 bootstrap
、mount
、unmount
三个生命周期钩子,供主应用在适当的时机调用。
- main.js 注册微应用,增加判断让子应用就算脱离了父应用也可以独立运行
- PS:qiankun 生命周期函数都必须是 Promise,使用 async 会返回一个Promise对象
import './public-path.js'
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
let instance = null
function render(props = {}) {
const { container } = props
instance = new Vue({
router,
store,
render: h => h(App),
}).$mount(container ? container.querySelector('#app-micro') : '#app-micro')
}
if (!window.__POWERED_BY_QIANKUN__) {
render()
}
export async function bootstrap() {
console.log('[vue] vue app bootstraped')
}
export async function mount(props) {
console.log('[vue] props from main framework', props)
render(props);
}
export async function unmount() {
instance.$destroy()
instance.$el.innerHTML = ''
instance = null
}
export async function update(props) {
console.log('update props', props)
}
小结经历这几步,qiankun 父应用与微应用就接入完成了。当父应用完成加载微应用的时候,微应用就会遵循对应的解析 规则,插入到父应用的HMTL中了。
九、预加载微应用
预先请求子应用的 HTML、JS、CSS 等静态资源,等切换子应用时,可以直接从缓存中读取这些静态资源,从而加快渲染子应用。
registerMicroApps 模式下在 start
方法配置预加载应用
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([...AppsConfig])
start({ prefetch: "all" })
prefetch - boolean | 'all' | string[] | (( apps: RegistrableApp[] ) => { criticalAppNames: string[]; minorAppsName: string[] })
- 可选,是否开启预加载,默认为 true
。
配置为 true
则会在第一个微应用 mount 完成后开始预加载其他微应用的静态资源
配置为 all
则主应用 start
后即开始预加载所有微应用静态资源
配置为 string[]
则会在第一个微应用 mounted 后开始加载数组内的微应用资源
配置为 function
则可完全自定义应用的资源加载时机 (首屏应用及次屏应用)
loadMicroApps 模式下
import { prefetchApps } from 'qiankun';
export const MICRO_PREFETCH_APPS = [
{ name: 'vue-child', entry: '//localhost:7101/' },
{ name: 'vue-app', entry: '//localhost:8081/' }
]
prefetchApps(MICRO_PREFETCH_APPS)
export const MICRO_CONFIG = [
{
name: 'you app name',
entry: '//localhost:7286/',
container: '#yuo-container-container',
activeRule: '/your-prefix',
isPreload: true,
}
]
import { prefetchApps } from 'qiankun';
import { MICRO_CONFIG } from '@/const/micro/application-list.js';
const MICRO_PREFETCH_APPS = MICRO_CONFIG.reduce(
(total, { isPreload, name, entry }) => (isPreload ? [...total, { name, entry }] : total),
[]
)
prefetchApps(MICRO_PREFETCH_APPS)
- 笔者用的模式就是 loadMicroApps 模式,为了日后维护的便携性,改造一下:,新增 isPreload 字段维护是否开启预加载,这样有关于微应用的信息都在此 js文件维护,避免散弹式修改。
十、路由模式选择与改造
我们应该怎么选择路由?
最好的路由模式就是主应用、子应用都统一模式,可以减少不同模式之间的兼容工作
本文选择统一为:父子路由hash 模式
主模式 |
子模式 |
推荐 |
接入影响 |
解决方案 |
备注 |
hash |
hash |
强烈推荐 |
无 |
|
|
hash |
history |
不推荐 |
有 |
history.pushState |
改造成本大 |
history |
history |
强烈推荐 |
无 |
|
|
history |
hash |
推荐 |
无 |
|
|
PS: 每个模式之间的组合并不是接入就可以完成的,都需要一些改造,如:增加路由前缀,路由配置base设置,不同的模式activeRule的规则都不同
路由改造工作
新增微应用路由前缀
新增前缀不是微应用必须的,但是为了从 URL 上与其他应用隔离,也是为了接入旧应用的时候,能让 activeRule 方法能识别并激活应用,故新增路由前缀。
父应用路由表
[
{
path: '/your-prefix',
name: 'Home',
component: Home
},
{
path: '/your-prefix/*',
name: 'Home',
component: Home
}
]
PS:子应用路由切换,由于应用与路由都是通过 URL 注册与销毁的,当子应用路由跳转地址,无法与父应用的路由地址匹配上的时候页面会销毁,需要注意路由匹配,或者增加路由兜底。
子应用 hash 模式
new VueRouter({
mode: 'hash',
routes: [
{
path: `${ window.__POWERED_BY_QIANKUN__ ? 'your-prefix' : ''}/login`,
component: _import('login/index.vue')
}
]
})
子应用 history 模式
new VueRouter({
mode: 'history',
base: window.__POWERED_BY_QIANKUN__ ? 'your-prefix' : null,
routes: [
{
path: '/login',
component: _import('login/index.vue')
}
]
})
十一、 📝 旧项目路由接入改造
但是由于笔者是接入的是旧项目并且又是 hash 路由模式想顺利接入,一个个加三元则改动太多路由表了,为了减少对于旧项目接入时的影响仅在以下三处做修改(ps:因为懒)
1. hash路由模式:格式化路由表对象,微路由表路径,别名,重定向增加前缀区分应用
- 这里我们利用递归函数需要给路由动态增加前缀、path 、redirect、alias 这个三种状态需要动态处理一下
- 路由表数据
const routes = [
{
path: '/home',
name: 'home',
component: Home
},
{
path: '/about',
name: 'about',
redirect: 'home',
component: () => import('../views/About.vue')
},
{
path: '/about',
name: 'about',
alias: '/user/about',
component: () => import('../views/About.vue')
}
]
格式化路由方法
let SUN_ROUTER_PATH_LIST = []
export function formatRouterParams(parameter) {
if (!window.__POWERED_BY_QIANKUN__) {
return parameter.data
}
const recursionData = ({ data, params, value, deepKey }) => {
return data.reduce((total, item) => {
item = formatData({ item, params, value })
if (deepKey && item[deepKey] && Array.isArray(item[deepKey])) {
item[deepKey] = recursionData({
data: item[deepKey],
params,
value,
deepKey
})
}
return [...total, item]
}, [])
}
return recursionData(parameter)
}
export function formatData({ item, params, value }) {
if (!item) return
if (Array.isArray(params)) {
params.forEach(key => {
if (Object.prototype.hasOwnProperty.call(item, key)) {
item[key] = geRouterValue(value, item[key])
}
})
} else if (params) {
item[params] = geRouterValue(value, item[params])
}
SUN_ROUTER_PATH_LIST.push(item)
return item
}
export function geRouterValue(value, key) {
return typeof value === 'function' ? value(key) : value
}
调用递归方法统一替换微应用路由表
const BASE_ROUTER_PATH = 'your-prefix'
const router = new VueRouter({
mode: 'hash',
routes: formatRouterParams({
data: route,
deepKey: 'children',
params: ['path', 'redirect', 'alias'],
value: value => {
if (window.__POWERED_BY_QIANKUN__ && typeof value === 'string') {
const path = value[0] === '/' ? value : `/${value}`
return BASE_ROUTER_PATH + path
}
return value
}
})
})
遍历结果返回-路由表,统一增加“your-prefix”前缀啦
[
{
"path": "your-prefix/home",
"name": "home",
"component": "Home"
},
{
"path": "your-prefix/about",
"name": "about",
"redirect": "your-prefix/home",
"component": ""
},
{
"path": "your-prefix/about",
"name": "about",
"alias": "your-prefix/user/about",
"component": ""
}
]
2. router.beforeEach
跳转的时候调用检查跳转函数,判断是否需要增加前缀
router.beforeEach((to, from, next) => {
checkLink(to, next, () => {
next()
})
})
- 简单来说:如果是在 qiankun 环境中,并且不是跳转其他微应用的path, 并且跳转不是格式化前缀的路径,并且当前拼接的地址与格式化的路由地址是一致的才拼接 next
const { name } = require('../../package.json')
export const BASE_ROUTER_PATH = `/${name}`
export function checkLink(to, next, callback) {
const IS_HAVE_QIANKUN = window.__POWERED_BY_QIANKUN__
const IS_JUMP_TO_MICRO_APP = Object.values(LINK_MICRO_APP_LIST).includes(to.path)
const IS_BASE_PATH_SYMBOL = to.path === '/'
const IS_HAVE_BASE_ROUTER_PATH = getBasePath(to.path, '/') === getBasePath(BASE_ROUTER_PATH, '/')
const IS_ADD_PREFIX = IS_HAVE_QIANKUN && !IS_JUMP_TO_MICRO_APP && !IS_HAVE_BASE_ROUTER_PATH
if (IS_ADD_PREFIX || IS_BASE_PATH_SYMBOL) {
const path = `${BASE_ROUTER_PATH}${to.path}`
if (SUN_ROUTER_PATH_LIST.some(e => [e.path, e.redirect, e.alias].includes(path))) {
next({ path })
}
}
callback && callback()
}
export function getBasePath(path, prefix = '') {
if (!path) return
const pathArray = String(path).split('/').filter(item => item)
const basePath = prefix + pathArray[0]
return basePath
}
3.改写router.push OR router.replace
const originalPush = VueRouter.prototype.push
VueRouter.prototype.push = function push(location) {
return originalPush.call(this, location).catch(err => err)
}
ps: 其可以直接在push中改写, 省略 router.befroeEach
const originalPush = VueRouter.prototype.push
VueRouter.prototype.push = function push(location) {
const IS_JUMP_TO_MICRO_APP = Object.values(LINK_MICRO_APP_LIST).includes(location.path)
const IS_HAVE_QIANKUN = window.__POWERED_BY_QIANKUN__
const IS_BASE_PATH_SYMBOL = location.path === '/'
const IS_HAVE_BASE_ROUTER_PATH = getBasePath(location.path, '/') === BASE_ROUTER_PATH
const IS_ADD_PREFIX = IS_HAVE_QIANKUN && !IS_JUMP_TO_MICRO_APP && !IS_BASE_PATH_SYMBOL && !IS_HAVE_BASE_ROUTER_PATH
if (IS_ADD_PREFIX) {
location.path = `${BASE_ROUTER_PATH}${location.path}`
}
return originalPush.call(this, location)
}
4. 路由跳转记录
跳转方式: 路由跳转与正常使用无异
- ps: 在父应用是history模式,子应用是 hash模式的时候 子应用需要特殊处理一下URL重定向切换 history.pushState
跳转其他微应用
- 由于我们的应用之间式分离的,所以跳转外部应用的路由也是分离的,如果在项目中字面量固定写死风险太大了,如果外部应用发生一点改变,需要改项目里的路径的时候将会是一个噩梦,所以我们统一使用在各自的微应用维护一个常量列表去处理记录应用之间的跳转,方便全局统一管理。此处仅笔者一点拙见,如有更好建议请多多发表。
- PS:此处跳转的常量列表其实也可以放到基座应用去维护,但是最佳选择是有运维平台去维护应用之间跳转关系会更好~
export const LINK_MICRO_APP_LIST = {
CHILD_VUE: '/child/vue',
CHILD_REACT: '/child/react',
USER_INFO: '/user/info'
}
使用场景:
const IS_JUMP_TO_MICRO_APP = Object.values(LINK_MICRO_APP_LIST).includes(to.path)
let routes = [
{
path: '/about',
name: 'about',
redirect: LINK_MICRO_APP_LIST['CHILD_VUE'],
component: () => import('../views/About.vue')
}
]
router.beforeEach((to, from, next) => {
if (IS_JUMP_TO_MICRO_APP) {
next(false)
}
})
十二、 微应用与路由之间 如何 keep alive
- registerMicroApps模式下,为什么切换路由会导致应用重载?
- 例:A 到 B, 触发A unmount ⇒ 判断 B 是否加载过,已加载则触发 mount,未加载则触发 bootstrap ⇒ mount
- 详情可以看上文 “五、引入qiankun - 在主应用中注册微应用”
- URL 改变时应用匹配切换,路由的切换会导致应用的卸载与加载
- 如果子应用挂载在内部路由,路由跳转也将触发应用的重载
- 应用切换导致重载,导致组件状态丢失,为了保持应用实例不被加载,我们需要手动的控制应用的注册与销毁
- 方案一:loadMicroApp
- 优点:在一个页面中可以同时挂载多个微应用
- 缺点:无法根据路由匹配规则来挂载应用
- 适用场景:当需要在一个页面中同时挂载2个以上子应用,并且子应用的挂载不需要通过路由匹配来实现。
- PS:在基座中关闭标签页时,需要手动调用app的unmount钩子销毁应用,不然再次新建页签进入时还是以前的实例
loadMicroApp不能根据路由规则来挂载应用不是qiankun的问题,是我们的问题~
使用 router.*afterEach* + loadMicroApp 的解决应用 keep alive,思路是通过判断路由守卫的地址,如果是符合激活规则的则激活应用
1. 主应用 router 路由守卫
router.afterEach(to => {
setTimeout(() => {
microApplicationLoading(to.path)
})
})
2. 判断微应用加载的方法 microApplicationLoading
export const microApplicationList [
{
name: 'you app name',
entry: '//localhost:7286/',
container: '#yuo-container-container',
activeRule: '/your-prefix',
**isPreload: true,
isRouteStart: true,
props: {
router: router,
store: store,
parentEventHub: parentEventHub
}
}
]
import { loadMicroApp } from 'qiankun'
export async function microApplicationLoading(path) {
let currentActiveMicroConfig = await store.dispatch('d2admin/micro/GET_FIND_MICRO_CONFIG', path)
const microApplicationList = store.getters['d2admin/micro/microApplicationList']
if (!currentActiveMicroConfig || !currentActiveMicroConfig.isRouteStart) {
return
}
const cacheMicro = microApplicationList.get(currentActiveMicroConfig.activeRule)
const containerNode = getContainerNode(currentActiveMicroConfig.container)
const isNoTNodeContents = containerNode !== -1 && !containerNode
if (isNoTNodeContents || !cacheMicro) {
if (cacheMicro) {
cacheMicro.unmount()
cacheMicro.unmountPromise.then(() => {
loadRouterMicroApp(currentActiveMicroConfig)
})
return
}
loadRouterMicroApp(currentActiveMicroConfig)
}
}
export function loadRouterMicroApp(currentApp) {
const micro = loadMicroApp(currentApp)
micro.mountPromise.then(() => {
store.dispatch('d2admin/micro/SET_MICRO_APPLICATION_LIST', {
key: currentApp.activeRule,
value: micro
})
})
}
export function getContainerNode(container) {
const containerNode = container && document.querySelector(container)
if (containerNode) {
return containerNode.childNodes.length
}
return -1
}
vuex 方法 记录一下注册应用对象
import MICRO_CONFIG from '@/const/micro/application-list.js '
export default {
state: {
microApplicationList: new Map([])
},
getters: {
microApplicationList(state) {
return state.microApplicationList
}
},
actions: {
SET_MICRO_APPLICATION_LIST({ state, dispatch }, { key, value }) {
state.microApplicationList.set(key, value)
},
GET_FIND_MICRO_CONFIG({ state }, path) {
return MICRO_CONFIG.find(e => {
return getPathPrefix(path, '/') === getPathPrefix(e.activeRule, '/')
})
}
}
}
export function getPathPrefix(path, prefix = '') {
if (!path) return
const pathArray = String(path).split('/').filter(item => item)
const basePath = prefix + pathArray[0]
return basePath
}
十三、沙箱模式
CSS沙箱
微前端对于样式隔离问题,目前相关配套还不是很成熟
image.png
- 由于微前端场景下,不同技术栈的子应用会被集成到同一个运行时中,所以我们必须在框架层确保各个子应用之间不会出现样式互相干扰的问题。不同于 JavaScript 的隔离,目前 CSS 隔离在行业内还不完全的成熟,但是“甲之蜜糖,乙之砒霜“,每个方案都有着不同的优势与劣势。
- 样式隔离有着各种方案
- BEM (Block Element Module)规范
- CSS-Modules 构建时生成各自的作用域
- CSS in JS 使用 JS 语言写 CSS
- Shadow DOM 沙箱隔离
- experimentalStyleIsolation 给所有的样式选择器前面都加了当前挂载容器
- Dynamic Stylesheet 动态样式表
- postcss 增加命名空间
- 但是即使是有着如此多的样式隔离方案,css 还是会有一堆问题等着你去处理。例如:
- 不同应用依赖了同一个UI库,不同版本的情况
- 子应用,样式丢失或应用到了主项目的样式
- 微应用构运行时越界例如 body 构建 DOM 的场景(弹窗、抽屉、popover 等这种插入到主应用body 的dom 元素),必定会导致构建出来的 DOM 无法应用子应用的样式的情况。
本文采取的样式隔离的最佳实践是:采用约定式隔离,用 CSS 命名空间。备选:CSS Module、css-in-js 等工程化手段,建立约束:如:避免写全局样式,子应用不能侵入(如动态增加全局样式等)修改除本应用外的样式,子应用样式写在以子应用名作为命名空间的类里等。
- 默认沙箱
- qiankun是默认开启沙箱隔离的,默认情况下沙箱可以确保单实例场景子应用之间的样式隔离,但是无法确保主应用跟子应用、或者多实例场景的子应用样式隔离
- 严格样式隔离的沙箱模式
start({
sandbox: {
strictStyleIsolation: true
}
})
qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响,基于 ShadowDOM 的严格样式隔离并不是一个可以无脑使用的方案,大部分情况下都需要接入应用做一些适配后才能正常在 ShadowDOM 中运行起来
image.png
qiankun 还提供了一个实验性的样式隔离特性 experimentalStyleIsolation
image.png
- 当 experimentalStyleIsolation 被设置为 true 时,qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围
qiankun 还提供了一个实验性的样式隔离特性 experimentalStyleIsolation
image.png
- 当 experimentalStyleIsolation 被设置为 true 时,qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围
BEM (Block Element Module)规范命名约束
特定规则链接:
模块: .Block
模块多单词: .Header-Block
模块_状态: .Block_Modifier
模块__子元素: .Block__Element
模块__子元素_状态:.Block__Element_Modifier
- ‘-‘ 中划线 :仅作为连字符使用,表示某个块或者某个子元素的多单词之间的连接符号。
- __ 双下划线:双下划线用来连接块和块的子元素
- _ 单下划线:单下划线用来描述一个块或者块的子元素的一种状
- B -
Block
一个独立的模块,一个本身就有意义的独立实体 比如:header
、menu
、container
- E -
Element
元素,块的一部分但是自身没有独立的含义 比如:header title
、container input
- M -
Modifier
修饰符,块或者元素的一些状态或者属性标志 比如:small
、checked
CSS Modules
- 指的是我们像 import js 一样去引入我们的 css 代码,代码中的每一个类名都是引入对象的一个属性,通过这种方式,即可在使用时明确指定所引用的 css 样式。并且 CSS Modules 在打包的时候会自动将类名转换成 hash 值,完全杜绝 css 类名冲突的问题。
CSS In JS
- CSS in JS,意思就是使用 js 语言写 css,完全不需要些单独的 css 文件,所有的 css 代码全部放在组件内部,以实现 css 的模块化
postcss 增加命名空间
npm i postcss-plugin-namespace -D
module.exports = ctx => {
return {
plugins: [
require('postcss-plugin-namespace')('#your-prefix', {
ignore: ['html', /body/]
})
]
}
}
<html id="your-prefix"></html>
- public/index.html
- 配置postcss
在项目根目录创建postcss.config.js
文件
该插件会将全局所有class前加上统一前缀,并过滤掉ignore内的标签;ignore内可以写字符串,可以写正则表达式。但每次编译前都会运行,所以可能会增加编译时间
注意:如果用/body/
这样的正则,会将所有带body的class都过滤掉,比如el-drawer__body
、el-dialog__body
等。
*原文地址:落地微前端 qiankun 理论与实践指北*
一、前言
“一千个人眼里有一千个哈姆雷特” 本文仅是作者这段时间对微前端的思考与感悟,文笔拙劣,多多海涵。微前端的实现方式有很多种,但是微前端并不完美。只有适合自己、适合团队的才是最佳实践。
由于本文较长(PS:本人懒得分篇),目录一至目录七偏理论知识,目录七之后为实践操作与思考,大家可以适当的跳跃来看
微前端是什么?
- 微前端不是特指某一项技术,而是一种思想。是由2016年 ThoughtWorks Technology Radar 中提出的,借鉴后端微服务的架构模式,将 Web 应用由单一的单体应用转变为多个小型前端应用,聚合为一的应用。
- 所以微前端不是指具体的库,不是指具体的框架,不是指具体的工具,而是一种理想与架构模式。
- 微前端的核心三大原则就是:独立运行、独立部署、独立开发 所以满足这些的最佳人选就是 “iframe”!!!
image.png
微前端能解决我们什么问题?
举例: 一个持续多年的应用,经历几年的业务的更新迭代,当项目发展到一定程度的时候就会遇到以下问题
- 业务模块之间不断的堆叠,交错引用,业务耦合如何治理?
- 老技术、老代码不敢动,新技术、新架构又想用?
- 万年技术债?既要跟随业务敏捷迭代,又要保证代码库向好发展,旧的框架类库如何平稳升级?
- 一个项目多个团队开发,你冲突我,我冲突你,如何解决并行开发的冲突?
- 代码库持续膨胀,难以维护的项目代码,是屎上雕花?还是从头再来?
有没有一种可以分解复杂度,提升协作效率,支持灵活扩展的架构模式?微前端应运而生—— “更友好的iframe” 将一个巨无霸应用拆解为一个个独立的微应用应用,而用户又是无感知的!
微前端核心原则:
- 技术栈无关: 主应用不限制子应用接入的技术栈,每个应用的技术栈选型可以配合业务情景选择。
- 独立开发、独立部署:既可以组合运行,也可以单独运行。
- 环境隔离:应用之间 JavaScript、CSS 隔离避免互相影响
- 消息通信:统一的通信方式,降低使用通信的成本
- 依赖复用:解决依赖、公共逻辑需要重复维护的问题
这意味着我们可以循序渐进的进行巨石应用的拆解,去技术升级、去架构尝试、去业务拆解等等。以低成本、低风险的进行,为项目带来更多可能性
我们的项目适不适合改造成微前端项目模式?
看我们的项目满足不满足微前端化,先看能不能满足以下几点即可。
- 是否有明确的业务边界,业务是否高度集中。
- 业务是否高度耦合、项目是否足够庞大到需要拆分。
- 团队中存在多个技术栈并且无法统一,需要接入同一套主系统。
- 技术老旧,扩展困难,维护吃力不讨好。
- 开发协同、部署维护等工作,效率低下,一着不慎,满盘皆输。
注意:没有迫切的需求接入微前端,只会带来额外的负担,我们要知道我们使用微前端是为了什么?
二、微前端技术选型
微前端实现方案对比
技术方案 |
描述 |
技术栈 |
优点 |
缺点 |
单独构建 / 部署 |
构建速度 |
SPA 体验 |
项目侵入性 |
学习成本 |
通信难度 |
iframe |
每个微应用独立开发部署,通过 iframe的方式将这些应用嵌入到父应用系统中 |
无限制 |
1. 技术栈无关,子应用独立构建部署 2. 实现简单,子应用之间自带沙箱,天然隔离,互不影响 |
体验差、路由无法记忆、页面适配困难、无法监控、依赖无法复用,兼容性等都具有局限性,资源开销巨大,通信困难 |
支持 |
正常 |
不支持 |
高 |
低 |
高 |
Nginx 路由转发 |
通过Nginx配置实现不同路径映射到不同应用 |
无限制 |
简单、快速、易配置 |
在切换应用时触发发页面刷新,通信不易 |
支持 |
正常 |
不支持 |
正常 |
低 |
高 |
Npm 集成 |
将微应用抽离成包的方式,发布Npm中,由父应用依赖的方式使用,构建时候集成进项目中 |
无限制 |
1. 编译阶段的应用,在项目运行阶段无需加载,体验流畅 2.开发与接入成本低,容易理解 |
1. 影响主应用编译速度和打包后的体积 2. 不支持动态下发,npm包更新后,需要重新更新包,主应用需要重新发布部署 |
不支持 |
慢 |
支持 |
高 |
高 |
正常 |
通用中心路由基座式 |
微应用可以使用不同技术栈;微应用之间完全独立,互不依赖。统一由基座工程进行管理,按照DOM节点的注册、挂载、卸载来完成。 |
无限制 |
子应用独立构建,用户体验好,可控性强,适应快速迭代 |
学习与实现的成本比较高,需要额外处理依赖复用 |
支持 |
正常 |
支持 |
高 |
高 |
正常 |
特定中心路由基座式 |
微应用业务线之间使用相同技术栈;基座工程和微应用可以单独开发单独部署;微应用有能力复用基座工程的公共基建。 |
统一技术栈 |
子应用独立构建,用户体验好,可控性强,适应快速迭代 |
学习与实现的成本比较高,需要额外处理依赖复用 |
支持 |
正常 |
支持 |
高 |
高 |
正常 |
webpack5 模块联邦 |
webpack5 模块联邦 去中心模式、脱离基座模式。每个应用是单独部署在各自的服务器,每个应用都可以引用其他应用,也能被其他应用所引用 |
统一技术栈 |
基于webpack5,无需引入新框架,学习成本低,像引入第三方库一样方便,各个应用的资源都可以相互共享应用间松耦合,各应用平行的关系 |
需要升级Webpack5技术栈必须保持一致改造旧项目难度大 |
支持 |
正常 |
支持 |
低 |
低 |
正常 |
对于选择困难同学来说,可以参考以下纬度进行方案技术的选型
参考纬度 |
是否能支持未来的迭代 |
稳定性 |
该方案是否经历了社区的考验,有较多的成熟案例,同时保持较高的 活跃性 |
可拓展性 |
支持定制化开发,提供较高的可拓展能力,同时成本可以在接受范围内 |
可控性 |
发生问题后,能够在第一时间内进行问题排查,以最快的响应速度来处理问题,修复的方案是否会依赖于外部环境 |
市面框架对比:
- magic-microservices 一款基于 Web Components 的轻量级的微前端工厂函数。
- icestark 阿里出品,是一个面向大型系统的微前端解决方案
- single-spa 是一个将多个单页面应用聚合为一个整体应用的JavaScript 微前端框架
- qiankun 蚂蚁金服出品,基于 single-spa 在 single-spa 的基础上封装
- EMP YY出品,基于Webpack5 Module Federation 除了具备微前端的能力外,还实现了跨应用状态共享、跨框架组件调用的能力
- MicroApp 京东出品,一款基于WebComponent的思想,轻量、高效、功能强大的微前端框架
综合以上方案对比之后,我们确定采用了 qiankun
特定中心路由基座式的开发方案,原因如下:
- 保证技术栈统一 Vue、微应用之间完全独立,互不影响。
- 友好的“微前端方案“,与技术栈无关接入简单、像iframe一样简单
- 改造成本低,对现有工程侵入度、业务线迁移成本也较低。
- 和原有开发模式基本没有不同,开发人员学习成本较低。
- qiankun 的微前端有 3 年使用场景以及 Issue 问题解决积累,社区也比较活跃,在踩坑的路上更容易自救~
三、你需要明确的
微前端并不是万能的”解药“,没有正确治理,所有的 codebase 的归宿都是”屎山”
- qiankun不是一个完整的微前端解决方案!
- qiankun不是一个完整的微前端解决方案!!
- qiankun不是一个完整的微前端解决方案!!!
1.微前端的运行时容器
- qiankun 所帮你解决的这一块实际上是微前端的运行时容器,这是整个微前端工程化里面其中一个环节
- 从这个角度来讲 qiankun 不算是一个完整的微前端解决方案,而是微前端运行时容器的一个完整解决方案,当你用了 qiankun 之后,你几乎能解决所有的微前端运行时容器的问题,但是更多的一些涉及工程和平台的问题,则需要我们去思考与处理。
- 我们的版本管控、配置下发、监控发布,安全检测、等等这些怎么做,都不是 qiankun 作为一个库所能解答的,这些问题得根据具体情况,来选择适合自己的解决方案2. 迁移成本
- 对于老旧项目的接入,很难做到零成本迁移,在开发的时候要预留足够的踩坑,魔改代码的时间。如果是已经维持几年堆叠的屎山需要做好因为不规范编码,所产生的各种奇怪的兼容性问题,这个时候你甚至会怀疑,“微前端是否真的有必要?”3. 技术栈的选择
- 微前端的核心不是多技术共存,而是分解复杂度,提升协作效率,支持灵活扩展,能把“一堆复杂的事情”变成“简单的一件事情”,但是也不是无脑使用的,广东话来说“多个香炉多只鬼”,每多一个技术栈都会增加:维护成本,兼容成本,资源开销成本,这些都会无形的拖累生产力。
- 基座应用与微应用之间,强烈推荐使用相同的技术栈,相同的技术栈可以实现公共依赖库、UI库等抽离,减少资源开销,提升加载速度,最重要的是:“减少冲突的最好方式就是统一”,通过约束技术栈可以尽可能的减少项目之间的冲突,减少工作量与维护成本。
4. 微前端初尝试
- 对于微前端的接入最好的时候就是,刚开始不久或重要性不是特别强的项目,一方面项目具备兼容微前端的工程能力,另一方面项目使用微前端方案的成本最低,不需要改太多代码
- 对于老旧项目的接入建议还是从边缘简单的模版入手,逐步分解。
7. 标准化才能提升生产力
- 混乱的项目会拖累生产效率,同时混乱的微前端也会加剧内耗,所以只有标准化才能提升生产力。
- 解决微前端的接入问题是最简单的,但是微前端接入后的:工程化,应用监控,应用规范,应用管理才是微前端中困难的地方,如果你只是想简单的嵌入一个应用,我推荐你的使用 ”iframe“
9. qiankun 不支持 Vite !!!
- 🚀 Link微应用的拆与合思考:拆的是系统复杂度,合的是系统复用度 核心原则:高内聚,低耦合 github 未来是否考虑支持 vite
- 不建议尝试去改变目前的 qiankun,Vite的改造成本真的太高了,虽然webpack 比Vite慢,但是经过拆分的应用内容已经很小了,不会对项目有太大的拖累。
10. qiankun并不难
- 对于qiankun的学习其实大家不用很担心,好像一听微前端就很难的样子。因为 qiankun 真的很简单满打满算 10个API 都没有,接下来让我们一起走进qiankun的世界吧~~
- 🚀 Link qiankun 官网文档
四、微应用拆分规则
微应用的拆与合思考:拆的是系统复杂度,合的是系统复用度 核心原则:高内聚,低耦合
微应用的拆解没有具体规则,但是以下规则应该可以给你在进行系统拆分时提供一些依据。
- “尽量减少彼此的通信和依赖“,微前端的通信交互、链接跳转等操作所带来等成本其实是很大的,所以在拆分的时候尽量“完全独立,互不依赖”
- 微应用的拆分的时候切忌“盲目细致拆分”,过度拆分会导致 “做的很牛逼,但是没有用的困局”,微应用的拆分并不是一步到位的,我们要根据实际情况逐步拆分。如果一开始不知道应该划分多细,可以先粗粒度划分,然后随着需求的发展,逐步拆分。
- 如:现在有一个售后管理系统,我们按业务线拆分为:客服管理,库存管理,物流管理,未来客服管理需求功能持续庞大再拆解为:智能客服、电话客服、在线客服。而这些客服,又可以嵌入供应商管理中心,商品管理中心 等项目使用。
- 在拆分的时候我们应该尽量考虑未来场景:渐变式技术栈迁移,前端应用聚合、多系统业务复用,如何做业务解耦和代码复用。
- 应用之间应该尽量解耦,子应用的事情就应该由子应用来做。
- 如:子应用的一些标识,如:路由前缀,应用名称,根节点容器名称,依赖库的使用
- 需要明确什么是子应用应该维护的,什么是父应用应该维护的,如果什么资源都一股脑的使用父应用下发,则会导致应用之间耦合严重。
建议按照业务域来做拆分
- 保持核心业务的独立性,把无关的子业务拆分解耦。业务之间开发互不影响,业务之间可拆解微应用,单独打包,单独部署。
- 业务关联紧密的功能单元应该做成一个微应用、反之关联不紧密的可以考虑拆分成多个微应用,判断业务关联是否紧密的标准:看这个微应用与其他微应用是否有频繁的通信需求。
- 如果有可能说明这两个微应用本身就是服务于同一个业务场景,合并成一个微应用可能会更合适。
- 分析平台差异,平台差异大可以根据平台特性拆分
- 分析页面结构,如果结构清晰,可以根据结构拆分
- 分析产品业务,将产品逻辑耦合度高的功能合并到一起
五、引入qiankun - 在主应用中注册微应用
选择基座的模式?
- 通用中心路由基座式:只有公共功能的主应用(菜单栏、登录、退出…)不包含任何业务逻辑
- 特定中心路由基座式:一个含业务代码的项目作为基座,所有新功能作为子应用引入
以下案例是以Vue技术栈作为应用技术栈,建议应用之间还是统一技术栈,降低维护、上手、学习成本。越是不同技术、不同库的版本不同需要做的处理就越更多。
qiankun 注册微应用的方式:
💫自动模式:使用 registerMicroApps + start,路由变化加载微应用
- 当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配
- 首次load应用,创建子应用实例,渲染。
- 切到其他子应用后切回,会重新创建新的子应用实例并渲染。
- 之前的子应用实例 qiankun 直接不要了,即使你没有手动销毁实例。
- 采用这种模式的话 一定要在子应用暴露的 unmount 钩子里手动销毁实例,不然会导致内存泄漏。
- activeRule -
string | (location: Location) => boolean | Array<string | (location: Location) => boolean>
必选,微应用的激活规则。
- 支持直接配置字符串或字符串数组,如
activeRule: '/app1'
或 activeRule: ['/app1', '/app2']
,当配置为字符串时会直接跟 url 中的路径部分做前缀匹配,匹配成功表明当前应用会被激活。
- 支持配置一个 active function 函数或一组 active function。函数会传入当前 location 作为参数,函数返回 true 时表明当前微应用会被激活。如
location => location.pathname.startsWith('/app1')
自动挂载:registerMicroApps + start
yarn add qiankun
import { registerMicroApps, start } from 'qiankun';
const MICRO_CONFIG = [
{
name: 'vue app',
entry: '//localhost:7100',
container: '#yourContainer',
activeRule: '/yourActiveRule',
props: {
xxxx: '/'
}
}
]
registerMicroApps(MICRO_CONFIG)
start()
*activeRule 规则示例:此处拿官网的举例~**ctiveRule:'/app1'
activeRule:'/users/:userId/profile'
activeRule:'/pathname/#/hash'
activeRule:['/pathname/#/hash', '/app1']
手动模式:使用 loadMicroApp 手动注册微应用
- 每个子应用都有一个唯一的实例ID,reload时会复用之前的实例
- 如果需要卸载则需要手动卸载 xxxMicroApp.unmount()
由于registerMicroApps的特性,会导致路由的keep alive 失效,故本文使用 loadMicroAp + router.beforeEach 进行来达到自动注册的目的。
如果微应用不是直接跟路由关联的时候,你可以选择手动加载微应用的方式会更加灵活。
手动挂载: loadMicroApps
import { loadMicroApp } from 'qiankun';
this.microApp = loadMicroApp({
name: 'vue app',
entry: '//localhost:7100',
container: '#yourContainer',
activeRule: '/yourActiveRule',
props: {
xxxx: '/'
}
})
this.microApp.unmount()
六、微应用挂载节点
微应用可以挂载在页面的任意位置,微应用、微项目、微页面、微组件,一切皆有可能。
第一种:路由页内挂载,把子应用内嵌页入使用
// 主应用/src/views/About.vue
<template>
<div class="about">
<div id="sub-app-container"></div>
</div>
</template>
第二种:根DOM中与主应用同级挂载,切换的时候隐藏应用,显示当前应用
// 主应用/scr/App.vue
<template>
<div id="app">
<!-- 不同的微应用 -->
<div v-show="location.hash.startsWith('#/operation')" id="sub-operation-container"></div>
<div v-show="location.hash.startsWith('#/inventory')" id="sub-inventory-container"></div>
</div>
</template>
七、应用加载解析流程图
简易的图示了qiankun是如何通过 import-html-entry 加载微应用的
简易流程:
- qiankun 会用 原生fetch方法,请求微应用的 entry 获取微应用资源,然后通过 response.text 把获取内容转为字符串。
- 将 HTML 字符串传入 processTpl 函数,进行 HTML 模板解析,通过正则匹配 HTML 中对应的 javaScript(内联、外联)、css(内联、外联)、代码注释、entry、ignore 收集并替换,去除
html/head/body
等标签,其他资源保持原样
- 将收集的
styles
外链URL对象通过 fetch 获取 css,并将 css 内容以 <style>
的方式替换到原来 link标签的位置
- 收集 script 外链对象,对于异步执行的 JavaScript 资源会打上
async
标识 ,会使用 requestIdleCallback 方法延迟执行。
- 接下来会创建一个匿名自执行函数包裹住获取到的 js 字符串,最后通过 eval 去创建一个执行上下文执行 js 代码,通过传入 proxy 改变 window 指向,完成 JavaScript 沙箱隔离。源码位置。
- 由于 qiankun 是自执行函数执行微应用的 JavaScript,因此在加载后的微应用中是看不到 JavaScript 资源引用的,只有一个资源被执行替换的标识。
- 当一切准备就绪的时候,执行微应用的 JavaScript 代码,渲染出微应用
八、微应用接入三步走
第一步:微应用的入口文件 修改 *webpack_public_path*
- 在
src
目录新增 public-path.js
webpack
默认的 publicPath
为 ""
空字符串,会基于当前路径来加载资源。但是我们在主应用中加载微应用资源的时候会导致资源丢失,所以需要重新设置 __webpack_public_path__
的值
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
第二步:微应用webpack 新增配置
- webpack 配置修改 PS: 什么是 umd 模块?
const { name } = require('./package.json')
module.exports = {
devServer: {
port: 8081,
disableHostCheck: true,
headers: {
'Access-Control-Allow-Origin': '*'
}
},
configureWebpack: {
output: {
library: `${name}-[name]`,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${name}`
}
}
}
第三步:微应用添加生命周期
微应用需要在自己的入口文件,添加 bootstrap
、mount
、unmount
三个生命周期钩子,供主应用在适当的时机调用。
- main.js 注册微应用,增加判断让子应用就算脱离了父应用也可以独立运行
- PS:qiankun 生命周期函数都必须是 Promise,使用 async 会返回一个Promise对象
import './public-path.js'
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
let instance = null
function render(props = {}) {
const { container } = props
instance = new Vue({
router,
store,
render: h => h(App),
}).$mount(container ? container.querySelector('#app-micro') : '#app-micro')
}
if (!window.__POWERED_BY_QIANKUN__) {
render()
}
export async function bootstrap() {
console.log('[vue] vue app bootstraped')
}
export async function mount(props) {
console.log('[vue] props from main framework', props)
render(props);
}
export async function unmount() {
instance.$destroy()
instance.$el.innerHTML = ''
instance = null
}
export async function update(props) {
console.log('update props', props)
}
小结经历这几步,qiankun 父应用与微应用就接入完成了。当父应用完成加载微应用的时候,微应用就会遵循对应的解析 规则,插入到父应用的HMTL中了。
九、预加载微应用
预先请求子应用的 HTML、JS、CSS 等静态资源,等切换子应用时,可以直接从缓存中读取这些静态资源,从而加快渲染子应用。
registerMicroApps 模式下在 start
方法配置预加载应用
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([...AppsConfig])
start({ prefetch: "all" })
prefetch - boolean | 'all' | string[] | (( apps: RegistrableApp[] ) => { criticalAppNames: string[]; minorAppsName: string[] })
- 可选,是否开启预加载,默认为 true
。
配置为 true
则会在第一个微应用 mount 完成后开始预加载其他微应用的静态资源
配置为 all
则主应用 start
后即开始预加载所有微应用静态资源
配置为 string[]
则会在第一个微应用 mounted 后开始加载数组内的微应用资源
配置为 function
则可完全自定义应用的资源加载时机 (首屏应用及次屏应用)
loadMicroApps 模式下
import { prefetchApps } from 'qiankun';
export const MICRO_PREFETCH_APPS = [
{ name: 'vue-child', entry: '//localhost:7101/' },
{ name: 'vue-app', entry: '//localhost:8081/' }
]
prefetchApps(MICRO_PREFETCH_APPS)
export const MICRO_CONFIG = [
{
name: 'you app name',
entry: '//localhost:7286/',
container: '#yuo-container-container',
activeRule: '/your-prefix',
isPreload: true,
}
]
import { prefetchApps } from 'qiankun';
import { MICRO_CONFIG } from '@/const/micro/application-list.js';
const MICRO_PREFETCH_APPS = MICRO_CONFIG.reduce(
(total, { isPreload, name, entry }) => (isPreload ? [...total, { name, entry }] : total),
[]
)
prefetchApps(MICRO_PREFETCH_APPS)
- 笔者用的模式就是 loadMicroApps 模式,为了日后维护的便携性,改造一下:,新增 isPreload 字段维护是否开启预加载,这样有关于微应用的信息都在此 js文件维护,避免散弹式修改。
十、路由模式选择与改造
我们应该怎么选择路由?
最好的路由模式就是主应用、子应用都统一模式,可以减少不同模式之间的兼容工作
本文选择统一为:父子路由hash 模式
主模式 |
子模式 |
推荐 |
接入影响 |
解决方案 |
备注 |
hash |
hash |
强烈推荐 |
无 |
|
|
hash |
history |
不推荐 |
有 |
history.pushState |
改造成本大 |
history |
history |
强烈推荐 |
无 |
|
|
history |
hash |
推荐 |
无 |
|
|
PS: 每个模式之间的组合并不是接入就可以完成的,都需要一些改造,如:增加路由前缀,路由配置base设置,不同的模式activeRule的规则都不同
路由改造工作
新增微应用路由前缀
新增前缀不是微应用必须的,但是为了从 URL 上与其他应用隔离,也是为了接入旧应用的时候,能让 activeRule 方法能识别并激活应用,故新增路由前缀。
父应用路由表
[
{
path: '/your-prefix',
name: 'Home',
component: Home
},
{
path: '/your-prefix/*',
name: 'Home',
component: Home
}
]
PS:子应用路由切换,由于应用与路由都是通过 URL 注册与销毁的,当子应用路由跳转地址,无法与父应用的路由地址匹配上的时候页面会销毁,需要注意路由匹配,或者增加路由兜底。
子应用 hash 模式
new VueRouter({
mode: 'hash',
routes: [
{
path: `${ window.__POWERED_BY_QIANKUN__ ? 'your-prefix' : ''}/login`,
component: _import('login/index.vue')
}
]
})
子应用 history 模式
new VueRouter({
mode: 'history',
base: window.__POWERED_BY_QIANKUN__ ? 'your-prefix' : null,
routes: [
{
path: '/login',
component: _import('login/index.vue')
}
]
})
十一、 📝 旧项目路由接入改造
但是由于笔者是接入的是旧项目并且又是 hash 路由模式想顺利接入,一个个加三元则改动太多路由表了,为了减少对于旧项目接入时的影响仅在以下三处做修改(ps:因为懒)
1. hash路由模式:格式化路由表对象,微路由表路径,别名,重定向增加前缀区分应用
- 这里我们利用递归函数需要给路由动态增加前缀、path 、redirect、alias 这个三种状态需要动态处理一下
- 路由表数据
const routes = [
{
path: '/home',
name: 'home',
component: Home
},
{
path: '/about',
name: 'about',
redirect: 'home',
component: () => import('../views/About.vue')
},
{
path: '/about',
name: 'about',
alias: '/user/about',
component: () => import('../views/About.vue')
}
]
格式化路由方法
let SUN_ROUTER_PATH_LIST = []
export function formatRouterParams(parameter) {
if (!window.__POWERED_BY_QIANKUN__) {
return parameter.data
}
const recursionData = ({ data, params, value, deepKey }) => {
return data.reduce((total, item) => {
item = formatData({ item, params, value })
if (deepKey && item[deepKey] && Array.isArray(item[deepKey])) {
item[deepKey] = recursionData({
data: item[deepKey],
params,
value,
deepKey
})
}
return [...total, item]
}, [])
}
return recursionData(parameter)
}
export function formatData({ item, params, value }) {
if (!item) return
if (Array.isArray(params)) {
params.forEach(key => {
if (Object.prototype.hasOwnProperty.call(item, key)) {
item[key] = geRouterValue(value, item[key])
}
})
} else if (params) {
item[params] = geRouterValue(value, item[params])
}
SUN_ROUTER_PATH_LIST.push(item)
return item
}
export function geRouterValue(value, key) {
return typeof value === 'function' ? value(key) : value
}
调用递归方法统一替换微应用路由表
const BASE_ROUTER_PATH = 'your-prefix'
const router = new VueRouter({
mode: 'hash',
routes: formatRouterParams({
data: route,
deepKey: 'children',
params: ['path', 'redirect', 'alias'],
value: value => {
if (window.__POWERED_BY_QIANKUN__ && typeof value === 'string') {
const path = value[0] === '/' ? value : `/${value}`
return BASE_ROUTER_PATH + path
}
return value
}
})
})
遍历结果返回-路由表,统一增加“your-prefix”前缀啦
[
{
"path": "your-prefix/home",
"name": "home",
"component": "Home"
},
{
"path": "your-prefix/about",
"name": "about",
"redirect": "your-prefix/home",
"component": ""
},
{
"path": "your-prefix/about",
"name": "about",
"alias": "your-prefix/user/about",
"component": ""
}
]
2. router.beforeEach
跳转的时候调用检查跳转函数,判断是否需要增加前缀
router.beforeEach((to, from, next) => {
checkLink(to, next, () => {
next()
})
})
- 简单来说:如果是在 qiankun 环境中,并且不是跳转其他微应用的path, 并且跳转不是格式化前缀的路径,并且当前拼接的地址与格式化的路由地址是一致的才拼接 next
const { name } = require('../../package.json')
export const BASE_ROUTER_PATH = `/${name}`
export function checkLink(to, next, callback) {
const IS_HAVE_QIANKUN = window.__POWERED_BY_QIANKUN__
const IS_JUMP_TO_MICRO_APP = Object.values(LINK_MICRO_APP_LIST).includes(to.path)
const IS_BASE_PATH_SYMBOL = to.path === '/'
const IS_HAVE_BASE_ROUTER_PATH = getBasePath(to.path, '/') === getBasePath(BASE_ROUTER_PATH, '/')
const IS_ADD_PREFIX = IS_HAVE_QIANKUN && !IS_JUMP_TO_MICRO_APP && !IS_HAVE_BASE_ROUTER_PATH
if (IS_ADD_PREFIX || IS_BASE_PATH_SYMBOL) {
const path = `${BASE_ROUTER_PATH}${to.path}`
if (SUN_ROUTER_PATH_LIST.some(e => [e.path, e.redirect, e.alias].includes(path))) {
next({ path })
}
}
callback && callback()
}
export function getBasePath(path, prefix = '') {
if (!path) return
const pathArray = String(path).split('/').filter(item => item)
const basePath = prefix + pathArray[0]
return basePath
}
3.改写router.push OR router.replace
const originalPush = VueRouter.prototype.push
VueRouter.prototype.push = function push(location) {
return originalPush.call(this, location).catch(err => err)
}
ps: 其可以直接在push中改写, 省略 router.befroeEach
const originalPush = VueRouter.prototype.push
VueRouter.prototype.push = function push(location) {
const IS_JUMP_TO_MICRO_APP = Object.values(LINK_MICRO_APP_LIST).includes(location.path)
const IS_HAVE_QIANKUN = window.__POWERED_BY_QIANKUN__
const IS_BASE_PATH_SYMBOL = location.path === '/'
const IS_HAVE_BASE_ROUTER_PATH = getBasePath(location.path, '/') === BASE_ROUTER_PATH
const IS_ADD_PREFIX = IS_HAVE_QIANKUN && !IS_JUMP_TO_MICRO_APP && !IS_BASE_PATH_SYMBOL && !IS_HAVE_BASE_ROUTER_PATH
if (IS_ADD_PREFIX) {
location.path = `${BASE_ROUTER_PATH}${location.path}`
}
return originalPush.call(this, location)
}
4. 路由跳转记录
跳转方式: 路由跳转与正常使用无异
- ps: 在父应用是history模式,子应用是 hash模式的时候 子应用需要特殊处理一下URL重定向切换 history.pushState
跳转其他微应用
- 由于我们的应用之间式分离的,所以跳转外部应用的路由也是分离的,如果在项目中字面量固定写死风险太大了,如果外部应用发生一点改变,需要改项目里的路径的时候将会是一个噩梦,所以我们统一使用在各自的微应用维护一个常量列表去处理记录应用之间的跳转,方便全局统一管理。此处仅笔者一点拙见,如有更好建议请多多发表。
- PS:此处跳转的常量列表其实也可以放到基座应用去维护,但是最佳选择是有运维平台去维护应用之间跳转关系会更好~
export const LINK_MICRO_APP_LIST = {
CHILD_VUE: '/child/vue',
CHILD_REACT: '/child/react',
USER_INFO: '/user/info'
}
使用场景:
const IS_JUMP_TO_MICRO_APP = Object.values(LINK_MICRO_APP_LIST).includes(to.path)
let routes = [
{
path: '/about',
name: 'about',
redirect: LINK_MICRO_APP_LIST['CHILD_VUE'],
component: () => import('../views/About.vue')
}
]
router.beforeEach((to, from, next) => {
if (IS_JUMP_TO_MICRO_APP) {
next(false)
}
})
十二、 微应用与路由之间 如何 keep alive
- registerMicroApps模式下,为什么切换路由会导致应用重载?
- 例:A 到 B, 触发A unmount ⇒ 判断 B 是否加载过,已加载则触发 mount,未加载则触发 bootstrap ⇒ mount
- 详情可以看上文 “五、引入qiankun - 在主应用中注册微应用”
- URL 改变时应用匹配切换,路由的切换会导致应用的卸载与加载
- 如果子应用挂载在内部路由,路由跳转也将触发应用的重载
- 应用切换导致重载,导致组件状态丢失,为了保持应用实例不被加载,我们需要手动的控制应用的注册与销毁
- 方案一:loadMicroApp
- 优点:在一个页面中可以同时挂载多个微应用
- 缺点:无法根据路由匹配规则来挂载应用
- 适用场景:当需要在一个页面中同时挂载2个以上子应用,并且子应用的挂载不需要通过路由匹配来实现。
- PS:在基座中关闭标签页时,需要手动调用app的unmount钩子销毁应用,不然再次新建页签进入时还是以前的实例
loadMicroApp不能根据路由规则来挂载应用不是qiankun的问题,是我们的问题~
使用 router.*afterEach* + loadMicroApp 的解决应用 keep alive,思路是通过判断路由守卫的地址,如果是符合激活规则的则激活应用
1. 主应用 router 路由守卫
router.afterEach(to => {
setTimeout(() => {
microApplicationLoading(to.path)
})
})
2. 判断微应用加载的方法 microApplicationLoading
export const microApplicationList [
{
name: 'you app name',
entry: '//localhost:7286/',
container: '#yuo-container-container',
activeRule: '/your-prefix',
**isPreload: true,
isRouteStart: true,
props: {
router: router,
store: store,
parentEventHub: parentEventHub
}
}
]
import { loadMicroApp } from 'qiankun'
export async function microApplicationLoading(path) {
let currentActiveMicroConfig = await store.dispatch('d2admin/micro/GET_FIND_MICRO_CONFIG', path)
const microApplicationList = store.getters['d2admin/micro/microApplicationList']
if (!currentActiveMicroConfig || !currentActiveMicroConfig.isRouteStart) {
return
}
const cacheMicro = microApplicationList.get(currentActiveMicroConfig.activeRule)
const containerNode = getContainerNode(currentActiveMicroConfig.container)
const isNoTNodeContents = containerNode !== -1 && !containerNode
if (isNoTNodeContents || !cacheMicro) {
if (cacheMicro) {
cacheMicro.unmount()
cacheMicro.unmountPromise.then(() => {
loadRouterMicroApp(currentActiveMicroConfig)
})
return
}
loadRouterMicroApp(currentActiveMicroConfig)
}
}
export function loadRouterMicroApp(currentApp) {
const micro = loadMicroApp(currentApp)
micro.mountPromise.then(() => {
store.dispatch('d2admin/micro/SET_MICRO_APPLICATION_LIST', {
key: currentApp.activeRule,
value: micro
})
})
}
export function getContainerNode(container) {
const containerNode = container && document.querySelector(container)
if (containerNode) {
return containerNode.childNodes.length
}
return -1
}
vuex 方法 记录一下注册应用对象
import MICRO_CONFIG from '@/const/micro/application-list.js '
export default {
state: {
microApplicationList: new Map([])
},
getters: {
microApplicationList(state) {
return state.microApplicationList
}
},
actions: {
SET_MICRO_APPLICATION_LIST({ state, dispatch }, { key, value }) {
state.microApplicationList.set(key, value)
},
GET_FIND_MICRO_CONFIG({ state }, path) {
return MICRO_CONFIG.find(e => {
return getPathPrefix(path, '/') === getPathPrefix(e.activeRule, '/')
})
}
}
}
export function getPathPrefix(path, prefix = '') {
if (!path) return
const pathArray = String(path).split('/').filter(item => item)
const basePath = prefix + pathArray[0]
return basePath
}
十三、沙箱模式
CSS沙箱
微前端对于样式隔离问题,目前相关配套还不是很成熟
image.png
- 由于微前端场景下,不同技术栈的子应用会被集成到同一个运行时中,所以我们必须在框架层确保各个子应用之间不会出现样式互相干扰的问题。不同于 JavaScript 的隔离,目前 CSS 隔离在行业内还不完全的成熟,但是“甲之蜜糖,乙之砒霜“,每个方案都有着不同的优势与劣势。
- 样式隔离有着各种方案
- BEM (Block Element Module)规范
- CSS-Modules 构建时生成各自的作用域
- CSS in JS 使用 JS 语言写 CSS
- Shadow DOM 沙箱隔离
- experimentalStyleIsolation 给所有的样式选择器前面都加了当前挂载容器
- Dynamic Stylesheet 动态样式表
- postcss 增加命名空间
- 但是即使是有着如此多的样式隔离方案,css 还是会有一堆问题等着你去处理。例如:
- 不同应用依赖了同一个UI库,不同版本的情况
- 子应用,样式丢失或应用到了主项目的样式
- 微应用构运行时越界例如 body 构建 DOM 的场景(弹窗、抽屉、popover 等这种插入到主应用body 的dom 元素),必定会导致构建出来的 DOM 无法应用子应用的样式的情况。
本文采取的样式隔离的最佳实践是:采用约定式隔离,用 CSS 命名空间。备选:CSS Module、css-in-js 等工程化手段,建立约束:如:避免写全局样式,子应用不能侵入(如动态增加全局样式等)修改除本应用外的样式,子应用样式写在以子应用名作为命名空间的类里等。
- 默认沙箱
- qiankun是默认开启沙箱隔离的,默认情况下沙箱可以确保单实例场景子应用之间的样式隔离,但是无法确保主应用跟子应用、或者多实例场景的子应用样式隔离
- 严格样式隔离的沙箱模式
start({
sandbox: {
strictStyleIsolation: true
}
})
qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响,基于 ShadowDOM 的严格样式隔离并不是一个可以无脑使用的方案,大部分情况下都需要接入应用做一些适配后才能正常在 ShadowDOM 中运行起来
image.png
qiankun 还提供了一个实验性的样式隔离特性 experimentalStyleIsolation
image.png
- 当 experimentalStyleIsolation 被设置为 true 时,qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围
qiankun 还提供了一个实验性的样式隔离特性 experimentalStyleIsolation
image.png
- 当 experimentalStyleIsolation 被设置为 true 时,qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围
BEM (Block Element Module)规范命名约束
特定规则链接:
模块: .Block
模块多单词: .Header-Block
模块_状态: .Block_Modifier
模块__子元素: .Block__Element
模块__子元素_状态:.Block__Element_Modifier
- ‘-‘ 中划线 :仅作为连字符使用,表示某个块或者某个子元素的多单词之间的连接符号。
- __ 双下划线:双下划线用来连接块和块的子元素
- _ 单下划线:单下划线用来描述一个块或者块的子元素的一种状
- B -
Block
一个独立的模块,一个本身就有意义的独立实体 比如:header
、menu
、container
- E -
Element
元素,块的一部分但是自身没有独立的含义 比如:header title
、container input
- M -
Modifier
修饰符,块或者元素的一些状态或者属性标志 比如:small
、checked
CSS Modules
- 指的是我们像 import js 一样去引入我们的 css 代码,代码中的每一个类名都是引入对象的一个属性,通过这种方式,即可在使用时明确指定所引用的 css 样式。并且 CSS Modules 在打包的时候会自动将类名转换成 hash 值,完全杜绝 css 类名冲突的问题。
CSS In JS
- CSS in JS,意思就是使用 js 语言写 css,完全不需要些单独的 css 文件,所有的 css 代码全部放在组件内部,以实现 css 的模块化
postcss 增加命名空间
npm i postcss-plugin-namespace -D
module.exports = ctx => {
return {
plugins: [
require('postcss-plugin-namespace')('#your-prefix', {
ignore: ['html', /body/]
})
]
}
}
<html id="your-prefix"></html>
- public/index.html
- 配置postcss
在项目根目录创建postcss.config.js
文件
该插件会将全局所有class前加上统一前缀,并过滤掉ignore内的标签;ignore内可以写字符串,可以写正则表达式。但每次编译前都会运行,所以可能会增加编译时间
注意:如果用/body/
这样的正则,会将所有带body的class都过滤掉,比如el-drawer__body
、el-dialog__body
等。
JavaScript 沙箱
qiankun框架为了实现 JavaScript隔离,提供了三种不同场景使用的沙箱,分别是 **snapshotSandbox
*、*proxySandbox
*、*legacySandbox
**
- 快照沙箱(snapshotSandbox):
qiankun
的快照沙箱是基于diff
来实现的,主要用于不支持window.Proxy
的低版本浏览器(IE 浏览器),而且也只适应单个的子应用
- 代理沙箱(proxySandbox):
qiankun
基于es6
的Proxy
实现了两种应用场景不同的沙箱,
- 一种是
legacySandbox
(单例)
- 一种是
proxySandbox
(多例)
- PS:qiankun 默认开启沙箱模式
- 但是qiankun目前还是有一些缺陷:给某个内置对象添加属性或方法会导致突破沙箱限制,污染都全局的window属性
- 沙箱不是万能的,沙箱只有一层的劫持,例如 Date.prototype.xxx 这样的改动是不会被还原的
image.png
window.localStorage.setItem = function() {
console.log('Hi child')
}
console.log(window.localStorage.setItem)
console.log(window.localStorage.setItem)
- 微应用挂载window的 是 proxy 代理出来的 window,并不是真实的window,所以修改会被隔离掉
image.png
window.user = {
my: {
name: 'I m your father'
}
}
console.log(window.user)
window.user = {
my: {
name: 'child'
}
}
console.log(window.user)
console.log(window.user)
微前端中最多的问题就是在沙箱中了,无论是 CSS 还是 JavaScript 沙箱都不是十全十美的,我们只能通过各种约束来避免沙箱出现问题的可能。例如:建立团队前缀,命名空间 CSS、事件、本地存储和 Cookie,以避免冲突并明确所有权。
笔者整理了一下经常出现问题的场景。
- 由于 qiankun 沙箱的缺陷,window 对象并不是完全隔离的,子应用的 window 又是基于父应用的,经常导致的是:父应用的依赖库已经挂到window上了,子应用再挂载的时候就报错了
- 微前端海纳百川的特性,当不同技术栈的应用被集合在同一个”运行时环境“的时候,微应用之间会出现样式互扰的问题,依赖版本冲突的问题
- 代码在沙箱内运行错误的问题,主要是 BOM,DOM 的 API 使用冲突,因为无法隔离所以会有改写的危机
- qiankun 会将微应用的 JS/CSS 内容都记录在全局变量中,如果一直重复的挂载应用没有卸载,会导致内存占用过多,导致页面卡顿。
- 给 body 、 document 等绑定的事件,必须在 unmount 周期清除,使用 document.body.addEventListener 或者 document.body.onClick 添加的事件并不会被沙箱移除,会对其他的页面产生影响
- 第三方引入的 JS 不生效,有些 JS 文件本身是个立即执行函数,或者会动态的创建 scipt 标签,但是所有获取资源的请求是被乾坤劫持处理,所以都不会正常执行,也不会在 window 下面挂载相应的变量
- 由于是相同的 window 对象,不会有应用之间的隔离,localStorage、sessionStorage、cookie 等对象互相冲突覆盖
- 改变全局变量 window/location 的默认行为,通过 document 操作 Layout 的 DOM,这些本身都是一些不推荐的做法
十四、localStorage、sessionStorage应用之间的使用
- 因为父子应用都是同一个 window,所以 localStorage、sessionStorage、cookie, 这些方法就会造成数据覆盖问题
- 正常读取即可,因为无论父子应用,存储的相关信息都以父应用的地址进行存储。
- 需要注意微应用之间数据冲突、数据覆盖问题,这里改写一个 setItem getItme 解决这个问题
PS:
- 此方案只是针对难以改动的老项目去做的,不推荐去改变 window 的方法,如果您有这个需求则应该去抽离成一个类或函数去做。
- 子项目的改动原 window 的 prototype qiankun 的沙箱无法处理隔离
- 目前不支持 sessionStorage[“keyName”] = value, sessionStorage.keyName = value 这种写法,如果想使用以上方法可以使用 proxy or defineProperty 改写本文不再赘述
- 动态给 getItem、setItem方法加前缀,这样在接入旧项目的时候不会这么痛苦,取巧方式,不推荐
const storageMap = [
{
storage: sessionStorage,
method: 'getItem'
},
{
storage: sessionStorage,
method: 'setItem'
},
{
storage: localStorage,
method: 'getItem'
},
{
storage: localStorage,
method: 'setItem'
}
]
function formatItem(storage, method) {
storage[method] = function(key, value, isGlobal = false) {
if (window.__POWERED_BY_QIANKUN__ && !isGlobal) {
key = BASE_ROUTER_PATH + key
}
Object.getPrototypeOf(storage)[method].call(this, key, value)
}
}
storageMap.forEach(({ storage, method }) => {
formatItem(storage, method)
})
十五、资源共享
https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2d381aa6a6e34b11bf0c9ae59dcc85f4~tplv-k3u1fbpfcp-zoom-1.image
对于应用之间的资源共享,笔者认为这个与微前端的概念是有矛盾的。
微前端的概念:
- 「技术栈无关」:主框架不限制接入应用的技术栈,子应用具备完全自主权
- 「独立开发 独立部署」:子应用仓库独立,可独立开发,部署完成后主框架自动完成同步更新
- 「独立运行」:每个子应用之间状态隔离,运行时状态不共享,不要共享运行时,即使所有团队都使用相同的框 架。构建自包含的独立应用程序。不要依赖共享状态或全局变量。
矛盾思考:
- 「技术栈无关」是架构上的准绳,具体到实现时,对应的就是:应用之间不应该有任何直接或间接的技术栈、依赖、以及实现上的耦合。
- 按照理想的情况:我们希望微前端尽可能独立解耦,但是不同微应用之间可能存在大量相同的重复的资源依赖,在分秒必争的今天,每一个资源的开销都不容小觑,如果把一些可复用的资源直接共享出去,那岂不是可以高效降低资源的开销了吗。
- 当我们把资源共享出去的时,也给应用带来了依赖冗余,微应用把公共资源引入的同时,也把未来的复杂性也给引入进来了。
1. 共享模块方式
以下是笔者整理的共享模块的方式,
npm 依赖
- 抽离相关代码(utils、组件..) 将其打包并上传 npm 库,然后在需要的微应用中以本地依赖或 npm link 的方式安装依赖,以 npm 的方式达到资源共享的目的。但是其本质只是代码层面的共享与复用,每个应用构建的的时候还是会把依赖包一起打包
- 并且 npm 管理,每次 npm 更新的时候都要在各微应用进行重新构建发布。
git submodule or git subtree
- 🚀 Link git submodule
- subtree 和 submodule 的目的都是用于 git 子仓库管理,二者的主要区别在于,subtree 属于拷贝子仓库,而 submodule 属于引用子仓库。
- 他们允许你将一个 Git 仓库当作另外一个 Git 仓库的子目录。这允许你克隆另外一个仓库到你的项目中并且保持你的提交相对独立
- 创建一个 libs 的项目进行管理维护,里面存放各种公用的方法,组件,图片等,并且同步到gitlab上
git submodule
和 git subtree
都是很好的子仓库管理方案,但缺点是每次子应用变更后,聚合库还得同步一次变更,考虑到并不是所有人都会使用该聚合仓库,子仓库独立开发时往往不会主动同步到聚合库,使用聚合库的同学就得经常做同步的操作,比较耗时耗力,不算特别完美。
webpack Externals
- 🚀 Link git Externals
- 配置 webpack 输出的 bundle 中排除依赖,换句话说通过在 Externals 定义的依赖,最终输出的 bundle 不存在该依赖,
- externals 前提是依赖都要有 cdn 或 找到它对应的 JS 文件,例如:jQuery.min.js 之类的,也就是说这些依赖插件得要是支持 umd 格式的才行。
- 通过这种形式在微前端基座应用加载公共模块,并将微应用引用同样模块的Externals 移除掉,就可以实现模块共享了 但是存在微应用技术栈多样化不统一的情况,可能有的使用 Vue3,有的使用 React 开发,但 externals 并无法支持
多版本共存
的情况
qiankun不建议共享依赖,担心原型链污染等问题,如果一定要使用:推荐使用webpack的 Externals 来共享依赖库。
使用场景:
- 如果主子应用使用的是相同的库或者包!!! (
vue、axios、vue-router、element
等) 可以用 externals 的方式来引入,减少加载重复包导致资源浪费,一个项目使用了之后,另一个项目使用不再重复加载,可以直接复用这个文件。
使用原理:
qiankun
将子项目的外链 script
标签,内容请求到之后,会记录到一个全局变量中,下次再次使用,他会先从这个全局变量中取。这样就会实现内容的复用,只要保证两个链接的 url
一致即可。
使用方式:
- 微应用之间使用
- 只要子项目配置了
webpack
的 externals,并在 index.html
中使用外链 script
引入这些公共依赖,只要这些公共依赖URL一致即可,请求的时候会优先从缓存中读取,类似HTTP缓存
- 微应用使用基座依赖
- 给微应用的公共依赖的加上
ignore
属性(这是自定义的属性,非标准属性)。
qiankun
在入口解析的时候会判断如果有这个属性就忽略。子项目独立运行,这些 js/css
仍能被加载,如此,便实现了“子项目复用主项目的依赖”。
module.exports = {
configureWebpack: {
externals: {
'vue': 'Vue',
'vue-router': 'VueRouter',
'vuex': 'Vuex',
'element-ui': 'ELEMENT'
}
}
}
<link ignore rel="stylesheet" href="//cnd.com/antd.css">
<script ignore src="//cnd.com/antd.js"></script>
PS:主项目使用externals
后,子项目可以复用它的依赖,但是不复用依赖的子项目会报错。
🚀 Link # [Bug]公共依赖提取的时候,qiankun,代理window访问并没有先访问微应用的window,再访问主应用的window
webpack DLL
- 🚀 Link webpack DLL
- dll 插件可以帮助我们直接将已安装好的依赖在 node_module 中打包出来,结合 add-asset-html-webpack-plugin 插件帮助我们将生成打包好的 js 文件插入到 html 中
- 因为使用公共依赖,意味着所有使用公共依赖的应用,必须使用同版本的依赖,并且 qiankun 使用 dllplugin 提取公共依赖后,导致不同子应用中的全局 filter、component、mixin 相互影响
使用lerna管理
- Lerna · 是一个管理工具,用于管理包含多个软件包(package)的 JavaScript 项目 | Lerna 中文文档
通过聚合目录
- 聚合目录相当于是一个空目录,在该目录下 clone 所有子仓库,并 .gitignore,子仓库的代码提交都在各自的仓库目录下进行操作,这样聚合库可以避免做同步的操作。
上面的方案都是业内比较成熟的方案,还需开发者深入了解,笔者采用的是:NPM、webpack external。对外的且稳定的组件或封装,推荐 npm
包方式。
2. 通过主应用共享资源给微应用
主应用的下发资源的核心就是:注册的时候通过 props 下发
props 方式
- 父应用注册时或加载时,将依赖通过
props
传递给子应用,子应用在 bootstrap
或者 mount
钩子函数中获取
- 主应用注册下发,任何你想要的资源,但是切勿无脑下发资源,需要考虑日后解耦或独立运行的问题。
import { layout, assets, config, layout, public } from '/lib'
export default [{
name: 'you-app-name',
entry: '//localhost:7286/',
container: '#you-app-name-container',
activeRule: '/you-app-name',
props: {
hideLayout: true,
defaultPath: '',
commonComponent: {},
public: public,
assets: assets,
config: config,
layout: layout
}
}]
如果是动态数据可以注册的时候传递下发
import store from '@/store/index'
import router from '@/router'
currentApp.props = {
...currentApp.props,
router: router,
store: store
}
loadMicroApp(currentApp)
import childStore from '@/store/index'
import childRouter from '@/router'
export async function mount(props) {
render(props)
}
function render(props = {}) {
const { container, router,store,layout, config, assets, public, commonComponent } = props
instance = new Vue({
childRouter,
childStore,
data() {
return {
parentRouter: router,
parentVuex: store,
parentLayout: layout,
parentConfig: config,
parentAssets: assets,
parentPublic: public,
parentCommonComponent: commonComponent
}
},
render: h => h(App)
}).$mount(container ? container.querySelector('#you-micro') : '#you-micro')
}
window 方式
- 因为主项目会先加载,然后才会加载子项目,所以一般是子项目复用主项目的组件,做法也很简单,主项目加载时,将组件挂载到
window
上,子项目直接注册即可
- 但是笔者这里不推荐任何修改 window 的方式,因为沙箱缺陷的缘故,不打扰就是最好的安排~
主项目入口文件:
import HelloWorld from '@/components/HelloWorld.vue'
window.commonComponent = { HelloWorld };
子项目直接使用:
components: {
HelloWorld: window.__POWERED_BY_QIANKUN__ ? window.commonComponent.HelloWorld : import('@/components/HelloWorld.vue'))
}
项目间的组件共享
子项目本身自己也有这个组件,当别的子项目已经加载过了,就复用别人的组件,如果别的子项目未加载,就使用自己的这个组件
适用场景就是避免组件的重复加载,这个组件可能并不是全局的,只是某个页面使用。做法分三步:
1.由于子项目之间的全局变量不共享,主项目提供一个全局变量,用来存放组件
import HelloWorld from '@/components/HelloWorld.vue'
props: {
commonComponent: {
HelloWorld
}
}
2.子项目拿到这个变量挂载到 window
上
export async function mount(props) {
window.commonComponent = props.data.commonComponent
render(props.data)
}
3.子项目中的共享组件写成异步组件,异步组件需要返回 Promise.resolve()
components: {
HelloWorld: async () => {
if (!window.commonComponent) {
window.commonComponent = {}
}
const HelloWorld = window.commonComponent.HelloWorld
return HelloWorld || (window.commonComponent.HelloWorld = import('@/components/HelloWorld.vue'))
}
十六、应用通信
通信设计原则
- 跨应用通信:解耦易接入
- 开放但不失约束:通信收口,统一管理
- 简单易用:学习成本低,接口尽可能少
- 易于维护:分模块管理,避免通信冲突
- 容易排查:链路监控性强,及时跟踪问题
微前端通信方式
- 基于 URL
- 使用简单、通用性强,但能力较弱,不适用复杂的业务场景
- 基于 Props
- 应用给子应用传值。适用于主子应用共享组件、公共方法调用等。
- 发布/订阅模式
- 一对多关系,观察者和被观察者是抽象耦合的。但是数据链路难跟踪。
- 状态管理模式
- 基于
localStorage
、sessionStorage
实现的通信方式
- 不推荐,因为 JSON.stringify() 会造成数据丢失,它只会对Number、String、Booolean、Array转换,对于undefined、function、NaN、 regExp、Date 都会丢失本身的值
基于URL、Props 、LocalStorage 的方式就不讲述了上文都有对应的说明,以下只对 发布/订阅模式,状态管理模式进行讲解
发布/订阅模式 EventBus
笔者这里的设计模式是,主应用注册 EventBus,然后通过 props 下发微应用,这样微应用既有主应用的EventBus 也可以有自己的 EventBus
Vue.prototype.$eventBus = new Vue()
export const parentEventBus = Vue.prototype.$eventBus
import { parentEventBus } from '@/main'
currentApp.props = {
...currentActiveMicroConfig.props,
parentEventBus: parentEventBus
}
loadMicroApp(currentApp)
export async function mount(props) {
render(props)
}
function render(props = {}) {
const { parentEventBus } = props
Vue.prototype.$eventBus = new Vue()
Vue.prototype.$parentEventBus = parentEventBus
}
this.$parentEventBus.$off('you-event')
this.$parentEventBus.$on('you-event', data => {
})
this.$parentEventBus.$emit('you-event', {...})
使用 qiankun initGlobalState
import { initGlobalState } from 'qiankun'
export const initialState = {}
const actions = initGlobalState(initialState)
export default actions
import actions from '@/const/micro/actions'
actions.setGlobalState({
xxxxDataKey: xxxValue
})
actions.onGlobalStateChange((state, prev) => {
console.log(state, prev, '子应用的 state: 变更后的状态; prev 变更前的状态')
})
function emptyAction() {
console.warn('Current execute action is empty!')
}
class Actions {
actions = {
onGlobalStateChange: emptyAction,
setGlobalState: emptyAction
}
setActions(actions) {
this.actions = actions
}
onGlobalStateChange(...args) {
return this.actions.onGlobalStateChange(...args)
}
setGlobalState(...args) {
return this.actions.setGlobalState(...args)
}
}
const actions = new Actions()
export default actions
import actions from './const/micro/actions'
export async function mount(props) {
actions.setActions(props)
}
actions.onGlobalStateChange((state, prev) => {
})
状态管理模式
import store from '@/store/index'
currentApp.props = {
...currentActiveMicroConfig.props,
store: store
}
loadMicroApp(currentApp)
export async function mount(props) {
render(props)
}
function render(props = {}) {
const { container, store } = props
instance = new Vue({
childStore,
data() {
return {
parentVuex: store,
}
},
render: h => h(App)
}).$mount(container ? container.querySelector('#you-micro') : '#you-micro')
}
this.$root.parentVuex.state.xxxx
this.$root.parentVuex.commit('xxxx', {})
十七、微应用内存溢出思考
image.png
- qiankun 会将微应用的 JS/CSS 内容都记录在全局变量中,如果一直重复的挂载应用没有卸载,会导致内存占用过多,导致页面卡顿
- 虽然官方没有明确说名内存的溢出问题,但是笔者在开发的过程中,在重复加载应用的时候崩溃过几次,出于安全性思考还是使用一些手段来约束变量的开销吧~
- 微应用卸载的时候清空微应用注册的附加内容及 DOM 元素等
- 设置自动销毁时间,去销毁那些长时间挂载的应用,
- 设置最大运行应用数量,超过规定的数量的时候吧第一个应用销毁
1. 卸载时清空无用实例
export async function unmount() {
instance.$destroy()
instance.$el.innerHTML = ''
instance = null
route = null
}
2. 设置过期时间与最大运行数,这里的的内容可以结合上面的内容来看,上文有对应的说明
// src/const/micro/index.js
export const MAX_RUN_MICRO_NUMBER = 5 // 最大运行微应用数量
// 主应用/src/const/micro/application-list.js
export default [{
name: 'you-app-name', // 应用的名字
// ... micro app config
// 自动销毁时间 单位:3000(ms) or (Infinity = 永久不会销毁)
unmountTime: '300000'
}]
注册的时候记录时间
export function loadRouterMicroApp(currentApp) {
const micro = loadMicroApp(currentApp)
micro.mountPromise.then(() => {
micro.createTime = new Date().getTime()
micro.unmountTime = currentApp.unmountTime || 'Infinity'
store.dispatch('d2admin/micro/SET_MICRO_APPLICATION_LIST', {
key: currentApp.activeRule,
value: micro
})
})
}
路由守卫的时候判断是否需要卸载
router.afterEach(to => {
microApplicationLoading(to.path)
})
// 主应用/src/const/micro/qianun-utils.js
// 加载微应用方法
export async function microApplicationLoading(path) {
// 1. 根据路由地址加载当前应用配置
let currentActiveMicroConfig = await store.dispatch('d2admin/micro/GET_FIND_MICRO_CONFIG', path)
// 2. 获取微应用列表
const microApplicationList = store.getters['d2admin/micro/microApplicationList']
// 3. 判断应用运行时间销毁应用
store.dispatch('d2admin/micro/CHECK_UNMOUNT_MICRO', { microApplicationList, currentActiveMicroConfig })
// ... code 后面注册判断操作就省略了
}
判断是否最大堆栈、判断是否超时销毁
export default {
state: {
microApplicationList: new Map([]),
},
actions: {
CHECK_UNMOUNT_MICRO({ state, dispatch }, { microApplicationList, currentActiveMicroConfig }) {
if (!microApplicationList.size) {
return
}
const currentTime = new Date().getTime()
Array.from(microApplicationList).forEach(([key, item]) => {
const runningTime = currentTime - item.createTime
const unmountTime = item.unmountTime
if (currentActiveMicroConfig) {
item.createTime = new Date().getTime()
dispatch('SET_MICRO_APPLICATION_LIST', {
key: item.activeRule,
value: item
})
return
}
if (runningTime >= unmountTime && unmountTime !== 'Infinity') {
dispatch('DELETE_MICRO_APPLICATION_LIST', key)
}
})
},
DELETE_MICRO_APPLICATION_LIST({ state }, key) {
const micro = state.microApplicationList.get(key)
micro && micro.unmount()
state.microApplicationList.delete(key)
},
SET_MICRO_APPLICATION_LIST({ state, dispatch }, { key, value }) {
dispatch('CLEAR_MICRO_STACK')
state.microApplicationList.set(key, value)
},
CLEAR_MICRO_STACK({ state, dispatch }) {
if (MAX_RUN_MICRO_NUMBER === 'Infinity') {
return
}
if (state.microApplicationList.size < MAX_RUN_MICRO_NUMBER) {
return
}
const key = state.microApplicationList.keys().next().value
dispatch('DELETE_MICRO_APPLICATION_LIST', key)
}
}
}
十八、同一路由多应用共存
- 如果一个页面同时展示多个微应用,需要使用
loadMicroApp
来加载。
- 如果这些微应用都有路由跳转的需求,要保证这些路由能互不干扰,需要使用
momery
路由。
vue-router
使用 abstract
模式,react-router
使用 memory history
模式,angular-router
不支持。
- Vue Router 的导航方法 (
push
、 replace
、 go
) 在各类路由模式(history
、 hash
和 abstract
) 下表现一致。
abstract
是vue路由中的第三种模式,本身是用来在不支持浏览器API的环境中,充当fallback,无论 hash还是history模式都会对浏览器上的url产生作用,于是我们利用到了abstract这种与浏览器分离的路由模式解决多应用路由冲突的问题。
function render({ data = {} , container, defaultPath } = {}) {
router = new VueRouter({
mode: 'abstract', // 不会被URL所影响
routes
})
instance = new Vue({
router,
store,
render: h => h(App)
}).$mount(container ? container.querySelector('#appVueHash') : '#appVueHash')
if (defaultPath) {
router.push(defaultPath)
}
}
十九、微应用开发与部署
开发与部署目录建议
建议在开发与部署的时候,所有的微应用都放在一个目录,虽然qiankun的应用只需提供微应用URL地址即可,从理论上来说项目放在那里都是没有影响的。但是出于管理维护的目的,我们还是推荐:
- 相关应用都在同一个目录下,统一管理
- 所有微应用都是独立项目、独立仓库、独立部署
└── micro-app-container
├── main/
├── child/
| ├── vue-hash/
| ├── vue-history/
├── package.json
├── node_modules/
使用npm-run-all 简化script配置
根据上面的结构一个一个 启动or打包应用太麻烦了,使用npm-run-all 命令 解决npm run 命令无法同时运行多个脚本的问题
npm-run-all的三个特点:
- 顺序执行 、并行执行、混合执行
--parallel
: 并行运行多个命令,例如:npm-run-all –parallel lint build
--serial
: 多个命令按排列顺序执行,例如:npm-run-all –serial clean lint build:
--continue-on-error
: 是否忽略错误,添加此参数 npm-run-all 会自动退出出错的命令,继续运行正常的
--race
: 添加此参数之后,只要有一个命令运行出错,那么 npm-run-all 就会结束掉全部的命令
安装依赖
npm install npm-run-all --save-dev
yarn add npm-run-all --dev
配置命令 package.json, 一键给所有应用安装依赖
"scripts": {
"install:child-hash": "cd child/child-hash && yarn",
"install:child-history": "cd child/child-history && yarn",
"install:main": "cd main && yarn",
"install-all": "npm-run-all install:*",
"start:child-hash": "cd child/child-hash && npm run serve",
"start:child-history": "cd child/child-history && npm run serve",
"start:main": "cd main && npm run serve",
"serve-all": "npm-run-all --parallel start:*",
"build:child-hash": "cd child/child-hash && npm run build",
"build:child-history": "cd child/child-history && npm run build",
"build:main": "cd main && npm run build",
"build-all": "npm-run-all --parallel build:*"
}
或者配合脚本可以自己写一写简单的 shell 脚本
git clone http:/xxxxxxx.git
git clone http://xxxxxxxx.git
package.json 中增加命令执行
"clone:all": "bash ./scripts/clone-all.sh"
部署的时候也和开发的时候一样,不过可以直接放在基座应用里面使用
└── html/
|
├── child/
| ├── vue-hash/
| ├── vue-history/
├── index.html
├── css/
├── js/
此时需要设置微应用构建时的 publicPath
和 history
模式的路由 base
,然后才能打包放到对应的目录里。构建的时候切记要修改 webpack 中的 publicPath 地址!!!
vue-history 微应用
base: window.__POWERED_BY_QIANKUN__ ? '/app-vue-history/' : '/child/vue-history/',
module.exports = {
publicPath: '/child/vue-history/',
};
同时主应用的配置文件 entry 入口也要和当前环境一样需要动态更改
微应用 webpack 打包 publicPath 配置(vue.config.js
):
微应用路由设置:
但是笔者觉得这样写真的不优雅,笔者希望有关于微前端的所有配置都在一个微前端配置页里面维护,并且清晰可见。
所以笔者的做法是方法判断环境传入一个对象,减少三元的丑陋与混乱,把路由前缀动态的下发给微应用,但微应用注册的时候,在动态把前缀加上
function getEentry({ prodPath, devPath }) {
const isProduction = process.env.NODE_ENV === 'production'
return isProduction ? prodPath : devPath
}
export default [
{
name: 'your-name',
entry: getEentry({
devPath: '//localhost:7286/',
prodPath: `/child/your-name/`
}),
props: {
routeBase: '/app-vue-history/',
}
}
]
总结
- 感谢各位能看到这里,这是笔者在微前端实践的一些心得,碍于篇幅原因,很多技术细节我们就不再文中赘述了,如果有希望了解更多 qiankun 原理,或者更多实践细节的小伙伴,可以在文章底部留言,在此很感谢能给予我实践机会的彬哥与标哥,希望本文能在您微前端的探索之路为您照亮前方,感谢支持,本文如果有笔误的的地方,欢迎提出,定会及时修复与改进,愿君代码路上一路畅通无阻~