本文中主应用采用vue2构建,微应用采用vue2vue3react构建。其中主应用和微应用都采用webpack打包,并且主应用路由模式选择了History(该模式需要服务端支持),微应用路由模式采用HistoryHash

如果在项目采用vite构建的前提下需要使用qiankun,请查看文档《微前端qiankun接入Vite构建的子应用》

如果主应用路由模式需要使用hash,请查看文档《微前端(qiankun)Hash路由实践》


创建主应用

1.安装qiankun

1
`yarn add qiankun` 或者 `npm i qiankun -S`

2. 注册微应用

在入口文件main.js中注册所有微应用并启动qiankun

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import { registerMicroApps, start, addGlobalUncaughtErrorHandler, initGlobalState } from 'qiankun'

// 1. 定义所有微应用
const apps = [
{
// 必须与子应用注册名字相同
name: 'micro-app-vue2',
// 入口路径,开发时为子应用所启本地服务,上线时为子应用线上路径
entry: '//localhost:3003',
// 子应用挂载的节点
container: '#micro-app-vue2',
// 当访问路由为 /micro-app-vue2 时加载子应用
activeRule: '/micro-app-vue2',
// 主应用向子应用传递参数
props: {
msg: "我是来自主应用的值"
}
},
{
name: 'micro-app-vue3',
entry: '//localhost:3001',
container: '#micro-app-vue3',
activeRule: '/micro-app-vue3'
},
{
name: 'micro-app-react',
entry: '//localhost:4001',
container: '#micro-app-react',
activeRule: '/micro-app-react'
}
]

// 2. 微应用生命周期
// qiankun暴露了五个生命周期钩子:beforeLoad、beforeMount、afterMount 、beforeUnmount和afterUnmount,这五个钩子可以在主应用中注册子应用时使用。
const microAppLifCycles = {
beforeLoad: [ // 全局的微应用生命周期钩子,子应用加载前
app => {
console.log('子应用加载-beforeLoad', app)
return Promise.resolve()
}
],
beforeMount: [ // 全局的微应用生命周期钩子,子应用挂载前
app => {
console.log('2-beforeMount', app)
return Promise.resolve()
}
],
afterMount: [ // 全局的微应用生命周期钩子,子应用挂载后
app => {
console.log('3-afterMount', app)
return Promise.resolve()
}
],
beforeUnmount: [
app => {
console.log('4-beforeUnmount', app)
}
]
}

// 3. 注册微应用的基础配置信息(第二个参数可选)
registerMicroApps(apps, microAppLifCycles)

// 4. 添加全局的未捕获异常处理器
addGlobalUncaughtErrorHandler((event) => {
const { message: msg } = event
if (msg && msg.includes('died in status LOADING_SOURCE_CODE')) {
console.error('微应用加载失败,请检查应用是否可运行', event)
}
})

// 5. 启动微应用
start({
prefetch: true, // 是否开启预加载
sandbox: {
experimentalStyleIsolation: true // 实验性的样式隔离
}
})

关于预加载微应用prefetch的属性说明:

(1)配置为 true 则会在第一个微应用 mount 完成后开始预加载其他微应用的静态资源

(2)配置为 all 则主应用 start 后即开始预加载所有微应用静态资源

(3)配置为 string[] 则会在第一个微应用 mounted 后开始加载数组内的微应用资源

(4)配置为 function 则可完全自定义应用的资源加载时机 (首屏应用及次屏应用)

当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑,所有 activeRule 规则匹配上的微应用就会被挂载到指定的 container 中,同时依次调用微应用暴露出的生命周期钩子。

3.创建微应用SFC

每个微应用使用一个对应的单文件组件进行挂载,并在mounted中进行判断如果未开启qiankun则进行开启。

该处以microAppVue2组件为例,其它组件只需要修改id和组件name。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template>
<!-- 这个id需要和注册微应用中的container的值对应 -->
<div id="micro-app-vue2"></div>
</template>

<script>
import { start } from 'qiankun'
export default {
name: 'microAppVue2',
components: {},
mounted () {
if (!window.qiankunStarted) {
window.qiankunStarted = true
start({
prefetch: true, // 是否开启预加载
sandbox: {
experimentalStyleIsolation: true // 实验性的样式隔离
}
})
}
}
}
</script>

4.路由配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)

const routes = [
{
// 这个path需要和注册微应用中的activeRule的值对应
path: '/micro-app-vue2',
component: () => import('@/views/microAppVue2/index.vue')
},
{
path: '/micro-app-vue3',
component: () => import('@/views/microAppVue3/index.vue')
},
{
path: '/micro-app-react',
component: () => import('@/views/microAppReact/index.vue')
}
]

