微前端架构实践

微前端概念

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

为什么不使用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


加载微应用的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,可以实现真正意义的样式隔离

hello

沙箱机制

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

实现JS沙箱的方式

  • 快照沙箱

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

  • 代理沙箱

基于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






微应用的生命周期钩子

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(
    
      
    ,
    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

你可能感兴趣的:(微前端架构实践)