微前端框架调研

一、前言

1. 为什么要使用微前端

平时我们写项目的时候,都是用单页应用,用组件去拼接项目,随着我们的功能越来越复杂,我们会发现,项目中的组件越来越多,有时项目上线后,我们只想公开组件的某个部分,在更改组件后,会出现一个问题,我们需要把整个个项目重新打包,打包时间会比较长(可能修改了一个组件,打包却需要5-10分钟)。另外,组件的维护也非常不方便(可能有成千上百个组件)。除此之外,一个项目可能有多个团队去开发(比如按模块去划分,交给不同的团队去开发),这样可以面临人员众多,不好管理,团队技术栈不统一的问题。为了解决这些问题,我们就想按应用进行拆分,实现分别管理。
而微前端架构的核心思想就是将前端应用程序按不同的功能不同的维度拆分成多个小型、自治的子应用,每个子应用都有自己的业务逻辑、UI组件和路由系统。这些子应用可以独立开发、测试和部署,然后通过一种集成机制进行组合和协同工作(通过主应用来加载这些子应用)。微前端的核心在于拆,拆完后再合,实现分而治之!

2. 解决的问题

  • 同步更新(多个业务应用依赖同一个服务应用的功能模块,只需更新服务应用,其他业务应用可以立马更新)
  • 增量升级(对旧的应用程序逐步翻新,对原有整体几乎不做修改地升级)
  • 简单、解耦的代码库
  • 独立部署
  • 自主的团队(根据业务功能划分,而不是技术种类)

3. 如何实现微前端

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

4. 实现微前端的技术方案

  • 采用何种方案进行应用拆分?
  • 采用何种方式进行应用通信?
  • 应用之间如何进行隔离?

技术

描述(拆分、通信、隔离)

缺点

微前端框架

iframe

  • 微前端最简单方案,通过iframe加载子应用
  • 通信可以通过postMessage进行通信
  • 完美的沙箱机制自带应用隔离
  • 不是单页应用,会导致浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用
  • 弹框类的功能无法应用到整个大应用中,只能在对应的窗口内展示
  • 由于可能应用间不是在相同的域内,主应用的 cookie 要透传到根域名都不同的子应用中才能实现免登录效果
  • 每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程,占用大量资源的同时也在极大地消耗资源
  • iframe的特性导致搜索引擎无法获取到其中的内容,进而无法实现应用的seo

MicroApp

wujie

Web Components

  • 将前端应用程序分解为自定义HTML元素
  • 基于CustomEvent实现通信
  • Shadow DOM天生的作用域隔离
  • 浏览器支持问题(无法兼容所有浏览器)
  • 学习成本
  • 调试困难
  • 修改样式困难等问题

MicroApp

wujie

SystemJS/single-spa

  • single-spa通过路由劫持实现应用的加载(采用SystemJS),提供应用间公共组件加载及公共业务逻辑处理。子应用需要暴露固定的钩子(bootstrap、mount、unmount)接入协议
  • 基于props主子应用间通信
  • 无沙箱机制,需要自己实现JS沙箱及CSS沙箱
  • 学习成本
  • 无沙箱机制
  • 需要对原有的应用进行改造
  • 子应用间相同资源重复加载问题

qiankun(基于single-spa)

Module federation

  • 通过模块联邦将组件进行打包导出使用
  • 共享模块的方式进行通信
  • 无CSS沙箱和JS沙箱

需要webpack5

EMP

5. 微前端方案种类

目前国内微前端方案大概分为:

  • 基座模式:通过搭建基座、配置中心来管理子应用。如基于 single-spa 的偏通用的 qiankun 方案,也有基于本身团队业务量身定制的方案。
  • 自组织模式: 通过约定进行互调,但会遇到处理第三方依赖等问题。
  • 去中心模式: 脱离基座模式,每个应用之间都可以彼此分享资源。如基于 Webpack 5 Module Federation 实现的 EMP 微前端方案,可以实现多个应用彼此共享资源分享。

二、single-spa

Single-spa 是一个前端微服务框架,允许将多个前端应用集成到一个主应用中,实现独立开发、测试和部署。它支持不同技术栈的应用在同一页面上协同工作,通过生命周期管理实现应用的动态加载和卸载,适用于构建大型、可扩展的前端项目。

1. SystemJS实现

