微前端是什么
什么是微前端
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 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。
其实这个问题之前 这篇 也提到过,这里再单独拿出来回顾一下好了。
url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
其中有的问题比较好解决(问题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