微前端原理和实战(single-spa qiankun)

11,685 阅读6分钟

什么是微前端?

微前端就是将不同的功能按照不同的维度拆分成多个子应用。通过主应用(基座)来加载这些子应用。 微前端的核心在于拆,拆完后在合

为什么要去用他?

  • 不同的团队开发同一个应用,如何使用不同的技术栈?
  • 每个团队都可以独立开发,独立部署
  • 项目中一些老的应用代码

微前端的实现思想就是将一个应用划分成若干个子应用,将子应用打包成一个个的lib。当路径切换 时加载不同的子应用。这样每个子应用都是独立的,技术栈也不用做限制了!从而解决了前 端协同开发问题

实现微前端的几种方案

方案描述优点缺点
Nginx路由转发通过Nginx配置反向代理来实现不同路径映射到不同应用简单,快速,易配置在切换应用时会触发浏览器刷新,影响体验
iframe嵌套父应用单独是一个页面,每个子应用嵌套一个iframe实现简单,子应用之间自带沙箱,天然隔离,互不影响frame的样式显示、兼容性等都具有局限性;太过简单而显得low
npm包形式子工程以NPM包的形式发布源码;打包构建发布还是由基座工程管理,打包时集成。打包部署慢,不能单独部署打包部署慢,不能单独部署
通用中心路由基座式子工程可以使用不同技术栈;子工程之间完全独立,无任何依赖;统一由基座工程进行管理,按照DOM节点的注册、挂载、卸载来完成。不限定技术栈,单独部署通信方式不够灵活
特定中心路由基座式子业务线之间使用相同技术栈;基座工程和子工程可以单独开发单独部署;子工程有能力复用基座工程的公共基建。通信方式多,单独部署限定技术栈

目前主流的微前端框架

  • Single-SPA single-spa 是一个用于前端微服务化的 JavaScript 前端解决方案 (本身没有处理样式隔离, js 执行隔离) 实现了路由劫持和应用加载
  • qiankun 基于Single-SPA, 提供了更加开箱即用的 API ( single-spa + sandbox+ import-html-entry ) 做到了,技术栈无关、并且接入简单(像iframe 一样简单)

single-spa

基座和子项目都使用vue技术栈。react其实也是一个道理:基座提供注册逻辑、子应用提供三个协议接入方法和打包格式

基座(vue)

在基座里我们调用single-spa提供给我们的registerApplication和start的方法 1.registerApplication参数有四个个appNameOrConfig、appOrLoadApp、activeWhen、customProps。分别对应的是注册的子项目名和一些配置。加载子项目时执行的fn、执行的规则、和通信的参数

main.js

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import {registerApplication,start} from 'single-spa';

// 动态加载url
async function loadScript(url){
  return new Promise((resolve,reject)=>{
    let script = document.createElement('script');
    script.src = url;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  })
}
// singleSpa 缺陷 不够灵活 不能动态加载js文件
// 样式不隔离 没有js沙箱的机制

registerApplication('myVueApp',
  async ()=>{
      //当匹配成功的时候,加载子应用的js
      await loadScript(`http://localhost:10000/js/chunk-vendors.js`);
      await loadScript(`http://localhost:10000/js/app.js`)
      return window.singleVue; // 子应用打包umd格式。bootstrap mount unmount
  },
  //当匹配到/vue的时候执行上面的方法
  location => location.pathname.startsWith('/vue'), 
)
//启动应用
start();


new Vue({
  router,
  render: h => h(App)
}).$mount('#app')

子项目

子项目最重要的就是提供三个方法 bootstrap、mount、unmount 和 打包格式

main.js

这里借助了社区提供的single-spa-vue、react就使用single-spa-react。它会默认导出三个方法。

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

const appOptions = {
    el:'#vue', // 挂载到父应用中的id为vue的标签中
    router,
    render: h => h(App)
}
const vueLifeCycle = singleSpaVue({
  Vue,
  appOptions
})
// 如果是微前端模式下,single-spa会在window上挂在一个singleSpaNavigate的属性。
// 这时候我们需要将public_path改成子项目中的地址。
if(window.singleSpaNavigate){
  __webpack_public_path__ = 'http://localhost:10000/'
}
//这个是让子项目能够单独的运行
if(!window.singleSpaNavigate){
  delete appOptions.el;
  new Vue(appOptions).$mount('#app');
}

