微前端-技术方案总结

2021-07-03  本文已影响0人  Johnson杰

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

微前端是什么

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

为什么要有微前端

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

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

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

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

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

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

技术方案

当前主流的方案

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

iframe 的优缺点

iframe 的优点

iframe 的问题

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

技术点

Entry 方式

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

JS Entry 方式

JS Entry 的原理是:

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

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

// vue-cli 3.x vue.config.js
config.optimization.runtimeChunk('single') // 不能使用

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 的 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 标签,避免重复加载

父子应用通信

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

数据

事件

目前用的较多的方案是 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

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

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

icestark

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

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

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

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

在 Entry 方式上

在 JS 沙盒上

在 CSS 沙盒上

在应用通信上

在应用监控上

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

qiankun

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

在 Entry 方式上

在 JS 沙盒上

在 CSS 沙盒上

在应用通信上

在应用监控上

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

Garfish

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

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

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

上一篇 下一篇

猜你喜欢

热点阅读