新旧微前端架构比较

2,049 阅读11分钟

本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

前言

本文是我终于把你送进了大厂的另一个版本,准确的说是女生视角下的故事。本文感情线和技术线会更加细腻,请细细体会。

0. 商业解决方案

我叫白依依。

“小白,了解一些商业化的解决方案就走吧。”

我看着我桌上那份 《小白的商业解决方案》 郁闷了很久。老大告诉我那句话差点没让我难过不安1小时,直到我看到桌上那份打印好的资料。你说封面上写着“小白”二字就是意外着开玩笑对不对?说吗商业解决方案,一听就是业界大佬才有资格触及的区域。

我,小白,和真小白也差不多……

我暗自懊恼,心里吐槽老大,说个话就不能说清楚。我翻开第一页,一行加粗大字:微前端架构。

微前端架构

先来看张图举个美团的栗子。

image.png

看起来这么复杂的吗?

  • 基础能力层
    • 基础能力。例如权限管理(拥有什么角色就能看见什么内容)
  • 产品功能层
    • 具体的需求功能实现。例如 数据分析师角色进入你的平台要看分析报告
  • 业务方案层
    • 以业务划分出不同区域
  • 相关系统
    • 你的平台集成了多少系统?

到这,看不太懂没关系。先记住一个概念,看看别人家做的微前端架构师基于哪些内容。

重新来总结一下,到底什么是微前端架构。

  1. 和业务不相关,对访问产品进行辅助作用的内容放入一个容器。
  2. 实现产品功能相关,可以是多个容器。
  3. 对不同的产品做一个功能集合,形成不同业务方案。
  4. 1-3可实现为一个系统。多个系统请重复执行1-3.

到这,还没明白也没关系。重新再说一遍人话。

  • 假如你有一个前端项目(主应用),其中啥事也不干,只做一件事情。那就是把访问不同系统的能力集成在一起,搭配角色即可成功访问相对应的内容。

  • 然后你有很多前端项目(微应用),其中除了产品功能啥也不干,不管权限也不管角色正确。举个栗子,你会有 商城系统、到店系统、供应链系统等。 然后每个系统又可以拥有很多个产品,产品又分布在不同微应用当中。

到这,觉得依然不够说人话程度也没关系。重新分析一下

各种方案调研

方案介绍优势劣势
1. iframe</div> 1. 开发和引入成本低。1. 主/微应用通信困难
2. 兼容性高2. 内部跳转不友好,例如登录跳转
3. 隔离性好3. 组件、模块无法复用
2. npm集成把子项目抽打成包发布到npm,谁要用谁引入依赖便好。1.编译过程、项目运行过程无需额外加载资源,所以体验会比较流畅1. 主应用的编译速度和打包后的体积容易过大
2.开发接入成本低2. 不支持动态下发,按需加载,npm包更新后,主应用需要重新发布
3. single-spa/qiankun通过路由变化驱动主应用加载微应用的bundle,将其渲染至指定节点1. 隔离机制完善,例如沙箱。可隔离样式、js脚本。1. 微应用间通信成本较高
2. 主应用不限制接入应用的技术栈2.需要对微应用作额外管理
4. 模块联邦webpack5 新增功能插件,实现模块联邦、组件联邦最大程度实现模块、组件自由化,去中心化太过自由,依然缺乏管理

所以看得出来,qiankun更适合应用级的集成。模块联邦。模块/组件颗粒度更大的集成

