微前端架构实践

微前端概念

微前端借鉴了微服务的思想,将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的子应用,而在用户看来是一个整体。

为什么不使用iframe

  • 刷新页面后iframe的状态不能保存
  • 微应用不能使用浏览器前进、后退功能
  • 通信不方便

应用通信

  1. 基于URL来进行数据传递,但是传递消息能力弱
  2. 基于 CustomEvent 实现通信
  3. 基于 props 通信

single-spa

初始化应用

创建微应用
vue create child-vue
? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, Router
? Use history mode for router? (Requires proper server setup for index fallback 
in production) Yes
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated confi
g files
? Save this as a preset for future projects? No
创建主应用

主应用可以是简单HTML文件,或者由Vue、React等框架构建

vue create parent-vue
? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, Router
? Use history mode for router? (Requires proper server setup for index fallback 
in production) Yes
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated confi
g files
? Save this as a preset for future projects? No

主应用接入微应用,微应用必须暴露三个接口,分别是bootstrapmountunmount,对于vue创建的微应用,可以使用single-spa-vue来辅助生成三个接口函数

配置微应用

child-vue

npm i single-spa-vue
微应用提供钩子函数

child-vue/src/main.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 = {
  el: "#vue", // 挂载到主应用的id为vue的标签中
  router,
  render: (h) => h(App),
};

const vueLifeCycle = singleSpaVue({
  Vue,
  appOptions,
});

export const bootstrap = vueLifeCycle.bootstrap;
export const mount = vueLifeCycle.mount;
export const unmount = vueLifeCycle.unmount;
微应用打包成lib

child-vue/vue.config.js

module.exports = {
  configureWebpack: {
    output: {
      library: "singleVue",
      //  打包成 umd 模块,export 导出的属性可以通过 window.singleVue.bootstrap/mount/unmount访问到
      libraryTarget: "umd",
    },
    devServer: {
      port: 10000,
    },
  },
};

微应用打包后的app.js

dwY1XV.png
路由前缀问题

微应用的所有路由都需要添加一个前缀,比如/vue

child-vue/src/router/index.js

const router = new VueRouter({
  mode: "history",
  // 给路由添加前缀
  base: "/vue",
  routes,
});

export default router;

如果没有添加前缀,在localhost:8080/vue访问结果结果如下,因为微应用匹配不到/vue开头的路由

dwaw5D.png

添加前缀后,再次访问相同url

dwdYWQ.png
路由跳转问题

点击微应用的链接时,发现请求的url是主应用的url,解决这个问题的方法是在微应用中使用绝对路径

child-vue/src/main.js

// 如果有主应用引用,发送请求时都用绝对路径
if (window.singleSpaNavigate) {
  // 末尾的 / 不能遗漏
  __webpack_public_path__ = "http://localhost:10000/";
  console.log(__webpack_public_path__);
}
export const bootstrap = vueLifeCycle.bootstrap;
export const mount = vueLifeCycle.mount;
export const unmount = vueLifeCycle.unmount;
允许单独访问微应用

child-vue/src/main.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 = {
  el: "#vue", // 挂载到主应用的id为vue的标签中
  router,
  render: (h) => h(App),
};

const vueLifeCycle = singleSpaVue({
  Vue,
  appOptions,
});

// 如果有主应用引用,发送请求时都用绝对路径
if (window.singleSpaNavigate) {
  // 末尾的 / 不能遗漏
  __webpack_public_path__ = "http://localhost:10000/";
  console.log(__webpack_public_path__);
} else {
  // 没有主应用引用时允许单独访问子应用
  delete appOptions.el;
  new Vue(appOptions).$mount("#app");
}
export const bootstrap = vueLifeCycle.bootstrap;
export const mount = vueLifeCycle.mount;
export const unmount = vueLifeCycle.unmount;

配置主应用

parent-vue

npm i single-spa
承载微应用的容器

parent-vue/src/App.vue

<template>
  <div id="app">
    <router-link to="/vue">加载vue创建的子应用</router-link>
    <!-- 微应用挂载的位置 -->
    <div id="vue"></div>
  </div>
</template>
加载微应用的js文件

