·vincent

微前端实战总结篇

s

微前端

微前端实战总结篇

1637890535019.png

微前端现有的落地方案可以分为三类,自组织模式、基座模式以及模块加载模式。

一、为什么需要微前端?

这里我们通过 3W(what,why,how)的方式来讲解什么是微前端:

1.What?什么是微前端?

1637890535458.png

微前端就是将不同的功能按照不同的维度拆分成多个子应用。通过主应用来加载这些子应用。

微前端的核心在于拆, 拆完后再合!

2.Why?为什么去使用他?

  • 不同团队间开发同一个应用技术栈不同怎么破?
  • 希望每个团队都可以独立开发,独立部署怎么破?
  • 项目中还需要老的应用代码怎么破?

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

3.How?怎样落地微前端?

1637890536000.png

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

总结:子应用可以独立构建,运行时动态加载,主子应用完全解耦,并且技术栈无关,靠的是协议接入(这里提前强调一下:子应用必须导出 bootstrap、mount、unmount三个方法)。

这里先回答一下大家可能会有的疑问:

这不是 iframe 吗?

如果使用的是iframe,当 iframe 中的子应用切换路由时用户刷新页面就尴尬了。

应用间如何通信?

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

如何处理公共依赖?

  • CDN - externals
  • webpack联邦模块

二、SingleSpa 实战

官网 https://zh-hans.single-spa.js.org/docs/configuration

1.构建子应用

首先创建一个 vue 子应用,并通过single-spa-vue来导出必要的生命周期:

sh
1
2vue create spa-vue
3npm install single-spa-vue
js
1// main.js
2
3import singleSpaVue from "single-spa-vue";
4
5const appOptions = {
6  el: "#vue",
7  router,
8  render: (h) => h(App),
9};
10
11// 在非子应用中正常挂载应用
12if (!window.singleSpaNavigate) {
13  delete appOptions.el;
14  new Vue(appOptions).$mount("#app");
15}
16
17const vueLifeCycle = singleSpaVue({
18  Vue,
19  appOptions,
20});
21
22// 子应用必须导出以下生命周期:bootstrap、mount、unmount
23export const bootstrap = vueLifeCycle.bootstrap;
24export const mount = vueLifeCycle.mount;
25export const unmount = vueLifeCycle.unmount;
26export default vueLifeCycle;

配置子路由基础路径

js
1// router.js
2const router = new VueRouter({
3  mode: "history",
4  base: "/vue", //改变路径配置
5  routes,
6});

1637890536548.png

2.配置库打包

将子模块打包成类库

js
1//vue.config.js
2module.exports = {
3  configureWebpack: {
4    // 把属性挂载到window上方便父应用调用 window.singleVue.bootstrap/mount/unmount
5    output: {
6      library: "singleVue",
7      libraryTarget: "umd",
8    },
9    devServer: {
10      port: 10000,
11    },
12  },
13};

1637890537215.png

3.主应用搭建

html
1<div id="nav">
2  <router-link to="/vue"
3    >vue项目router-link>
4    <div id="vue">div> div></div></router-link
5  >
6</div>

将子应用挂载到id="vue"标签中

js
1import Vue from "vue";
2import App from "./App.vue";
3import router from "./router";
4import { registerApplication, start } from "single-spa";
5
6Vue.config.productionTip = false;
7
8async function loadScript(url) {
9  return new Promise((resolve, reject) => {
10    let script = document.createElement("script");
11    script.src = url;
12    script.onload = resolve;
13    script.onerror = reject;
14    document.head.appendChild(script);
15  });
16}
17
18// 注册应用
19registerApplication(
20  "myVueApp",
21  async () => {
22    console.info("load");
23    // singlespa问题
24    // 加载文件需要自己构建script标签 但是不知道应用有多少个文件
25    // 样式不隔离
26    // 全局对象没有js沙箱的机制 比如加载不同的应用 每个应用都用同一个环境
27    // 先加载公共的
28    await loadScript("http://localhost:10000/js/chunk-vendors.js");
29    await loadScript("http://localhost:10000/js/app.js");
30
31    return window.singleVue; // bootstrap mount unmount
32  },
33  // 用户切换到/vue下 我们需要加载刚才定义的子应用
34  (location) => location.pathname.startsWith("/vue")
35);
36
37start();
38
39new Vue({
40  router,
41  render: (h) => h(App),
42}).$mount("#app");

