微前端框架qiankun实践

基于qiankun实现Angular向React的渐进式重构

2022-09-09 12:00:03

#微前端框架 qiankun 实践

最近把一个年代较久的Angular应用重构成React,分了五六个迭代一步步重写,终于全部完成替换。用这篇博客记录一下碰到的问题。

#为什么需要微前端框架?

#融合不同前端技术栈的项目

现如今前端框架主要有AngularReactVue,每个框架都有很多周边工具,我碰到的主要有两种场景需要在一个应用中使用不同技术栈:

一是技术栈转型,由于种种原因要从一个技术栈转换到另一个技术栈,但是又没办法全部代码一次性重构,微前端是一种非常好的实施渐进式重构的手段和策略。

二是工具使用依赖于技术栈,假如你想引入一个表单生成引擎或图可视化工具,但这些工具依赖于另一个技术栈,在当前使用的技术栈生态中没有更好的产品替代,这种时候也可以考虑另起一个微项目来实现这一模块的功能。

#解决单体项目的不可维护性

微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。

qiankun 可以将一个业务复杂的项目分为多个子应用,每个子应用独立开发独立部署,部署完成后主应用自动完成同步更新。

#实践背景

本次使用 qiankun 目的是将Angular 的项目转成 React。Angular 项目使用的是Angular@9.x,是个比较旧的项目,业务比较多,代码中全局样式较多且比较混乱。React 项目是基于 Umi 框架构建的。

#微前端的工作原理

  1. 子应用导出生命周期供主应用调用。
  2. 提供子应用主应用通信方式。
  3. JS 沙箱模式,隔绝应用。
  4. CSS 隔离方案。

#主应用和子应用选取

由于React项目是最终目标,因此直接将 React 项目作为主应用,Angular项目改造为子应用,再按模块来重构,逐步替换。

#React 主应用配置

由于 React 项目是使用了UmiUmi自身提供了qiankun的插件@umijs/plugin-qiankun,开启qiankun只需要少量的配置。

配置步骤如下:

  1. 安装插件@umijs/plugin-qiankun

  2. config.ts中配置子应用

qiankun: {
	master: {
		apps: [
			{
				name: 'angular9',
				entry: 'http://localhost:8001', // 入口
				// 可放一些静态的参数传给子应用
				props: {
					testProp1: 'test1',
				},
			},
		],
		prefetch: 'all',
		sandbox: {
			strictStyleIsolation: false,
			experimentalStyleIsolation: true,
		},
	},
},
  1. 给子应用分配路由
routes: [
	{
		path: '/',
		component: '@/layouts',
		routes: [
			// other
			{
				path: 'v1',
				microApp: 'angular9', // 上面的子应用的名字
				microAppProps: {
					autoSetLoading: false, // 是否开启loading动画
					className: 'appClassName',
					wrapperClassName: 'wrapperClass',
				},
			},
			// other
		],
	},
],

这里使用的是路由绑定的方式,@umijs/plugin-qiankun还提供了组件<MicroApp />来引入子应用,组件引入的方式适合不带路由的子应用。

  1. 引入Zone.js

Angular的脏数据检测依赖Zone.js,当一个页面上存在多个Zone实例时,Zone.js会抛出错误。因此为确保页面上只运行一个Zone实例,统一在主应用引入Zone.js

#Angular 子应用配置

注:qiankun官方文档中的Angular子应用配置是旧版本,最新版本的应参考 github 仓库https://github.com/umijs/qiankun/tree/master/examples/angular9。

  1. 安装需要的插件
yarn add -D @angular-builders/custom-webpack
yarn add single-spa single-spa-angular
  • Angular是使用Webpack构建,常规的配置在angular.json指定,使用@angular-builders/custom-webpack可以扩展其他Webpack配置。

  • single-spa-angularsingle-spa官方提供的,使用它可以快速的配置生命周期的导出和Webpack配置。

  1. main.ts中配置入口加载和生命周期导出
+ import { singleSpaAngular, getSingleSpaExtraProviders } from 'single-spa-angular';
+ import { singleSpaPropsSubject } from './single-spa/single-spa-props';

// 只在非qiankun子应用环境下执行
+	if (!(window as any).__POWERED_BY_QIANKUN__) {
		platformBrowserDynamic()
			.bootstrapModule(AppModule)
			.catch(err => console.error(err));
+	}