const router = new VueRouter({
// 选择history模式时,需要设置base,这样在访问路由时会自动带上项目的基路径
mode: 'history',
base: process.env.BASE_URL,
routes
})

export default router

5.主应用代理微应用的接口地址

在使用 qiankun 微前端框架时,主应用可以通过配置代理来转发子应用的接口请求。这样可以解决跨域问题和统一管理接口访问。

首先,在主应用的配置文件中(例如 webpack.config.js)添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const { createProxyMiddleware } = require('http-proxy-middleware');

module.exports = {
// ...其他配置

devServer: {
// ...其他 devServer 配置项

// 添加代理配置
proxy: {
'/api': {
target: 'http://子应用的接口地址',
changeOrigin: true,
pathRewrite: {
'^/api': '', // 如果子应用接口有基础路径,需要进行重写
},
},
},
},
};

上述代码中,将 /api 路径下的请求转发到了子应用的接口地址,并进行了域名跨域处理。你可以根据实际情况修改目标地址和路径重写规则。

然后,在微应用中调用接口时,使用相对于主应用的 /api 路径作为前缀,例如:

1
2
3
4
fetch('/api/users')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));

这样就能将请求发送给子应用的接口地址,并成功获取响应数据。

注意:以上示例是基于 webpack-dev-server 进行配置的,在生产环境部署时需要根据实际情况配置代理服务器。


创建微应用

vue2vue3react的配置由于框架不同而在代码上有些微区别(比如vue2创建实例是new vuevue3createApp),但整体qiankun的配置步骤和配置内容相同。此处不做区分,以下仅以vue2为例:

1.修改配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// vue.config.js

// 1. package.json中name的值必须和主应用中注册微应用的name的值对应
const packageName = require('./package.json').name;
module.exports = {
configureWebpack: {
// 2. 打包方式改造
output: {
library: packageName,
// 这里设置为umd意思是在 AMD 或 CommonJS 的 require 之后可访问。
libraryTarget: "umd",
// webpack用来异步加载chunk的JSONP 函数。
jsonpFunction: `webpackJsonp_${packageName}`,
},
},
devServer: {
port: "3003",
disableHostCheck: true, // 关闭主机检查,使微应用可以被 fetch
headers: {
// 3. 因为qiankun内部请求都是fetch来请求资源,所以子应用必须允许跨域 "Access-Control-Allow-Origin": "*",
},
}
};

为什么 qiankun 要求子应用打包为 umd 库格式呢?

主要是为了主应用能够拿到子应用在 入口文件 导出的 生命钩子函数,这也是主应用和子应用之间通信的关键。

umd全称是UniversalModuleDefinition,是一种通用模块定义格式,通常用在前端模块化开发中。

由于不同的模块化规范定义不同,为了让各种规范的模块可以通用,在不同的环境下都可以正常运行,就出现了umd这个通用格式。

umd 格式是一种既可以在浏览器环境下使用,也可以在 node 环境下使用的格式。它将 CommonJSAMD以及普通的全局定义模块三种模块模式进行了整合。

2.修改publicPath

src目录下新建public-path.js,用于处理打包后静态资源的路径问题。内容如下:

1
2
3
4
if(window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

3.路由配置

1
2
3
4
5
6
7
8
9
10
11
12
13
import Vue from 'vue'
import Router from 'vue-router'
import HelloWorld from '@/components/HelloWorld'

Vue.use(Router)

export default new Router({
// 1. 设置history模式
mode: 'history',
// 2. 设置base,值和主应用中的activeRule保持一致
base: window.__POWERED_BY_QIANKUN__ ? "/micro-app-vue2" : "/",
routes
})

当使用vue-router v4.x时,是通过createRouter的方式实例化router,设置histroybase通过如下方式:

1
2
3
4
5
// 1. Hash路由设置方法,hash不需要设置base
history: createWebHashHistory(),

// 2. 设置 History 路由时必须添加base
// history: createWebHistory(qiankunWindow.__POWERED_BY_QIANKUN__ ? '/micro-app-vue3-vite' : '/'),

当微应用为History时,主应用的router/index.js中必须多添加一项路由配置:

1
2
3
4
5
6
7
8
9
// ...
{
hidden: true,
name: 'microAppVue2',
// 匹配微应用的路由
path: '/micro-app-vue2/*',
component: () => import('@/views/microAppVue2/index.vue')
},
// ...

4.入口文件修改

修改入口文件main.js,修改内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 1. 顶部引入public-path
import './public-path'
import Vue from 'vue'
import App from './App'
import router from './router'

Vue.config.productionTip = false

// 2. 定义一个Vue实例
let instance = null

// 3. 渲染方法
function render(props = {}) {
const { container } = props
instance = new Vue({
router,
render: (h) => h(App)
}).$mount(container ? container.querySelector('#app') : '#app')
}

// 4. 判断是否在qiankun环境下,非qiankun环境下独立运行
if (!window.__POWERED_BY_QIANKUN__) {
render()
}

// 5. 暴露qiankun生命周期钩子
export async function bootstrap() {}
export async function mount(props) {
render(props);
}
export async function unmount() {
instance.$destroy();
instance.$el.innerHTML = '';
instance = null;
}

项目在迁移成子应用时,需要在入口文件配合qiankun来做一些改动,而这些改动有可能影响子应用的独立运行。为了解决子应用也能独立运行的问题,qiankun注入了一个变量:window.__POWERED_BY_QIANKUN__,这样就可以判断当前应用是否在独立运行。

但是变量需要在运行时动态的注入,那么该变量设置的位置就需要考虑清楚,qiankun选择在single-spa提供的生命周期前进行变量的注入,在beforeLoadbeforeMount中把变量置为true,在beforeUnmount中把变量置为false

single-spa一样,qiankun子应用的接入必须暴露三个生命周期:

(1)Bootstrap:只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。

(2)Mount:应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法。

(3)Unmount:应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例。


主应用和微应用通信

1.主应用中注册全局状态

在入口文件main.js中定义并监听全局状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { initGlobalState } from 'qiankun'

// 定义全局状态,并返回通信方法,建议在主应用使用,微应用通过 props 获取通信方法
const { onGlobalStateChange, setGlobalState } = initGlobalState({
user: 'qiankun'
})

// 在当前应用监听全局状态,有变更触发 callback
onGlobalStateChange((value, prev) => console.log('[onGlobalStateChange - main]:', value + prev))

// 按一级属性设置全局状态,微应用中只能修改已存在的一级属性
setGlobalState({
ignore: 'master',
user: {
name: 'master'
}
})

2.微应用中监听并修改

在入口文件main.js中添加如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 微应用通过 props 获取通信方法
function storeTest(props) {
props.onGlobalStateChange &&
props.onGlobalStateChange(
(value, prev) => console.log(`[onGlobalStateChange - ${props.name}]:`, value, prev),
true,
);
props.setGlobalState &&
props.setGlobalState({
ignore: props.name,
user: {
name: props.name,
},
});
}

export async function mount(props) {
storeTest(props);
// 挂载到子应用,方便使用
instance.config.globalProperties.$onGlobalStateChange = props.onGlobalStateChange;
instance.config.globalProperties.$setGlobalState = props.setGlobalState;
}

以上通信过程如下:

(1)主应用里面先initGlobalState

(2)主应用监听onGlobalStateChange

(3)主应用去修改setGlobalState

  (3.1)主应用监听到并执行回调

​   (3.2)子应用监听到并执行回调

(4)子应用监听onGlobalStateChange

(5)子应用去修改setGlobalState

​   (5.1)主应用监听到并执行回调

​   (5.2)子应用监听到并执行回调


qiankun 功能拓展

1.如何封装主应用和微应用中的共同的组件

在微前端架构中,主应用和子应用可能会共享一些组件,为了实现组件的共享和封装,可以采用以下方法:

(1)封装为独立的 npm 包:将共享的组件封装为独立的 npm 包,并发布到私有或公共的 npm 仓库中。主应用和子应用都可以通过 npm 安装该组件,并在需要的地方引入和使用。

(2)Git 仓库依赖:将共享组件放置在一个独立的 Git 仓库中,并通过 Git 仓库的依赖关系来引入组件。主应用和子应用可以通过 Git 仓库的 URL 或路径来引入共享组件。

(3)Git Submodule:如果主应用和子应用都在同一个 Git 仓库下,可以使用 Git Submodule 的方式来引入共享组件。将共享组件作为子模块添加到主应用和子应用的仓库中。

(4)本地引用:如果主应用和子应用处于同一个代码仓库中,可以直接通过相对路径引入共享组件。将共享组件放置在一个独立的目录下,并通过相对路径引用。

(5)将公共依赖打包为umd格式,并在qiankun的global对象上注册,使得其他子应用可以通过global对象来访问公共依赖。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. 在主应用中注册公共依赖
import React from 'react';
import ReactDOM from 'react-dom';
import MyComponent from 'my-component';

window.MyComponent = MyComponent;

// 2. 在子应用中使用公共依赖
import React from 'react';
import ReactDOM from 'react-dom';
import { useEffect } from 'react';

const { MyComponent } = window;

无论选择哪种方式,关键是要保持共享组件的独立性和可维护性。确保共享组件的代码和样式与具体的主应用和子应用解耦,避免出现冲突和依赖混乱的情况。同时,建议对共享组件进行版本管理,以便在更新和维护时能够更好地控制和追踪变更。

在微前端架构中,可以通过合适的方式引入共享组件,使主应用和子应用可以共享和复用组件,提高开发效率和代码质量。

2.子应用调用其他子应用组件

(1)vue子应用调用React子应用中的组件

可以先在主应用中通过 registerMicroApps 方法注册好所有的子应用,并在主应用中管理子应用之间的通信。然后,可以在需要使用其他子应用组件的子应用中通过 loadMicroApp 方法异步加载对应的子应用并获取到对应子应用的实例,从而使用其提供的组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { loadMicroApp } from 'qiankun';

const loadReactApp = () => loadMicroApp({
name: 'reactApp',
entry: '//localhost:8082',
container: '#react',
activeRule: '/react',
});

// 在需要使用 ReactApp 中的组件的 VueApp 组件中异步加载 ReactApp,并使用其提供的组件
const VueApp = {
template: `
<div>
<h1>VueApp</h1>
<react-component />
</div>
`,
mounted() {
// 异步加载ReactApp并获取其实例,然后通过getComponent方法获取到了ReactApp中提供的ReactComponent组件,并将其转换为Vue组件供VueApp使用
loadReactApp().then(app => {
const ReactComponent = app.getComponent('ReactComponent');
Vue.component('react-component', {
template: `<div><ReactComponent /></div>`,
});
});
},
};

(2)React子应用中调用Vue子应用的组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { loadMicroApp, getGlobalState } from 'qiankun';

function App() {
const [vueComponent, setVueComponent] = useState(null);

useEffect(() => {
// React子应用中,加载Vue子应用并获取其组件
const vueApp = loadMicroApp({
name: 'vue3',
entry: '//localhost:8081',
container: '#vue3',
activeRule: '/vue3',
props: { name: 'vue-app' }
});

vueApp.onGlobalStateChange((state, prev) => {
console.log('[React] Vue global state changed: ', state);
});

vueApp
.loadPromise.then(() => {
const vueInstance = vueApp.bootstrap();
setVueComponent(vueInstance.$children[0]);
});

return () => vueApp.unmount();
}, []);

return (
<div>
<h1>React App</h1>
<hr />
<h2>Vue App Component:</h2>
{vueComponent && (
<div>
<p>{vueComponent.message}</p>
<button onClick={() => vueComponent.handleClick()}>Click me!</button>
</div>
)}
</div>
);
}

export default App;

(3)传递参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import React from 'react';
import { render, hydrate } from 'react-dom';
import { registerMicroApps, start } from 'qiankun';
import App from './App';

// 注册子应用
registerMicroApps([...]);

// 启动 qiankun
start();

// 在 App 组件中调用其他子应用的组件
function App() {
return (
<div>
{/* 调用 Vue 子应用的组件 */}
<div>
{/* 在调用时,可以将需要传递的参数放在props对象中,然后作为第二个参数传递给render方法 */}
{render('vue3', { name: 'Tom' }, { container: '#vue-container' })}
</div>
</div>
);
}

// 渲染根组件
hydrate(<App />, document.getElementById('root'));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div>
<h2>Vue Component</h2>
<p>Hello, {{ name }}!</p>
</div>
</template>


<script>
export default {
name: 'VueComponent',
// 直接使用
props: {
name: {
type: String,
required: true,
},
},
};
</script>

3. CSS 沙箱

css隔离主要分为两种,一种是父子之间的隔离,另一种是子子之间的隔离。子应用之间的隔离,qiankun中并没有特别的提出,本质上就是在子应用加载时把其相应的样式加载进来,在卸载时进行移除即可。而父子之间的隔离在qiankun种有两种实现方法。

(1)strictStyleIsolation: Shadow DOM

第一种是严格样式隔离,核心是Shadow DOM。它可以让一个dom拥有自己的“影子”DOM树,这个DOM树不能在主文档中被任意访问,可以拥有局部样式规则,天然实现了样式隔离,如上图所示,被代理的dom节点称为shadow hostshadow tree中的根节点称为shadow root

比如我们常用的<video>标签,一个标签就实现了一个简易的播放器,但其实它是由一些看不到的dom封装而成的,这里就是使用了shadow DOM

现在先来模拟一下父子的样式污染问题,在下面的demo中子应用的样式设置成所有字体颜色为红色,使得父元素和子元素所有的文字都为红色。

1
2
3
4
5
6
7
8
9
10
11
12
13
<div>
<h5>父元素</h5>
</div>

<div id="app1">
<style>
*{
color:red;
}
</style>
<h5>子元素</h5>
<p class="title">一行文字</p>
</div>

使用严格样式隔离解决一下这个问题:获取到子应用的根节点,然后打开影子模式,把原来的dom结构赋值到代理的影子根节点中,然后清空原来的dom结构。

1
2
3
4
5
6
7
function openShadow(domNode) {
var shadow = domNode.attachShadow({ mode: "open" });
shadow.innerHTML = domNode.innerHTML;
domNode.innerHTML = "";
}
var bodyNode = document.getElementById("app1");
openShadow(bodyNode);

(2)experimentalStyleIsolation

第二种父子样式隔离是实验性样式隔离 ,即通过运行时修改CSS选择器来实现子应用间的样式隔离。

下面也是一个模拟污染的demo,可以看到主应用和子应用有重名的选择器,子应用在后面,所以父样式被覆盖,造成了污染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<head>
<style>
p.title {
color:red;
}
</style>
</head>

<body>
<p class="title">父应用</p>
<div id="data-qiankun-A">
<style>
p.title {
color:blue;
}
</style>
<p class="title">子应用</p>
</div>
</body>

这里首先获取到子应用,然后通过正则匹配其中的所有<style>标签,给每一个标签加上前缀,从而缩小其样式应用的范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function scopeCss(styleNode, prefix) {
const css = ruleStyle(styleNode.sheet.cssRules[0], prefix);
styleNode.textContent = css;
}
function ruleStyle(rule, prefix) {
const rootSelectorRE = /((?:[^\w\-.#]|^)(body|html|:root))/gm;
let { cssText } = rule;
// 绑定选择器, a,span,p,div { ... }
cssText = cssText.replace(/^[\s\S]+{/, (selectors) =>
selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {
// 绑定 div,body,span { ... }
if (rootSelectorRE.test(item)) {
return item.replace(rootSelectorRE, (m) => {
// 不要丢失有效字符 如 body,html or *:not(:root)
const whitePrevChars = [",", "("];
if (m & amp;& amp; whitePrevChars.includes(m[0])) {
return `${m[0]}${prefix}`;
}
// 用前缀替换根选择器
return prefix;
});
}
return `${p}${prefix} ${s.replace(/^ */, "")}`;
})
);
return cssText;
}
var container = document.getElementById("data-qiankun-A");
var styleNode = container.getElementsByTagName("style")[0];
scopeCss(styleNode, "#data-qiankun-A")

效果如下图所示,可以看到子应用的<style>标签中的选择器都加上了前缀,使父应用的颜色保持原有的红色,子应用的颜色是新设置的蓝色。

4. JS 沙箱

js隔离是另一个在微前端中需要关注的问题,qiankun中有三种js隔离的做法。

(1)SnapshotSandbox

第一种是快照沙箱,先来看一下具体demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let sandbox = new SnapshotSandbox();
var a = '主应用A';
var c = '主应用C';
console.log('主应用原来的Window:', a, c);
function beforeMounted() {
sandbox.active();
console.log("加载子应用前");
}
function beforeUnMounted() {
sandbox.inactive();
console.log("卸载子应用前");
}
function app1() {
beforeMounted();
window.a = 'app1A';//修改
window.c = null;//删除
window.d = 'app1D';//新增
console.log("子应用的Window:", window.a, window.c, window.d);
beforeUnMounted();
}
app1();
console.log('主应用现在的Window:', a, c, d);

主应用中声明两个变量a和c,分别赋值主应用A和主应用C,然后加载子应用之后对全局变量ac进行修改,并且新增d,最后卸载时再打印acd,可以在左图看到主应用的变量被污染了。

这时候开启沙箱再运行一遍,可以在右图看到主应用被恢复回来了,解决了变量污染的问题。

沙箱快照的核心思想如下:在子应用挂在前对当前主应用的全局变量保存,然后恢复之前的子应用环境,在子应用运行期间则正常get和set,在卸载时保存当前变量恢复主应用变量,整个过程类似于中断和中断恢复。

具体代码可参考这个demo,但这里也有一个比较明显的缺点就是每次切换时需要去遍历window,这种做法会有较大的时间消耗。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class SnapshotSandbox {
() {
this.proxy = window; //window属性
this.modifyPropsMap = {}; //记录在window上的修改
}
active() {//激活
this.windowSnapshot = {}; //快照
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
this.windowSnapshot[prop] = window[prop]
}
Object.keys(this.modifyPropsMap).forEach(p => {
window[p] = this.modifyPropsMap[p]
})
}
}
inactive() {//卸载
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
if (window[prop] != this.windowSnapshot[prop]) {
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop];
}
}
}
}
}