qiankun 简单分析源码

  • 初始化全局配置
    • 注册子应用 registerMicroApps(apps, lifeCycles?)
    • 获取子应用资源 使用了 import-html-entry 库从 entry 进入加载子应用,加载完成后将返回一个对象: {}
     const {template,execScript,assetPublicPath,getExternalScripts,getExternalStyleSheets} = await importEntry(entry,{
                    getTemplate:flow(getTemplate,getDefaultTplWrapper(appName)),
                    ...settings
                })
                console.log(template()); // => <!DOCTYPE html> <html>...</html>
                console.log(getExternalScripts()); //  得到js脚本 list
    
                 [
                     /******/(function(modules){/* webpackBootstrap... /*******/})(),
                     /******/(this["webpackJsonP_react"] = this['webpackJsonP.r' /******/]),
                     ....
    
                 ]
                 * */
                console.log(getExternalStyleSheets()) ; // => [] 样式表 css
    
                 // 然后我们再来分析 execScripts 方法,该方法的作用就是指定一个 proxy(默认是 window)对象,然后执行该模板文件中所有的 JS,并返回 JS 执行后 proxy 对象的最后一个属性
                 // 在微前端架构中,这个对象一般会包含一些子应用的生命周期钩子函数,主应用可以通过在特定阶段调用这些生命周期钩子函数,进行挂载和销毁子应用的操作。
    
    • 主应用挂载子应用 HTML 模板
    • 沙箱运行环境 - genSandbox
                // genSandbox 部分代码如下
                export function genSandbox(appName: string,singular: boolean){
                    ...
                    let sandbox: SandBox;
                    if(window.Proxy){
                        sandbox = singular ? new ProxySandBox(appName): new LegacySandBox(appName)
                    }else{
                        sandox = new SnapshotSandbox(appName)
                    }
                    ...
                }
                // 可以看出 genSandbox 内部的沙箱主要是通过是否支持 window.Proxy 分为 ProxySandbox 和 SnapshotSandbox 两种(多实例还有一种 LegacySandbox 沙箱)
    
              /**
                 * 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
                */
                export default class SnapshotSandbox implements SandBox {
                    proxy: WindowProxy;
                    name: string;
                    type: SandBoxType;
                    sandboxRunning: boolean;
                    private windowSnapshot;
                    private modifyPropsMap;
                    constructor(name: string);
                    active(): void;
                    inactive(): void;
                }
                // SnapshotSandbox 的沙箱环境主要是通过激活时记录 window 状态快照,在关闭时通过快照还原 window 对象来实现的
    
    • 挂载沙箱 - mountSandbox
    • 计时器劫持 - patchTimer
    • 动态添加样式表和脚本文件劫持 - patchDynamicAppend
    • 卸载沙箱 - unmountSandbox
    • 不写了,再下下去你们也不会看 😂
    • 注册内部生命周期函数
    • 进入到 mount 挂载流程
    • 进入到 unmount 卸载流程
  • 启动主应用 start(opts?)

所依,你看懂qiankun了吗?

step 1: 主应用先获取微应用的HTML模版、JS脚本、CSS样式表 三大法宝。

step 2: 挂载微应用资源

step 3: 注册生命周期

模块联邦

ModuleFederationPlugin 的用法
new ModuleFederationPlugin({
  name: "app1",
  library: { type: "var", name: "app1" },
  filename: "remoteEntry.js",
  remotes: {
    app2: 'app2',
    app3: 'app3',
  },
  remoteType: 'var',
  exposes: {
    antd: './src/antd',
    button: './src/button',
  },
  shared: ['react', 'react-dom'],
  shareScope: 'default'
})