// 协议接入 我定好了协议 父应用会调用这些方法
export const bootstrap = vueLifeCycle.bootstrap;
export const mount = vueLifeCycle.mount;
export const unmount = vueLifeCycle.unmount;


配置子项目的打包方式 vue.config.js

module.exports = {
    configureWebpack:{
        output:{
            library:'singleVue',
            libraryTarget:'umd'
        },
        devServer:{
            port:10000
        }
    }
}

single-spa 的缺陷

  • 从上图可以看出,基座在匹配到路径时候需要手动的添加js,如果子项目打包出一万个js,那....
  • 样式不隔离
  • 没有js沙箱的机制

qiankun

向接入iframe一样简单 qiankun是通过fetch方法直接把html插入到容器里,所以子项目需要允许跨域才行

基座

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import ElementUI from "element-ui";
import "element-ui/lib/theme-chalk/index.css";
Vue.use(ElementUI);
// 从qiankun中导出两个方法
import { registerMicroApps, start } from "qiankun";
const apps = [
  {
    name: "vueApp", // 应用的名字
    entry: "//localhost:10000",//加载的html路径
    container: "#vue", // 容器名
    activeRule: "/vue", // 激活的路径
  },
  {
    name: "reactApp",
    entry: "//localhost:20000",
    container: "#react",
    activeRule: "/react",
  },
];
registerMicroApps(apps); // 注册应用
start(); // 开启

new Vue({
  router,
  render: (h) => h(App),
}).$mount("#app");

子项目

import Vue from 'vue'
import App from './App.vue'
import router from './router'

// Vue.config.productionTip = false


let instance = null
function render(props) {
    instance = new Vue({
        router,
        render: h => h(App)
    }).$mount('#app'); // 这里是挂载到自己的html中  基座会拿到这个挂载后的html 将其插入进去
}
if (window.__POWERED_BY_QIANKUN__) { // 动态添加publicPath
    __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
if (!window.__POWERED_BY_QIANKUN__) { // 默认独立运行
    render();
}
// 子组件的协议就ok了
export async function bootstrap(props) {

};
export async function mount(props) {
    render(props)
}
export async function unmount(props) {
    instance.$destroy();
}

子项目打包配置

module.exports = {
    devServer:{
        port:10000,
        // 需要允许跨域
        headers:{
            'Access-Control-Allow-Origin':'*'
        }
    },
    configureWebpack:{
        output:{
            library:'vueApp',
            libraryTarget:'umd'
        }
    }
}

应用通信

  • 基于URL来进行数据传递,但是传递消息能力弱
  • 基于 CustomEvent 实现通信
  • 基于props主子应用间通信
  • 使用全局变量、 Redux 进行通信

css隔离方案

子应用之间样式隔离

  • Dynamic Stylesheet 动态样式表,当应用切换时移除老应用样式,添加新应用样式

主应用和子应用之间的样式隔离

  • BEM (Block Element Modifier) 约定项目前缀
  • CSS-Modules 打包时生成不冲突的选择器名
  • Shadow Dom 真正意义上的隔离

js沙箱机制

  • 快照沙箱,在应用沙箱挂载或卸载时记录快照,在切换时依据快照恢复环境 (无法支持多实

例)

  • Proxy 代理沙箱,不影响全局环境

快照沙箱

  • 1.激活时将当前window属性进行快照处理
  • 2.失活时用快照中的内容和当前window属性比对
  • 3.如果属性发生变化保存到 modifyPropsMap 中,并用快照还原window属性
  • 4.在次激活时,再次进行快照,并用上次修改的结果还原window
class SnapshotSandbox{
        constructor(){
            this.proxy = window; 
            this.modifyPropsMap = {}; 
            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(window[prop]  !== this.windowSnapshot[prop]){
                        this.modifyPropsMap[prop] = window[prop];
                        window[prop] = this.windowSnapshot[prop]
                    }
                }
            }
        }
    }

代理沙箱

每个应用都创建一个proxy来代理window,好处是每个应用都是相对独立,不需要直接更改全局window属性!

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
    }
}

基于jquery项目的微前端重构

项目背景

项目中使用的是backbonejs的jq框架,还包含bootstrap等一系列依赖。虽说jquery一把梭,但维护起来着实有点繁琐。于是就产生了用微前端重构的想法。

  • 项目中使用的路由模式是hash模式

首先由于本项目是一个类似于后台管理系统的界面,左侧有很多菜单。如果jquery项目作为子项目可能改动会比较大,于是就直接将jq项目作为基站。当点击左侧路由的时候,通过匹配机制去加载子应用然后嵌入到容器内。

