qiankun 源码学习
什么是乾坤
乾为天,坤为地,乾坤代表天地。
不得不说这个名字:
qiankun 是基于 single-spa 的微前端解决方案。
什么是微前端?什么是 single-spa?
前端喜欢不断的造轮子这件事儿是毋庸置疑的,但轮子都有被造出来的原因,比如 single-spa,他是最早的微前端框架。
微前端产生的原因很多:拆分巨石应用/集成其他应用(可能技术栈不同)/各前端模块独立开发部署......
而 single-spa 是第一个微前端框架,它的特性:
-
独立部署每一个单页面应用
-
新功能使用新框架,旧的单页应用不用重写可以共存
-
改善初始加载时间,延迟加载代码
为什么标题不是 single-spa 呢
显然我们并没有采用 signle-spa 来做微前端技术选型,为什么呢?将前端大型项目比作做饭,那 single-spa 只提供了火力旺盛的灶台,你需要找到适合自己的刀具、锅、锅铲等工具,然后才能开始做饭,所以你想靠它从零开始,可能要耗费不少精力。
single-spa 本身到底做了什么?
-
注册
import { registerApplication, start } from 'single-spa';
// Config with more expressive API
registerApplication({
name: 'app1',
app: () => import('src/app1/main.js'),
activeWhen: '/app1',
customProps: {
some: 'value',
}
);
// 记住这里
start();
在主应用中,你要配置什么时候加载对应子应用,是通过路由 activeWhen来决定的,子应用的入口文件则是 app,也可以给子应用传递自定义参数 customProps。之后执行 start 开始执行 mount 逻辑。
-
子应用
export async function bootstrap(props) {...}
export async function mount(props) {...}
export async function unmount(props) {...}
对于子应用,需要在入口文件实现以上三个钩子函数,当 activeWhen 匹配该子应用的路由时,则会执行一次 bootstrap钩子,之后不会再执行,然后会执行 mount进行 DOM 挂载,当路由切换时,执行 unmount将该应用从 body中移除。
基本上 single-spa 就这了这些,你可能会问,那它怎么实现微前端间样式和全局变量隔离?怎么加载不同框架的代码?如何单独部署呢?如何更新微前端资源?这些都需要你自己实现,或者找工具,或者造轮子。(最开始的 single-spa 是这样,后来社区上出现了很多主流框架的微前端实现,像 single-spa-react/single-spa-vue/single-spa-angular等,但也不是实现了所有特性)
qiankun 的特点
qiankun 基本帮你实现了以上 single-spa 未实现的点
深入乾坤
qiankun 的重点在主应用,也就是 主loader ,子应用只要满足 single-spa 规定的几个钩子方法就可以被 qiankun 正常加载,所以 single-spa 支持的应用,它都支持(废话!)。
qiankun 是这样使用的:
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
{
name: 'reactApp',
entry: '//localhost:3000',
container: '#container',
activeRule: '/app-react',
},
{
name: 'vueApp',
entry: '//localhost:8080',
container: '#container',
activeRule: '/app-vue',
},
{
name: 'angularApp',
entry: '//localhost:4200',
container: '#container',
activeRule: '/app-angular',
},
]);
// 启动 qiankun
start();
这里和 single-spa 最大的区别就是 entry 返回 html 文件。
registerMicroApps 注册
import { registerApplication } from 'single-spa';
export function registerMicroApps<T extends ObjectType>(
apps: Array<RegistrableApp<T>>,
lifeCycles?: FrameworkLifeCycles<T>,
) {
// Each app only needs to be registered once
const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));
microApps = [...microApps, ...unregisteredApps];
unregisteredApps.forEach((app) => {
const { name, activeRule, loader = noop, props, ...appConfig } = app;
registerApplication({
name,
app: async () => {
loader(true);
await frameworkStartedDefer.promise;
const { mount, ...otherMicroAppConfigs } = (
await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
)();
return {
mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
...otherMicroAppConfigs,
};
},
activeWhen: activeRule,
customProps: props,
});
});
}
以上其实就是用 single-spa 的 registerApplication 来注册各个微前端。
如果有个微前端在注册之后直接被激活了,则会执行对应 app内部逻辑,
app 函数 返回 Promise,走到 await frameworkStartedDefer.promise 时会 pending住,不会再走下面的逻辑,为什么会这样,下文告诉你。
start 初始化
注册完成之后用户就执行 start()进入主逻辑了
import {start as startSingleSpa } from 'single-spa';
export function start(opts: FrameworkConfiguration = {}) {
frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
const {
prefetch,
sandbox,
singular,
urlRerouteOnly = defaultUrlRerouteOnly,
...importEntryOpts
} = frameworkConfiguration;
// 预加载
if (prefetch) {
doPrefetchStrategy(microApps, prefetch, importEntryOpts);
}
// 兼容性提示,当下你可以忽略它
if (sandbox) {
if (!window.Proxy) {
console.warn('[qiankun] Miss window.Proxy, proxySandbox will degenerate into snapshotSandbox');
frameworkConfiguration.sandbox = typeof sandbox === 'object' ? { ...sandbox, loose: true } : { loose: true };
if (!singular) {
console.warn(
'[qiankun] Setting singular as false may cause unexpected behavior while your browser not support window.Proxy',
);
}
}
}
// 调用qiankun的 start
startSingleSpa({ urlRerouteOnly });
started = true;
frameworkStartedDefer.resolve();
}
start 时,先是做了预加载逻辑 doPrefetchStrategy,之后直接调用 single-spa的 start来初始化。之后将 frameworkStartedDefer放开,这样注册阶段的 loadApp逻辑就可以往下走了。
这里有两个疑问点:
-
为什么 single-spa 不在注册之后直接 start ?而是暴露 start api 让用户手动执行?
-
为什么 qiankun 要设置 frameworkStartedDefer 卡点, start 完成后在 load 子应用?
其实这两个问题是同一个问题,当某个微前端一开始就被激活时:
对于 single-spa 来说,要经历这个步骤:
注册 -> 激活对应微前端 -> load 资源 -> 初始化(start) -> 挂载
只有 start 时,才可以挂载微前端到主应用上,这样做可以让用户对挂载的操作更有把控力,比如 ajax 请求用户权限后,再决定是否挂载微前端。
对于qiankun来说,要经历这个步骤:
注册 -> 激活对应微前端 -> 初始化(start) -> load 资源 -> 挂载
可以理解为,如果没执行 start ,那么连 load 资源都没必要了。也就是保证微前端在 start 之后再进行加载,比single-spa 更激进。
但这里我存疑,如果qiankun 这样做的原因和 single-spa一样,是为了避免没必要的加载?那么当该子应用真正需要被挂载的情况下,load 资源的时机不就滞后了吗?
预加载 和 直接加载
我们可以通过配置 start的 prefetch参数开启预加载:
export function doPrefetchStrategy(
apps: AppMetadata[],
prefetchStrategy: PrefetchStrategy,
importEntryOpts?: ImportEntryOpts,
) {
const appsName2Apps = (names: string[]): AppMetadata[] => apps.filter((app) => names.includes(app.name));
if (Array.isArray(prefetchStrategy)) {
prefetchAfterFirstMounted(appsName2Apps(prefetchStrategy as string[]), importEntryOpts);
} else if (isFunction(prefetchStrategy)) {
(async () => {
// critical rendering apps would be prefetch as earlier as possible
const { criticalAppNames = [], minorAppsName = [] } = await prefetchStrategy(apps);
prefetchImmediately(appsName2Apps(criticalAppNames), importEntryOpts);
prefetchAfterFirstMounted(appsName2Apps(minorAppsName), importEntryOpts);
})();
} else {
switch (prefetchStrategy) {
case true:
prefetchAfterFirstMounted(apps, importEntryOpts);
break;
case 'all':
prefetchImmediately(apps, importEntryOpts);
break;
default:
break;
}
}
}
这里的逻辑比较简单,主要是对参数类型的判断, 从而做不同的处理,处理分为两个函数:prefetchAfterFirstMounted(首屏之后) 和 prefetchImmediately(直接加载)。
function prefetchAfterFirstMounted(apps: AppMetadata[], opts?: ImportEntryOpts): void {
window.addEventListener('single-spa:first-mount', function listener() {
// getAppStatus single-spa 内部方法
const notLoadedApps = apps.filter((app) => getAppStatus(app.name) === NOT_LOADED);
if (process.env.NODE_ENV === 'development') {
const mountedApps = getMountedApps();
console.log(`[qiankun] prefetch starting after ${mountedApps} mounted...`, notLoadedApps);
}
notLoadedApps.forEach(({ entry }) => prefetch(entry, opts));
window.removeEventListener('single-spa:first-mount', listener);
});
}
export function prefetchImmediately(apps: AppMetadata[], opts?: ImportEntryOpts): void {
if (process.env.NODE_ENV === 'development') {
console.log('[qiankun] prefetch starting for apps...', apps);
}
apps.forEach(({ entry }) => prefetch(entry, opts));
}
prefetchAfterFirstMounted 会监听 single-spa:first-mount的自定义事件,然后遍历 prefetch传入微前端列表中未加载的应用。
prefetchImmediately 则直接遍历 直接预加载,你认为首屏需要展示的应用就可以放到这里。
const isSlowNetwork = navigator.connection
? navigator.connection.saveData ||
(navigator.connection.type !== 'wifi' &&
navigator.connection.type !== 'ethernet' &&
/(2|3)g/.test(navigator.connection.effectiveType))
: false;
/**
* prefetch assets, do nothing while in mobile network
* @param entry
* @param opts
*/
function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
// 离线和网络慢的情况下不做预加载
if (!navigator.onLine || isSlowNetwork) {
// Don't prefetch if in a slow network or offline
return;
}
requestIdleCallback(async () => {
const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});
}
这里prefetch内部用了 importEntry 来做加载,我们先不进入这个库,会在下文讲它,你只要记住 getExternalScripts加载脚本,getExternalStyleSheets加载样式就行。
让我们先来看看几个有意思的点。
1,什么时候不做预加载?
可以看到离线的状态或者 isSlowNetwork状态,主要是利用 navigator 来做一些弱网判断,这几个值分别是:
也就是说,弱网环境不会做预加载操作,这也给了我们一些思路,比如在项目开发的时候,判断网络环境去加载对应资源:在弱网环境下可以牺牲图片的分辨率换取加载速度的提升。
2,requestIdleCallback 是啥?
requestAnimationFrame我们可能听说过,requestIdleCallback又是啥?
其实他两个是同一个维度上的东西,前者是在浏览器当前帧绘制之前调用,后者是在绘制之后的空闲时间调用。前者经常被我们用来做面试题 ^v^:如何实现流畅的动画?而后者就可以用来做优先级不高的事件避免其影响用户体验,比如这里的预加载,比如我们的打点上报时机。
加载模版之 import-html-entry
qiankun做了最重要的两件大事儿,其中一件就是支持配置模版来加载微前端资源,import-html-entry这个库就是干这件事儿的。
大多数 SPA 应用都会将资源打包成一个 html 模版和若干资源,当这些应用的开发者有天想升级为微前端架构时,直接将微前端入口配置为模版(而不是去配置入口js和各种样式文件)的成本是很低的,这点我们在项目中应该也深有体会。所以呀,当我们做产品的时候得帮用户“偷懒”,帮用户多省点事儿,用户就会爱上我们的产品~
我们不去深究 import-html-entry 库,只需要知道其功能就行。
import { importEntry } from 'import-html-entry';
async function getTemplate() {
const {
execScripts,
template,
assetPublicPath,
getExternalScripts,
getExternalStyleSheets
} =
await importEntry('http://127.0.0.1:5500/src/pages/home/index.html');
console.log(template);
console.log(assetPublicPath);
const styles = await getExternalStyleSheets();
console.log(styles);
const script = await getExternalScripts();
console.log(script);
}
importEntry接受一个模版链接,返回值有以下:
-
template 将模版中的 css 和 js 资源标签注释,并且将 css 的内容插入到模版中
-
assetPublicPath 资源公共路径
-
getExternalStyleSheets 返回数组,包括所有css内容
-
getExternalScripts 发起js 脚本请求,返回数组,包括所有 js 代码字符串
-
execScripts(proxy, strictGlobal) 发起 js 脚本请求,接收一个 proxy 对象,返回入口脚本中 proxy 或者 window 的最后一个非 property 属性
其他方法都好说,这最后一个方法的功能有点奇怪,我举个例子:
假设模版的入口脚本内容是:
((global) => {
global['purehtml'] = {
bootstrap: () => {
console.log('purehtml bootstrap');
return Promise.resolve();
},
mount: () => {
console.log('purehtml mount');
return render($);
},
unmount: () => {
console.log('purehtml unmount');
return Promise.resolve();
},
};
})(window);
那执行 execScripts 之后会返回 purehtml 对象:
async function getTemplate() {
const { execScripts } =
await importEntry('http://url/index.html');
const runScript = await execScripts();
console.log(runScript);
}
getTemplate();
这下你应该能猜到它的主要作用了吧,这里我们就不深入了,先记住这几个 API,后面再见到它们就更亲切了。
loadApp 之沙箱
终于到了核心的加载逻辑了,qiankun 干的另一件大事儿就是实现了沙箱,也会在这里展开:假设初始化路由匹配到 /app1,用户执行了 start,接下来就到了加载 /app1 的微前端资源了。
模版渲染和样式隔离
export async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
const { entry, name: appName } = app;
const appInstanceId = `${appName}_${+new Date()}_${Math.floor(Math.random() * 1000)}`;
const markName = `[qiankun] App ${appInstanceId} Loading`;
if (process.env.NODE_ENV === 'development') {
performanceMark(markName);
}
const { singular = false, sandbox = true, excludeAssetFilter, ...importEntryOpts } = configuration;
// 上文我们认识的几个方法
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
// 用 div 包装 html 内容,并赋予唯一id
const appContent = getDefaultTplWrapper(appInstanceId, appName)(template);
const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;
const scopedCSS = isEnableScopedCSS(sandbox);
// 再创建一个div,包装 html,主要是对样式隔离的处理,strictStyleIsolation是 ShadowDom scoped 是增加className style
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appName,
);
const initialContainer = 'container' in app ? app.container : undefined;
const legacyRender = 'render' in app ? app.render : undefined;
// 返回一个 render 函数,故名思义,调用它把 html dom 填充到指定 DOM 上
const render = getRender(appName, appContent, legacyRender);
// 第一次加载设置应用可见区域 dom 结构
// 确保每次应用加载前容器 dom 结构已经设置完毕, 把 initialContainer 塞进 element,如果有的话就不塞了
render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading');
// 获取 app className dom
const initialAppWrapperGetter = getAppWrapperGetter(
appName,
appInstanceId,
!!legacyRender,
strictStyleIsolation,
scopedCSS,
() => initialAppWrapperElement,
);
// stop ,看到这里,我需要把下文的代码抽出来单独看,因为到了另一个核心功能 沙箱了。
/**被抢走的代码 ^-^ ***/
}
以上其实就做了render html 和 css 样式隔离这两个主要功能,css 样式隔离有两种,一种是添加 scope 命名空间,一种是 shadow dom,两种方式都有各自的弊端,我们项目中都没开启,这里就不展开讲了。
基于样式冲突的处理可以看我们项目中用的权哥的方案:https://yuque.antfin-inc.com/junshun.mq/rhz9sm/wig7le#Y1S5n
沙箱
loadApp 的下半部分代码主要是包装了生命周期钩子,然后返回给 single-spa 的 registerApplication 方法。
这里的核心逻辑是,如何执行微前端的 entry 脚本?这就引出沙箱的概念。
沙箱是什么?
沙箱是一个隔离的运行环境,沙箱和宿主环境是隔离的,沙箱间也是隔离的。
为什么要有沙箱?
如果没有沙箱的话,微前端 A 定义了一个 全局变量 window.a = 1,微前端 B也有一个全局变量 window.a = 2。
那之后对变量 a 的操作预期就不明确了,如果引发了缺陷可能让你浪费巨多的时间去排查。或者,被卸载掉的微前端对window对象的操作会一直留在内存里,这也是种性能浪费嘛。
可以没有沙箱
既然担心全局变量污染,那就不去写全局变量的代码不就行了,可以在 CR 流程中加一环节,以代码规范规避这个事情。我看网上有的团队确实是这样干的,用规范来舍弃沙箱。但.......这有个前提,微前端都是由团队内开发的。而我们要常常接第三方平台,比如大禹接 星轨 接政务中台的自动化测试工具......里面的代码都是不可控的。所以对我们而言:沙箱,得有!
沙箱方案?
-
其实 iframe 就是一个隔离的沙箱,可以构造 iframe 中的 window 分配给微前端, const iframe = document.createElement( 'iframe' ); 然后用这个 window 对象执行 eval 微前端代码,这个方案被阿里云的微前端框架 https://github.com/aliyun/alibabacloud-alfa 采纳。缺点嘛,通信有点麻烦,得通过 postMessage 来做。
-
而 qiankun 走了另外的路:
-
单例沙箱:通过代理 window 对象,当微前端 A mounted 时,A 对 window 的操作会被记录在全局变量中;当 A unmounted 时,会根据这个全局变量 delete A 对 window 的修改;当 A 再次 mounted 时,则把全局变量中的属性再赋给 window。可以显而易见的,该模式只能同时渲染一个微前端。所以这个沙箱基本处于废弃状态。
-
多例沙箱:因为单例的局限性,所以有多例沙箱,它为每一个微前端构造了新代理对象 fakeWindow,微前端为 window 的操作都会被代理到 fakeWindow,接下来我们重点将这个沙箱是怎么被创建出来的。
/* loader的下半部分代码 **/
let global = window;
const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose;
let sandboxContainer;
// 核心方法
if (sandbox) {
sandboxContainer = createSandboxContainer(
appName,
// FIXME should use a strict sandbox logic while remount, see https://github.com/umijs/qiankun/issues/518
initialAppWrapperGetter,
scopedCSS,
useLooseSandbox,
excludeAssetFilter,
);
// 用沙箱的代理对象作为接下来使用的全局对象
global = sandboxContainer.instance.proxy as typeof window;
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}
// execScripts 用 createSandboxContainer 生成的代理对象来执行微前端代码
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox);
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global,
sandboxContainer?.instance?.latestSetProp,
);
这里我省去了其他不重要的逻辑,createSandboxContainer生成了 fakeWindow 代理对象,并且用这个对象 execScripts微前端代码,这样在微前端内部的对 window的操作便指向了 fakeWindow。接下来我们看看 这个 fakeWindow 代理对象是怎么产生的。
function createFakeWindow(global: Window) {
// map always has the fastest performance in has check scenario
// see https://jsperf.com/array-indexof-vs-set-has/23
const propertiesWithGetter = new Map<PropertyKey, boolean>();
const fakeWindow = {} as FakeWindow;
/*
copy the non-configurable property of global to fakeWindow
see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
> A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object.
*/
Object.getOwnPropertyNames(global)
.filter((p) => {
const descriptor = Object.getOwnPropertyDescriptor(global, p);
return !descriptor?.configurable;
})
.forEach((p) => {
const descriptor = Object.getOwnPropertyDescriptor(global, p);
if (descriptor) {
const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');
/*
make top/self/window property configurable and writable, otherwise it will cause TypeError while get trap return.
see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/get
> The value reported for a property must be the same as the value of the corresponding target object property if the target object property is a non-writable, non-configurable data property.
*/
if (
p === 'top' ||
p === 'parent' ||
p === 'self' ||
p === 'window' ||
(process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
) {
descriptor.configurable = true;
/*
The descriptor of window.window/window.top/window.self in Safari/FF are accessor descriptors, we need to avoid adding a data descriptor while it was
Example:
Safari/FF: Object.getOwnPropertyDescriptor(window, 'top') -> {get: function, set: undefined, enumerable: true, configurable: false}
Chrome: Object.getOwnPropertyDescriptor(window, 'top') -> {value: Window, writable: false, enumerable: true, configurable: false}
*/
if (!hasGetter) {
descriptor.writable = true;
}
}
if (hasGetter) propertiesWithGetter.set(p, true);
// freeze the descriptor to avoid being modified by zone.js
// see https://github.com/angular/zone.js/blob/a5fe09b0fac27ac5df1fa746042f96f05ccb6a00/lib/browser/define-property.ts#L71
rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
}
});
return {
fakeWindow,
propertiesWithGetter,
};
}
既然核心用了 Proxy,肯定要有一个代理对象。代理对象是要满足几点要求:
A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object.
一个属性不能被称为 不可配置的,如果它不存在于目标对象的私有属性中 或者 它作为一个 可配置的属性存在于目标对象中
基于此点,所以要遍历 window 对象中的不可配置属性,并通过 Object.defineProperty 配置到 fekeWindow 的私有属性中。
top/self/window property configurable and writable, otherwise it will cause TypeError while get trap return.
而对这几个属性的 可配置属性设置为 true,则是为了避开这条:当一个属性是不可配置的,但其值和 目标对象的值不相等则会抛出错误。所以这里需要修改为 configurable = true
其他的则是对浏览器兼容性以及 angular 做的兼容。
有了目标对象,就可以实现代理了。这里主要实现了 proxy 的几个 traps 函数,就不展开了。
const proxy = new Proxy(fakeWindow, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
...
},
get(target: FakeWindow, p: PropertyKey): any {
...
},
// trap in operator
// see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
has(target: FakeWindow, p: string | number | symbol): boolean {
return p in unscopables || p in target || p in rawWindow;
},
getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined {
...
},
// trap to support iterator with sandbox
ownKeys(target: FakeWindow): ArrayLike<string | symbol> {
return uniq(Reflect.ownKeys(rawWindow).concat(Reflect.ownKeys(target)));
},
defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean {
...
},
deleteProperty(target: FakeWindow, p: string | number | symbol): boolean {
...
},
// makes sure `window instanceof Window` returns truthy in micro app
getPrototypeOf() {
...
},
});
用 fakeWindow 执行完微前端代码后,生命周期钩子被传入到 single-spa 中,之后就交给 single-spa 来调度了。
补充
简要流程
https://yuque.antfin.com/wenxian.dh/lry0tb/gfkzcv
是否要开启预加载
预加载听起来很美好,但是否要开启预加载你得知道预加载发生在什么时机,假设当前主应用是 A,微前端 1 是 B,微前端 2 是 C。
-
A: a.umi.js
-
B: b.umi.js + b.vendor.js
-
C: c.umi.js + c.vendor.js
假设我们给 C 开启了预加载,当前激活的页面是 B 应用,那加载逻辑应该是这样的:
a.umi.js -> b.umi.js -> c.umi.js -> b.vendor.js
可以得出,微前端 C 的预加载脚本影响到了微前端 B 的 vendor 渲染。
这里用我们业务来举例:当前是 alita-frontend的页面,此时 alita-frontend的 vendor在 collboration umi.js 后加载,这无疑影响到 alita-frontend 的打开速度。
所以,开启预加载可能会拖慢非预加载项目的首屏渲染速度。要慎重!
你可能注意到,微前端的加载是依赖主应用 umi.js 的加载的,因为这个行为是在主应用运行时触发的
所以要想微前端加载的快,就得优化主应用加载的时间,这个收益比较大。有两种方式,我认为这两种方式取其一即可:
-
主应用不要做其他事情,比如大禹的主应用现在承担着渲染侧导航栏的责任,把这块业务逻辑抽出去可以节省不少加载时间,这点是我们可以去优化的方向。
-
子应用的加载能否提前?和主应用的 umi 并发加载,但这里要解决 qiankun 的加载时序问题,这个方向有待探索。
是否要开启样式隔离
不,现有的两个官方方案都有各自的问题,样式冲突主要体现在 全局样式和 antd 上。
对于全局样式我们有统一的 theme 包,在模版引入,为了兼容开发环境,需要在开发环境引入主题包
对于antd,一是开启 antdStyleClear 参数即可,它可以帮我们删掉微前端中的 antd 样式依赖,以使用模版中引入的 antd.css。二是 antd: false删掉umi帮我们自动引入的 antd
// 兼容开发环境
'style': isProduction ? [] : [require('@ali-dayu/{主题名}/lib/link/index').default.prod],
'gts-base': {
// 是否清理掉源码 import 的 antd 样式
// 因为使用的是 babel-loader 方式处理的所有 node_modules 里面的文件
// 所以编译时长有一定可能会增加(也有可能降低,这个主要取决于编译 antd less 和 babel-loader 处理 node_modules 谁耗时长)
// 默认:false
// 【重要】:如果开启了这个属性,代表你的工程不会再引入任何 antd/xx/less,所以此时 第三步 的插件也不再有用;
antdStyleClear: true,
},
'antd: false
微前端的优化
Q:第一招,对于四大项:react、react-dom、moment、moment-locale
A:已经在模版中渲染了,直接通过配置 external 掉就不会被打包了。如果没去掉,可能写法上有问题,要使用 import Moment from 'moment' 而不是 import Utils from 'moment/es/xxx'
'gts-base': {
// 是否 external 掉四大项,react、react-dom、moment、moment-locale
// 默认:true
externalsDefault: true,
}
Q:第二招,对于 antd
A:模版中已经引入了,直接配置
externals: {
antd: 'antd',
},
antd: false
Q:第三招,对于第三方包 如:g2dayuEditor
A:比如g2如果一个微前端用到,则动态导入用到的图表,否则通过script或者 runtimeImport加载享受缓存的快感。
externals: {
g2: 'g2',
},
scripts: ['https://gw.alipayobjects.com/os/lib/antv/g2/4.1.19/dist/g2.min.js']
Q:第四招,主应用已经加载过的库,子应用就不用加载了
A:比如去掉 coreJs
// .env 文件
BABEL_POLYFILL=none
Q:第五招,针对 xapi 的优化
A: 参考:https://code.dayu.work/gts-xapis/transform-xapi-import
Q:第六招,关掉沙箱或者升级 qiankun 版本
A:沙箱的逻辑比较复杂,社区也对其做不断的优化,非常推荐你读这篇文章:https://yuque.antfin.com/zexuan.dzx/qhwim4/huxpfp,对于我们而言,在项目中保持 qiankun 的最新版本的收益是很大的。