+ const { bootstrap, mount, unmount } = singleSpaAngular({
+   bootstrapFunction: singleSpaProps => {
+	 	// 主应用向子应用传参
+     singleSpaPropsSubject.next(singleSpaProps);
+     return platformBrowserDynamic(getSingleSpaExtraProviders()).bootstrapModule(AppModule);
+   },
+   template: '<app-root />',
+   Router,
+   NgZone: NgZone,
+ });
  1. 配置路由 base,修改app-routing.module.ts
@NgModule({
  ...
  providers: [
    {
      provide: APP_BASE_HREF,
      useValue: (window as any).__POWERED_BY_QIANKUN__ ? '/v1' : '/',
    },
  ],
})
  1. 注释掉zone.js的引入

注释掉polyfills.ts中的zone.js引入

// import 'zone.js/dist/zone'; // Included with Angular CLI.

index.html中引入,适配子应用单独运行

<head>
 <script src="https://unpkg.com/zone.js" ignore></script>
</head>

#微前端常见问题

#父子应用间通信

UmiqiankuninitGlobalState结合initial-state插件做了封装,只需在入口简单配置,即可使用useModel来跟子应用通信。在Angular子应用中即通过single-spa插件导出更新的生命周期并获取主应用共享的状态。

  1. 主应用配置

修改app.ts

// 给路由绑定的微应用传值
export function useQiankunStateForSlave() {
  const [masterState, setMasterState] = useState<MasterState>({});

  return {
    masterState,
    setMasterState,
  };
}

组件中拿值和传值

import { useModel } from 'umi';

const Layout: React.FC<LayoutProps> = ({ children }) => {
  const { masterState, setMasterState } = useModel('@@qiankunStateForSlave');

  const { data: user } = useRequest(fetchUser);

  useEffect(() => {
    if (user) {
      setMasterState({ user });
    }
  }, [user]);

	// ...
}
  1. 子应用配置

main.ts中导出update生命周期

const { bootstrap, mount, unmount, update } = singleSpaAngular({
  // ...,
  updateFunction: (singleSpaProps: SingleSpaProps) => {
    console.log("update singleSpaProps", singleSpaProps);
    singleSpaPropsSubject.next(singleSpaProps);
    return Promise.resolve();
  },
	// ...
});

组件中获取

export class AppComponent implements OnInit, OnDestroy {
	// ...
	ngOnInit(): void {
		// 监听主应用共享数据更新
    singleSpaPropsSubject.pipe(takeUntil(this.destroy)).subscribe((props) => {
      const { user } = props.masterState ?? {};
      if (user) {
        this.authService.user.next(user);
      }
    });
	}
}

single-spa-props.ts中导出一个方法用于给父组件传值

// 向父组件传值
export const setMasterState = (state: Partial<MasterState>) => {
  singleSpaPropsSubject.pipe(take(1)).subscribe((props) => {
    props.setMasterState({ ...props.masterState, ...state });
  });
};

#样式隔离

qiankun提供了两种样式隔离方案,通过配置sandbox来开启样式隔离。strictStyleIsolationtrue表示开启ShadowDom 的严格样式隔离,这种模式下qiankun会为每个微应用的容器包裹上一个shadow dom节点。experimentalStyleIsolationqiankun提供的一个实验性的样式隔离特性,会给每个子应用的样式多套一层根选择器来达到子应用间样式隔离的效果。

ShadowDom方案需要根据不同框架去做改造和适配才能用起来,所以项目中使用了第二种方案。

#部署配置

在做微前端改造之前,本项目是以Nginx作为服务器,因此只需要根据子应用路由的定义,在Nginx上加一个转发规则,转到子应用的文件目录即可。需要注意的是,子应用的入口配置需要和Nginx上的路径配置保持一致。

qiankun: {
    master: {
      apps: [
        {
          name: 'angular9',
          entry:
            process.env.NODE_ENV === 'development'
              ? 'http://localhost:8001'
              : '/v1/',
        },
      ],
      // ...
    },
  },

#碰到的坑

#antd popover 导致页面崩溃

经过排查是antd的基础组件rc-trigger@5.2.10的问题,在最近发布的新版本中已经修复了改问题,在修复之前是通过patches来修改包的源代码。