jquery

首先引入qiankun

import {registerMicroApps, addGlobalUncaughtErrorHandler, start} from 'qiankun';

mian.js

function genActiveRule(routerPrefix) {
    return location => location.hash.startsWith('#' + routerPrefix);
}
$(function() {
    // 布局
    createLayout();
    // 路由
    createRoute();
    // 前往上次的页面
    gotoLastPage();
    registerMicroApps([
        {
            name: 'micro',
            entry: '//xxx.com/home/vue/',
            container: '#main-app',
            activeRule: genActiveRule('/vue')
        }
    ]);
    addGlobalUncaughtErrorHandler(event => {
        console.error(event);
        const {message: msg} = event;
        // 加载失败时提示
        if (msg && msg.includes('died in status LOADING_SOURCE_CODE')) {
            console.error('子应用加载失败,请检查应用是否可运行');
        }
    });
    start();
    monitorOrders.init();
});

首先entry的内容就是我们子应用打包部署后能访问到的地址 增加了一个qiankun提供的错误提示

子项目(vue)

main.js

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

// import "./public-path";
import App from "./App.vue";
import routes from "./routes";

Vue.use(VueRouter);
Vue.use(ElementUI);
Vue.config.productionTip = false;

let instance = null;
let router = null;

/**
 * 渲染函数
 * 两种情况:主应用生命周期钩子中运行 / 微应用单独启动时运行
 */
function render() {
  console.log("window.__POWERED_BY_QIANKUN__", window.__POWERED_BY_QIANKUN__);
  // 在 render 中创建 VueRouter,可以保证在卸载微应用时,移除 location 事件监听,防止事件污染
  router = new VueRouter({
    // 运行在主应用中时,添加路由命名空间 /vue
    base: window.__POWERED_BY_QIANKUN__ ? "/vue" : "/",
    mode: "hash",
    routes,
  });

  // 挂载应用
  instance = new Vue({
    router,
    render: (h) => h(App),
  }).$mount("#app");
}

// 独立运行时,直接挂载应用
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap() {
  console.log("VueMicroApp bootstraped");
}

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props) {
  console.log("VueMicroApp mount", props);
  render(props);
}

/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount() {
  console.log("VueMicroApp unmount");
  instance.$destroy();
  instance = null;
  router = null;
}

vue.config.js

const path = require("path");
function resolve(dir) {
  return path.join(__dirname, dir);
}

const stage = process.env.ENV === "testing";

const publicPath =
  process.env.ENV === "production"
    ? xxxx:xxx
    

let output = {
  library: "Micro",
  // 将你的 library 暴露为所有的模块定义下都可运行的方式
  libraryTarget: "umd",
  // 按需加载相关,设置为 webpackJsonp_VueMicroApp 即可
  jsonpFunction: `webpackJsonp_customerMicro`,
};
let cssExtract = {};
if (stage) {
  output.filename = `js/[name].js?v=[hash]`;
  output.chunkFilename = `js/[name].js?v=[hash]`;
  cssExtract = {
    filename: "css/[name].css?v=[hash]",
    chunkFilename: "css/[name].css?v=[hash]",
  };
}
module.exports = {
  publicPath,
  devServer: {
    // 监听端口
    port: 10200,
    // 关闭主机检查,使微应用可以被 fetch
    disableHostCheck: true,
    // 配置跨域请求头,解决开发环境的跨域问题
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },
  css: {
    extract: cssExtract,
  },
  configureWebpack: () => {
    const conf = {
      resolve: {
        alias: {
          "@": resolve("src"),
        },
      },
      output: output,
    };
    return conf;
  },
  chainWebpack(config) {
    config.plugins.delete("preload");
    config.plugins.delete("prefetch");
    config.module
      .rule("vue")
      .use("vue-loader")
      .loader("vue-loader")
      .tap((options) => {
        options.compilerOptions.preserveWhitespace = true;
        return options;
      })
      .end();
    config.when(process.env.NODE_ENV === "development", (config) => config.devtool("cheap-source-map"));
  },
};

我上面的配置是区分了测试环境和正式环境的。大家也可以根据实际的情况来。基站那边的entry也可以根据环境来。

微前端资源系列集合

微前端系列集合

感谢大家

1.如果文章对你有帮助,请动动你的小手指点个赞👍吧,你的赞是我创作的动力。

2.关注公众号「前端精英」!不定期推送高质量文章哦。