1637890537912.png

4.动态设置子应用 publicPath

js
1if (window.singleSpaNavigate) {
2  __webpack_public_path__ = "http://localhost:10000/";
3}

三、qiankun 实战

qiankun是目前比较完善的一个微前端解决方案,它已在蚂蚁内部经受过足够大量的项目考验及打磨,十分健壮。这里附上官网。

https://qiankun.umijs.org/zh/guide

1.主应用编写

js
1<template>
2  <!--注意这里不要写app 否则跟子应用的加载冲突
3  <div id="app">-->
4  <div>
5    <el-menu :router="true" mode="horizontal">
6      <!-- 基座中可以放自己的路由 -->
7      <el-menu-item index="/">Home</el-menu-item>
8
9      <!-- 引用其他子应用 -->
10      <el-menu-item index="/vue">vue应用</el-menu-item>
11      <el-menu-item index="/react">react应用</el-menu-item>
12    </el-menu>
13    <router-view />
14
15    <!-- 其他子应用的挂载节点 -->
16    <div id="vue" />
17    <div id="react" />
18  </div>
19</template>
20
21<style>
22
23</style>

注册子应用

js
1import { registerMicroApps, start } from "qiankun";
2// 基座写法
3const apps = [
4  {
5    name: "vueApp", // 名字
6    // 默认会加载这个HTML,解析里面的js动态执行 (子应用必须支持跨域)
7    entry: "//localhost:10000",
8    container: "#vue", // 容器
9    activeRule: "/vue", // 激活的路径 访问/vue把应用挂载到#vue上
10    props: {
11      // 传递属性给子应用接收
12      a: 1,
13    },
14  },
15  {
16    name: "reactApp",
17    // 默认会加载这个HTML,解析里面的js动态执行 (子应用必须支持跨域)
18    entry: "//localhost:20000",
19    container: "#react",
20    activeRule: "/react", // 访问/react把应用挂载到#react上
21  },
22];
23
24// 注册
25registerMicroApps(apps);
26// 开启
27start({
28  prefetch: false, // 取消预加载
29});

2.子 Vue 应用

js
1// src/router.js
2
3const router = new VueRouter({
4  mode: "history",
5  // base里主应用里面注册的保持一致
6  base: "/vue",
7  routes,
8});
js
1// main.js
2
3import Vue from "vue";
4import App from "./App.vue";
5import router from "./router";
6
7Vue.config.productionTip = false;
8
9let instance = null;
10function render() {
11  instance = new Vue({
12    router,
13    render: (h) => h(App),
14  }).$mount("#app"); // 这里是挂载到自己的HTML中 基座会拿到挂载后的HTML 将其插入进去
15}
16
17// 独立运行微应用
18// https://qiankun.umijs.org/zh/faq#%E5%A6%82%E4%BD%95%E7%8B%AC%E7%AB%8B%E8%BF%90%E8%A1%8C%E5%BE%AE%E5%BA%94%E7%94%A8%EF%BC%9F
19if (!window.__POWERED_BY_QIANKUN__) {
20  render();
21}
22
23// 如果被qiankun使用 会动态注入路径
24if (window.__POWERED_BY_QIANKUN__) {
25  // qiankun 将会在微应用 bootstrap 之前注入一个运行时的 publicPath 变量,你需要做的是在微应用的 entry js 的顶部添加如下代码:
26  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
27}
28
29// 子应用的协议 导出供父应用调用 必须导出promise
30export async function bootstrap(props) {} // 启动可以不用写 需要导出方法
31export async function mount(props) {
32  render();
33}
34export async function unmount(props) {
35  instance.$destroy();
36}

这里不要忘记子应用的钩子导出。

js
1// vue.config.js
2
3module.exports = {
4  devServer: {
5    port: 10000,
6    headers: {
7      "Access-Control-Allow-Origin": "*", //允许访问跨域
8    },
9  },
10  configureWebpack: {
11    // 打umd包
12    output: {
13      library: "vueApp",
14      libraryTarget: "umd",
15    },
16  },
17};

3.子 React 应用

再起一个子应用,为了表明技术栈无关特性,这里使用了一个React项目:

js
1// app.js
2
3import logo from "./logo.svg";
4import "./App.css";
5import { BrowserRouter, Route, Link } from "react-router-dom";
6
7function App() {
8  return (
9    // /react跟主应用配置保持一致
10    <BrowserRouter basename="/react">
11      <Link to="/">首页</Link>
12      <Link to="/about">关于</Link>
13
14      <Route
15        path="/"
16        exact
17        render={() => (
18          <div className="App">
19            <header className="App-header">
20              <img src={logo} className="App-logo" alt="logo" />
21              <p>
22                Edit <code>src/App.js</code> and save to reload.
23              </p>
24              <a
25                className="App-link"
26                href="https://reactjs.org"
27                target="_blank"
28                rel="noopener noreferrer"
29              >
30                Learn React
31              </a>
32            </header>
33          </div>
34        )}
35      />
36
37      <Route path="/about" exact render={() => <h1>About Page</h1>}></Route>
38    </BrowserRouter>
39  );
40}
41
42export default App;
js
1// index.js
2import React from "react";
3import ReactDOM from "react-dom";
4import "./index.css";
5import App from "./App";
6import reportWebVitals from "./reportWebVitals";
7
8function render() {
9  ReactDOM.render(
10    <React.StrictMode>
11      <App />
12    </React.StrictMode>,
13    document.getElementById("root")
14  );
15}
16
17// If you want to start measuring performance in your app, pass a function
18// to log results (for example: reportWebVitals(console.log))
19// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
20reportWebVitals();
21
22// 独立运行
23if (!window.__POWERED_BY_QIANKUN__) {
24  render();
25}
26
27// 子应用协议
28export async function bootstrap() {}
29export async function mount() {
30  render();
31}
32export async function unmount() {
33  ReactDOM.unmountComponentAtNode(document.getElementById("root"));
34}
js
1// index.js
2import React from "react";
3import ReactDOM from "react-dom";
4import "./index.css";
5import App from "./App";
6import reportWebVitals from "./reportWebVitals";
7
8function render() {
9  ReactDOM.render(
10    <React.StrictMode>
11      <App />
12    </React.StrictMode>,
13    document.getElementById("root")
14  );
15}
16
17// If you want to start measuring performance in your app, pass a function
18// to log results (for example: reportWebVitals(console.log))
19// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
20reportWebVitals();
21
22// 独立运行
23if (!window.__POWERED_BY_QIANKUN__) {
24  render();
25}
26
27// 子应用协议
28export async function bootstrap() {}
29export async function mount() {
30  render();
31}
32export async function unmount() {
33  ReactDOM.unmountComponentAtNode(document.getElementById("root"));
34}

重写 react 中的 webpack 配置文件 (config-overrides.js)

yarn add react-app-rewired --save-dev

修改 package.json 文件

json
1
2// react-scripts 改成 react-app-rewired
3"scripts": {
4    "start": "react-app-rewired start",
5    "build": "react-app-rewired build",
6    "test": "react-app-rewired test",
7    "eject": "react-app-rewired eject"
8  },

在根目录新建配置文件

js
1// 配置文件重写
2touch config-overrides.js
3
4// 配置文件重写
5touch config-overrides.js
js
1// config-overrides.js
2
3module.exports = {
4  webpack: (config) => {
5    // 名字和基座配置的一样
6    config.output.library = "reactApp";
7    config.output.libraryTarget = "umd";
8    config.output.publicPath = "http://localhost:20000/";
9    return config;
10  },
11  devServer: function (configFunction) {
12    return function (proxy, allowedHost) {
13      const config = configFunction(proxy, allowedHost);
14
15      // 配置跨域
16      config.headers = {
17        "Access-Control-Allow-Origin": "*",
18      };
19      return config;
20    };
21  },
22};

配置.env 文件

根目录新建.env

js
1
2PORT=20000
3# socket发送端口
4WDS_SOCKET_PORT=20000

React 路由配置

js
1
2import { BrowserRouter, Route, Link } from "react-router-dom"
3
4const BASE_NAME = window.__POWERED_BY_QIANKUN__ ? "/react" : "";
5
6function App() {
7  return (
8    <BrowserRouter basename={BASE_NAME}><Link to="/">首页Link><Link to="/about">关于Link><Route path="/" exact render={() => <h1>hello homeh1>}>Route><Route path="/about" render={() => <h1>hello abouth1>}>Route>BrowserRouter>
9  );
10}
11

1637890538378.png

四、飞冰微前端实战

官方接入指南 https://micro-frontends.ice.work/docs/guide