(2)legacySandBox

第二种则是legacy沙箱,下面的demo比上一个稍微复杂一点。主要是加载了两次子应用,并且每次改变的变量值不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
let { sandbox, fakeWindow } = new legacySandBox();
var a = '主应用A';
var c = '主应用C';

console.log('主应用原来的Window:', a, c);
function beforeMounted() {
sandbox.active();
console.log("加载子应用前");
}
function beforeUnMounted() {
sandbox.inactive();
console.log("卸载子应用前");
}
function app1(win = window) {//这里使用了fakeWindow作为window
beforeMounted();
if (win.a === 'app1A') {
win.a = 'app1A-2';
win.c = '2';
win.d = 'app1D-2';
console.log("子应用第二次加载Window:",
win.a, win.c, win.d);
} else {
win.a = 'app1A';//修改
win.c = null;//删除
win.d = 'app1D';//新增
console.log("子应用第一次加载Window:",
win.a, win.c, win.d);
}
beforeUnMounted();
}
app1(fakeWindow); console.log('主应用现在的1Window:', a, c, d);
app1(fakeWindow); console.log('主应用现在的2Window:', a, c, d);

左图显示的是主应用被污染的结果,右图是打开沙箱之后解决污染的结果。

legacy沙箱的主要原理是使用了ES6中的Proxy,把原来的window代理到fakeWindow上,这样就不用遍历整个window去应用和恢复环境了。除此之外,它还在沙箱内部设置了三个变量池:addedPropsMapinSandbox用于存放子应用运行期间新增的全局变量,用于在卸载子应用的时候删除;modifiedPropsOrginalMapInSandbox用于存放子应用运行期间修改的全局变量,用于卸载时进行恢复;currentUpdatedPropsValueMap用于存放子应用运行期间所有变化的变量,这样可以在加载子应用时恢复其上一次的环境。