parent-vue/src/main.js

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import { registerApplication, start } from "single-spa";
Vue.config.productionTip = false;

/**
 *  appName: string 子应用别名
 *  applicationOrLoadingFn 路由匹配成功进行子应用加载
 *  activityFn 路由匹配规则
 *  customProps?: {} | CustomPropsFn<{}> 自定义参数,用于主应用和微应用通信
 */
registerApplication(
  "myVueApp",
  async () => {
    // 必须先加载公共模块 chunk-vendors.js ,然后加载私有模块 app.js
    await loadScript("http://localhost:10000/js/chunk-vendors.js");
    await loadScript("http://localhost:10000/js/app.js");
    // 加载完成后,window 上会挂载一个 singleVue 属性(该名称在微应用的vue.config.js中定义),该属性包含 bootstrap、mount、unmount等方法,将其作为函数返回结果
    return window.singleVue;
  },
  (location) => location.pathname.startsWith("/vue") // 用户切换到 /vue 时加载 myVueApp
);
start();
new Vue({
  router,
  render: (h) => h(App),
}).$mount("#app");

SingleSpa的缺陷

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

CSS隔离方案

微应用之间样式隔离

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

主应用和微应用之间样式隔离
  • BEM(Block Element Modifier)约定项目前缀
  • CSS-Modules 打包生成不冲突的选择器名
  • Shadow DOM 真正意义的隔离
  • css-in-js
Shadow DOM

影子DOM,可以实现真正意义的样式隔离

<div>
    <p>hello</p>
    <div id="shadow"></div>
</div>

<script>
    
    let shadowDOM = document
    .getElementById("shadow")
    .attachShadow({ mode: "closed" });
    let pEl = document.createElement("p");
    let styleEl = document.createElement("style");
    styleEl.textContent = `
    p{color:red;}`;
    pEl.innerHTML = "world";
    shadowDOM.appendChild(pEl);
    // 该样式只会作用到影子标签
    shadowDOM.appendChild(styleEl);
</script>
沙箱机制

假如A应用加载后在window上添加一个变量window.a,在没有沙箱机制的情况下,B应用加载后也可以访问到window.a,这就是所谓的全局污染问题。沙箱可以确保应用结束后还原window环境

实现JS沙箱的方式

  • 快照沙箱
<script>
    class SnapshotSandbox {
    constructor() {
        this.proxy = window;
        this.modifyPropsMap = {}; // 记录在 window 上的修改
        // 默认沙箱处于激活状态
        this.active();
    }
    active() {
        this.windowSnapshot = {};
        for (const prop in window) {
        if (window.hasOwnProperty(prop)) {
            this.windowSnapshot[prop] = window[prop];
        }
        }
        // 恢复上次修改后的 window
        Object.keys(this.modifyPropsMap).forEach((prop) => {
        window[prop] = this.modifyPropsMap[prop];
        });
    }
    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);
    sandbox.inactive();
    console.log(window.a, window.b);
    sandbox.active();
    console.log(window.a, window.b);
    })(sandbox.proxy);
</script>

缺点是不适用于多个微应用的情况

  • 代理沙箱

基于es6的proxy来实现,不同应用使用不同代理

qiankun

项目初始化

创建项目
vue create qiankun-base
vue create qiankun-vue 
npx create-react-app qiankun-react
安装依赖

qiankun-base

vue add element
npm install qiankun

qiankun-react

# 修改 react 配置
yarn add react-app-rewired

配置主应用

注册微应用

qiankun-base/src/main.js

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import "./plugins/element.js";

import { registerMicroApps, start } from "qiankun";

const apps = [
  {
    name: "vueApp", // 微应用别名
    entry: "//localhost:10000", // 默认会加载这个html,解析里面的js文件并且执行,注意微应用必须支持跨域
    container: "#vue", // 微应用挂载位置
    activeRule: "/vue", // 激活规则,当访问到 /vue 时挂载这个应用
  },
  {
    name: "reactApp",
    entry: "//localhost:20000",
    container: "#react",
    activeRule: "/react",
  },
];
// 注册微应用
registerMicroApps(apps);
// 启动
start({
  prefetch: false, // 取消预加载
});

