微前端-技术方案总结

开始写这篇文章的起因是公司的大前端部门开始实现公司自己的微前端框架
在和大前端部门的合作中,对微前端相关的知识和技术点、难点的总结

微前端是什么

微前端的思想概念来源于微服务架构。是一种由独立交付的多个前端应用组成整体的架构风格。
具体的,将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的小块,而在用户看来仍然是内聚的单个产品

为什么要有微前端

我们正常的一个单体应用,主要负责一个完整的业务,所以也被称为独石应用(一个建筑完全由一个石头雕塑成)

但是随着版本迭代会出现很多痛点

  • 增量更新慢
    • 项目文件越多,每次打包编译需要的时间也越长
    • 每次上线,未修改的文件都需要重新编译(chunkhash 和 dll 并不能从根本上解决问题)
  • 高耦合
    • 修改代码带来的关联影响大
    • 项目庞大导致增加新人熟悉项目的难度和时间
  • 无法独立部署:无关的功能模块没有拆分,无法各自独立部署
  • 无法团队自治:如果将模块拆分给各个小团队,无法实现团队自我维护

从公司和用户层面来看,不利于效率提升
一个公司的 OA、CRM、ERP、PMS 等后台,没有统一的入口,不方便使用,降低工作效率

从用户层面来看,不利于用户体验流量管理
一个被更多赋能的产品或者应用,更容易获得用户的青睐,获得流量

因此,在借鉴微服务架构的基础上,诞生了微前端架构

微前端作为一种大型应用的解决方案,目的就是解决上面提到的痛点,做到以下几点:

  • 技术选型独立:每个开发团队自行选择技术栈(VueReactAngularJquery),不受其他团队影响
  • 业务独立:每个交付产物既可以独立使用,也可以融合成一个大型应用使用
  • 样式隔离:父子应用之间、子应用之间不会有样式冲突、覆盖

技术方案

当前主流的方案

  • 大仓库拆分成独立的模块文件夹,通过 webpack 统一去构建。本质上没有变化,只是在项目结构和编译分包上的优化。
  • 大仓库拆成小仓库。互相之间通过 location.href 切换。比较适合后台类型的应用
  • 大仓库拆成小仓库,发包到 npm 上,然后集成。较上者更进了一步,主要针对 headerfootersiderBar 等公共部分组件。
  • 大仓库拆成小仓库,不通过页面跳转,通过注入的方式集成到主应用
    • iframe(天然的微前端方案,但是弊端很多)
    • single-spa
    • web components(最适合但是兼容性最差)

从趋势上看,最终都是向注入集成的技术方案靠拢

iframe 的优缺点

iframe 的优点

  • 浏览器原生的硬隔离方案,改造成本低
  • 天然支持 CSS 隔离、JS 隔离

iframe 的问题

  • URL 不同步
    • iframe 内部页面跳转,url 不会更新
    • 浏览器刷新导致 iframe url 状态丢失、后退前进按钮无法使用。
  • UI 不同步
    • DOM 结构不共享。iframe 里的弹窗遮罩无法在整个父应用上覆盖
  • 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
  • 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
  • 双滚动条

综合考量之下,iframe 不适合作为微前端的方案,最多只能作为过渡阶段的方案来使用

技术点

Entry 方式

Entry 用于父应用引入子应用相应的资源文件(包括 JSCSS),主要分为两种方式:

  • JS Entry
  • HTML Entry

JS Entry 方式

JS Entry 的原理是:

  1. CSS 打包进 JS,生成一个 manifest.json 配置文件
  2. manifest.json 中标识了子应用资源文件的相对路径地址
  3. 主应用通过插入 script 标签 src 属性的方式加载子应用资源文件(子应用域名 + manifest.json 中的相对路径地址)

基于这样的原理,因此 JS Entry 有缺陷:

  • 打包时,需要额外对工程化代码做修改,生成一份资源配置文件 manifest.json 给主应用加载
  • 打包时,需要额外对样式打包做修改,需要把 CSS 打包进 JS 中,也增加了编译后的包体积
  • 打包时,不能在 html 中插入行内 script 代码。因为 manifest.json 中只能存放地址路径。因此要禁止 webpack 把配置代码直接打入 html
// vue-cli 3.x vue.config.js
config.optimization.runtimeChunk('single') // 不能使用
  • 父子应用域名不一致,父应用加载子应用 manifest.json 会发生跨域,需要额外处理

HTML Entry 方式

HTML Entry 是利用 import-html-entry 直接获取子应用 html 文件,解析 html 文件中的资源加载入主应用
第一步,解析远程 html 文件,得到一个对象

// 使用
import importHTML from 'import-html-entry'
importHTML(url, opts = {})

// 获取到的对象
{
    template: 经过处理的脚本,link、script 标签都被注释掉了,
    scripts: [脚本的http地址 或者 { async: true, src: xx } 或者 代码块],
    styles: [样式的http地址],
    entry: 入口脚本的地址,是标有 entry 的 script 的 src,或者是最后一个 script 标签的 src
}

第二步,处理这个对象,向外暴露一个 Promise 对象,这个对象回传的值就是下面这个对象

// import-html-entry 源码中对获取到的对象的处理
{
     // template 是 link 替换为 style 后的 template
    template: embedHTML,
    // 静态资源地址
    assetPublicPath,
    // 获取外部脚本,最终得到所有脚本的代码内容
    getExternalScripts: () => getExternalScripts(scripts, fetch),
    // 获取外部样式文件的内容
    getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
    // 脚本执行器,让 JS 代码(scripts)在指定 上下文 中运行
    execScripts: (proxy, strictGlobal) => {
        if (!scripts.length) {
            return Promise.resolve();
        }
        return execScripts(entry, scripts, proxy, { fetch, strictGlobal });
    }
}

getExternalStyleSheets 做了哪些事?
getExternalStyleSheets 会做两件事

  1. 将子应用中的 link 标签转为 style 标签
  2. 把对应的 href 远程文件内容通过 fetch get 的方式放进 style 标签中
    • 如果是 inline style,通过 substring 的方式获取行内 style 代码字符串
    • 如果是 远程 style,通过 fetch get 方式获取 href 地址对应的代码字符串
// import-html-entry getExternalStyleSheets 源码
export function getExternalStyleSheets(styles, fetch = defaultFetch) {
    return Promise.all(styles.map(styleLink => {
        if (isInlineCode(styleLink)) {
            // if it is inline style
            return getInlineCode(styleLink);
        } else {
            // external styles
            return styleCache[styleLink] || (styleCache[styleLink] = fetch(styleLink).then(response => response.text()));
        }
    }))
}

getExternalScripts 做了哪些事?
getExternalScripts 同样做了两件事

  1. 按顺序获取子应用 html 中的 script,并拼成一个 scripts 数组
  2. 使用 fetch get 的方式循环加载 scripts 数组
    • 如果是 inline script,通过 substring 的方式获取行内 JS 代码字符串
    • 如果是 远程 script ,通过 fetch get 方式获取 src 地址对应的代码字符串

最后返回一个 scriptsText 数组,数组里每个元素都是子应用 scripts 数组中的可执行代码的字符串
这个数组就是 execScripts 真正使用的参数

这里会遇到一些问题:

  1. 跨域
    父应用 fetch 子应用第三方库的 cdn 文件,大部分 cdn 站点支持 CORS 跨域
    但是少部分 cdn 站点不支持,因此导致跨域 fetch 文件失败

  2. 重复加载
    一些通用的 cdn 文件,父子应用都进行了加载,当父应用加载子应用时,会因为重复加载执行这部分 cdnJS 代码,导致错误

解决方案:
直接硬编码把需要加载的 cdn script 写进父应用的 html
父应用直接加载父子应用需要的全部 cdn
子应用通过是否通过微前端方式加载的标识判断是否独立运行,自行独立加载这部分 cdn 文件

这个方案的优点是:父应用不需要做重复加载的逻辑判断,交给子应用自己判断
相对应的缺点是:A子应用不需要用到的B子应用的 cdn 也在第一时间加载,徒耗性能

execScripts 做了哪些事?
execScripts 是真正执行子应用 JS 文件的函数

  1. 先调用 getExternalScripts 获取可执行的 JS 代码数组
  2. 最终使用 eval 在当前上下文中执行 JS 代码。
  3. proxy 参数支持传入一个上下文对象,从而保证了 JS沙盒 的可行性

HTML Entry 优于 JS Entry 的地方

  1. 不用生成额外的 manifest.json
  2. 不用把 css 打包进 js
  3. 全局 css 独立打包,不会冗余
  4. 不使用生成 script 的方式插入子应用 JS 代码,不会生成额外的 DOM 节点

JS 沙盒

JS 沙盒的目的是隔离两个子应用,避免互相影响
JS 沙盒的实现有两种方式

  • 代理沙盒:利用 proxy API,可以实现多应用沙箱,把不同的应用对应不同的代理
  • 快照沙盒:将不同沙盒之间的区别保存起来,只能两个,多了会混乱

