书接上文万字理解微前端内容!!!🔥(一)iframe 方案,内容太多,只能拆成两部分了😭
二、主流技术方案对比
1. iframe 方案
这个部分在万字理解微前端内容!!!🔥(一)iframe 方案中将完了
2. 路由分发+资源处理
2.1 single-spa
single-spa 是通过**路由劫持(监听路由变化)**实现应用的加载(采用 SystemJS),提供应用间公共组件加载及公共业务逻辑处理。子应用需要暴露固定的生命周期 bootstrap、mount、unmount 接入协议。主应用通过路由匹配实现对子应用生命周期的管理。
single-spa 的核心就是定义了一套协议。协议包含主应用的配置信息和子应用的生命周期,通过这套协议,主应用可以方便的知道在什么情况下激活哪个子应用。
主应用如何导入子应用(需要用户自定义实现,推荐使用 SystemJS + import maps),通信采用 props
<script type="systemjs-importmap">
{
"imports": {
"@react-mf/root-config": "//localhost:9000/react-mf-root-config.js"
}
}
</script>
<script>
singleSpa.registerApplication({
name: "app", // 子应用名
app: () => System.import("@react-mf/root-config"), // 如何加载你的子应用
activeWhen: "/appName", // url 匹配规则,表示啥时候开始走这个子应用的生命周期
customProps: {
// 自定义 props,从子应用的 bootstrap, mount, unmount 回调可以拿到
authToken: "xc67f6as87f7s9d",
},
});
</script>
缺点
- 学习成本高(systemjs)
- 无沙箱机制,需要自己实现 js 沙箱和 css 隔离
- 需要对原有的应用进行改造
- 子应用间相同资源重复加载的问题
2.1.1 补充:SystemJS
SystemJS 是一个模块规范,就好像 commonjs、AMD、ES6 等。
搭建 react 项目测试
先采用 webpack 搭建一个可以独立运行的 react 项目来测试:
- 安装依赖
pnpm i react react-dom
pnpm i webpack webpack-cli webpack-dev-server @babel/core @babel/preset-react @babel/preset-env babel-loader html-webpack-plugin -D
- 配置 webpack
const HtmlWebpackPlugin = require("html-webpack-plugin");
const path = require("path");
module.exports = (env) => {
return {
mode: "development",
output: {
filename: "index.js",
path: path.resolve(__dirname, "dist"),
// 将子应用打包成类库,在主应用中进行加载,加载的方式采用systemjs
// systemjs是一种模块规范
libraryTarget: env.production ? "system" : "",
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
},
},
],
},
devServer: {
port: 3000,
},
plugins: [
// 生成环境不生成html文件,因为最后打包后的资源,也就是js,通过systemjs进行引入
!env.production &&
new HtmlWebpackPlugin({
template: "./public/index.html",
}),
].filter(Boolean),
externals: env.production ? ["react", "react-dom"] : [],
};
};
- 新建
.babelrc文件,添加预设
{
"presets": [
"@babel/preset-env",
[
"@babel/preset-react",
{
// 配置react的运行模式 automatic:automatic模式会自动引入react-dom和react-router-dom等依赖
"runtime": "automatic"
}
]
]
}
-
新增
public/index.html文件,添加<div id="root"></div> -
新增
src/index.js和src/App.js
// src/index.js
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")).render(<App />);
// src/App.js
import React from "react";
export default function App() {
return <h1>hello world</h1>;
}
- 配置脚本
// package.json
{
"scripts": {
"dev": "webpack serve",
"build": "webpack --env production"
}
}
将项目打包成 SystemJS 模块
将上述 react 测试项目执行 pnpm build 进行打包,可以看到打包的内容:通过 System.register() 方法将模块内容进行注册,并返回一个模块对象。
如果在 webpack 中配置了
externals,将包排出在外后,在System.register()的第一个参数中会传入模块的依赖包名称,并且在第二个参数返回的{}.setters会进行模块替换。
- 使用了
externals
// 需要保证参数一的资源加载完毕后,再执行函数
System.register(["react", "react-dom"], function (__WEBPACK_DYNAMIC_EXPORT__, __system_context__) {
var __WEBPACK_EXTERNAL_MODULE_react__ = {};
var __WEBPACK_EXTERNAL_MODULE_react_dom__ = {};
Object.defineProperty(__WEBPACK_EXTERNAL_MODULE_react__, "__esModule", { value: true });
Object.defineProperty(__WEBPACK_EXTERNAL_MODULE_react_dom__, "__esModule", { value: true });
return {
setters: [
function (module) {
Object.keys(module).forEach(function (key) {
// react对象,上面有 useRef, useState 属性,key就是这些属性
__WEBPACK_EXTERNAL_MODULE_react__[key] = module[key];
});
},
function (module) {
Object.keys(module).forEach(function (key) {
__WEBPACK_EXTERNAL_MODULE_react_dom__[key] = module[key];
});
},
],
execute: function () {},
};
});
- 未使用
externals
System.register([], function (__WEBPACK_DYNAMIC_EXPORT__, __system_context__) {
return {
execute: function () {},
};
});
将 react 项目通过打包,打包成了 systemjs,后续可以直接使用这个模块了,但是需要通过 systemjs 来进行加载。
如果在主应用中去使用 systemjs 模块
主应用需要安装 systemjs,通过 System.import() 方法进行加载(加载打包后的模块)。
如果在打包的时候,排出了第三方依赖,需要配置
import maps通过 cdn 加载第三方资源。
<h1>主应用</h1>
<!-- 子应用挂载点 -->
<div id="root"></div>
<script type="systemjs-importmap">
{
"imports": {
"react": "https://cdn.bootcdn.net/ajax/libs/react/18.2.0/umd/react.development.js",
"react-dom": "https://cdn.bootcdn.net/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js"
}
}
</script>
<!-- systemjs 模块加载器 -->
<script src="https://cdn.bootcdn.net/ajax/libs/systemjs/6.15.1/system.min.js"></script>
<script>
// 直接加载子应用
System.import("../dist/index.js");
</script>
如何手写 systemjs 规范
手写内容:
- system 是如何定义的(可以参考打包后的结果)
System.register(依赖列表,回调函数),回调函数返回一个对象,对象包含setters和execute。setters是用来保存加载后的资源,打包内容中 webpack 采用变量替换了execute是真正打包项目的逻辑(这个项目里也就是子应用真正的渲染逻辑)
setters 举例:例如打包排除了 react 和 react-dom,在打包后的文件中就会出现两个变量 __WEBPACK_EXTERNAL_MODULE_react__,__WEBPACK_EXTERNAL_MODULE_react_dom__ 用作代替后续项目中需要依赖的这些变量的部分。
// 例如 ReactDOM.xxx => __WEBPACK_EXTERNAL_MODULE_react_dom__.xxx
__WEBPACK_EXTERNAL_MODULE_react_dom__[key] = module[key];
分析:
有一个 System 构造函数,上面有 import 和 register 方法。
整体思路:根据 importmap 来解析出 url 和 对应的模块,通过 load 方法去加载对应的模块(load 是通过创建 script 标签来加载),importmap 的第三方资源在加载后,会在 window 上挂载对应的模块(cdn 资源要是 umd 格式),例如:react 会加载 window.React。在 System.import("../dist/index.js") 中加载的模块,是打包后的资源,内部是 webpack 通过变量替代了 cdn 加载的模块,加载后需要去替换掉对应的变量。
// 直接加载子应用(异步的)
System.import("../dist/index.js").then(() => {
console.log("模块加载完毕");
});
// dist/index.js
System.register([], fn);
具体手写代码:
<h1>主应用</h1>
<script type="systemjs-importmap">
{
"imports": {
"react": "https://cdn.bootcdn.net/ajax/libs/react/18.2.0/umd/react.development.js",
"react-dom": "https://cdn.bootcdn.net/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js"
}
}
</script>
<div id="root"></div>
<script>
class SystemJs {
import(id) {
// id 是资源的路径,可以是第三方cdn资源路径
return Promise.resolve()
.then(() => {
// 1. 解析importMap
this.#processScript();
// 2. 去当前路径去找对应资源路径
// http://127.0.0.1:5500/mini-systemjs/main/main.html,找到最后一个 / 的位置,前面的就是路径
const lastSepIndex = location.href.lastIndexOf("/");
// http://127.0.0.1:5500/mini-systemjs/main/
const baseUrl = location.href.slice(0, lastSepIndex + 1);
// 当前是模拟本地路径,所以这里“写死了“
if (id.startsWith("../dist")) {
return baseUrl + id;
}
})
.then((id) => {
// 这里的id就是上个Promise返回的baseUrl + id
/**
* id: http://127.0.0.1:5500/mini-systemjs/main/../dist/index.js,
* 浏览器会转为 http://127.0.0.1:5500/mini-systemjs/dist/index.js
*/
// 根据文件路径加载资源
return this.#load(id);
})
.then((register) => {
// register: [['react', 'react-dom'], function() {}] 是在load执行完成后,通过resolve传到这里的
const [deps, declare] = register;
/**
* 因为这个 declare 为 function(__WEBPACK_DYNAMIC_EXPORT__, __system_context__) {
* return { setters, execute }
* }
* setters:是用来保存加载后的资源,打包内容中webpack采用变量替换了
* execute:是真正资源加载逻辑
*/
const { setters, execute } = declare(() => {}, {});
Promise.all(
deps.map((dep, i) => {
return this.#load(dep).then(() => {
// setters: [function(module) {}] 会传入 module 参数(例如:module -> react)
// cdn加载完毕后,会在window上添加属性,例如:window.React
const property = this.#getLastGlobalProperty();
setters[i](property);
});
})
).then(() => {
// 当对应变量替换好后,执行具体渲染逻辑
execute();
});
});
}
#mapUrl = {};
// 解析importMap
#processScript() {
Array.from(document.querySelectorAll("script")).forEach((script) => {
if (script.type === "systemjs-importmap") {
const imports = JSON.parse(script.innerHTML).imports;
Object.entries(imports).forEach(([key, value]) => {
this.#mapUrl[key] = value;
});
}
});
}
// 加载资源
#load(id) {
return new Promise((resolve, reject) => {
// 通过script来获取资源,不用fetch是因为fetch会跨域
const script = document.createElement("script");
script.src = this.#mapUrl[id] || id; // this.#mapUrl[id] 去映射表查找url资源,这样就可以支持cdn了
script.async = true;
// 此时会去执行脚本,在打包的文件中,有System.register方法去执行,接下来要去书写register方法
document.head.appendChild(script);
script.onload = () => {
let _lastRegister = this.#lastRegister;
this.#lastRegister = undefined;
resolve(_lastRegister);
};
});
}
constructor() {
this.#saveGlobalProperty();
}
#set = new Set();
// 保存之前的window属性(快照)
#saveGlobalProperty() {
for (let k in window) {
this.#set.add(k);
}
}
// 获取window上最后添加的属性(新增属性,cdn导入后,会在window上添加属性)
#getLastGlobalProperty() {
for (let k in window) {
if (this.#set.has(k)) continue;
this.#set.add(k);
return window[k];
}
}
/**
* @param {string[]} deps 依赖列表
* @param {function} declare 声明函数
*/
register(deps, declare) {
// 为了后续在load使用,保存在变量中
// 等文件加载完成后,load中会将结果传递到下一个Promise中
this.#lastRegister = [deps, declare];
}
#lastRegister;
}
const System = new SystemJs();
// 直接加载子应用(异步的)
System.import("../dist/index.js").then(() => {
console.log("模块加载完毕");
});
</script>
2.1.2 single-spa 使用
single-spa 是借助 systemjs 来实现模块的加载,通过路由匹配来切换子应用。
主应用:访问 index.html 就去加载 localhost:9000/wifi-root-config.js 资源,wifi-root-config.js 就去加载远程 www.baidu.com 内容。
// index.html
<script type="systemjs-importmap">
{
"imports": {
// umd格式
"@wifi/root-config": "//localhost:9000/wifi-root-config.js",
"@wifi/react": "//localhost:9001/wifi-react.js"
}
}
</script>
<script>
System.import("@wifi/root-config")
</script>
// wifi-root-config.js
import { registerApplication, start } from "single-spa";
// 注册应用(可以远程加载资源)
registerApplication({
name: "@wifi/root-config", // 子应用名
app: () => System.import("www.baidu.com"), // 如何加载你的子应用
activeWhen: ["/"],
// activeWhen: (location) => location.pathname.startsWith("/"),
});
// 注册子应用(本地)
registerApplication({
name: "@wifi/react", // 子应用名
app: () => System.import("@wifi/react"), // 如何加载你的子应用 需要在 systemjs-importmap 中添加对应的资源
activeWhen: ["/react"], // 访问 /react 就去加载react子应用
});
start({
// 默认是true,表示只有url发生变化时才会重新路由
urlRerouteOnly: true,
});
子应用:子应用必须提供接入协议(暴露出对应生命周期)
// wifi-react.js
import React from "react";
import ReactDOM from "react-dom";
import singleSpaReact from "single-spa-react";
import Root from "./root.component";
const lifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: Root,
errorBoundary(err, info, props) {
return null;
},
});
// 接入协议,子应用必须提供接入协议
export const { bootstrap, mount, unmount } = lifecycles;
2.1.3 single-spa 原理
- 预先注册子应用(激活路由、子应用资源、生命周期函数)
- 监听路由的变化,匹配到了激活的路由则加载子应用资源,顺序调用生命周期函数并最终渲染到容器
<script src="https://cdn.bootcdn.net/ajax/libs/single-spa/5.9.3/umd/single-spa.min.js"></script>
<script>
// single-spa 基于路由的微前端
// 如何接入已经写好的应用,对于 single-spa 而言,需要改写子应用(添加接入协议)
/**
* 接入协议:
* - bootstrap:子应用启动
* - mount:子应用挂载
* - unmount:子应用卸载
*/
// cdn 的:singleSpa
const { registerApplication, start } = singleSpa;
// app1 模拟子应用
const app1 = {
// bootstrap可以是一个数组,也可以是一个函数(数组好处:可以同时编写多个函数),函数是一个 promise
bootstrap: [async () => console.log("app1 bootstrap1"), async () => console.log("app1 bootstrap2")],
mount: async () => {
// createApp(App).mount(#root)
console.log("app1 mount");
},
unmount: async () => {
console.log("app1 unmount");
},
};
const app2 = {
bootstrap: async () => {
console.log("app2 bootstrap");
},
mount: async () => {
console.log("app2 mount");
},
unmount: async () => {
console.log("app2 unmount");
},
};
// 参数1:应用名 参数2:加载的那个应用(必须要返回一个promise) 3. 路由匹配规则
registerApplication(
"app1",
async () => app1,
(location) => location.hash.startsWith("#/app1")
);
registerApplication(
"app2",
async () => app2,
(location) => location.hash.startsWith("#/app2")
);
start();
</script>
2.2 qiankun
qiankun 是一个基于 single-spa 的微前端实现库。
优点
- 监听路由自动的加载、卸载当前路由对应的子应用
- 完备的沙箱方案
- js 沙箱做了
SnapshotSandbox、LegacySandbox、ProxySandbox三套渐进增强方案 - css 沙箱做了两套
strictStyleIsolation、experimentalStyleIsolation两套适用不同场景的方案
- js 沙箱做了
- 路由保持,浏览器刷新、前进、后退,都可以作用到子应用
- 应用间通信简单,全局注入
- 增加资源预加载能力,预先子应用 html、js、css 资源缓存下来,加快子应用的打开速度
缺点
- 基于路由匹配,无法同时激活多个子应用,也不支持子应用保活
- css 沙箱无法绝对的隔离,js 沙箱在某些场景下执行性能下降严重
- 无法支持 vite 等 ESM 脚本运行(qiankun 使用
UMD格式)
2.2.1 qiankun 使用
分别以 webpack、vite 和 原生html 作为子应用为例,主子应用有路由和没路由使用有点不同(关键:在于路径是否配置正确)。
主应用 —— 不带路由
主应用采用
vite+vue创建,对主应用要求不大。
- 新建
registerMicroApps.ts文件,用来注册子应用
import { registerMicroApps, start } from "qiankun";
registerMicroApps(
[
// 当匹配到 activeRule 的时候,请求获取 entry 资源,渲染到 container 中。
{
name: "webpack-vue", //子应用名称
entry: "//localhost:7001", //子应用入口(html入口)
container: "#container", //子应用挂载容器
activeRule: "/webpack-vue", //子应用路由匹配规则
loader: (loading) => {}, // loading 加载状态
},
],
{
// 可选参数
beforeLoad: {},
beforeMount: {},
// ...
}
);
start();
- 在
main.ts中引入registerMicroApps.ts文件即可
import { createApp } from "vue";
import "./style.css";
import App from "./App.vue";
+ import "./registerMicroApps";
createApp(App).mount("#app");
- 主应用需要给定一个渲染容器(通过
registerMicroApps注册子应用的container参数指定),子应用会渲染到该容器中。
<div id="container"></div>
可以参考下面 webpack 子应用,将子应用先接入到主应用。
主应用 —— 带路由
主应用如果跳转子应用的路由,由于主应用中没有注册这些路由,会有警告,可以在路由表配置:
const routes = [
{
path: "/webpack-vue/:pathMatch(.*)*",
// 出口容器设置单独的页面
component: () => import("./container.vue"),
},
];
子应用如果是 vue2,对应 vue-router 是 3 的版本,跳转会有报错
具体内容可以看:juejin.cn/post/695682…
解决办法:
在主应用中的路由拦截中,对参数进行改写(vue3 的路由没有这个问题)
router.beforeEach((to, from, next) => {
if (!window.history.state.current) window.history.state.current = to.fullPath;
if (!window.history.state.back) window.history.state.back = from.fullPath;
// 手动修改history的state
return next();
});
webpack-vue 子应用 —— 不带路由
‼️ 注意:子应用必须要支持跨域,并且要求子应用暴露的方式是 UMD 格式
- 先配置 webpack
这里通过
vue.config.js进行配置
const { defineConfig } = require("@vue/cli-service");
module.exports = defineConfig({
devServer: {
port: 7001,
headers: {
"Access-Control-Allow-Origin": "*",
},
},
configureWebpack: {
output: {
library: "webpack-vue",
// 把微应用打包成 umd 库格式
libraryTarget: "umd",
// webpack 5 需要把 jsonpFunction 替换成 chunkLoadingGlobal
// 主要用于动态加载(懒加载)模块时,定义全局变量名称,以确保多个 Webpack 运行时(runtime)共存时不会冲突。
// 如果多个 Webpack 应用同时运行(例如微前端场景),可能会发生全局变量冲突,导致 chunk 加载失败。
chunkLoadingGlobal: `webpackJsonp_webpack-vue`,
},
},
});
- 在 main.ts 中导出对应的接入协议
这个接入协议(bootstrap, mount, unmount)需要是异步的
import { type App as InsApp, createApp } from "vue";
import App from "./App.vue";
let app: InsApp;
function render(props: any = {}) {
const { container } = props;
app = createApp(App);
app.mount(container ? container.querySelector("#app") : "#app");
}
// 非qiankun环境下
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
export async function bootstrap() {
console.log("vue app bootstraped");
}
export async function mount(props: any) {
render(props);
}
export async function unmount(props: any) {
app!.unmount();
}
- 解决
__POWERED_BY_QIANKUN__类型报错问题
这是因为 ts 的 window 上没有 __POWERED_BY_QIANKUN__ 属性,所以需要添加一个全局声明。
新建一个 global.d.ts 文件,内容如下:
declare global {
interface Window {
__POWERED_BY_QIANKUN__?: boolean;
}
}
// 添加 export {} 会将文件视为一个模块,这样全局声明就会正确生效。
export {};
- 解决图片不显示问题,新建
public-path.js文件,并在入口文件导入即可
public-path.js 内容如下:
if (window.__POWERED_BY_QIANKUN__) {
// 用于修改运行时的 publicPath,__INJECTED_PUBLIC_PATH_BY_QIANKUN__就是当前子应用自己的路径
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
main.ts 中导入:
+ import './public-path'
import { type App as InsApp, createApp } from 'vue'
import App from './App.vue'
添加之前,图片路径:
/img/vite.svg添加之后:
http://localhost:7001/img/vite.svg
这里不仅可以解决子应用的图片在主应用路径不正确的问题,还可以解决子应用路由是懒加载,其他页面报错的问题。
子应用路由是懒加载,就需要配置,因为懒加载的路由打包后,跟图片一样,不在入口文件,需要去配置 PublicPath。
webpack-vue 子应用 —— 带路由
- 配置
router路由表
这个和正常项目配置路由一样,只不过需要添加路由前缀:createWebHistory(window.__POWERED_BY_QIANKUN__ ? '/webpack-vue/' : '/'),
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "home",
component: () => import(/* webpackChunkName: "home" */ "../views/HomeView.vue"),
},
{
path: "/about",
name: "about",
component: () => import(/* webpackChunkName: "about" */ "../views/AboutView.vue"),
},
];
const router = createRouter({
history: createWebHistory(window.__POWERED_BY_QIANKUN__ ? "/webpack-vue/" : "/"),
routes,
});
export default router;
- 修改
main.ts中的render方法
function render(props: any = {}) {
app = createApp(App)
+ app.use(router)
}
vite 子应用
由于 qiankun 需要子应用的格式为 UMD,而 vite 开发模式是 ESM,所以只能使用 vite-plugin-qiankun 这个插件来进行适配。
- 配置 vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import qiankun from "vite-plugin-qiankun";
export default defineConfig({
plugins: [
vue(),
qiankun("vite-vue", {
// useDevMode:确定为开发模式
useDevMode: true,
}),
],
server: {
port: 7002,
// 允许跨域
cors: true,
// origin:用于定义开发调试阶段生成资源的 origin,解决静态资源不生效问题
origin: `http://localhost:${7002}`,
},
build: {
lib: {
entry: "./src/main.ts", // 入口文件
name: "vite-vue", // 子应用名称
fileName: "vite-vue", // 打包后的文件名
formats: ["umd"], // 打包为 UMD 格式
},
},
});
- 改造
main.ts
由于没有环境变量,需要从 vite-plugin-qiankun 包中进行导入。
import { createApp, type App as AppInstance } from "vue";
import App from "./App.vue";
import "./style.css";
import { renderWithQiankun, qiankunWindow } from "vite-plugin-qiankun/dist/helper";
let app: AppInstance | null = null;
function render(props: any = {}) {
const { container } = props;
app = createApp(App);
app.mount(container ? container.querySelector("#app") : "#app");
}
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
render();
}
renderWithQiankun({
mount(props) {
render(props);
},
bootstrap() {
console.log("bootstrap");
},
unmount(_props) {
app?.unmount();
app!._container.innerHTML = "";
app = null;
},
update() {
console.log("update");
},
});
补充:UMD 格式
如果要使用 qiankun 改在原生子应用,需要知道一些 UMD 格式的知识。
这里拿 webpack 打包 umd 格式的例子来说明:
- index.js
包含:默认导出和按需导出
const num = 100;
export default num;
export const str1 = "str1";
export const str2 = "str2";
- webpack 配置
/** @type {import('webpack').Configuration} */
const config = {
entry: "./index.js",
output: {
filename: "bundle.js",
libraryTarget: "umd",
library: "Demo",
},
};
module.exports = config;
- 输出的产物,可以只看浏览器环境部分
root["Demo"] = factory();
root 为 下面的 self,类型为:Window & typeof globalThis,那么就可以通过 window["Demo"] 来访问到这个模块。
(function webpackUniversalModuleDefinition(root, factory) {
if (typeof exports === "object" && typeof module === "object") module.exports = factory();
else if (typeof define === "function" && define.amd) define([], factory);
else if (typeof exports === "object") exports["Demo"] = factory();
else root["Demo"] = factory();
})(self, () => {
// self:Window & typeof globalThis
return (() => {})();
});
- 浏览器运行结果
window["Demo"] = {
default: 100,
str1: "str1",
str2: "str2",
};
原生子应用
前提:这个原生子应用,需要通过 url 进行访问,不能是本地路径。
<!-- 需要通过 http-server 启动页面,端口设置为 7003,允许跨域 -->
<!-- 或者通过 vscode live-server 插件启动页面 -->
<!-- 这里写好了:通过 npm run dev 启动(http-server --port 7003 --cors) -->
<div id="vanilla-root"></div>
<script>
const root = document.getElementById("vanilla-root");
// 导出最终接入协议即可
window["sub-vanilla"] = {
bootstrap: async () => {
console.log("vanilla app bootstrap");
},
mount: async () => {
console.log("vanilla app mount");
root.innerHTML = "<h1>vanilla app</h1>";
},
unmount: async () => {
console.log("vanilla app unmount");
root.innerHTML = "";
},
};
if (!window.__POWERED_BY_QIANKUN__) {
root.innerHTML = "<h1>vanilla app</h1>";
}
</script>
⚠️ 注意:对应原生子应用,可能由于渲染速度快的问题,导致主应用容器没有加载好,可以将 start 方法,放到主应用的 mount 方法中。拿 vue 举例:
<template>
<div id="container"></div>
</template>
<script setup lang="ts">
import { start } from "qiankun";
import { onMounted } from "vue";
onMounted(() => {
if (!window.qiankunStarted) {
window.qiankunStarted = true;
start();
}
});
declare global {
interface Window {
qiankunStarted: boolean;
}
}
</script>
手动加载子应用
通过 qiankun 提供的 loadMicroApp 方法,手动加载子应用。
<template>
<button @click="loadApp">点击加载子应用</button>
<div ref="containerRef"></div>
</template>
<script lang="ts" setup>
import { loadMicroApp } from "qiankun";
import { ref } from "vue";
const containerRef = ref<null | HTMLElement>(null);
const loadApp = () => {
if (!containerRef.value) {
return;
}
loadMicroApp({
name: "sub-vanilla",
entry: "//localhost:7003",
container: containerRef.value as HTMLElement,
});
};
</script>
2.2.2 主子应用通信
props 方式
主应用在注册子应用时,可以传入 props 参数,子应用在接入协议(生命周期)中,可以拿到传递的 props 参数。
// 主应用
registerMicroApps([
{
name: "webpack-vue",
entry: "//localhost:7001",
container: "#container",
activeRule: "/webpack-vue",
props: {
propsData: "propsData",
},
},
]);
// 子应用
export async function mount(props: any) {
console.log("vue app mount", props);
render(props);
}
actions:initGlobalState(state)
qiankun 提供了 initGlobalState 方法,用于初始化全局状态,并返回一个对象,对象中包含 onGlobalStateChange 和 setGlobalState 方法。
主应用:
import { initGlobalState, MicroAppStateActions } from "qiankun";
// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state);
/**
* onGlobalStateChange: (callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) => void
* 在当前应用监听全局状态,有变更触发 callback,fireImmediately = true 立即触发 callback
*/
actions.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
}, true);
// 移除当前应用的状态监听,微应用 umount 时会默认调用
actions.offGlobalStateChange();
子应用:
export function mount(props) {
props.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
props.setGlobalState(state);
}
全局事件总线 EventBus
利用 window 对象作为全局通信桥梁,主应用和子应用都可以访问 window,可以在其中挂载一个全局事件中心(如 EventEmitter),用于跨应用通信。
主应用:
- 创建 eventbus 类
// src/utils/EventBus.ts
class EventBus {
private events: Record<string, Function[]> = {};
on(eventName: string, callback: Function) {
if (!this.events[eventName]) {
this.events[eventName] = [];
}
this.events[eventName].push(callback);
}
emit(eventName: string, data?: any) {
const callbacks = this.events[eventName];
if (callbacks) {
callbacks.forEach((cb) => cb(data));
}
}
off(eventName: string, callback: Function) {
const index = this.events[eventName]?.indexOf(callback);
if (index !== -1) {
this.events[eventName].splice(index!, 1);
}
}
once(eventName: string, callback: Function) {
const wrapper = (data: any) => {
callback(data);
this.off(eventName, wrapper);
};
this.on(eventName, wrapper);
}
}
export default new EventBus();
- 挂载到 window
// src/main.ts
import eventBus from "./utils/EventBus";
(window as any).customEventBus = eventBus;
- 监听子应用发送的消息
(window as any).customEventBus.on("from-child", (msg) => {
console.log("主应用收到子应用消息:", msg);
});
子应用:
(window as any).customEventBus.emit("from-child", {
content: "Hello from child app!",
});
2.2.3 qiankun 样式隔离
css 样式隔离方案:
- css-module:为 CSS 类名生成唯一的哈希值
- scoped:Vue 特有
- css-in-js:自动生成唯一的类名,类名哈希化
- bem: 一种命名规范,bem(block, element, modifier)
- shadow-dom:创建一个隔离的 DOM 树,样式作用域仅限于 Shadow DOM 内部
- iframe:子应用在 iframe 中运行,样式隔离
在 qiankun 中,提供了 experimentalStyleIsolation 和 strictStyleIsolation 两种 API 方案,分别对应 css-module 和 shadow-dom。
-
experimentalStyleIsolation:会将import导入的 css 文件,添加特定选择器,全部添加到<style>标签中,并插入到子应用的<head>中,从而实现样式隔离。缺点:子应用的样式不影响主应用,但子应用不能隔离主应用的全局样式。
-
strictStyleIsolation:通过创建影子 DOM 的方式进行样式隔离。缺点:但是因为是完全隔离,但是如果子应用的 DOM 挂载到外层(例如
body上),会导致样式不生效。最好的办法就是不挂载到body上。
⚠️ 注意:在 vite 作为子应用时,通过 import 'style.css' 引入的样式会作用到主应用中,即使开启了影子 DOM 和 CSS Module 也不起作用。(也可以通过不要全局导入样式来解决)
因为 Vite 在开发模式下采用的是 ESM 模块化加载方式,对于 ESM 动态导入的样式文件(如
import './style.css')并不会自动处理。因为 qiankun 通过 fetch 来获取子应用资源,我们可以通过请求去看 vite 项目的 url:是没有 css 文件的,而是进入
main.ts后才进行加载。
而 webpack 会将这个样式文件打包到 js 文件中,并返回给主应用。(css 在下图的
app.js中)
因此,在 Vite 子应用中直接写
import './style.css'会将样式插入到全局<head>中,从而影响主应用。从下图可以明显的看到这个子应用样式表是会加载到主应用中的(所以需要给子应用添加前缀,避免和主应用冲突)
这时候需要借助 postcss 插件,将给子应用项目添加前缀即可。(缺点:会导致独立运行的子项目样式不会生效,因为没有容器)
- 安装
pnpm i postcss-prefix-selector
- vite.config.ts 配置
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import qiankun from "vite-plugin-qiankun";
import prefixer from "postcss-prefix-selector";
export default defineConfig({
// ...
css: {
postcss: {
plugins: [
prefixer({
// 配置子应用渲染容器
prefix: "#container",
transform(prefix, selector) {
// 处理选择器转换逻辑
return `${prefix} ${selector}`;
},
}),
],
},
},
});
到这里基本的样式已经实现隔离,但是如果引入了组件库样式,又会出现问题,这里拿 element-plus 为例:
从图中可以看到,之前编写的 postcss 前缀转换函数,将 :root 也添加上了前缀,这个就出现了问题。
⚠️ 这里需要注意:伪类
:root只能单独使用,表示文档的根元素(即<html>标签),不能与其它选择器组合使用(如#container:root)这个写法是错误的,导致组件库组件不能获取到正确的变量。
这里我们只需要在前缀转换函数添加一个一个判断即可:
import prefixer from 'postcss-prefix-selector';
export default defineConfig({
// ...
css: {
postcss: {
plugins: [
prefixer({
prefix: '#container',
transform(prefix, selector) {
+ if (selector === ':root') {
+ return selector;
+ }
// 处理选择器转换逻辑
return `${prefix} ${selector}`;
},
}),
],
},
},
});
除了上面这种办法,也可以在主应用中也同样引入一份 element-plus 组件库的样式,但是这样使用不是很优雅,不是很推荐。
💣💣💣 这里有发现一个 bug:就是当使用 Message 消息提示组件的时候,由于这个组件库样式,都加了前缀(除了:root),会导致该组件样式丢失,原因很简单:就是这个组件不是挂载在 prefix: '#container' 这个前缀容器中的,所以在开发的时候,可以将一些能挂载到内部的组件就挂载到内部。
2.2.4 qiankun 沙箱
在前端开发中,为了保证不同应用之间的代码不会相互干扰,我们需要使用 JS 隔离机制,通常称为沙箱。在乾坤框架中,我们使用三种不同的 JS 隔离机制,分别是快照沙箱(SnapshotSandbox),支持单应用的代理沙箱(LegacySandbox),支持多应用的代理沙箱(ProxySandbox)。由于后面两个都是基于 Proxy 来实现的,因此也可以分为快照沙箱和Proxy 代理沙箱。
- 快照沙箱(
SnapshotSandbox)需要遍历 window 上的所有属性,性能较差。 - ES6 新增的 Proxy,产生了新的沙箱支持单应用的代理沙箱(
LegacySandbox),它可以实现和快照沙箱一样的功能,但是性能更好,但是缺点是:也会和快照沙箱一样,污染全局的 window,因此它仅允许页面同时运行一个微应用。 - 而多应用的代理沙箱(
ProxySandbox),可以允许页面同时运行多个微应用,并且不会污染全局的 window。
qiankun 默认使用 ProxySandbox,可以通过 start.singular 来修改,如果发现浏览器不支持 Proxy 时,会自动优雅降级使用 SnapshotSandbox。
SnapshotSandbox 快照沙箱
快照沙箱原理:A 应用启动前先保留 Window 属性,应用卸载掉的时候,把 A 修改的属性保存下来。等会新应用 B 进来我就把之前保留的原始未修改的 Window 给 B,应用 A 回来我就把 A 之前修改的属性还原回来。
这种方法比较浪费性能:因为需要给 Window 拍照,后续需要和 Window 进行对比。
简单实现:
class SnapshotSandbox {
constructor() {
this.windowSnapshot = {};
this.modifiedPropsMap = {};
}
active() {
// 当激活的时候,给 window 拍个快照
this.windowSnapshot = {};
Object.keys(window).forEach((key) => {
this.windowSnapshot[key] = window[key];
});
// 如果是重新调用 active 的时候,表示又一次激活,需要将保存的属性还原回来
Object.keys(this.modifiedPropsMap).forEach((key) => {
window[key] = this.modifiedPropsMap[key];
});
}
inactive() {
// 需要记录全局哪些属性被修改了
this.modifiedPropsMap = {};
// 如果失活,需要和快照的 window 进行对比,将变化的内容存入modifiedPropsMap
Object.keys(window).forEach((key) => {
if (window[key] !== this.windowSnapshot[key]) {
// 记录修改的属性
this.modifiedPropsMap[key] = window[key];
// 将 window 属性还原成最初样子
window[key] = this.windowSnapshot[key];
}
});
}
}
const sandbox = new SnapshotSandbox();
sandbox.active();
window.a = 100;
window.b = 200;
sandbox.inactive();
console.log(window.a, window.b); // undefined undefinded
sandbox.active();
console.log(window.a, window.b); // 100 200
LegacySandbox 单应用的代理沙箱
LegacySandbox 原理:只存储修改或添加的属性,不用给 window 拍照。需要存储新增的属性(addPropsMap),修改前的属性(modifiedPropsMap)和记录所有修改后、新增的属性(currentPropsMap)。后续的操作是通过 Proxy 代理空对象进行处理的,例如:sandbox.proxy.a 是读取的 window 上的属性,sandbox.proxy.a = 100 是需要记录修改或新增的值,并且修改 window 上的属性。
这种方法的优点:性能比快照沙箱好(不用去监听整个 window)。缺点:Proxy 的兼容性不好,如果两个应用同时运行,window 只有一个,可能会有冲突。
modifiedPropsMap,addPropsMap是对失活的时候,进行属性还原的
currentPropsMap是用在激活时候,进行属性还原的
class LegacySandbox {
constructor() {
/**
* modifiedPropsMap,addPropsMap是对失活的时候,进行属性还原的
*/
// 修改后,需要记录window上该属性的原值
this.modifiedPropsMap = {};
// 需要记录新增的内容
this.addPropsMap = {};
/**
* currentPropsMap是用在激活时候,进行属性还原的
*/
// 需要记录所有(不管修改还是新增),对于window上属性的删除就是修改
this.currentPropsMap = {};
// 创建了一个假 Window 对象,fakeWindow => {}
const fakeWindow = Object.create(null);
const proxy = new Proxy(fakeWindow, {
get: (target, key, receiver) => {
// 当取值的时候,直接从 window 上取值
return window[key];
},
set: (target, key, value, receiver) => {
if (!window.hasOwnProperty(key)) {
// 如果 window 上没有这个属性,则记录新增的属性
this.addPropsMap[key] = value;
} else if (!this.modifiedPropsMap.hasOwnProperty(key)) {
// 如果 window 上有这个属性,并且之前没有修改过,需要记录原 window 上的值
this.modifiedPropsMap[key] = window[key];
}
// 无论新增还是修改,都记录一份(变化后的值)
this.currentPropsMap[key] = value;
// 修改window上的属性,修改成最新的内容
window[key] = value;
},
});
this.proxy = proxy;
}
active() {
// 激活时,恢复之前的内容
Object.keys(this.currentPropsMap).forEach((key) => {
this.setWindowProps(key, this.currentPropsMap[key]);
});
}
inactive() {
// 失活的时候,需要把修改的属性还原回去
Object.keys(this.modifiedPropsMap).forEach((key) => {
this.setWindowProps(key, this.modifiedPropsMap[key]);
});
// 如果新增了属性,在失活时需要移除
Object.keys(this.addPropsMap).forEach((key) => {
this.setWindowProps(key, undefined);
});
}
// 设置window上的属性
setWindowProps(key, value) {
if (value === undefined) {
// 移除后面新增的属性
delete window[key];
} else {
// 变回原来的初始值
window[key] = value;
}
}
}
const sandbox = new LegacySandbox();
// 需要去修改代理对象,代理对象setter后会去修改window上的属性
sandbox.proxy.a = 100;
console.log(window.a, sandbox.proxy.a); // 100 100
sandbox.inactive();
console.log(window.a, sandbox.proxy.a); // undefined undefined
sandbox.active();
console.log(window.a, sandbox.proxy.a); // 100 100
ProxySandbox 多应用的代理沙箱
ProxySandbox 原理:产生各自的代理对象,读取的时候从代理对象上取值,没有再去读 window。修改的时候,设置代理对象,只需要修改代理对象即可,不会去操作 window。
优点:不会污染全局的 window。
class ProxySandbox {
constructor() {
// 控制激活和失活
this.running = false;
const fakeWindow = Object.create(null);
this.proxy = new Proxy(fakeWindow, {
get: (target, key) => {
// 获取的时候,先从 fakeWindow 上取值,如果取不到,再从 window 上取值
return key in target ? target[key] : window[key];
},
set: (target, key, value) => {
if (this.running) {
// 激活才去设置
target[key] = value;
}
return true;
},
});
}
active() {
if (!this.running) this.running = true;
}
inactive() {
this.running = false;
}
}
const sandbox1 = new ProxySandbox();
const sandbox2 = new ProxySandbox();
sandbox1.active();
sandbox2.active();
// 修改不会影响window,不用去还原window
sandbox1.proxy.a = 100;
sandbox2.proxy.a = 200;
console.log(sandbox1.proxy.a, sandbox2.proxy.a); // 100 200
sandbox1.inactive();
sandbox2.inactive();
sandbox1.proxy.a = 200;
sandbox2.proxy.a = 400;
console.log(sandbox1.proxy.a, sandbox2.proxy.a); // 100 200
子应用在使用时:把 sandbox1.proxy 当作 window 参数传递给子应用,子应用调用 window 上的属性时,会去 sandbox1.proxy 上取值。(LegacySandbox 同理)
(function (window) {
console.log(window.a); // 100
})(sandbox1.proxy);
2.2.5 补充:创建 JS 沙箱还有哪些方法?
evel 函数
eval() 函数会将传入的字符串当做 JS 代码进行执行。但是这个函数存在安全问题,为了安全性,通常我们通过 eval 控制代码在特定对象的作用域中运行。
可以通过将自定义对象作为 this 或者使用 with 语句来限制全局作用域。
with:用于扩展作用域链,允许你在一个特定的对象范围内执行一段代码。它通常用于简化对对象属性的访问。例如:
const obj = { name: "Alice", age: 25, }; with (obj) { console.log(name); // 输出: Alice console.log(age); // 输出: 25 }
function createEvalSandbox(code, globalObject) {
// 通过 eval 在指定的作用域中执行代码
eval(`(function(global) {
with(global) {
${code}
}
})(globalObject)`);
}
const sandboxGlobal = { name: "wifi" };
const code = `console.log(name);`;
createEvalSandbox(code, sandboxGlobal); // 输出: wifi
Function 构造器
和 eval() 一样,Function 构造器也可以在特定的作用域中执行代码。
function createSandbox(code, globalObject) {
const sandboxFunction = new Function(
"global",
`with(global) {
${code}
}`
);
sandboxFunction(globalObject);
}
// 示例
const sandboxGlobal = { name: "wifi" };
const code = `console.log(name);`;
createSandbox(code, sandboxGlobal); // 输出: wifi
使用 Object.create() 和 with
通过 Object.create() 创建一个新的对象,并将其作为沙箱的作用域,使用 with 语句使得代码在这个对象的上下文中运行。
function createSandboxWithObjectCreate(code, globalObject) {
const sandbox = Object.create(globalObject); // 创建一个新的对象,继承自 globalObject
with (sandbox) {
eval(code); // 使用 eval 执行代码,sandbox 作为作用域
}
}
// 示例
const sandboxGlobal = { foo: "bar" };
const code = `console.log(foo);`;
createSandboxWithObjectCreate(code, sandboxGlobal); // 输出: bar
2.2.6 qiankun 源码分析
我们先从应用注册方法 registerMicroApps 和 启动方法 start 开始分析。
registerMicroApps
这个方法主要是进行应用的注册,在 single-spa 的基础上进行了封装,内部调用的是single-spa 的 registerApplication 方法。
这里主要是 registerApplication 的 app 参数,这个 app 方法不会立即执行,等到路径匹配到后再去执行,其中 await frameworkStartedDefer.promise 需要等待这个 promise 完成后才执行后续内容,这个 promise 完成是在 start 中调用的 frameworkStartedDefer.resolve() 方法(这个 start 方法后续会说),还有个核心是其中的 loadApp 方法。
// Deferred:暴露 Promise 的 resolve 和 reject 方法,使得你可以在类外部调用 resolve() 或 reject() 来控制这个 Promise 的状态。
const frameworkStartedDefer = new Deferred<void>();
export function registerMicroApps<T extends ObjectType>(
apps: Array<RegistrableApp<T>>, // 本次要注册的应用
lifeCycles?: FrameworkLifeCycles<T> // 应用生命周期钩子
) {
// 维护一个注册队列,防止应用被重复注册,返回未注册的应用 name属性就是拿来区分的
const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));
// 最新需要注册的应用
microApps = [...microApps, ...unregisteredApps];
// 循环注册未注册的应用
unregisteredApps.forEach((app) => {
// appConfig 剩余配置 例如:{ entry: 'http://localhost:9000/index.html', container: '#subapp-viewport' }
// noop:空函数(lodash的)
const { name, activeRule, loader = noop, props, ...appConfig } = app;
// 基于 single-spa 的注册应用(路由劫持)
registerApplication({
name,
app: async () => {
loader(true);
// 等待调用 start 方法调用后,才执行下面部分
// 需要当前等待Promise调用完成,也就是等到后面执行qiankun的start方法时候,最后会将这个Promise调用resolve方法
await frameworkStartedDefer.promise;
const { mount, ...otherMicroAppConfigs } = (
await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
)();
// return 返回的是应用的接入协议
return {
mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
...otherMicroAppConfigs,
};
},
activeWhen: activeRule,
customProps: props,
});
});
// 目前不会执行,会等到路径匹配到后执行app方法
}
Deffer 类:
export class Deferred<T> {
promise: Promise<T>;
resolve!: (value: T | PromiseLike<T>) => void;
reject!: (reason?: any) => void;
constructor() {
this.promise = new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
}
loadApp
loadApp 主要做了下面这些事情:
importEntry:将 html 文件解析为 template, css, jsimportEntry的具体使用可以去看这个文章:juejin.cn/post/744509…- 根据样式隔离来生成对应的外层容器(分为两种:
css scoped和shadowDOM) - 创建 js 沙箱(
createSandboxContainer方法) - 在沙箱中执行 js 脚本(
execScripts方法) - 去“丰富“接入协议,提供给
single-spa
export async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>
): Promise<ParcelConfigObjectGetter> {
const { entry, name: appName } = app;
// 给应用实例添加名字 `${appName}_${globalAppInstanceMap[appName]}`
const appInstanceId = genAppInstanceIdByName(appName);
const markName = `[qiankun] App ${appInstanceId} Loading`;
const {
singular = false,
sandbox = true,
excludeAssetFilter,
globalContext = window,
...importEntryOpts
} = configuration;
// 获取html文件,拿到js的执行器
// execScripts 这个是执行真正的脚本逻辑
const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);
// trigger external scripts loading to make sure all assets are ready before execScripts calling
// 获取额外的外部脚本,以确保在调用execScripts之前所有外部js都已准备就绪
await getExternalScripts();
// 单例模式需要保证上一个应用卸载后才能加载新应用
if (await validateSingularMode(singular, app)) {
await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
}
// getDefaultTplWrapper:获取文件内容,对模版进行了处理(将 head 标签转为 qiankun-head)
/**
* 例如:
原内容:
<head>
<title>Vue App</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app"></div>
</body>
输出结果:
<div id="__qiankun_microapp_wrapper_for_vue__" data-name="vue" data-version="2.9.8" data-sandbox-cfg="{...}">
<qiankun-head>
<title>Vue App</title>
<link rel="stylesheet" href="style.css">
</qiankun-head>
<body>
<div id="app"></div>
</body>
</div>
*/
const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);
// shadowDom加载css方式
const strictStyleIsolation = typeof sandbox === "object" && !!sandbox.strictStyleIsolation;
// scoped作用域css的处理
const scopedCSS = isEnableScopedCSS(sandbox);
// 创建app的容器(创建div)
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId
);
// 将应用初始化在哪个容器中
const initialContainer = "container" in app ? app.container : undefined;
const legacyRender = "render" in app ? app.render : undefined;
const render = getRender(appInstanceId, appContent, legacyRender);
// 第一次加载设置应用可见区域 dom 结构
// 确保每次应用加载前容器 dom 结构已经设置完毕
render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, "loading");
// 拿到外层的html容器或者shadowRoot节点
const initialAppWrapperGetter = getAppWrapperGetter(
appInstanceId,
!!legacyRender,
strictStyleIsolation,
scopedCSS,
() => initialAppWrapperElement
);
// window
let global = globalContext;
let mountSandbox = () => Promise.resolve();
let unmountSandbox = () => Promise.resolve();
// 快照沙箱(true)
const useLooseSandbox = typeof sandbox === "object" && !!sandbox.loose;
// proxy沙箱
const speedySandbox = typeof sandbox === "object" ? sandbox.speedy !== false : true;
let sandboxContainer;
if (sandbox) {
// 创建沙箱容器
sandboxContainer = createSandboxContainer(
appInstanceId,
initialAppWrapperGetter,
scopedCSS,
useLooseSandbox,
excludeAssetFilter,
global,
speedySandbox
);
// 用沙箱的代理对象作为接下来使用的全局对象
/**
* (function(window) {
* ...
* })(sandbox.proxy)
*/
global = sandboxContainer.instance.proxy as typeof window;
// 沙箱的挂载和卸载
// mount:沙箱激活 unmount:沙箱失活
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}
// execScripts在沙箱中运行,默认在执行前,需要给global(子应用window,假window),扩展自定义属性
// 例如:global.__POWERED_BY_QIANKUN__ = true;等
const {
beforeUnmount = [],
afterUnmount = [],
afterMount = [],
beforeMount = [],
beforeLoad = [],
} = mergeWith({}, getAddOns(global, assetPublicPath), lifeCycles, (v1, v2) => concat(v1 ?? [], v2 ?? []));
// 这个方法用于将多个Promise合并为1个,通过then去链式调用,去依次执行
/**
* function() => [
async fn1() => {},
async fn2() => {},
async fn3() => {},
] 合并为 fn1.then(fn2).then(fn3) ...
* */
await execHooksChain(toArray(beforeLoad), app, global);
// 根据指定的沙箱环境去执行脚本,execScripts内部通过eval+with实现
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {
scopedGlobalVariables: speedySandbox ? cachedGlobals : [],
});
// 获取到应用导出的接入协议,可以使用了。可以获取window上最后新增的属性,这个属性就是拿到后的协议(因为是采用的UMD格式)
// window["App1Name"]
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global,
sandboxContainer?.instance?.latestSetProp
);
// 发布订阅
const { onGlobalStateChange, setGlobalState, offGlobalStateChange }: Record<string, CallableFunction> =
getMicroAppStateActions(appInstanceId);
// FIXME temporary way
const syncAppWrapperElement2Sandbox = (element: HTMLElement | null) => (initialAppWrapperElement = element);
const parcelConfigGetter: ParcelConfigObjectGetter = (remountContainer = initialContainer) => {
let appWrapperElement: HTMLElement | null;
let appWrapperGetter: ReturnType<typeof getAppWrapperGetter>;
const parcelConfig: ParcelConfigObject = {
name: appInstanceId,
bootstrap,
mount: [
async () => {
if (process.env.NODE_ENV === "development") {
const marks = performanceGetEntriesByName(markName, "mark");
// mark length is zero means the app is remounting
if (marks && !marks.length) {
performanceMark(markName);
}
}
},
async () => {
// 如果是单例模式,要保证之前应用的卸载
if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
return prevAppUnmountedDeferred.promise;
}
return undefined;
},
// 应用程序挂载/重新挂载前的初始包装元素(外层div容器 / shadowDOM)
async () => {
appWrapperElement = initialAppWrapperElement;
appWrapperGetter = getAppWrapperGetter(
appInstanceId,
!!legacyRender,
strictStyleIsolation,
scopedCSS,
() => appWrapperElement
);
},
// 添加 mount hook, 确保每次应用加载前容器 dom 结构已经设置完毕
async () => {
const useNewContainer = remountContainer !== initialContainer;
if (useNewContainer || !appWrapperElement) {
// element will be destroyed after unmounted, we need to recreate it if it not exist
// or we try to remount into a new container
appWrapperElement = createElement(appContent, strictStyleIsolation, scopedCSS, appInstanceId);
syncAppWrapperElement2Sandbox(appWrapperElement);
}
render({ element: appWrapperElement, loading: true, container: remountContainer }, "mounting");
},
// 挂载沙箱(激活),上面通过 sandboxContainer.mount; 赋值
mountSandbox,
// 执行beforeMount
async () => execHooksChain(toArray(beforeMount), app, global),
// 调用接入协议的mount方法
async (props) => mount({ ...props, container: appWrapperGetter(), setGlobalState, onGlobalStateChange }),
// finish loading after app mounted
async () => render({ element: appWrapperElement, loading: false, container: remountContainer }, "mounted"),
async () => execHooksChain(toArray(afterMount), app, global),
// initialize the unmount defer after app mounted and resolve the defer after it unmounted
async () => {
// 单例模式下,在外部可以调用Promise的resolve和reject
if (await validateSingularMode(singular, app)) {
prevAppUnmountedDeferred = new Deferred<void>();
}
},
async () => {
if (process.env.NODE_ENV === "development") {
const measureName = `[qiankun] App ${appInstanceId} Loading Consuming`;
performanceMeasure(measureName, markName);
}
},
],
unmount: [
async () => execHooksChain(toArray(beforeUnmount), app, global),
async (props) => unmount({ ...props, container: appWrapperGetter() }),
unmountSandbox,
async () => execHooksChain(toArray(afterUnmount), app, global),
async () => {
render({ element: null, loading: false, container: remountContainer }, "unmounted");
offGlobalStateChange(appInstanceId);
// for gc 卸载操作,垃圾回收
appWrapperElement = null;
syncAppWrapperElement2Sandbox(appWrapperElement);
},
async () => {
// 将promise设置为resolve,表示卸载操作已经完成
if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
prevAppUnmountedDeferred.resolve();
}
},
],
};
if (typeof update === "function") {
parcelConfig.update = update;
}
return parcelConfig;
};
return parcelConfigGetter;
}
其中创建沙箱:createSandboxContainer
先判断浏览器是否支持 Proxy, 不支持,创建 SnapshotSandbox 快照沙箱;支持的话,又需要区分是多例还是单例模式,去分别创建 ProxySandbox(多例) 和 LegacySandbox(单例)。然后返回 mount 和 unmount 方法,这两个方法和上面 【2.2.4 qiankun 沙箱】中说到的激活和失活是一致的。
export function createSandboxContainer(/** ...参数省略*/) {
let sandbox: SandBox;
if (window.Proxy) {
sandbox = useLooseSandbox
? new LegacySandbox(appName, globalContext)
: new ProxySandbox(appName, globalContext, { speedy: !!speedySandBox });
} else {
sandbox = new SnapshotSandbox(appName);
}
// ...省略
return {
instance: sandbox,
// 沙箱被 mount,可能是从 bootstrap 状态进入的 mount,也可能是从 unmount 之后再次唤醒进入 mount
async mount() {
sandbox.active();
// ...省略
},
//恢复 global 状态,使其能回到应用加载之前的状态
async unmount() {
// ...省略
sandbox.inactive();
},
};
}
丰富接入协议可以看下面这个图:
start
这个 start 方法内部也是采用的是 single-spa 的 start 方法。
export function start(opts: FrameworkConfiguration = {}) {
// start参数中新增 prefetch预加载,默认为true singular单例模式
frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
const { prefetch, urlRerouteOnly = defaultUrlRerouteOnly, ...importEntryOpts } = frameworkConfiguration;
// 预加载策略
if (prefetch) {
doPrefetchStrategy(microApps, prefetch, importEntryOpts);
}
// 对沙箱来做降级处理
frameworkConfiguration = autoDowngradeForLowVersionBrowser(frameworkConfiguration);
// 运行 single-spa 的 start 方法
startSingleSpa({ urlRerouteOnly });
started = true;
// 调用成功的promise => 也就是Promise.resolve()
frameworkStartedDefer.resolve();
}
可以看一下这个预加载的实现:预加载传参的方式有很多,根据不同情况做了处理
// 预加载
export function doPrefetchStrategy(
apps: AppMetadata[],
prefetchStrategy: PrefetchStrategy,
importEntryOpts?: ImportEntryOpts
) {
const appsName2Apps = (names: string[]): AppMetadata[] => apps.filter((app) => names.includes(app.name));
// prefetch: true
if (Array.isArray(prefetchStrategy)) {
// 数组写法 prefetch: []
prefetchAfterFirstMounted(appsName2Apps(prefetchStrategy as string[]), importEntryOpts);
} else if (isFunction(prefetchStrategy)) {
// 函数写法 prefetch: () => {return []}
(async () => {
const { criticalAppNames = [], minorAppsName = [] } = await prefetchStrategy(apps);
prefetchImmediately(appsName2Apps(criticalAppNames), importEntryOpts);
prefetchAfterFirstMounted(appsName2Apps(minorAppsName), importEntryOpts);
})();
} else {
// prefetch: false | 'all' | undefined
switch (prefetchStrategy) {
case true:
// 第一个应用加载完后,加载其他应用
prefetchAfterFirstMounted(apps, importEntryOpts);
break;
case "all":
prefetchImmediately(apps, importEntryOpts);
break;
default:
break;
}
}
}
他们会去执行一个函数:prefetchAfterFirstMounted,这个函数会监听 single-spa:first-mount 自定义事件,这个事件是在应用加载完成后派发的,然后再去加载未加载的应用。
方法中的 prefetch 采用的是 requestIdleCallback,这个方法会在浏览器空闲时去加载。
function prefetchAfterFirstMounted(apps: AppMetadata[], opts?: ImportEntryOpts): void {
// single-spa中默认内部会派发事件(dispatchEvent) single-spa:first-mounted,表示应用已经挂载完成
window.addEventListener("single-spa:first-mount", function listener() {
// 获取到所有未加载的应用
const notLoadedApps = apps.filter((app) => getAppStatus(app.name) === NOT_LOADED);
if (process.env.NODE_ENV === "development") {
const mountedApps = getMountedApps();
console.log(`[qiankun] prefetch starting after ${mountedApps} mounted...`, notLoadedApps);
}
// 将没有加载的应用依次去加载 prefetch:使用requestIdleCallback去预加载
notLoadedApps.forEach(({ entry }) => prefetch(entry, opts));
// 移除事件监听
window.removeEventListener("single-spa:first-mount", listener);
});
}
// prefetch方法
function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
// 如果网络不佳,不去加载
if (!navigator.onLine || isSlowNetwork) {
return;
}
requestIdleCallback(async () => {
// 通过 import-html-entry 这个插件,替代systemjs,会拉去入口的html文件,去解析出js和css
// 预加载入口文件
const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
// 获取额外的样式表和脚本
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});
}
2.2.7 qiankun 流程总结
- 注册微应用时通过 fetch 请求 HTML entry,然后正则匹配得到内部样式表、外部样式表、内部脚本、外部脚本
- 通过 fetch 获取外部样式表、外部脚本然后与内部样式表、内部脚本按照原来的顺序组合组合之前为样式添加属性选择器(data-微应用名称);将组合好的样式通过 style 标签添加到 head 中。(外部样式表也会被读取到内容后写入
<style>中) - 创建 js 沙盒:不支持 Proxy 的用 SnapshotSandbox(通过遍历 window 对象进行 diff 操作来激活和还原全局环境),支持 Proxy 且只需要单例的用 LegcySandbox(通过代理来明确哪些对象被修改和新增以便于卸载时还原环境),支持 Proxy 且需要同时存在多个微应用的用 ProxySandbox(创建了一个 window 的拷贝对象,对这个拷贝对象进行代理,所有的修改都不会在 rawWindow 上进行而是在这个拷贝对象上),最后将这个 proxy 对象挂到 window 上面
- 执行脚本:将上下文环境绑定到 proxy 对象上,然后 eval 执行
注册采用的是 single-spa start 启动也是 single-spa 的方法,相比之下,qiankun 新增了下面的功能:
- 预加载功能:利用
requestIdleCallback进行加载 - 沙箱功能:js 沙箱(创建 sandbox,让
execScript方法运行在 sandbox 中) 样式隔离(影子 DOM,scoped css) - 获取导出的接入协议(在沙箱中执行的),并进行扩展,然后放入 single-spa 的接入协议中
2.2.8 手写qiankun
这里主要是实现核心的
registerMicroApps和start两个方法
registerMicroApps:主要就是保存子应用
// 子应用注册接口
export interface IRegisterMicroApp {
name: string;
entry: string;
container: string;
activeRule: string;
props?: Record<string, any>;
bootstrap: () => Promise<void>;
mount: (props: Record<string, any>) => Promise<void>;
unmount: (props: Record<string, any>) => Promise<void>;
update?: (props: Record<string, any>) => Promise<void>;
}
let _app: IRegisterMicroApp[] = [];
export const getApps = () => _app;
export const registerMicroApps = (apps: IRegisterMicroApp[]) => {
_app = apps;
};
start:启动子应用
运行原理:
- 监听路由变化
- hash: window.onhashchange
- history: window.onpopstate
- history.go, history.back, history.forward 使用 popstate 事件监听
- 匹配子应用
- 加载子应用
- 渲染子应用
export const start = () => {
// 监听路由变化
rewriteRouter()
// 初始执行匹配 -> 处理路由变化
handleRouter()
};
- rewriteRouter:对路由进行劫持,并重写
let prevRoute = ""; // 上一个路由
let nextRoute = window.location.pathname; // 下一个路由(最新的路由)
export const getPrevRoute = () => prevRoute;
export const getNextRoute = () => nextRoute;
export const rewriteRouter = () => {
// - hash: window.onhashchange
// - history: window.onpopstate
// - history.go, history.back, history.forward 使用 popstate 事件监听
// - pushState, replaceState 需要通过函数重写的方式进行劫持
window.addEventListener("popstate", () => {
// popstate触发时,路由已经跳转完成了
prevRoute = nextRoute; // 之前的
nextRoute = window.location.pathname; // 最新的
// 处理路由变化
handleRouter();
});
// 保存一份
const rawPushState = window.history.pushState;
window.history.pushState = (...args) => {
// 路由跳转前
prevRoute = window.location.pathname;
// 进行路由跳转
rawPushState.apply(window.history, args);
// 路由跳转后
nextRoute = window.location.pathname;
handleRouter();
};
const rawReplaceState = window.history.replaceState;
window.history.replaceState = (...args) => {
prevRoute = window.location.pathname;
rawReplaceState.apply(window.history, args);
nextRoute = window.location.pathname;
handleRouter();
};
};
- handleRouter:处理路由变化,对子应用进行挂载的
import { getApps } from "..";
import { importHtml } from "../import-html";
import type { IRegisterMicroApp } from "../type";
import { getNextRoute, getPrevRoute } from "./rewrite-router";
// 处理路由变化
export const handleRouter = async () => {
const apps = getApps();
// 获取当一个路由
const prevApp = apps.find((app) => {
if (app?.activeRule === getPrevRoute()) {
return null
}
return getPrevRoute().startsWith(app.activeRule)
});
// 2. 匹配子应用(获取当前路由)
const app = apps.find((app) => {
if (app?.activeRule === getPrevRoute()) {
return null
}
// return window.location.pathname.startsWith(app.activeRule)
return getNextRoute().startsWith(app.activeRule);
});
// 如果有上一个应用,先销毁
if (prevApp) {
await unmount(prevApp);
}
if (!app) {
return;
}
// 3. 加载子应用: 请求获取子应用资源(html、css、js)
// const html = await fetch(app.entry).then(res => res.text())
const { template, execScripts } = await importHtml(app.entry);
const container = document.querySelector(app.container);
/**
* 子应用不会渲染:
* 因为vue、react都是通过js来渲染页面的,而innerHTML中的script,会直接被浏览器忽略掉,不会加载执行
*/
// container!.innerHTML = html
container?.appendChild(template);
// 新增环境变量
window.__POWERED_BY_QIANKUN__ = true;
window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = app.entry + "/";
// 所以我们需要手动去执行js代码,可以通过 eval()、new Function() 来实现
const appExports = await execScripts();
app.bootstrap = appExports.bootstrap;
app.mount = appExports.mount;
app.unmount = appExports.unmount;
app.update = appExports.update;
await bootstrap(app);
await mount(app);
// 4. 渲染子应用
};
declare global {
interface Window {
__POWERED_BY_QIANKUN__: boolean;
__INJECTED_PUBLIC_PATH_BY_QIANKUN__: string;
}
}
async function bootstrap(app: IRegisterMicroApp) {
app.bootstrap && (await app.bootstrap());
}
async function mount(app: IRegisterMicroApp) {
app.mount &&
(await app.mount({
container: document.querySelector(app.container),
}));
}
async function unmount(app: IRegisterMicroApp) {
app.unmount &&
(await app.unmount({
container: document.querySelector(app.container),
}));
}
在 handleRouter 使用到的 importHtml 方法,主要是用来获取到子应用的html,然后将对应的js内容进行隔离,去单独获取运行
// 子应用导出生命周期
export interface IAppLifeCycles {
bootstrap: () => Promise<void>;
mount: (props: Record<string, any>) => Promise<void>;
unmount: (props: Record<string, any>) => Promise<void>;
update?: (props: Record<string, any>) => Promise<void>;
}
/**
* 将html隔离为 html、css、js
*/
export const importHtml = async (url: string) => {
const html = await fetch(url).then((res) => res.text());
const template = document.createElement("div");
// template.innerHTML = '<p>hello sub app</p>'
template.innerHTML = html;
const scripts = template.querySelectorAll("script");
// 获取所有js代码
const getExternalScripts = () => {
return Promise.all(
Array.from(scripts).map((script) => {
const src = script.getAttribute("src");
/**
* script分为:
* 1. 内联代码 <script>...</script>
* 2. 外部代码 <script src="..."></script>
*/
if (src) {
const scriptUrl = src.startsWith("http") ? src : url + src;
return fetch(scriptUrl).then((res) => res.text());
} else {
return Promise.resolve(script.innerHTML);
}
})
);
};
// 获取并执行js代码
const execScripts = async (): Promise<IAppLifeCycles> => {
const scripts = await getExternalScripts();
// 手动构造一个commonjs环境,便于从umd产物中获取内容(module.exports = factory(); factory就是导出的内容)
const module = { exports: {} };
// @ts-ignore
const exports = module.exports; // 没有使用也需要写(具体可以参考umd打包产物)
scripts.forEach((code) => {
eval(code);
});
// 子应用通过umd进行打包构建,但是通过获取到子应用window中的名字太麻烦
// return (window as any)['webpack-vue']
// 可以参考 my-qiankun/umd.js
// 我们可以构造一个commonjs环境,让他以commonjs方式获取,不从window拿(写在上面)
type SubAppExports = IAppLifeCycles & {
[key: string]: any;
};
return module.exports as SubAppExports;
};
return {
template,
getExternalScripts,
execScripts,
};
};
这里需要注意的是umd格式在浏览器是通过
window['xxx']可以获取到对应的内容,但是传入对应的xxx名字很麻烦,可以去构造一个commonjs的运行环境,可以就能更方便的获取到子应用导出的生命周期了。/** * 可以参考umd打包的产物 */ (function webpackUniversalModuleDefinition(root, factory) { // root:window factory:function() { return {...} } // commonjs代码 if (typeof exports === "object" && typeof module === "object") module.exports = factory(); // 兼容amd规范 else if (typeof define === "function" && define.amd) define([], factory); // commonjs else if (typeof exports === "object") exports["Demo"] = factory(); // window['xxx'] else root["Demo"] = factory(); })(window, function () { // 内部的代码 // 最后返回导出的结果 return { a: 1, b: 2, }; });
3. Web Components
3.1 原生 Web Components
原生的 web components 最大好处在于样式完全隔离,上面讲述的无界也是因为样式隔离选择的此方案,但是这个方案也有很多缺点:
- 兼容性:浏览器兼容性差(但主流浏览器都支持)
- 组件间通信复杂::Web Components 的隔离性导致父子应用或兄弟组件间的通信需依赖自定义事件或全局状态管理,增加了复杂度
- 调试困难
在无界部分讲述了 web components 的使用,这里不过多赘述
3.2 Micro App
MicroApp 是京东开源的一款微前端框架,通过 web components + js 沙箱实现(Proxy 沙箱)。
MicroApp 的优点是接入成本低,只需要子应用支持跨域即可。缺点是:兼容性不好,没有降级方案。
MicroApp 的实现原理是:
- 创建 Web Components 组件
- 通过
HTML Entry获取到 html,将模版放到 webcomponent 中 - css 做作用域隔离,js 做 Proxy 沙箱
(function (window, code) { new Function( "window", `with(window) { ${code} }` )(window); // 传入进去 proxyWindow,对应 new Function 的第一个参数 "window" })(proxyWindow, code); - 执行完毕后,应用正常挂载
4. Module Federation:去中心化方案
Module Federation(以下简称 MF) 是 Webpack5 的新特性,借助于 MF, 我们可以在多个独立构建的应用之间,动态地调用彼此的模块。这种运行机制可以轻松地拆分应用,真正做到跨应用的模块共享。
去中心化:没有“基座”概念,任何一个容器可以是远程(remote)也可以是主机(host)。
缺点:没有沙箱机制,无法做到隔离。
- remote 共享模块
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = (env = {}) => ({
// ...
plugins: [
new ModuleFederationPlugin({
/**
* host应用
* remotes: {
* '@app': 'remote-app1@http://localhost:3001/remoteEntry.js'
* }
*/
// 当前应用的名称,需要全局唯一,也是全局变量名,给 host 拼接 url 使用的
name: "remote-app1",
// host应用 引入的文件名(http://localhost:3001后面的文件名)
filename: "remoteEntry.js",
library: { type: 'var', name: 'remote-app1' }, // 共享模块的全局引用
exposes: { // 导出的模块,只有在此申明的模块才可以作为远程依赖被使用
"./Button": "./src/components/Button",
},
shared: ['vue'] // 远程加载的模块对应的依赖使用本地项目的依赖
}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, "./public/index.html"),
})
],
devServer: {
port: 3001,
}
});
- host 应用
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = (env = {}) => ({
// ...
plugins: [
new ModuleFederationPlugin({
name: 'app2',
filename: "remoteEntry.js",
remotes: { // 引入远程应用的导出的模块, name@host address/filename.
'@app': 'remote-app1@http://localhost:3001/remoteEntry.js'
},
shared: ['vue'] // 抽离的依赖与其它应用保持一致
}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, "./public/index.html"),
})
],
});
在
shared中定义公共依赖库,可以避免公共依赖重复加载
有一个库 EMP 也是采用的 Module Federation,这个库自己实现了一个 cli,可以快速搭建微前端项目,cli 帮忙简化了webpack的配置文件。
关于 Module Federation 的更多介绍,可以参考: