【第2078期】iframe 接班人-微前端框架 qiankun 在中后台系统实践

作者:大转转FE

前言

今日早读文章由转转@赵慧杰,@黄家兴投稿分享。

公众号回复关键词 转转 查看本专栏所有文章

正文从这开始~~

背景

在转转的中台业务中,交易流转、业务运营和商户赋能等功能,主要集中在两个系统中(暂且命名为 inner/outer )。两个系统基座(功能框架)类似,以 inner 系统为例,如图:

inner系统基座

业务现状问题

维护迭代,随时间延续是不可避免的

至今,inner/outer 均有以下特点:

初次接触上述问题时,闪现在脑海里的是:用 iframe 呀。确实,刚开始也是这样做的。

问题暴露,在维护迭代中是个契机

系统在一个长时间跨度的运行下,随着维护人员的变迁、使用人群的增多,更多的问题也接踵而至:

样式不统一

由于没有统一规范,每个功能模块在不同的开发者键盘下设想的结构不同,输出的风格也不统一,使整个系统看起来略显杂乱。

浏览器前进/后退

首先,iframe 页面没有自己的历史记录,使用的是基座(父页面)的浏览历史。所以,当iframe 页在内部进行跳转时,浏览器地址栏无变化,基座中加载的 src 资源也无变化,当浏览器刷新时,无法停留在iframe内部跳转后的页面上,需要用户重新走一遍操作,体验上会大打折扣。

弹窗遮罩层覆盖可视范围

iframe 页产生的弹窗,一般只能遮罩 iframe 区域。

页面间消息传递

与基座非同源下,iframe 无法直接获取基座 url 的参数,消息传递需要周转一下,如使用postmessage来实现;而动态创建的 iframe 页,或许还需要借助本地存储等。

页面缓存

iframe 资源变更上线后,打开系统会发现 iframe 页依旧是老资源。需要用时间戳方案或强制刷新。

加载异常处理

与基座非同源下,onerror 事件无法使用。使用 try catch 解决此问题,尝试获取 contentDocument 时将抛出异常

以上问题,从业务价值看,对用户的使用体验会有损失;从工程价值看,希望能通过技术提升业务体验的同时,也提高系统的维护性。

改进实践 - 微前端

实践新技术,在问题暴露时是方向

大多数工程师,包括我,一边儿嘴里说着:学不动啦!一边儿想尝试一些新方式来优化系统。

结合问题分类,有思考一些尝试方向,如:

中后台 UI 规范:历经迭代,百花齐放,然而更需要的是找到合适我司的风格,保持一致性。

另外,大互联网时代,从工程角度看,社区对类似系统的探索有很多,除了 iframe 外,也有不少相对成熟的替代方案:

提起这两个,就要提一下微前端理念,目前社区有很多关于微前端架构的介绍,这里简单提一下:

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

大致是说,微前端有以下特点:

基于此,不难想到:iframe 也是符合微前端理念的。那其他方案又是如何做的呢?

single-spa

社区里 single-spa 介绍也不少。根据 demo 比葫芦画瓢,可以知道它的架构分布:

single-spa架构

启动服务的配置主要是在single-spa-config 文件中,包含项目名称、 项目地址、路由配置等:

  1. // single-spa-config.js

  2. import {registerApplication, start } from 'single-spa';


  3. // 子应用唯一ID

  4. const microAppName = 'react';


  5. // 子应用入口

  6. const loadingFunction = () => import('./react/app.js');


  7. // url前缀校验

  8. const activityFunction = location => location.pathname.startsWith('/react');


  9. // 注册

  10. registerApplication(

  11. microAppName,

  12. loadingFunction,

  13. activityFunction

  14. );


  15. //singleSpa 启动

  16. start();

single-spa 让基座和子应用共用一个 document,那就需要对子应用进行改造:把子项目的容器和生成的 js 插入到基座项目中。

  1. <div id='micro-react'></div>

  2. <script src=/js/chunk-vendors.js></script>

  3. <script src=/js/app.js></script>

不过这种方式需要对现有项目的打包方式和配置项进行改造,成本很大。所以,对于已有的工程项目,我选择了放弃使用。

qiankun

qiankun 也是社区提到比较多的一个开源框架,是基于single-spa 实现了开箱即用。可以采用html entry 方式接入子应用,且子应用只需暴露一些生命周期,改动较少。【少】这个点,真是让我跃跃欲试。

目前我司业务场景是单实例模式(一个运行时只有一个子应用被激活),我们可以根据一张图来看看单实例下以html entry方式 qiankun 实现流程:

qiankun原理

如上图所示,一个子应用的全过程有:

具体实现细节,大家可以参考qiankun源码。

实践
基座

从规范化开发角度,我司的中后台系统是基于 umi 开发(详细可参考我们之前的文章 umi 中后台项目实践)。在构建主应用使用了配套的 qiankun 插件:@umijs/plugin-qiankun。

初始化配置项,注册子应用

插件安装之后,我们可以在入口文件里配置:

此处主要以运行时为例

  1. // app.js

  2. export const qiankun = Promise.resolve().then(() => ({

  3. // 运行时注册子应用信息

  4. apps: [

  5. {

  6. // 结算单管理

  7. name: 'settlement', // 唯一id,与子应用的library 保持一致

  8. entry: '//xxx', // html entry

  9. history: 'hash', // 子应用的 history 配置,默认为当前主应用 history 配置

  10. container: '#root-content', // 子应用存放节点

  11. mountElementId: 'root-content' // 子应用存放节点

  12. }, {

  13. // 公告消息

  14. name: 'news', // 唯一id,与子应用的library 保持一致

  15. entry: '//xxx', // html entry

  16. history: 'hash', // 子应用的 history 配置,默认为当前主应用 history 配置

  17. container: '#root-content', // 子应用存放节点

  18. mountElementId: 'root-content' // 子应用存放节点

  19. }

  20. ],

  21. jsSandbox: { strictStyleIsolation: true }, // 是否启用 js 沙箱,默认为 false

  22. prefetch: true, // 是否启用 prefetch 特性,默认为 true

  23. lifeCycles: {

  24. // see https://github.com/umijs/qiankun#registermicroapps

  25. beforeLoad: (props) => {

  26. return Promise.resolve(props).then(() => loading())

  27. },

  28. afterMount: (props) => {

  29. console.log('afterMount', props)

  30. },

  31. afterUnmount: (props) => {

  32. console.log('afterUnmount', props)

  33. }

  34. }

  35. }))

装载子应用,在路由配置中使用microApp来获取相应的子应用名称:

  1. // router.config.js

  2. export default [

  3. {

  4. path: '/',

  5. component: '../layouts/BasicLayout',

  6. routes: [

  7. ...

  8. {

  9. path: '/settlement/list',

  10. name: '结算单管理',

  11. icon: 'RedEnvelopeOutlined',

  12. microApp: 'settlement', // 子应用唯一id

  13. },

  14. {

  15. path: '/settlement/detail/:id',

  16. name: '结算单管理',

  17. icon: 'RedEnvelopeOutlined',

  18. microApp: 'settlement', // 子应用唯一id

  19. hideInMenu: true,

  20. },

  21. ...

  22. ...

  23. {

  24. component: './404',

  25. },

  26. ],

  27. },

  28. {

  29. component: './404',

  30. },

  31. ]

以上就是基座的改动点,看起来代码侵入性很少。

子应用

在子应用中,需要做如下的配置

入口文件设置 baseName,及暴露钩子函数

  1. //设置主应用下的子应用路由命名空间

  2. const BASE_NAME = window.__POWERED_BY_QIANKUN__ ? "/settlement" : "";


  3. // 独立运行时,直接挂载应用

  4. if (!window.__POWERED_BY_QIANKUN__) {

  5. effectRender();

  6. }


  7. // 在子应用初始化的时候调用一次

  8. export async function bootstrap() {

  9. console.log("ReactMicroApp bootstraped");

  10. }


  11. export async function mount(props) {

  12. console.log("ReactMicroApp mount", props);

  13. effectRender(props);

  14. }


  15. //卸载子应用的应用实例

  16. export async function unmount(props) {

  17. const { container } = props || {};

  18. ReactDOM.unmountComponentAtNode(document.getElementById('root-content')

  19. );

  20. }

webpack 配置中,需要设置输出为 umd 格式:

  1. // 设置别名

  2. merge: {

  3. plugins: [new webpack.ProvidePlugin({

  4. React: 'react',

  5. PropTypes: 'prop-types'

  6. })],

  7. output: {

  8. library: `[name]`, // 子应用的包名,这里与主应用中注册子应用名称一致

  9. libraryTarget: "umd", // 所有的模块定义下都可运行的方式

  10. jsonpFunction: `webpackJsonp_ReactMicroApp`, // 按需加载

  11. }

  12. } //自定义webpack配置

OK,配置完成!

理论上,启动项目,部署等都应该没有问题了。咦,打开地址,页面一直在 loading,控制台一堆报错,看起来要踩一踩坑了。

踩坑

版本一致性

如果主应用和子应用都是基于 umi 框架,在使用 @umijs/umi-plugin-qiankun 插件时,要使用同一个版本,否则子应用报错。

跨域

qiankun 是通过 fetch 去获取子应用资源的,所以必须支持跨域

  1. const mountDOM = appWrapperGetter();

  2. const { fetch } = frameworkConfiguration;

  3. const referenceNode = mountDOM.contains(refChild) ? refChild : null;


  4. if (src) {

  5. execScripts(null, [src], proxy, {

  6. fetch,

  7. strictGlobal: !singular,

  8. beforeExec: () => {

  9. Object.defineProperty(document, 'currentScript', {

  10. get(): any {

  11. return element;

  12. },

  13. configurable: true,

  14. })

  15. };

  16. })

  17. }

比如:基座地址为 b.zhuanzhuan.com, 子应用为 d.zhuanzhuan.com 。当基座去加载子应用时,会出现跨域错误。

曾经有采用通过 Node 服务做一层中转,跳过跨域问题:

  1. ....

  2. maxDays: 3, // 保留最大天数日志文件

  3. }


  4. // 代理

  5. config.httpProxy = {

  6. '/cors': {

  7. target: 'https://d.zhuanzhuan.com',

  8. pathRewrite: {'^/cors' : ''}

  9. }

  10. };


  11. return config