代理沙盒

  • 获取属性:proxyObj[key] || window[key]
  • 设置属性:proxyObj[key] = value
    利用函数作用域的形参 window(实参 proxyObj),来代替全局对象 window
// proxy 的 demo
class ProxySandbox {
    constructor() {
        const rawWindow = window;
        const fakeWindow = {}
        const proxy = new Proxy(fakeWindow, {
            set(target, p, value) {
                target[p] = value;
                return true
            },
            get(target, p) {
                return target[p] || rawWindow[p];
            }
        })
        this.proxy = proxy
    }
}
let sandbox1 = new ProxySandbox();
let sandbox2 = new ProxySandbox();
window.a = 1;
((window) => {
    window.a = 'hello';
    console.log(window.a)
})(sandbox1.proxy);
((window) => {
    window.a = 'world';
    console.log(window.a)
})(sandbox2.proxy);

快照沙盒

沙箱失活时,把记录在 window 上的修改记录赋值到 modifyPropsMap 上,等待下次激活
沙箱激活时,先生成一个当前 window 的快照 windowSnapshot,把记录在沙箱上的 window 修改对象 modifyPropsMap 赋值到 window
沙箱实际使用的还是全局 window 对象

// snapshot 的 demo
class SnapshotSandbox {
    constructor() {
        this.windowSnapshot = {}; // window 状态快照
        this.modifyPropsMap = {}; // 沙箱运行时被修改的 window 属性
        this.active();
    }
    
    // 激活
    active() {
        // 设置快照
        this.windowSnapshot = {};
        for (const prop in window) {
            if (window.hasOwnProperty(prop)) {
                this.windowSnapshot[prop] = window[prop];
            }
        }
        // 还原这个沙箱上一次记录的环境
        Object.keys(this.modifyPropsMap).forEach(p => {
            window[p] = this.modifyPropsMap[p]
        })
    }
    