配置属性:

  • name,必须,唯一 ID,作为输出的模块名(容器),使用的时通过 name/{name}/name/{expose} 的方式使用;
  • library,可选,打包方式,默认{ type: "var", name: options.name },其中这里的 name 为作为 umdname,是挂载在全局下的变量名;
  • filename,可选,打包后的文件名;
  • remotes,可选,表示当前应用是一个 Host,可以引用 Remote 中 expose 的模块;
  • remoteType,可选,默认 var,("var"|"module"|"assign"|"this"|"window"|"self"|"global"|"commonjs"|"commonjs2" 等远程容器的外部类型;
  • exposes,可选,表示当前应用是一个 Remoteexposes 内的模块可以被其他的 Host 引用,引用方式为 import(name/{name}/name/{expose})
  • shared,可选,主要是用来避免项目出现多个公共依赖,若是配置了这个属性,webpack在加载的时候会先判断本地应用是否存在对应的包,若是不存在,则加载远程应用的依赖包;
  • shareScope,可选,用于所有共享模块的共享作用域名称
// 公共依赖shared的配置项
Shared = string[] | {
  [string]: {
    eager?: boolean; // 是否立即加载模块而不是异步加载
    import?: false | SharedItem; // 应该提供给共享作用域的模块。如果在共享范围中没有发现共享模块或版本无效,还充当回退模块。默认为属性名
    packageName?: string; // 设置包名称以查找所需的版本。只有当包名不能根据请求自动确定时,才需要这样做(如要禁用自动推断,请将requiredVersion设置为false)。
    requiredVersion?: false | string; // 共享范围内模块的版本要求
    shareKey?: string; // 用这个名称在共享范围中查找模块
    shareScope?: string; // 共享范围名称
    singleton?: boolean; // 是否在共享作用域中只允许共享模块的一个版本 (单例模式).
    strictVersion?: boolean; // 如果版本无效则不接受共享模块(默认为true,如果本地回退模块可用且共享模块不是一个单例,否则为false,如果没有指定所需的版本则无效)
    version?: false | string; // 所提供模块的版本,将替换较低的匹配版本
  }[]
}

敲黑板,划重点! 在使用 Module Federation 的时候一定要记得,将公共依赖配置到 shared 中。另外,一定要两个项目同时配置 shared ,否则会报错

ModuleFederationPlugin 的原理

ModuleFederationPlugin主要做了三件事:

  • 如何共享依赖:使用 SharePlugin。 该插件使公共依赖可共享

  • 如何公开模块:使用 ContainerPlugin。 该插件为指定的公开模块创建 entryentry.js``执行后会在window 上挂一下对象,该对象有两个方法,get和initget 方法用来获取模块。init 方法用来初始化容器,它可以提供共享模块。

    // remote对象里的get和init方法
    var get = (module, getScope) => {
        __webpack_require__.R = getScope;
        getScope = (
            __webpack_require__.o(moduleMap, module)
                ? moduleMap[module]()
                : Promise.resolve().then(() => {
                    throw new Error('Module "' + module + '" does not exist in container.');
                })
        );
        __webpack_require__.R = undefined;
        return getScope;
    };
    var init = (shareScope, initScope) => {
        if (!__webpack_require__.S) return;
        var oldScope = __webpack_require__.S["default"];
        var name = "default"
        if(oldScope && oldScope !== shareScope) throw new Error("Container initialization failed as it has already been initialized with a different share scope");
        __webpack_require__.S[name] = shareScope;
        return __webpack_require__.I(name, initScope);
    };
    // This exports getters to disallow modifications
    __webpack_require__.d(exports, {
        get: () => get,
        init: () => init
    });
    

在使用 Remote 的模块时候,通过 init 将自身 shared 写入 Remote 中,再通过 get 获取Remoteexpose 的组件,而作为 Remote 时,判断Host中是否有可用的共享依赖,若有,则加载Host的这部分依赖,若无,则加载自身依赖。

ContainerReferencePlugin:

该插件将特定的引用添加到作为外部资源(externals)的容器中,并允许从这些容器中导入远程模块。在导入时会调用容器使用者提供的remote进行重载。

通过remotes定义的模块,也会在 __webpack_modules__ 中声明但不会有具体实现,这就和异步导入类似。 webpack5 中新增了 __webpack_require__.e 方法,对通过次方法导入的模块执行一下三个函数,并且全部成功才返回。

  • __webpack_require__.f.consumes 用来判断和消费shared模块,如果当前环境下已经有这个模块就不向远程请求
  • __webpack_require__.f.remotes 用来连接容器
  • __webpack_require__.f.j 用来加载JS

1. 线上还是出问题了,紧急发布。

那天,风很大。下午3、4点的天乌黑的,简直吓死了宝宝。

“呦呵,来凑热闹啦?带伞木有啦?要不要喝一杯我新买的咖啡啦?”

老大慈眉善目的样子简直不要太招人恨。我就学习了一个礼拜,天天晚上看源码,眼黑圈都重了好几轮。我在想,这么苦的吗? 周一早上,老大公开宣布我们要做渐进式微前端架构,我是负责人。 我简直要坏掉了。

排期就5个人日,我每天都在极度焦虑当中渡过,真的简直从头到脚都要坏掉了!这礼拜刚好来例假,脑袋昏昏沉沉只想睡觉,看到人就想发脾气。你说我放弃?有用吗?老大真的就是那种人,这活儿要干不好,他会让我自己滚蛋的……

发布那天,意外的发现测试一点麻烦都没找我。连续加了一个星期的班,我终于做出了成功,然而例假根本没有走的情况。我有点不安……

急诊室。

“老大,成功上线了吗?”

“嗯。”

“老大,没有bug吗?”

“没。”

“老大,医院的味道好难闻。”

“消毒水的味道。”

“老大,为什么你要赶我走?”

“赶你走? 什么意思?”

“你说的啊:小白,了解一些商业化的解决方案就走吧。”

“噢。我那时让你自己离开茶水间,别被人事抓到你在摸鱼。”

哇…… 555555.

我还是没忍住崩溃了。一方面是感觉委屈,另一方面是缓了一口气。

“你这有可能是子宫内膜炎症,得做一下妇科检查。小小白马上就来了,你平时注意休息。”

无情!冷漠!老大一句安慰都木有,出去接小小白了。

小小白一来,脸色铁青。 我知道肯定是出问题了,而今晚能出问题的就是我。

“小白,你还好吧? 天呐,你也太不爱惜自己了。哪能在例假期间不注意休息,疯狂加班的?”

“老大交给我的任务没完成啊!”

……

小小白不沉默了。我很清楚,老大肯定是回去处理紧急发布的事情了。

前端可视化解决方案

未完待续。 来例假了,写不动了。