效率前端微应用推进之微前端研发提效

2023年 11月 22日 147.2k 0

一、背景

业务背景

得物效率前端所在的效率工程为提升企业协作效率而生,面临大量的 PC 侧的中后台应用场景。

在之前的微信公众号《得物效率前端微应用推进过程与思考》中详细介绍了效率前端推进微应用落地的思路和部分效果。

这篇文章将着重介绍得物效率前端微应用推进中,微前端的研发效率遇到的挑战和解决方案。

名词解释

微应用

「微应用」是得物效率前端内部称谓,是一个基于“monorepo & 微前端 & 基座与业务分离”的、包括“文档 & 工具”的一套体系化降低研发成本和提升用户体验的技术产品。

微前端

「微前端」是得物效率前端微应用推进的重要一环,尤其是父子应用技术栈不同时,利用 iframe / qiankun / wujie / micro-app 等工具进行微前端化改造,能显著增强业务扩展性。

图片图片

基座应用/父应用

微前端中,基座应用(父应用)、子应用是常见概念,本文描述中,“基座应用”又名“父应用”,为简化文案,“基座应用与子应用”也被称为“父子应用”。

浏览器插件/扩展

chrome://extensions/ 页面中对第三方工具称为“扩展”,在本文语境下,又称为“插件”。

二、研发费力度痛点

就研发效率而言,微前端在团队多个业务落地后,面临研发过程费力度高的问题。

费力点1

本地开发时,从启动 1 个本地服务,变为需启动 2 个本地服务:

大大多数情况下,项目需要启动  2 个本地服务(基座应用和子应用)才能进行日常开发,因为子应用通常依赖基座应用透传一些依赖数据。

而非微前端场景下,启动 1 个应用就可以了,这反而引入了降低了研发效率。虽然微前端方案更注重业务效率,但研发效率也是必须要考虑的。

如果电脑性能一般的话,卡顿问题就随之而来了。

费力点2

基座应用本地代码需要做适配性改造:

如父应用需要区分本地环境和生产环境类似这样的代码,虽然代码量不大,但还是需要关注的:

// 基座代码
if (isLocal) {
    return 
} else if (isProd) {
    return 
}

费力点3

频繁无规律刷新:

在 Qiankun 微前端框架且本地开发环境下,基座应用与子应用页面均需要 WebSocket 与其自身本地服务进行通信。

在同时启动基座与子应用的本地服务后,修改子应用代码以及偶发的,页面会触发 Reload,而不是局部更新,开发体验很差。针对这个问题我们做了一些分析。

HMR 的热更新逻辑

本地开发过程会启动 Webpack-Dev-Server 服务,其会监听业务文件变化,浏览器通过 WebSocket 与 Webpack-Dev-Server 进行通信。

当发现文件内容改变时 Webpack-Dev-Server 会根据更新的文件内容生成 Hash 信息传递给浏览器,如图:

图片图片

当浏览器收到信息时,会根据收到的信息和配置进行判断是刷新操作还是热更新操作。热更新时,Webpack-Dev-Serer 通过 Jsonp 拉取最新的 JS 模块代码,并进行模块替换,如图:

图片图片

若此过程异常,则会降级为页面刷新。

无规律刷新原因分析

基座应用(端口 8010)热更新时返回的文件 Json 和 JS 文件(分别是 **:8010/update.json 和 **:8010/update.js)内容如下:

图片图片

图片图片

但在嵌套在基座应用中的本地子应用(端口 8020)热更新时,两个同类文件并没有返回内容(**:8020/update.json 和 **:8020/update.js)

图片图片

图片图片

若浏览器单独打开两个文件的地址(**:8020/update.json 和 **:8020/update.js),有内容返回。

图片图片

但是在基座与子应用嵌套的情况下,子应用的任何请求(包括 update.json / update.js )都会被基座应用代理,开发环境下很容易出现子应用的更新探针请求被基座代理后,出现内容丢失的情况。

子应用 HMR 逻辑检测到更新探针请求内容异常,局部更新失效,降级为页面刷新。

即使该问题解决,微前端应用开发者依然面临同时启动 2 个应用才能启动开发的问题,所以我们不过度投入精力关注这个问题。

三、技术调研

解决「默认情况下,父子应用需要分别独立启动,并指定关联关系」的问题,最好的方式是回归到非微前端场景下的常规开发方式,即只启动 1 个应用进行本地开发。

通常情况下,我们开发的是子应用(也就是业务页面),那先实现子应用单独启动即可开启项目开发吧,以下是面向该需求的技术调研。

Shared 通信

Shared 通信方案的原理是,主应用维护一个状态池,通过 Shared 实例暴露一些方法给子应用使用。

同时,子应用需要单独维护一份 Shared 实例,在独立运行时使用自身的 Shared 实例,在嵌入主应用时使用主应用的 Shared 实例,这样就可以保证在使用和表现上的一致性。

Shared 通信方案要求父子应用都各自维护一份属于自己的 Shared 实例,同样会增加项目的复杂度。同时,在子应用独立运行时,Shared 只能获取本地缓存数据,无法真正做到完全独立于子应用运行。

图片图片

Mock 父应用环境

也就是在子应用中模拟父应用嵌套环境,提供一个独立的模拟父应用的组件,封装了 Layout 布局、权限、用户信息等,并且具备必要的父子通信能力,子应用调用该 Mock 组件,独立启动以后进行日常开发。

这个方案和 Shared 通信有类似之处。

用户体验

// 这是子应用代码


import { MicroLayout } from '@abc/components';


//  组件内使用

    {children}

图片图片

流程设计

图片图片

其他

对业务开发者而言,基座应用 Layout 的改动均需要使用者进行 Mock Component 的升级,比较麻烦。

对 Mock Component 开发者而言,需要额外开发父子通信方案 Microservice,用于 Mock Component 与子应用的通信。

这套方案在应对比较简单的甚至没有数据传输的微应用上是可以的,但是对于自定义化程度较高,复杂程度较高的微应用项目来说就不是很方便了。

四、Chrome 代理插件

也就是通过 Chrome 插件,将线上子应用 URL 代理到本地代码。

利用浏览器提供的插件特性,劫持 HTTP 请求,将已经部署在测试/预发/线上环境的项目的微应用部分代理到开发者本地启动的项目,这样可以不用启动基座应用,直接打开目标环境的主应用,却可以访问本地子应用项目进行开发工作。

用户体验

图片图片

其他

子应用需要的数据通过目标环境的父应用获得,和第一套方案相比,他既可以满足复杂场景下的子应用独立启动,也不需要关注每个接入的子应用他的数据依赖关系,研发成本也较低。

图片图片

四、Chrome 代理插件

产品设计

“Chrome 插件代理子应用到本地代码”以提升微前端研发效率,该浏览器插件需要具备以下核心能力:

图片图片

规则灵活配置。插件实现了 from 和 to 的地址映射规则配置表单。

如下图,所有含 https://t1-xxxxxxxx.net/microapp/ 路径的请求都将被代理到 localhost:8020/microapp/。