    // 失活
    inactive() {
        // 记录本次的修改
        // 还原 window 到激活之前的状态
        this.modifyPropsMap = {};
        for (const prop in window) {
            if (window.hasOwnProperty(prop) && this.windowSnapshot[prop] !== window[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) //1
    sandbox.inactive() //失活
    console.log(window.a) //undefined
    sandbox.active() //激活
    console.log(window.a) //1
})(sandbox.proxy);
//sandbox.proxy就是window

目前主流方法是优先代理沙箱,如果不支持 proxy API,则使用快照沙箱

CSS 沙盒

子应用样式

子应用通过 BEM + css module 的方式隔离
保证A子应用的样式不会在B子应用的 DOM 上生效

子应用切换

子应用失活,样式 style 不需要删除,因为已经做了隔离
已加载的子应用重新激活,也不需要重新插入 style 标签,避免重复加载

父子应用通信

父子应用通信主要分为:数据事件

数据

  • url
  • localStorage
  • sessionStorage
  • cookie
  • eventBus

事件

  • 子应用 main.js export 到父应用的 window 对象
  • 父应用 自定义事件
  • 父应用 window.eventBus
  • H5 api sharedWorker
  • H5 api BroadcastChannel

目前用的较多的方案是 eventBus自定义事件

应用监控

每个项目都有对自己的应用监控

  • 用户行为监控
  • 错误监控
  • 性能监控

如果使用代理沙箱
因为 proxy API 只能代理对象的 get set,无法代理事件的监听和移除,子应用的监控在代理对象上无法执行
所以只能直接在父应用上监听父子应用的事件

如果使用快照沙箱
因为同时只有一个子应用被激活,只有一个子应用的JS在执行,同时又是直接操作 window 对象
可以考虑直接使用子应用自己的监控,因为都是对 window 的事件监听,所以可以同时监听到父子两个应用的事件

下面列举 single-spaqiankun 的监控方案

// single-spa 的异常捕获
export { addErrorHandler, removeErrorHandler } from 'single-spa';

// qiankun 的异常捕获
// 监听了 error 和 unhandlerejection 事件
export function addGlobalUncaughtErrorHandler(errorHandler: OnErrorEventHandlerNonNull): void {
  window.addEventListener('error', errorHandler);
  window.addEventListener('unhandledrejection', errorHandler);
}

// 移除 error 和 unhandlerejection 事件监听
export function removeGlobalUncaughtErrorHandler(errorHandler: (...args: any[]) => any) {
  window.removeEventListener('error', errorHandler);
  window.removeEventListener('unhandledrejection', errorHandler);
}

现有框架对比

image.png

参考上图

single-spa

比较基础的微前端框架,也是我公司大前端部门搭建自有框架的选择方案
需要自己定制的部分较多,包括

  • Entry 方式
  • JS 沙盒
  • CSS 沙盒
  • 父子应用通信方式
  • 应用监控事件处理

官网:https://zh-hans.single-spa.js.org/
github:https://github.com/single-spa/single-spa

icestark

icestark 是阿里的微前端框架,现在的不限制主应用所使用的框架了
针对 React 主应用不限框架的主应用 有两种不同的接入方式

PS:通过下面的引用描述来看,目前应该不支持多个子应用共存(待确认)

一般情况下不存在多个微应用同时运行的场景

页面运行时同时只会存在一个微应用,因此多个微应用不存在样式相互污染的问题

在 Entry 方式上

  • 通过 fetch + 创建 script 标签的方式注入。有一点 JS Entry 和 HTML Entry 中间过渡的意思
  • 不需要子应用生成配置文件,但是会生成 scriptDOM 节点

在 JS 沙盒上

  • 如果是不可控的子应用官方建议使用 iframe 的方案嵌入
  • 如果是可控的子应用,使用代理沙盒(还未研究过对应的源码,但快照沙盒作为降级策略,应该也有被使用,待确认)

在 CSS 沙盒上

  • 主要方案是 BEM + CSS Modules
  • 实验性方案是 Shadow DOM
  • 全局样式库,例如 normalize.cssreset.css 统一由主应用引入

在应用通信上

  • 使用了 eventBus 的方案来处理数据事件

在应用监控上

  • 统一由主应用来监控

官网:https://micro-frontends.ice.work/
github:https://github.com/ice-lab/icestark

qiankun

同样是阿里的微前端框架,qiankun 是对 single-spa 的一层封装
核心做了构建层面的一些约束以及沙箱能力,支持多子应用并存
但是接入的修改成本较高
总的来说算是目前比较优选的微前端框架

在 Entry 方式上

  • 已经支持 HTML Entry 的方式,在框架内部也是依赖的 import-html-entry

在 JS 沙盒上

  • 使用三种沙盒
    • legacySandBox:支持 proxy API 且只有单子应用并存
    • proxySandBox:支持 proxy API 且多子应用并存
    • snapshotSandBox:不支持 proxy API 的快照沙盒
  • legacySandBox 其实是 proxySandBoxsnapshotSandBox 的结合,既想要 proxy 的代理能力,又想在一定程度上有直接操作 window 对象的能力

在 CSS 沙盒上

  • 主要方案是 BEM
  • BEM 不需要子应用自己处理,在子应用接入 qiankun 框架时可以通过配置统一增加 prefix
  • 全局样式库,例如 normalize.cssreset.css 统一由主应用引入

在应用通信上

  • Actions 方案:适用于通信较少
    • 数据上:使用一个 store 来存储数据,使用观察者模式来监听
    • 事件上:利用观察者派发事件的触发事件通信
  • Shared 方案:适用于通信较多
    • 主应用基于 redux 维护一个状态池,通过 shared 实例暴露一些方法给子应用使用
    • 子应用需要单独维护一份 shared 实例,保证在使用和表现上的一致性
      • 独立运行时使用自身的 shared 实例
      • 在嵌入主应用时使用主应用的 shared 实例
    • 数据和事件都可以通过 redux 来通信

在应用监控上

  • 统一由主应用来监控

官网:https://qiankun.umijs.org/zh
github:https://github.com/umijs/qiankun

Garfish

从开发者大会上看到的方案,来自于字节跳动,有希望成为最优的方案

  • 支持多子应用并存
  • 支持 HTML EntryJS Entry
  • JS 沙盒直接使用快照沙盒
  • 通过HTML整体快照,来实现 CSS 沙盒
  • 通信
    • 数据:同样通过一个 store 来保存数据
    • 事件:通过自定义事件
  • 监控
    • 保留 window addEventListener removeEventListener 的副本
    • 在沙盒 document 对象上监听监控

最大的特点是,能够快照子应用的 DOM 节点,保持 DOM 树
加上 JS 沙盒 、 CSS 沙盒,能够保持整个子应用的完整状态

官网:https://garfish.dev/
github:https://github.com/bytedance/garfish

你可能感兴趣的:(微前端-技术方案总结)