qiankun框架孵化自蚂蚁金融科技基于微前端架构的云产品统一接入平台,是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。目前在github上的star数量已经达到了10.6k,社区也比较活跃,大部分常见的问题在社区中都能够快速找到答案。使用此框架,前端开发者们可以按照不同的业务模式将项目拆分成多个子应用,以达到各项目能够独立开发、独立部署的目的,且不限制应用技术栈,旧项目也能够实现渐进式升级。
背景
众所周知,一个应用(尤其是后台应用)随着需求的不断迭代、以及维护人员的变迁等等,很容易变成一个巨石应用。随之而来的就是各种应用难以维护的问题,以及项目技术架构过老却无法抽出大量的人力进行重构。同时随着各类技术框架的出现,前端环境也变得越来越复杂,项目越来越重。在这种情况下,就需要一种技术能够实现对巨石应用的拆分,并能够灵活地支持各种框架的扩展 。
微前端架构的出现就是为了解决此类问题。
微前端简介
1 | 什么是微前端 |
微前端的概念在2016年由ThoughtWorks首次提出。它不是一种新型的技术,而是一种类似于微服务的架构模式,是将微服务的理念应用到了浏览器端。即:将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。
在ThoughtWorks中提出微前端概念[3]的内容主要表述的是:为避免创建大型的单体应用,于是将类似微服务架构的微前端引入到项目中。我们可以对应用程序按其页面和功能进行分解,每个应用都由一个团队拥有,目标是都支持独立开发、测试、部署。有多种技术可以将应用程序功能(有些是旧的,有些是新的)结合起来,对用户来说仍是一个应用。团队也可以使用微服务架构来支持对应的应用程序。
在比较早期的时候,前后端是不分离的模式,页面的渲染和重定向都由后端来控制;接着发展到先后出现了前后端半分离、前后端分离的模式;接着到了2014年,后端出现了微服务架构;再到近几年前端也出现了类似于微服务的微前端架构。整个发展过程显示出我们要承接的需求越来越复杂,技术多样化,项目也越来越重,如图1.1所示。
图1.1 前后端架构模式发展过程
现在我们已经了解了微前端的背景与概念,那么它都能够提供什么能力,有什么特点?
微前端的特点主要有以下几点[1]:
(1)技术栈无关。
(2)主框架不限制接入应用的技术栈,子应用具备完全自主权。
(3)独立开发、独立部署。
(4)子应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新。
(5)独立运行时。
(6)每个子应用之间状态隔离,运行时状态不共享。
2 | 常见的微前端框架 |
目前主要存在的微前端框架有以下几种:
(1)sing-spa:是社区公认的主流方案,解决了以应用为维度的路由,应用的注册,监听,最重要的是赋予了应用生命周期和生命周期相关事件。
(2)qiankun:基于 single-spa 封装,增加 umi 特色,增加沙箱机制(JS、ShadowDOM、HTML Loader、预加载等微前端系统所需的能力。升级 2.0 后,支持多个微应用的同时加载)。
(3)Icestark:类似于 single-spa 实现,React 技术栈友好。
(4)Mooa:基于 single-spa,是一个为 Angular 服务的微前端框架,针对 IE 10 及 iframe优化的微前端解决方案。
可以看到,几乎所有的微前端框架都是基于single-spa实现的。那么single-spa都实现了什么功能?为什么各个微前端框架都要基于此框架来进行二次封装?具体生命周期如图1.2所示。
图1.2 single-spa生命周期
single-spa主要实现了一整套生命周期[2],包括bootstrap、mount、unmount、unload。它的执行机制是,当window.location.href 匹配到子应用对应url 时,激活对应的子应用,将其状态改变为active状态,并执行这一套生命周期流程。
single-spa的执行原理:
registerApplication注册微前端服务的函数,在此函数中首先执行sanitizeArguments方法,作用是校验传入的配置参数是否符合规范,并将处理后的参数赋值到registration对象中然后return出来。接着校验是否有重名的应用,通过校验后将应用push到apps数组中,执行reroute函数。reroute函数是一个核心的部分,它主要的作用是控制各应用的状态,以及执行对应的生命周期函数。
在reroute函数中会if判断是否执行了start方法,对start和registerApplication的调用进行区分。其它的几个主要方法是:
(1)在getAppChanges方法中按(需要被移除、卸载、加载、挂载)状态将应用分为四类,执行机制中非常重要的一环根据location.href进行路由匹配判断应用是否需要被激活也在这里实现;
(2)loadApps方法作用是对需要被激活的子应用进行遍历,并调用toLoadPromise方法,用户自定义的加载函数(子应用代码的加载)就在toLoadPromise方法中加载,同时获取到子应用的生命周期函数挂载到app子应用的属性上但不执行。
它在start函数中调用reroute时,根据路由匹配判断是否要渲染该子应用,确认之后会执行上述挂载好的生命周期函数。
除了上述的生命周期及路由匹配激活子应用等主要功能之外,微前端需要实现的目标能力在single-spa中都没有实现。所以各框架要在此基础上进行二次封装,来实现微前端框架完整的能力。下面我们主要介绍一下微前端qiankun框架。
qiankun框架介绍
1 | qiankun框架在项目中的应用实例 |
qiankun框架只需要在主应用中进行安装,子应用暴露出上面在single-spa中提到的四个生命周期钩子函数即可。
下面我们来看一下在实际项目中如何使用qiankun框架,具体需要在主子应用中做怎样的配置。
(1)主应用中的配置
// 在主应用中安装qiankun
$ yarn add qiankun # 或者 npm i qiankun -S
代码如下:
// 主应用 main.js
import Vue from 'vue'
import App from './App.vue'
import {
registerMicroApps,
start,
setDefaultMountApp,
runAfterFirstMounted
} from 'qiankun';
new Vue({
render: h => h(App),
}).$mount('#main-app')
const apps = [
{
name: 'vueApp', // 应用的名字
entry: '//localhost:8085', // 默认会加载这个html 解析里面的js 动态的执行 (子应用必须支持跨域)fetch
container: '#vue', // 容器名(此项目页面中定义的容器id,用于把对应的子应用放到此容器中)
activeRule: '/vue', // 激活的路径
props: { a: 1 } // 传递的值(可选)
},
{
name: 'reactApp',
entry: '//localhost:3000', // 默认会加载这个html 解析里面的js 动态的执行 (子应用必须支持跨域)fetch
container: '#react',
activeRule: '/react'
}
]
// 注册微应用,并且在微应用激活时,创建运行沙箱,在不同阶段调用不同的生命周期钩子函数。
registerMicroApps(apps);
// 初始化一些全局设置,然后启动主应用
// 这些初始化的配置参数有一部分将在 registerMicroApps 注册子应用的回调函数中使用在符合 activeRule 激活规则时将会激活子应用,执行回调函数,返回一些生命周期钩子函数
start({
prefetch: false, // 取消预加载
strictStyleIsolation: true
});
// 设置主应用启动后默认进入的微应用
setDefaultMountApp('/vue');
// 第一个微应用 mount 后需要调用的方法,比如开启一些监控或者埋点脚本
runAfterFirstMounted(() => {
console.log(999);
});
// index.html
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<!-- 此处的id需要与main.js中的一致 -->
<div id="main-app"></div>
</body>
</html>
// App.vue
<template>
<!-- id需要与上面main.js中一致 -->
<div id="main-app">
</div>
</template>
<script>
export default {
name: 'App',
mounted() {
if (!window.qiankunStarted) {
window.qiankunStarted = true;
start();
}
}
}
</script>
(2)手动加载子应用
如果在某些时机(例如点击某个按钮时)需要加载子应用,就需要使用 loadMicroApp Api对微应用进行手动加载。
使用方法如下:
// 业务组件文件中
import { loadMicroApp } from 'qiankun'
loadMicroApp(
{
name: 'vueApp',
entry: '//localhost:8085',
container: '#vue',
activeRule: '/vue'
}
)
// 配合手动加载子应用还有prefetchApps Api
// 手动预加载指定的微应用静态资源。仅手动加载微应用场景需要,基于路由自动激活场景直接配置 prefetch 属性即可。
// prefetchApps(apps, importEntryOpts?)
(3)子应用中的配置
// router.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/home',
name: 'Home',
component: Home
}
]
const router = new VueRouter({
mode: 'history',
base: '/vue',
routes
})
export default router
// public-path.js
/* eslint-disable */
if(window.__POWERED_BY_QIANKUN__){
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}
// main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import './public-path'
let instance = null
function render(props) {
console.log(props);
instance = new Vue({
router,
render: h => h(App)
}).$mount('#vueApp'); // 这里是挂载到自己的html中 基座会拿到这个挂载后的html 将其插入进去
}
if (!window.__POWERED_BY_QIANKUN__) { // 默认独立运行
render();
}
// 父应用加载子应用,子应用必须暴露三个接口:bootstrap、mount、unmount
// 子组件的协议就ok了
export async function bootstrap(props) { // 初始化子应用
console.log('bootstrap', props);
}
export async function mount(props) { // 挂载子应用
render(props)
}
export async function unmount(props) { // 卸载子应用
console.log('unmount', props);
instance.$destroy();
}
// App.js
<template>
<!-- 此处的id与main.js中的一致 -->
<div id="vueApp">
<Home />
</div>
</template>
<script>
import Home from './views/Home.vue'
export default {
name: 'App',
components: {
Home
}
}
</script>
// vue.config.js
module.exports = {
// 关闭主机检查,使微应用可以被fetch请求到
disableHostCheck: true,
devServer: {
port: 8085,//这里的端口是必须和父应用配置的子应用端口一致
headers: {
//因为qiankun内部请求都是fetch来请求资源,所以子应用必须允许跨域
'Access-Control-Allow-Origin': '*'
}
},
configureWebpack: {
output: {
//资源打包路径
library: 'vueApp',
libraryTarget: 'umd'
}
}
}
以上就是qiankun框架在项目中的具体使用配置,下面我们来了解一下qiankun框架在实现过程中遇到的问题及解决方案,以及目前qiankun框架在使用中存在哪些问题。
qiankun框架实现原理
qiankun的编写是基于single-spa和import-html-entry两个库,single-spa主要解决的是子应用的调度问题,import-html-entry则是提供window.fetch方案去加载子应用的代码。
1 | 框架实现遇到的问题及解决方案 |
(1)Future State
Future State的交互流程,如图1.3所示。
图1.3 Future State出现的过程
在我们日常使用系统的过程中,经常会存在查看、编辑这样的交互。假设我们从主应用页面进行一系列点击操作后,跳转到了子应用的一个详情页://app.xesv5.com/bApp/12 中,刷新浏览器页面后,主子应用的资源都会被重新加载。如果主应用资源加载完成, 但子应用未完全加载完,就会因为路由不匹配导致页面404或出现路由报错的情况。
解决方案是当页面路由地址与我们配置的url匹配时,首先加载entry中的资源,等资源加载完成后再由子应用的路由系统接管 url change 事件。
app: async () => {
...
const { mount, ...otherMicroAppConfigs } = (
// 调用loadApp
await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
)();
...
},
// 子应用的激活规则
activeWhen: activeRule,
const { entry, name: appName } = app;
// get the entry html content and script executor
// 使用 import-html-entry 库从 entry 进入加载子应用
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
// 当子应用路由切出时,主框架也需要触发相应的 destroy 事件,子应用在监听到该事件时,调用自己的卸载方法卸载应用
(2)Bundle集成
在微前端架构中主应用的主要作用是加载渲染公共资源,集成各子应用,并对整体项目的渲染、卸载等内容与时机进行统一的管理和把控,主要起到了一个枢纽的作用。
根据我们所期望的微前端架构应有的特点:独立打包、独立部署、独立运行,在qiankun框架中采用的是运行时集成中的前端路由的方式。
运行时集成的优点是:主子应用完全解耦,所以子应用能够独立构建打包发布,能够满足微前端框架的独立打包、独立部署的特点。主应用在运行时动态加载子应用资源即可。
前端路由方式是每个子应用暴露出渲染函数,主应用在启动时加载各个子应用的独立 Bundle,之后根据路由规则渲染相应的子应用。
(3)子应用资源加载
这里涉及到对子应用代码的加载,在single-spa中这一部分是开放的,它将加载函数的代码留给开发者自己来写。
在qiankun框架中使用HTML Entry的方式,通过window.fetch请求子应用的文件资源。
// 使用single-spa的registerApplication Api 注册微前端服务
registerApplication({
name, // 子应用的名称
app: async () => { // 子应用代码的加载函数(activeRule 激活时调用)
loader(true);
await frameworkStartedDefer.promise;
const { mount, ...otherMicroAppConfigs } = (
// 调用loadApp
await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
)();
return {
mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
...otherMicroAppConfigs,
};
},
// 配置微前端模块
activeWhen: activeRule, // 子应用的激活规则
customProps: props, // 主应用需要传递给子应用的数据
});
// 执行所有的script中的代码,并返回为html模板入口脚本链接entry指向的模块导出对象。
execScripts(null, [src], proxy, {
fetch,
strictGlobal,
beforeExec: () => {
const isCurrentScriptConfigurable = () => {
const descriptor = Object.getOwnPropertyDescriptor(document, 'currentScript');
return !descriptor || descriptor.configurable;
};
if (isCurrentScriptConfigurable()) {
Object.defineProperty(document, 'currentScript', {
get(): any {
return element;
},
configurable: true,
});
}
},
success: () => {
manualInvokeElementOnLoad(element);
element = null;
}
});
function manualInvokeElementOnLoad(element: HTMLLinkElement | HTMLScriptElement) {
// 动态脚本加载
const loadEvent = new CustomEvent('load');
const patchedEvent = patchCustomEvent(loadEvent, () => element);
if (isFunction(element.onload)) {
element.onload(patchedEvent);
} else {
element.dispatchEvent(patchedEvent);
}
}
HTML Entry的方式是直接将子应用打出来 HTML 作为入口,同时将document 作为子节点塞到主框架的容器中。这样不仅可以极大的减少主应用的接入成本,子应用的开发及打包方式基本上也不需要调整,同时可以天然的解决子应用之间样式隔离的问题。
(4)子应用之间的影响隔离
在qiankun框架中,通过了一个沙箱环境对主应用和子应用之间的环境进行隔离,来避免全局污染。
在qiankun中有两种样式隔离方式:分别为ShadowDOM 和 Scoped CSS。
// ShadowDOM
if (strictStyleIsolation) {
if (!supportShadowDOM) { // 不支持ShadowDOM
console.warn(
'[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
);
} else {
const { innerHTML } = appElement;
// 将内容清空
appElement.innerHTML = '';
let shadow: ShadowRoot;
// 添加ShadowDOM节点
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// createShadowRoot was proposed in initial spec, which has then been deprecated
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;
}
}
// Scoped CSS
// 将子应用的name动态设置为父类选择器
if (scopedCSS) {
const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
if (!attr) {
appElement.setAttribute(css.QiankunCSSRewriteAttr, appName);
}
const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appName);
});
}
除了样式隔离外,还有js隔离的问题。我们需要考虑如何使各个子应用之间的全局变量不会互相干扰。
qiankun框架中设计了js沙箱来解决这个问题:
沙箱的类型分为两种,分别是app环境沙箱和render沙箱。app环境沙箱指应用初始化过之后,会在什么样的上下文环境运行。每个应用的环境沙箱只会初始化一次,因为子应用只会触发一次 bootstrap。render沙箱是子应用在 app mount 开始前生成好的沙箱。每次子应用切换过后,render 沙箱都会重现初始化。这么设计的目的是为了保证每个子应用切换回来之后,还能运行在应用 bootstrap 之后的环境下。
整体的流程就是在子应用 bootstrap和mount前记录当前快照,在应用卸载的时候,恢复 global 状态,使其能回到应用加载之前的状态。当应用再次进入时沙箱被mount,此时将global状态再恢复到mount前,从而确保应用在remount时拥有跟第一次mount时一致的全局上下文,如图1.4所示。
图1.4 js沙箱图示
qiankun提供了三种沙箱方案,分别适用于不同的场景。
(1)SnapShotSandbox:用于不支持 Proxy 的低版本浏览器(单例模式)。
(2)SingularProxySandbox:基于 Proxy 实现的沙箱(单例模式)。
(3)ProxySandbox:适用于多个子应用的情况。
/**
* 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
*/
export default class SnapshotSandbox implements SandBox {
proxy: WindowProxy;
name: string;
type: SandBoxType;
sandboxRunning = true;
private windowSnapshot!: Window;
private modifyPropsMap: Record<any, any> = {};
constructor(name: string) {
this.name = name;
this.proxy = window;
this.type = SandBoxType.Snapshot;
}
active() {
// 记录当前快照
this.windowSnapshot = {} as Window;
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});
// 恢复之前的变更
Object.keys(this.modifyPropsMap).forEach((p: any) => {
window[p] = this.modifyPropsMap[p];
});
this.sandboxRunning = true;
}
inactive() {
this.modifyPropsMap = {};
iter(window, (prop) => {
if (window[prop] !== this.windowSnapshot[prop]) {
// 记录变更,恢复环境
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop];
}
});
this.sandboxRunning = false;
}
}
/**
* 基于 Proxy 实现的沙箱
* TODO: 为了兼容性 singular 模式下依旧使用该沙箱,等新沙箱稳定之后再切换
*/
export default class SingularProxySandbox implements SandBox {
/** 沙箱期间新增的全局变量 */
private addedPropsMapInSandbox = new Map<PropertyKey, any>();
/** 沙箱期间更新的全局变量 */
private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();
/** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */
private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();
...
active() {
// 恢复原值
if (!this.sandboxRunning) {
this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
}
this.sandboxRunning = true;
}
inactive() {
// renderSandboxSnapshot = snapshot(currentUpdatedPropsValueMapForSnapshot);
// restore global props to initial snapshot
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));
this.sandboxRunning = false;
}
constructor(name: string) {
this.name = name;
this.type = SandBoxType.LegacyProxy;
const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this;
const rawWindow = window;
const fakeWindow = Object.create(null) as Window;
const setTrap = (p: PropertyKey, value: any, originalValue: any, sync2Window = true) => {
if (this.sandboxRunning) {
if (!rawWindow.hasOwnProperty(p)) {
// 记录沙箱期间新加的值
addedPropsMapInSandbox.set(p, value);
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
// 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
// 记录沙箱后的值
currentUpdatedPropsValueMap.set(p, value);
if (sync2Window) {
// 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
(rawWindow as any)[p] = value;
}
this.latestSetProp = p;
return true;
}
// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
return true;
};
const proxy = new Proxy(fakeWindow, {
set: (_: Window, p: PropertyKey, value: any): boolean => {
const originalValue = (rawWindow as any)[p];
return setTrap(p, value, originalValue, true);
},
get(_: Window, p: PropertyKey): any {
// avoid who using window.window or window.self to escape the sandbox environment to touch the really window
// or use window.top to check if an iframe context
if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
return proxy;
}
const value = (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
}
});
this.proxy = proxy;
}
}
/**
* 基于 Proxy 实现的沙箱
*/
export default class ProxySandbox implements SandBox {
/** window 值变更记录 */
private updatedValueSet = new Set<PropertyKey>();
...
active() {
if (!this.sandboxRunning) activeSandboxCount++;
this.sandboxRunning = true;
}
inactive() {
if (--activeSandboxCount === 0) {
variableWhiteList.forEach((p) => {
if (this.proxy.hasOwnProperty(p)) {
// 删除白名单中子应用添加的值
delete window[p];
}
});
}
this.sandboxRunning = false;
}
constructor(name: string) {
this.name = name;
this.type = SandBoxType.Proxy;
const { updatedValueSet } = this;
const rawWindow = window;
const { fakeWindow, propertiesWithGetter } = createFakeWindow(rawWindow);
const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>();
const hasOwnProperty = (key: PropertyKey) => fakeWindow.hasOwnProperty(key) || rawWindow.hasOwnProperty(key);
const proxy = new Proxy(fakeWindow, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
// We must kept its description while the property existed in rawWindow before
if (!target.hasOwnProperty(p) && rawWindow.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
const { writable, configurable, enumerable } = descriptor!;
if (writable) {
Object.defineProperty(target, p, {
configurable,
enumerable,
writable,
value,
});
}
} else {
// @ts-ignore
target[p] = value;
}
if (variableWhiteList.indexOf(p) !== -1) {
// @ts-ignore
rawWindow[p] = value;
}
// 更新修改过的值
updatedValueSet.add(p);
this.latestSetProp = p;
return true;
}
// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
return true;
},
get(target: FakeWindow, p: PropertyKey): any {
// eslint-disable-next-line no-nested-ternary
const value = propertiesWithGetter.has(p)
? (rawWindow as any)[p]
: p in target
? (target as any)[p]
: (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
},
});
this.proxy = proxy;
activeSandboxCount++;
}
}
2 | 代码层面存在的问题 |
qiankun框架在使用中也存在一些问题,此处列一些我们大概率会遇到的问题,希望大家在真正使用前思考一下如何设计代码实现,能够避开或解决这些问题:
(1)子应用登出或直接对localStorage等缓存内容做操作时,会把主应用的也去掉;
(2)在两个应用中,localStorage使用了相同的key;
(3)主子应用样式串扰问题;
(4)主应用与子应用同域名端口会造成死循环;
(5)主应用和子应用使用不同版本的vue后路由切换报错。
对于问题3,举例来讲,如果在子应用中设置了position: fixed,会出现子应用在主应用上层浮动的现象。但由于父子应用属于同一个document,所以这种样式问题没有办法解决。
除了存在于代码层面的问题之外,在日常的开发测试等方面也存在一些需要注意的点。
3 | 使用时需注意的问题 |
(1)公共资源的提取和复用
在项目开发中我们会引入第三方库,并且会封装多个业务组件和业务模板。引入微前端架构后,将一个整体的应用拆分成多个独立的子应用,也就意味着这些资源需要跨团队、跨人员来进行沟通维护。在不同的微应用中,可能最开始统一使用的组件,随着需求的迭代慢慢出现差异化,很容易出现组件频繁变更、不断兼容的问题。
在组件更新方面比较推荐的模式是谁用谁改,团队成员都可以对组件进行修改,同时需要有专人进行把控,要做好codeReview并进行全面自测,降低出现问题的概率和风险。
(2)如何测试
常规的软件测试包括单元测试、集成测试、系统测试、验收测试、回归测试。对于应用了微前端架构这样的复杂应用来说,集成测试更显得尤为重要。这一步同时也需要多个项目的前后端成员共同协作配合,来支持整个测试流程,如图1.5所示。
图1.5 测试的阶段
应用qiankun框架带来的变化
1 | 效率 |
应用了qiankun框架之后,在维护效率上会有较大的提升,最明显的就是能够支持项目渐进式升级,能够做部分代码的重构。而在开发效率上一般来说变化不大,因为日常维护工作还是在子应用或主应用内部去做的,跨应用的操作一般都主要体现在登陆逻辑上,其它情况很少会有跨应用的操作,并且官方也不建议频繁地跨子应用通信。
2 | 复杂度 |
引入微前端架构可以使单个微应用变得更简洁、体量更小。但对项目整体来讲,这是一个拆开再合并的过程,这样一来前端环境就会变得更加复杂,操作复杂度也不断升高。
例如对于一个公共功能,以前只需要解决在一个项目环境下的问题即可,现在由于各微应用内部使用技术方案的不同,需要进行多次开发和问题解决,也就是说从需求的开发、跟测到上线,整体流程各环节的复杂度都会随之上升。
3 | 缺点 |
(1)框架在使用中存在的很多问题是需要人为规避的,这样会使不确定性增加,出现问题的概率变大。
(2)前端环境的复杂度升高,开发过程中需要解决的问题也会随之增多,某些问题定位的难度也会变大。
(3)需要更清晰的团队职责划分,涉及到多应用的交互操作时,沟通和维护成本也会变高。
总结来讲,在项目中应用微前端框架,不论是在人力还是技术上都有一定成本存在,所以我们要根据目标需求和现有资源,去衡量是否要采用这一技术方案。
总结
微前端架构产生和发展的主要原因是前端各种技术框架层出不穷,项目环境日益复杂,项目长时间迭代成为巨石应用导致难以维护和升级;而它最核心的地方在于对巨石应用项目的拆分,使拆分出的项目能够完全独立部署运行,并且子应用能够支持多技术并存,能够实现项目的渐进式升级,平滑的进行架构的升级过渡。这样就很好的解决了我们目前存在的棘手问题。
但是微前端架构的引入也会使整个项目环境变得更加复杂,同时我们需要考虑的问题和遇到的坑也会变多。所以是否使用微前端架构,引入微前端后项目应该如何设计才能规避掉一些已知问题,以及项目需求的开发、部署、测试、上线方案和维护人员的分配,都是需要我们根据实际情况来做权衡考量的。
参考资料
[1]可能是你见过最完善的微前端解决方案 (https://zhuanlan.zhihu.com/p/78362028)
[2] 【微前端】single-spa 到底是个什么鬼 (https://zhuanlan.zhihu.com/p/378346507)
[3] Micro frontends(https://www.thoughtworks.com/radar/techniques/micro-frontends)
往期回顾 | |
扫码关注我们
致力于互联网教育技术的创新和推广
@学而思网校技术团队
分享、点赞与在看,至少我要拥有一个吧