4

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

 2 years ago
source link: https://ranying666.github.io/2021/07/21/microapp-qiankun/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

微前端是什么

什么是微前端

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

<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

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

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

<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

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

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中

function render(props = {}) {
const {container} = props;
router = new VueRouter({
base: '/app2', //修改这里
mode: 'history',
routes,
});
....



image-20210721182215280.png

如何隔离样式

基于 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

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



image-20210721182803786.png

如何在应用间通讯

actions.js

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

<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组件:

<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

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

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

Action.vue

<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值都会同步到监听的所有组件中。

image-20210721181739102.png

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

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


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK