介绍乾坤
什么是乾坤?
介绍乾坤的概念和背景
乾坤(qiankun)是一款成熟的微前端框架,由蚂蚁金服推出。它基于single-spa进行二次开发,旨在帮助开发者更简单、无痛地构建一个生产可用的微前端架构系统。
它支持在一个主应用中加载和运行多个子应用,每个子应用都是一个独立的单页面应用(SPA)。
什么是微前端?
我们先来了解一下什么是微前端。
-
微前端的定义:
-
微前端是一种前端架构风格,它将一个大型应用拆分成多个更小、更易维护的子应用,这些子应用可以独立开发、部署和运行。
-
-
微前端的核心思想:
-
每个子应用是独立的
-
可以自由选择技术栈
-
独立部署和升级
-
在浏览器中集成
-
-
微前端的优势:
-
提高开发效率:各团队可以独立开发,不受其他团队的影响
-
方便技术升级:可以在不同的子应用中逐步引入新技术,而不用一次性重构整个系统
-
增加代码可维护性:更小的代码库意味着更少的耦合和更容易的维护
-
独立部署:不同子应用可以独立发布和更新,降低部署风险
-
-
微前端的适用场景:
-
大型企业级应用
-
多团队协作开发的项目
-
需要逐步升级技术栈的老项目
-
需要高可维护性和灵活性的项目
-
-
实践环节:
-
讨论实际项目中的痛点,分析这些痛点如何通过微前端架构来解决
-
-
微前端实现的四种常见方式:
-
Iframe:
-
优点:完全隔离,简单易用
-
缺点:性能差,体验不好,通信复杂
-
-
基于Web Components:
-
优点:标准化,兼容性好
-
缺点:浏览器支持度不一,学习成本高
-
-
基于JavaScript的微前端框架(如Single-SPA、Qiankun):
-
优点:灵活性高,生态丰富
-
缺点:需要一定的学习成本和配置
-
-
Server-side Composition:
-
优点:前后端分离,后端统一渲染
-
缺点:复杂度高,适用于特定场景
-
为什么选择乾坤?
-
上手简单,文档完善
-
支持多种框架和技术栈
-
强大的沙箱机制和路由管理
基本概念
乾坤的基本架构:
-
主应用(基座应用):负责微应用的加载和管理
-
子应用:独立开发和部署的前端应用
主应用和子应用的概念
主应用概念
-
角色与功能:主应用充当容器的角色,负责管理和控制子应用的生命周期、路由、状态等信息。它负责注册和加载子应用,并在适当的时机将子应用挂载到预留的位置。
-
路由管理:主应用通过劫持前端路由,实现不同子应用之间的隔离和通信。当路由匹配到子应用时,主应用会加载子应用的资源,并将其挂载到指定的位置。
-
通信机制:主应用提供了一套完整的应用通信机制,可以通过props向子应用传递数据,也可以接收子应用发送的消息。
子应用概念
-
独立性:子应用是独立的小型前端应用,可以独立开发、测试、部署。它们可以使用不同的技术栈和框架,从而提高了整个应用的灵活性和可扩展性。
-
JS沙箱:为了保证子应用的独立性,Qiankun为每个子应用创建了一个JS沙箱。这个沙箱可以隔离子应用的全局变量、样式等,防止不同子应用之间的污染。
-
样式隔离:除了JS沙箱外,Qiankun还提供了样式隔离的功能。通过为每个子应用添加唯一的样式前缀,可以确保子应用的样式不会影响到其他应用。
-
通信机制:子应用可以通过特定的方式向主应用发送消息,实现与主应用之间的双向通信。同时,子应用也可以接收主应用传递的数据。
注意事项
确保主应用和子应用的路由配置不会冲突,即子应用的路由前缀(activeRule)在主应用中是唯一的。
子应用通常不需要关心自己的 base 配置,因为路由是由主应用来管理的。
上手实践
主应用
-
在主应用的入口文件(通常是main.js)中,引入并注册乾坤。
-
在项目根目录下使用npm或yarn安装qiankun。
-
在主应用中注册微应用
import { registerMicroApps, start } from 'qiankun'; // 注册微应用 registerMicroApps([ { name: 'vueApp', // 微应用名称 entry: '//localhost:8081', // 微应用入口地址 container: '#container', // 微应用挂载的DOM容器 activeRule: '/app-vue', // 微应用激活的路由规则 }, // 可以继续添加其他微应用 ]); // 启动乾坤 start();
-
当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑,所有 activeRule 规则匹配上的微应用就会被插入到指定的 container 中,同时依次调用微应用暴露出的生命周期钩子。
如果微应用不是直接跟路由关联的时候,你也可以选择手动加载微应用的方式:
import { loadMicroApp } from 'qiankun';
loadMicroApp({
name: 'app',
entry: '//localhost:7100',
container: '#container',
});
子应用挂载区域:
-
在主应用的模板中,需要有一个与 container 配置相对应的 DOM 元素作为子应用的挂载点。
<div id="container"></div>
微应用
微应用不需要额外安装任何其他依赖即可接入 qiankun 主应用。
-
在主应用项目的同级目录下,创建微应用的目录,并分别初始化每个微应用。
1.新增 public-path.js 文件,用于修改运行时的 publicPath。
注意:运行时的 publicPath 和构建时的 publicPath 是不同的,两者不能等价替代。
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
2.微应用建议使用 history 模式的路由,需要设置路由 base,值和它的 activeRule 是一样的。
3.在入口文件最顶部引入 public-path.js,修改并导出三个生命周期函数。
4.修改 webpack 打包,允许开发环境跨域和 umd 打包。
导出相应的生命周期钩子具体操作:
-
在每个微应用的入口文件中,导出微应用的生命周期钩子函数,如bootstrap、mount、unmount。
-
配置微应用的路由和视图,确保能够正确响应主应用的激活规则。
/** bootstrap - 初始化子应用
* bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
* 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
*/
export async function bootstrap() {
console.log('react app bootstraped');
}
/** mount - 挂载子应用
* 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
*/
export async function mount(props) {
ReactDOM.render(<App />, props.container ? props.container.querySelector('#root') : document.getElementById('root'));
}
/** unmount - 卸载子应用
* 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
*/
export async function unmount(props) {
ReactDOM.unmountComponentAtNode(
props.container ? props.container.querySelector('#root') : document.getElementById('root'),
);
}
/**
* 可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效
*/
export async function update(props) {
console.log('update props', props);
}
配置微应用的打包工具具体操作:
微应用的打包工具需要增加如下配置:
webpack v5:
const packageName = require('./package.json').name;
module.exports = {
output: {
library: `${packageName}-[name]`,
libraryTarget: 'umd',
chunkLoadingGlobal: `webpackJsonp_${packageName}`,
},
};
webpack v4:
const packageName = require('./package.json').name;
module.exports = {
output: {
library: `${packageName}-[name]`,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${packageName}`,
},
};
启动项目
-
分别启动主应用和各个微应用,确保它们的端口不冲突。
-
访问主应用的地址,通过路由切换来查看和测试微应用的加载和展示情况。
配置跨域和代理
-
如果主应用和微应用部署在不同的域名或端口下,可能需要配置跨域访问。这可以通过在主应用的开发服务器中设置代理来实现。
在Vue CLI项目中,可以在vue.config.js文件中配置devServer的proxy选项来设置代理规则。
核心功能如何实现--拆解源码
qiankun 的原理简单说:
1.监控路由变化;2.匹配子应用;3.加载子应用;4.渲染子应用
监控路由变化的实现是 通过监听hashChange和popState这两个原生事件来检测路由变化的。
注册子应用
qiankun/src/apis.ts at master · umijs/qiankun · GitHub
registerMicroApps 函数的作用是注册子应用,并且在子应用激活时,创建运行沙箱,在不同阶段调用不同的生命周期钩子函数。
registerApplication 方法是 single-spa 中注册子应用的核心函数。
该函数有四个参数,分别是:
-
name(子应用的名称,子应用之间必须确保唯一)
-
回调函数(activeRule 激活时调用)
-
activeRule(子应用的激活规则)
-
props(主应用需要传递给子应用的数据)
简单理解为注册子应用, 在符合 activeRule 激活规则时将会激活子应用,执行回调函数,返回一些生命周期钩子函数。
子应用的加载
loadApp函数 (代码太长了,放个链接)
qiankun/src/loader.ts at master · umijs/qiankun · GitHub
主要完成了以下几件事:
1、通过 HTML Entry 的方式远程加载微应用,fetch得到微应用的 html 模版(首屏内容)、JS 脚本执行器、其他静态资源路径。
2、实现样式隔离,shadow DOM 或者 scoped css 两种方式
3、渲染微应用
4、创建运行时沙箱,JS 沙箱、样式沙箱
5、合并沙箱传递出来的 生命周期方法、用户传递的生命周期方法、框架内置的生命周期方法,将这些生命周期方法统一整理,导出一个生命周期对象。
供 single-spa 的 registerApplication 方法使用,这个对象就相当于使用 single-spa 时你的微应用导出的那些生命周期方法,只不过 qiankun 额外填了一些生命周期方法,做了一些事情。
6、给微应用注册通信方法并返回通信方法,然后会将通信方法通过 props 注入到微应用
获取子应用资源 - import-html-entry
import-html-entry 是一个用于在qiankun中动态加载子应用 HTML 入口文件的库。
import-html-entry 帮助我们在运行时动态地加载子应用的 HTML 入口文件,并从 HTML 文件中提取出子应用所需的资源(如 CSS、JS 等)。
qiankun 如何通过 import-html-entry 加载子应用资源的详细步骤:
1.子应用注册:在主应用中,通过 qiankun 的 registerMicroApps 方法注册子应用,其中需要提供子应用的 HTML 入口文件的 URL。
2.加载 HTML 入口文件:当需要加载某个子应用时,qiankun 会通过 import-html-entry库 fetch来加载子应用的 HTML 入口文件(这就是为什么需要子应用资源允许跨域)。这样可以确保子应用的资源得到正确加载,并在加载完成后进行处理。
3.解析 HTML 入口文件:一旦 HTML 入口文件加载完成,import-html-entry 将解析该文件的内容,会通过一大堆正则去匹配提取出子应用的 JavaScript 和 CSS 资源的 URL。
4.动态加载资源:import-html-entry 使用动态创建 <script> 和 <link> 标签的方式,按照正确的顺序加载子应用的 JavaScript 和 CSS 资源。
5.创建沙箱环境:在加载子应用的 JavaScript 资源时,import-html-entry 会创建一个沙箱环境(sandbox),用于隔离子应用的全局变量和运行环境,防止子应用之间的冲突和污染。
6.返回子应用入口js 脚本文件:最后,import-html-entry 返回一个可以加载子应用的 JavaScript 模块。这个模块通常是一个包含子应用初始化代码的函数,可以在主应用中调用以加载和启动子应用。
主应用挂载子应用 HTML 模板
CSS隔离
开启沙箱的情况下,qiankun 将会自动隔离微应用之间的样式,但是无法在主应用与微应用之间样式隔离(或者多实例场景的子应用样式隔离),可以通过手动的方式确保主应用与微应用之间的样式隔离。比如给主应用的所有样式添加一个前缀。
官方文档API中如下:
start(Options?)
prefetch:可选,是否开启预加载
sandbox 可选,是否开启沙箱,默认为 true。
值可以是 boolean | { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean }
1. 严格沙箱
当配置为 { strictStyleIsolation: true } 时表示开启严格的样式隔离模式。
这种模式下 qiankun 会为每个微应用(包括 HTML、CSS 和 JavaScript)的容器包裹上一个 shadow dom 节点,与主文档中的其他内容隔离开来,从而确保微应用的样式不会对全局造成影响。
步骤(手动方式):
-
在子应用中创建shadow root。
-
将子应用的HTML、CSS和JavaScript附加到shadow root上。
缺点:
-
由于Shadow DOM包裹子应用的根节点,从而实现 CSS 的真正隔离,所以可能会影响子应用中的某些全局样式或第三方库,因此需要谨慎使用。
1.子应用的弹窗、抽屉、popover因找不到主应用的body会丢失,或跑到整个屏幕外
2.主应用不方便去修改子应用的样式
源码实现:在 createElement 方法中通过 shadow dom 来实现。
function createElement(
appContent: string,
strictStyleIsolation: boolean,
scopedCSS: boolean,
appInstanceId: string,
): HTMLElement {
// 创建一个 div 元素
const containerElement = document.createElement('div');
// 将字符串模版 appContent 设置为 div 的innerHTML
containerElement.innerHTML = appContent;
// appContent always wrapped with a singular div
const appElement = containerElement.firstChild as HTMLElement;
// 如果开启了严格的样式隔离,则将 appContent 的子元素(微应用的入口模版)用 shadow dom 包裹,以达到微应用之间样式严格隔离的目的
if (strictStyleIsolation) {
if (!supportShadowDOM) {
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;
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;
}
}
if (scopedCSS) {
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;
}
shadow dom 是浏览器原生提供的一种能力,在过去的很长一段时间里,浏览器用它来封装一些元素的内部结构。
以一个有着默认播放控制按钮的 <video> 元素为例,实际上,在它的 Shadow DOM 中,包含来一系列的按钮和其他控制器。
Shadow DOM 标准允许你为你自己的元素(custom element)维护一组 Shadow DOM。具体内容可查看 shadow DOM https://developer.mozilla.org/zh-CN/docs/Web/API/Web_components/Using_shadow_DOM
2.实验性沙箱
除此以外,qiankun 还提供了一个实验性的样式隔离特性,当 experimentalStyleIsolation 被设置为 true 时,qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围,因此改写后的代码会表达类似为如下结构:
// 假设应用名是 react16
.app-main {
font-size: 14px;
}
div[data-qiankun-react16] .app-main {
font-size: 14px;
}
缺点:
子应用的弹窗、抽屉、popover因插入到了主应用的body,所以导致样式丢失或应用了主应用了样式
源码实现:
// handle case:
// @media screen and (max-width: 300px) {}
private ruleMedia(rule: CSSMediaRule, prefix: string) {
const css = this.rewrite(arrayify(rule.cssRules), prefix);
return `@media ${rule.conditionText || rule.media.mediaText} {${css}}`;
}
// handle case:
// @supports (display: grid) {}
private ruleSupport(rule: CSSSupportsRule, prefix: string) {
const css = this.rewrite(arrayify(rule.cssRules), prefix);
return `@supports ${rule.conditionText || rule.cssText.split('{')[0]} {${css}}`;
}
}
let processor: ScopedCSS;
export const QiankunCSSRewriteAttr = 'data-qiankun';
/** 做了两件事:
* 实例化 processor = new ScopedCss(),真正处理样式选择器的地方
* 生成样式前缀 `div[data-qiankun]=${appName}`
*/
export const process = (
appWrapper: HTMLElement,
stylesheetElement: HTMLStyleElement | HTMLLinkElement,
appName: string,
): void => {
// lazy singleton pattern 单例模式
if (!processor) {
processor = new ScopedCSS();
}
// 目前支持 style 标签
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]=${appName}`
const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;
/**
* 实际处理样式的地方
* 拿到样式节点中的所有样式规则,然后重写样式选择器
* 含有根元素选择器的情况:用前缀替换掉选择器中的根元素选择器部分,
* 普通选择器:将前缀插到第一个选择器的后面
*/
processor.process(stylesheetElement, prefix);
}
};
qiankun对于CSS的隔离,还可以主要通过以下几种方式,以确保主应用和子应用之间的样式不会相互干扰:
1.Shadow DOM:
-
原理:Shadow DOM 提供了一种封装 DOM 组件的方法,组件的内容(包括 HTML、CSS 和 JavaScript)被封装在 shadow root 中,与主文档中的其他内容隔离开来。
-
步骤(手动方式):
-
在子应用中创建shadow root。
-
将子应用的HTML、CSS和JavaScript附加到shadow root上。
-
注意:由于Shadow DOM包裹子应用的根节点,从而实现 CSS 的真正隔离,所以可能会影响子应用中的某些全局样式或第三方库,因此需要谨慎使用。
2.CSS Modules:
-
原理:CSS Modules 是一种将 CSS 类名转换为局部作用域的技术,通过为类名添加唯一的哈希值后缀,确保每个组件的样式是独立的。
-
子应用可以使用 CSS Modules 来编写样式,这样即使在同一个页面中运行多个子应用,也不会因为类名冲突而影响到彼此。
-
步骤:
-
在构建工具(如Webpack)中配置CSS Modules。
-
在子应用的CSS文件中使用CSS Modules的语法。
-
示例:在React中使用CSS Modules,你需要安装css-loader(可能已包含在Webpack配置中),并在组件中引入样式文件:
-
import styles from './MyComponent.module.css'; function MyComponent() { return <div className={styles.myClassName}>...</div>; }
-
3.BEM (Block Element Modifier) 约定项目前缀:
-
原理:BEM 是一种 CSS 命名约定,它通过在类名中增加命名空间(Block)来避免类名冲突。
-
主应用和子应用可以约定使用不同的命名空间,以确保各自的样式不会相互干扰。
-
步骤:
约定一个命名规范,例如block-name__element-name--modifier-name。在子应用的CSS文件中遵循这个命名规范。
示例:.my-block__my-element--my-modifier { /* styles here */}
4.experimentalStyleIsolation:
-
原理:这是 qiankun 提供的一个配置项,用于给子应用的样式选择器添加自定义的前缀(如 data-qiankun-app-name),从而避免样式冲突。
-
步骤:当开启 experimentalStyleIsolation 时,qiankun 会自动处理子应用中的样式标签,给选择器添加前缀。
-
示例配置:
registerMicroApps([
// ...其他配置
], {
// 其他配置项
sandbox: {
experimentalStyleIsolation: true
}
});
start();
5.CSS-in-JS:
-
原理:这是一种将 CSS 样式直接写在 JavaScript 中的技术,如 styled-components、emotion 或react-jss等库,可以在组件级别上定义样式,从而实现样式的局部化和隔离。。
-
步骤:
安装并配置CSS-in-JS库(如styled-components)。
在组件中直接使用CSS-in-JS语法编写样式。
-
示例(以styled-components为例):
import styled from 'styled-components';
const MyButton = styled.button`
color: green;
margin: 5px 0;
`;
function MyComponent() {
return <MyButton>Click me</MyButton>;
}
使用 CSS-in-JS 可以确保每个组件的样式都是局部的,不会影响到其他组件。但需要注意的是,这可能会增加打包体积和运行时性能开销。
JS隔离
qiankun对于JavaScript的隔离主要通过沙箱机制实现,具体有以下几种沙箱:
-
legacySandBox
-
proxySandBox
-
snapshotSandBox
其中 legacySandBox、proxySandBox 是基于 Proxy API 来实现的,在不支持 Proxy API 的低版本浏览器中,会降级为 snapshotSandBox。
legacySandBox 仅用于 singular 单实例模式,而多实例模式会使用 proxySandBox。
LegacySandbox(支持单应用的代理沙箱):
(随着ES6的普及,利用Proxy可以比较良好地解决性能问题。LegacySandbox使用Proxy来实现和快照沙箱相似的功能,但性能更好。
由于会污染全局的window对象,LegacySandbox也仅仅允许页面同时运行一个微应用。)
legacySandBox 的本质上还是操作 window 对象,但是他会存在三个状态池,分别用于子应用卸载时还原主应用的状态和子应用加载时还原子应用的状态:
-
addedPropsMapInSandbox(/** 沙箱期间新增的全局变量 */): 存储在子应用运行时期间新增的全局变量,用于卸载子应用时还原主应用全局变量;
-
modifiedPropsOriginalValueMapInSandbox(/** 沙箱期间更新的全局变量 */):存储在子应用运行期间更新的全局变量,用于卸载子应用时还原主应用全局变量;
-
currentUpdatedPropsValueMap(** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */):存储子应用全局变量的更新,用于运行时切换后还原子应用的状态;
const rawWindow = window;
const fakeWindow = Object.create(null) as Window;
// 创建对fakeWindow的劫持,fakeWindow就是我们传递给自执行函数的window对象
const proxy = new Proxy(fakeWindow, {
set(_: Window, p: PropertyKey, value: any): boolean {
// 运行时的判断
if (sandboxRunning) {
// 如果window对象上没有这个属性,那么就在状态池中记录状态的新增;
if (!rawWindow.hasOwnProperty(p)) {
addedPropsMapInSandbox.set(p, value);
// 如果当前 window 对象存在该属性,并且状态池中没有该对象,那么证明改属性是运行时期间更新的值,记录在状态池中用于最后window对象的还原
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
const originalValue = (rawWindow as any)[p];
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
// 记录全局对象修改值,用于后面子应用激活时还原子应用
currentUpdatedPropsValueMap.set(p, value);
(rawWindow as any)[p] = value;
return true;
}
return true;
},
get(_: Window, p: PropertyKey): any {
// iframe的window上下文
if (p === "top" || p === "window" || p === "self") {
return proxy;
}
const value = (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
},
});
// 子应用沙箱激活
active() {
// 通过状态池,还原子应用上一次写在前的状态
if (!this.sandboxRunning) {
this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
}
this.sandboxRunning = true;
}
// 子应用沙箱卸载
inactive() {
// 还原运行时期间修改的全局变量
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
// 删除运行时期间新增的全局变量
this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));
this.sandboxRunning = false;
}
所以,总结起来,legacySandBox 还是会操作 window 对象,但是他通过激活沙箱时还原子应用的状态,卸载时还原主应用的状态来实现沙箱隔离的
ProxySandbox(支持多应用的代理沙箱):
原理:激活沙箱后,每次对window取值的时候,先从自己沙箱环境的fakeWindow里面找,如果不存在,就从rawWindow(外部的window)里去找;当对沙箱内部的window对象赋值的时候,会直接操作fakeWindow,而不会影响到rawWindow。
因为 proxySandBox 不直接操作 window,所以在激活和卸载的时候也不需要操作状态池更新 / 还原主子应用的状态了。
相比较看来,proxySandBox 是现阶段 qiankun 中最完备的沙箱模式,完全隔离了主子应用的状态,不会像 legacySandBox 模式下在运行时期间仍然会污染 window, 支持多个子应用同时加载。
在 qiankun 中,proxySandBox 用于多实例场景。
什么是多实例场景?一般我们的中后台系统同一时间只会加载一个子应用的运行时。但是也存在这样的场景,某一个子应用聚合了多个业务域,这样的子应用往往会经历多个团队的多个同学共同维护自己的业务模块,这时候便可以采用多实例的模式聚合子模块(这种模式也可以叫微前端模块)。
下面是源码中创建子应用 window 的副本:
function createFakeWindow(global: Window) {
// 这里qiankun给我们了一个知识点:在has和check的场景下,map有着更好的性能 :)
const propertiesWithGetter = new Map<PropertyKey, boolean>();
const fakeWindow = {} as FakeWindow;
// 从window对象拷贝不可配置的属性
// 举个例子:window、document、location这些都是挂在Window上的属性,他们都是不可配置的
// 拷贝出来到fakeWindow上,就间接避免了子应用直接操作全局对象上的这些属性方法
Object.getOwnPropertyNames(global)
.filter((p) => {
const descriptor = Object.getOwnPropertyDescriptor(global, p);
// 如果属性不存在或者属性描述符的configurable的话
return !descriptor?.configurable;
})
.forEach((p) => {
const descriptor = Object.getOwnPropertyDescriptor(global, p);
if (descriptor) {
// 判断当前的属性是否有getter
const hasGetter = Object.prototype.hasOwnProperty.call(
descriptor,
"get"
);
// 为有getter的属性设置查询索引
if (hasGetter) propertiesWithGetter.set(p, true);
// freeze the descriptor to avoid being modified by zone.js
// zone.js will overwrite Object.defineProperty
// const rawObjectDefineProperty = Object.defineProperty;
// 拷贝属性到fakeWindow对象上
rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
}
});
return {
fakeWindow,
propertiesWithGetter,
};
}
源码中 proxySandBox 的 getter/setter:
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 (sandboxRunning) {
// 在fakeWindow上设置属性值
target[p] = value;
// 记录属性值的变更
updatedValueSet.add(p);
// SystemJS属性拦截器
interceptSystemJsProps(p, value);
return true;
}
// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
return true;
},
get(target: FakeWindow, p: PropertyKey): any {
if (p === Symbol.unscopables) return unscopables;
// 避免window.window 或 window.self 或window.top 穿透sandbox
if (p === "top" || p === "window" || p === "self") {
return proxy;
}
if (p === "hasOwnProperty") {
return hasOwnProperty;
}
// 批处理场景下会有场景使用,这里就不多赘述了
const proxyPropertyGetter = getProxyPropertyGetter(proxy, p);
if (proxyPropertyGetter) {
return getProxyPropertyValue(proxyPropertyGetter);
}
// 取值
const value = propertiesWithGetter.has(p)
? (rawWindow as any)[p]
: (target as any)[p] || (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
},
// 还有一些对属性做操作的代码
});
源码中 proxySandBox 的 激活 / 卸载:
active() {
this.sandboxRunning = true;
// 当前激活的子应用沙箱实例数量
activeSandboxCount++;
}
inactive() {
clearSystemJsProps(this.proxy, --activeSandboxCount === 0);
this.sandboxRunning = false;
}
SnapshotSandbox(快照沙箱):
最后一种沙箱就是 snapshotSandBox,在不支持 Proxy 的场景下会降级为 snapshotSandBox。
原理:在激活沙箱时,将window的快照信息保存起来。当修改window的数据时,记录这些修改。在退出沙箱时,将修改过的信息还原,并将window恢复到初始进入沙箱前的状态。
**
* 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
*/
export default class SnapshotSandbox implements SandBox {
proxy: WindowProxy;
name: string;
type: SandBoxType;
sandboxRunning = true;
private windowSnapshot!: Window;
private modifyPropsMap: Record<any, any> = {};
private deletePropsSet: Set<any> = new Set();
constructor(name: string) {
this.name = name;
this.proxy = window;
this.type = SandBoxType.Snapshot;
}
active() {
// 记录当前快照
this.windowSnapshot = {} as Window;
// iter方法就是遍历目标对象的属性然后分别执行回调函数
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});
// 恢复之前的变更
Object.keys(this.modifyPropsMap).forEach((p: any) => {
window[p] = this.modifyPropsMap[p];
});
// 删除之前删除的属性
this.deletePropsSet.forEach((p: any) => {
delete window[p];
});
this.sandboxRunning = true;
}
inactive() {
this.modifyPropsMap = {};
this.deletePropsSet.clear();
iter(window, (prop) => {
if (window[prop] !== this.windowSnapshot[prop]) {
// 记录变更,恢复环境
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop];
}
});
iter(this.windowSnapshot, (prop) => {
if (!window.hasOwnProperty(prop)) {
// 记录被删除的属性,恢复环境
this.deletePropsSet.add(prop);
window[prop] = this.windowSnapshot[prop];
}
});
if (process.env.NODE_ENV === 'development') {
console.info(
`[qiankun:sandbox] ${this.name} origin window restore...`,
Object.keys(this.modifyPropsMap),
this.deletePropsSet.keys(),
);
}
this.sandboxRunning = false;
}
patchDocument(): void {}
}
缺点:需要遍历window上的所有属性,性能较差。
启用沙箱
-
默认设置:qiankun默认使用的是LegacySandbox沙箱。
-
配置更改:可以通过配置来更改使用的沙箱类型,例如使用ProxySandbox或SnapshotSandbox。
-
自动降级:当发现浏览器不支持Proxy时,qiankun会自动优雅降级使用SnapshotSandbox。
qiankun通过JS沙箱机制,有效地隔离了子应用之间的全局变量和事件,防止了全局污染。
同时,提供了多种沙箱类型供开发者选择,以适应不同的使用场景和浏览器环境。这些沙箱机制确保了微前端架构下多个子应用能够和谐共存、互不干扰。
乾坤框架高级使用
动态加载子应用
我们可能会遇到需要根据用户权限或其他条件动态加载子应用的情况。乾坤框架支持动态加载子应用,通过 loadMicroApp 方法可以实现。
1.配置主应用
在主应用的 src/micro-apps/registerMicroApps.js 中,添加 loadMicroApp 方法的使用示例:
import { loadMicroApp } from 'qiankun';
const app = loadMicroApp({
name: 'app1',
entry: '//localhost:7101',
container: '#dynamicContainer',
props: { someProp: 'someValue' },
});
// 可控制 app 的挂载与卸载
app.mount();
app.unmount();
2.修改主应用的 App.vue
<template>
<div id="app">
<nav>
<ul>
<li>
<router-link to="/app1">App1</router-link>
</li>
<li>
<router-link to="/app2">App2</router-link>
</li>
</ul>
</nav>
<div id="container"></div>
<button @click="loadApp1">Load App1</button>
<div id="dynamicContainer"></div>
</div>
</template>
<script>
import { loadMicroApp } from 'qiankun';
export default {
name: 'App',
methods: {
loadApp1() {
const app = loadMicroApp({
name: 'app1',
entry: '//localhost:7101',
container: '#dynamicContainer',
props: { someProp: 'someValue' },
});
app.mount();
},
},
};
</script>
子应用的独立运行与调试
为了提高开发效率,我们需要确保子应用可以独立运行和调试。这样可以避免在每次修改子应用时都需要重新启动主应用。
在子应用中添加独立运行的判断
修改子应用 app1 的 main.js:
// src/main.js
import './public-path';
import Vue from 'vue';
import App from './App.vue';
import router from './router';
Vue.config.productionTip = false;
let instance = null;
function render(props = {}) {
const { container } = props;
instance = new Vue({
router,
render: h => h(App),
}).$mount(container ? container.querySelector('#app') : '#app');
}
//window 的 __POWERED_BY_QIANKUN__ 属性为 true,在子应用中使用
//window.__POWERED_BY_QIANKUN__ 值判断是否运行在主应用容器中。
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
export async function bootstrap() {
console.log('Vue app1 bootstraped');
}
export async function mount(props) {
console.log('Vue app1 mount');
render(props);
}
export async function unmount() {
console.log('Vue app1 unmount');
instance.$destroy();
instance.$el.innerHTML = '';
instance = null;
}
乾坤框架的事件通信机制
乾坤框架提供了一套事件通信机制,允许主应用和子应用之间进行事件传递。主要通过 qiankun 提供的 initGlobalState 、onGlobalStateChange, setGlobalState 实现主应用的全局状态管理,然后默认会通过props将通信方法传递给子应用。
1.主应用中初始化全局状态
在主应用的 src/micro-apps/globalState.js 中:
import { initGlobalState } from 'qiankun';
const initialState = { user: 'admin' };
const actions = initGlobalState(initialState);
actions.onGlobalStateChange((state, prev) => {
console.log('main app state change:', state, prev);
});
export default actions;
2.在主应用中传递全局状态
在主应用的 src/micro-apps/registerMicroApps.js 中:
import actions from './globalState';
registerMicroApps([
{
name: 'app1',
entry: '//localhost:7101',
container: '#container',
activeRule: '/app1',
props: { actions },
},
{
name: 'app2',
entry: '//localhost:7102',
container: '#container',
activeRule: '/app2',
props: { actions },
},
]);
start();
3.子应用中使用全局状态
在子应用 app1 中,修改 main.js:
props默认会有onGlobalStateChange和setGlobalState两个api
// src/main.js
import './public-path';
import Vue from 'vue';
import App from './App.vue';
import router from './router';
Vue.config.productionTip = false;
let instance = null;
function render(props = {}) {
const { container, actions } = props;
if (actions) {
actions.onGlobalStateChange((state, prev) => {
console.log('app1 state change:', state, prev);
});
actions.setGlobalState({ user: 'app1' });
}
instance = new Vue({
router,
render: h => h(App),
}).$mount(container ? container.querySelector('#app') : '#app');
}
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
export async function bootstrap() {
console.log('Vue app1 bootstraped');
}
export async function mount(props) {
console.log('Vue app1 mount');
render(props);
}
export async function unmount() {
console.log('Vue app1 unmount');
instance.$destroy();
instance.$el.innerHTML = '';
instance = null;
}
父子应用通过onGlobalStateChange这个方法进行通信,这其实是一个发布-订阅的设计模式。
乾坤框架的插件机制
乾坤框架支持插件机制,可以通过插件扩展框架的功能。插件可以对框架的生命周期、应用注册、启动等过程进行自定义处理。
1.创建插件
在主应用的 src/plugins/qiankunPlugin.js 中:
// src/plugins/qiankunPlugin.js
const qiankunPlugin = {
beforeLoad: async (app) => {
console.log('before load', app);
},
beforeMount: async (app) => {
console.log('before mount', app);
},
afterMount: async (app) => {
console.log('after mount', app);
},
beforeUnmount: async (app) => {
console.log('before unmount', app);
},
afterUnmount: async (app) => {
console.log('after unmount', app);
},
};
export default qiankunPlugin;
2.使用插件
在主应用的 src/micro-apps/registerMicroApps.js 中:
import qiankunPlugin from '../plugins/qiankunPlugin';
registerMicroApps(
[
{
name: 'app1',
entry: '//localhost:7101',
container: '#container',
activeRule: '/app1',
},
{
name: 'app2',
entry: '//localhost:7102',
container: '#container',
activeRule: '/app2',
},
],
qiankunPlugin,
);
start();
性能优化技巧
微前端架构在提升开发效率和可维护性方面有很多优势,但也带来了一些性能问题和复杂性。这里我们介绍一些常见的性能优化方法和问题解决方案。
实践操作
1.懒加载子应用
使用 loadMicroApp 实现懒加载,只有在需要时才加载子应用。
2.减少跨应用通信
尽量减少主应用和子应用之间的通信频率,可以通过本地缓存等方法降低通信成本。
3.优化子应用的打包配置
确保子应用的打包体积尽可能小,使用 webpack 的 splitChunks 插件将公共依赖分离出来。
有些方法或工具类是所有子应用都需要用到的,每个子应用都copy一份肯定是不好维护的,所以可以抽取公共代码,根目录下新建一个common文件夹用于存放公共代码。
4.子应用支持独立开发
整个系统可能有N个子应用,如果启动整个系统可能会很慢很卡,而产品的某个需求可能只涉及到其中一个子应用,因此开发时只需启动涉及到的子应用即可,独立启动专注开发。
5.子应用独立仓库
子应用可能会越来越多,如果子应用和基座都集合在同一个git仓库,就会越来越臃肿。
子应用独立git仓库后,可以做到独立启动独立开发了,这时候又会遇到问题:开发环境都是独立的,无法一览整个应用的全貌。
要有一个整个项目对所有子应用git仓库的聚合管理才行,该聚合仓库要求做到能够一键install所有的依赖(包括子应用),一键启动整个项目。
这里提供三种方案:
-
使用git submodule。
-
使用git subtree。
-
单纯地将所有子仓库放到聚合目录下并.gitignore掉。 使用lerna管理。
6.解决子应用样式隔离问题
使用 CSS Modules 或 scoped 样式防止样式冲突。
部署与发布
同服务器部署
通常的做法是主应用部署在一级目录,微应用部署在二/三级目录。
微应用想部署在非根目录,在微应用打包之前需要做两件事:
1.必须配置 webpack 构建时的 publicPath 为目录名称
2.history 路由的微应用需要设置 base ,值为目录名称,用于独立访问时使用。
部署之后注意三点:
1.activeRule 不能和微应用的真实访问路径一样,否则在主应用页面刷新会直接变成微应用页面。
2.微应用的真实访问路径就是微应用的 entry,entry 可以为相对路径。
3.微应用的 entry 路径最后面的 / 不可省略,否则 publicPath 会设置错误,例如子项的访问路径是 http://localhost:8080/app1,那么 entry 就是 http://localhost:8080/app1/。
具体的部署有以下两种方式,选择其一即可。
方案 1:微应用都放在在一个特殊名称(不会和微应用重名)的文件夹下(建议使用)
需要设置微应用构建时的 publicPath 和 history 模式的路由 base,然后才能打包放到对应的目录里。
方案 2:微应用直接放在二级目录,但是设置特殊的 activeRule
基本操作和上面是一样的,只要保证 activeRule 和微应用的存放路径名不一样即可。
不同服务器部署
主应用和微应用部署在不同的服务器,使用 Nginx 代理访问
一般这么做是因为不允许主应用跨域访问微应用,做法就是将主应用服务器上一个特殊路径的请求全部转发到微应用的服务器上,即通过代理实现“微应用部署在主应用服务器上”的效果。
几个问题
1.qiankun为什么需要将子应用输出为umd?
qiankun架构下的子应用通过 webpack 的 umd 输出格式来做,让父应用在执行子应用的 js 资源时可以通过 eval,将 window 绑定到一个 Proxy 对象上,以此来防止污染全局变量,方便对脚本的 window 相关操作做劫持处理,达到子应用之间的脚本隔离。
为了让qiankun 拿到子应用export的生命周期函数,所以需要将子应用打包成 umd 格式。
2.为什么配置的publicPath?
publicPath 主要解决的是微应用动态载入的 脚本、样式、图片 等地址不正确的问题,不配置的话微应用加载的资源会 404。
public-path.js作用:当通过乾坤调用时动态的给webpack的public_path 赋予主应用的根路径。
3.微应用静态资源一定要支持跨域吗?
由于 qiankun 是通过 fetch 去获取微应用的引入的静态资源的,所以必须要求这些静态资源支持跨域。
4.原生js沙箱:shadowrealm
ECMAScript 中能实现 js 独立运行环境方式有以下几种:
1.iframe:每个 iframe 都是独立的运行环境,但 iframe 需要在页面创建元素,而且只能在浏览器端使用,且有较高的维护成本;
2.eval 或 Function:功能太过单一,需要更丰富完整的实现
3.nodejs 的 vm 模块:是隔离 js 运行环境的较好的方案,所以当前提案的实现也是参考了 vm 模块 4.Web Workers:同样不是 ECMAScript 自有能力,单独引进来使用或许成本太高
总之是目前实现 js 独立运行环境能力太过薄弱,需要打造成熟的语法体系,就有了 ShadowRealms
declare class ShadowRealm {
constructor();
importValue(specifier: string, bindingName: string):
Promise<PrimitiveValueOrCallable>;
evaluate(sourceText: string): PrimitiveValueOrCallable;
}
该类包含两个主要方法:
importValue:接收两个参数,返回一个是原始或可调用的 Promise 对象
evaluate:接收一个参数,返回原始值或可调用值
const sr = new ShadowRealm()
globalThis.a = 999
sr.evaluate("globalThis.a = 1000")
console.log(globalThis.a) // 999