# 微前端

相关资料:

  1. web components阮一峰 (opens new window)
  2. single-spa到期是什么鬼 (opens new window)
  3. qiankun到低是个什么鬼 (opens new window)
  4. 微前端在美团外卖的实践 (opens new window)

# 1、解决的问题

微前端(Micro-Frontends)是一种类似于微服务的架构,它将微服务的理念应用于前端,即将 Web 应用由单一的单页面应用转变为多个小型前端应用聚合为一的应用。然后各个前端应用还可以独立运行、独立开发、独立部署。

  • 使各子模块或者子系统进行隔离,独立部署和独立打包。
  • 能够使各个子系统进行数据分享,例如用户信息。
  • 能够对js、css等进行相互隔离,防止出现污染。

# 2、实现方案

# 1、路由转发

使用后端进行路由转发,不同的路径指向不同的系统。

  • 技术栈就可以进行隔离,独立开发和部署

  • 如果要分享用户信息等,可以通过cookie等技术进行分享

  • 每次路由匹配到的话,都会进行刷新,因此也防止了JS,css的污染问题

缺点:每次跳转都相当于重新刷新了一次页面,不是页面内进行跳转,影响体验

优点: 简单,可快速配置

# 2、iframe嵌套

通过创建一个父程序,在父程序中监听路由的变化,卸载或加载相应的子程序iframe。因每一个iframe就相当于一个单独的页面,所以iframe具有天然的JS和css隔离。在信息共享方面,我们可以使用postMessage或者contentWindow的方式进行。

