微前端探秘:从实践到源码 - 全面解读微前端生态(上篇)

401 阅读10分钟

项目 github地址:github.com/DevWizardFe…
项目亮点

  1. 微前端 概念 及应用
  2. systemJS源码
  3. single-spa实战及源码
  4. qiankun实战及源码(沙箱原理)(蚂蚁金服)
  5. MicroApp、WebComponent实战及源码(京东零售)
  6. wujie实战使用(腾讯)
  7. webpack5 Module Federation Emp2实战

一、为什么需要微前端

:::warning 微前端就是将不同的功能按照不同的维度拆分成多个子应用。通过主应用来加载这些子应用。
::: 对比(丰贺): DeepTable DeepUx DeepModel DeepFlow DeepBI
微前端的核心在于拆, 拆完后在合,实现分而治之!

image.png

1.微前端解决的问题

  1. 不同团队(技术栈不同),同时开发一个应用
  2. 每个团队开发的模块都可以独立开发,独立部署
  3. 实现增量迁移

2.如何实现微前端

我们可以将一个应用划分成若干个子应用,将子应用打包成一个个的模块。当路径切换时加载不同的子应用。这样每个子应用都是独立的,技术栈也不用做限制了!

3.实现微前端的技术方案

  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 

image.png

创建子项目
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三个项目,就可以完整看到效果了。
image.png
点击不同的 路由 跳转不同应用

四、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 的主要作用
  1. 应用注册
    • app.js 提供了 registerApplication 函数,用于注册微前端应用。这是微前端架构中的一个关键步骤,因为它决定了如何和何时加载各个独立的应用。
    • 在注册应用时,需要提供应用的名称、加载函数、激活条件(例如特定的路由路径),以及自定义属性。
  2. 状态管理
    • 通过 apps 数组来管理所有注册的应用。每个应用都有其对应的状态,如未加载、加载中、已加载等。
    • 状态的变更通常会触发相应的生命周期事件(例如加载、挂载、卸载)。
  3. 重路由逻辑
    • 在应用注册后,app.js 通过调用 reroute 函数来处理可能的路由变化。这是确保应用根据当前URL正确加载和展示的关键。

app.js 的核心流程

  1. 应用注册
    • 当调用 registerApplication 函数时,会创建一个包含应用信息的对象,并将其添加到 apps 数组中。这个对象包含应用的名称、加载函数、激活条件、自定义属性和当前状态。
  2. 初始化应用状态
    • 初始状态设置为 NOT_LOADED,表示应用尚未加载。
  3. 触发重路由
    • 注册应用后,调用 reroute 函数。reroute 负责检查当前URL,确定哪些应用需要被加载、激活或卸载。
  4. 应用加载与激活
    • 基于当前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)
dc59bb1dac4da262b0de44aad309d2a.png

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)
  1. 创建 React 应用:首先,你需要使用 Create React App 创建一个新的 React 应用。打开终端并运行以下命令:
npx create-react-app substrate
cd substrate
  1. 安装乾坤:在你的主应用中安装乾坤依赖。
npm install qiankun --save
  1. 配置主应用:接下来,你需要在主应用中配置乾坤。打开 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;
  1. 启动主应用:最后,运行你的主应用。
npm start
创建 React 子应用(m-react)
  1. 创建 React 子应用:在新的终端窗口中,使用 Create React App 创建另一个 React 应用。
npx create-react-app m-react
cd m-react
  1. 安装乾坤子应用依赖:为了使该应用作为乾坤的子应用运行,你需要安装一些依赖。
npm install qiankun @rescripts/cli -D

@rescripts/cli 是一个工具,允许你在不需要'eject'的情况下,自定义 Create React App 的 webpack 配置。在微前端架构中,通常需要对子应用的 webpack 配置进行一些调整,以确保它们能够正确地作为微前端子应用运行。例如,你可能需要修改输出格式、设置 public path 等。使用 **@rescripts/cli **可以让你轻松地进行这些配置,而无需对 Create React App 的默认配置进行破坏性的修改。

  1. 配置子应用:修改 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
    }
}
  1. 修改子应用入口文件:在 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
  1. PORT=40000:
    • 这个环境变量用于指定应用运行时使用的端口号。
    • 默认情况下,CRA 会让 React 应用运行在 3000 端口。通过设置 PORT 环境变量,你可以改变应用运行的端口。
    • 在这个例子中,将 PORT 设置为 40000 意味着当你运行这个 React 应用时,它将在 localhost:40000 上可用。
  2. 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 连接将使用这个端口。
  3. 启动 React 子应用:在 3001 端口上启动 React 子应用。
npm start
创建 Vue 子应用(m-vue)
  1. 创建 Vue 子应用:使用 Vue CLI 创建 Vue 应用。
vue create m-vue
cd m-vue
  1. 安装乾坤子应用依赖:和 React 子应用一样,安装所需依赖。
npm install qiankun -S
  1. 配置 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'
    }
  }
})

  1. 修改子应用入口文件:在 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。例如:

  1. 声明 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>
  1. 在 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
image.png
在主应用定义全局状态,并返回通信方法,微应用通过 props 获取通信方法

全局开启sandbox
start({
    sandbox: {
        // 实现了动态样式表
        // css-module,scoped 可以再打包的时候生成一个选择器的名字  增加属性 来进行隔离
        // BEM
        // CSS in js
        // shadowDOM 严格的隔离

        // strictStyleIsolation:true,
        experimentalStyleIsolation: true // 缺点 就是子应用中的dom元素如果挂在到了外层,会导致样式不生效
    }
})
  1. 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元素如果挂在到了外层,会导致样式不生效。