图片图片

  • 缓存能力。用户关闭浏览器/电脑后再次打开,仍然能够使用之前保存的代理规则。
  • 快捷操作。为常用产品配置内置规则,一键即可启动,非常方便。
  • 实时显示。需要实时显示代理规则的生效情况,方便用户确认哪些规则正在生效。
  • 技术设计

    Proxy 和 Popup

    Chrome 插件分为 2 个模块:Proxy 代理劫持模块和 Popup 用户交互模块。

    图片图片

    功能流转

    图片图片

    无感更新(Seamless Update)

    图片图片

    popup.html 作为用户界面入口,动态引入 popup.js 处理用户交互,popup.js 动态引入 proxy.js 执行 url 拦截规则。

    这么做需要在 chrome 插件的 csp 安全策略配置中加入 cdn 域名白名单,允许插件访问外部 cdn 资源。

    manifest.json 配置如下:

    值得注意的是,无感更新方案只在 Chrome V2 中实现了,V3 版本的 Chrome 插件执行了更为保守的安全策略,限制第三方资源的加载。

    {
    ...
    "permissions": [
      "webRequest", // 允许浏览器开放http请求劫持的功能
      "storage", // 允许使用浏览器缓存
      "activeTab", 
      "background", 
      "webRequestBlocking",
      ""
    ],
    // 允许特定域名可以访问的安全策略
    "content_security_policy":"script-src 'self' https://cdn.xxxxx.com 'sha256-G7YAg/PQDo8GYc/fSYvWtXP98kXS7iqT7K4QZgyhUIE='; object-src 'self'",
    "content_scripts": [
      {
        "matches": ["*://*.xxxxxxx.net/*"],
        "js": ["contentScript.bundle.js"]
      }
    ]
    }
    // ==========attentinotallow===========
    //v2配置,v2版本中可以配置script等通过外部引入,这个content_security_policy配置参数不加或者加上之后相应的值填none
    "content_security_policy": "script-src 'none'; object-src 'none'",
    
    
    //v3配置,v3版本中安全政策配置script引入等信息,都必须填写self,即只允许script标签引用当前插件内部文件,不允许引用外部链接,如果不填写self的话,插件添加到扩展程序时会报错
    "content_security_policy": {
    //原文:此政策涵盖您的扩展程序中的页面,包括 html 文件和服务人员;
    "extension_pages": "script-src 'self'; object-src 'self'",
    
    
    //原文:此政策涵盖您的扩展程序使用的任何[沙盒扩展程序页面](https://developer.chrome.com/docs/extensions/mv3/manifest/sandbox/)。;
    "sandbox": "sandbox allow-scripts; script-src 'self'; object-src 'self'"
    },

    在 popup.html 中动态引入 popup.js 示例:

    
    
      
        
        
        Popup
        
          var scriptEl = document.createElement('script');
          scriptEl.defer = "defer";
          scriptEl.src = "https://cdn.xxxxxxxxxxx/popup.js?timestamp=" + Date.now();
          document.getElementsByTagName('head')[0].appendChild(scriptEl);
      
      
    
    
      
        
      
    

    各模块 JS 在构建后上传至 CDN,再在 popup.html / background.js / ... 中动态引入以实现无感更新。

    图片图片

    Proxy

  • 拦截页面请求。
  • 提供缓存配置的功能,用户在关闭浏览器后,配置的映射关系不会自动消失,以便用户下次打开的时候正常提供服务。
  • 通信功能,需要将用户在 Popup 页面交互时提交的数据地址数据提供给拦截方法,从而实现对应的拦截效果。
  • 提供配置信息状态的缓存数据,方便观测拦截效果。
  • 以下是拦截逻辑的相关代码:

    chrome.webRequest.onBeforeRequest.addListener(
      worker.getRequest.bind(worker),
      {urls: worker.getFilterUrls(worker.replaceRules)},
      ['blocking']
    );
    // Class background 
    class Background {
        constructor() {
        this.replaceRules = []
        }
        getRequest(details) {
          if (!details) {
            // chrome未返回任何request
            return false
          }
          const {url} = details
          const returnObj = {}
          if (this.replaceRules.length) {
            // 存在替换规则
            this.replaceRules.map((item) => {
              if (url.includes(item.from)) {
                // 更新 icon 状态
                this.setBadgeInfo(true)
                // 代理替换
                returnObj.redirectUrl = url.replace(item.from, item.to)
                // 缓存更新当前代理域名
                chrome.storage.sync.get(null, function (data) {
                  if (data.messageProxyingData) {
                    // 存在被代理的数据,合并数据
                    const isExistMessageProxyingData = data.messageProxyingData
                    chrome.storage.sync.set({messageProxyingData: Object.assign(isExistMessageProxyingData, {[item.key]: true})})
                  } else {
                    // 未存在缓存,使用新数据
                    chrome.storage.sync.set({messageProxyingData: {[item.key]: true}})
                  }
                })
              }
            })
          }
          return returnObj
        }
    }

    可以看到,chrome.webRequest.onBeforeRequest/ getRequest 配合拦截获取请求地址,然后拦截替换目标路径,当然这是比较简单的逻辑,复杂的可以参考 glob 写法代理链接。

    Popup

    Popup 用户交互模块,支持一键开启和一键关闭、支持自定义配置映射地址。

    图片图片

    const sendMessage = (data: DataType[]) => {
      // 保留数据中已开启的数据,进行数据传输
      const finalData = data.filter.((item) => item.is_open);
      chrome.runtime.sendMessage({ data: finalData, type: 'rule' });
    };
    const handleSaveData = (
      data: DataType[],
      isNeedSendMsg: boolean,
      key?: number[]
    ) => {
      setDataSource([...data]);
      // 缓存配置
      chrome.storage.sync.set({ popupData: data });
      if (isNeedSendMsg) {
        sendMessage(data);
        if (key?.length) {
          chrome.storage.sync.get(null, function (data) {
            const messageProxyingData = data.messageProxyingData || {};
            if (messageProxyingData) {
              key.forEach.((item) => {
                delete messageProxyingData[item];
              });
            }
            chrome.storage.sync.set({ messageProxyingData });
          });
        }
      }
    };

    ContentScript

    原本无需在 ContentScript 中植入代理相关的任何逻辑,但在 Qiankun 微前端场景下有一个问题,子应用的热更新会失效(可能导致 HMR 无效进而只能 Reload 页面查看最新页面效果),为此我们正好可以借助 Chrome 插件可注入 ContentScript 的能力,为用户自动规避一些问题。

    下面这段注入页面的 ContentScript 可以辅助解决 Qiankun 子应用代码更新后 HMR 失效的问题(方案来自社区)。

    const scrpit = document.createElement('script')
    scrpit.textContent = `
    window.__REACT_ERROR_OVERLAY_GLOBAL_HOOK__ =
            window.__REACT_ERROR_OVERLAY_GLOBAL_HOOK__ || {
                iframeReady: function () {
                    var overlay = document.querySelector(
                        'iframe[style*="z-index: 2147483647;"]',
                    );
                    if (overlay) {
                        overlay.style.display = 'none';
                    }
                },
            };
    `
    document.body.appendChild(scrpit)

    五、推进情况

    项目覆盖率

    目前该插件在效率前端的微应用项目覆盖率达到了 100%,在推进过程中,只需要用户「安装 1 次插件」,即可使用,后续更新无需关心(减少「插件需要更新」的心理负担)。

    研发效率

  • 避免了父子应用均启动时,子应用代码更新后,父应用被动触发 Reload 的问题。
  • 子应用 HMR 热更新延时与非微前端项目没有差距。
  • 用户反馈

    从研发侧的反馈来看,确实有效地提高了微应用场景下的研发效率,告别启动多个本地服务的烦恼。

    同时,Chrome 插件方案实现简单、使用方便,是此类场景下,在研发成本和用户体验上的表现比较均衡的解决方案。

    六、思考

    这个产品是效率前端业务小组发起,从 0 到 1 进行产品设计、技术方案调研、开发、完成落地的,既让参与的同学经历了完整的技术产品研发过程,也解决了实际业务中遇到的问题,并得到了正反馈。

    对于此产品,还有可以持续完善的地方,比如:可以增加本地服务的保活探针,替代手动开启代理规则,更智能化;或者利用其动态更新能力扩充功能,从很具体的小事情入手,不断解决更多问题。

    对于团队,发现工作中的痛点,用工具化的方式沉淀解决方案,解决实际问题,也是前端同学提升自身价值的一个可行路径。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论