Skip to content

微前端的使用

TIP

微前端的概念:构建一个现代 Web 应用所需要的技术/策略/方法,具备多个团队独立开发、部署的特性

一、概述

概念

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

微前端的优势

  • 独立测试部署,各个模块相互独立,互不影响
  • 扩展性高
  • 技术兼容更好,各个模块可以使用不同的技术

微前端的缺点

  • 子应用之间共享资源能力较差
  • 需要对旧的代码改造升级才可以使用

目前主流的微前端解决方案

  • iframe (最大的问题是 刷新页面 路由会丢失 本质刷新的主应用 而不是路由应用 所以被放弃了)
  • single-spa,2018 年诞生,是一个用于前端微服务化的 JavaScript 前端解决方案 (本身没有处理样式隔离,js 执行隔离) 实现了路由劫持和应用加载
  • qiankun,2019 年诞生,基于 single-spa, 提供了更加开箱即用的 API (single-spa + sandbox + import-html-entry) 做到了,技术栈无关、并且接入简单(像 iframe 一样简单)
  • webpack5 的模块联邦,2020 年诞生,提供了一种新的解决思路

二、single-spa 使用

1 创建子应用

js
vue create child-vue

npm i single-spa-vue

main.js 入口文件

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

Vue.config.productionTip = false;

// vue2项目的配置选项
const appOptions = {
  el: "#vue", // 挂载到父应用中id为vue的标签中
  router,
  render: (h) => h(App),
};

// 当父应用 调用我的时候,控制子应用路由跳转的资源引用路径
if (window.singleSpaNavigate) {
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = "http://localhost:10001/";
}

// 支持子应用独立运行、部署,不依赖于基座应用
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;

router/index.js 路由文件

js
const router = new VueRouter({
  mode: "history",
  // 这个名字需要和 打包的名字  以及 主应用引用的名称一样
  base: "vue", //process.env.BASE_URL,
  routes,
});

vue.config.js 修改 webpack 的基础配置

js
module.exports = {
  configureWebpack: {
    output: {
      library: "singleVue", // 注入到window上面的名称
      libraryTarget: "umd", // 打包出的格式
    },
    devServer: {
      port: 10000,
    },
  },
};

2 创建父应用

js
vue create parent-vue

npm i single-spa

main.js 入口文件

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

Vue.config.productionTip = false;

// 异步加载脚本方法
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);
  });
}

// 1 注册应用
registerApplication(
  // 第一个参数:名称 随便取
  "myVueApp",
  // 第二个参数:必须是promise函数
  async () => {
    console.log("加载默默");
    // system JS
    await loadScript(`http://localhost:10001/js/chunk-vendors.js`);
    await loadScript(`http://localhost:10001/js/app.js`);
    return window.singleVue; // bootstrap mount unmount
  },
  // 第三个参数:匹配路径函数,用户切换到/vue的路径下,需要加载第二个参数的定义的子应用
  (location) => location.pathname.startsWith("/vue"),
  // 第四个参数:自定义参数。包括属性和方法,非必传
  { a: 1, b: 1 }
);
// 2 启动应用
start();

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

App.vue 修改挂载节点

html
<template>
  <div id="app">
    <router-link to="/vue">加载vue应用</router-link>
    <!-- 子应用加载的位置 -->
    <div id="vue"></div>
  </div>
</template>

3 single-spa 的缺陷

  • 不够灵活
  • 不能动态加载 JS 文件
  • 样式不隔离
  • 没有 JS 沙箱的机制

三、CSS 隔离方案

1 子应用与子应用的样式隔离

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

2 主应用和子应用的样式隔离

  • BEM(Block Element Modifier) 约定项目前缀
  • CSS-Modules 打包时生成不冲突的选择器名(主流方案,vue 的样式加 hash 后缀)
  • css-in-js
  • Shadow DOM 真正意义上的隔离

3 Shadow DOM

  • Shadow DOM 可以实现真正的隔离机制,即隐藏 DOM 树

graph

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>

  <body>
    <div>
      <p>hello world</p>
      <div id="shadow"></div>
    </div>
    <script>
      // 1 创建影子
      let shadowDom = shadow.attachShadow({ mode: "open" });

      // 2 创建元素
      let pElement = document.createElement("p");
      pElement.innerHTML = "hello shadow";

      // 3 编写样式
      let styleElement = document.createElement("style");
      styleElement.textContent = `
			p{color:red}
		`;
      // 4 将元素和样式都放到影子内部
      shadowDom.appendChild(pElement);
      shadowDom.appendChild(styleElement);
    </script>
  </body>
</html>

四、JS 沙箱机制

  • 创建一个干净的环境给子应用使用,当切换子应用的时候,可以选择丢弃属性和恢复属性
  • 目的:为了子应用从开始到结束的运行声明周期中,并在切换中都不会影响到全局
  • JS 常见的两种沙箱机制:
    1. 快照沙箱,在应用沙箱挂载或卸载时记录快照,在切换时依据快照恢复环境 (无法支持多实例)
    2. Proxy 代理沙箱,不影响全局环境

graph

