qiankun微前端应用关键技术实践


微前端是什么

什么是微前端

Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently. – Micro Frontends

微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

微前端架构具备以下几个核心价值:

  • 技术栈无关
    主框架不限制接入应用的技术栈,微应用具备完全自主权

  • 独立开发、独立部署
    微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新

  • 增量升级

    在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略

  • 独立运行时
    每个微应用之间状态隔离,运行时状态不共享

Why Not Ifame

为什么不用 iframe,这几乎是所有微前端方案第一个会被 challenge 的问题。但是大部分微前端方案又不约而同放弃了 iframe 方案,自然是有原因的,并不是为了 “炫技” 或者刻意追求 “特立独行”。

如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。

iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。

其实这个问题之前这篇也提到过,这里再单独拿出来回顾一下好了。

  1. url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
  2. UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..

  3. 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。

  4. 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。

qiankun微前端特性

  • 📦 基于 single-spa 封装,提供了更加开箱即用的 API。
  • 📱 技术栈无关,任意技术栈的应用均可 使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。
  • 💪 HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。
  • 🛡 样式隔离,确保微应用之间样式互相不干扰。
  • 🧳 JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
  • ⚡️ 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。
  • 🔌 umi 插件,提供了 @umijs/plugin-qiankun 供 umi 应用一键切换成微前端架构系统。

关键技术实践

主应用mian-base(基座)

实践如何加载多个子应用

App.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
45
46
47
<template>
<div id="app">
<img alt="Vue logo" src="./assets/logo.png">
<h1>Welcome to <span class="qiankun">qiankun</span> Main App</h1>
<li>
<a href="/">main</a>
</li>
<li>
<a href="/about">about</a>
</li>
<!-- 子应用 vue app -->
<li>
<a href="/vue">app1</a>
</li>
<li>
<a href="/app2">app2</a>
</li>
<Action></Action>
<!-- 主应用容器 -->
<router-view></router-view>
<!-- 子应用容器 -->
<div id="container"></div>
</div>
</template>

<script>

export default {
name: 'App'
}
</script>

<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}

.qiankun {
font-size: x-large;
color: #42b983;
}
</style>

router.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
import Vue from 'vue';
import Router from 'vue-router';
import HelloWorld from "./components/HelloWorld";
import About from "./components/About";

Vue.use(Router);

const routes = [
{
path: '/',
name: 'HelloWorld',
component: HelloWorld,
},
{
path: '/about',
name: 'About',
component: About,
}
];

const router = new Router({
mode: 'history',
routes: routes,
})

export default router;

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
import Vue from 'vue'
import App from './App.vue'
import router from './router'

// 导入qiankun.js
import {registerMicroApps, start} from "qiankun";

Vue.config.productionTip = false

new Vue({
router,
render: h => h(App),
}).$mount('#app');


// 注册子应用
registerMicroApps([
{
name: 'vue app1', // 子应用名称
entry: '//localhost:7101', // 子应用入口
container: '#container', // 子应用所在容器
activeRule: '/vue', // 子应用触发规则(路径)
},
{
name: 'vue app2', // 子应用名称
entry: '//localhost:7102', // 子应用入口
container: '#container', // 子应用所在容器
activeRule: '/app2', // 子应用触发规则(路径)
},
]);


// 开启服务
start()

另外2个组件,./components/HelloWorld,./components/About ,代码略。

子应用vue-app1

App.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
36
37
38
39
40
<template>
<div id="app">
<h2>来自 <span class="qiankun"> app1 </span>的内容</h2>
<li>
<router-link to="/">home</router-link>
</li>
<li>
<router-link to="/about">about</router-link>
</li>
<li>
<router-link to="/helloword">helloword</router-link>
</li>
<li>
<router-link to="/action">message</router-link>
</li>
<router-view></router-view>
</div>
</template>

<script>
export default {
name: 'App',
}
</script>

<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}

.qiankun {
font-size: x-large;
color: coral;
}
</style>

router.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
import Vue from 'vue';
import VueRouter from 'vue-router';
import Home from './components/Home'
import HelloWorld from "./components/HelloWorld";

Vue.use(VueRouter);

const routes = [
{
path: '/',
name: 'home',
component: Home,
},
{
path: '/helloword',
name: 'HelloWorld',
component: HelloWorld,
},
{
path: '/about',
name: 'about',
component: () => import(/* webpackChunkName: "about" */ './components/About.vue'),
},
];
export default routes;

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';

Vue.config.productionTip = false;

let router = null;
let instance = null;

function render(props = {}) {
const {container} = props;
router = new VueRouter({
base: '/vue',
mode: 'history',
routes,
});

instance = new Vue({
router,
render: h => h(App),
}).$mount(container ? container.querySelector('#app') : '#app');
}

// webpack打包公共文件路径
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
// 独立运行
if (!window.__POWERED_BY_QIANKUN__) {
render();
}

// 生命周期
export async function bootstrap() {
console.log('[vue] vue app bootstraped');
}

export async function mount(props) {
console.log('[vue] props from main framework', props);
render(props);
}

export async function unmount() {
instance.$destroy();
instance.$el.innerHTML = '';
instance = null;
router = null;
}

另外3个组件,./components/HelloWorld,./components/About ,./components/Home代码略。

子应用vue-app2

跟vue-app1类似,区别在于:main.js中