缺点: iframe样式兼容问题,包括功能性兼容性以及业务性兼容性的问题,另可能会存在一些安全问题:

  • 主应用劫持快捷键操作
  • 事件无法冒泡顶层,不能冒泡至父程序
  • iframe 内元素会被限制在文档树中,视窗宽高限制问题
  • 无法共享基础库进一步减少包体积
  • 事件通信繁琐且限制多(https://blog.csdn.net/willspace/article/details/49003963)

优点:实现起来简单,自带沙盒特性

# 3、web components开发

将每个子应用采用web components进行开发。纯web-components相当于自定义了一个html标签,我们就可以在任何的框架中进行使用此标签。

缺点:需要对之前的子系统都要进行改造,并且通信方面较为复杂

优点: 每个子应用拥有独立的script和css,也可单独部署

# 4、组合应用路由分发

# 1、简介

每个子应用单独的打包,部署和运行。父应用基座,基于父应用进行路由管理,全部使用前端进行路由管理。

例如:有子应用A的路由是/testA,子应用B的路由是/testB,那么父应用在监听到/testA的时候,如果此时处于/testB,那么首先会进行一个子应用B的卸载,卸载完成之后,在去加载子应用A。

优点:纯前端改造,相比于路由式,无刷新,体验感良好

缺点:需要解决样式冲突,JS污染问题,通信技术等

# 2、解决方法

目前的微前端采用的技术方案是组合是应用路由开发,他的缺点是需要自行解决JS的沙盒环境、css的样式重叠或冲突问题、通信技术问题。

# 1、css冲突解决
  1. 类似于vue的scoped。在打包的时候,对css选择器加上响应的属性,属性的key值是一些不重复的hash值,然后在选择的时候,使用属性选择器进行选择。
  2. 可以自定义前缀。在开发子模块之前,需要确定一个全局唯一的css前缀,然后在书写的过程中同一添加此前缀,或在根root上添加此前缀,使用less或sass作用域嵌套即可解。
# 2、js沙盒环境

沙盒环境最主要做的就是一个js作用域、属性等的隔离:

  1. diff方法:当我们的子页面加载到父类的基座中的时候,我们可以生成一个map的散列表。在页面渲染之前,我们先把当前的window上的变量等都存储在这个map中;当页面卸载的时候,我们在遍历这个map,将其数据在替换回去。
class Sandbox {
    constructor() {
      this.cacheMy = {}; // 存放修改的属性,子类属性
      this.cacheBeforeWindow = {}; //存储父类属性
    }
    showPage() {
      this.cacheBeforeWindow = {};
      //父类存起来,for in 遍历原型链上的属性和方法
      for (const item in window) {
        this.cacheBeforeWindow[item] = window[item];
      }
  		//子类放上去
      Object.keys(this.cacheMy).forEach(p => {
        window[p] = this.cacheMy[p];
      })
  
    }
  
    hidePage() {
      for (const item in window) {
        if (this.cacheBeforeWindow[item] !== window[item]) {  
          this.cacheMy[item] = window[item]; // 记录变更
          window[item] = this.cacheBeforeWindow[item]; // 还原window
        }
      }
    }
  }
  
  const diffSandbox = new Sandbox();
  // 模拟页面激活
  diffSandbox.showPage();  // 激活沙箱
  window.info = '我是子应用';
  console.log('页面激活,子应用对应的值', window.info);
  // 模拟页面卸载
  diffSandbox.hidePage();
  console.log('页面卸载后,子应用的对应的值', window.info);
  // 模拟页面激活
  diffSandbox.showPage();   // 重新激活
  console.log('页面激活,子应用对应的值', window.info);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
  1. 使用代理的形式:使用proxy监听get和set方法,针对当前路由进行window的属性或方法的存取
const windowMap = new Map();
const resertWindow = {};

let routerUrl = ''; //地址栏目
const handler = {
    get: function(obj, prop) {
        const tempWindow = windowMap.get(routerUrl);
        return tempWindow[prop];
    },
    set: function(obj, prop, value) {
        if (!windowMap.has(routerUrl)) {
            windowMap.set(routerUrl, JSON.parse(JSON.stringify(resertWindow)));
        }
        const tempWindow =  windowMap.get(routerUrl);
        tempWindow[prop] = value;
    },
};

let proxyWindow = new Proxy(resertWindow, handler);
// 首先是父类的啊属性.
proxyWindow.a = '我是父类的a属性的值';
 
// 改变路由到子类
routerUrl = 'routeA';
proxyWindow.a = '我是routerA的a属性的值'

// 改变路由到父类
routerUrl = '';
console.log(proxyWindow.a); //'我是父类的a属性的值'

// 改变路由到子类
routerUrl = 'routeA';
console.log(proxyWindow.a); //'我是routerA的a属性的值'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

# 3、single-spa

# 1、使用

# 1、子类项目

1、安装single-spa-vue: npm install single-spa-vue --save

2、main.js中加入single-spa-vue相应的生命周期

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

Vue.config.productionTip = false

// 基于基座应用,导出生命周期函数
const vueLifecycle = singleSpaVue({
  Vue,
  appOptions
})
// 启动生命周期
export function bootstrap (props) {
  console.log('app2 bootstrap')
  return vueLifecycle.bootstrap(() => {})
}
// 挂载生命周期
export function mount (props) {
  console.log('app2 mount')
  return vueLifecycle.mount(() => {})
}
// 卸载生命周期
export function unmount (props) {
  console.log('app2 unmount')
  return vueLifecycle.unmount(() => {})
}

const appOptions = {
  el: '#microApp',
  router,
  render: h => h(App)
}

// 支持应用独立运行、部署,不依赖于基座应用
if (!process.env.isMicro) {
  delete appOptions.el
  new Vue(appOptions).$mount('#app')
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

3、配置webpack输出为umd格式:vue.config.js

const package = require('./package.json')
module.exports = {
  // 告诉子应用在这个地址加载静态资源,否则会去基座应用的域名下加载
  publicPath: '//localhost:8082',
  // 开发服务器
  devServer: {
    port: 8082
  },
  configureWebpack: {
    // 导出umd格式的包,在全局对象上挂载属性package.name,基座应用需要通过这个全局对象获取一些信息,比如子应用导出的生命周期函数
    output: {
      // library的值在所有子应用中需要唯一,使用
      library: package.name,
      libraryTarget: 'umd'
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

解析:webpack中的path和publicPath

  • path:配置文件到最后要输出的目录,必须是绝对路径,例如:path: './dist'那就是指定输出到dist目录下
  • publicPath:配置默认公共前缀

# 2、父类项目

1、引入single-spa:npm install single-spa --save

2、父类基准配置,改造父类main.js

  1. 配置子应用,将子应用配置通过registerApplication注册进single-spa中

    registerApplication -> apps(app、activeWhen) -> apps.app中loadApp(请求资源) -> app.activeWhen(激活条件)

  2. 挂载后启动start()

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

Vue.config.productionTip = false

// 远程加载子应用
function createScript(url) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script')
    script.src = url
    script.onload = resolve
    script.onerror = reject
    const firstScript = document.getElementsByTagName('script')[0]
    firstScript.parentNode.insertBefore(script, firstScript)
  })
}

// 记载函数,返回一个promise
function loadApp(url, globalVar) {
  // 支持远程加载子应用
  return async () => {
    await createScript(url + '/js/chunk-vendors.js')
    await createScript(url + '/js/app.js')
    // 这里的return很重要,需要从这个全局对象中拿到子应用暴露出来的生命周期函数
    return window[globalVar]
  }
}

// 子应用列表
const apps = [
  {
    // 子应用名称
    name: 'app1',
    // 子应用加载函数,是一个promise
    app: loadApp('http://localhost:8083', 'app1'),
    // 当路由满足条件时(返回true),激活(挂载)子应用
    activeWhen: location => location.pathname.startsWith('/app1'),
    // 传递给子应用的对象
    customProps: {}
  },
  {
    name: 'app2',
    app: loadApp('http://localhost:8082', 'app2'),
    activeWhen: location => location.pathname.startsWith('/app2'),
    customProps: {}
  }
]

// 注册子应用
for (let i = apps.length - 1; i >= 0; i--) {
  registerApplication(apps[i])
}

new Vue({
  router,
  mounted() {
    start() // 启动
  },
  render: h => h(App)
}).$mount('#app')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

# 2、原理

Single-spa是一个子应用生命周期的调度者,为应用定义了bootstrap、load、mount、unmount四个生命周期回调。

浏览器首次打开父类应用时候:

  • 首先调用registerApplication注册子app;
  • 访问路径时,父类应用判断当前的路由是属于哪一个子应用的,判断依据是apps中的activeWhen配置;
  • 将当前的子应用划分状态,appToLoad、appToUnmounted、appToMounted;
  • 根据子应用的状态,先去执行需要卸载的子应用,卸载完成之后,就会去执行状态为appToLoad、appToMounted的子应用,最后执行相应的回调函数(即子应用中注册的那些生命周期)

# 3、和qiankun的区别

组合式应用路由分发分为两种解决方案,一种是JS entry,另外一种是html entry

  • JS Entry 的方式通常是子应用将资源打成一个 entry script,但这个方案的限制也颇多,如要求子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。除了打出来的包可能体积庞大之外的问题之外,资源的并行加载等特性也无法利用上。
  • HTML Entry 则更加灵活,直接将子应用打出来 HTML 作为入口,主框架可以通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。这样不仅可以极大的减少主应用的接入成本,子应用的开发方式及打包方式基本上也不需要调整,而且可以天然的解决子应用之间样式隔离的问题

qiankun基于single-spa,在single-spa上做了改造,使得接入更加方便:

  • 相比于single-spa,qiankun他解决了JS沙盒环境,不需要我们自己去进行处理。在single-spa的开发过程中,我们需要自己手动的去写调用子应用JS的方法(如上面的 createScript方法),而qiankun不需要,乾坤只需要你传入响应的apps的配置即可,会帮助我们去加载
  • qiankun在JS Entry基础上使用HTML Entry,single-spa使用JS Entry。

# 4、qiankun

在 single-spa 基础上添加更多的功能。以下是 qiankun 提供的特性:

  • 实现了子应用的加载,在原有 single-spa 的 JS Entry 基础上再提供了 HTML Entry
  • 样式和 JS 隔离:CSS 样式隔离,主要有 Shadow DOM 和 Scoped CSS 两种方案;实现沙箱,JS 隔离,主要对 window 对象、各种 listeners 和方法进行隔离
  • 更多的生命周期:beforeMount, afterMount, beforeUnmount, afterUnmount
  • 子应用预加载:提前下载 HTML、CSS、JS,并有三种策略
  • 全局状态管理:类似 Redux,Event Bus
  • 全局错误处理:主要监听 error 和 unhandledrejection 两个事件

# 5、systemJs和Module federation

共用依赖的处理:

  • 造一个utility module包,在这个包导出所有公用资源内容,并用systemJs的importmap在主应用的index.html里申明
  • 使用webpack5 module federation特性实现公用依赖的导入

# 1、systemJs

可以在浏览器使用 ES6 的 import/export 语法,通过 importmap 指定依赖库的地址。

和 single-spa 没有关系,只是 in-browser import/export 和 single-spa 倡导的 in-browser run time 相符合,所以 single-spa 将其作为主要的导入导出工具。

<script type="systemjs-importmap">
    {
      "imports": {
        "@react-mf/root-config": "//localhost:9000/react-mf-root-config.js"
      }
    }
</script>

<script>
  singleSpa.registerApplication({
    name: 'taobao', // 子应用名
    app: () => System.import('@react-mf/root-config'), // 如何加载你的子应用
    activeWhen: '/appName', // url 匹配规则,表示啥时候开始走这个子应用的生命周期
    customProps: { // 自定义 props,从子应用的 bootstrap, mount, unmount 回调可以拿到
        authToken: 'xc67f6as87f7s9d'
    }
  })
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 2、模块联邦

mf(模块联邦)是webpack5的新插件,它的主要功能是我们可以将项目中的部分组件或全部组件暴露给外侧使用。webpack5模块联邦让webpack达到线上Runtime的效果,让代码直接在项目间利用CDN共享,不再需要本地安装npm包,构件再发布。

# 1、使用

模块联邦本身是一个普通的 Webpack 插件 ModuleFederationPlugin,插件有几个重要参数:

  1. name:应用的名称。在其他应用查找的时候,会在这个name的作用域下去查找对应的组件。
  2. remotes:一个映射管理,将其他远程的名称映射成本地的别名,例如上面的我们将其他远程项目app_2映射成了本地app_two
  3. filename:这些对外暴露的模块存放在哪个文件中。
  4. exposes**:**对外暴露的模块。只有对外暴露的相应的模块功能才能被使用。
  5. shared:制定了这个参数,可以让远程加载的模块对应依赖改为使用本地项目的 React 或 ReactDOM。
//webpack配置
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
export default {
  plugins: [
    new ModuleFederationPlugin({
      name: "app_two",
      library: { type: "var", name: "app_two" },
      filename: "remoteEntry.js",
      exposes: {
        Search: "./src/Search"
      },
      shared: ["react", "react-dom"]
    })
  ]
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

在项目中使用,分为两个步骤:

  • 第一个步骤首先是引用对应的模块打包后的脚本,例如app_2的remoteEntry.js,可以将对应的模块打包后的脚本部署到cdn上,然后在template.html中,将其引用
  • 组件中直接使用import导入即可使用
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

module.exports = {
  // other webpack configs...
  plugins: [
    new ModuleFederationPlugin({
      name: "app_one_remote",
      remotes: {
        app_two: "app_two_remote",
        app_three: "app_three_remote"
      },
      exposes: {
        AppContainer: "./src/App"
      },
      shared: ["react", "react-dom", "react-router-dom"]
    }),
    new HtmlWebpackPlugin({
      template: "./public/index.html",
      chunks: ["main"]
    })
  ]
};
//输出:<script src="http://localhost:3003/remoteEntry.js"></script>

import { Search } from "app_two/Search";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

# 2、原理

  1. 加载其他应用的组件通过mf打包后暴露出来的文件remoteEntry.js
  2. 执行remoteEntry.js,在全局作用域下挂载一个名为在mf中定义的name的属性,这个属性暴露了get和override这两个方法
  3. 在组件中引用的时候,会通过__webpack_require__.e去进行引用。
  4. __webpack_require__.e中调用__webpack_require__.f中的对应的方法,从而得到相应的组件。

# 6、其他

组合应用路由分发:分为通用中心路由基准式和特定中心路由基座式

  • 通用中心路由基准式:single-spa、qiankun
  • 特点中心路由基座式:在统一技术栈的情况下,使用当前技术栈生态内的路由和状态管理方案来进行路由和状态的管理。以vue为例,各子应用在加载时利用vue-router和vuex的api将自身信息注册到主工程中,子工程加载的过程相当于将自身注入到主工程内,加载完成后相当于成为了主工程的一部分,通信和路由跳转都与单页应用保持一致。
Last Updated: 11/10/2021, 8:09:16 PM