微前端 → 深入分析与方案实践

1,583 阅读32分钟

实践方案

在进行微前端的重构前,需要考虑项目需求、开发成本、维护成本、未来的可拓展性、是否与当前的技术和需求有冲突等方面;

在私有化方面的实践

在进行项目的正常更新迭代的过程中,经常会遇到客户要求本地化或定制化的需求;本地化就会有代码安全方面的考量,最好是不给客户源代码,最差则是只给客户购买功能的源代码。而定制化从易到难则可以分为独立新模块、改造现有模块、替换现有模块。
通过微前端技术,我们可以很容易达到本地化代码安全的下限——只给客户他所购买的模块的前端源码;定制化里最简单的独立新模块也变得简单:交付团队增加一个新的微前端工程即可,不需要揉进现有研发工程中,不占用研发团队资源。 在开发时可以区分本地和线上环境,本地与原工程无异,线上需要理由UMD方式输出几个钩子函数,包含初始化、加载和卸载

image.png

将普通项目改造成qiankun主应用基座步骤
  • 创建微应用容器
    • 用于承载微应用,渲染显示微应用
  • 注册微应用
    • 设置微应用的激活条件、微应用的地址等
  • 启动qiankun项目

常规公司内部需求

经常会遇到需要将公司内部的一个现有工具内嵌到当前项目中,大多都是技术栈不统一的项目,因此常规的做法就是通过iframe进行内嵌,或者就是通过微前端的方式进行实现,当然两者都可以满足业务需求,但是还是要折腾一些新技术,因此会采用微前端进行实现,当然此前需要做的很重要的事情就是调研微前端方案是否与当前需要内嵌的项目技术栈方面是否有无解的问题冲突在,否则后期的场景自己可以脑补一下了~

  • 基本逻辑步骤
    • 基座配置(不限技术栈)
      • 只负责导航的渲染和登录态的下发,为子应用提供一个挂载的容器,不该做的事不做
    • 子应用配置
      • 端口号配置、跨域headers配置、output配置、webpack_public_path_配置、项目初始化改写、添加微前端需要的声明周期钩子
        • 注意Vue可以通过vue.config.js进行webpack改写配置,React可以通过react-app-rewired方案复写webpack就可以了
  • 进阶
    • 在数据共享中,要想摘出所有子应用都共用的模块或数据是不现实的,因此一般是将主应用中下发一些自身用到的模块,子应用可以优先选择主应用下发的模块或数据,只有当主应用也没有时才进行加载,当然了子应用也可以进行版本比较后进行判断是否要引用主应用下发的数据模块
    • qiankun通过initGlobalState, onGlobalStateChange, setGlobalState实现主应用的全局状态管理,然后默认会通过props将通信方法传递给子应用。其内部还是使用的发布-订阅的模式进行实现的;但一般情况下都需要对数据状态做进一步的封装与设计
    • Vue项目还是共享Vuex比较方便,混合技术栈的可以采用qiankun提供的
      // 主项目初始化
      import { initGlobalState } from 'qiankun';
      const actions = initGlobalState(state);
      // 主项目项目监听和修改
      actions.onGlobalStateChange((state, prev) => {
        // state: 变更后的状态; prev 变更前的状态
        console.log(state, prev);
      });
      actions.setGlobalState(state);
      
      // 子项目监听和修改
      export function mount(props) {
        props.onGlobalStateChange((state, prev) => {
          // state: 变更后的状态; prev 变更前的状态
          console.log(state, prev);
        });
        props.setGlobalState(state);
      }
      
      
      • 例如通过将Vuex进行抽离,主子应用都进行初始化引用,然后各自维护其定义的数据,可以实现子应用脱离主应用后正常的数据交互
      • 在进行分治拆分后,难免会有子应用单独更改的需求,此时依赖主应用中下发的数据就没办法进行透传了,;因此为了进行复用,可以将登录逻辑封装在Common中,然后在子应用独立运行的逻辑中添加登录相关的逻辑即可
      • qiankun提供了loader方法可以获取到子应用的加载状态,因此可以在主应用中通过loader方法动态获取子应用的加载状态从而动态的设置主应用的加载样式
    • 部署问题
      • 一般是将主应用放在服务器的根目录下,所有子应用放在某个文件夹下,避免主子应用路由冲突,可以在于nginx进行分发
      server {  # 定义一个服务器块
          listen       80;  # 监听 80 端口
          server_name qiankun.fengxianqi.com;  # 服务器的域名
      
          location / {  # 处理根路径('/')的请求
              root   /data/web/qiankun/main;  # 定义主应用所在的目录
              index index.html;  # 设置默认的索引文件为 index.html
              try_files $uri $uri/ /index.html;  # 尝试按照请求的 URI 查找文件,如果找不到则尝试加上'/'再次查找,如果仍然找不到则返回 /index.html
          }
      
          location /subapp {  # 处理 '/subapp' 路径的请求
              alias /data/web/qiankun/subapp;  # 设置别名,指向子应用的目录
              try_files $uri $uri/ /index.html;  # 同样的文件查找逻辑
          }
      }
      

每日优鲜微前端方案 → Single-SPA