但考虑应用的访问量,以及线上线下环境维护成本,觉得必要性不是很大,最终选择通过 nginx 解决跨域。

子应用内部跳转

子应用内部跳转,需要在基座路由上提前注册好,否则在跳转后,页面识别不到。

  1. {

  2. path: '/settlement/detail/:id',

  3. name: '结算单管理',

  4. icon: 'RedEnvelopeOutlined',

  5. microApp: 'settlement',

  6. hideInMenu: true,

  7. },

css 污染

qiankun 只能解决子应用之间的样式相互污染,不能解决子应用样式污染基座的样式。比如:当切换到某个子应用时,左侧菜单栏突然往右移了。

查看控制台,不难发现,子应用的相同模块覆盖了基座:

这个问题,可以通过改变基座的前缀来解决,搞一个postcss 插件给不同的组件添加不同的前缀。

这里补充一个 css 隔离常用的方式如:css前缀、CSS Module、动态加载/卸载样式表。

qiankun 中 css沙箱机制 采用的是 动态加载/卸载样式表。

重写 HTMLHeadElement.prototype.appendChild 事件

  1. // Just overwrite it while it have not been overwrite

  2. if (

  3. HTMLHeadElement.prototype.appendChild === rawHeadAppendChild &&

  4. HTMLBodyElement.prototype.appendChild === rawBodyAppendChild &&

  5. HTMLHeadElement.prototype.insertBefore === rawHeadInsertBefore

  6. ) {

  7. HTMLHeadElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({

  8. rawDOMAppendOrInsertBefore: rawHeadAppendChild,

  9. appName,

  10. appWrapperGetter,

  11. proxy,

  12. singular,

  13. dynamicStyleSheetElements,

  14. scopedCSS,

  15. excludeAssetFilter,

  16. }) as typeof rawHeadAppendChild;

  17. ....

当子应用加载时,在 head 插入 style/link ; 当卸载时,直接移除。

  1. // Just overwrite it while it have not been overwrite

  2. if (

  3. HTMLHeadElement.prototype.removeChild === rawHeadRemoveChild &&

  4. HTMLBodyElement.prototype.removeChild === rawBodyRemoveChild

  5. ) {

  6. HTMLHeadElement.prototype.removeChild = getNewRemoveChild({

  7. appWrapperGetter,

  8. headOrBodyRemoveChild: rawHeadRemoveChild,

  9. });

  10. HTMLBodyElement.prototype.removeChild = getNewRemoveChild({

  11. appWrapperGetter,

  12. headOrBodyRemoveChild: rawBodyRemoveChild,

  13. });

  14. }

看起来很完美,但有时候会出现,基座样式丢失的问题。这个跟子应用卸载的时机有关系:当切换子应用时,当前子应用沙箱环境还未被卸载,但基座 css 已被插入,当卸载时会连带基座 css 一起被清除。

错误捕获,降级处理

若子应用加载失败,需要给相应的提示或动态插入iframe页:

  1. // iframe.js

  2. export default ({ sourceUrl }) =>

  3. <iframe

  4. src={sourceUrl}

  5. title="xxxx"

  6. width="100%"

  7. height="100%"

  8. border="0"

  9. frameBorder="0"

  10. />


  11. import { render } from 'react-dom';


  12. // 全局未捕获异常处理器

  13. addGlobalUncaughtErrorHandler((event) => {

  14. console.error(event);

  15. const { message, location: { hash } } = event;

  16. // 加载失败时提示

  17. if (message && message.includes("died in status LOADING_SOURCE_CODE")) {

  18. Modal.Confirm({

  19. content: "子应用加载失败,请检查应用是否可运行"

  20. onOk: () => import('./Inframe.js')

  21. });

  22. }

  23. });

路由懒加载样式丢失

子应用中存在按需加载的路由,在加载时页面样式丢失,这是官方库产生的问题,issue 里已有大佬提 PR 啦,可参考 https://github.com/umijs/qiankun/issues/857

以上,就是我们的不完全踩坑。

应用间的通信,在我司的业务场景中复杂度不高,使用官方提供的方案就可以解决,此处没有详说。

后续

持续性思考会带来的技术红利

此次接入 qiankun,也只是处于表面应用。后续我们更要思考接入它之后更深的工程价值,如:

自动接入 qiankun,结合我司已有的脚手架和 umi 模板,额外添加一个命令,自动注册子应用,做到自动化。

子应用间组件共享,基座和子应用大概率都用到了 react/dva 等,是否可以在基座加载完之后,子应用直接复用?当然,浅显思考应该少不了 webpack 的 externals。

关于本文

作者:@赵慧杰 原文:https://mp.weixin.qq.com/s/w1BxPzsdwD_hKsYRTnkW4g

为你推荐


【第1929期】目标是最完善的微前端解决方案 - qiankun 2.0


【第2065期】做B端后台产品很复杂?一份完整的设计流程和规范!


欢迎自荐投稿,前端早读课等你来