Vue.config.productionTip = false;

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

qiankun-base/src/App.vue

<template>
  <div id="base">
    <el-menu :router="true" mode="horizontal" :default-active="$route.path">
      <el-menu-item index="/">Base应用</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></router-view>
    <!-- vue应用的挂载位置 -->
    <div id="vue"></div>
    <!-- React应用的挂载位置 -->
    <div id="react"></div>
  </div>
</template>

<script>
export default {
  name: "app",
  components: {},
};
</script>

<style></style>
微应用的生命周期钩子

qiankun-base/src/main.js

// 注册微应用
registerMicroApps(apps, {
  beforeMount: () => {
    console.log("加载中");
  },
});
向微应用传参

通过props向微应用传参

qiankun-base/src/main.js

const apps = [
  {
    name: "vueApp", // 微应用别名
    entry: "//localhost:10000", // 默认会加载这个html,解析里面的js文件并且执行,注意微应用必须支持跨域
    container: "#vue", // 微应用挂载位置
    activeRule: "/vue", // 激活规则,当访问到 /vue 时挂载这个应用
    props: {
      a: "qiankun", // 向微应用传参,不能与已有参数重名
    },
  },
  {
    name: "reactApp",
    entry: "//localhost:20000",
    container: "#react",
    activeRule: "/react",
  },
];

微应用接收参数

微应用在mount方法中接收参数

qiankun-vue/src/main.js

export async function bootstrap(props) {}
export async function mount(props) {
  console.log(props);
  // props 是向该微应用传递的参数,可以通过store保存参数
  render(props);
}
export async function unmount(props) {
  // 卸载应用
  instance.$destroy();
}

配置微应用

接入Vue微应用
添加路由前缀

qiankun-vue/src/router/index.js

const router = new VueRouter({
  mode: "history",
  base: "/vue",
  routes,
});
导出钩子函数

qiankun-vue/src/main.js

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后将其挂载到container指定标签中,注意,注意这个id必须是唯一的,主应用不能含有同名id
}

if (!window.__POWERED_BY_QIANKUN__) {
  // 作为独立应用运行
  render();
} else {
  // 作为子应用运行,动态修改public_path,解决子应用发送请求的问题
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

// 必须导出这三个函数
export async function bootstrap(props) {}
export async function mount(props) {
  // props 是向该子应用传递的参数,可以通过store保存参数
  render(props);
}
export async function unmount(props) {
  // 卸载应用
  instance.$destroy();
}
微应用打包成lib

qiankun-vue/vue.config.js

module.exports = {
  // 支持跨域
  devServer: {
    port: 10000,
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },
  configureWebpack: {
    output: {
      library: "vueApp",
      libraryTarget: "umd",
    },
  },
};
id冲突问题

当主应用中某个元素的id与微应用(html中)挂载元素的id相同时,微应用会错误的挂载到主应用这个同名id所在元素,而不是主应用在main.js中指定的元素,所以要避免重名id的情况。

接入react微应用

qiankun-react/package.json

"scripts": {
  "start": "react-app-rewired start",
  "build": "react-app-rewired build",
  "test": "react-app-rewired test",
  "eject": "react-app-rewired eject"
},
配置跨域和打包路径

qiankun-react/config-overrides.js

module.exports = {
  webpack: (config) => {
    config.output.library = "reactApp";
    config.output.libraryTarget = "umd";
    config.output.publicPath = "http://localhost:20000/";
    return config;
  },
  devServer: (configFunction) => {
    return function (proxy, allowedHost) {
      const config = configFunction(proxy, allowedHost);
      config.headers = {
        "Access-Control-Allow-Origin": "*",
      };
      return config;
    };
  },
};
配置运行端口

qiankun-react/.env

PORT=20000
WDS_SOCKET_PORT=20000
导出钩子函数

qiankun-react/src/index.js

import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";

function render() {
  ReactDOM.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
    document.getElementById("root")
  );
}
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}
export async function bootstrap(props) {}
export async function mount(props) {
  render(props);
}
export async function unmount(props) {
  ReactDOM.unmountComponentAtNode(document.getElementById("root"));
}

运行结果

dB35FJ.gif
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容