能否用iframe实现微前端
问题:- 如果使用iframe,iframe中的子应用切换路由时用户刷新页面就尴尬了。
多个滚动条的问题
iframe里的弹框是弹在iframe中间,而不是整个屏幕的中间
iframe实现的微前端通信需要用postMessage iframe的隔离性较好,不存在样式和js冲突问题
因为子应用路由切换,主应用感知不到,点刷新时刷新的是主应用
SingleSPA
SingleSPA只做了两件事:路由劫持,应用加载,但是还有很多后续问题没有解决,例如:
- 子应用之间如何通信
- 父子应用用到的公共依赖怎么抽离
- 样式怎么避免冲突?隔离怎么做?
- 加载子应用时,子应用动态加载的chunk js需要自己手动创建script挂载到页面上
- 需要学习System规范,要引入system库
- 没有预加载的逻辑
子应用之间如何通信
- 基于URL来进行数据传递,但是传递消息能力弱
- 基于
CustomEvent实现通信 postMessage onmessage,根据我个人使用经验,会有时机问题,例如主应用给子应用postMessage的时候,子应用还没有加上onmessage监听,而且调试起来很麻烦,必须父子应用都启动起来才可以 - 基于props主子应用间通信,registerApplication方法的最后一个参数
- 使用全局变量通信
- 使用
Redux进行通信(不能用vuex,vuex是专门为vue量身定做的状态管理库)
父子组件之间通信
如果是使用乾坤,可以通过onGlobalStateChange和setGlobalState
const apps = [
{
name:'vueApp',
entry:'//localhost:10000',
container:'#vue',
activeRule:'/vue',
props: {a:1}
},
{
name:'reactApp',
entry:'//localhost:20000',
container:'#react',
activeRule:'/react'
}
]
registerMicroApps(apps);
如上述代码所示,在注册子应用时,我们给vueApp这个子应用添加了初始化属性{a:1},在子应用里面就可以拿到:
let instance = null;
function render(){
instance = new Vue({
router,
render: h => h(App)
}).$mount('#app')
}
if(window.__POWERED_BY_QIANKUN__){
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
if(!window.__POWERED_BY_QIANKUN__){render()}
export async function bootstrap(){}
export async function mount(props){
// 这里就可以拿到{a:1}
console.log(props);
render();
}
export async function unmount(){instance.$destroy();}
如何处理子应用之间的公共依赖
CDN- externalswebpack联邦模块- 共用一个组件库
- systemjs-importmap
父子应用代码实现:
使用SingleSPA
子应用:
import singleSpaVue from 'single-spa-vue';
const appOptions = {
el: '#vue',
router,
render: h => h(App)
}
// 默认情况下,子应用加载资源时都会相对于父应用的host + port加载
// 例如:基座监听8000端口,当子应用想要加载一个about.js时,并不会去子应用自己监听的localhost:10000/about.js下加载,而是会去基座监听的localhost:8000/about.js下加载
// 因此我们需要增加如下配置,window.singleSpaNavigate代表是以子应用的方式运行
if(window.singleSpaNavigate){
__webpack_public_path__ = 'http://localhost:10000/'
}
// 在非子应用中正常挂载应用
if(!window.singleSpaNavigate){
delete appOptions.el;
new Vue(appOptions).$mount('#app');
}
const vueLifeCycle = singleSpaVue({
Vue,
appOptions
});
// 子应用必须导出 以下生命周期 bootstrap、mount、unmount
export const bootstrap = vueLifeCycle.bootstrap;
export const mount = vueLifeCycle.mount;
export const unmount = vueLifeCycle.unmount;
export default vueLifeCycle;
当这个Vue应用以子应用的形式启动时,它的el参数(此处appOptions.el参数)就是基座应用的某个元素了
当访问的url命中某个子应用时,基座应用要加载对应的子应用
打包方式:
module.exports = {
configureWebpack: {
output: {
library: 'singleVue',
libraryTarget: 'umd'
},
devServer:{
port:10000
}
}
}
此配置会在window上添加如下属性/方法:
window.singleVue.bootstrap
window.singleVue.mount
window.singleVue.unmount
基座应用
<div id="nav">
<router-link to="/vue">vue项目</router-link>
<div id="vue"></div>
</div>
const routes = [
{ path: '/',name: 'home', component: Home },
{ path: '/about',name: 'about', component: About }
]
const router = new VueRouter({
mode: 'history',
base: '/vue',
routes
})
import Vue from 'vue'
import App from './App.vue'
import router from './router'
const loadScript = async (url)=> {
await new Promise((resolve,reject)=>{
const script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script)
});
}
import { registerApplication, start } from 'single-spa';
registerApplication(
'singleVue',
async ()=>{
await loadScript('http://localhost:10000/js/chunk-vendors.js');
await loadScript('http://localhost:10000/js/app.js');
return window.singleVue
},
location => location.pathname.startsWith('/vue')
)
start();
new Vue({
router,
render: h => h(App)
}).$mount('#app')
registerApplication此处注册了名为singleVue的子应用,并在url访问/vue时触发第2个回调,该回调必须返回promise,从上述代码中也可以看到我们用了async来修饰
还有一点需要注意,我们在每个应用中配置路由时,通常只会配/home /about这类,但是如果我们的链接也是跳到/home的话,是无法匹配到/vue前缀的,这将会导致子应用不能正常加载,所以一旦我们切到某个子应用,只要还在这个子应用内部跳转,就需要一直带着这个前缀,所以需要配置basename='vue'属性
还有,子应用里面加载资源时,其默认是会请求到基座应用中的,例如子应用里面加载一个/about.js资源,假设基座应用启动的端口是8080,这个资源就会按照http://localhost:8080/about.js 这样的路径去加载,这个明显是错误的,因此需要在子应用中判断一下,如果子应用是以微前端方式启动的,就将publicPath改为http://localhost:10000:
if(window.singleSpaNavigate){
__webpack_public_path__ = 'http://localhost:10000/'
}
除此之外,我们还希望子应用可以单独启动,因此我们需要判断不以微前端方式启动时,要正常的new Vue对象:
if(!window.singleSpaNavigate){
delete appOptions.el;
new Vue(appOptions).$mount('#app');
}
使用qiankun
基座应用,采用vue:
<el-menu :router="true" mode="horizontal">
<el-menu-item index="/">首页</el-menu-item>
<el-menu-item index="/vue">vue应用</el-menu-item>
<el-menu-item index="/react">react应用</el-menu-item>
</el-menu>
<router-view v-show="$route.name"></router-view>
<div v-show="!$route.name" id="vue"></div>
<div v-show="!$route.name" id="react"></div>
基座注册子应用:
注意:默认会加载entry里配的html,解析里面的js,动态的执行,子应用必须支持跨域
import {registerMicroApps,start} from 'qiankun'
const apps = [ { name:'vueApp', entry:'//localhost:10000', container:'#vue', activeRule:'/vue' }, { name:'reactApp', entry:'//localhost:20000', container:'#react', activeRule:'/react' }]
registerMicroApps(apps);
start();
Vue子应用:
导出的方法必须是返回promise的
let instance = null;
function render(){
instance = new Vue({
router,
render: h => h(App)
}).$mount('#app')
}
// 动态添加publicPath
if(window.__POWERED_BY_QIANKUN__){
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
// 默认独立运行
if(!window.__POWERED_BY_QIANKUN__){render()}
export async function bootstrap(){}
export async function mount(props){render();}
export async function unmount(){instance.$destroy();}
module.exports = {
devServer:{
port:10000,
headers:{
'Access-Control-Allow-Origin':'*'
}
},
configureWebpack:{
output:{
library:'vueApp',
libraryTarget:'umd'
}
}
}
React子应用:
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
function render() {
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
}
if(!window.__POWERED_BY_QIANKUN__){
render()
}
export async function bootstrap() {}
export async function mount() {render();}
export async function unmount() {
ReactDOM.unmountComponentAtNode(document.getElementById("root"));
}
重写react中的webpack配置文件 (config-overrides.js)
yarn add react-app-rewired --save-dev
module.exports = {
webpack: (config) => {
config.output.library = `reactApp`;
config.output.libraryTarget = "umd";
config.output.publicPath = 'http://localhost:20000/'
return config
},
devServer: function (configFunction) {
return function (proxy, allowedHost) {
const config = configFunction(proxy, allowedHost);
config.headers = {
"Access-Control-Allow-Origin": "*",
};
return config;
};
},
};
qiankun相比single-spa的优势:
- 有预加载
- js沙箱
- shadow-dom做样式隔离
样式隔离怎么做?
css样式隔离
对于子应用之间的样式隔离,可以采用动态样式表的方案:
Dynamic Stylesheet动态样式表,当应用切换时移除老应用样式,添加新应用样式
动态样式表使用的前提是将老的应用移除掉,但对于基座应用来说,是不能移除的,所以基座应用和子应用之间的样式隔离,可以采用命名空间:
BEM(Block Element Modifier) 约定项目前缀CSS-Modules打包时生成不冲突的选择器名Shadow DOM真正意义上的隔离 乾坤默认有该功能css-in-js
Shadow DOM:
<body>
<div>
<p>hello world</p>
<div id="shadow"></div>
</div>
<script>
// attachShadow({ mode: "closed" }) 代表外界无法访问shadow dom
let shadowDOM = document.getElementById('shadow').attachShadow({ mode: "closed" })
let pElm = document.createElement('p')
pElm.innerHTML = 'hello zf'
let styleElm = document.createElement('style')
styleElm.textContent = `
p { color: red; }
`
shadowDOM.appendChild(styleElm)
shadowDOM.appendChild(pElm)
</script>
</body>
上面代码中,我们往shadowDOM里添加了一个style标签,里面给所有的p加了样式,从效果中可以看到,只有shadowDOM里的p标签变成了红色
shadow-dom的问题:如果在shadow dom中将某些元素挂到了shadow-dom外面的部分,例如antd里经常会将modal弹框、popover浮层、option放到body上去
js样式隔离
沙箱机制:
如果应用加载过程中,刚开始加载A应用时,往window上挂载了变量a:window.a,然后加载B应用时,如果不做处理就会将window.a带到B应用里面
此时就需要在应用切换时创造一个干净的环境给这个子应用使用,具体做法是将当切换到某个子应用时,将原来window上的变量暂存,然后新的子应用可以在window上做任何操作,等再切回旧的子应用时再将暂存的变量还原回来
class SnapshotSandbox {
constructor () {
this.proxy = window;
this.modifyPropsMap = {};
this.active();
}
// 将快照上的kv赋给window
active () {
this.windowSnapshot = {};
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
this.windowSnapshot[prop] = window[prop]
}
}
// 上次被激活时保存的window上的属性,被放在了this.modifyPropsMap里
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]
}
}
}
}
}
let sandbox = new SnapshotSandbox();
(window => {
window.a = 1
window.b = 2
console.log(window.a, window.b) // 1 2
sandbox.inactive();
console.log(window.a, window.b) // undefined undefined
sandbox.active();
console.log(window.a, window.b) // 1 2
})(sandbox.proxy);
上述沙箱实现方式被称为快照沙箱,但是如果有多个子应用互相切换的话,这样做就不可以了,这时可以用es6的proxy,这是另外一种沙箱——代理沙箱
class ProxySandbox {
constructor() {
const rawWindow = window;
const fakeWindow = {}
const proxy = new Proxy(fakeWindow, {
set(target, p, value) {
target[p] = value;
return true
},
get(target, p) {
return target[p] || rawWindow[p];
}
});
this.proxy = proxy
}
}
let sandbox1 = new ProxySandbox();
let sandbox2 = new ProxySandbox();
window.a = 1;
((window) => {
window.a = 'hello';
console.log(window.a)
})(sandbox1.proxy);
((window) => {
window.a = 'world';
console.log(window.a)
})(sandbox2.proxy);
使用微前端后需要注意的东西
路由改造,子应用的所有路由必须要以某个前缀开头,需配置basename
微前端的适用场合
1、代码体量庞大,随着业务的日积月累,已经演变为巨石应用,需要进行拆解
2、不同团队的项目需要整合到一起,为了便于各自维护各自的应用,通过微前端进行隔离
3、打包上线时,希望独立打包构建更改的部分,而不是全量构建
4、不同项目使用了不一样的技术栈,希望整合到一起的时候使用
5、实现增量更新,如果在某些模块希望使用新的技术栈,则可以单独作为一个子应用使用新的技术栈,老的那一部分保持原有状态不变