4.1 react 主应用编写

js
1$ npm init ice icestark-layout @icedesign/stark-layout-scaffold
2$ cd icestark-layout
3$ npm install
4$ npm start
js
1// src/app.jsx中加入
2
3const appConfig: IAppConfig = {
4
5  ...
6
7  icestark: {
8    type: 'framework',
9    Layout: FrameworkLayout,
10    getApps: async () => {
11      const apps = [
12      {
13        path: '/vue',
14        title: 'vue微应用测试',
15        sandbox: false,
16        url: [
17          // 测试环境
18          // 请求子应用端口下的服务,子应用的vue.config.js里面 需要配置headers跨域请求头
19          "http://localhost:3001/js/chunk-vendors.js",
20          "http://localhost:3001/js/app.js",
21        ],
22      },
23      {
24        path: '/react',
25        title: 'react微应用测试',
26        sandbox: true,
27        url: [
28          // 测试环境
29          // 请求子应用端口下的服务,子应用的webpackDevServer.config.js里面 需要配置headers跨域请求头
30          "http://localhost:3000/static/js/bundle.js",
31        ],
32      }
33    ];
34      return apps;
35    },
36    appRouter: {
37      LoadingComponent: PageLoading,
38    },
39  },
40};
41
42
43// 侧边栏菜单
44// src/layouts/menuConfig.ts 改造
45
46const asideMenuConfig = [
47  {
48    name: 'vue微应用测试',
49    icon: 'set',
50    path: '/vue'
51  },
52  {
53    name: 'React微应用测试',
54    icon: 'set',
55    path: '/react'
56  },
57]

4.2 vue 子应用接入

sh
1# 创建一个子应用
2vue create vue-child
js
1// 修改vue.config.js
2
3module.exports = {
4  devServer: {
5    open: true, // 设置浏览器自动打开项目
6    port: 3001, // 设置端口
7    // 支持跨域 方便主应用请求子应用资源
8    headers: {
9      "Access-Control-Allow-Origin": "*",
10      "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
11      "Access-Control-Allow-Headers":
12        "X-Requested-With, content-type, authorIdization",
13    },
14  },
15  configureWebpack: {
16    // 打包成lib包 umd格式
17    output: {
18      library: "icestark-vue",
19      libraryTarget: "umd",
20    },
21  },
22};

src/main.js改造

js
1import { createApp } from "vue";
2import App from "./App.vue";
3import router from "./router";
4import store from "./store";
5
6import {
7  isInIcestark,
8  getMountNode,
9  registerAppEnter,
10  registerAppLeave,
11  setLibraryName,
12} from "@ice/stark-app";
13
14let vue = createApp(App);
15vue.use(store);
16vue.use(router);
17
18// 注意:`setLibraryName` 的入参需要与 webpack 工程配置的 output.library 保持一致
19//  重要 不加不生效 和 vue.config.js中配置的一样
20setLibraryName("icestark-vue");
21
22export function mount({ container }) {
23  // ![](http://img-repo.poetries.top/images/20210731130030.png)
24  console.log(container, "container");
25  vue.mount(container);
26}
27
28export function unmount() {
29  vue.unmount();
30}
31
32if (!isInIcestark()) {
33  vue.mount("#app");
34}

router 改造

js
1import { getBasename } from "@ice/stark-app";
2
3const router = createRouter({
4  // 重要 在主应用中的基准路由
5  base: getBasename(),
6  routes,
7});
8
9export default router;

4.3 react 子应用接入

js
1
2create-react-app react-child
3// src/app.js
4
5import { isInIcestark, getMountNode, registerAppEnter, registerAppLeave } from '@ice/stark-app';
6
7export function mount(props) {
8  ReactDOM.render(<App />, props.container);
9}
10
11export function unmount(props) {
12  ReactDOM.unmountComponentAtNode(props.container);
13}
14
15if (!isInIcestark()) {
16  ReactDOM.render(<App />, document.getElementById('root'));
17}
18
19if (isInIcestark()) {
20  registerAppEnter(() => {
21    ReactDOM.render(<App />, getMountNode());
22  })
23  registerAppLeave(() => {
24    ReactDOM.unmountComponentAtNode(getMountNode());
25  })
26} else {
27  ReactDOM.render(<App />, document.getElementById('root'));
28}
29

npm run eject后,改造 config/webpackDevServer.config.js