下面是具体实现的沙箱demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class legacySandBox {
() {
this.addedPropsMapInSandbox = new Map();//记录子应用运行期间新增的key
this.modifiedPropsOriginalValueMapInSandbox = new Map();//记录子应用运行期间修改的key
this.currentUpdatedPropsValueMap = new Map();//记录子应用运行期间的值
this.sandboxRunning = false;
const _this = this;
const fakeWindow = new Proxy(window, {
set(_, p, value) {
if (_this.sandboxRunning) {
if (!window.hasOwnProperty(p)) {
_this.addedPropsMapInSandbox.set(p, value);
} else if (!_this.modifiedPropsOriginalValueMapInSandbox.has(p)) {
const originalValue = window[p];
_this.modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
_this.currentUpdatedPropsValueMap.set(p, value);
window[p] = value;
return true;
}
return true;
},
get(_, p) {
if (p === "top" || p === "window" || p === "self") {
return proxy;
}
return window[p];
}
})
return { sandbox: this, fakeWindow };
}
active() {//激活
if (!this.sandboxRunning) {
this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
}
this.sandboxRunning = true;
}
inactive() {//卸载
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
this.addedPropsMapInSandbox.forEach((v, p) => this.setWindowProp(p, undefined, true));
this.sandboxRunning = false;
}
setWindowProp(p, v) {
window[p] = v;
}
}

(3)ProxySandbox

第二种沙箱的实现对于单例模式来说已经比较完善了,但是不适用于多例模式,即同时有多个子应用在运行期间的时候,qiankun针对这个问题提出来proxySandbox

proxySandbox依然是使用proxy代理window,但不同的是对于每个子应用都代理了一个fakeWindow,这样在查找变量的时候在本地的fakeWindow上查找,如果没有找到就到主应用的window上查找,而修改时只修改本地的fakeWindow,不会影响到其他的应用,在最终卸载时把fakeWindow删除即可。

5.其他功能

其他功能比如“旧项目路由接入改造”、“微应用和路由之间如何Keep alive”、“webStorage应用之间的使用”、“资源共享”、“微应用内存溢出问题”、“同一路由多应用共存”等功能见《万字长文-落地微前端 qiankun 理论与实践指北》


常见问题

1.qiankun官方统计的常见问题

官方问题汇总

2.子应用静态资源加载失败问题

说明:子应用的静态资源引用时都是相对路径,当子应用放入到基座中后,通过主应用url访问时,静态资源的请求域名默认会使用主应用的,所以导致静态资源全部加载失败。

常规情况可以通过以下前两点解决这个问题:

(1)使用 webpack 运行时 publicPath配置qiankun 将会在微应用 bootstrap 之前注入一个运行时的 publicPath 变量,你需要做的是在微应用的 entry js 的顶部添加如下代码:

1
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;

关于运行时 publicPath 的技术细节,可以参考 webpack 文档。

runtime publicPath 主要解决的是微应用动态载入的 脚本、样式、图片 等地址不正确的问题。

(2)使用 webpack 静态 publicPath配置 你需要将你的 webpack publicPath 配置设置成一个绝对地址的 url,比如在开发环境可能是:

1
2
3
4
5
{
output: {
publicPath: `//localhost:${port}`,
}
}

(3)对于vite构建的子应用,上述两种方法可能失效,可尝试如下设置origin方法:

1
2
3
4
5
6
7
8
export default defineConfig({
// ...
server: {
host: 'localhost',
port: 5174,
origin: 'http://localhost:5174'
}
})

3. 解决切换路由时报错

[Vue Router warn]: Error with push/replace State DOMException: Failed to execute ‘replaceState’ on ‘History’: A history state object with URL ‘http://localhost:8080undefined/’ cannot be created in a document with origin ‘http://localhost:8080’ and URL ‘http://localhost:8080/mypage1/’.

1
2
3
4
5
6
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();
});