实现微前端的技术方案中,single-spa 是个微前端框架,它基于 SystemJs 实现,而另外一个应用广泛的微前端框架乾坤 qiankun 则是基于 single-spa 实现的,所以这里有必要了解一下 SystemJS 的基本实现流程。SystemJS 是一个通用的模块加载器,支持加载多种模块格式,如 AMD、CommonJS、UMD 等。它能在浏览器上动态加载模块,可以在运行时按需加载,它可以为每个模块创建独立的作用域,以防止模块之间的全局污染。它还提供了配置选项和插件系统,可以根据需求进行灵活的定制和扩展。微前端的核心就是加载微应用,我们将应用打包成模块,在浏览器中通过 SystemJS 来加载模块。以下是模拟 SystemJS 模拟实现流程的代码。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport">
    <title>Document</title>
</head>

<body>
<!--主应用 - 基座 - 用来加载子应用-->

<div id="root"></div>
<script type="systemjs-importmap">
<!--模拟webpack打包react项目-->
    {
        "imports":{
            "react-dom":"https://cdn.bootcdn.net/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js",
            "react":"https://cdn.bootcdn.net/ajax/libs/react/18.2.0/umd/react.development.js"
        }
    }
</script>
<script>
    // 直接加载子应用, 导入打包后的包来进行加载, 采用的规范:system规范
    // 以下是自己实现的systemjs
    // 1) systemjs 是如何定义的: 先看源码打包后的结果,发现是执行函数System.register(依赖列表,回调函数返回值:setters,execute)
    // 2) react , react-dom  加载后调用setters 将对应的结果赋予给 webpack
    // 3) 调用执行逻辑  执行页面渲染
    // 模块规范 用来加载system模块的

    // 本质就是先加载依赖列表 再去加载真正的逻辑
    // (内部通过script脚本加载资源 , 给window拍照保存先后状态)

    const newMapUrl = {};

    // 解析 importsMap
    function processScripts() {
        Array.from(document.querySelectorAll('script')).forEach(script => {
            if (script.type === "systemjs-importmap") {
                const imports = JSON.parse(script.innerHTML).imports
                Object.entries(imports).forEach(([key, value]) => newMapUrl[key] = value)
            }
        })
    }

    // 加载资源
    function load(id) {
        return new Promise((resolve, reject) => {
            const script = document.createElement('script');
            script.src = newMapUrl[id] || id; // 支持cdn的查找
            script.async = true;
            document.head.appendChild(script);
            // 此时会执行代码
            script.addEventListener('load', function () {
                let _lastRegister = lastRegister;
                lastRegister = undefined;
                resolve(_lastRegister);
            })
        })
    }

    let set = new Set(); // 1)先保存window上的属性
    function saveGlobalProperty() {
        for (let k in window) {
            set.add(k);
        }
    }

    saveGlobalProperty();

    function getLastGlobalProperty() {  // 2)看下window上新增的属性
        for (let k in window) {
            if (set.has(k)) continue;

            set.add(k);
            return window[k]; // 我通过script新增的变量
        }
    }

    let lastRegister;//保存回调参数

    class SystemJs {
        import(id) { // 这个id原则上可以是一个第三方路径cdn
            return Promise.resolve(processScripts()).then(() => {
                // 1)去当前路径查找对应的资源(如本例中的index.js)
                // 这里以"./"路径为例实现流程,实际路径也可能是http,https,这里不做处理
                const lastSepIndex = location.href.lastIndexOf('/');
                const baseURL = location.href.slice(0, lastSepIndex + 1);
                if (id.startsWith('./')) {
                    return baseURL + id.slice(2);
                }
                
            }).then((id) => {
                // 根据文件的路径 来加载资源
                let execute
                return load(id).then((register) => {
                    //register: [deps:依赖列表, declare:回调函数,返回setters,execute]
                    let {setters, execute: exe} = register[1](() => {
                    })
                    execute = exe
                    // execute 是真正执行的渲染逻辑
                    // setters 是用来保存加载后的资源,加载资源调用setters
                    return [register[0], setters]
                }).then(([registeration, setters]) => {
                    return Promise.all(registeration.map((dep, i) => {
                        return load(dep).then(() => {
                            const property = getLastGlobalProperty()
                            // 加载完毕后,会在window上增添属性 window.React window.ReactDOM,将新增的属性传递过去
                            setters[i](property)
                        })
                        // 拿到的是函数,加载资源 将加载后的模块传递给这个setter
                    }))
                }).then(() => {
                    execute();
                })
            })
        }

        register(deps, declare) {
            // 将回调的结果保存起来
            lastRegister = [deps, declare]
        }
    }

    const System = new SystemJs()
    System.import('./index.js').then(() => {
        console.log('模块加载完毕')
    })
</script>
</body>
</html>

webpack 打包后的 index.js 中调用 System.register() 方法