diff --git a/node_modules/rc-trigger/es/Popup/PopupInner.js b/node_modules/rc-trigger/es/Popup/PopupInner.js
index aacf97b..8ebea1b 100644
--- a/node_modules/rc-trigger/es/Popup/PopupInner.js
+++ b/node_modules/rc-trigger/es/Popup/PopupInner.js
@@ -82,9 +82,12 @@ var PopupInner = /*#__PURE__*/React.forwardRef(function (props, ref) {
     if (status === 'align') {
       // Repeat until not more align needed
       if (alignedClassName !== nextAlignedClassName) {
-        Promise.resolve().then(function () {
+        // Promise.resolve().then(function () {
+        //   forceAlign();
+        // });
+        setTimeout(() => {
           forceAlign();
-        });
+        }, 0);
       } else {
         goNextStatus(function () {
           var _prepareResolveRef$cu;
diff --git a/node_modules/rc-trigger/lib/Popup/PopupInner.js b/node_modules/rc-trigger/lib/Popup/PopupInner.js
index b603854..7daa11b 100644
--- a/node_modules/rc-trigger/lib/Popup/PopupInner.js
+++ b/node_modules/rc-trigger/lib/Popup/PopupInner.js
@@ -102,9 +102,12 @@ var PopupInner = /*#__PURE__*/React.forwardRef(function (props, ref) {
     if (status === 'align') {
       // Repeat until not more align needed
       if (alignedClassName !== nextAlignedClassName) {
-        Promise.resolve().then(function () {
+        // Promise.resolve().then(function () {
+        //   forceAlign();
+        // });
+        setTimeout(() => {
           forceAlign();
-        });
+        }, 0);
       } else {
         goNextStatus(function () {
           var _prepareResolveRef$cu;

#Angular 应用卸载后重新挂载

这是在Umi下才会触发的问题,直接使用qiankun不会触发,大概看了一下源码,Umi是通过qiankunloadMicroApp来加载子应用的,在执行卸载操作之前,Angular应用已经监听到了路由变化,并且不知道由于什么原因在卸载之后又重新渲染了。这个问题一直找不到根本解决方案,暂时用一个临时解决办法解决了。

Angular应用的App组件中监听Location变化,当路由非本应用基础路径时,调用routerdispose方法手动销毁路由器。

 constructor(
    private location: Location,
    private router: Router
  ) {}

  ngOnInit(): void {
    this.location.subscribe((change) => {
      // Angular应用卸载后路由会响应并使应用重新挂载,跳出子应用前先把router销毁
      if (change.type === "popstate" && !change.url.includes("/v1")) {
        this.router.dispose();
      }
    });
  }

#子应用静态文件访问异常

  1. 确保子应用使用正确的路径引入图片,由于配置了 base-href 为非/,不能使用绝对路径

在html使用

const logo = require("src/assets/dashboard-users.svg").default;

在CSS中使用

	background-image: url('~/assets/img/pro.svg');
  1. 配置启动命令ng serve的参数--deploy-url为当前运行的地址如下,且将ng build--deploy-url配置为服务器子目录名称。
ng serve ... --deploy-url //localhost:8002/

#路由回退异常

原因也是 Angular 应用卸载后重新挂载,解决卸载之后重新挂载的问题即可。

#使用experimentalStyleIsolation设置样式隔离之后,子应用的应用对全局浮层下的元素不起作用

原因是生成的样式选择器全部都是在子应用root元素下生效,子应用的全局浮层默认渲染在body下,因此需要将浮层的默认容器渲染在子应用的root元素下。

angular使用了ng-zorro组件库,是使用angular/cdkoverlay来做浮层管理的,可以这样自定义overlay容器:

import { Inject, Injectable, OnDestroy } from "@angular/core";
import { OverlayContainer } from "@angular/cdk/overlay";
import { Platform } from "@angular/cdk/platform";
import { DOCUMENT } from "@angular/common";

@Injectable()
export class AppOverlayContainer extends OverlayContainer implements OnDestroy {
  constructor(@Inject(DOCUMENT) document: Document, _platform: Platform) {
    super(document, _platform);
  }

  protected _createContainer(): void {
    super._createContainer();
    if (!this._containerElement) {
      return;
    }
    const parent = document.querySelector("app-root") || document.body;
    parent.appendChild(this._containerElement);
  }

  ngOnDestroy(): void {
    super.ngOnDestroy();
    this._containerElement = null;
  }
}

AppModule下使用依赖

@NgModule({
  // ...
  providers: [
    {
      provide: OverlayContainer,
      useClass: AppOverlayContainer,
    },
  ],
})

#Demo 地址

https://github.com/hmilin/umi-qiankun-with-angular

#参考文档

single-spa

qiankun

@umijs/plugin-qiankun