1 快照沙箱

  • 激活时将当前 window 属性进行快照处理
  • 失活时用快照中的内容和当前 window 属性比对,如果属性发生变化保存到 modifyPropsMap 中,并用快照还原 window 属性
  • 再次激活时,再次进行快照,并用上次修改的结果还原 window
  • 总结一句话,激活拷贝 window 的属性到缓存中,失活对比缓存,还原 window 属性
js
class SnapshotSandbox {
  constructor() {
    this.proxy = window; // window属性
    this.modifyPropsMap = {}; // 记录在window上的修改
    this.active();
  }
  // 激活沙箱
  active() {
    // window对象的快照
    this.windowSnapshot = {};
    for (const prop in window) {
      if (window.hasOwnProperty(prop)) {
        // 将window上的属性进行拍照
        this.windowSnapshot[prop] = window[prop];
      }
    }
    // 修改的属性进行还原
    Object.keys(this.modifyPropsMap).forEach((p) => {
      window[p] = this.modifyPropsMap[p];
    });
  }
  // 失活沙箱
  inactive() {
    for (const prop in window) {
      // diff 差异
      if (window.hasOwnProperty(prop)) {
        // 将上次拍照的结果和本次window属性做对比
        if (this.windowSnapshot[prop] !== window[prop]) {
          // 保存修改后的结果
          this.modifyPropsMap[prop] = window[prop];
          // 还原window
          window[prop] = this.windowSnapshot[prop];
        }
      }
    }
  }
}

let sandbox = new SnapshotSandbox();
((window) => {
  window.a = 1;
  window.b = 2;
  window.c = 3;
  console.log(a, b, c); // 1 2 3

  sandbox.inactive();
  console.log(a, b, c); // undefined undefined undefined

  sandbox.active();
  console.log(a, b, c); // 1 2 3
})(sandbox.proxy);

快照沙箱只能针对单实例应用场景,如果是多个实例同时挂载的情况则无法解决,只能通过 proxy 代理沙箱来实现

2 Proxy 代理沙箱

js
// 代理沙箱类
class ProxySandbox {
  constructor() {
    // 缓存原始的window对象
    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); // hello
})(sandbox1.proxy);

((window) => {
  window.a = "world";
  console.log(window.a); // world
})(sandbox2.proxy);

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

五、qiankun 使用

qiankun 官网

1 主应用

sh
vue create qiankun-base

npm i qiankun
html
// App.vue 主应用进行布局

<template>
  <!-- 不能有 id="app" -->
  <div>
    <el-menu :router="true" mode="horizontal">
      <!-- 自己的路由应用 -->
      <el-menu-item index="/">Home</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 />
    <div id="vue"></div>
    <div id="react"></div>
  </div>
</template>
js
// main.js 入口修改

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";
import { registerMicroApps, start } from "qiankun";

const apps = [
  {
    name: "vueApp",
    entry: "//localhost:10001", // 子应用必须支持跨域,利用fetch去请求资源文件
    container: "#vue", // 挂载子应用的容器
    activeRule: "/vue", // 激活的路径
    props: { a: 1 },
  },
  {
    name: "reactApp",
    entry: "//localhost:20000",
    container: "#react",
    activeRule: "/react",
  },
];
// 注册应用
registerMicroApps(apps);
// 开启
start({
  prefetch: false, // 'all' 取消预加载,不点击路由,不加载
});

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

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

2 vue 子应用

js
// router/index.js  路由修改

const router = new VueRouter({
  mode: "history",
  base: "/vue",
  routes,
});
js
// main.js 入口修改

import Vue from "vue";
import App from "./App.vue";
import router from "./router";

let instance = null;
function render() {
  instance = new Vue({
    router,
    render: (h) => h(App),
  }).$mount("#app"); // 挂载到自己的html中,基座拿到挂载后的html,插入到占位中
}

// 父应用中运行
if (window.__POWERED_BY_QIANKUN__) {
  // eslint-disable-next-line no-undef
  __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(props);
}
export async function unmount() {
  instance.$destroy();
}
js
// vue.config.js 服务修改

module.exports = {
  devServer: {
    port: 10001,
    headers: {
      // 跨域配置
      "Access-Control-Allow-Origin": "*",
    },
  },
  configureWebpack: {
    output: {
      library: "vueApp",
      libraryTarget: "umd",
    },
  },
};

3 react 子应用

重写 webpack 的配置

sh
yarn add react-app-rewired --save-dev
json
// package.json

  "scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    "test": "react-app-rewired test",
    "eject": "react-app-rewired eject"
  },
js
// config-overrides.js 修改webpack配置

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;
    };
  },
};
js
// index.js 配置入口

import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import { BrowserRouter, Route, Link } from "react-router-dom";

const BASE_NAME = window.__POWERED_BY_QIANKUN__ ? "/react" : "";

function render() {
  ReactDOM.render(
    <BrowserRouter basename={BASE_NAME}>
      <Link to="/">首页</Link>
      <Link to="/about">关于</Link>
      <Route path="/" exact render={() => <h1>hello home</h1>}></Route>
      <Route path="/about" render={() => <h1>hello about</h1>}></Route>
    </BrowserRouter>,
    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"));
}

Released under the MIT License.