项目 github地址:github.com/DevWizardFe…
项目亮点
- 微前端 概念 及应用
- systemJS源码
- single-spa实战及源码
- qiankun实战及源码(沙箱原理)(蚂蚁金服)
- MicroApp、WebComponent实战及源码(京东零售)
- wujie实战使用(腾讯)
- webpack5 Module Federation Emp2实战
一、为什么需要微前端
:::warning
微前端就是将不同的功能按照不同的维度拆分成多个子应用。通过主应用来加载这些子应用。
:::
对比(丰贺): DeepTable DeepUx DeepModel DeepFlow DeepBI
微前端的核心在于拆, 拆完后在合,实现分而治之!
1.微前端解决的问题
- 不同团队(技术栈不同),同时开发一个应用
- 每个团队开发的模块都可以独立开发,独立部署
- 实现增量迁移
2.如何实现微前端
我们可以将一个应用划分成若干个子应用,将子应用打包成一个个的模块。当路径切换时加载不同的子应用。这样每个子应用都是独立的,技术栈也不用做限制了!
3.实现微前端的技术方案
- 采用何种方案进行应用拆分?
- 采用何种方式进行应用通信?
- 应用之间如何进行隔离?
** 1)iframe **
- 微前端的最简单方案,通过iframe加载子应用。
- 通信可以通过postMessage进行通信。
- 完美的沙箱机制自带应用隔离。
缺点:用户体验差 (弹框只能在iframe中、在内部切换刷新就会丢失状态)
** 2)Web Components **
- ** **将前端应用程序分解为自定义 HTML 元素。
- 基于CustomEvent实现通信 。
- Shadow DOM天生的作用域隔离 。
缺点:浏览器支持问题、学习成本、调试困难、修改样式困难等问题。
** 3)single-spa **
- single-spa 通过路由劫持实现应用的加载(采用SystemJS),提供应用间公共组件加载及公共业务逻辑处理。
- 子应用需要暴露固定的钩子bootstrap、mount、 unmount接入协议。
- 基于props主子应用间通信 无沙箱机制,需要实现自己实现JS沙箱以及CSS沙箱
缺点:学习成本、无沙箱机制、需要对原有的应用进行改造、子应用间相同资源重复加载问题。
** 4)Module federation **
- 通过模块联邦将组件进行打包导出使用
- 共享模块的方式进行通信
- 无CSS沙箱和JS沙箱
缺点:需要webpack5。
4.Why Not Iframe 为什么不是 iframe
为什么不用 iframe,这几乎是所有微前端方案第一个会被 challenge 的问题。但是大部分微前端方案又不约而同放弃了 iframe 方案,自然是有原因的,并不是为了 "炫技" 或者刻意追求 "特立独行"。
如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。
iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。
1url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
2UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
3全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
4慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。
二.SystemJS剖析
SystemJS 是一个通用的模块加载器,它能在浏览器上动态加载模块。微前端的核心就是 加载微应 用,我们将应用打包成模块,在浏览器中通过 SystemJS 来加载模块。
// 直接加载子应用, 导入打包后的包 来进行加载, 采用的规范 system规范
// 这个地方是自己实现systemjs
// 1) systemjs 是如何定义的 先看打包后的结果 System.register(依赖列表,后调函数返回值一个setters,execute)
// 2) react , react-dom 加载后调用setters 将对应的结果赋予给webpack
// 3) 调用执行逻辑 执行页面渲染
// 模块规范 用来加载system模块的
const newMapUrl = {};
// 解析 importsMap
function processScripts() {
Array.from(document.querySelectorAll('script')).forEach(script => {
if (script.type === "systemjs-importmap") {
const imports = JSON.parse(script.innerHTML).imports; // 解析JSON对象
Object.entries(imports).forEach(([key, value]) => newMapUrl[key] = value)
}
})
}
// 加载资源
function load(id) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = newMapUrl[id] || id; // 支持cdn的查找
script.async = true; //异步加载
document.head.appendChild(script);
// 此时会执行代码
script.addEventListener('load', function () {
// 拿到注册表的内容 并且 清空注册表 指针转移
let _lastRegister = lastRegister;
lastRegister = undefined
resolve(_lastRegister);
})
})
}
let set = new Set(); // 1)先保存window上的属性
function saveGlobalProperty() {
for (let k in window) {
set.add(k);
}
}
saveGlobalProperty();
function getLastGlobalProperty() { // 看下window上新增的属性
for (let k in window) {
if (set.has(k)) continue;
set.add(k);
return window[k]; // 我通过script新增的变量
}
}
let lastRegister;
// 模块规范 用来加载System模块的
class SystemJs {
import(id) { // 这个id原则上可以是一个第三方路径cdn
return Promise.resolve(processScripts()).then(() => {
// 1)去当前路径查找 对应的资源 index.js 完整路径
const lastSepIndex = location.href.lastIndexOf('/');
const baseURL = location.href.slice(0, lastSepIndex + 1);
if (id.startsWith('./')) {
return baseURL + id.slice(2);
}
// http https
}).then((id) => {
// 根据文件的路径 来加载资源
let execute
return load(id).then((register) => {
let { setters, execute:exe } = register[1](() => { })
execute = exe
// execute 是真正执行的渲染逻辑
// setters 是用来保存加载后的资源,加载资源调用setters
// console.log(setters,execute)
return [register[0], setters]
}).then(([registeration, setters]) => {
return Promise.all(registeration.map((dep, i) => {
return load(dep).then(() => {
const property = getLastGlobalProperty()
// 加载完毕后,会在window上增添属性 window.React window.ReactDOM
setters[i](property)
})
// 拿到的是函数,加载资源 将加载后的模块传递给这个setter
}))
}).then(() => {
execute();
})
})
}
register(deps, declare) {
// 将回调的结果保存起来
lastRegister = [deps, declare]
}
}
const System = new SystemJs()
System.import('./index.js').then(() => {
console.log('模块加载完毕')
})
// 本质就是先加载依赖列表 再去加载真正的逻辑
// (内部通过script脚本加载资源 , 给window拍照保存先后状态) 快照
// JSONP
// single-spa 如何借助了 这个system 来实现了模块的加载
三.single-spa实战
1.安装脚手架
通过single-spa-cli创建基座 应用
npm install create-single-spa -g
create-single-spa substrate
创建子项目
single-spa application / parcel
用于跨应用共享JavaScript逻辑的微应用
in-browser utility module (styleguide, api cache, etc)
创建基座容器
single-spa root config
生成基座项目,用于加载子应用
1)主应用的root-config文件
import { registerApplication, start } from "single-spa";
// 注册应用
registerApplication({
name: "@single-spa/welcome",
app: () =>
System.import( // 远程加载模块
"https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js"
),
activeWhen: (location)=>location.pathname === '/' ,
});
registerApplication({
name: "@jw/react", // 不重名即可
app: () =>
System.import('@jw/react'),
activeWhen: (location)=>location.pathname.startsWith('/react') ,
});
registerApplication({
name: "@jw/vue", // 不重名即可
app: () =>
System.import('@jw/vue'),
activeWhen: (location)=>location.pathname.startsWith('/vue') ,
});
// registerApplication({
// name: "@jw/navbar",
// app: () => System.import("@jw/navbar"),
// activeWhen: ["/"]
// });
start({
urlRerouteOnly: true,
});
// 根应用
// 父应用的加载过程 9000 -> index.ejs -> @jw/root-config -> jw-root-config
// 匹配路径加载应用
// 写实现原理 我们给你加载一下
// 动态加载方式
2)主应用的ejs文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Root Config</title>
<script src="https://cdn.jsdelivr.net/npm/regenerator-runtime@0.13.7/runtime.min.js"></script>
<script type="systemjs-importmap">
{
"imports": {
"single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js"
}
}
</script>
<link rel="preload" href="https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js" as="script">
<!-- Add your organization's prod import map URL to this script's src -->
<!-- <script type="systemjs-importmap" src="/importmap.json"></script> -->
<% if (isLocal) { %>
<script type="systemjs-importmap">
{
"imports": {
"@jw/root-config": "//localhost:9000/jw-root-config.js",
"@jw/react":"//localhost:3000/jw-react.js",
"@jw/vue":"//localhost:4000/js/app.js"
}
}
</script>
<% } %>
<script src="https://cdn.jsdelivr.net/npm/import-map-overrides@2.2.0/dist/import-map-overrides.js"></script>
<% if (isLocal) { %>
<script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/system.js"></script>
<script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/extras/amd.js"></script>
<% } else { %>
<script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/system.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/extras/amd.min.js"></script>
<% } %>
</head>
<body>
<main>
<a onClick="go('/')">去welcome</a>
<a onClick="go('/react')">去react</a>
<a onClick="go('/vue')">去vue</a>
</main>
<script>
function go(url){
history.pushState({},null,url)
}
System.import('@jw/root-config');
</script>
<div id="vue"></div>
<div id="react"></div>
</body>
</html>
3)主应用 加载流程
根应用
父应用的加载过程 9000 -> index.ejs -> @jw/root-config -> jw-root-config
匹配路径加载应用
2.生成react子应用
create-single-spa react-project
1)配置路由
npm install react-router-dom
import { BrowserRouter as Router, Route, Link, Routes } from 'react-router-dom'
import Home from './components/Home.js'
import About from './components/About.js'
export default function Root(props) {
return <Router basename="/react">
<div>
<Link to="/">Home React </Link>
<Link to="/about">About React</Link>
</div>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</Router>
}
2) webpack.config.js配置
delete defaultConfig.externals; // 关闭 externals
return merge(defaultConfig,{
devServer:{
port: 3000 //修改端口
}
});
3) 注册子应用
在index.ejs中
<script type="systemjs-importmap">
{
"imports": {
"@jw/root-config": "//localhost:9000/jw-root-config.js",
"@jw/react":"//localhost:3001/jw-react.js"
}
}
</script>
在jw-root-config.js中
registerApplication({
name: "@jw/react",
app: () => System.import('@jw/react'),
activeWhen: (location)=> location.pathname.startsWith('/react'),
});
3.生成 vue子应用
create-single-spa vue-project
1)vue.config.js配置 :
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
publicPath: 'http://localhost:3002',
devServer: {
port: 3002
},
chainWebpack: (config) => {
// 优化构建 减少冲突
if (config.plugins.has("SystemJSPublicPathWebpackPlugin")) {
config.plugins.delete("SystemJSPublicPathWebpackPlugin");
}
}
})
2)注册子应用
<script type="systemjs-importmap">
{
"imports": {
"@jw/root-config": "//localhost:9000/jw-root-config.js",
"@jw/react":"//localhost:3001/jw-react.js",
"@jw/vue":"//localhost:3002/js/app.js"
}
}
</script>
registerApplication({
name: "@jw/vue",
app: () => System.import("@jw/vue"),
activeWhen: location => location.pathname.startsWith('/vue'),
});
在substrate/index.ejs中 添加路由跳转
<main>
<a onClick="go('/')">去welcome</a>
<a onClick="go('/react')">去react</a>
<a onClick="go('/vue')">去vue</a>
</main>
<script>
function go(url){
history.pushState({},null,url);
}
System.import('@jw/root-config');
</script>
然后启动 root-config和jw/react jw/vue三个项目,就可以完整看到效果了。
点击不同的 路由 跳转不同应用
四、single-spa源码解析
1.回顾single-spa的核心功能
创建index.html页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!-- <a href="#/a">a应用</a>
<a href="#/b">b应用</a> -->
<!-- <script src="https://cdn.bootcdn.net/ajax/libs/single-spa/5.9.3/umd/single-spa.min.js"></script> -->
<script type="module">
// 微前端 就是可以加载不同的应用 基于路由的微前端
// 如何接入已经写好的应用 对于single-spa而言,我们需要改写子应用 (接入协议) bootstrap, mount, unmount
// /a /b
import { registerApplication, start } from './single-spa/single-spa.js'
// let { registerApplication, start } = singleSpa
let app1 = {
bootstrap: [
async () => console.log('app1 bootstrap1'),
async () => console.log('app1 bootstrap2')
],
mount: [
async (props) => {
// new Vue().$mount()...
console.log('app1 mount1', props)
},
async () => {
// new Vue().$mount()...
console.log('app1 mount2')
}
],
unmount: async (props) => {
console.log('app1 unmount')
}
}
let app2 = {
bootstrap: async () => console.log('app2 bootstrap1'),
mount: [
async () => {
// new Vue().$mount()...
return new Promise((resolve,reejct)=>{
setTimeout(()=>{
console.log('app2 mount')
resolve()
},1000)
})
}
],
unmount: async () => {
console.log('app2 unmount')
}
}
// 当路径是#/a 的时候就加载 a应用
// 所谓的注册应用 就是看一下路径是否匹配,如果匹配则“加载”对应的应用
registerApplication('a', async () => app1, location => location.hash.startsWith('#/a'), { a: 1 })
registerApplication('b', async () => app2, location => location.hash.startsWith('#/b'), { a: 1 })
// 开启路径的监控,路径切换的时候 可以调用对应的mount unmount
start()
// 这个监控操作 应该被延迟到 当应用挂挂载完毕后再行
window.addEventListener('hashchange', function () {
console.log(window.location.hash, 'p----')
})
// window.addEventListener('popstate',function(){
// console.log(window.location.hash,'p----')
// })
</script>
<a onclick="go('#/a')">a应用</a>
<a onclick="go('#/b')">b应用</a>
<script>
function go(url) { // 用户调用pushState replaceState 此方法不会触发逻辑reroute
history.pushState({}, null, url)
}
</script>
</body>
</html>
由此可见,single-spa的核心功能就是
- 注册应用 registerApplication
- 启动应用 start
- 以及应用的生命周期也可以称作为接入协议
- bootstrap, mount, and unmount的实现是必须的,unload则是可选的
- 生命周期函数是 single-spa 在注册的应用上调用的一系列函数,single-spa 会在各应用的主文件中,查找对应的函数名并进行调用。
2.实现核心方法 registerApplication & start
index.html
用自己实现的single-spa方法替代 CDN
<script src="cdn.bootcdn.net/ajax/libs/s…
import { registerApplication, start } from './single-spa/single-spa.js'
single-spa文件夹下 目录树
single-spa
│
├── application // 主应用
│ ├── app.helpers.js // 定义应用状态和辅助函数,如判断应用是否激活、应加载或卸载等。
│ └── app.js // 处理应用的注册逻辑,包括保存注册的应用和执行重路由。
│
├── lifecycles //生命周期
│ ├── bootstrap.js // 处理应用的启动逻辑,将应用状态从未启动转变为未挂载。
│ ├── load.js // 处理应用的加载逻辑,将应用状态从未加载转变为未启动。
│ ├── mount.js // 处理应用的挂载逻辑,将应用状态从未挂载转变为已挂载。
│ └── unmount.js // 处理应用的卸载逻辑,将应用状态从已挂载转变为未挂载。
│
├── navigation // 路由导航系统
│ ├── navigation-event.js // 劫持和处理路由事件,确保应用根据URL变化正确响应。
│ └── reroute.js // 核心路由重定向逻辑,决定何时加载、启动、挂载或卸载应用。
│
├── single-spa.js // 框架的入口文件,导出 `registerApplication` 和 `start` 方法。
└── start.js // 定义 `start` 方法,启动 `single-spa` 应用,允许应用挂载。
export { registerApplication } from "./application/app.js"; // 根据路径加载应用
export { start } from "./start.js"; // 开启应用 挂载组件
主应用 application下:
app.js 的主要作用
- 应用注册
- app.js 提供了 registerApplication 函数,用于注册微前端应用。这是微前端架构中的一个关键步骤,因为它决定了如何和何时加载各个独立的应用。
- 在注册应用时,需要提供应用的名称、加载函数、激活条件(例如特定的路由路径),以及自定义属性。
- 状态管理
- 通过 apps 数组来管理所有注册的应用。每个应用都有其对应的状态,如未加载、加载中、已加载等。
- 状态的变更通常会触发相应的生命周期事件(例如加载、挂载、卸载)。
- 重路由逻辑
- 在应用注册后,app.js 通过调用 reroute 函数来处理可能的路由变化。这是确保应用根据当前URL正确加载和展示的关键。
app.js 的核心流程
- 应用注册
- 当调用 registerApplication 函数时,会创建一个包含应用信息的对象,并将其添加到 apps 数组中。这个对象包含应用的名称、加载函数、激活条件、自定义属性和当前状态。
- 初始化应用状态
- 初始状态设置为 NOT_LOADED,表示应用尚未加载。
- 触发重路由
- 注册应用后,调用 reroute 函数。reroute 负责检查当前URL,确定哪些应用需要被加载、激活或卸载。
- 应用加载与激活
- 基于当前URL和应用的激活条件,reroute 决定哪些应用应该被加载。加载过程由应用的加载函数控制,这通常涉及到下载和执行代码。
- 一旦应用被加载,它将根据其生命周期进入下一阶段,比如启动和挂载。
import { reroute } from "../navigation/reroute.js";
import { NOT_LOADED } from "./app.helpers.js"
export const apps = []
export function registerApplication(appName,loadApp,activeWhen,customProps){
const registeration = {
name:appName,
loadApp,
activeWhen,
customProps,
status:NOT_LOADED
}
apps.push(registeration)
// 我们需要给每个应用添加对应的状态变化
// 未加载 -》 加载 -》挂载 -》 卸载
// 需要检查哪些应用要被加载,还有哪些应用要被挂载,还有哪些应用要被移除
reroute(); // 重写路由
}
app.helpers.js 主要作用
应用状态定义
- 状态常量:定义了一系列描述应用生命周期各阶段的常量,如 NOT_LOADED(未加载),LOADING_SOURCE_CODE(加载中),NOT_BOOTSTRAPED(未启动),NOT_MOUNTED(未挂载),MOUNTED(已挂载)等。这些状态对应于应用从被注册到被加载、启动、挂载和最终卸载的整个过程。
状态判断函数
- isActive:判断一个应用是否处于已挂载状态(即当前正被用户使用)。
- shouldBeActive:根据应用的激活规则(通常是基于URL的规则)判断一个应用是否应该被激活(即是否应该加载和挂载)。
应用变更辅助函数
- getAppChanges:这个函数是 single-spa 中非常关键的部分,它负责计算出基于当前URL应该被加载、挂载或卸载的应用集合。具体而言,它会遍历所有已注册的应用,并根据它们的当前状态和激活规则,将它们归类为待加载(appsToLoad)、待挂载(appsToMount)和待卸载(appsToUnmount)。
import { apps } from "./app.js";
// app status
export const NOT_LOADED = 'NOT_LOADED'; // 没有被加载
export const LOADING_SOURCE_CODE = 'LOADING_SOURCE_CODE'; // 路径匹配了 要去加载这个资源
export const LOAD_ERROR = 'LOAD_ERROR'
// 启动的过程
export const NOT_BOOTSTRAPED = 'NOT_BOOTSTRAPED'; // 资源加载完毕了 需要启动,此时还没有启动
export const BOOTSTRAPING = 'BOOTSTRAPING'; // 启动中
export const NOT_MOUNTED = 'NOT_MOUNTED'; // 没有被挂载
// 挂载流程
export const MOUNTING = 'MOUNTING'; // 正在挂载
export const MOUNTED = 'MOUNTED'; // 挂载完成
// 卸载流程
export const UNMOUNTING = 'UNMOUNTING'; // 卸载中
// 加载正在下载应用 LOADING_SOURCE_CODE,激活已经运行了
// 看一下这个应用是否正在被激活
export function isActive(app){
return app.status === MOUNTED; // 此应用正在被激活
}
// 看一下此应用是否被激活
export function shouldBeActive(app){
return app.activeWhen(window.location)
}
export function getAppChanges(){
const appsToLoad = []
const appsToMount = []
const appsToUnmount = []
apps.forEach((app)=>{
let appShouldBeActive = shouldBeActive(app)
switch(app.status){
case NOT_LOADED:
case LOADING_SOURCE_CODE:
// 1) 标记当前路径下 哪些应用要被加载
if(appShouldBeActive){
appsToLoad.push(app)
}
break;
case NOT_BOOTSTRAPED:
case BOOTSTRAPING:
case NOT_MOUNTED:
// 2) 当前路径下 哪些应用要被挂在
if(appShouldBeActive){
appsToMount.push(app)
}
break;
case MOUNTED:
// 3) 当前路径下 哪些应用要被卸载
if(!appShouldBeActive){
appsToUnmount.push(app)
}
break
default:
break;
}
})
return {appsToLoad,appsToMount,appsToUnmount}
}
生命周期lifecycles下:
应用的生命周期管理(bootstrap、load、mount、unmount)
bootstrap.js
这个文件处理应用的启动流程。
- toBootstrapPromise 函数:
- 检查应用是否处于未启动状态(NOT_BOOTSTRAPED),如果是,则将状态改变为启动中(BOOTSTRAPING)。
- 然后执行应用的 bootstrap 方法(通常用于初始化操作),并在完成后将状态改变为未挂载(NOT_MOUNTED)。
import { BOOTSTRAPING, NOT_BOOTSTRAPED, NOT_MOUNTED } from "../application/app.helpers.js";
export function toBootstrapPromise(app){
return Promise.resolve().then(()=>{
if(app.status !== NOT_BOOTSTRAPED){
// 此应用加载完毕了
return app;
}
app.status = BOOTSTRAPING
return app.bootstrap(app.customProps).then(()=>{
app.status = NOT_MOUNTED;
return app
})
})
}
load.js
这个文件处理应用的加载流程。
toLoadPromise 函数:
- 检查应用是否处于未加载状态(NOT_LOADED),如果是,则将状态改变为加载中(LOADING_SOURCE_CODE)。
- 然后加载应用(loadApp 方法),通常包括下载和执行代码。
- 加载完成后,将状态改为未启动(NOT_BOOTSTRAPED),并准备应用的启动、挂载和卸载方法。
import { LOADING_SOURCE_CODE, NOT_BOOTSTRAPED, NOT_LOADED } from "../application/app.helpers.js"
function flattenArrayToPromise(fns) {
fns = Array.isArray(fns) ? fns : [fns]
return function(props){ // redux
return fns.reduce((rPromise,fn)=>rPromise.then(()=>fn(props)), Promise.resolve())
}
}
export function toLoadPromise(app){
return Promise.resolve().then(()=>{
if(app.status !== NOT_LOADED){
// 此应用加载完毕了
return app;
}
app.status = LOADING_SOURCE_CODE; // 正在加载应用
// loadApp 对于之前的内容 System.import()
return app.loadApp(app.customProps).then(v=>{
const {bootstrap,mount,unmount} = v;
app.status = NOT_BOOTSTRAPED;
app.bootstrap = flattenArrayToPromise(bootstrap);
app.mount = flattenArrayToPromise(mount);
app.unmount = flattenArrayToPromise(unmount);
return app
})
})
}
mount.js
这个文件处理应用的挂载流程。
oMountPromise 函数:
- 检查应用是否处于未挂载状态(NOT_MOUNTED),如果是,则执行挂载操作(mount 方法)。
- 挂载完成后,将应用状态改为已挂载(MOUNTED)。
import { MOUNTED, NOT_MOUNTED } from "../application/app.helpers.js";
export function toMountPromise(app){
return Promise.resolve().then(()=>{
if(app.status !== NOT_MOUNTED){
return app;
}
return app.mount(app.customProps).then(()=>{
app.status = MOUNTED;
return app
})
})
}
unmount.js
这个文件处理应用的卸载流程。
toUnmountPromise 函数:
- 检查应用是否处于已挂载状态(MOUNTED),如果是,则执行卸载操作(unmount 方法)。
- 卸载完成后,将应用状态改为未挂载(NOT_MOUNTED)。
import { MOUNTED, NOT_MOUNTED, UNMOUNTING } from "../application/app.helpers.js"
export function toUnmountPromise(app){
return Promise.resolve().then(()=>{
if(app.status !== MOUNTED){
return app;
}
app.status = UNMOUNTING;
// app.unmount 方法用户可能写的是一个数组。。。。。
return app.unmount(app.customProps).then(()=>{
app.status = NOT_MOUNTED;
})
})
}
导航系统 navigation下:
navigation-event.js主要作用
navigation-event.js 文件在 single-spa 微前端框架中扮演着重要的角色,主要负责劫持和管理浏览器的路由事件,以确保在URL变化时,应用能够按需加载、激活或卸载。下面是对其核心流程的概括和解释:
劫持路由事件
- 监听路由变化:通过监听 hashchange 和 popstate 事件,navigation-event.js 能够捕捉到浏览器地址栏的变化。这些变化通常表示用户正在进行导航(比如点击后退按钮或更改URL)。
- 重写事件监听器:该文件重写了 window.addEventListener 和 window.removeEventListener 方法。这样做是为了能够控制这些事件的监听器,即确保 single-spa 能够在必要时触发重路由逻辑,而不是仅依赖于浏览器默认行为。
管理自定义事件监听器
- capturedEventListeners:用于存储被框架捕获的事件监听器。当路由事件(如 hashchange 或 popstate)触发时,这些监听器将被调用。
- 调整事件监听器的行为:当应用尝试添加或移除路由事件监听器时,navigation-event.js 会根据其内部逻辑调整这些监听器的行为,以确保框架能够正确地处理路由变化。
触发重路由逻辑
- callCaptureEventListeners:当路由发生变化时,这个函数负责调用所有被捕获的监听器,并最终触发 reroute 方法,启动应用加载或卸载的流程。
拦截历史API的调用
- patchFn:对 window.history.pushState 和 window.history.replaceState 进行了补丁,以便在这些方法被调用时(即使是程序性地修改URL),也能触发 reroute 方法。
// 对用户的路径切换 进行劫持,劫持后,重新调用reroute方法,进行计算应用的加载
import { reroute } from "./reroute.js";
function urlRoute() {
reroute(arguments)
}
window.addEventListener('hashchange', urlRoute)
window.addEventListener('popstate', urlRoute); // 浏览器历史切换的时候会执行此方法
// 但是当路由切换的时候 我们触发single-spa的addEventLister, 应用中可能也包含addEventLister
// 需要劫持原生的路由系统,保证当我们加载完后再切换路由
const capturedEventListeners = {
hashchange: [],
popstate: []
}
const listentingTo = ['hashchange', 'popstate']
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
window.addEventListener = function (eventName, callback) {
// 有要监听的事件, 函数不能重复
if (listentingTo.includes(eventName) && !capturedEventListeners[eventName].some(listener => listener === callback)) {
return capturedEventListeners[eventName].push(callback)
}
return originalAddEventListener.apply(this, arguments)
}
window.removeEventListener = function (eventName, callback) {
// 有要监听的事件, 函数不能重复
if (listentingTo.includes(eventName)) {
capturedEventListeners[eventName] = capturedEventListeners[eventName].filter(fn => fn !== callback)
return
}
return originalRemoveEventListener.apply(this, arguments)
}
export function callCaptureEventListeners(e) {
if (e) {
const eventType = e[0].type;
if (listentingTo.includes(eventType)) {
capturedEventListeners[eventType].forEach(listener => {
listener.apply(this, e)
});
}
}
}
function patchFn(updateState, methodName) {
return function () {
const urlBefore = window.location.href;
const r = updateState.apply(this, arguments); // 调用此方法 确实发生了路径的变化
const urlAfter = window.location.href;
if (urlBefore !== urlAfter) {
// 手动派发popstate事件
window.dispatchEvent(new PopStateEvent("popstate"))
}
return r;
}
}
window.history.pushState = patchFn(window.history.pushState, 'pushState')
window.history.replaceState = patchFn(window.history.replaceState, 'replaceState')
reroute.js主要作用
确定应用状态变更
- 计算应用变更:reroute 使用 getAppChanges 函数来确定哪些应用需要被加载、挂载或卸载。这是基于每个应用的当前状态(如未加载、未挂载等)和激活条件(通常是基于URL的规则)。
管理应用加载和挂载
- 加载应用:如果确定某些应用需要被加载(它们处于 NOT_LOADED 或 LOADING_SOURCE_CODE 状态),reroute 会调用相应的加载逻辑(通常是 toLoadPromise)。
- 挂载应用:对于已加载但未挂载的应用,reroute 会触发它们的挂载流程(通过 toMountPromise)。
处理应用卸载
- 卸载应用:如果当前路由状态不再需要某些已挂载的应用,reroute 会调用卸载逻辑(toUnmountPromise)以释放资源和清理。
处理并发路由变更
- 异步队列管理:reroute 还处理并发的路由变更请求。如果在一个应用变更过程中,另一个路由变更被触发,它会将这个新请求放入一个队列中,以便按顺序处理。
触发应用更新
- 应用更新:在路由变更后,reroute 确保每个应用都处于正确的状态,并且已挂载的应用能够响应最新的路由状态。
import { getAppChanges, shouldBeActive } from "../application/app.helpers.js";
import { toBootstrapPromise } from "../lifecycles/bootstrap.js";
import { toLoadPromise } from "../lifecycles/load.js";
import { toMountPromise } from "../lifecycles/mount.js";
import { toUnmountPromise } from "../lifecycles/unmount.js";
import { started } from "../start.js";
import './naviation-event.js'
import { callCaptureEventListeners } from "./naviation-event.js";
// 后续路径变化 也需要走这里, 重新计算哪些应用被加载或者写在
let appChangeUnderWay = false;
let peopleWaitingOnAppChange = []
export function reroute(event) {
// 如果多次触发reroute 方法我们可以创造一个队列来屏蔽这个问题
if(appChangeUnderWay){
return new Promise((resolve,reject)=>{
peopleWaitingOnAppChange.push({
resolve,reject
})
})
}
// 获取app对应的状态 进行分类
const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges()
// 加载完毕后 需要去挂载的应用
if(started){
appChangeUnderWay = true
// 用户调用了start方法 我们需要处理当前应用要挂载或者卸载
return performAppChange();
}
// 先拿到应用去加载 -》
return loadApps();
function loadApps() {
// 应用的加载
return Promise.all(appsToLoad.map(toLoadPromise)).then(callEventListener)// 目前我们没有调用start
}
function performAppChange(){
// 将不需要的应用卸载掉, 返回一个卸载的promise
// 1) 稍后测试销毁逻辑
const unmountAllPromises = Promise.all(appsToUnmount.map(toUnmountPromise))
// 流程加载需要的应用 -》 启动对应的应用 -》 卸载之前的 -》 挂载对应的应用
// 2) 加载需要的应用(可能这个应用在注册的时候已经被加载了)
// 默认情况注册的时候 路径是 /a , 但是当我们start的时候应用是/b
const loadMountPromises = Promise.all(appsToLoad.map(app=> toLoadPromise(app).then(app=>{
// 当应用加载完毕后 需要启动和挂载,但是要保证挂载前 先卸载掉来的应用
return tryBootstrapAndMount(app,unmountAllPromises)
})));
// 如果应用 没有加载 加载 -》启动挂载 如果应用已经加载过了 挂载
const MountPromises = Promise.all(appsToMount.map(app=> tryBootstrapAndMount(app,unmountAllPromises)))
function tryBootstrapAndMount(app,unmountAllPromises){
if(shouldBeActive(app)){
// 保证卸载完毕在挂载
return toBootstrapPromise(app).then(app=> unmountAllPromises.then(()=> toMountPromise(app)))
}
}
return Promise.all([loadMountPromises,MountPromises]).then(()=>{ // 卸载完毕后
callEventListener();
appChangeUnderWay = false;
if(peopleWaitingOnAppChange.length > 0){
peopleWaitingOnAppChange = []; // 多次操作 我缓存起来,。。。。
}
})
}
function callEventListener(){
callCaptureEventListeners(event)
}
}
五、qiankun从入门到实战
创建 React 主应用(Substrate)
- 创建 React 应用:首先,你需要使用 Create React App 创建一个新的 React 应用。打开终端并运行以下命令:
npx create-react-app substrate
cd substrate
- 安装乾坤:在你的主应用中安装乾坤依赖。
npm install qiankun --save
- 配置主应用:接下来,你需要在主应用中配置乾坤。打开 src/App.js 并修改文件,以便注册子应用并启动乾坤。
import { registerMicroApps, start } from 'qiankun';
function App() {
// 在这里注册子应用
registerMicroApps([
{
name: 'reactApp',
entry: '//localhost:40000', // 默认react启动的入口是10000端口
activeRule: '/react', // 当路径是 /react的时候启动
container: '#container', // 应用挂载的位置
loader,
props: { a: 1, util: {} }
},
{
name: 'vueApp',
entry: '//localhost:20000', // 默认react启动的入口是10000端口
activeRule: '/vue', // 当路径是 /react的时候启动
container: '#container', // 应用挂载的位置
loader,
props: { a: 1, util: {} }
}
]);
// 启动乾坤
start();
return (
<div className="App">
<h1>主应用</h1>
<div id="subapp-container" />
</div>
);
}
export default App;
- 启动主应用:最后,运行你的主应用。
npm start
创建 React 子应用(m-react)
- 创建 React 子应用:在新的终端窗口中,使用 Create React App 创建另一个 React 应用。
npx create-react-app m-react
cd m-react
- 安装乾坤子应用依赖:为了使该应用作为乾坤的子应用运行,你需要安装一些依赖。
npm install qiankun @rescripts/cli -D
@rescripts/cli 是一个工具,允许你在不需要'eject'的情况下,自定义 Create React App 的 webpack 配置。在微前端架构中,通常需要对子应用的 webpack 配置进行一些调整,以确保它们能够正确地作为微前端子应用运行。例如,你可能需要修改输出格式、设置 public path 等。使用 **@rescripts/cli **可以让你轻松地进行这些配置,而无需对 Create React App 的默认配置进行破坏性的修改。
- 配置子应用:修改 package.json,以便使用 @rescripts/cli。
"scripts": {
"start": "rescripts start",
"build": "rescripts build",
"test": "rescripts test",
"eject": "rescripts eject"
}
然后,在子应用的根目录下创建一个 .rescriptsrc.js 文件来配置子应用的 webpack。
qiankun前端要求应用暴露的方式是umd格式。
module.exports = {
webpack:(config)=>{
config.output.libraryTarget = 'umd';
config.output.library = 'm-react'; // 打包的格式是umd格式
return config
},
devServer:(config)=>{
config.headers = {
'Access-control-Allow-Origin':"*"
}
return config
}
}
- 修改子应用入口文件:在 src/index.js 中,将子应用暴露给乾坤。
import './registerApps';
创建一个新文件 src/registerApps.js,包含子应用的启动逻辑。
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
function render(props) {
const { container } = props;
ReactDOM.render(<App />, container ? container.querySelector('#root') : document.querySelector('#root'));
}
// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
render({});
}
export async function bootstrap() {
console.log('React 子应用 bootstraped');
}
export async function mount(props) {
console.log('React 子应用 mount');
render(props);
}
export async function unmount(props) {
const { container } = props;
ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.querySelector('#root'));
}
在 m-react项目的src目录下新建public-path.js
if(window.__POWERED_BY_QIANKUN__){//检查子应用是否在乾坤环境中运行
//如果子应用是通过乾坤加载的,window.__POWERED_BY_QIANKUN__ 会被设置为 true。
// 当子应用在乾坤环境下运行时,这行代码会将 webpack 的 public path 设置为乾坤注入的 public path。
//保证静态资源正常加载
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}
在根目录下 新建 .env文件
PORT=40000
WDS_SOCKET_PORT=40000
- PORT=40000:
- 这个环境变量用于指定应用运行时使用的端口号。
- 默认情况下,CRA 会让 React 应用运行在 3000 端口。通过设置 PORT 环境变量,你可以改变应用运行的端口。
- 在这个例子中,将 PORT 设置为 40000 意味着当你运行这个 React 应用时,它将在 localhost:40000 上可用。
- WDS_SOCKET_PORT=40000:
- WDS 代表 Webpack Dev Server,它是一个提供实时重载功能的小型 Express 服务器。
- WDS_SOCKET_PORT 环境变量用于指定 Webpack Dev Server 用于 WebSocket 连接的端口。
- 这在你需要 Webpack Dev Server 监听一个特定端口以实现例如实时重新加载的功能时很有用。
- 设置 WDS_SOCKET_PORT 为 40000 意味着 Webpack Dev Server 的 WebSocket 连接将使用这个端口。
- 启动 React 子应用:在 3001 端口上启动 React 子应用。
npm start
创建 Vue 子应用(m-vue)
- 创建 Vue 子应用:使用 Vue CLI 创建 Vue 应用。
vue create m-vue
cd m-vue
- 安装乾坤子应用依赖:和 React 子应用一样,安装所需依赖。
npm install qiankun -S
- 配置 Vue 子应用:修改 vue.config.js 文件,配置子应用的 webpack。
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
devServer:{
port:20000,
headers:{
'Access-Control-Allow-Origin':"*" //子应用跨域
}
},
configureWebpack:{
output:{
libraryTarget:'umd',
library:'m-vue'
}
}
})
- 修改子应用入口文件:在 src/main.js 中配置子应用的生命周期。
import './public-path.js'
import { createApp } from 'vue'
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';
import App from './App.vue'
import routes from './router'
let app;
let history;
let router;
function render(props) {
app = createApp(App)
history = createWebHistory(window.__POWERED_BY_QIANKUN__ ? '/vue' : '/')
router = createRouter({
history,
routes
})
app.use(router)
const container = props.container
app.mount(container ? container.querySelector('#app') : document.getElementById('app'))
}
if (!window.__POWERED_BY_QIANKUN__) {
render({})
}
export async function bootstrap() {
console.log('vue bootsrap')
}
export async function mount(props) {
render(props)
}
export async function unmount() {
app.unmount()
history.destroy();
app = null;
router = null
}
5.在src目录下 添加 public-path.js 文件
当子应用在乾坤环境下运行时,这行代码会将 webpack 的 public path 设置为乾坤注入的 public path
保证静态资源的正常加载
if(window.__POWERED_BY_QIANKUN__){
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}
动态加载static子应用
一些非 webpack 构建的项目,例如 jQuery 项目、jsp 项目,都可以按照这个处理。
接入之前请确保你的项目里的图片、音视频等资源能正常加载,如果这些资源的地址都是完整路径(例如 (qiankun.umijs.org/logo.png)
则没问题。如果都是相对路径,需要先将这些资源上传到服务器,使用完整路径。>
接入非常简单,只需要额外声明一个 script,用于 export 相对应的 lifecycles。例如:
- 声明 entry 入口
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Purehtml Example</title>
</head>
<body>
<div>
Purehtml Example
</div>
</body>
+ <script src="//yourhost/entry.js" entry></script>
</html>
- 在 entry js 里声明 lifecycles
const render = ($) => {
$('#purehtml-container').html('Hello, render with jQuery');
return Promise.resolve();
};
((global) => {
global['m-static'] = {
bootstrap: () => {
console.log('purehtml bootstrap');
return Promise.resolve();
},
mount: () => {
console.log('purehtml mount');
return render($);
},
unmount: () => {
console.log('purehtml unmount');
return Promise.resolve();
},
};
})(window);
3.在substrate基座里面可以通过 loadMicroApp动态加载 无需注册
import React from 'react'
import {BrowserRouter, Link} from 'react-router-dom'
import { useEffect } from 'react';
import { loadMicroApp} from 'qiankun'
function App() {
const containerRef = React.createRef();
useEffect(()=>{
loadMicroApp({
name:'m-static',
entry: 'http://localhost:30000',
container:containerRef.current
})
})
// keep-alive 可以实现动态的加载
return (
<div className="App">
<BrowserRouter>
<Link to="/react">React应用</Link>
<Link to="/vue">Vue应用</Link>
</BrowserRouter>
<div ref={containerRef}></div>
<div id='container'></div>
</div>
);
}
export default App;
4.http-server -p 30000 --cors 通过 http-server 来启动静态资源
主子应用间通讯
initGlobalState
在主应用定义全局状态,并返回通信方法,微应用通过 props 获取通信方法
全局开启sandbox
start({
sandbox: {
// 实现了动态样式表
// css-module,scoped 可以再打包的时候生成一个选择器的名字 增加属性 来进行隔离
// BEM
// CSS in js
// shadowDOM 严格的隔离
// strictStyleIsolation:true,
experimentalStyleIsolation: true // 缺点 就是子应用中的dom元素如果挂在到了外层,会导致样式不生效
}
})
- experimentalStyleIsolation(实验性样式隔离)
experimentalStyleIsolation 是一种较为轻量的样式隔离方式。它在运行时动态地给子应用的所有 DOM 元素添加一个独特的属性(如 data-qiankun),并重写子应用的所有 CSS 选择器,使它们只对带有该特定属性的元素生效。
registerMicroApps([
{
name: 'app1',
entry: '//localhost:3001',
container: '#container',
activeRule: '/app1',
props: { ... },
// 开启实验性样式隔离
experimentalStyleIsolation: true
},
// ...其他子应用配置
]);
2. strictStyleIsolation(严格样式隔离)
strictStyleIsolation 是一种更为严格的样式隔离方式,它利用了 Shadow DOM 的封装特性。通过将子应用的 DOM 封装在一个 Shadow DOM 容器中,子应用的 CSS 样式完全隔离,不会影响到外部的 DOM。
registerMicroApps([
{
name: 'app2',
entry: '//localhost:3002',
container: '#container',
activeRule: '/app2',
props: { ... },
// 开启严格样式隔离
strictStyleIsolation: true
},
// ...其他子应用配置
]);
缺点:
- experimentalStyleIsolation:由于是在运行时重写 CSS 选择器,可能会有性能影响,尤其是在子应用有大量样式规则时。同时,这种方法可能不适用于动态插入的样式或使用 Shadow DOM 的组件。
- strictStyleIsolation:使用 Shadow DOM 可以提供最彻底的样式隔离,但需要考虑浏览器的兼容性。旧版浏览器可能不支持 Shadow DOM,或者支持不完全。
- 子应用中的dom元素如果挂在到了外层,会导致样式不生效。