微前端基础

229 阅读7分钟

能否用iframe实现微前端

问题:- 如果使用iframeiframe中的子应用切换路由时用户刷新页面就尴尬了。 多个滚动条的问题 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 - externals
  • webpack联邦模块
  • 共用一个组件库
  • 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标签变成了红色

image.png

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、实现增量更新,如果在某些模块希望使用新的技术栈,则可以单独作为一个子应用使用新的技术栈,老的那一部分保持原有状态不变