暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

微前端框架qiankun剖析

原创 手机用户3154 2023-02-13
1919

一、single-spa简介

要了解qiankun的实现机制,那我们不得不从其底层依赖的single-spa说起。随着微前端的发展,我们看到在这个领域之中出现了各式各样的工具包和框架来帮助我们方便快捷的实现自己的微前端应用。在发展早期,single-spa可以说是独树一帜,为我们提供了一种简便的微前端路由工具,大大降低了实现一个微前端应用的成本。我们来看一下一个典型single-spa微前端应用的架构及代码。

主应用(基座):
作为整个微前端应用中的项目调度中心,是用户进入该微前端应用时首先加载的部分。在主应用中,通过向single-spa提供的registerApplication函数传入指定的参数来注册子应用,这些参数包括子应用名称name、子应用如何加载app、子应用何时激活activeWhen、以及需要向子应用中传递的参数customProps等等。在完成整体注册后调用start函数启动整个微前端项目。

// single-spa-config.js
import { registerApplication, start } from 'single-spa';

// Config with more expressive API
registerApplication({
  name: 'app1',
  app: () => import('src/app1/main.js'),
  activeWhen: ['/myApp', (location) => location.pathname.startsWith('/some/other/path')],
  customProps: {
    some: 'value',
  }
});

start();

子应用:
子应用是实际展示内容的部分,最主要的工作是导出single-spa中所规定的生命周期函数,以便于主应用调度。其中,bootstrap在子应用第一次加载时调用,mount在子应用每次激活时调用,unmount在子应用被移出时调用。此外在这些生命周期函数中我们可以看到props参数被传入,这个参数中包含了子应用注册名称、singleSpa实例、用户自定义参数等信息,方便子应用的使用。

console.log("The registered application has been loaded!");

export async function bootstrap(props) {
  const {
    name, // The name of the application
    singleSpa, // The singleSpa instance
    mountParcel, // Function for manually mounting
    customProps, // Additional custom information
  } = props; // Props are given to every lifecycle
  return Promise.resolve();
}
export async function mount(props) {...}
export async function unmount(props) {...}

可以看到Single-spa作为一个微前端框架领域最为广泛使用的包,其为我们提供了良好的子应用路由机制。但是除此之外,single-spa也留下了很多需要用户自行解决的问题:

  1. 子应用究竟应该如何加载,从哪里加载?
  2. 子应用运行时会不会互相影响?
  3. 主应用与子应用、子应用之间具体可以通过customProps互相通信,但是怎样才能知道customProps发生了变化呢?

因此,市面上出现了很多基于single-spa二次封装的微前端框架。他们分别使用不同的方式,基于各自不同的侧重点包装出了更加完善的产品。对于这些产品,我们可以将single-spa在其中的作用类比位理解为react-router之于react项目的作用——single-spa作为一个没有框架、技术栈限制的微前端路由为它们提供了最底层的子应用间路由及生命周期管理的服务。在近几年微前端的发展壮大过程中,早期推出并经久不衰的阿里qiankun框架算的上是一枝独秀了。

二、qiankun简介

作为目前微前端领域首屈一指的框架,qiankun无论是从接入的方便程度还是从框架本身提供的易用性来说都是可圈可点的。qiankun基于single-spa进行了二次开发,不但为用户提供了简便的接入方式(包括减少侵入性,易于老项目的改造),还贴心的提供了沙箱隔离以及实现了基于发布订阅模式的应用间通信方式,大大降低了微前端的准入门槛,对于微前端工程化的推动作用是不可忽视的。
因为其基于single-spa二次开发, 所以qiankun微前端架构与第一章中所提及的并无二致,下面我们列出一个典型的qiankun应用的代码并类比其与single-spa的代码区别。

主应用:
这里qiankun将single-spa中的app改为了entry并对其功能进行了增强,用户只需要输入子应用的html入口路径即可,其余加载工作由qiankun内部完成,当然也可以自行列出所需加载的资源。此外加入了container选项,让用户显示指定并感知到子应用所挂载的容器,简化了多个子应用同时激活的场景。

