微前端
- 微前端
- 基本知识
- 主要的微前端框架
- iframe
- 优点:
- 缺点:
- single-spa
- 示例
- 主应用
- spa-root-config.js
- microfrontend-layout.html
- index.ejs
- 子应用
- spa-react-app2.js
- root.component.js
- 修改路由
- spa-demo/microfrontend-layout.html
- spa-demo/react-app1/webpack.config.js
- spa-demo/react-app2/webpack.config.js
- spa-demo/react-app1/src/root.component.js
- 两个应用连接,将两个应用展示在同一个页面上
- spa-demo/microfrontend-layout.html
- 实现样式隔离
- 创建styles文件,并启动服务
- 子应用中写入
- 主应用的css策略也要改
- JS隔离
- 子应用中
- qiankun
- 使用
- 安装依赖
- 启动服务
- ShadowDom 影子DOM
- 示例:
- 使用shadowDom
- proxySandbox VS snapshotSandbox
- 全局弹窗
- 路由
- 预加载
- 通信方式
- EMP - 同架构应用的微前端
- 微前端基础知识介绍
- 常见框架
- 对比总结
微前端
基本知识
微前端,Micro-Frontends,多个前端组成的前端
后端比较早使用到微前端,和后端微服务结合
对于 Web服务 来讲,后端是一个又一个的接口
前端对接口之前的关联是不在意的
微服务,多人协作问题,版本迭代问题,服务整体稳定性问题
前端请求时候,通过在请求头中增加东西,后端识别后,将请求引导到新的服务上去
后端通过微服务将每个服务单独部署,给单个服务增加数量或者使用容器化的技术,将服务容量提升
将多个各自独立的单体应用服务共同打包聚合成一个单独应用的方式
一个web前端应用中,直接运行其他web应用
特点:
- 技术栈无关:子应用是react,vue,vue3的都是没有关系的,各个应用之间是完全独立(逻辑上)的
- 独立开发、部署、仓库都独立
- 增量升级:复杂的站点,看起来是一个页面,但是其中分了很多的模块,各个模块之前可能是完全不同部门来负责的,发布的应用可能导致整个页面挂掉,这肯定是不允许的,因此需要将不同部分负责的页面尽量是独立的。
- 状态隔离:运行时的数据状态是独立的,不包括子应用,主副应用通信这种情况
- 环境隔离:应用,css之间是隔离的
- 消息通信:应用之间通信降低沟通成本,有整体体验
- 依赖复用:应用是不同部门的,但是依赖是同一个版本的,是可以复用的。一般是使用 webpack 的 external 来进行复用的
主要的微前端框架
iframe
优点:
浏览器层面就直接支持,不需要搞其他任何开发就可以直接使用
不同的iframe之间是完全隔离的,各个iframe之间是完全不影响的,有自己的js线程,有自己的dom树,css样式树
浏览器对iframe本身有很多的安全限制,防止出现安全问题
缺点:
- URL 是不同步的
在浏览器的主应用中改变hashTak,各个iframe之间拿不到主应用的基本信息,页面刷新以下,各个iframe中的url就会自动回到副应用设置的初始值去 - UI不同步
弹窗,抽屉只能在当前iframe中去实现,没有办法做整个页面的抽屉和弹窗 - 完全隔离
各个iframe共享资源是不可能的,比如,主应用登录后,子应用想要免登录也是不可能的,禁止读取cookie等 - 速度
各个应用都要跑完一遍整个生命周期,域名解析,资源加载,loading,数据请求等全部都是自己独立的,无法实现资源复用,体验差
single-spa
用生命周期的概念实现各个子应用之间的加载,卸载,状态管理等等
子应用的调度,url的变化,各种事件的传递,各种函数的处理全都是根据生命周期来的
注册应用 -> url变化 -> app active,app激活状态,一个url对应着一个或多个子应用 -> 找到子应用 -> 执行子应用的生命周期钩子,life cycle
生命周期钩子:
bootstrap 状态初始化
mount 加载
unmount 卸载
应用分类:
- root-config 主应用:负责注册管理所有的子应用,还有整体的node管理
- app-parcel 子应用
示例
执行:
pnpx create-single-spa --moduleType root-config
将版本自动升级的符号去掉后安装,并且改下 webpack-config-single-spa-react
pnpm i
cd spa-demo
创建子应用1:
pnpx create-single-spa --moduleType app-parcel
也是一样将版本升级符号去掉
创建子应用2:react-app2,步骤和上面一样
重新强制安装
pnpm install --force --ignore-scripts
主应用
主应用启动的时候是可以配置预加载的,可以将子应用加载起来,但是不展示它,当要展示的时候,就会特别快
主应用目录:
spa-root-config.js
主应用启动的流程
import { registerApplication, start } from "single-spa";
import {constructApplications,constructRoutes,constructLayoutEngine,
} from "single-spa-layout";
import microfrontendLayout from "./microfrontend-layout.html";// 定义程序
const routes = constructRoutes(microfrontendLayout);
// 定义主应用程序
const applications = constructApplications({routes, //路由列表// 加载app应用程序loadApp({ name }) {return import(/* webpackIgnore: true */ name);},
});
const layoutEngine = constructLayoutEngine({ routes, applications });applications.forEach(registerApplication);
layoutEngine.activate();
start();
microfrontend-layout.html
主应用路由定义文件
<single-spa-router><!--This is the single-spa Layout Definition for your microfrontends.See https://single-spa.js.org/docs/layout-definition/ for more information.--><!-- Example layouts you might find helpful:<nav><application name="@org/navbar"></application></nav><route path="settings"><application name="@org/settings"></application></route>--><main><!-- 默认路由,也就是 / 这个路由 --><route default><!-- 执行 @single-spa/welcome 子应用 --><application name="@single-spa/welcome"></application></route></main>
</single-spa-router>
index.ejs
html的模板文档
将后续的子应用启动地址填入这里:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta http-equiv="X-UA-Compatible" content="ie=edge"><title>Root Config</title><meta http-equiv="Content-Security-Policy" content="default-src 'self' https: localhost:*; script-src 'unsafe-inline' 'unsafe-eval' https: localhost:*; connect-src https: localhost:* ws://localhost:*; style-src 'unsafe-inline' https:; object-src 'none';"><meta name="importmap-type" use-injector /><!-- If you wish to turn off import-map-overrides for specific environments (prod), uncomment the line below --><!-- More info at https://github.com/single-spa/import-map-overrides/blob/main/docs/configuration.md#domain-list --><!-- <meta name="import-map-overrides-domains" content="denylist:prod.example.com" /> --><!-- Shared dependencies go into this import map --><!-- 默认注册这个 --><script type="injector-importmap">{"imports": {"single-spa": "https://cdn.jsdelivr.net/npm/single-spa@6.0.3/lib/es2015/esm/single-spa.min.js"}}</script><link rel="preload" href="https://cdn.jsdelivr.net/npm/single-spa@6.0.3/lib/es2015/esm/single-spa.min.js" as="module"><!-- Add your organization's prod import map URL to this script's src --><!-- <script type="injector-importmap" src="/importmap.json"></script> --><!-- 如果是本地的话,还要注册这个 --><!-- 子应用在这里注册 --><% if (isLocal) { %><script type="injector-importmap">{"imports": {"@spa/root-config": "//localhost:9000/spa-root-config.js","@spa/react-app1": "http://localhost:8081/spa-react-app1.js","@spa/react-app2": "http://localhost:8080/spa-react-app2.js","@single-spa/welcome": "https://cdn.jsdelivr.net/npm/single-spa-welcome/dist/single-spa-welcome.min.js"}}</script><% } %><script src="https://cdn.jsdelivr.net/npm/import-map-overrides@5.1.1/dist/import-map-overrides.js"></script><script src="https://cdn.jsdelivr.net/npm/@single-spa/import-map-injector@2.0.1/lib/import-map-injector.js"></script>
</head>
<body><noscript>You need to enable JavaScript to run this app.</noscript><main></main><script>window.importMapInjector.initPromise.then(() => {import('@spa/root-config');});</script><import-map-overrides-full show-when-local-storage="devtools" dev-libs></import-map-overrides-full>
</body>
</html>
启动主应用:
显示的是 默认路由:@single-spa/welcome 这个页面
添加开发工具:
在控制台执行:
localStorage.setItem(‘devtools’,true)
子应用
子应用是不去自己启动自己的,只是告诉主应用有 bootstrap,mount,unmount 三个钩子,让主应用在合适的时机触发这三个钩子就行了
子应用代码:
spa-react-app2.js
向主应用提供的钩子
import React from "react";
import ReactDOMClient from "react-dom/client";
import singleSpaReact from "single-spa-react";
import Root from "./root.component";// singleSpaReact 创建整个应用
const lifecycles = singleSpaReact({React,ReactDOMClient,rootComponent: Root, //根组件// 捕获react错误errorBoundary(err, info, props) {// Customize the root error boundary for your microfrontend here.return null;},
});// 子应用入口导出这三个生命周期钩子,主应用调用mount方法,则会进入到rootComponent
export const { bootstrap, mount, unmount } = lifecycles;
子应用往外导出是导出 根组件 还是 这三个生命周期钩子?
single-spa 子应用来说,不会去主动启动自己的,只会告诉主应用是子应用有这三个生命周期钩子,你在合适的时机,触发这三个生命周期钩子就行了
主应用启动的时候,是可以配置预加载的,可以将子应用加载起来,但是不展示它,预加载的时候,会执行 bootstrap 方法
如果当前页面url变化了,发现 react-app1处于激活状态,那么主应用就会调用mount方法,将当前页面的控制权交给你
当前页面url又变化了,react-app1已经从激活状态变到非激活状态了,会留一个清理现场的时间,则会调用 unmount,清理js,全局变量等
root.component.js
实际业务代码
// 指定了一个组件
export default function Root(props) {return <section>{props.name} is mounted!</section>;
}
启动子应用:
pnpm start:standalone 主应用可不启动,想要单独启动子应用,自己做调试开发时候可以使用这个命令
正常启动
pnpm start
修改路由
spa-demo/microfrontend-layout.html
<single-spa-router><!--This is the single-spa Layout Definition for your microfrontends.See https://single-spa.js.org/docs/layout-definition/ for more information.--><!-- Example layouts you might find helpful:<nav><application name="@org/navbar"></application></nav><route path="settings"><application name="@org/settings"></application></route>--><main><route default><!-- <application name="@single-spa/welcome"></application> --><div>main app</div></route><route path="react-app1"><application name="@spa/react-app1"></application></route><route path="react-app2"><application name="@spa/react-app1"></application></route></main>
</single-spa-router>
spa-demo/react-app1/webpack.config.js
const { merge } = require("webpack-merge");
const singleSpaDefaults = require("webpack-config-single-spa-react");module.exports = (webpackConfigEnv, argv) => {const defaultConfig = singleSpaDefaults({orgName: "spa",projectName: "react-app1",webpackConfigEnv,argv,outputSystemJS: false,});// 去掉共享依赖delete defaultConfig.externals;return merge(defaultConfig, {// modify the webpack config however you'd like to by adding to this object});
};
同理,在 react-app2 的 webpack.config.js 中 也加上 删除共享依赖这句:
spa-demo/react-app2/webpack.config.js
const { merge } = require("webpack-merge");
const singleSpaDefaults = require("webpack-config-single-spa-react");module.exports = (webpackConfigEnv, argv) => {const defaultConfig = singleSpaDefaults({orgName: "spa",projectName: "react-app2",webpackConfigEnv,argv,outputSystemJS: false,});// 去掉共享依赖delete defaultConfig.externals;return merge(defaultConfig, {// modify the webpack config however you'd like to by adding to this object});
};
spa-demo/react-app1/src/root.component.js
export default function Root(props) {return <section>hello,{props.name} is mounted!</section>;
}
重启 react-app1,react-app2:
两个应用连接,将两个应用展示在同一个页面上
spa-demo/microfrontend-layout.html
<single-spa-router><!--This is the single-spa Layout Definition for your microfrontends.See https://single-spa.js.org/docs/layout-definition/ for more information.--><!-- Example layouts you might find helpful:<nav><application name="@org/navbar"></application></nav><route path="settings"><application name="@org/settings"></application></route>--><main><div style="display:flex;gap:20px;margin:10px 20px"><a href="/">main app</a><a href="/react-app1">react-app1</a><a href="/react-app2">react-app2</a><a href="/all">All MicroFrontends Apps</a></div><route default><!-- <application name="@single-spa/welcome"></application> --><div>main app</div></route><route path="react-app1"><application name="@spa/react-app1"></application></route><route path="react-app2"><application name="@spa/react-app1"></application></route><route path="all"><div style="border: 1px solid green;"><application name="@spa/react-app1"></application></div><div style="border: 1px solid cyan;margin-top: 50px;"><application name="@spa/react-app2"></application></div></route></main>
</single-spa-router>
实现样式隔离
添加依赖 single-spa-css,这个依赖能够帮助我们管理css
在react-app1中安装:
pnpm add single-spa-css
创建styles文件,并启动服务
body {background-color: #aaa;
}
http-server启动一个服务
当前目录下所有静态资源的访问:
子应用中写入
spa-demo/react-app1/src/spa-react-app1.js:
在入口中,一般是:
挂载的时候做某个事情
卸载的时候做某个事情
先启动子应用,主应用,再启动样式服务,免得子应用的8080端口被占用然后报错
启动8888端口服务
import React from "react";
import ReactDOMClient from "react-dom/client";
import singleSpaReact from "single-spa-react";
import Root from "./root.component";
import singleSpaCss from 'single-spa-css';const lifecycles = singleSpaReact({React,ReactDOMClient,rootComponent: Root,errorBoundary(err, info, props) {// Customize the root error boundary for your microfrontend here.return null;},
});const styleLifeCycles = singleSpaCss({// 刚刚启动的样式服务地址cssUrls: ["http://192.168.10.4:8888/style.css"],// 是否在webpack打包中去除掉css文件webpackExtractedCss: false,// 子应用切换的时候就看不到样式了shouldUnmount: true
});// export const { bootstrap, mount, unmount } = lifecycles;// 数组的顺序就是在主应用调用的时候执行的顺序
// 先启动样式的bootstrap,再启动应用的bootstrap
export const bootstrap = [styleLifeCycles.bootstrap,lifecycles.bootstrap,
];export const mount = [styleLifeCycles.mount,lifecycles.mount,
];// 卸载的时候要先卸载主应用,再卸载样式
export const unmount = [lifecycles.unmount,styleLifeCycles.unmount,
];
主应用的css策略也要改
spa-demo/src/index.ejs:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta http-equiv="X-UA-Compatible" content="ie=edge"><title>Root Config</title><meta http-equiv="Content-Security-Policy" content="content-src * ws: wss:;"><meta name="importmap-type" use-injector /><!-- If you wish to turn off import-map-overrides for specific environments (prod), uncomment the line below --><!-- More info at https://github.com/single-spa/import-map-overrides/blob/main/docs/configuration.md#domain-list --><!-- <meta name="import-map-overrides-domains" content="denylist:prod.example.com" /> --><!-- Shared dependencies go into this import map --><script type="injector-importmap">{"imports": {"single-spa": "https://cdn.jsdelivr.net/npm/single-spa@6.0.3/lib/es2015/esm/single-spa.min.js"}}</script><link rel="preload" href="https://cdn.jsdelivr.net/npm/single-spa@6.0.3/lib/es2015/esm/single-spa.min.js" as="module"><!-- Add your organization's prod import map URL to this script's src --><!-- <script type="injector-importmap" src="/importmap.json"></script> --><% if (isLocal) { %><script type="injector-importmap">{"imports": {"@spa/root-config": "//localhost:9000/spa-root-config.js","@spa/react-app1": "http://localhost:8080/spa-react-app1.js","@spa/react-app2": "http://localhost:8081/spa-react-app2.js","@single-spa/welcome": "https://cdn.jsdelivr.net/npm/single-spa-welcome/dist/single-spa-welcome.min.js"}}</script><% } %><script src="https://cdn.jsdelivr.net/npm/import-map-overrides@5.1.1/dist/import-map-overrides.js"></script><script src="https://cdn.jsdelivr.net/npm/@single-spa/import-map-injector@2.0.1/lib/import-map-injector.js"></script>
</head>
<body><noscript>You need to enable JavaScript to run this app.</noscript><main></main><script>window.importMapInjector.initPromise.then(() => {import('@spa/root-config');});</script><import-map-overrides-full show-when-local-storage="devtools" dev-libs></import-map-overrides-full>
</body>
</html>
这样就修改了子应用的样式
添加了两个link,
preload:只是想要资源加载一下,但是不想展示它,或者不想应用它的时候,就使用preload,一般是在性能优化的时候会用到这个东西
没有preload修饰的:会直接将元素渲染出来
单独的 react-app2 是没有样式的:
react-app1,react-app2应用都在的情况:
样式也会被渲染,因为是用到了body上
样式隔离还有
- scopedCss,在样式上加一些特定的名字,确保css只影响应用中的方式
- shadowDOM
例子中这两种都没有用到,只是用了全局的css来演示了一下
JS隔离
做两个事情:
single-spa没有提供JS隔离,只是提供了一个扩展,让应用在卸载的时候可以将全局的,变量的影响给它移除掉
qiankun中会使用 sandbox 的方式,让每个应用之间,有自己完全隔离的全局变量/window变量,每个子应用都是自己的window,自己怎么改都不会影响到主应用的,但是在代码实践上其实和single-spa实现方式是一样的。
qiankun中的 sandbox 分两种:
- ProxySandbox
- LegacySandbox
子应用中
- 安装依赖
pnpm add single-spa-leaked-globals
- react-app1中应用
spa-demo/react-app1/src/spa-react-app1.js
import React from "react";
import ReactDOMClient from "react-dom/client";
import singleSpaReact from "single-spa-react";
import Root from "./root.component";
import singleSpaCss from 'single-spa-css';
import singleSpaLeakedGlobals from 'single-spa-leaked-globals';// getLinkedGlobals:深层次对比前后两个对象是否发生变化
// restoreGlobals:应用启动之前,缓存的全局变量全部恢复过来
const {getLinkedGlobals,restoreGlobals} = singleSpaLeakedGlobals();const lifecycles = singleSpaReact({React,ReactDOMClient,rootComponent: Root,errorBoundary(err, info, props) {// Customize the root error boundary for your microfrontend here.return null;},
});const styleLifeCycles = singleSpaCss({// 刚刚启动的样式服务地址cssUrls: ["http://192.168.10.4:8888/style.css"],// 是否在webpack打包中去除掉css文件webpackExtractedCss: false,// 子应用切换的时候就看不到样式了shouldUnmount: true
});const jsLifeCycles = singleSpaLeakedGlobals({globalVariableNames: ['_','$','jQuery','mode','type']
})// export const { bootstrap, mount, unmount } = lifecycles;// 数组的顺序就是在主应用调用的时候执行的顺序
// 先启动样式的bootstrap,再启动应用的bootstrap
export const bootstrap = [styleLifeCycles.bootstrap,jsLifeCycles.bootstrap,lifecycles.bootstrap,
];export const mount = [styleLifeCycles.mount,jsLifeCycles.mount,lifecycles.mount,
];// 卸载的时候要先卸载主应用,再卸载样式
export const unmount = [lifecycles.unmount,jsLifeCycles.unmount,styleLifeCycles.unmount,
];
qiankun
qiankun github
-
在 single-spa 基础上去做的
-
qiankun主要是运用原生的
fetch 方法
,像刚刚spa中js,css应用的还是浏览器的能力,但是qiankun不一样,运用原生的 fetch 方法
,来请求微应用的各种资源,将返回的内容再转换成对于的各种各样的字符串 -
single-spa 与HTML文档是没有关系的,是直接导出的React组件去做这个事情,通过生命周期钩子来完成的
qiankun会获取html文档,解析了后再做一个子应用
- fetch:获取资源文件
- processTpl:解析html做子应用,将所有的js文件,css文件,内敛的css文件全部解析出来
- style 收集的所有的styles,通过 fetch方法
- script 收集的所有的script对象,url,也会通过fetch方法拉回来
- requestIdleCallback 浏览器在空闲时候会执行的回调,并且还会告诉剩余时间,然后做各种各样的事情,一般情况,只有低优先级的时候才会使用这个方法,因为有可能这个方法不执行,在高优先级的时候,使用 requestAnimationFrame 方法,这个方法是必定会执行的。
- 匿名自执行函数:包裹住所有的JS,使用闭包来对JS做了限制,通过evl做了执行,定了上下文,通过传入的proxy来改变window的指向,
沙箱机制
- 渲染子应用
基于Single-spa封装的,也支持各种框架,通过解析html的方式兼容一整套的,类似 iframe,qiankun在iframe思路上实现功能的。
样式,使用 shadowDOM 做样式隔离的
js,通过 沙箱机制,将js代码拉回来后,通过evl/自执行函数,执行JS,限制上行文,包括window的访问的操作等,通过 proxy 代理
资源预加载,通过解析html搞定的
使用
安装依赖
-
安装qiankun主依赖
pnpm i
-
安装主应用main依赖
pnpm i
-
安装子应用react16依赖
-
安装子应用vue依赖
启动服务
-
启动vue服务
pnpm start
-
启动react16服务
pnpm start
报错:
openssl 不支持
使用cross-env加个环境变量将它忽略掉pnpm add --save-dev cross-env
修改启动代码:
qiankun/examples/react16/package.json:cross-env NODE_OPTIONS=–openssl-legacy-provider rescripts start
-
构建qiankun
pnpm build
报错:
isConstDestructAssignmentSupported 类型推导有问题
这里改一下:
成功了:
-
启动主应用
pnpm start
react16-home页面:
react16-about页面:
react16-弹窗:
Vue3的页面:
vue用的组件是elementUI,react用的是组件AntD
ShadowDom 影子DOM
qiankun的样式使用shadowDom来做隔离的,shadowDom不是真实的能看到的dom
示例:
- 创建html页面
video.html:
<htmkl><body><video width="300" height="100" controls></video></body>
</htmkl>
- 启动一个httpServer端
只写了一个video标签,但是video上面有很多可操作的东西
可以看到这里就是shadowDom
shadowDom里面全部都是这样一个个dom元素,像上面各种控制的组件在里面都能找得到,本身在dom树里是没有单独存在的,它是在某一个元素上有一个完整的dom树这样
shadowDom对当前组件的dom和css都提供了封装,实际上在浏览器预览文档的时候就会指定dom写好这个元素,外部的配置不影响内部,内部的配置也不影响外部
在查看源代码的时候还是看不到shadowDom
shadowDOM有两种模式:1. open 2. close
就比如,这样:
open的时候,外部就可以访问shadowDom里面的元素,如果是close,那么对外部则是完全不可见的
使用shadowDom
<htmkl><body><!-- <video width="300" height="100" controls></video> --><script>// 给div挂载一个shadowDomconst ele = document.createElement("div");const shadow = ele.attachShadow({mode: "open",});shadow.innerHTML = "<div>hello world</div>";document.body.appendChild(ele);</script></body>
</htmkl>
proxySandbox VS snapshotSandbox
- qiankun
- src
- sandbox
- proxySandbox.ts
- snapshotSandbox.ts 两个文件
- sandbox
- src
- proxySandbox 使用代理的方式来为每一个子应用创建一个完全独立的环境,与window全局变量是没有任何关系的
- snapshotSandbox 通过single-spa-link-global的方式来做的,解决兼容问题,用于不支持proxy的低版本浏览器
两个都是基于SandBox基础上实现
snapshotSandbox.ts
/*** @author Hydrogen* @since 2020-3-8*/
import type { SandBox } from '../interfaces';
import { SandBoxType } from '../interfaces';function iter(obj: typeof window | Record<any, any>, callbackFn: (prop: any) => void) {// eslint-disable-next-line guard-for-in, no-restricted-syntaxfor (const prop in obj) {// patch for clearInterval for compatible reason, see #1490if (obj.hasOwnProperty(prop) || prop === 'clearInterval') {callbackFn(prop);}}
}/*** 基于 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; //proxy就是windowthis.type = SandBoxType.Snapshot; //类型是Snapshot}// 激活的时候active() {// 记录当前快照this.windowSnapshot = {} as Window;// 迭代器对window进行迭代iter(window, (prop) => {// 将window上的属性给到windowSnapshot快照上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();// 然后迭代当前的windowiter(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 {}
}
proxySandbox.ts
/*** 基于 Proxy 实现的沙箱*/
export default class ProxySandbox implements SandBox {/** window 值变更记录 */private updatedValueSet = new Set<PropertyKey>();private document = document;name: string;type: SandBoxType;proxy: WindowProxy;sandboxRunning = true;latestSetProp: PropertyKey | null = null;active() {// proxy的sandbox是可以有多个实例的,也就是说,一个子应用是可以有多个sandbox的// 而snapshot的sandbox只有一个实例,每个子应用都只有一个上下文if (!this.sandboxRunning) activeSandboxCount++;this.sandboxRunning = true;}inactive() {if (process.env.NODE_ENV === 'development') {console.info(`[qiankun:sandbox] ${this.name} modified global properties restore...`, [...this.updatedValueSet.keys(),]);}// 恢复现场的// 如果在测试环境或者 activeSandboxCount === 0 时,需要恢复现场if (inTest || --activeSandboxCount === 0) {// reset the global value to the prev value// 遍历 globalWhitelistPrevDescriptor这个对象Object.keys(this.globalWhitelistPrevDescriptor).forEach((p) => {const descriptor = this.globalWhitelistPrevDescriptor[p];if (descriptor) {// 恢复了globalContext的代理Object.defineProperty(this.globalContext, p, descriptor);} else {// @ts-ignore// 清理环境delete this.globalContext[p];}});}this.sandboxRunning = false;}......constructor(name: string, globalContext = window, opts?: { speedy: boolean }) {......// 生成proxy,给fakeWindow添加getter/setterconst proxy = new Proxy(fakeWindow, {// 更改fakeWindow变量的值,而所有的更改都是改到globalWhitelistPrevDescriptor上去了,而不是改到fakeWindow上去set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {if (this.sandboxRunning) {this.registerRunningApp(name, proxy);// sync the property to globalContext 同步到全局上下文// 定义了globalWhitelistPrevDescriptor对象if (typeof p === 'string' && globalVariableWhiteList.indexOf(p) !== -1) {this.globalWhitelistPrevDescriptor[p] = Object.getOwnPropertyDescriptor(globalContext, p);// @ts-ignoreglobalContext[p] = value;} else {......}......}},// get返回的是rebindTarget2Fn函数的返回值// 而rebindTarget2Fn(boundTarget, value)的传参boundTarget有可能是nativeGlobal(原始global)和globalContext(虚拟的proxy代理的上下文)get: (target: FakeWindow, p: PropertyKey): any => {.......const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;return rebindTarget2Fn(boundTarget, value);},});
.......}
.......
}
这两个整体操作是类似的,只不过一个操作的是真的window对象,一个操作的是代理过的window对象,而且proxy支持不同激活的实例是单独的对象环境
全局弹窗
微前端在处理全局弹窗的时候,一般都是在body下面挂一个新的dom来处理的,就会有个问题,这里每个子应用都有自己的dom元素,而新的这个dom是不受子元素控制的,本身样式隔离,就比如qiankun,qiankun是用shadowDOM的方式将子应用样式做隔离的,而现在在子元素shadowDOM之外,也就是body下面创建一个新的全局弹窗,而这时全局弹窗的样式要怎么办呢?
那么这时候需要将样式能够达到实现一个全局的效果
qiankun是做了特殊处理的,可以将一些特定的样式挂载到主应用上去
qiankun的每个子应用是有一个单独的节点的
无界的shadowDOM本身就是挂载在主应用上的,因此它这时的全局弹窗就是真正的全局弹窗
无界直接将这个应用挂载到主应用上去了
路由
qiankun中,如果定义一个路径是给到子应用的,在页面刷新的开始时候需要一下子就进入到子应用的话,并且如果这个子应用根本就没有加载完成的话,那么就可能会匹配到主应用的404的问题。
因此,qiankun这里会有一个特殊的变动,与single-spa类似的,将url嵌入直接劫持掉了,只是qiankun的劫持稍微有点不同,这里可以参考源代码
预加载
qiankun里有一个预加载,比如
- qiankun
- examples
- main
- index.js
- main
- examples
当启动主应用的时候,不管是否打开了react16,它都会加载react16所有的资源
通信方式
参考 globalState.ts的代码
- qiankun
- src
- globalState.ts
- src
/*** @author dbkillerf6* @since 2020-04-10*/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 (process.env.NODE_ENV === 'development') {console.warn(`[qiankun] globalState tools will be removed in 3.0, pls don't use it!`);}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*/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;},};
}
EMP - 同架构应用的微前端
EMP也是一个微组件的解决方案,借助的是webpack Module Federation,webpack的模块邦联
类似external,但是具体实现是不一样的
它可以将一个组件分成两种,一种叫remote,一种叫host
host应用是可以引用remote的应用的
将应用分成remote和host两种,但是我们每个应用是如果是由别人继承的话,就作为remote的状态,而真正的host应用是可以引用我,大家都在一个webpack里,但是都是独立的,因为打包都是独立的,每个子应用都作为remote的方式,每个remote之间都是独立的,打包,发布,部署也全是独立的
除了微前端之外还有一个微组件
微组件在业内不是很流行,但是在很多公司有很多的实践
module federation对于大部分公司来说是比较鸡肋的,由于都是同一套webpack配置,无需再分过多
module federation 针对一些应用比较复杂,或者说负责某一应用的部门特别多,做的东西被很多部门使用才会考虑这种模式
而做的东西被很多部门使用为什么不用npm包,是由于npm包是有一些问题的,因为它的版本号是锁定的,只有npm包的使用方才能决定用哪个版本,而发布方是没有办法控制别人用哪个包的,但是通过module federation就可以由开发方自己来控制
module federation使用场景有限,因为导出的是什么,别人就要用什么
而微前端是在一个应用中集成不同框架的东西,不管是vue还是react,最大特点是这个,但是module federation只能是一个框架里的,如果组件是react,那么使用方也得是react
而且module federation这个导出来的东西,导出的是一个组件,不是真正的单体应用。