Single-SPA有个必要条件是项目需要是类似单页面应用,必须要有完善的路由机制,像之前的jQuery这种老项目就不适合Single-SPA去进行改造了

技术背景

团队是做toB的,技术栈是Vue,团队项目大多是服务于同一个大平台,且项目间有挺多的公共组件,但具体的项目是独立的,导致的结果就是在切换项目(系统)时,页面都会刷新,体验感极差,且加载速度也很慢;此外后续的迭代有太多相似的功能,每次修改或维护的成本很高;

现阶段有一个类似私有化的需求,主要是在十多个项目中,每个项目抽取若干个功能模块进而组成一个新的项目,基于现有的框架技术是可以实现的,但是每次点击来自不同系统的功能页面时就要刷新一次,还有就是令人诟病的加载速度问题;除非单独开发一套,但是是非常不现实滴,因此微前端技术就可以在这种场景下大展拳脚了;

最终的实现效果
  • 主项目
    • 负责基建、路由管理和状态维护,只有一个HTML入口
    • 子项目通过主项目按需加载,可以实现子项目间切换不需要刷新
      • 远程加载子项目的核心是System.js,但子项目需要打包成umd格式(不仅适用于该库,还有其他好处),该库还可以解决不同项目间会加载无用资源的问题 image.png
      • systemjs只是在加载index.html时注册了这些CDN地址,不会直接去加载,当子项目里用到的时候,systemjs会接管模块引入,systemjs会去上面注册的map中查找匹配的模块,就再动态去加载资源。这样就避免了不同子项目在这套架构下产生的多余加载
    • 需要单独加错误监控、埋点处理等基建维护
  • 公共功能模块、依赖资源等
    • 菜单栏、登录、登出等功能与子项目脱离,写在主项目里,此类的相关改动只在主项目中进行,其他相关项目不再处理
      • 菜单栏tab系统(类似浏览器的tab页签),这些tab页签通过keep-alive和一系列对缓存的处理,使其体验接近原生浏览器tab
    • 子项目原本依赖的公共部分如Vue、Vuex、element、公共npm包等全部由主项目调度,配合webpack的externals功能通过外链的方式按需加载,一旦有一个项目加载过,下一个项目就不需要再加载了,这样依赖每个子项目的dist文件里就只有子项目自己的业务代码 → 子项目代码体积减少80%,最终只有几十K,项目加载速度嘎嘎快
      • single-spa 是使用 systemJs 加载子项目和公共依赖的,将公共依赖和子项目一起配置到 systemJs 的配置文件 importmap.json ,就可以实现公共依赖的按需加载
      {
        "imports": {
          "appVueHash": "http://localhost:7778/app.js",
          "appVueHistory": "http://localhost:7779/app.js",
          "single-spa": "https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.7/system/single-spa.min.js",
          "vue": "https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js",
          "vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.0.7/dist/vue-router.min.js",
          "echarts": "https://cdn.bootcss.com/echarts/4.2.1-rc1/echarts.min.js"
        }
      }
      
      
      • qiankun的实现是:父项目提供公共依赖,子项目可以自由选择用或者不用 - 不建议(不符合独立性、不好查找公共依赖)
      import Vue from 'vue'
      import App from './App.vue'
      import router from './router'
      import store from './store'
      import { registerMicroApps, start } from 'qiankun';
      import Vuex from 'vuex';
      import VueRouter from 'vue-router';
      
      new Vue({
        router,
        store,
        render: h => h(App)
      }).$mount("#app");
      
      registerMicroApps([
        { 
          name: 'app-vue-hash', 
          entry: 'http://localhost:1111', 
          container: '#appContainer', 
          activeRule: '/app-vue-hash', 
          props: { data : { store, router, Vue, Vuex, VueRouter } }
        },
      ]);
      
      start();
      
      
      //子项目
      const { VueRouter, Vuex } = props.data;
      Vue.use(VueRouter);
      Vue.use(Vuex);
      
      • webpack配置
        • 只要子项目配置了 webpackexternals,并在 index.html 中使用外链 script 引入这些公共依赖,只要这些公共依赖在同一台服务器上,便可以实现子项目的公共依赖的按需引入,一个项目使用了之后,另一个项目使用不再重复加载,可以直接复用这个文件。
        • webpackexternals其原理是将公共依赖挂载到window上,但是前轮的JS沙箱在卸载时会移除window上新增和变化的变量,导致不会有缓存效果,还是会重复请求获取
  • 子项目
    • 不需要重新开发,只是按照「微前端」的相关规范进行部分改造,导致后续新需求的开发成本也极大的降低了
  • 为了让tab切换不刷新,这里使用了keep-alive去缓存页面,考虑到内存性能,在关闭tab页签时通过一些方法(主要是keep-alive的exclude属性)去除了keep-alive缓存,同时为了让子项目间的tab切换也不刷新,对图3下面提到的包装器也进行了不小的改造。让tab切换不刷新只是为了提升用户体验,这一步不是必要的,有一定的成本。

眼见为实 → 让不同的独立的单页项目随意组合成一个项目 image.png

实现细节

image.png