4.微应用不是直接跟路由关联或是有需要手动触发子应用加载的场景该怎么做?

这时候qiankun提供了一个 loadMicroApp 的方法进行子应用的手动加载,本质上是利用single-spamountRootParcel api来实现的。

1
2
3
4
5
6
7
import { loadMicroApp } from 'qiankun';
loadMicroApp(
{
name: 'app',
entry: '//localhost:7100',
container: '#Container',
} );

使用loadMircoApp还需要在子应用中暴露update钩子。

1
2
3
4
// 增加 update 钩子以便主应用手动更新微应用
export async function update(props) {
renderPatch(props);
}

5.CSS 样式污染问题

在微应用为vite时,样式问题是重灾区。由于没有沙箱,样式也不能进行隔离,由于前文提到的css加载的方式都是全局在主应用上生效的,所以全局上css互相影响的概率就很大,并且子应用使用的和主应用使用的组件库都是Element 或 Ant Design系列的,一些类名都是一致,这种情况下如果还按照之前的开发习惯直接控制组件对应的类名样式,大概率会出现互相层叠污染的情况,这时就需要一些开发规范和工程化的手段来设置样式。

以下以Ant Design为例,说明如何引入prefix来解决样式冲突问题,当然ElementPlus中也支持设置 prefix

(1)Ant Design修改prefix