大致步骤如下:

1)webpack 打包应用生成 index.htmlindex.js 文件

2)index.html 中通过 new SystemJs() 创建 SystemJs 实例

3)通过 System.import('./index.js) 加载模块,这个过程中:

【1】首先通过 processScripts() 方法,通过解析type为 systemjs-importmap script 标签,将 imports 中的依赖映射关系存到 newMapUrl

【2】获取引入模块文件在当前路径下的地址,并通过 load() 函数加载对应的资源文件 index.js

【3】load() 函数加载资源时,通过 script 脚本加载资源,加载完毕后,会在 window 上添加 window.Reactwindow.ReactDOM 等属性

【4】加载 index.js 时,会执行 index.js 中的生成的 System.register(deps,declare) 函数,在 register() 函数中,将回调的参数 deps,declare 保存到 lastRegister 中,加载完成后,将lastRegister 的值返回

【5】基于上一步返回的 lastRegister 解构出依赖列表和 settersexecute 函数(setters 是用来保存加载后的资源,加载资源调用 settersexecute 是真正执行的渲染逻辑)

【6】遍历依赖列表,通过 load() 函数下载依赖,然后通过 getLastGlobalProperty() 函数获取步骤【3】新增的模块(在此之前会通过 saveGlobalProperty()window 进行快照,保存 window上的属性,然后在getLastGlobalProperty() 函数中通过对比拿到新增的属性),将加载后的模块传递给 setters 函数,setters 函数将加载后的资源对象赋值到相应的 __WEBPACK_EXTERNAL_MODULE_react_dom__[key] 、__WEBPACK_EXTERNAL_MODULE_react__[key] 对象上(key 为模块包含的属性值,比如 react-dom 包含findDomNode、render……等)

【7】所有的依赖列表加载完成以后,通过 execute() 函数执行真正的渲染逻辑

2. single-spa实战

2.1. 创建基座项目

基座项目,用于加载子应用

#安装single-spa脚手架
npm install create-single-spa -g
#创建single-spa项目 substrate 基座
create-single-spa substrate

根据自己需要选择配置项,这里的demo选项如下图所示:

配置项说明:

# 创建⼦项⽬
> single-spa application / parcel
# ⽤于跨应⽤共享JavaScript逻辑的微应⽤
> in-browser utility module (styleguide, api cache, etc)
# 创建基座容器
> single-spa root config

生成的项目目录结构如下:

npm install 安装依赖,再执行 npm run start 命令,这时会在本地启动一个端口号为 9000 的服务,启动后会调用文件 index.ejs ,执行 System.import('@singleSpaDemo/root-config') 方法,该方法从 type 为 systemjs-importmap 的 script 中找到 @singleSpaDemo/root-config 对应的地址 //localhost:9000/singleSpaDemo-root-config.js 去加载对应的资源文件 singleSpaDemo-root-config.js(该文件是根据你设置的组织名称和项目名称拼接后自动生成的) ,在该文件中配置了当路径为“/”时加载一个远程的资源文件 https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js,显示资源文件的内容。

启动后,页面如下:

2.2. 创建react子应用

create-single-spa react-project

配置项如下:

生成的项目目录结构如下:

webpack.config.js 中配置webpack

// 关闭externals,默认singleSpaDefaults插件将react、react-dom都排除了,这里删掉这个排除项
delete defaultConfig.externals; 
return merge(defaultConfig, {
  devServer:{
    port:3001 // 修改端⼝
  }
});

npm install 安装依赖

单独启动时,执行 npm run start:standalone 命令,在本地启动端口号为 3001 的服务,启动后,页面如下:

要集成到父应用中,步骤如下:

1)先执行 npm run start 命令, 启动后的页面如下,拷贝路径

http://localhost:3001/singleSpaDemo-react.js(这个路径对应的是webpack打包出来的该react子应用的System模块文件)

2)去基座项目(父应用)的配置文件 singleSpaDemo-oot-config.js 中,注册该 react 子应用

registerApplication({
  name: "@singleSpaDemo/react",//名字随便取,不重名即可
  app: () => System.import("@singleSpaDemo/react"),
  activeWhen: (location) =>
    location.pathname.startsWith('/react'),
});

3)步骤2)中 System.import("@singleSpaDemo-react") 中的文件会从 index.ejs 中的 importmap 中去找,所以需要如下配置(配置的地址为步骤1中拷贝的地址):

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

4)需要注意的是,子应用需要提供接入协议,对于single-spa来说,保留接入协议,就可以接入到项目中,该react 应用中是暴露以下:

5) 保证主应用和子应用(子应用启动npm run start/npm run start:standalone都可以)都启动的情况下,就可以在浏览器访问主应用(http://localhost:9000)和react子应用(http://localhost:9000/react)了

2.3. 创建vue子应用

create-single-spa vue-project

配置项如下:

生成的项目目录结构如下:

vue.config.js 中修改配置:

const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  devServer: {
    port: 4000
  }
})

单独启动时,执行 npm run serve:standalone 命令,在本地启动端口号为 4000 的服务,启动后,发现报错

修改一下 vue.config.js 配置即可:

const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  devServer: {
    port: 4000
  },
  chainWebpack: (config) => {
    if (config.plugins.has("SystemJSPublicPathWebpackPlugin")) {
      config.plugins.delete("SystemJSPublicPathWebpackPlugin");
    }
  }
})

重启后,页面如下:

修改 vue项目中的 router/index.js 路径配置,这样vue项目路径会以 /vue开始

要集成到父应用中,步骤如下:

1)先执行 npm run serve 命令,启动后页面如下,拷贝路径 http://localhost:4000/js/app.js(这个路径对应的是webpack打包出来的该vue子应用的System模块文件)

2)去基座项目(父应用)的配置文件 singleSpaDemo-oot-config.js 中,注册该vue子应用

registerApplication({
  name: "@singleSpaDemo/vue",//名字随便取,不重名即可
  app: () => System.import("@singleSpaDemo/vue"),
  activeWhen: (location) =>
    location.pathname.startsWith('/vue'),
});

3)步骤2)中 System.import("@singleSpaDemo-vue") 中的文件会从 index.ejs 中的 importmap 中去找,所以需要如下配置(配置的地址为步骤1中拷贝的地址):

  <script type="systemjs-importmap">
    {
      "imports": {
        "@singleSpaDemo/root-config": "//localhost:9000/singleSpaDemo-root-config.js",
        "@singleSpaDemo/react": "//localhost:3001/singleSpaDemo-react.js",
        "@singleSpaDemo/vue": "//localhost:4000/js/app.js"
      }
    }
  </script>

4)需要注意的是,子应用需要提供接入协议,对于 single-spa 来说,保留接入协议,就可以接入到项目中,该vue 应用中是暴露以下:

5) 保证主应用和子应用(子应用启动npm run serve/npm run serve:standalone都可以)都启动的情况下,就可以在浏览器访问主应用(http://localhost:9000)和react子应用(http://localhost:9000/vue/)了

修改 vue.config.js 中的 publicPathhttp://localhost:4000,这样vue子应用页面会从子应用路径下去下载资源而不是从父应用路径下去下载资源

3. single-spa实现

3.1. single-spa简单应用

在实现 single-spa 之前,先通过 single-spa 的简单应用了解一下大致的执行过程。在 index.html 页面中,远程调用 single-spa.min.js,写好子应用 app1,app2,然后改写子应用 app1,app2 的接入协议 bootstrap, mount, unmount,通过 registerApplication() 注册应用,通过start() 开启路径监控。

<!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>
    <script type="module">
        // 微前端就是可以加载不同的应用,基于路由的微前端
        // 如何接入已经写好的应用:对于singlespa而言,需要改写子应用 (接入协议) bootstrap, mount, unmount
        // /a  /b
        import { registerApplication, start } from "https://cdn.staticfile.net/single-spa/6.0.0/es2015/esm/single-spa.min.js"
        let app1 = {
            bootstrap: [
                async () => console.log('app1 bootstrap1'),
                async () => console.log('app1 bootstrap2')
            ],
            mount: [
                async (props) => {
                    console.log('app1 mount1', props)
                },
                async () => {
                    console.log('app1 mount2')
                }
            ],
            unmount: async (props) => {
                console.log('app1 unmount')
            }
        }
        let app2 = {
            bootstrap: async () => console.log('app2 bootstrap1'),
            mount: [
                async () => {
                    return new Promise((resolve, reject) => {
                        setTimeout(() => {
                            console.log('app2 mount')
                            resolve()
                        }, 1000)
                    })
                }
            ],
            unmount: async () => {
                console.log('app2 unmount')
            }
        }
        // 当路径是#/a 的时候就加载 a应用 app1
        // 当路径是#/b 的时候就加载 b应用 app2
        // 所谓的注册应用 就是看一下路径是否匹配,如果匹配则“加载”对应的应用
        registerApplication('a', async () => app1, location => location.hash.startsWith('#/a'), { a: 1 })
        registerApplication('b', async () => app2, location => location.h
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值