微前端的使用

541 阅读1分钟

single-spa(未实现css隔离和js沙箱)

子应用

import Vue from 'vue';
import App from './App.vue';
import router from './router';
import singleSpaVue from 'single-spa-vue';

Vue.config.productionTip = false;

const appOptions = {
  // 挂载到父项目对应id中
  el: '#spa-container',
  render: h => h(App),
  router
};
const vueLifecycles = singleSpaVue({
  Vue,
  appOptions: appOptions
});

// 直接挂载会有冲突
// 如果是基座项目调用,就会存在singleSpaNavigate,否则不存在
if (!window.singleSpaNavigate) {
  delete appOptions.el;
  new Vue(appOptions).$mount('#app');
}

// 父项目注册时,会将参数传过来,可以自定义传参
export const bootstrap = props => {
  console.log(props, 'bootstrap');
  return vueLifecycles.bootstrap(props);
};
export const mount = props => {
  console.log(props, 'mount');
  return vueLifecycles.mount(props);
};
export const unmount = vueLifecycles.unmount;

基座

import { registerApplication, start } from 'single-spa';

const insetScript = src => {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = src;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });
};

const insetCss = href => {
  const link = document.createElement('link');
  link.href = href;
  link.rel = 'stylesheet';
  document.head.appendChild(link);
};

const apps = [
  {
    // 该子应用的名字,自定义即可
    name: 'manage-permission-spa',
    // 当activeWhen为true时,将触发该方法,挂载app,需返回暴露出来的全局变量
    app: async () => {
      await insetScript(
        'http://8.129.90.25:9000/admin/static/vendor.1.26e7843d1fbfff010b2e.js'
      );
      await insetScript(
        'http://8.129.90.25:9000/admin/static/main/index.a63c24ab6bc9fd855ffa.js'
      );
      insetCss(
        'http://8.129.90.25:9000/admin/static/main/index.ad87c26f69b2030d7fa2.css'
      );
      return window.managePermission;
    },
    // location匹配
    activeWhen: location => location.hash.startsWith('#/permission/manage'),
    // 传参到子应用
    customProps: (name, location) => {
      return {
        data: 'data'
      };
    }
  }
];

apps.forEach(app => registerApplication(app));

start();

路由处理

需要保证路由的前缀是一致的,hash的保证#后一致

比如主项目路由为http://localhost:8090/admin/#/permission/manage,由于在项目中,使用的是hash模式,vue-router配置中不能设置base,所以使用时需要在vue-router路由前都加上/permission/manage/,保证hash前缀完全一致

如果主应用路由为http://localhost:8090/ticket/create,并且路由使用的是history模式,那路由前缀就是http://localhost:8090/ticket/create,可以在vue-router配置base:'/ticket/create'即可

父子通信

实现各模块之间相互通信,待更新...

模块加载

实现子应用模块路径自动获取,待更新...

公共依赖

// 在项目中使用cdn引入,如果在父项目和子项目都直接在node_module中引用,那么可能会有重复引入的错误
  externals: {
    vue: 'Vue',
    'element-ui': 'ELEMENT',
    'vue-router': 'VueRouter'
  },

css隔离

在父项目的全局css样式,会影响到子项目

  • 自己约定好项目前缀
  • 使用css-module,打包时加上前缀(主流)
  • css-in-js
  • Shadow,dom真正意义上的隔离(qiankun框架使用)

使用postcss-selector-namespace给css统一添加前缀

// .postcssrc.js

module.exports = {
  plugins: [
    ['postcss-preset-env'],
    // postcss-selector-namespace: 给所有css添加统一前缀,然后父项目添加命名空间
    ['postcss-selector-namespace',{
      namespace() {
        return "#manage-permission-spa"; // 返回要添加的类名
      }
    }]]
};

js隔离

由于子应用都是在window挂载,所以,会时全局变量有污染,,如果需要做隔离,参考以下两种做法

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);

快照沙箱

