示例源码与笔记 微前端qiankun从搭建到部署的实践
https://github.com/zengbotao/qiankun-example
安装根目录工程依赖
qiankun 是基于 single-spa 做了二次封装的微前端框架,解决了 single-spa 的一些弊端和不足。
在非Umijs中的乾坤案例
qiankun 实战 demo,父应用 vue,子应用使用 react
, vue
和 原生HTML
。
single-spa 作为一个基础的微前端框架,它主要提供了以下两个能力
- 加载子应用的入口,注意这里是入口,加载子应用的方法还是需要二次开发的
- 维护子应用和路由的状态,包括子应用的初始化、挂载和卸载,同时也监听了路由的变化来切换子应用
那我们通常接入 single-spa 会做以下几个步骤
- 路由区分,因为我们需要区分不同的子应用,一般是通过添加路由前缀实现的
- 入口改造,因为我们要提供给 single-spa 的子应用加载方法,所以通常需要将多入口改造成单入口(mpa to spa)
- 打包环境修改,因为我们需要同时将所有子应用打包进 single-spa 里面,这样才能找到加载子应用的方法
到这里如果对构建有经验的读者就意识到了,single-spa 这种加载的方法我们称之为 JS-Entry。由于 JS 执行还不支持远端调用,所以我们修改打包构建。那么这对于一个已经在线上正常跑的业务代码几乎是无法接受的,
其次,即使是能够接受把所有的代码打包到一起,由于所有代码都需要在运行前加载,这对于页面的体验尤其是性能优化很难做,比如 js 的按需加载,懒加载,css 的独立打包等常规方案就不可做。
最后,如果从 CI-CD 的角度看,我们每次修改一个子应用的代码,就需要把所有的代码都打包一遍上线。目前需要用到微前端的场景,大部分都已经是古早的业务,也基本上都是所谓的“巨石应用”。这样的构建流程对于团队合作来说也是危机重重,一不小心就会踩了大坑。
以上的这一些问题我们可以归纳为,single-spa 对于应用的侵入性太强,导致无法很好的集成之前的业务代码。
刚刚除了提到 single-spa 的侵入问题以外,qiankun 框架还封装了以下能力,这一些能力也是 single-spa 没有提供给我们的:
- 样式和 js 的隔离问题:如何确保 js 和 css 之间互相不产生影响,又或者子应用 A 修改了全局变量,怎么保证子应用 B 拿到的全局变量还是之前的环境。
- 资源预加载:如何保证在子应用 A 切换子应用 B 的时候体验良好。
- 应用间通信:主子应用之间是怎么相互同步通用的状态的。
/**
* 核心方法,完成以下几件事
* 1. 通过HTML-entry的方式远程加载微应用
* 2. 样式隔离,使用css scoped或者shadow-dom
* 3. 渲染子应用
* 4. 运行时沙箱,js沙箱、全局沙箱
* 5. 生命周期方法
* 6. 注册相关通信方法
* @param app
* @param configuration
* @param lifeCycles
* @returns
*/
export async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
const { entry, name: appName } = app;
// 获取app实例
const appInstanceId = genAppInstanceIdByName(appName);
// 生成时间戳
const markName = `[qiankun] App ${appInstanceId} Loading`;
if (process.env.NODE_ENV === 'development') {
performanceMark(markName);
}
// 配置信息
const {
singular = false,
sandbox = true,
excludeAssetFilter,
globalContext = window,
...importEntryOpts
} = configuration;
// 调用import-html-entry, 获取子应用的入口html和脚本执行器
/**
* template时link替换为style后的模版
* execScripts是在指定的js上下文环境中执行代码
* assetPublicPath静态资源地址
*/
// get the entry html content and script executor
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
// as single-spa load and bootstrap new app parallel with other apps unmounting
// (see https://github.com/CanopyTax/single-spa/blob/master/src/navigation/reroute.js#L74)
// we need wait to load the app until all apps are finishing unmount in singular mode
// 这里是single-spa的限制,加载、初始化、卸载不能同时执行,所以使用了一个promise控制调用流
if (await validateSingularMode(singular, app)) {
await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
}
/** 样式隔离开始 */
// 用一个容器元素包裹子应用的html入口
// appContent = `<div id="__qiankun_microapp_wrapper_for_${appInstanceId}__" data-name="${appName}">${template}</div>`
const appContent = getDefaultTplWrapper(appInstanceId)(template);
// 是否严格样式隔离
const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;
if (process.env.NODE_ENV === 'development' && strictStyleIsolation) {
console.warn(
"[qiankun] strictStyleIsolation configuration will be removed in 3.0, pls don't depend on it or use experimentalStyleIsolation instead!",
);
}
//scope css
const scopedCSS = isEnableScopedCSS(sandbox);
// 将appContent由字符串模版转换成html的dom,如果需要严格样式隔离,这里使用了shadow-dom
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId,
);
/** 子应用渲染开始 */
const initialContainer = 'container' in app ? app.container : undefined;
const legacyRender = 'render' in app ? app.render : undefined;
const render = getRender(appInstanceId, appContent, legacyRender);
// 第一次加载设置应用可见区域 dom 结构
// 确保每次应用加载前容器 dom 结构已经设置完毕
// 渲染子应用到容器节点,并且展示loading状态
render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading');
// 得到一个 getter 函数
// 通过该函数可以获取 <div id="__qiankun_microapp_wrapper_for_${appInstanceId}__" data-name="${appName}">${template}</div>
const initialAppWrapperGetter = getAppWrapperGetter(
appInstanceId,
!!legacyRender,
strictStyleIsolation,
scopedCSS,
() => initialAppWrapperElement,
);
/** 运行时沙箱开始 */
let global = globalContext;
// 挂载的异步控制流程
let mountSandbox = () => Promise.resolve();
let unmountSandbox = () => Promise.resolve();
const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose;
let sandboxContainer;
if (sandbox) {
/**
* 运行时沙箱,由JS的沙箱和css的沙箱两部分
* 在正常情况下,返回了window的proxy对象
* unmount方法卸载子应用,并且恢复到之前的环境
* mount方法挂载子应用,并且增强相关函数,接下来会讲解
*/
sandboxContainer = createSandboxContainer(
appInstanceId,
// FIXME should use a strict sandbox logic while remount, see https://github.com/umijs/qiankun/issues/518
initialAppWrapperGetter,
scopedCSS,
useLooseSandbox,
excludeAssetFilter,
global,
);
// 用沙箱的代理对象作为接下来使用的全局对象
global = sandboxContainer.instance.proxy as typeof window;
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}
// 将传的生命周期函数合并,一些addon看起来是打了一些标记
const {
beforeUnmount = [],
afterUnmount = [],
afterMount = [],
beforeMount = [],
beforeLoad = [],
} = mergeWith({}, getAddOns(global, assetPublicPath), lifeCycles, (v1, v2) => concat(v1 ?? [], v2 ?? []));
// 按照顺序执行beforeLoad生命周期
await execHooksChain(toArray(beforeLoad), app, global);
// get the lifecycle hooks from module exports
// 获取微任务暴露出来的生命周期函数
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox);
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global,
sandboxContainer?.instance?.latestSetProp,
);
// 注册通信方法,通过props会传递给子应用
const { onGlobalStateChange, setGlobalState, offGlobalStateChange }: Record<string, CallableFunction> =
getMicroAppStateActions(appInstanceId);
// FIXME temporary way
const syncAppWrapperElement2Sandbox = (element: HTMLElement | null) => (initialAppWrapperElement = element);
const parcelConfigGetter: ParcelConfigObjectGetter = (remountContainer = initialContainer) => {
let appWrapperElement: HTMLElement | null;
let appWrapperGetter: ReturnType<typeof getAppWrapperGetter>;
const parcelConfig: ParcelConfigObject = {
name: appInstanceId,
bootstrap,
// 挂载阶段的生命周期
mount: [
async () => {
if (process.env.NODE_ENV === 'development') {
const marks = performanceGetEntriesByName(markName, 'mark');
// mark length is zero means the app is remounting
if (marks && !marks.length) {
performanceMark(markName);
}
}
},
// 如果是单例模式的沙箱,则需要等微应用卸载完成之后才能执行挂载任务
async () => {
if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
return prevAppUnmountedDeferred.promise;
}
return undefined;
},
// initial wrapper element before app mount/remount
async () => {
appWrapperElement = initialAppWrapperElement;
// 重新初始化模版
appWrapperGetter = getAppWrapperGetter(
appInstanceId,
!!legacyRender,
strictStyleIsolation,
scopedCSS,
() => appWrapperElement,
);
},
// 添加 mount hook, 确保每次应用加载前容器 dom 结构已经设置完毕
async () => {
const useNewContainer = remountContainer !== initialContainer;
// 这种情况是主应用的容器换掉了,所以需要执行一系列的方法rebuild
if (useNewContainer || !appWrapperElement) {
// element will be destroyed after unmounted, we need to recreate it if it not exist
// or we try to remount into a new container
appWrapperElement = createElement(appContent, strictStyleIsolation, scopedCSS, appInstanceId);
syncAppWrapperElement2Sandbox(appWrapperElement);
}
// 渲染微应用到容器节点,展示mounted状态
render({ element: appWrapperElement, loading: true, container: remountContainer }, 'mounting');
},
// 运行时沙箱导出的mount
mountSandbox,
// exec the chain after rendering to keep the behavior with beforeLoad
// 执行beforeMount
async () => execHooksChain(toArray(beforeMount), app, global),
// 向子应用的mount生命周期函数传递参数
async (props) => mount({ ...props, container: appWrapperGetter(), setGlobalState, onGlobalStateChange }),
// finish loading after app mounted
async () => render({ element: appWrapperElement, loading: false, container: remountContainer }, 'mounted'),
async () => execHooksChain(toArray(afterMount), app, global),
// initialize the unmount defer after app mounted and resolve the defer after it unmounted
// 子应用挂载完成之后初始化这个promise,并且在微应用卸载以后resolve这个promise
async () => {
if (await validateSingularMode(singular, app)) {
prevAppUnmountedDeferred = new Deferred<void>();
}
},
async () => {
if (process.env.NODE_ENV === 'development') {
const measureName = `[qiankun] App ${appInstanceId} Loading Consuming`;
performanceMeasure(measureName, markName);
}
},
],
// 卸载微应用
unmount: [
async () => execHooksChain(toArray(beforeUnmount), app, global),
// 执行微应用的生命周期函数
async (props) => unmount({ ...props, container: appWrapperGetter() }),
// 沙箱导出的unmount方法
unmountSandbox,
async () => execHooksChain(toArray(afterUnmount), app, global),
async () => {
render({ element: null, loading: false, container: remountContainer }, 'unmounted');
offGlobalStateChange(appInstanceId);
// for gc
appWrapperElement = null;
syncAppWrapperElement2Sandbox(appWrapperElement);
},
// 子应用卸载的时候会resolve这个promise,确保框架能进行后续的工作
async () => {
if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
prevAppUnmountedDeferred.resolve();
}
},
],
};
// 子应用有可能定义了update方法,覆盖
if (typeof update === 'function') {
parcelConfig.update = update;
}
return parcelConfig;
};
return parcelConfigGetter;
}
在主应用中,你需要配置Qiankun,包括注册子应用、设置路由规则以及配置子应用的样式等。主应用是整个微前端应用的容器,它负责管理子应用的加载和通信。
- 在主应用的入口文件中,引入Qiankun的registerMicroApps方法和主应用的路由组件。
- 配置Qiankun的注册表,包括子应用的名字,入口和路由规则。“micro-app.js”
- 主应用的入口文件中注册App,start
每个子应用都是一个独立的前端应用,可以使用任何前端框架,如React、Vue、Angular等。你需要为每个子应用创建独立的项目,并在其中配置Qiankun的子应用适配器。 每个子应用是一个独立的Vue应用。
- 创建public-path.js文件
- 写渲染方法,创建实例,挂载到相应的div
- 配置子应用的适配器和生命周期钩子。
Qiankun提供了API来实现主应用和子应用之间的通信。你可以使用这些API来传递数据、调用子应用的方法等。
通过Qiankun的initGlobalState方法和onGlobalStateChange方法,你可以在主应用和子应用之间共享全局状态。
在主应用中,使用initGlobalState来初始化全局状态,并通过setGlobalState来更新状态。 在子应用中,使用onGlobalStateChange来订阅全局状态的变化,并通过回调函数处理状态变化。
使用Qiankun的invoke方法,子应用可以调用主应用暴露的方法。
在主应用中,通过props参数将方法暴露给子应用。 在子应用中,使用invoke方法调用主应用的方法。
在完成开发后,你需要将主应用和子应用进行构建,并将它们部署到相应的服务器上。
- 将主应用的构建产物(例如
dist/main-app
目录下的文件)部署到Web服务器上,确保服务器可以提供这些静态文件。 - 同样,将每个子应用的构建产物(例如
dist/sub-app1
和dist/sub-app2
目录下的文件)部署到各自的Web服务器上。 - 部署主应用和子应用:
https://qiankun.umijs.org/zh/cookbook#%E5%A6%82%E4%BD%95%E9%83%A8%E7%BD%B2
UMD 模式
qiankun
是一个基于 single-spa 的微前端实现库,它允许你将多个前端应用集成到一个主应用中。在使用 qiankun
时,子应用需要打包成 UMD(Universal Module Definition)模式的原因有以下几点:
- 模块兼容性:UMD 模式是一种兼容多种模块定义方式的打包格式,它既支持 CommonJS,也支持 AMD,同时还支持全局变量的方式。这意味着无论是在 Node.js 环境、RequireJS 环境,还是在普通的浏览器环境中,UMD 模块都能够正常工作。
- 动态加载:
qiankun
需要动态加载子应用,这意味着子应用需要以一种可以被动态导入的方式打包。UMD 格式正好满足这一点,因为它不依赖于特定的模块加载器,可以直接通过<script>
标签加载。 - 全局变量暴露:在 UMD 模式中,子应用可以通过全局变量暴露自己的生命周期钩子(如
bootstrap
、mount
、unmount
),这样qiankun
主应用可以在加载子应用时,通过这些全局变量来调用子应用的生命周期函数。 - 简化集成:使用 UMD 模式打包子应用可以简化集成过程,因为子应用不需要依赖于特定的构建工具或模块系统,可以独立于主应用的技术栈进行开发和部署。
总之,将子应用打包成 UMD 模式是为了确保子应用可以在 qiankun
微前端架构中以最小的依赖和最大的兼容性运行。这样做可以使得微前端架构更加灵活和健壮