从工程化的角度可以解决多个ant design组件库同时使用的样式污染问题,qiankun官方也有提供该解决方案,就是通过ant-desigtn注入prefix,拿ant-desigin-vue来说:

1
2
3
4
5
<template>
<a-config-provider prefix-cls="custom">
<my-app />
</a-config-provider>
</template>

这样实际使用的ant的类名都是以.custom-xxx的类名了

上图所见这样实现的自定义类名可以做到多份ant的css引入类名重复的问题。

当然如果自定义类名的话可以在less构建时注入相同的prefix,让全局的less存在相同的一个前缀变量,实际修改.ant-xxx类似的类名时需要替换.ant为相应的注入的prefix变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// vite.config.js
export default () => {
return {
css: {
preprocessorOptions: {
less: {
modifyVars: {
// 这里可以注入全局的less变量,通过注入的变量名称去实际业务样式修改的地方拼接prefix变量
'ant-prefix': 'custom', // 这里注入的prefix如上文提到<a-config-provider prefix-cls="custom"> 需要一致,以便一致
},
javascriptEnabled: true,
},
},
},

}
}

通过上面的vite配置注入的ant-prefix这样可以在实际的样式中拼接使用:

1
2
3
4
5
.@{ant-prefix} {
.@{ant-prefix}-col {
width: 100%;
}
}

这样构建出来的样式修改也是.custom-xxx的类名,可以达到修改自己的ant组件样式还不影响其他ant的项目UI。

(2)手动隔离子应用的样式

上文的方式解决了UI框架级别的css污染,同时我们自己的全局样式配置也会有一些css样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
input:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px white inset !important;
}

:-webkit-autofill {
transition: background-color 5000s ease-in-out 0s !important;
}

a:focus,
a:active,
button,
div,
svg,
span,
label {
outline: none !important;
}