import { registerMicroApps, start } from 'qiankun'; registerMicroApps([ { name: 'react app', // app name registered entry: '//localhost:7100', container: '#yourContainer', activeRule: '/yourActiveRule', }, { name: 'vue app', entry: { scripts: ['//localhost:7100/main.js'] }, container: '#yourContainer2', activeRule: '/yourActiveRule2', }, ]); start();

子应用:

与single-spa基本一致,导出了三个生命周期函数。这里可以看到在mount中我们手动将react应用渲染到了页面上,反之在unmount中我们将其从页面上清除。

/** * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。 */ export async function bootstrap() { console.log('react app bootstraped'); } /** * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法 */ export async function mount(props) { ReactDOM.render(<App />, props.container ? props.container.querySelector('#root') : document.getElementById('root')); } /** * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例 */ export async function unmount(props) { ReactDOM.unmountComponentAtNode( props.container ? props.container.querySelector('#root') : document.getElementById('root'), ); }

可以看到,由于其帮助我们完成了子应用的加载工作,所以用户的配置相比于single-spa更为简便了。但是,除了这个明面上的工作,qiankun还在暗处为我们的易用性做出了很多努力,接下来,我们会围绕着以下三个方面来深入剖析qiankun内部源码和相关实现原理:

  1. qiankun如何实现用户只需配置一个URL就可以加载相应子应用资源的;
  2. qiankun如何帮助用户做到子应用间独立运行的(包括JS互不影响和CSS互不污染);
  3. qiankun如何帮助用户实现更简便高效的应用间通信的;

三、子应用加载

qiankun的子应用注册方式非常简单,用户只需要调用registerMicroApps函数并将所需参数传入即可.前文中我们说到qiankun是基于single-spa二次封装的框架,因此qiankun中的路由监听和子应用生命周期管理实际上都是交给了single-spa来进行实现的。

import { registerApplication } from 'single-spa'; let microApps: Array<RegistrableApp<Record<string, unknown>>> = []; export function registerMicroApps<T extends ObjectType>( apps: Array<RegistrableApp<T>>, lifeCycles?: FrameworkLifeCycles<T>, ) { // 判断应用是否注册过,保证每个应用只注册一次 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; // 调用single-spa的子应用注册函数,将用户输入的参数转换为single-spa所需的参数 registerApplication({ name, // 这里提供了single-spa所需的子应用加载方式函数 app: async () => { loader(true); await frameworkStartedDefer.promise; // 调用转换函数loadApp将用户输入的url等解析转换运行,最终生成增强后的子应用生命周期函数(包括mount,unmount,bootstrap) const { mount, ...otherMicroAppConfigs } = ( await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles) )(); // 返回值为loadApp生成的一系列生命周期函数,其中mount函数数组再次增强 return { mount: [async () => loader(true), ...toArray(mount), async () => loader(false)], ...otherMicroAppConfigs, }; }, activeWhen: activeRule, customProps: props, }); }); }

可以看到,qiankun在子应用加载上所做的工作就是将用户调用registerMicroApps时所提供的参数经过一系列处转换之后,改造成single-spa中registerApplication所需要的参数。

import { importEntry } from 'import-html-entry'; export async function loadApp<T extends ObjectType>( app: LoadableApp<T>, configuration: FrameworkConfiguration = {}, lifeCycles?: FrameworkLifeCycles<T>, ): Promise<ParcelConfigObjectGetter> { const { entry, name: appName } = app; const { singular = false, sandbox = true, excludeAssetFilter, globalContext = window, ...importEntryOpts } = configuration; // 。。。。。。 // 依赖了import-html-entry库中的方法解析了用户输入的url(entry参数),得到了template(HTML模版),execScripts(所依赖JS文件的执行函数)以及assetPublicPath(公共资源路径) const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts); // 。。。。。。 // 在window沙箱中(global参数)执行entry依赖的js文件,得到相关生命周期( bootstrap, mount, unmount, update) // 这里可以忽略getLifecyclesFromExports函数,其返回与scriptExports一致,只是为了检查子应用是否导出了必须的生命周期 const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, { scopedGlobalVariables: speedySandbox ? trustedGlobals : [], }); const { bootstrap, mount, unmount, update } = getLifecyclesFromExports( scriptExports, appName, global, sandboxContainer?.instance?.latestSetProp, ); // 。。。。。 // 导出single-spa所需配置的getter方法(因为配置项与子应用挂在的container相关,默认为用户输入的container,后续用户可以手动加载子应用并指定其渲染位置) const initialContainer = 'container' in app ? app.container : undefined; const parcelConfigGetter: ParcelConfigObjectGetter = (remountContainer = initialContainer) => { const parcelConfig: ParcelConfigObject = { name: appInstanceId, bootstrap, // mount数组在子应用渲染时依次执行 mount: [ // 。。。。。。 // 执行沙箱隔离 mountSandbox, // 调用用户自定义mount生命周期,并传入setGlobalState/onGlobalStateChange的应用间通信方法函数 async (props) => mount({ ...props, container: appWrapperGetter(), setGlobalState, onGlobalStateChange }), // 。。。。。。 ], // unmount数组在子应用卸载时依次执行 unmount: [ // 。。。。。。。 // 调用用户自定义unmount生命周期 async (props) => unmount({ ...props, container: appWrapperGetter() }), // 卸载隔离沙箱 unmountSandbox, // 清理工作 async () => { render({ element: null, loading: false, container: remountContainer }, 'unmounted'); // 清理子应用对全局通信的订阅 offGlobalStateChange(appInstanceId); // for gc appWrapperElement = null; syncAppWrapperElement2Sandbox(appWrapperElement); }, // 。。。。。。。 ], }; return parcelConfig; } return parcelConfigGetter }

可以看到,qiankun在其加载函数loadApp中做了一些额外的工作。

  1. 为了方便使用,qiankun提供了基于url入口来加载子应用的方式。为了获取用户提供的html文件(或者资源文件数组)并解析出其中所需的资源,qiankun依赖了import-html-entry库中的相关方法,执行并得到了子应用导出的用户自定义生命周期。
  2. 对用户自定义的生命周期进行增强(包括挂载/卸载应用间的隔离沙箱,初始化或传入应用间通信方法等等),返回框架增强后的生命周期函数数组并注册在single-spa中。

经过源码的分析我们可以看出,qiankun在子应用加载上就是作为中间层存在的,其主要作用就是简化用户对于子应用注册的输入,通过框架内部的方法转换并增强了用户的输入最终将其传入了single-spa之中,在后续的执行中真正负责子应用加载卸载的是single-spa。

四、沙箱隔离

在基于single-spa开发的微前端应用中,子应用开发者需要特别注意的是:

  1. 要谨慎修改和使用全局变量上的属性(如window、document等),以免造成依赖该属性的自身应用或其它子应用运行时出现错误;
  2. 要谨慎控制CSS规则的生效范围,避免覆盖污染其它子应用的样式;

但这样的低级人为保证机制是无法在大规模的团队开发过程中对应用的独立性起到完善保护的,而qiankun框架给我们提供的最便利和有用的功能就是其基于配置的自动化沙箱隔离机制了。有了框架层面的子应用隔离支持,用户无论是在编写JS代码还是修改CSS样式时都不必再担心代码对于全局环境的污染问题了。沙箱机制一方面提升了微应用框架运行的稳定性和独立性,另一方面也降低了微前端开发者的心智负担,让其只需专注于自己的子应用代码开发之中。

4.1 JS隔离

在JS隔离方面,qiankun为开发者提供了三种不同模式的沙箱机制,分别适用于不同的场景之中。

1. Snapshot沙箱

该沙箱主要用于不支持Proxy对象的低版本浏览器之中,不能由用户手动指定该模式,qiankun会自动检测浏览器的支持情况并降级到Snapshot沙箱实现。由于这种实现方式在子应用运行过程中实际上修改了全局变量,因此不能用于多例模式之中(同时存在多个已挂载的子应用)。

// 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器 export default class SnapshotSandbox implements SandBox { private windowSnapshot!: Window; private modifyPropsMap: Record<any, any> = {}; constructor() {} 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]; }); } inactive() { this.modifyPropsMap = {}; iter(window, (prop) => { if (window[prop] !== this.windowSnapshot[prop]) { // 记录变更,恢复环境 this.modifyPropsMap[prop] = window[prop]; window[prop] = this.windowSnapshot[prop]; } }); } }

沙箱内部存在两个对象变量windowSnapshot和modifyPropsMap ,分别用来存储子应用挂载前原始window对象上的全部属性以及子应卸载时被其修改过的window对象上的相关属性。

Snapshot沙箱会在子应用mount前将modifyPropsMap中存储的属性重新赋值给window以恢复该子应用之前执行时的全局变量上下文,并在子应用unmount后将windowSnapshot中存储的属性重新赋值给window以恢复该子应用运行前的全局变量上下文,从而使得两个不同子应用的window相互独立,达到JS隔离的目的。

2. Legacy沙箱

当用户手动配置sandbox.loose: true时该沙箱被启用。Legacy沙箱同样会对window造成污染,但是其性能比要比snapshot沙箱好,因为该沙箱不用遍历window对象。同样legacy沙箱也只适用于单例模式之中。

/** * 基于 Proxy 实现的沙箱 * TODO: 为了兼容性 singular 模式下依旧使用该沙箱,等新沙箱稳定之后再切换 */ export default class LegacySandbox implements SandBox { /** 沙箱代理的全局变量 */ proxy: WindowProxy; /** 沙箱期间新增的全局变量 */ private addedPropsMapInSandbox = new Map<PropertyKey, any>(); /** 沙箱期间更新的全局变量 */ private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>(); /** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */ private currentUpdatedPropsValueMap = new Map<PropertyKey, any>(); constructor() { const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this; const rawWindow = window; const fakeWindow = Object.create(null) as Window; const setTrap = (p: PropertyKey, value: any, originalValue: any) => { if (!rawWindow.hasOwnProperty(p)) { // 当前 window 对象不存在该属性,将其记录在新增变量之中 addedPropsMapInSandbox.set(p, value); } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) { // 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值 modifiedPropsOriginalValueMapInSandbox.set(p, originalValue); } // 无论何种修改都记录在currentUpdatedPropsValueMap中 currentUpdatedPropsValueMap.set(p, value); // 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据 (rawWindow as any)[p] = value; }; 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 value; }, }); this.proxy = proxy } active() { // 激活时将子应用之前的所有改变重新赋予window,恢复其运行时上下文 this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v)); } inactive() { // 卸载时将window上修改的值复原,新添加的值删除 this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v)); this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true)); } private setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) { if (value === undefined && toDelete) { delete (this.globalContext as any)[prop]; } else { (this.globalContext as any)[prop] = value; } } }

Legacy沙箱为一个空对象fakewindow使用proxy代理拦截了其全部的set/get等操作,并在loader中用其替换了window。当用户试图修改window属性时,fakewindow上代理的set操作生效捕获了相关修改,其分别将新增的属性和修改前的值存入addedPropsMapInSandbox和modifiedPropsOriginalValueMapInSandbox这两个Map之中,此外还将所有修改记录在了currentUpdatedPropsValueMap之中,并改变了window对象。

这样当子应用挂载前,legacy沙箱会将currentUpdatedPropsValueMap之中记录的子应用相关修改重新赋予window,恢复其运行时上下文。当子应用卸载后,legacy沙箱会遍历addedPropsMapInSandbox和modifiedPropsOriginalValueMapInSandbox这两个Map并将window上的相关值恢复到子应用运行之前的状态。最终达到了子应用间JS隔离的目的。

3. Proxy沙箱

Proxy沙箱是qiankun框架中默认使用的沙箱模式(也可以通过配置sandbox.loose: false来开启),只有该模式真正做到了对window的无污染隔离(子应用完全不能修改全局变量),因此可以被应用在单/多例模式之中。

Proxy沙箱的原理也非常简单,它将window上的所有属性遍历拷贝生成一个新的fakeWindow对象,紧接着使用proxy代理这个fakeWindow,用户对window操作全部被拦截下来,只作用于在这个fakeWindow之上

// 便利window拷贝创建初始代理对象 function createFakeWindow(globalContext: Window) { const fakeWindow = {} as FakeWindow; Object.getOwnPropertyNames(globalContext) .forEach((p) => { const descriptor = Object.getOwnPropertyDescriptor(globalContext, p); rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor)); }); return { fakeWindow }; } /** * 基于 Proxy 实现的沙箱 */ export default class ProxySandbox implements SandBox { // 标志该沙箱是否被启用 sandboxRunning = true; constructor() { const { fakeWindow } = createFakeWindow(window); const proxy = new Proxy(fakeWindow, { set: (target: FakeWindow, p: PropertyKey, value: any): boolean => { if(this.sandboxRunning){ // 修改代理对象的值 target[p] = value; return true; } } get: (target: FakeWindow, p: PropertyKey): any => { // avoid who using window.window or window.self to escape the sandbox environment to touch the really window if (p === 'window' || p === 'self' || p === 'globalThis') { return proxy; } // 获取代理对象的值 const value = target[p]; return value; }, }) } active() { if (!this.sandboxRunning) activeSandboxCount++; this.sandboxRunning = true; } inactive() { this.sandboxRunning = false; } }

4.2 CSS隔离

对于CSS隔离的方式,在默认情况下由于切换子应用时,其相关的CSS内外连属性会被卸载掉,所以可以确保单实例场景子应用之间的样式隔离,但是这种方式无法确保主应用跟子应用、或者多实例场景的子应用样式隔离。不过,qiankun也提供了两种可配置生效的内置方式供使用者选择。

1. ShadowDOM

当用户配置sandbox.strictStyleIsolation: true时,ShadowDOM样式沙箱会被开启。在这种模式下 qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响。

// 在子应用的DOM树最外层进行一次包裹 function createElement( appContent: string, strictStyleIsolation: boolean, scopedCSS: boolean, appInstanceId: string, ): HTMLElement { // 包裹节点 const containerElement = document.createElement('div'); containerElement.innerHTML = appContent; // 子应用最外层节点 const appElement = containerElement.firstChild as HTMLElement; // 当开启了ShadowDOM沙箱时 if (strictStyleIsolation) { const { innerHTML } = appElement; appElement.innerHTML = ''; let shadow: ShadowRoot; // 判断浏览器兼容的创建ShadowDOM的方式,并使用该方式创建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(); } // 将子应用内容挂在ShadowDOM根节点下 shadow.innerHTML = innerHTML; } // 。。。。。。 return appElement; }

2. Scoped CSS

因为ShadowDOM存在着上述的一些问题,qiankun贴心的为用户提供了另一种更加无脑简便的样式隔离方式,那就是Scoped CSS。通过配置sandbox.experimentalStyleIsolation: true,Scoped样式沙箱会被开启。

在这种模式下,qiankun会遍历子应用中所有的CSS选择器,通过对选择器前缀添加一个固定的带有该子应用标识的属性选择器的方式来限制其生效范围,从而避免子应用间、主应用与子应用的样式相互污染。

export const QiankunCSSRewriteAttr = 'data-qiankun'; // 在子应用的DOM树最外层进行一次包裹 function createElement( appContent: string, strictStyleIsolation: boolean, scopedCSS: boolean, appInstanceId: string, ): HTMLElement { // 包裹节点 const containerElement = document.createElement('div'); containerElement.innerHTML = appContent; // 子应用最外层节点 const appElement = containerElement.firstChild as HTMLElement; // 。。。。。。 // 当开启了Scoped CSS沙箱时 if (scopedCSS) { // 为外层节点添加qiankun自定义属性,其值设定为子应用id标识 const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr); if (!attr) { appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId); } // 获取子应用中全部样式并进行处理 const styleNodes = appElement.querySelectorAll('style') || []; forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => { css.process(appElement!, stylesheetElement, appInstanceId); }); } return appElement; }

qiankun首先对子应用最外层的包裹节点(一般为div节点)添加一个属性名为data-qiankun,值为appInstanceId的属性。接着遍历处理子应用中的所有样式。

export const process = ( appWrapper: HTMLElement, stylesheetElement: HTMLStyleElement | HTMLLinkElement, appName: string, ): void => { // lazy singleton pattern if (!processor) { processor = new ScopedCSS(); } // !!!注意,对于link标签引入的外联样式不支持。qiankun在初期解析使用的import-html-entry在解析html模版时会将所有外联样式拉取并转换为style标签包裹的内联样式,所以这里不再处理link的外联样式。 if (stylesheetElement.tagName === 'LINK') { console.warn('Feature: sandbox.experimentalStyleIsolation is not support for link element yet.'); } const mountDOM = appWrapper; if (!mountDOM) { return; } // 获取包裹元素标签 const tag = (mountDOM.tagName || '').toLowerCase(); if (tag && stylesheetElement.tagName === 'STYLE') { // 生成属性选择器前缀,准备将其添加在选择器前(如div[data-qiankun=app1]) const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`; processor.process(stylesheetElement, prefix); } }; // 。。。。。。 process(styleNode: HTMLStyleElement, prefix: string = '') { if (styleNode.textContent !== '') { // 获取相关css规则rules const textNode = document.createTextNode(styleNode.textContent || ''); this.swapNode.appendChild(textNode); const sheet = this.swapNode.sheet as any; // type is missing const rules = arrayify<CSSRule>(sheet?.cssRules ?? []); // 重写这些CSS规则,将前缀添加进去 const css = this.rewrite(rules, prefix); // 用重写后的CSS规则覆盖之前的规则 styleNode.textContent = css; // 标志符,代表该节点已经处理过 (styleNode as any)[ScopedCSS.ModifiedTag] = true; return; } // 监听节点变化 const mutator = new MutationObserver((mutations) => { for (let i = 0; i < mutations.length; i += 1) { const mutation = mutations[i]; // 忽略已经处理过的节点 if (ScopedCSS.ModifiedTag in styleNode) { return; } // 如果新增了未处理过的子节点(代表了用户新注入了一些属性),那么会再次重写所有的CSS规则以确保新增的CSS不会污染子应用外部 if (mutation.type === 'childList') { const sheet = styleNode.sheet as any; const rules = arrayify<CSSRule>(sheet?.cssRules ?? []); const css = this.rewrite(rules, prefix); styleNode.textContent = css; (styleNode as any)[ScopedCSS.ModifiedTag] = true; } } }); // 注册监听 mutator.observe(styleNode, { childList: true }); } // 具体CSS规则重写方式 private rewrite(rules: CSSRule[], prefix: string = '') { // 。。。。。。 // 这里省略其实现方式,整体实现思路简单但步骤很繁琐,主要就是对字符串的正则判断和替换修改。 // 1. 对于根选择器(html/body/:root等),直接将其替换为prefix // 2. 对于其它选择器,将prefix放在最前面( selector1 selector2, selector3 =》 prefix selector1 selector2,prefix selector3) }

可以看到,qiankun通过为子应用的外层包裹元素注入属性并将子应用全部样式的作用范围都限制在该包裹元素下(通过添加指定的属性选择器作为前缀)实现了scoped样式沙箱隔离。需要注意的是,如果用户在运行时对内联样式进行修改,qiankun是可以侦测到并帮助用户限制其作用范围,但如果用户在运行时引入了新的外联样式或者自行创建了新的内联标签,那么qiankun并不会做出反应,相关的CSS规则还是可能会污染全局样式。

五、通信方式

对于微前端来说,除了应用间的隔离外,应用间的通信也是非常重要的部分。这里,single-spa提供了从主应用向子应用传递customProps的方式实现了最基础的参数传递。但是真实的开发场景需要的信息传递是非常复杂的,静态的预设参数传递只能起到很小的作用,我们还需要一种更加强大的通信机制来帮助我们开发应用。

这里,qiankun在框架内部预先设计实现了完善的发布订阅模式,降低了开发者的上手门槛。我们首先来看一下qiankun中的通信是如何进行的。

// ------------------主应用------------------ import { initGlobalState, MicroAppStateActions } from 'qiankun'; // 初始化 state const actions: MicroAppStateActions = initGlobalState(state); // 在当前应用监听全局状态,有变更触发 callback actions.onGlobalStateChange((state, prev) => { // state: 变更后的状态; prev 变更前的状态 console.log(state, prev); }); // 按一级属性设置全局状态,微应用中只能修改已存在的一级属性 actions.setGlobalState(state); // 移除当前应用的状态监听,微应用 umount 时会默认调用 actions.offGlobalStateChange(); // ------------------子应用------------------ // 从生命周期 mount 中获取通信方法,使用方式和 master 一致 export function mount(props) { props.onGlobalStateChange((state, prev) => { // state: 变更后的状态; prev 变更前的状态 console.log(state, prev); }); props.setGlobalState(state); }

接下来,让我们一起来看一下它是如何实现的。

import { cloneDeep } from 'lodash'; import type { OnGlobalStateChangeCallback, MicroAppStateActions } from './interfaces'; // 全局状态 let globalState: Record<string, any> = {}; // 缓存相关的订阅者 const deps: Record<string, OnGlobalStateChangeCallback> = {}; // 触发全局监听 function emitGlobal(state: Record<string, any>, prevState: Record<string, any>) { Object.keys(deps).forEach((id: string) => { if (deps[id] instanceof Function) { // 依次通知订阅者 deps[id](cloneDeep(state), cloneDeep(prevState)); } }); } // 初始化 export function initGlobalState(state: Record<string, any> = {}) { if (state === globalState) { console.warn('[qiankun] state has not changed!'); } else { const prevGlobalState = cloneDeep(globalState); globalState = cloneDeep(state); emitGlobal(globalState, prevGlobalState); } // 返回相关方法,形成闭包存储相关状态 return getMicroAppStateActions(`global-${+new Date()}`, true); } export function getMicroAppStateActions(id: string, isMaster?: boolean): MicroAppStateActions { return { /** * onGlobalStateChange 全局依赖监听 * * 收集 setState 时所需要触发的依赖 * * 限制条件:每个子应用只有一个激活状态的全局监听,新监听覆盖旧监听,若只是监听部分属性,请使用 onGlobalStateChange * * 这么设计是为了减少全局监听滥用导致的内存爆炸 * * 依赖数据结构为: * { * {id}: callback * } * * @param callback * @param fireImmediately 是否立即执行callback */ onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) { if (!(callback instanceof Function)) { console.error('[qiankun] callback must be function!'); return; } if (deps[id]) { console.warn(`[qiankun] '${id}' global listener already exists before this, new listener will overwrite it.`); } / 注册订阅 deps[id] = callback; if (fireImmediately) { const cloneState = cloneDeep(globalState); callback(cloneState, cloneState); } }, /** * setGlobalState 更新 store 数据 * * 1. 对输入 state 的第一层属性做校验,只有初始化时声明过的第一层(bucket)属性才会被更改 * 2. 修改 store 并触发全局监听 * * @param state */ setGlobalState(state: Record<string, any> = {}) { if (state === globalState) { console.warn('[qiankun] state has not changed!'); return false; } const changeKeys: string[] = []; const prevGlobalState = cloneDeep(globalState); globalState = cloneDeep( Object.keys(state).reduce((_globalState, changeKey) => { if (isMaster || _globalState.hasOwnProperty(changeKey)) { changeKeys.push(changeKey); return Object.assign(_globalState, { [changeKey]: state[changeKey] }); } console.warn(`[qiankun] '${changeKey}' not declared when init state!`); return _globalState; }, globalState), ); if (changeKeys.length === 0) { console.warn('[qiankun] state has not changed!'); return false; } // 触发全局监听 emitGlobal(globalState, prevGlobalState); return true; }, // 注销该应用下的依赖 offGlobalStateChange() { delete deps[id]; return true; }, }; }

可以看到在initGlobalState函数的执行中完成了一个发布订阅模式的创建工作,并返回了相关的订阅/发布/注销方法。接着qiankun将这些返回的方法通过生命周期函数mount传递给子应用,这样子应用就能够拿到并使用全局状态了,从而应用间的通信就得以实现了。此外offGlobalStateChange会在子应用unmount时自动调用以解除该子应用的订阅,避免内存泄露。

六、结语

qiankun在single-spa的基础上进行了二次封装,分别从子应用加载方式、应用间沙箱隔离、应用间通信这三个方面着手,通过自己的方式降低了用户的使用门槛,简便了微前端项目的开发改造成本,从而成为目前为止最为流行的微前端框架。

优化点 single-spa qiankun
子应用加载方式 用户自行编码配置子应用加载方式 用户只需配置子应用入口URL
应用间沙箱隔离 无隔离机制 内置了三种JS沙箱和两种CSS沙箱
应用间通信 主应用通过customProps向子应用传递静态参数 内置了一整套基于发布订阅的通信模式
「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论