class SnapshotSandbox {
    constructor() {
        this.proxy = window; // window属性 
        this.modifyPropsMap = {}; //记录在window上的修改
        this.active();
    }
    active() { //激活
        this.windowSnapshot = {}; //拍照
        for (const prop in window) {
            if (window.hasOwnProperty(prop)) {
                this.windowSnapshot[prop] = window[prop];
            }
        }
        Object.keys(this.modifyPropsMap).forEach(p => {
            window[p] = this.modifyPropsMap[p]
        })
    }
    inactive() {//失活
        for (const prop in window) {
            if (window.hasOwnProperty(prop)) {
                if (this.windowSnapshot[prop] !== window[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) //1
    sandbox.inactive() //失活
    console.log(window.a) //undefined
    sandbox.active() //激活
    console.log(window.a) //1
})(sandbox.proxy);

qiankun(基于single-spa)

子应用

import Vue from 'vue';
import App from './App.vue';
import VueRouter from 'vue-router';
import routes from './router';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';

Vue.use(ElementUI);
Vue.use(VueRouter);

Vue.config.productionTip = false;

let instance = null;
let router = null;

const render = (props = {}) => {
  // 因为卸载后重新挂载,可能会造成路由丢失,所以在render内注册
  router = new VueRouter({
  // 父应用调用是,路由前缀/ticket/create
    base: window.__POWERED_BY_QIANKUN__ ? '/ticket/create' : '/',
    mode: 'history',
    routes
  });
  instance = new Vue({
    el: '#app',
    router,
    render: h => h(App)
  });
};
if (window.__POWERED_BY_QIANKUN__) {
  // 动态设置 webpack publicPath,防止资源加载出错
  // eslint-disable-next-line no-undef,camelcase
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
} else {
  render();
}

// 子应用需要导出这三个异步函数
export const bootstrap = async props => {
  console.log('bootstrap', props)
};

export const mount = async props => {
  render(props);
};

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

基座

import { registerMicroApps, start } from 'qiankun';
import { Loading } from 'element-ui';

let loadingInstance = null;

const apps = [
  {
    name: 'createTicket',
    // entry: '//8.129.90.25:5555',
    entry: '//localhost:3000',
    container: '#qiankun-container',
    activeRule: location => location.pathname.startsWith('/ticket/create'),
    loader: loading => {
      console.log(`loading变化了,当前状态: ${loading}`);
      if (loading) {
        loadingInstance = Loading.service({
          fullscreen: true,
          text: 'Loading',
          spinner: 'el-icon-loading',
          background: 'rgba(0, 0, 0, 0.7)'
        });
      } else {
        loadingInstance.close();
        loadingInstance = null;
      }
    }
  }
];

registerMicroApps(apps, {
  beforeLoad: [
    app => {
      console.log('before load', app);
    }
  ],
  beforeMount: [
    app => {
      console.log('before mount', app);
    }
  ],
  afterUnmount: [
    app => {
      console.log('after unload', app);
    }
  ]
});

export default start;

子应用webpack配置

const path = require('path');
const { name } = require('../../package');
output: {
          path: path.resolve(__dirname, '../../dist'),
          filename: 'static/[name]/index.[chunkhash].js',
          chunkFilename: 'static/[name].[id].[chunkhash].js',
          publicPath: '/',
          // 下面为官方推荐配置
          library: `${name}-[name]`,
          libraryTarget: 'umd',
          jsonpFunction: `webpackJsonp_${name}`,
          globalObject: 'window'
 }

qiankun通过fetch获取资源会产生跨域问题,nginx解决跨域配置

add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';

js与css隔离问题

qiankun自动开启js与css隔离,但是,在实践中,发现导入element的css时,会有重复导入,导致默认样式重置的问题,采用scoped的方式,或者通过start传参experimentalStyleIsolation: true,来添加特殊选择器前缀,都能有效解决

父子通信

官网定义的initGlobalState,可以在父子应用之间进行通信,待更新..