用户访问index.html后,浏览器运行加载器的js文件,加载器去读取配置文件,然后注册配置文件中配置的各个项目后,首先加载主项目(菜单等),再通过路由判定,动态远程加载子项目。
在资源服务器上起一个监听服务(我使用的是nodejs脚本+pm2守护),原有子项目的部署方式完全不变(前后端完全分离,资源带hash),当监听服务检测到文件改动时,去子项目部署文件夹里找它的index.html,把入口js用正则(new RegExp(src="(\/${content[i]}\/index\.\w{8}.js)) // 对应图中的/brain/index.3c4b55cf.js)匹配出来,写入apps.config.js。

重构体会
  • 上线流程
    • 可以保留原来的线上业务,线下做一套微前端的版本出来,测试没有问题后搞个新网址直接灰度,灰度出问题后降级使用原网址,灰度到一定程度就可以全量推了
  • 优点
    • 项目体积缩小,整合后的公共资源最多只加载一次,性能提升;
    • 用户体验方面更好,不会有不同项目的感受,此外不会出现刷新加载慢等问题
    • 项目灵活性提升:可以随意组合现有项目为一个新项目
  • 痛点
    • 污染方面:
      • 需要制定具体的接入和开发规范
    • 代码逻辑冲突方面:
      • 公共模块间的逻辑冲突问题,如Vue.mixinVue.componentsVue.use、自定义指令等,需要额外的成本区解决
相比于iframe的优点
  • 资源加载问题、iframe显示区域受限
  • iframe浏览记录无法被识别到,项目菜单栏和浏览器前进后退会出现问题
  • 统一微前端实现后所有子项目的监控、埋点、统一部分功能的添加和修改都会方便很多,开发成本大打折扣
  • iframe速度较慢,每次进入子应用时都要重新构建整个上下文

微前端在美团外卖的实践

技术背景

微前端是一种利用微件拆分来达到工程拆分治理的方案,可以解决工程膨胀、开发维护困难等问题。目前美团外卖商家广告端开发和维护的系统主要有三端 → PC系统、H5系统和KA系统(前两者为单门店投放、KA是多门店投放系统PC端);详细比对如下:

  • 业务场景分析
    • PC端和H5端相同业务线的基本业务逻辑一致,但UI差异很大
    • PC端和KA端相同业务线的部分业务逻辑一致UI差异较小
  • 期望结果
    • 开发效率问题,希望能复用的部分只开发一次,而不是三次
  • 现存在的问题
    • 如何进行物理层面的复用(前提是不同端的代码在不同的git仓库中)
      • 实践方案
        • 以共享文件的方式去消除物理空间上的隔离,而不是去连接不同的物理空间,前提是三端的技术栈都是一致的;
        • 具体实现是通过将三端系统都放在同一个仓库中,通过Common文件夹提供物理层面上可复用的土壤
        • 具体的方案有NPM包、Git subtree等类「共享文件」的方式
      • 引发的问题
        • 新业务线产品增加,同时为了保证系统间复用效率最大化,将代码统一仓库管理,导致文件数量暴增管理和协同开发难度不断增大
        • 文件/组件越来越多,文件结构越来越复杂,导致业务开发寻址难度增加
        • 同一项目中文件结构越来越复杂,导致开发、构建、部署速度越来越慢,开发体验下降
        • 没有进行物理隔离,出现跨业务线互相紊乱的现象
      • 对应的后续优化方案 → 针对上面的引发的问题
        • 进行工程优化的常规手段进行分治,具体就可以用到微前端的概念
        • 拆分解耦
          • 按照业务领域拆分成不同仓库进行分别维护,互不影响
          • 物理层面(功能模块和业务领域等)拆分,加速寻址
          • 逻辑层面拆分,杜绝引用混乱
        • 降低侵入性
          • 将代码改动降到最低,减少甚至是消除回归测试的成本
        • 尽量避免技术栈的拓展
          • 实现统一共建和技术沉淀,毕竟技术是互通的,精通一门,同类型的基本就是洒洒水啦
      • 方案选择 image.png
        • NPM式:
          • 子工程以 NPM 包的形式发布源码;打包构建发布还是由基座工程管理,打包时集成;
        • iframe式:
          • 子工程不限技术栈,完全独立,无任何额外依赖
          • 基建需要和子工程监理通信机制,存在的问题是无SPA体验,路由管理困难
        • 通用中心路由基座式
          • 子工程依旧各种独立
          • 统一由基座工程进行管理,按照DOM节点的注册、挂载和卸载来完成
        • 特定中心路由基座式
          • 子工程使用相同技术栈
          • 基座工程和子工程单独开发部署
          • 子工程可以复用基建工程的公共基建
    • 如何进行逻辑层面的复用
      • 期望值是不同端的相同逻辑如何使用一份代码进行抽象,且可以无缝衔接到各个业务项目中
最终效果

image.png

实现细节

image.png

  • 动态化方案
    • 动态路由(使得子工程之间有能力互通切换)
      • 方案一:使用特制的路由管理模块
        • Single-Spa,实现了一套自己的路由来监听和切换子工程,但需要子工程来按照基建工程的规范俩动态对接,同时需要特定的模块管理系统来辅助完成,如systemjs
        • 存在的问题
          • 对原有工程改造成本很大,包体积的开销且需要有一定的学习成本
      • 方案二:React-Router管理
        • 保持技术栈不变,对工程侵入较低,和SPA的最终效果一致 image.png
  • 路由配置信息设计 image.png

如果业务很复杂,完全可以在子工程中通过 webpack 的动态 import 进行路由懒加载,
也就是说,子工程完全可以按照路由再次切分成 chunks 来减少 JS 的包体积

  • 子工程接口设计
  • 复用方案设计
    • 除了基本的逻辑复用外,还需要注意框架基本库、业务组件等,一般在基建中进行共享,子应用加载的时候通过external的方式加载这些库,直接引用即可; image.png
  • 流程方案设计
    • 前提是已经确定好了程序拆分运行的整体衔接后还需要如下操作
    • 确认开发方案、部署方案和回滚方案
    • 开发方案
      • 方案一:提供基座工程的Dev环境,子工程在本地启动后在Dev环境下开发,前提是需要有一套基座工程的更新机制
      • 方案二(推荐):子工程开发者拉取基座工程到本地启动本地开发环境,然后再启动子工程进行本地联调开发;最好提供子工程脚手架来快速创建子工程 image.png
    • 部署方案
      • 子工程的开发和部署完全独立,单个业务线的打包和部署速度非常快 image.png
    • 回滚方案
      • 常规的部署时,将静态资源放置到CDN上,具体是通过contenthash值来区分不同版本,因此在实现回滚时就可以获取到上一个版本的静态资源,然后更新基建中的配置信息即可完成,此过程子项目的路由基本信息是不变的
    • 监控方案
      • 需要监控子工程的配置信息、静态资源加载等节点进行埋点上报,从而统计到子工程加载成功率,及时发现相关的问题
      • 偶尔的报警可能是用户端的网络或设备问题,但是大规模的报警或者同一个资源多次出现加载失败问题就需要进行重视排查解决了
总结

整体的改造需要尽量保留现有业务的开发逻辑习惯,如保持SPA的开发体验、整体的工程改造成本要低、插拔式开发、实现无侵入式代码逻辑,且支持热更新、开发体验不降级等目的

微前端在小米CRM系统中的实践

历史巨石应用较多,最终采用微前端实现方案的结合进行实现的,从而解决现有的问题 → 没有最优的架构,只有最合适的架构

技术背景

有多个项目进行迭代升级,其中部分是前后端未分离的,部分是新技术实现的SPA应用,需要进行域名的统一(可以共享cookie,统一SSO登录等)、界面和交互的统一、方便后续的拓展维护、CI构建等;

实现后效果图

image.png

实现细节

对于那些老系统采用iframe的方式进行接入(分别部署在不同的服务器上),对于新系统或者后续的新需求采用single-spa进行改造实现(部署在同一个服务器上)

  • 实现注册子应用路由的动态可配置化

image.png

后期维护升级
  • 可以通过lerna统一管理所有项目,方便维护
  • 可以使用SystemJS实现应用的动态加载、独立部署

微前端在平台级管理系统中的最佳实践(中原银行)

技术背景

银行系统通常生命周期长、业务复杂。生命周期长使得对历史遗留项目升级困难,业务复杂带来了项目和团队的管理开发过程中的各种问题。

开发管理端项目时常会遇到两个问题:实现起来繁琐且复杂、与行内相关规范习惯对接需要有学习成本和费时费力;本项目承担了公司平台级管理系统的角色,提供了一套具有统一的设计规范、前端框架、开发模式和技术栈的模版工程,支持赋能了十几套内部系统

最终实现效果

image.png

实现细节

需要包含的内容有:构建工具(基于webpack实现)、主/子应用、应用加载器(基于qiankun实现)、应用管理中心等模块,并具有相应的设计体系保证子应用之间交互和逻辑的一致性;

  • 常用的开发模式 image.png
    • 线上主应用+本地子应用(推荐)
      • 本地只需要启动子应用,线上主应用通过配置加载运行本地子应用,可以节省本地资源消耗
      • 前提是需要一台测试机器部署主应用
    • 本地主应用+本地子应用(推荐)
      • 本地起一个主应用,通过配置加载运行本地的子应用
      • 缺点是消耗本地资源
    • 主子融合
      • 本地只启动一个融入主应用部分功能的子应用(伪主应用),模拟主应用环境,
      • 优点是独立部署更加便利。
      • 缺点是不利于主应用更新,并且增加了子应用的体积。
  • 存在的问题及解决方案
    • 如何让子应用与主应用的菜单等逻辑相结合,如何管理子应用和其包含的菜单功能权限
    • 将子应用及其功能抽象为主应用的多级菜单,结合菜单权限管理的能力进行子应用的路由等控制
  • 应用拆分
    • 微前端第一步就是确定主、子应用的范围,将现有的管理端模版项目按照业务聚合度进行定制化拆分,最终拆分为一个基座主应用和多个子应用 image.png
  • 应用管理中心
    • 在主应用中新加子应用看板,控制子应用的上下线,配置子应用的参数等
    • 基于应用看板,可以批量动态注册子应用到应用加载器qiankun中,使得多个子应用实现按需加载和展示
    • 子应用的权限通过配置管理中心进行统一配置实现,且无需手动手写代码进行相关的注册,降低复杂度 image.png
  • 内部构建工具定制化脚手架
    • 将构建流程和项目剥离,可以单独审计 image.png
  • mock能力的拓展
    • 解决子应用个别接口的特殊需求,避免过度更改主应用的配置信息来兼容子应用的特殊场景 image.png
  • 设计体系
    • 即开发规范,包含基本要素、界面设计、组件设计、相关规则等
  • 体验磨平
    • 包括系统的统一的展示,统一的日志记录能力、主子应用共享登录信息,简化子应用开发成本等 image.png

实践问题汇总

通信方式

通信的原则:基于props以单向数据流的方式传递给子应用、基于浏览器原生事件做跨业务之间的通信;兄弟节点间通信以主应用作为消息总线,不建议自己封装的Pub/Sub机制,也不推荐直接基于某一状态管理库做数据通信

Broadcast Channel方式

BroadCast Channel 可以帮我们创建⼀个⽤于⼴播的通信频道。当所有⻚⾯都监听同⼀频道的消息 时,其中某⼀个⻚⾯通过它发送的消息就会被其他所有⻚⾯收到。

  • 创建实例 const bc = new BroadcastChannel('alienzhou');
  • 监听消息
    import { bc } from '../js/event.js'
    bc.onmessage = function(e) {
        console.log('receive:', e.data);
    };
    bc.onmessageerror = function(e) {
        console.warn('error:', e);
    };
    
  • 发送消息
    import { bc } from '../js/event.js'
    bc.postMessage('hello')
    
  • 关闭
    • 取消message监听
    • 直接调用实例的关闭逻辑 → 浏览器会回收
      • bc.close()
      • 当然此方式关闭后要想恢复,可以重新创建一个相同name的Broadcast Channel
Service Worker方式

Service Worker 是⼀个可以⻓期运⾏在后台的 Worker,能够实现与⻚⾯的双向通信。多⻚⾯共享间 的 Service Worker 可以共享,将 Service Worker 作为消息的处理中心(中央站)即可实现⼴播效果。

  • 注册Service Worker image.png
  • Service Worker逻辑 image.png
  • 监听Service Worker的消息 image.png
  • 同步消息时进行Service Worker发送 navigator.serviceWorker.controller.postMessage(mydata);
localStorage方式
  • 注册监听事件 → 在需要的每个页面中
// 为窗口的'storage'事件添加监听函数
window.addEventListener('storage', function (e) { 
    // 如果触发事件的键是'ctc-msg'
    if (e.key === 'ctc-msg') { 
        // 将新的值解析为 JSON 对象
        const data = JSON.parse(e.newValue); 
        // 拼接字符串
        const text = '[receive]'+ data.msg + '⸺tab'+ data.from; 
        // 在控制台打印信息
        console.log('[Storage I] receive message:', text); 
    }
});
  • 发送消息
let mydata = {user:'lbxin'}

mydata.st = new Date() //避免数据没变化时事件不触发问题
window.localStorage.setItem('ctc-msg',JSON.stringify(mydata))
Shared Worker方式

相比于其他Worker方式,该方式可以实现数据的共享,但是无法主动通知所有页面,因此需要轮训的方式来拉取最新的数据;当然有时候也不需要定时进行拉取,而是在需要的时候手动获取一下,比如在某个应用或页面激活时进行手动获取一下

  • 启动Worker
    const sharedWorker = new SharedWorker('../util.shared.js', 'ctc');
  • 注册相关事件
// 定义一个初始值为 null 的变量 data ,用于存储消息数据
let data = null;
// 为当前对象添加 'connect' 事件的监听
self.addEventListener('connect', function (e) { 
    // 获取连接事件中的端口
    const port = e.ports[0];
    // 为端口添加'message' 事件的监听
    port.addEventListener('message', function (event) { 
        // 如果接收到的消息数据中包含 'get' 指令
        if (event.data.get) { 
            // 如果 data 有值,则将其通过端口发送出去
            if (data) {
                port.postMessage(data); 
            }
        } 
        // 如果不是 'get' 指令
        else { 
            // 将接收到的数据存储到 data 中
            data = event.data; 
        }
    });
    // 启动端口
    port.start();
});
  • 触发定时获取逻辑
// 每 1000 毫秒向 sharedWorker 的端口发送一个包含 'get: true' 的消息
setInterval(function () {
    sharedWorker.port.postMessage({ get: true });
}, 1000);

// 为 sharedWorker 的端口添加'message' 事件的监听
sharedWorker.port.addEventListener('message', (e) => { 
    // 获取接收到的消息数据
    const data = e.data; 
    // 构建要输出的文本
    const text = '[receive]'+ data.msg + '⸺tab'+ data.from; 
    // 打印接收到的消息相关的日志
    console.log('[Shared Worker] receive message:', text);
}, false);

// 启动 sharedWorker 的端口  
// 当是`sharedWorker.port.onmessage`监听时不需要进行手动start方法
sharedWorker.port.start();
  • 触发更新操作
    sharedWorker.port.postMessage(mydata);
window.open + window.opener方式

通过window.open打开的页面间的引用关系建立起树形结构的连接,从而实现相互的通信;

let childWins = [];
document.getElementById("index").addEventListener("click", function () {
  const win = window.open("./home/index");
  childWins.push(win);
});

// 过滤出未关闭的窗口
childWins = childWins.filter(w =>!w.closed);
// 如果存在未关闭的子窗口  子窗口
if (childWins.length > 0) {
  // 设置标志,表示消息并非来自开启者
  mydata.fromOpenner = false;
  // 向每个未关闭的子窗口发送消息
  childWins.forEach(w => w.postMessage(mydata));
}
// 如果存在开启当前窗口的窗口且未关闭 父窗口
if (window.opener &&!window.opener.closed) {
  // 设置标志,表示消息来自开启者
  mydata.fromOpenner = true;
  // 向开启当前窗口的窗口发送消息
  window.opener.postMessage(mydata);
}

在线演示 demo

以上是受限于同源策略 可以通过iframe实现桥接的逻辑实现不同源间的通信手段

image.png

由于 iframe 与⽗⻚⾯间可以通过指 定 origin 来忽略同源限制,因此可以在每个⻚⾯中嵌⼊⼀个 iframe (例如: lbxin.com/bridge.html ),⽽这些 iframe 由于使⽤的是⼀个 url,因此属于同源⻚ ⾯,其通信⽅式可以复⽤上⾯第⼀部分提到的各种⽅式。

  • 页面监听iframe的消息,然后做具体的业务处理
    window.addEventListener('message', function (e) { // …… do something});
  • 页面先与iframe通信
    window.frames[0].window.postMessage(mydata, '*')
  • iframe收到页面的消息后将消息转发给其他iframe实现间接通信,方式可选上述任意方法
  • iframe收到消息后将数据回传到当前页面,实现不同源的页面间的通信

合集

  • react子应用启动后,主应用第一次渲染后会挂掉
    • 子应用的热重载会引起父应用直接挂掉,因此需要在复写React的webpack时禁用掉热重载,但需要手动刷新了
  • 常见报错
    • 子项目未 export 需要的生命周期函数 image.png
    • 子项目加载时,容器未渲染好 image.png
    • CSS污染问题及加载bug
      • qiankun 只能解决子项目之间的样式相互污染,不能解决子项目的样式污染主项目的样式 → scoped解决大法
    • 路由跳转问题
      • 子项目跳转到主项目时,主项目的样式和JS会未加载
        • 原因是:子项目在跳转到主项目时,子项目需要卸载,而主项目的加载正好命中了该时间段,从而子项目的CSS沙箱和JS沙箱都进行了标记,在卸载时就错误的将标记的主项目的文件也给清除了;因此解决办法是在子项目卸载完成后再进行加载主项目或执行跳转逻辑;临时解决办法是做子主项目跳转到路由拦截
      • 子项目间的跳转(通过子项目实现)
        • 不能通过<router-link> 或者用 router.push/router.replace进行实现,原因是此方法的Router都是基于子项目的路由,所有的跳转都会基于子项目的base,当然直接a连接跳转也行,就是会刷页面
        • 解决办法就是将主项目的路由实例传递给子项目,子项目用主项目的路由实例进行跳转,当然就不可以通过右键进行跳转了
const childRoute = ['/app-vue-hash','/app-vue-history'];
const isChildRoute = path => childRoute.some(item => path.startsWith(item))
const rawAppendChild = HTMLHeadElement.prototype.appendChild;
const rawAddEventListener = window.addEventListener;
router.beforeEach((to, from, next) => {
  // 从子项目跳转到主项目
  if(isChildRoute(from.path) && !isChildRoute(to.path)){
    HTMLHeadElement.prototype.appendChild = rawAppendChild;
    window.addEventListener = rawAddEventListener;
  }
  next();
});

缓存问题

每次应用部署后,通过刷新页面发现更新的内容没有生效,必须通过强刷浏览器才可以生效,有以下几种解决方案

  • 通过修改入口index.html的响应头实现:Cache-Control:no-cache
  • 通过配置nginx:
    location = /index.html {
      add_header Cache-Control no-cache;
    }
    
  • 前两种不生效时:

有可能是qiankun内部使用fetch方法获取子应用数据时默认缓存的旧应用的数据,需要重新定义一下fetch方法,即配置no-cache即可解决

start({
    async fetch(url, ...args) {
        return window.fetch(url, { cache: 'no-cache' }, ...args)           
    }
});

硬编码更新频繁的问题

在常规开发中,各种配置文件都是通过硬编码写在项目中,如微应用的一些注册信息、接口网关、样式信息、外置CDN配置信息等;每次改动都需要重新打包部署,费时费力,因此可以采用BFF项目来解决,即服务于前端的后端;

实现微前端

相关原理

运行原理
  • registerMicroApps
    • 主要做子路由配置信息的缓存(本地缓存,例如缓存到_app中)
  • start中的原理
    • 监视路由变化 → rewriteRouter.js
      • hash路由模式:
        • window.onhashchange
          <html lang="en">
          
          <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <meta http-equiv="X-UA-Compatible" content="ie=edge">
            <title>hash router</title>
          </head>
          
          <body>
            <ul>
              <li><a href="#/">turn yellow</a></li>
              <li><a href="#/blue">turn blue</a></li>
              <li><a href="#/green">turn green</a></li>
            </ul>
          </body>
          <script>
            class Routers {
              constructor() {
                this.routes = {};
                this.currentUrl = '';
                this.refresh = this.refresh.bind(this);
                window.addEventListener('load', this.refresh, false);
                window.addEventListener('hashchange', this.refresh, false);
              }
          
              route(path, callback) {
                this.routes[path] = callback || function () { };
              }
          
              refresh() {
                this.currentUrl = location.hash.slice(1) || '/';
                this.routes[this.currentUrl]();
              }
            }
          
            window.Router = new Routers();
            var content = document.querySelector('body');
            // change Page anything
            function changeBgColor(color) {
              content.style.backgroundColor = color;
            }
            Router.route('/', function () {
              changeBgColor('yellow');
            });
            Router.route('/blue', function () {
              changeBgColor('blue');
            });
            Router.route('/green', function () {
              changeBgColor('green');
            });
          </script>
          
          </html>
          
          image.png
      • histroy路由
      class Routers {
        // 构造函数,在创建 Routers 类的实例时被调用
        constructor() {
          // 初始化一个对象来存储路由路径和对应的回调函数
          this.routes = {};
          // 内部方法,用于绑定浏览器的 popstate 事件
          this._bindPopState();
        }
      
        // 初始化路由,替换当前历史记录状态
        init(path) {
          // 使用 history.replaceState 方法替换当前历史记录项
          history.replaceState({ path: path }, null, path);
          // 如果指定路径有对应的回调函数,执行该回调
          this.routes[path] && this.routes[path]();
        }
      
        // 注册路由路径和对应的回调函数
        route(path, callback) {
          // 将回调函数与指定路径关联存储在 routes 对象中
          this.routes[path] = callback || function () { };
        }
      
        // 改变历史记录并触发对应路由的回调
        go(path) {
          // 使用 history.pushState 方法添加新的历史记录项
          history.pushState({ path: path }, null, path);
          // 如果指定路径有对应的回调函数,执行该回调
          this.routes[path] && this.routes[path]();
        }
      
        // 内部私有方法,用于绑定 popstate 事件
        _bindPopState() {
          // 为窗口添加 popstate 事件监听
          window.addEventListener('popstate', e => {
            // 获取事件对象中的状态数据中的路径
            const path = e.state && e.state.path;
            // 如果指定路径有对应的回调函数,执行该回调
            this.routes[path] && this.routes[path]();
          });
        }
      }
      
      // 在全局创建一个 Routers 类的实例并赋值给 Router 变量
      window.Router = new Routers();
      // 初始化路由为当前页面的路径
      Router.init(location.pathname);
      
      // 获取页面中的 body 元素和 ul 元素
      const content = document.querySelector('body');
      const ul = document.querySelector('ul');
      
      // 定义一个函数用于改变页面背景颜色
      function changeBgColor(color) {
        content.style.backgroundColor = color;
      }
      
      // 为根路径('/')注册回调函数,将背景颜色设置为黄色
      Router.route('/', function () {
        changeBgColor('yellow');
      });
      
      // 为 '/blue' 路径注册回调函数,将背景颜色设置为蓝色
      Router.route('/blue', function () {
        changeBgColor('blue');
      });
      
      // 为 '/green' 路径注册回调函数,将背景颜色设置为绿色
      Router.route('/green', function () {
        changeBgColor('green');
      });
      
      // 为 ul 元素添加点击事件监听
      ul.addEventListener('click', e => {
        // 如果点击的元素是 a 标签
        if (e.target.tagName === 'A') {
          // 阻止默认的链接跳转行为
          e.preventDefault();
          // 使用 Router 的 go 方法跳转到链接的 href 属性指定的路径
          Router.go(e.target.getAttribute('href'));
        }
      });
      
      ![image.png](https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/bd75b7afbe2d4883a5159095b6b03e1b~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1490\&h=3076\&s=711848\&e=png\&b=1e1e1e)
      *   `histroy.go/histroy.back/histroy.forward`(事件侦听)方式:
          *   使用popstate事件
          *   window\.onpopstate、window\.addEventListener('popstate',()=>{})
              ![image.png](https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/081e242b3cff4ef7aebaa9afe43a6cee~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1252\&h=304\&s=63095\&e=png\&b=1e1e1e)
      *   `histroy.pushState、histroy.replaceState`(函数重写)方式
          *   需要对原生方法进行重写,但是需要注意的是需要对原生的方法进行重新调用,避免导航等逻辑失效
              ![image.png](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a46c2749df4f4f908c24ee50fc74be1e~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=902\&h=346\&s=62329\&e=png\&b=1e1e1e)
              ![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c74825d5e8dd4e7ca784412eac2335f7~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1008\&h=346\&s=70793\&e=png\&b=1e1e1e)
      
    • 匹配子应用 → handleRouter.js
      • 注意📢:
        • 需要在上述步骤完成后手动的执行一下,避免刷新或初次进入时拦截失败
        • 需要先卸载上一个应用 然后再加载下一个应用 避免切换的时候重复出现多个子应用
          • 解决办法是进行上一步和下一步的路由的缓存
      • 获取当前的路由路径
        • window.location.pathname
      • 去缓存的路由配置信息里进行查找(_app) image.png
    • 加载子应用
      • 请求获取子应用的资源:HTML、JS、CSS
      • 注意📢:需要手动设置全局环境变量window.__POWERED_BY_QIANKUN__ = true,避免子应用在渲染时进行独立渲染将主应用进行替换掉 image.png
    • 渲染子应用
      • 在渲染子应用的时候会出现静态资源如图片加载失败的问题,此时是因为路由问题导致的,解决办法如下
        • 子应用手动更改webpack或框架的配置,进行baseurl的配置
        • 子应用通过环境判断,当处于微前端环境时进行读取全局变量来实现动态设置baseurl,此时需要主应用把子应用的域名配置给暴露出来(即设置为子应用的app.entry) image.png

拓展

实施微前端的6种方式 → 《前端架构从入门到微前端》

image.png

路由分发

路由分发式微前端,即通过路由将不同的业务分发到不同的、独立前端应用上。其通常可以通过 HTTP 服务器的反向代理来实现,又或者是应用框架自带的路由来解决。

image.png

前端微服务化

即和现阶段的微前端类似,一个页面由多个的前端应用组合成,单个应用独立运行;其实前端微组件就是从服务端的微服务化演变而来的,应用与浏览器上的微服务

image.png

微应用化

微应用化是指在开发时应用都是以单一、微小应用的形式存在的,而在运行时,则是通过构建系统合并这些应用,并组合成一个新的应用。其前提是只能使用同一种前端框架进行实现;

image.png

微件化

微件化(Widget)是一段可以直接嵌入应用上运行的代码,它由开发人员预先编译好,在加载时不需要再做任何修改或编译。微前端下的微件化是指,每个业务团队编写自己的业务代码,并将编译好的代码部署到指定的服务器上,运行时只需要加载指定的代码即可。

其实可以理解成一个函数或类的独立拆分,最终实现某个功能点,当然这是较小范围的实现,可以放大化实现到具体的组件或者SDK上;在实际开发中,微件可以是一个简单的组件,如时间选择器、搜索框、地图组件、图标组件、计时器等,具体的实现技术手段包括SPA的框架、Web Components标准创建的自定义元素等

image.png

iframe
Web Components

哪些业务场景不推荐使用微前端

  • 简单的小型应用,且未来不太会进行大规模的功能拓展、模块拆分和性能优化
    • 微前端中的性能优化方案
      • 资源预加载和缓存、子应用或功能模块的懒加载
      • 代码层面的分割和压缩
      • 主子应用间的通信减少,避免不必要的、频繁的、大量的数据传递
      • 优化样式胡布局,避免复杂布局计算和重绘
      • 建立合理的监控和分析机制
      • 公共依赖的提取优化
      • 合理设置路由应用加载顺序
  • 紧耦合的业务逻辑
    • 微前端更适合模块之间相对独立、且交互有限的情况
  • 短期项目或一般的快速原型开发
  • 技术团队规模较小且技术能力有限
  • 对安全性有较高要求的场景
    • 微前端在某些方面会增加安全管理的复杂性和风险
  • 业务需求稳定且不会频繁变更
    • 推荐使用单体应用架构
  • 多个站点的Web应用处于同一个renderer进程时带来的风险
    • 数据交叉泄露风险
    • 某个站点的应用故障或崩溃后会影响到其他应用或者主应用的正常运行
    • 某个应用权限提升后对其他同级应用的恶意操作
    • 安全风险如XSS、CSRF等影响范围扩大,恶意代码的传播范围扩大
    • 会话劫持,以某一个应用的会话权限信息进行与其他应用进行合法的操作,尤其的跨端操作的场景下增加了共享的风险
    • 解决办法就是参考现代浏览器中的多进程架构站点隔离机制,将来自不同源的Web应用进行隔离操作,从而限制应用间的相关交互通信逻辑,类似于沙箱的模式,以保障用户的安全和隐私

微前端架构的实现通常需要以下几方面

  • 微前端配置中心:版本管理、发布策略、动态构建、中心化关林等
  • 微前端观察工具:应用状态的可见与可控
  • 集成层:负责将不同的微应用组合起来呈现给用户。这可以通过多种方式实现,比如使用Web Components、iframe、JavaScript库(如single-spa)或简单的路由分发。
  • 通信机制:微应用之间可能需要通信,这可以通过浏览器事件、全局状态管理库(如Redux)或者专门设计的服务来实现。
  • 部署独立性:每个微应用应该能够独立部署,不影响其他微应用或主应用的运行。
  • 共享依赖:微应用间可能会有一些共享的库或组件,通过合理管理这些共享依赖可以减少冗余和加载时间。

推荐文献

每日优鲜供应链前端团队微前端改造
Webpack 4 构建大型项目实践 / 微前端
美团技术年货 → 微前端在美团外卖的实践 ❎
微前端在平台级管理系统中的最佳实践(中原银行)
# 微前端qiankun从搭建到部署的实践
标准微前端架构在蚂蚁的落地实践
基于 qiankun 的微前端最佳实践(万字长文) - 从 0 到 1 篇
qiankun 微前端实践总结
Micro Frontends