js
1
2hot: '',
3port: '',
4...
5
6// 支持跨域
7headers: {
8  'Access-Control-Allow-Origin' : '*',
9  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
10  'Access-Control-Allow-Headers': 'X-Requested-With, content-type, authorIdization',
11},

五、CSS 隔离方案

子应用之间样式隔离:

Dynamic Stylesheet动态样式表,当应用切换时移除掉老应用样式,再添加新应用样式,保证在一个时间点内只有一个应用的样式表生效

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

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

1637890538978.png

html
1<!DOCTYPE html>
2<html lang="">
3  <head>
4    <meta charset="utf-8" />
5    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
6    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
7    <title>shadow dom</title>
8  </head>
9  <body>
10    <p>hello world</p>
11    <div id="shadow"></div>
12    <script>
13      let shadowDOM = document
14        .getElementById("shadow")
15        .attachShadow({ mode: "closed" }); // 外界无法访问
16      let pEle = document.createElement("p");
17      pEle.innerHTML = "hello shadowDOM";
18      let styleEle = document.createElement("style");
19      styleEle.textContent = `p{color:red} `;
20
21      // ![](http://img-repo.poetries.top/images/20210731135230.png)
22      shadowDOM.appendChild(styleEle);
23      shadowDOM.appendChild(pEle);
24
25      // react vue 里面的弹框等因为挂载到body上 所以用shadowDOM不行
26      // 会挂载到全局污染样式
27      //document.body.appendChild(pEle)
28    </script>
29  </body>
30</html>

shadow DOM 内部的元素始终不会影响到它的外部元素,可以实现真正意义上的隔离

六、JS 沙箱机制

1637890539508.png

当运行子应用时应该跑在内部沙箱环境中

  • 快照沙箱,当应用沙箱挂载或卸载时记录快照,在切换时依据快照恢复环境 (无法支持多实例)
  • Proxy 代理沙箱,不影响全局环境

1.快照沙箱

  1. 激活时将当前 window 属性进行快照处理
  2. 失活时用快照中的内容和当前 window 属性比对
  3. 如果属性发生变化保存到modifyPropsMap中,并用快照还原 window 属性
  4. 再次激活时,再次进行快照,并用上次修改的结果还原 window 属性
js
1class SnapshotSandbox {
2  constructor() {
3    this.proxy = window;
4    this.modifyPropsMap = {}; // 修改了哪些属性
5    this.active();
6  }
7  active() {
8    this.windowSnapshot = {}; // window对象的快照
9    for (const prop in window) {
10      if (window.hasOwnProperty(prop)) {
11        // 将window上的属性进行拍照
12        this.windowSnapshot[prop] = window[prop];
13      }
14    }
15    Object.keys(this.modifyPropsMap).forEach((p) => {
16      window[p] = this.modifyPropsMap[p];
17    });
18  }
19  inactive() {
20    for (const prop in window) {
21      // diff 差异
22      if (window.hasOwnProperty(prop)) {
23        // 将上次拍照的结果和本次window属性做对比
24        if (window[prop] !== this.windowSnapshot[prop]) {
25          // 保存修改后的结果
26          this.modifyPropsMap[prop] = window[prop];
27          // 还原window
28          window[prop] = this.windowSnapshot[prop];
29        }
30      }
31    }
32  }
33}
js
1let sandbox = new SnapshotSandbox();
2((window) => {
3  window.a = 1;
4  window.b = 2;
5  window.c = 3;
6  console.log(a, b, c);
7  sandbox.inactive();
8  console.log(a, b, c);
9})(sandbox.proxy);

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

2.Proxy 代理沙箱

js
1class ProxySandbox {
2  constructor() {
3    const rawWindow = window;
4    const fakeWindow = {};
5    const proxy = new Proxy(fakeWindow, {
6      set(target, p, value) {
7        target[p] = value;
8        return true;
9      },
10      get(target, p) {
11        return target[p] || rawWindow[p];
12      },
13    });
14    this.proxy = proxy;
15  }
16}
17let sandbox1 = new ProxySandbox();
18let sandbox2 = new ProxySandbox();
19window.a = 1;
20((window) => {
21  window.a = "hello";
22  console.log(window.a);
23})(sandbox1.proxy);
24((window) => {
25  window.a = "world";
26  console.log(window.a);
27})(sandbox2.proxy);

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

EMP

doc

github