1
2
3
4
5
6
7
8
function render(props = {}) {
const {container} = props;
router = new VueRouter({
base: '/app2', //修改这里
mode: 'history',
routes,
});
....

如何隔离样式

基于 shadow DOM 的样式隔离

由于子应用加载后是以DIV的形式存在的,所以会有样式冲突的问题,但我们的现实需求肯定是希望相互独立互不影响的。其中的 class=qiankun现在就会被后面的覆盖。

在qiankun2.0中实现了shadow DOM样式隔离,添加设置:sandbox: { strictStyleIsolation?: boolean }。在开启 strictStyleIsolation 时,我们会将微应用插入到 qiankun 创建好的 Shadow Tree 中,微应用的样式(包括动态插入的样式)都会被挂载到这个 Shadow Host 节点下,因此微应用的样式只会作用在 Shadow Tree 内部,这样就做到了样式隔离。

但是开启 Shadow DOM 也会引发一些别的问题:

一个典型的问题是,一些组件可能会越过 Shadow Boundary 到外部 Document Tree 插入节点,而这部分节点的样式就会丢失;比如 antd 的 Modal 就会渲染节点至 ducument.body ,引发样式丢失;针对刚才的 antd 场景你可以通过他们提供的 ConfigProvider.getPopupContainer API 来指定在 Shadow Tree 内部的节点为挂载节点,但另外一些其他的组件库,或者你的一些代码也会遇到同样的问题,需要你额外留心。

此外 Shadow DOM 场景下还会有一些额外的事件处理、边界处理等问题,后续我们会逐步更新官方文档指导用户更顺利的开启 Shadow DOM。

所以请根据实际情况来选择是否开启基于 shadow DOM 的样式隔离,并做好相应的检查和处理。

修改main.js

1
2
3
4
5
6
// 开启服务
start({
sandbox: { //开启 Shadow DOM 沙箱,做到真正隔离
strictStyleIsolation: true
}
})

如何在应用间通讯

主应用

actions.js

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

const initialState = {
//这里写初始化数据
'user': 'init value'
}

// 初始化 state
const actions = initGlobalState(initialState);

actions.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
export default actions;

Action.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
<template>
<div>
<button @click="sendMes1">发送消息1</button>
<button @click="sendMes2">发送消息2</button>
<span>接收到消息:{{mes}}</span>
</div>
</template>

<script>
import actions from '../actions'

export default {
name: "Action.vue",
data() {
return {
mes: '',
mes1: {user: 'Kaven'},
mes2: {user: 'Ran'},
}
},
mounted() {
actions.onGlobalStateChange((state,prev) => { //监听全局状态
this.mes = state;
console.log("主应用:"+state,prev)
}, true);
},
methods: {
sendMes1() {
actions.setGlobalState(this.mes1);//通过setGlobalState改变全局状态
},
sendMes2() {
actions.setGlobalState(this.mes2);
}

},
}
</script>

修改App.vue,引入Action组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<div id="app">
....
<Action></Action>
....
</div>
</template>

<script>
import Action from './components/Action.vue'

export default {
name: 'App',
components:{
Action
}
}
</script>

子应用vue-app1

actions.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
36
function emptyAction() {  //设置一个actions实例
// 提示当前使用的是空 Action
console.warn("Current execute action is empty!");
}

class Actions {
// 默认值为空 Action
actions = {
onGlobalStateChange: emptyAction,
setGlobalState: emptyAction,
};

/**
* 设置 actions
*/
setActions(actions) {
this.actions = actions;
}

/**
* 映射
*/
onGlobalStateChange(...args) {
return this.actions.onGlobalStateChange(...args);
}

/**
* 映射
*/
setGlobalState(...args) {
return this.actions.setGlobalState(...args);
}
}

const actions = new Actions();
export default actions;

main.js

1
2
3
4
5
export async function mount(props) {
console.log('[vue] props from main framework', props);
actions.setActions(props); //注入通讯actions实例
render(props);
}

Action.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
<template>
<div>
<div>这是 app1 子应用</div>
<p>接收到的消息: {{mes}}</p>
<button @click="butClick">点击发送消息</button>
</div>
</template>

<script>
import actions from '../actions'//导入实例
export default {
data() {
return {
mes: '',
}
},
mounted() {
actions.onGlobalStateChange((state) => { //监听全局状态
this.mes = state
}, true);
},
methods: {
butClick() {
actions.setGlobalState({user: 'app1'})//改变全局状态
}
}
}
</script>

实现了主应用与子应用相互发送消息的效果,无论是主应用还是子应用改变state值都会同步到监听的所有组件中。

参考

本文源代码下载:https://github.com/ranying666/microapps-qiankun

官网API:https://qiankun.umijs.org/zh


文章作者: KavenRan
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 KavenRan !
 上一篇
组织行为学之行为决策理论 组织行为学之行为决策理论
组织行为学——行为决策理论 行为决策学是一门研究人在决策过程中的行为规律的科学,目前在西方,这门学科正方兴未艾。 它的用途相当广泛,不仅有助于个人做出理性的决策,更能在企业管理、政策制定等方面发挥积极显著的作用。 它现今已受到越来
2021-07-22
下一篇 
团队教练实践工作坊总结 团队教练实践工作坊总结
ICF (国际教练联合会),是一个全球性的领先的教练组织。此组织致力于促进教练技术的职业化发展,其方法包括树立高等级的道德标准,提供独立的证书,以及建立全球认证教练的网络。 欣赏式探询4D循环欣赏式探询可以用于个人或者组织, 它有四个个
2021-06-28
  目录