a {
color: #51ffff !important;
}

这种情况需要我们手动进行隔离,我们可以在挂载的根节点元素上设置vite注入的less变量ant-prefix相同的类名,然后如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.@{ant-prefix} {
input:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px white inset !important;
}

:-webkit-autofill {
transition: background-color 5000s ease-in-out 0s !important;
}

a:focus,
a:active,
button,
div,
svg,
span,
label {
outline: none !important;
}

a {
color: #51ffff !important;
}
}

通过如上的包裹实现手动样式隔离,子应用设置的全局样式只作用于自己,不会影响到其他的项目。

6.CSS样式没被插入到页面中去

qiankun的一个bug

如上图qiankun的issue中确实有跟我遇到一样的问题,这个问题原因可能是qiankun的问题,也可能是qiankun主应用配置的问题,但是我作为第三方接入并没有权限去调整对方的主应用配置,只能自己排查去从子应用去解决。

这个问题大概是我构建完的HTML Entry中样式是通过link标签引入的,同时还有一些我的第三方直接引入的css:

1
2
3
4
5
<head>
<link rel="styleSheet" href="/resouce/assets/xxx.css" />
<link rel="styleSheet" href="/resouce/assets/vonder.xxxx.css" />
<link rel="styleSheet" href="/resouce/assets/index.xxxx.css" />
</head>

上文介绍的这种情况通过import-html-entry解析会通过fetch请求回来,但是奇怪的是并不会插入到主应用中,fetch的类型也不是styleSheet,这样虽然资源请求回来了但是css样式并不会生效,通过排查发现如果是通过javascript动态插入的link,主应用则会正确插入css使样式生效:

1
2
3
4
5
6
<script>
var i = document.createElement('link');
i.rel = 'stylesheet';
i.href = ip:port/path/to/css.css;
document.head.appendChild(i);
</script>

以上的代码会被解析并正确执行,插入到主应用中,使之生效。

上面我们发现的一个qiankun加载css的问题,导致入口的css样式并不生效,也找到了方法去解决,开始的想法是在HTML Entry中用javascript脚本来将入口中的link标签转换为动态插入的javascript的代码,但是很遗憾由于打包后的css路径带有hash是动态的不能直接写死创建哪些link标签,动态获取link标签在主应用执行的时候会误伤主应用以及其他的子应用的link标签,所以这样并未达到预期.

那思路就转换为是否能通过工程化的思维来通过脚本的方式来处理,在生成HTML Entry时将拿到的link动态转换成上文提到代码,同时前文也分析过vite-plugin-qainkun的实现,就想是不是可以通过vite插件的方式来对Index.html生成时做一些文章

  • 可以把link标签对应的css资源转换成style标签

  • 可以像上文提到的通过javascript动态插入link把原来的link标签remove掉

最终选择了第二种方式来进行实现,当然这之前还需要一些vite插件编写的基础知识,这里就不再赘述,以后有机会单独写一篇关于这些构建工具插件开发的文章。

好了,现在通过参考vite-plugin-qiankun和vite插件的基本知识,结合自己的需求确定了我们只需要在插件的transformIndexHtml钩子方法中对Index.html进行脚本开发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// vite-plugin-css-importtransform.js
import cheerio from 'cheerio'; // 插件需要安装cheerio来操作html(cheerio的一些api与jquery一致,node爬虫中会比较常用这个库)
export default function cssImportPlugin() {
return {
name: 'transform-css-import',
transformIndexHtml: (html) => { // 该钩子函数里面可以拿到构建时的html字符串
const $ = cheerio.load(html); // 载入然后可以像jquery一样控制html的各个标签了
const links = $('head link[rel=styleSheet]'); // 拿到link css的标签然后转换
links.each(function (i, link) {
$('head').append(`<script>
var i${i} = document.createElement('link');
i${i}.rel = 'stylesheet';
i${i}.href = '${$(link).attr('href')}';
document.head.appendChild(i${i});
</script>`);
});
links.remove(); // 把原有的link删掉
return $.html(); // 返回html字符串
},
};
}

上面的插件逻辑比较简单,当然使用也比较简单,不需要传入什么参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// vite.config.js
import qiankun from 'vite-plugin-qiankun';
import cssImportPlugin from './vite-plugin-css-import-transform';

export default () => {
return {
plugins: [
qiankun('子应用标识', {
// 其他的一些配置
}),
cssImportPlugin(), // 引入自定义的vite插件并使用
]
}
}

通过插件构建后的HTMl Entry就变成了这样:

至此也解决了实际中HTML Entry引入css样式不生效的问题。