灏天阁

微前端在项目中如何落地

· Yin灏

一、业务述求

由于业务发展,相关业务需要集中管理,根据登录人信息获取响应权限,展示相应菜单及模块。

根据需求,我们能得到以下几点要求:

  • 不同应用模块,同时存在于系统
  • 通过菜单路由进行切换,非打开新的页签
  • 用户感知需要弱,不要存在浏览器地址变化等的割裂感

根据业务需求,能并行推出几项技术要求:

  • 避免项目臃肿
  • 新应用能使用非老项目框架
  • 新应用能独立开发、部署、运行
  • 应用隔离
  • 应用间支持通信

二、技术方案选型

根据以上业务需求&技术需求,我们需要一套微前端的架构方案

1、什么是微前端

Micro Frontends 官网 定义了微前端概念:

Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently.

多个团队一起开发一个现代网页应用,并且能够独立发布功能的技术、策略和方法。

微前端概念是从微服务概念扩展而来的,摒弃大型单体方式,将前端整体分解为小而简单的块,这些块可以独立开发、测试和部署,同时仍然聚合为一个产品出现在客户面前。可以理解微前端是一种将多个可独立交付的小型前端应用聚合为一个整体的架构风格。

值得留意的几个点:

  • 微前端不是一门具体的技术,而是整合了技术、策略和方法,可能会以脚手架、辅助插件和规范约束这种生态圈形式展示出来,是一种宏观上的架构。这种架构目前有多种方案,都有利弊之处,但只要适用当前业务场景的就是好方案。
  • 微前端并没有技术栈的约束。每一套微前端方案的设计,都是基于实际需求出发。如果是多团队统一使用了 react 技术栈,可能对微前端方案的跨技术栈使用并没有要求;如果是多团队同时使用了 react 和 vue 技术栈,可能就对微前端的跨技术栈要求比较高。

2、微前端的好处

  • 增量升级

由于历史包袱,有团队依旧存在使用着陈旧而庞大的前端单体模式,被过时的技术栈或赶工完成的代码质量死死拖住后腿,其程度严重到了让人想推翻重写。为了避免完全重写的风险  ,我们更加倾向于将旧的应用程序逐步地翻新,与此同时不受影响地继续为我们的客户提供新功能。

微前端能使我们更加自由地对产品的各个部分做出独立的决策,让团队能做到持续地增加新功能并且对原有的整体几乎不做修改,使我们的架构、依赖以及用户体验都能够增量升级。

另外,如果主框架中有一个非兼容性的重要更新,每个微前端可以选择在合适的时候更新,而不是被迫中止当前的开发并立即更新。如果我们想要尝试新的技术,或者是新的交互模式,对整体的影响也会更小。

  • 简单、解耦的代码库

每个单独的微前端项目的源代码库会远远小于一个单体前端项目的源代码库。这些小的代码库将会更易于开发。更值得一提的是,我们避免了不相关联的组件之间无意造成的不适当的耦合。通过增强应用程序的边界来减少这种意外耦合的情况的出现。

  • 独立部署

与微服务一样,微前端的独立可部署性是关键。它减少了部署的范围,从而降低了相关风险。

image.png

  • 以业务划分的团队

每个团队需要围绕业务功能垂直组建,而不是根据技术能力来组建。这为团队带来了更高的凝聚力。

image.png

3、实现微前端的方式

NG 转发 通过 ng 反向代理来实现不同路径映射不同路由 简单、快速、易配置 在切换应用时会触发浏览器刷新,影响体验;部分模块(例如菜单)需要重复实现。
iframe 嵌套 父应用通过 iframe 来嵌套各个子应用 简单、应用间天然隔离 样式局限性、兼容局限性、通信成本高
web components 浏览器原生组件化方案,将子应用全部改些为 web components,父应用依赖使用 独立、隔离 复杂、实现成本高
Webpack Federation 去中心、去基座模式,利用 webpack5 新特性将模块打包成“server components” 快速、学习成本低 依赖 webpack5、实现成本高、社区成熟度底
路由聚合性基座框架 使用路由聚合,创建隔离环境运行子应用 体验好、快速 成熟度高、需要额外处理特殊情况

基于以上方案,现在有一些已经开源的对应框架:

  • single-spasingle-spa.js.org/):将一个多 spa 项目聚合的路由聚合性基座框架微前端框架。
  • qiankunqiankun.umijs.org/):蚂蚁金服出品,基于 single-spa,较为成熟。
  • MicroAppzeroing.jd.com/):京东出品,基于WebComponent的微前端框架。
  • EMPemp2.netlify.app/):YY 出品,基于Webpack Federation,除了具备微前端的能力,还实现了跨应用状态共享、跨框架组件调用的能力。

对比发现,目前 EMP 最年轻,也最强,但是依赖于 webpack5,且成熟度较低。所以我们优先使用qiankun,他的特性有:

  • 基于 single-spa,开箱即用
  • 支持跨框架
  • 接入简单
  • 样式隔离
  • js 沙箱
  • 提供较多优化方案

三、微前端项目架构图

1、微前端运行架构图

qiankun 运行流程大概分为一下几个步骤:

  1. 父应用初始化
  2. 注册子应用
  3. 应用管理
  4. 应用加载

另外会有一些其他的处理:

  • 路由分发
  • 子应用销毁
  • 通信

image.png

2、项目应用架构图

  • 父应用拥有自己的业务模块,例如统计、任务
  • 父应用集成子应用所需的工具模块,例如:通知、通信、菜单、用户
  • 父应用与子应用有共用的模块,会抽成公共依赖包
  • 子应用之间共用的模块,会抽成公共依赖包
  • 子应用根据权限动态挂载

3、服务依赖架构图

  • 每个应用(父/子)都有自己对应的微服务
  • 服务间相互独立
  • 应用间共用服务依赖于公共服务

image.png

四、微前端实践中遇到的问题

1、子工程请求文件出现跨域问题

image.png

2.0.16 版本及以前版本 解决方案:自定义 fetch

// 白名单集合
const whiteUrl = ["https://api.map.baidu.com"];
// 脚本请求地址
const scriptUrl = ["https://api.map.baidu.com/getscript"];

/**
 * qiankun 自定义请求
 */
export function qiankunFetch(url, args) {
  let hijack = false;
  whiteUrl.some((item) => {
    if (url.includes(item)) {
      hijack = true;
      insertEleGetScript(url);
      return true;
    }
  });
  if (hijack) return Promise.resolve();
  return window.fetch(url, ...args);
}

/**
 * 插入标签获取脚本
 */
function insertEleGetScript(url) {
  let head = document.getElementsByTagName("head")[0];
  let ele = document.createElement("script");
  let id = +new Date() + Math.random();
  ele.src = url;
  ele.id = id;
  head.append(ele);
  ele.onload = function () {
    let removeFlag;
    scriptUrl.some((item) => {
      if (url.includes(item)) {
        removeFlag = true;
        return true;
      }
    });
    if (!removeFlag) head.removeChild(document.getElementById(id));
  };
}

2.0.16 以后版本解决方案: excludeAssetFilter(https://qiankun.umijs.org/zh/api

loadMicroApp({
  ....
}, {
  excludeAssetFilter: (assetUrl) => {
    const whiteUrl = ['baidu']
    return whiteUrl.some((e) => assetUrl.includes(e))
  },
})

2、百度地图/高德地图在子工程无法使用

现象:

Cannot read properties of null (reading 'parentNode')

分析:

根据 qiankun 的沙盒实现源码(https://github.com/umijs/qiankun/tree/master/src/sandbox)发现,子项目的 window 是经过 proxy 特殊处理的,但是地图组件需要使用原生的 window 进行处理,所以会报上述错误。

解决方案:

1、基座应用将原生 window 提供给子应用。

// 获取子应用使用
loadMicroApp(
{
  name: microName,
  props: {
    // 获取主工程Windows
    getParentWindows: () => {
      return window
    },
    ...
  },
  ...
},

2、子应用初始化地图时,更改 this 指向

// 获取原生window
const window_ = window.__POWERED_BY_QIANKUN__
  ? window._getParentWindows()
  : window;
// 更改this指向
window_.onLoad = () => {
  // 地图相关api调用
  this.initMap();
  this.mapSearchInit();
};

3、基座工程使用 gcommon,子工程使用 cook-ui,样式冲突(父子工程的样式冲突)

现象:

由于基座工程与子工程使用不同的 UI 组件库,基座使用的 gcommon ,子工程使用的 cook-ui ,但是这两个 UI 库都是基于 element-ui 的基础上演进而来,导致部分样式的命名完全相同,最终使基座工程与子工程样式冲突。

解决方案:

1、首先使用官方提供的样式解决方案:strictStyleIsolation (https://qiankun.umijs.org/zh/api),实现原理是通过为每个微应用添加一个 shadow dom 节点。基于 ShadowDOM 的严格样式隔离并不是一个可以无脑使用的方案,大部分情况下都需要接入应用做一些适配后才能正常在 ShadowDOM 中运行起来。 组件库的 class 名称需要支持自定义前缀 ShadowDOM,才能隔离开父子工程的样式。

如果大家使用的 AntDesign 的库,可以使用他提供的 prefixCls 来处理样式冲突,这个配置会为所有样式指定配置的样式前缀。

2、继续使用官方提供的进阶解决方案:experimentalStyleIsolation (qiankun.umijs.org/zh/api),我们可用通过这段代码简单看一下他的实现原理:

const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
  css.process(appElement!, stylesheetElement, appInstanceId);
});

他是自动为子工程所有 css 文件样式添加一份特定头部,例如 div 转为 div[data-qiankun-react16] ,以实现父子工程样式隔离。(子工程的所有加载文件都是被主工程拦截的)

但是无论 1 还是 2 对于很多挂载在全局 <style type="text/css"> 标签内的样式是无法隔离的,例如浮层组件、弹框组件: 这些组件是挂载在 body 上的导致组件渲染不在 shadow dom 节点下 样式<style type="text/css">标签注入的无法被截获

3、借助于 experimentalStyleIsolation 的思路,再结合 gcommon 与 cook-ui 的样式命名都是使用 el 开头

  • 在编译的时,编写 Babel 插件,指定将 gcommon 内使用 el 的地方全部替换为 portal-el (vue、js 文件内的代码替换)
// 调整webpack配置,指定gcommon使用change-class-prefix-loader
chainWebpack: (config) => {
...
// 将gcommon-ui的class名称前缀修改了
config.module
  .rule('change-prefix')
  .test(/.js$/)
  .include.add(path.resolve(__dirname, './node_modules/@gcommon/gcommon-ui'))
  .end()
  .use('change-prefix')
  .loader('./plugins/webpack/change-class-prefix-loader')
  .options({
    prefix: 'el-',
    replace: 'portal-el-',
  })
  .end()
}


/**
* change-class-prefix-loader.js
*/
module.exports = function changeClassPrefixLoader(source) {
const { prefix = 'el-', replace = 'gp-' } = loaderUtils.getOptions(this) || {}
const result = handleSource(source, prefix, replace)
return result.code
}


function handleSource(source, prefix, replace) {
const ast = parser.parse(source)
traverse(ast, {
  CallExpression(path) {
    path.traverse({
      Literal(path) {
        const node = path.node
        // 普通字符串替换
        if (
          typeof node.value === 'string' &&
          node.value.indexOf(prefix) !== -1 &&
          !canChange(path) &&
          config.classPrefixList.some((e) => node.value.includes(e))
        ) {
          const reg = new RegExp(`(^|(\s)+|(\.)+)${prefix}(?!icon)`, 'g')
          node.value = node.value.replace(reg, `$1${replace}`)
        }
        path.replaceWith(node)
      },
      RegExpLiteral(path) {
        const node = path.node
        if (node.pattern.indexOf(prefix) !== -1 && config.classPrefixList.some((e) => node.pattern.includes(e))) {
          const reg = new RegExp(prefix, 'g')
          node.extra.raw = node.extra.raw.replace(reg, replace)
          node.pattern = node.pattern.replace(reg, replace)
        }
        path.replaceWith(node)
      },
    })
  },
})
return generate(ast)
}


function canChange(path) {
const parenNode = path.parent
if (parenNode.type !== 'CallExpression') return false
return parenNode.callee && (parenNode.callee.name === 'h' || parenNode.callee.name === '_c')
}
  • 在编译的时,编写 postcss 插件,指定将 gcommon 内所有 css 名称使用 el 的地方全部替换为 portal-el
// postcss.config.j文件s
const changeCssPrefix = require("./plugins/postcss/change-css-prefix");
module.exports = {
  plugins: [
    // 将gcommon-ui的css名称前缀修改了
    changeCssPrefix({
      prefix: "el-",
      replace: "portal-el-",
    }),
  ],
};

/**
 * change-css-prefix
 */
const postcss = require("postcss");
const config = require("../../config");
module.exports = postcss.plugin("change-css-prefix", function (opts = {}) {
  const { prefix = "el-", replace = "gp-" } = opts || {};
  // 接收两个参数,第一个是每个css文件的ast,第二个参数中可获取转换结果相关信息(包括当前css文件相关信息)
  function plugin(css, result) {
    css.walkRules((rule) => {
      // 遍历当前ast所有rule节点
      const { selector } = rule;
      if (
        selector.includes(prefix) &&
        !selector.includes(replace) &&
        config.classPrefixList.some((e) => selector.includes(e))
      ) {
        const clone = rule.clone();
        const reg = new RegExp(`(^|(\s)*)\.${prefix}(?!icon)`, "g");
        clone.selector = selector.replace(reg, `$1.${replace}`);
        rule.replaceWith(clone);
      }
    });
  }
  return plugin;
});

使用后,发现大部分场景都能满足,但是由于 vue、js 代码中存在部分 el 拼接计算的代码,强行将前缀修改后,会影响计算。如果要处理,只能挨个挑出来,成本比较大。根据公司后续计划,也是全面推行 gcommon ,所以我们最后选择了把子工程提前切为 gcommon ,来解决样式冲突问题。

4、子工程 keep-alive 不可用

现象:

子工程原来有使用 keep-alive 做页面缓存,但是使用 qiankun 后,独立访问还能生效,通过通过基座访问,同一个子应用内生效,但是切子应用后,就之前存的就丢失了。

keep-alive 的实现原理是将 VirtualDom 缓存在 Vue 实例上,但是 qiankun 的默认加载子应用机制,会在切换子应用时销毁之前的应用,这样就会导致 keep-alive 失效。

解决方案:

将自动加载微应用改为手动挂载微应用,并且不做子应用销毁处理。

/**
 * 路由前守卫
 * 会在进入路由前判断是否进入子应用,是否需要初始化子应用等
 */
router.beforeEach(async (to, from, next) => {
  ...
  // 乾坤初始化子工程 && 防止重复启动乾坤
  if (!microApps[microName]) {
    ...
    // 启动微前端服务
    microApps[microName] = loadMicroApp(
      {
        name: microName,
        props: {
          // 获取主工程token
          getAuthorization: () => {
            return getCookieItem('Authorization')
          },
          // 获取主工程用户信息
          getUserInfo: () => {
            return store.state.userInfo
          },
          // 获取主工程菜单权限
          getMenuAndPermission: () => {
            return {
              menuList: store.state.menuList,
              permissionList: store.state.permissionList,
            }
          },
          // 获取子工程互相通讯时存入的一些临时值
          getMicroState: () => {
            return store.state.microState
          },
          // 获取主工程Windows
          getParentWindows: () => {
            return window
          },
          // 获取主工程路由对象
          getParentRouter: () => {
            return router
          },
          // 获取主工程我的工作、我的申请跳转函数
          getTaskRouterFun: () => {
            return { myTaskRouter, myApplyRouter, getTaskTypeEnumByModule, getAllTaskTypeEnum }
          },
        },
        ...
      }
      ...
    )
  }
  ...
  next()
})

5、注意子工程 entry 的路径

主工程访问:qiankun.umijs.org/guidePage

识别到 guidePage 路由则会加载微应用 qiankun.umijs.org/guide/

/guidePage 是路由

/guide 是前端静态资源转发配置

微服务打包需要加上 publicPath === ‘/guide’

包括 woff2 等字体库文件需要 通过 file-loader 也加上 publicPath

使所有微应用资源范围时都加上 publicPath 前缀,如:qiankun.umijs.org/guide/app.d…

微应用的入口地址:qiankun.umijs.org/guide/qiankun.umijs.org/guide 是有区别的

会导致子工程 public-path.js 文件中接收到的 webpack_public_path 值不同

第一个值为 qiankun.umijs.org/guide/ 去访问微应用资源,即 qiankun.umijs.org/guide/app.d…

而第二个会直接访问 qiankun.umijs.org 去访问微应用资源,即qiankun.umijs.org/app.dd223dd…

6、子工程之间如何通讯

现象:

子工程之间存在通讯诉求,比如子工程 A 访问子工程 B 时,希望带一些数据过去,但是通过路由传输无法承载这么多参数。

解决方案:

qiankun 本身就是基座型微前端框架,子应用之前是不能直接通讯的,需要借助于基座应用进行中转,通讯的数据可以放在基座应用的 vuex 或者 Storage 。

/**
 * 主工程:定义qiankun数据监控,数据变化时,存入vuex
 */
import { initGlobalState } from 'qiankun'
import store from '@/store'


const actions = initGlobalState({ microLoading: false, microState: {} })
actions.onGlobalStateChange((state, prevState) => {
  store.commit('setMicroLoading', state.microLoading)
  store.commit('setMicroState', state.microState)
})


export default actions


/**
 * 子应用入口文件
 */
 async function render(props) {
  const {
    container,
    getAuthorization,
    getUserInfo,
    getMenuAndPermission,
    getMicroState,
    getParentWindows,
    getParentRouter,
    getTaskRouterFun,
    setGlobalState,
  } = props
  // 主工程加载时
  if (container) {
    console.log('===================子工程加载===================')
    console.log('UserInfo: ', getUserInfo())
    setCookieItem('Authorization', getAuthorization())
    window._getAuthorization = getAuthorization
    window._getUserInfo = getUserInfo
    window._getMenuAndPermission = getMenuAndPermission
    window._getMicroState = getMicroState
    window._getParentWindows = getParentWindows
    window._getParentRouter = getParentRouter
    window._getTaskRouterFun = getTaskRouterFun
    window._setGlobalState = setGlobalState
    setGlobalState({ microLoading: false })
    ...
  }
  ...
}


/**
 * 子应用A 携带值跳转
 */
const parentRouter = window._getParentRouter ? window._getParentRouter() : this.$router
parentRouter.push('xxx')
window._setGlobalState && window._setGlobalState({ microState: { showSplitL2Dialog: true } })


/**
 * 子应用B 页面取值
 */
 window._getMicroState && window._getMicroState().showSplitL2Dialog

7、父子工程通过无法单纯通过路由实现交互

现象:

期望通过某个链接直接到达子应用的页面,并且子应用根据参数给与一定交互例如弹窗。

由于 qiankun 是通过路由进行父子工程区分,并且父工程无法得到子工程的路由名称,只能通过 path 进行跳转:

this.$router.push({ path: "路由", query: { key: value } });

但是根据 vue-router 的机制,使用 path 跳转时,只能通过 query 进行传递参数,这样也能实现交互的效果,但是是一直存在的,刷新页面后一样会触发原有交互。

解决方案:

路由与通讯剥离,路由只进行跳转,交互通过通讯来完成。

实现方式与问题 6 一致。

最后说两句:建议将 公共组件(垮工程使用)、工程配置、公共 utils 函数 封装成私服包被各个子工程统一使用,公共组件封装可以模仿 g-common 形式注入全局 且 自带国际化翻译

五、相关参考文献

- Book Lists -