万字了解什么是微前端???
前言
本文讲述什么是微前端、微前端的方案、微前端的核心点,以及原理方面。
案例的源码在:🥳源码
一、微前端简介
1. 什么是微前端?
微前端是一种将多个前端应用组合成一个整体的架构风格,将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的应用,而在用户看来仍然是内聚的单个产品。
微前端概念的误区:
- 微前端不是一门具体的技术,而是整合了技术、策略和方法,可能会以脚手架、配套工具和规范约束等等成体系的形式综合呈现,是一种宏观上的架构。这种架构目前有多种方案,各有利弊,但只要适用业务场景的就是好方案。
- 微前端本身并没有技术栈的约束。每一套微前端方案的设计,都是基于实际需求出发。如果是多团队统一使用了 React 技术栈,可能对微前端方案的跨技术栈使用并没有要求;如果是多团队同时使用了 React 和 Vue 技术栈,可能就对微前端的跨技术栈要求比较高。
- 微前端要求各个应用能独立开发、测试、部署,但并不要求各个应用能独立运行。也就是说,微前端的粒度不一定是应用级的,也有可能是页面级,甚至组件级。
摘自掘金·字节架构前端
2. 微前端要解决的问题?
- 技术:应用随着项目迭代越来越庞大,耦合度升高,以致缺乏灵活性,难以维护
- 团队协作:解决组织和团队间协作带来的工程问题
- 业务:用户喜欢聚合、一体化的应用,可以在既不重写原有系统的基础之下,又可以抽出人力来开发新的业务
3. 微前端的评价标准?
判断一个微前端是否优秀(符合需求)和全面,可以从以下维度进行评价分析:
二、主流技术方案对比
1. iframe
1.1 原生 iframe
通过 iframe
嵌入来实现微前端,(可能)是从微前端概念产生以来最为经典、能跑就行(又不是不能用)的解决方案。
优点 | 缺点 |
---|---|
iframe 使用简单,即来即用。 | 视窗大小不同步:例如我们在 iframe 内的弹窗想要居中展示。 |
iframe 可以创建一个全新、独立的宿主环境,子应用独立运行,隔离完美。 | 子应用间通信问题:只能通过 postMessage 传递序列化的消息。 |
iframe 支持在一个页面放上多个应用,组合灵活。 | 额外的性能开销:加载速度、构建 iframe 环境导致白屏时间太长。 |
路由状态丢失:刷新一下,iframe 的 url 状态就丢失了。 |
iframe 视窗大小不同步问题:
- iframe 有自己独立的视窗环境,它内部的页面获取的是 iframe 视窗的尺寸信息,而不是整个浏览器的视窗尺寸。当浏览器的窗口大小发生变化时,iframe 内的页面并不能及时感知到外部浏览器视窗的变化,导致计算出来的弹窗居中位置可能不准确。
1.2 无界
无界微前端是一款基于 基于 WebComponent 容器(ShadowRoot) + iframe 沙箱的微前端框架。
1.2.1 补充:ShadowDom
影子 DOM(Shadow DOM)允许你将一个 DOM 树附加到一个元素上,并且使该树的内部对于在页面中运行的 JavaScript 和 CSS 是隐藏的,在其之下你可以用与普通 DOM 相同的方式附加任何元素。
有一些影子 DOM 术语需要注意:
- 影子宿主(Shadow host): 影子 DOM 附加到的常规 DOM 节点。
- 影子树(Shadow tree): 影子 DOM 内部的 DOM 树。
- 影子边界(Shadow boundary): 影子 DOM 终止,常规 DOM 开始的地方。
- 影子根(Shadow root): 影子树的根节点。
影子 DOM 在平时开发中使用到的,例如常见的 video
标签,它有一个 shadowRoot
属性,这个属性就是影子 DOM 的根节点。
如何查看影子 DOM?
默认是不显示的,需要在控制台的设置中进行开启
从下图中可以看到,video 元素的影子 dom 结构,可以知道,video 本身是没有样式的,那些控件样式,是通过影子 dom 结构来实现的,保证了外部样式不会影响到默认的样式。
如何创建一个影子 dom?
<div id="host"></div>
<script>const host = document.querySelector("#host");const shadow = host.attachShadow({ mode: "open" });// 填充内容const span = document.createElement("span");span.textContent = "I'm in the shadow DOM";shadow.appendChild(span);
</script>
Element.shadowRoot 和“mode”选项
通过 mode
属性,可以让页面中的 JavaScript 可以通过影子宿主的 shadowRoot 属性访问影子 DOM 的内部。
{mode: "open"}
:可以通过页面内的 JavaScript 方法来获取 Shadow DOM{mode: "closed"}
:不可以从外部获取 Shadow DOM,此时shadowRoot
返回null
。
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
// const shadow = host.attachShadow({ mode: 'closed' })
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);document.querySelector("#btn").onclick = () => {const spans = host.shadowRoot.querySelectorAll("span");// mode为open,可以获取到影子dom内的元素,mode为closed,会报错spans.forEach((span) => {console.log(span);});
};
在影子 dom 内应用样式
一般有两种方法:
- 编程式,通过构建一个
CSSStyleSheet
对象并将其附加到影子根。 - 声明式,通过在一个
<template>
元素的声明中添加一个<style>
元素。
📦 编程式:
- 创建一个空的
CSSStyleSheet
对象 - 使用
CSSStyleSheet.replace()
或CSSStyleSheet.replaceSync()
设置其内容 - 通过将其赋给
ShadowRoot.adoptedStyleSheets
来添加到影子根
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });const sheet = new CSSStyleSheet();
sheet.replaceSync("span { color: red; border: 2px dotted black;}");
shadow.adoptedStyleSheets = [sheet];const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
也可以通过创建 style
标签来实现:
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
// 填充内容
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
// 填充样式 外部的style不会作用在里面
const style = document.createElement("style");
style.textContent = "span { color: blue; }";
shadow.appendChild(style);
📦 声明式:
构建 CSSStyleSheet 对象的一个替代方法是将一个 <style>
元素包含在用于定义 web 组件的 <template>
元素中。
<div id="host"></div>
<template id="my-shadow-dom"><style>span {color: blue;}</style><span>I'm in the shadow DOM</span>
</template><script>const host = document.querySelector("#host");const shadow = host.attachShadow({ mode: "open" });const template = document.getElementById("my-shadow-dom");shadow.appendChild(template.content);
</script>
Shadow DOM 在各种微前端方案中有广泛运用,比如无界、Garfish 等,主要的作用是:把每个子应用包裹到一个 Shadow DOM 中,从而保证其运行时的样式的绝对隔离。
1.2.2 无界方案
无界微前端框架通过继承 iframe 的优点,解决 iframe 的缺点,下面来看看无界是如何去解决的?
例如:假设有 A 应用,想要加载 B 应用:
在应用 A 中构造一个 shadow
和 iframe
,然后将应用 B 的 html
写入 shadow
中,js
运行在 iframe
中,注意 iframe
的 url
,iframe
保持和主应用同域但是保留子应用的路径信息,这样子应用的 js
可以运行在 iframe
的 location
和 history
中保持路由正确。
在 iframe
中拦截 document
对象,统一将 dom
指向 shadowRoot
,此时比如新建元素、弹窗或者冒泡组件就可以正常约束在 shadowRoot
内部。
为什么要改写嵌入的 iframe 的域名(从 hostB 转换为 hostA)?
答:为了利用同源 iframe 可以便捷通信的特性。
在 iframe 中操作路由,会导致主、子应用路由不统一的问题怎么解决?
答:监听 iframe 的路由变化并同步到主应用,浏览器的 url 也会同步到 iframe。
接下来的三步分别解决 iframe
的三个缺点:
- ✅ dom 割裂严重的问题(例如:弹窗只能在 iframe 内部展示,无法覆盖全局),主应用提供一个容器给到
shadowRoot
插拔,shadowRoot
内部的弹窗也就可以覆盖到整个应用 A - ✅ 路由状态丢失的问题,浏览器的前进后退可以天然的作用到
iframe
上,此时监听iframe
的路由变化并同步到主应用,如果刷新浏览器,就可以从 url 读回保存的路由 - ✅ 通信非常困难的问题,
iframe
和主应用是同域的,天然的共享内存通信,而且无界提供了一个去中心化的事件机制
将这套机制封装进 wujie 框架,可以发现:
- ✅ 首次白屏的问题,wujie 实例可以提前实例化,包括
shadowRoot
、iframe
的创建、js 的执行,这样极大的加快子应用第一次打开的时间 - ✅ 切换白屏的问题,一旦 wujie 实例可以缓存下来,子应用的切换成本变的极低,如果采用保活模式,那么相当于
shadowRoot
的插拔
由于子应用完全独立的运行在 iframe
内,路由依赖 iframe
的 location
和 history
,我们还可以在一张页面上同时激活多个子应用,由于 iframe
和主应用处于同一个浏览器的主窗口,因此浏览器前进、后退都可以作用到到子应用。
优缺点:
优点 | 缺点 |
---|---|
多应用同时激活在线,框架具备同时激活多应用,并保持这些应用路由同步的能力。 | 内存占用较高,为了降低子应用的白屏时间,将未激活子应用的 shadowRoot 和 iframe 常驻内存并且保活模式下每张页面都需要独占一个 wujie 实例,内存开销较大。 |
组件式的使用方式。 | 兼容性一般,目前用到了浏览器的 shadowRoot 和 proxy 能力 |
无需注册,更无需路由适配,在组件内使用,跟随组件装载、卸载。 | iframe 劫持 document 到 shadowRoot 时,某些第三方库可能无法兼容导致穿透。 |
应用级别的 keep-alive。 | |
纯净无污染。 |
应用加载机制和 js 沙箱机制
将子应用的 js
注入主应用同域的 iframe
中运行,iframe
是一个原生的 window
沙箱,内部有完整的 history
和 location
接口,子应用实例 instance
运行在 iframe
中,路由也彻底和主应用解耦,可以直接在业务组件里面启动应用。
iframe 连接机制和 css 沙箱机制
无界采用 webcomponent
来实现页面的样式隔离,无界会创建一个wujie
自定义元素,然后将子应用的完整结构渲染在内部
子应用的实例 instance
在 iframe
内运行,dom
在主应用容器下的 webcomponent
内,通过代理 iframe
的 document
到 webcomponent
,可以实现两者的互联。
html、css 不放在 iframe 中,是因为 iframe 导致 dom 割裂(例如弹窗不能覆盖到主应用),而 web component 可以实现。
例如:
// 模拟 iframe 的 document 对象
const iframeDocument = {getElementsByClassName: function (className) {console.log(`Original iframe document: Getting elements by class name: ${className}`);},getElementById: function (id) {console.log(`Original iframe document: Getting element by id: ${id}`);},
};// 模拟 webcomponent
const webcomponent = {getElementsByClassName: function (className) {console.log(`Webcomponent: Getting elements by class name: ${className}`);},getElementById: function (id) {console.log(`Webcomponent: Getting element by id: ${id}`);},
};// 使用 Proxy 进行代理
const proxiedIframeDocument = new Proxy(iframeDocument, {get(target, prop) {if (prop in webcomponent) {return webcomponent[prop];}return target[prop];},
});// 测试代理
proxiedIframeDocument.getElementById("test-id");
proxiedIframeDocument.getElementsByClassName("test-class");
将 document
的查询类接口:getElementsByTagName、getElementsByClassName、getElementsByName、getElementById、querySelector、querySelectorAll、head、body
全部代理到 webcomponent
,这样 instance
和 webcomponent
就精准的链接起来。
在源码中可以看到,使用 proxy 进行了代理(wujie-core/src/proxy.ts)
当子应用发生切换,iframe
保留下来,子应用的容器可能销毁,但 webcomponent
依然可以选择保留,这样等应用切换回来将 webcomponent
再挂载回容器上,子应用可以获得类似 vue
的 keep-alive
的能力.
路由同步机制
在 iframe
内部进行 history.pushState
,浏览器会自动的在 joint session history
中添加 iframe
的 session-history
,浏览器的前进、后退在不做任何处理的情况就可以直接作用于子应用
joint session history
:即联合会话历史记录,它将主页面和 iframe 等子文档的会话历史记录合并在一起。
session history
:指的是浏览器会话历史记录,也就是在当前浏览器会话期间用户访问过的页面序列。每个页面或者页面状态在会话历史里都有对应的一个条目。
劫持 iframe
的 history.pushState
和 history.replaceState
,就可以将子应用的 url
同步到主应用的 query
参数上,当刷新浏览器初始化 iframe
时,读回子应用的 url
并使用 iframe
的 history.replaceState
进行同步。
通信机制
承载子应用的 iframe
和主应用是同域的,所以主、子应用天然就可以很好的进行通信,在无界我们提供三种通信方式
具体查看:https://wujie-micro.github.io/doc/guide/communication.html
- props 注入机制
子应用通过 $wujie.props
可以轻松拿到主应用注入的数据
- window.parent 通信机制
子应用 iframe
沙箱和主应用同源,子应用可以直接通过 window.parent
和主应用通信
- 去中心化的通信机制
无界提供了 EventBus
实例,注入到主应用和子应用,所有的应用可以去中心化的进行通信
1.2.3 无界使用
主应用
// 主应用:src/App.jsx
import React from "react";
import { WujieReact } from "wujie-react";function App() {return (<div>{/* 导航菜单 */}<nav><Link to="/vue-app">Vue 子应用</Link><Link to="/react-app">React 子应用</Link></nav>{/* Vue 子应用容器 */}<WujieReact name="vueApp" url="http://localhost:3001" sync={true} props={{ user: { name: "Alice" } }} />{/* React 子应用容器 */}<WujieReact name="reactApp" url="http://localhost:3002" sync={true} /></div>);
}export default App;
startApp
:启动子应用,在页面挂载时调用destroyApp
:销毁子应用,在页面卸载时调用
wujie-vue
和wujie-react
也是根据下面这种写法封装的。
import { useRef, useEffect } from "react";
import { startApp, destroyApp } from "wujie";export default function SubApp() {const myRef = useRef(null);let destroy = null;const startAppFunc = async () => {destroy = await startApp({name: "subapp",url: "http://localhost:3001/",el: myRef.current,});};useEffect(() => {startAppFunc();return () => {if (destroy) {destroyApp(destroy);}};});return <div ref={myRef} />;
}
子应用
// Vue 子应用:src/main.js
import { createApp } from "vue";
import App from "./App.vue";if (window.__POWERED_BY_WUJIE__) {// 无界环境:挂载到无界提供的容器window.__WUJIE_MOUNT = () => {const app = createApp(App);// 接收主应用传递的 propsapp.config.globalProperties.$wujieProps = window.$wujie.props;app.mount("#app");};// 子应用卸载时清理window.__WUJIE_UNMOUNT = () => {app.unmount();};
} else {// 独立运行模式createApp(App).mount("#app");
}
/**********************************************************/
// React 子应用:src/index.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";if (window.__POWERED_BY_WUJIE__) {// 无界环境挂载window.__WUJIE_MOUNT = () => {ReactDOM.render(<App />, document.getElementById("root"));};window.__WUJIE_UNMOUNT = () => {ReactDOM.unmountComponentAtNode(document.getElementById("root"));};
} else {// 独立运行ReactDOM.render(<App />, document.getElementById("root"));
}
1.2.4 无界源码分析
我们先了解核心的几个方法在做什么,最后再将流程串联起来,就明白无界的运行原理了。
在 @wujie/core/src/index.ts
的入口文件中,
使用的函数为:processAppForHrefJump()
和 defineWujieWebComponent()
,
还包括关键的 Wujie
类,
导出的内容为 setupApp()
、startApp()
、preloadApp()
和 destroyApp()
,
下面对这个几个方法进行源码分析。
processAppForHrefJump
该函数用于监听浏览器的 popstate
事件,当用户点击前进/后退按钮或调用历史导航方法时,根据当前 URL 中的参数判断是否需要跳转子应用。若需跳转,则通过 iframe 替换内容实现子应用渲染;若为后退操作且之前有跳转记录,则恢复子应用原始内容。
export function processAppForHrefJump(): void {// 监听浏览器历史记录,发生变化时触发的一个事件。// 当用户点击浏览器的 “前进” 或 “后退” 按钮,// 或者通过 JavaScript 调用 history.back()、history.forward() 及 history.go() 方法时,就会触发 popstate 事件。window.addEventListener("popstate", () => {// 根据url生成a标签let winUrlElement = anchorElementGenerator(window.location.href);// 返回a标签连接解析的参数 http://www.example.com?a=1&b=2 => {a:1,b:2}const queryMap = getAnchorElementQueryMap(winUrlElement);winUrlElement = null;Object.keys(queryMap).map((id) => getWujieById(id)).filter((sandbox) => sandbox).forEach((sandbox) => {// sandox 是 wujie实例/*** 通过 wujie实例绑定的id获取到子应用url* wujie实例中:this.id = name; id为子应用传递的name*/const url = queryMap[sandbox.id];/*** 在 Wujie 的 constructor 中创建iframe* this.iframe = iframeGenerator(this, attrs, mainHostPath, appHostPath, appRoutePath);* iframeGenerator方法:创建了iframe,也就是创建 js沙箱【具体内容在 src/iframe.ts】*/// rawDocumentQuerySelector 判断是否存在wujie,来实用 querySelectorconst iframeBody = rawDocumentQuerySelector.call(sandbox.iframe.contentDocument, "body");// 前进hrefif (/http/.test(url)) {if (sandbox.degrade) {renderElementToContainer(sandbox.document.documentElement, iframeBody);renderIframeReplaceApp(window.decodeURIComponent(url),getDegradeIframe(sandbox.id).parentElement,sandbox.degradeAttrs);} elserenderIframeReplaceApp(window.decodeURIComponent(url),sandbox.shadowRoot.host.parentElement,sandbox.degradeAttrs);sandbox.hrefFlag = true;// href后退} else if (sandbox.hrefFlag) {if (sandbox.degrade) {// 走全套流程,但是事件恢复不需要const { iframe } = initRenderIframeAndContainer(sandbox.id, sandbox.el, sandbox.degradeAttrs);patchEventTimeStamp(iframe.contentWindow, sandbox.iframe.contentWindow);iframe.contentWindow.onunload = () => {sandbox.unmount();};iframe.contentDocument.appendChild(iframeBody.firstElementChild);sandbox.document = iframe.contentDocument;} else renderElementToContainer(sandbox.shadowRoot.host, sandbox.el);sandbox.hrefFlag = false;}});});
}
defineWujieWebComponent
这里需要补充一点
web component
的生命周期:
constructor()
: 构造函数,在组件实例化时调用。connectedCallback()
: 当组件被添加到 DOM 中时调用。disconnectedCallback()
: 当组件从 DOM 中移除时调用。attributeChangedCallback(name, oldValue, newValue)
: 当组件的属性发生变化时调用。adoptedCallback()
: 当组件被移动到新的文档中时调用。
定义 web component
容器,将 shadow
包裹并获得 dom 装载和卸载的生命周期
// src/shadow.ts
export function defineWujieWebComponent() {const customElements = window.customElements;if (customElements && !customElements?.get("wujie-app")) {class WujieApp extends HTMLElement {// 创建shadowRootconnectedCallback(): void {if (this.shadowRoot) return;const shadowRoot = this.attachShadow({ mode: "open" });const sandbox = getWujieById(this.getAttribute(WUJIE_APP_ID));// 对传入的 shadowRoot 元素进行属性劫持,使其某些属性指向代理的 iframe 环境。若元素已打过标记则跳过。patchElementEffect(shadowRoot, sandbox.iframe.contentWindow);// 挂载沙箱上sandbox.shadowRoot = shadowRoot;}// 卸载shadowRootdisconnectedCallback(): void {const sandbox = getWujieById(this.getAttribute(WUJIE_APP_ID));sandbox?.unmount();}}customElements?.define("wujie-app", WujieApp);}
}
Wujie 类
Wujie
类是无界沙箱的核心,沙箱实例就是 WuJie 类的实力,里面存储着 shadowroot、模板以及各种子应用的配置属性。在 startApp
和 preloadApp
中会 new
实例。
在这个构造函数中,主要做的:
- 初始化操作
- 创建同域的 iframe
- 将 iframe 的
window
,document
,location
进行代理,并挂载到沙箱实例上 - 将沙箱进行缓存
// src/sandbox.ts
class Wujie {constructor(options: {name: string; // 子应用的id,唯一标识url: string; // 子应用的url,可以包含protocol、host、path、query、hashattrs: { [key: string]: any }; // 子应用标签的属性,比如id、name、data-name、data-urldegradeAttrs: { [key: string]: any }; // 降级属性,比如data-src、data-src-typefiber: boolean; // 是否使用fiber,默认为false,优化手段degrade; // 是否降级,默认为false,即不降级plugins: Array<plugin>;lifecycles: lifecycles; // 生命周期钩子 beforeLoad等...}) {// 传递inject给嵌套子应用if (window.__POWERED_BY_WUJIE__) this.inject = window.__WUJIE.inject;else {this.inject = {idToSandboxMap: idToSandboxCacheMap,appEventObjMap,mainHostPath: window.location.protocol + "//" + window.location.host,};}const { name, url, attrs, fiber, degradeAttrs, degrade, lifecycles, plugins } = options;this.id = name;this.fiber = fiber;this.degrade = degrade || !wujieSupport;this.bus = new EventBus(this.id);this.url = url;this.degradeAttrs = degradeAttrs;this.provide = { bus: this.bus };this.styleSheetElements = [];this.execQueue = [];this.lifecycles = lifecycles;this.plugins = getPlugins(plugins);// 创建目标地址的解析const { urlElement, appHostPath, appRoutePath } = appRouteParse(url);const { mainHostPath } = this.inject;// 创建iframe iframeGenerator代码在后面有说this.iframe = iframeGenerator(this, attrs, mainHostPath, appHostPath, appRoutePath);if (this.degrade) {// 降级处理 使用 Object.defineProperties 进行代理【降级使用 localGenerator】const { proxyDocument, proxyLocation } = localGenerator(this.iframe, urlElement, mainHostPath, appHostPath);this.proxyDocument = proxyDocument;this.proxyLocation = proxyLocation;} else {// 正常处理 使用 Proxy 进行代理,代码在后面【无降级使用 proxyGenerator】const { proxyWindow, proxyDocument, proxyLocation } = proxyGenerator(this.iframe,urlElement,mainHostPath,appHostPath);this.proxy = proxyWindow;this.proxyDocument = proxyDocument;this.proxyLocation = proxyLocation;}this.provide.location = this.proxyLocation;addSandboxCacheWithWujie(this.id, this);}
}
创建 iframe iframeGenerator
的核心代码:
创建和主应用同源的 iframe,路径携带了子路由的路由信息,并且 iframe 必须禁止加载 html,防止进入主应用的路由逻辑
⚠️ 注意:子应用的资源和接口的请求都在主域名发起,所以会有跨域问题,子应用必须要支持跨域。
export function iframeGenerator(sandbox: WuJie,// 属性attrs: { [key: string]: any },// 主应用地址mainHostPath: string,// 子应用地址appHostPath: string,// 子路由appRoutePath: string
): HTMLIFrameElement {let src = attrs && attrs.src;// 判断是否需要使用这种 Object URL 来作为 iframe 的 src 属性值(URL.createObjectURL() 方法生成的特殊 URL)let useObjectURL = false;// 如果没有src,则使用objectURL,否则使用srcif (!src) {// getSandboxEmptyPageURL() 方法返回一个空的 HTML 页面的 URL,用于创建同源的 iframe 沙箱环境。下面有代码。src = getSandboxEmptyPageURL();// trueuseObjectURL = !!src; // !! 表示转为布尔值// 如果没有src,则使用主应用地址if (!src) src = mainHostPath; // fallback to mainHostPath}const iframe = window.document.createElement("iframe");const attrsMerge = {// 将iframe隐藏style: "display: none",...attrs,// 设置主应用的srcsrc,// 设置namename: sandbox.id,// 添加标记[WUJIE_DATA_FLAG]: "",};// 该方法通过遍历,将属性设置到元素上setAttrsToElement(iframe, attrsMerge);// 插入iframewindow.document.body.appendChild(iframe);/*** iframe.contentWindow:iframe 的 window属性* 如果与 iframe 父级同源,那么父级页面可以访问 iframe 的文档以及内部 DOM,*/const iframeWindow = iframe.contentWindow;// 变量需要提前注入,在入口函数通过变量防止死循环,下面有代码// sandbox => wujie实例patchIframeVariable(iframeWindow, sandbox, appHostPath);// 但是一旦设置`src`后,`iframe`由于同域,会加载主应用的`html`、`js`,所以必须在`iframe`实例化完成并且还没有加载完`html`时中断加载,防止污染子应用// 在 Wujie.active 会等待 iframeReady 执行完成// frame沙箱的src设置了主应用的host,初始化iframe的时候需要等待iframe的location.orign从'about:blank'初始化为主应用的host,内部采用 循环 + setTimeout 来实现的(1s后 url 没变化,结束循环)sandbox.iframeReady = stopIframeLoading(iframe, useObjectURL && { mainHostPath }).then(() => {// 变量中没有,重新添加if (!iframeWindow.__WUJIE) {patchIframeVariable(iframeWindow, sandbox, appHostPath);}/*** 初始化iframe的dom结构 => 主要是对iframe新增变量,标签,做大量重写操作(例如window、document、node、history、event这些)* 在 iframe 的 window 上添加变量,有 iframeDocument.head、querySelector、querySelectorAll、createElement 等方法* patchIframeHistory:重写iframe的history的pushState和replaceState方法 将从location劫持后的数据修改回来,防止跨域错误 同步路由到主应用,下面有说* patchIframeEvents:修改window对象的事件监听,只有路由事件采用iframe的事件*/initIframeDom(iframeWindow, sandbox, mainHostPath, appHostPath);/*** 如果有同步优先同步,非同步从url读取*/if (!isMatchSyncQueryById(iframeWindow.__WUJIE.id)) {iframeWindow.history.replaceState(null, "", mainHostPath + appRoutePath);}});return iframe;
}
stopIframeLoading
是为了停止加载这个 iframe,因为创建这个 ifame 的是主应用,所以是主应用的 src 或about:blank
,这样会导致 iframe 加载主应用的资源,但是 iframe 是子应用的,不需要加载主应用资源,所以需要停止掉,后续再去初始化子应用 iframe。initIframeDom
:主要是对 iframe 新增变量,标签,做大量重写操作(例如 window、document、node、history、event 这些)
// getSandboxEmptyPageURL:生成一个空的 HTML 页面的 URL,用于创建同源的 iframe 沙箱环境。
const getSandboxEmptyPageURL = () => {if (disabled) return "";if (prevURL) return prevURL;// 使用空页面 URL 可以防止 iframe 加载主应用的 HTML 内容,从而避免进入主应用的路由逻辑或其他副作用。const blob = new Blob(["<!DOCTYPE html><html><head></head><body></body></html>"], { type: "text/html" });prevURL = URL.createObjectURL(blob);return prevURL;
};// patchIframeVariable:为一个 iframe 设置多个全局变量
function patchIframeVariable(iframeWindow: Window, wujie: WuJie, appHostPath: string): void {// wujie实例iframeWindow.__WUJIE = wujie;// 子应用公共加载路径iframeWindow.__WUJIE_PUBLIC_PATH__ = appHostPath + "/";/*** $wujie对象,提供给子应用的接口public provide: {bus: EventBus;shadowRoot?: ShadowRoot;props?: { [key: string]: any };location?: Object;};*/iframeWindow.$wujie = wujie.provide;// 原生的window对象iframeWindow.__WUJIE_RAW_WINDOW__ = iframeWindow;
}// patchIframeHistory:重写iframe的history的pushState和replaceState方法(在 iframeGenerator 方法的 initIframeDom 方法中) 实现子应用路由和基座路由的联动
function patchIframeHistory(iframeWindow: Window, appHostPath: string, mainHostPath: string): void {const history = iframeWindow.history;const rawHistoryPushState = history.pushState;const rawHistoryReplaceState = history.replaceState;// 重写 pushStatehistory.pushState = function (data: any, title: string, url?: string): void {const baseUrl =mainHostPath + iframeWindow.location.pathname + iframeWindow.location.search + iframeWindow.location.hash;const mainUrl = getAbsolutePath(url?.replace(appHostPath, ""), baseUrl);const ignoreFlag = url === undefined;rawHistoryPushState.call(history, data, title, ignoreFlag ? undefined : mainUrl);if (ignoreFlag) return;updateBase(iframeWindow, appHostPath, mainHostPath);syncUrlToWindow(iframeWindow);};// 重写 replaceStatehistory.replaceState = function (data: any, title: string, url?: string): void {const baseUrl =mainHostPath + iframeWindow.location.pathname + iframeWindow.location.search + iframeWindow.location.hash;const mainUrl = getAbsolutePath(url?.replace(appHostPath, ""), baseUrl);const ignoreFlag = url === undefined;rawHistoryReplaceState.call(history, data, title, ignoreFlag ? undefined : mainUrl);if (ignoreFlag) return;updateBase(iframeWindow, appHostPath, mainHostPath);syncUrlToWindow(iframeWindow);};
}
其中有个 patchIframeHistory
,通过重写子应用的 pushState 实现子应用路由和基座路由的联动
无降级使用 proxyGenerator
非降级情况下对子应用的 window、document、location 进行代理
从图中代码可以看到使用 Proxy
分别对 window、document、location 进行代理
proxyWindow
getTargetValue()
用来修正 this 指针指向,因为子应用执行 window 的函数时,期望函数的 this 指向子应用的 window,但是由于代理 window 是在基座生成的,所以 this 指向的基座的 window,所以需要修正 this 指向。
const proxyWindow = new Proxy(iframe.contentWindow, {get: (target: Window, p: PropertyKey): any => {// location进行劫持if (p === "location") {return target.__WUJIE.proxyLocation;}// 判断自身if (p === "self" || (p === "window" && Object.getOwnPropertyDescriptor(window, "window").get)) {return target.__WUJIE.proxy;}// 不要绑定thisif (p === "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__" || p === "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__") {return target[p];}const descriptor = Object.getOwnPropertyDescriptor(target, p);if (descriptor?.configurable === false && descriptor?.writable === false) {return target[p];}// 修正this指针指向return getTargetValue(target, p);},set: (target: Window, p: PropertyKey, value: any) => {checkProxyFunction(target, value);target[p] = value;return true;},has: (target: Window, p: PropertyKey) => p in target,
});
proxyDocument
(🔗 核心:子应用 js 和 Webcomponents 通信的关键)
分析出 document 的所有属性以及方法,有些属性需要代理到全局的 document
,有些需要代理到沙箱的 shadow root
节点上。
通信关键:将 iframe 的 dom 代理到 shadowbox 上
const proxyDocument = new Proxy({},{get: function (_fakeDocument, propKey) {const document = window.document;const { shadowRoot, proxyLocation } = iframe.contentWindow.__WUJIE;// iframe初始化完成后,webcomponent还未挂在上去,此时运行了主应用代码,必须中止if (!shadowRoot) stopMainAppRun();const rawCreateElement = iframe.contentWindow.__WUJIE_RAW_DOCUMENT_CREATE_ELEMENT__;const rawCreateTextNode = iframe.contentWindow.__WUJIE_RAW_DOCUMENT_CREATE_TEXT_NODE__;// need fixif (propKey === "createElement" || propKey === "createTextNode") {return new Proxy(document[propKey], {apply(_createElement, _ctx, args) {const rawCreateMethod = propKey === "createElement" ? rawCreateElement : rawCreateTextNode;const element = rawCreateMethod.apply(iframe.contentDocument, args);patchElementEffect(element, iframe.contentWindow);return element;},});}if (propKey === "documentURI" || propKey === "URL") {return (proxyLocation as Location).href;}// from shadowRootif (propKey === "getElementsByTagName" ||propKey === "getElementsByClassName" ||propKey === "getElementsByName") {return new Proxy(shadowRoot.querySelectorAll, {apply(querySelectorAll, _ctx, args) {let arg = args[0];if (_ctx !== iframe.contentDocument) {return _ctx[propKey].apply(_ctx, args);}if (propKey === "getElementsByTagName" && arg === "script") {return iframe.contentDocument.scripts;}if (propKey === "getElementsByClassName") arg = "." + arg;if (propKey === "getElementsByName") arg = `[name="${arg}"]`;let res: NodeList[] | [];try {res = querySelectorAll.call(shadowRoot, arg);} catch (error) {res = [];}return res;},});}if (propKey === "getElementById") {return new Proxy(shadowRoot.querySelector, {// case document.querySelector.callapply(target, ctx, args) {if (ctx !== iframe.contentDocument) {return ctx[propKey]?.apply(ctx, args);}try {return (target.call(shadowRoot, `[id="${args[0]}"]`) ||iframe.contentWindow.__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__.call(iframe.contentWindow.document,`#${args[0]}`));} catch (error) {warn(WUJIE_TIPS_GET_ELEMENT_BY_ID);return null;}},});}if (propKey === "querySelector" || propKey === "querySelectorAll") {const rawPropMap = {querySelector: "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__",querySelectorAll: "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__",};return new Proxy(shadowRoot[propKey], {apply(target, ctx, args) {if (ctx !== iframe.contentDocument) {return ctx[propKey]?.apply(ctx, args);}// 二选一,优先shadowDom,除非采用array合并,排除base,防止对router造成影响return (target.apply(shadowRoot, args) ||(args[0] === "base"? null: iframe.contentWindow[rawPropMap[propKey]].call(iframe.contentWindow.document, args[0])));},});}if (propKey === "documentElement" || propKey === "scrollingElement") return shadowRoot.firstElementChild;if (propKey === "forms") return shadowRoot.querySelectorAll("form");if (propKey === "images") return shadowRoot.querySelectorAll("img");if (propKey === "links") return shadowRoot.querySelectorAll("a");const { ownerProperties, shadowProperties, shadowMethods, documentProperties, documentMethods } =documentProxyProperties;if (ownerProperties.concat(shadowProperties).includes(propKey.toString())) {if (propKey === "activeElement" && shadowRoot.activeElement === null) return shadowRoot.body;return shadowRoot[propKey];}if (shadowMethods.includes(propKey.toString())) {return getTargetValue(shadowRoot, propKey) ?? getTargetValue(document, propKey);}// from window.documentif (documentProperties.includes(propKey.toString())) {return document[propKey];}if (documentMethods.includes(propKey.toString())) {return getTargetValue(document, propKey);}},}
);
proxyLocation
代理 location
const proxyLocation = new Proxy({},{get: function (_fakeLocation, propKey) {const location = iframe.contentWindow.location;if (propKey === "host" ||propKey === "hostname" ||propKey === "protocol" ||propKey === "port" ||propKey === "origin") {return urlElement[propKey];}if (propKey === "href") {return location[propKey].replace(mainHostPath, appHostPath);}if (propKey === "reload") {warn(WUJIE_TIPS_RELOAD_DISABLED);return () => null;}if (propKey === "replace") {return new Proxy(location[propKey], {apply(replace, _ctx, args) {return replace.call(location, args[0]?.replace(appHostPath, mainHostPath));},});}return getTargetValue(location, propKey);},set: function (_fakeLocation, propKey, value) {// 如果是跳转链接的话重开一个iframeif (propKey === "href") {return locationHrefSet(iframe, value, appHostPath);}iframe.contentWindow.location[propKey] = value;return true;},ownKeys: function () {return Object.keys(iframe.contentWindow.location).filter((key) => key !== "reload");},getOwnPropertyDescriptor: function (_target, key) {return { enumerable: true, configurable: true, value: this[key] };},}
);
降级使用 localGenerator
在不支持 Proxy
的浏览器下,使用 Object.defineProperties
对降级情况下 document、location 代理处理。
importHtml
用来解析子应用模板的脚本、样式、模板(在 startApp
中有使用)
在主应用中,通过 fetch
来获取到子应用的内容,得到一个 html 字符串,然后通过正则表达式匹配到内部样式表、外部样式表、脚本;源码通过 /<(link)\s+.*?>/gis
匹配外部样式,通过 /(<script[\s\S]*?>)[\s\S]*?<\/script>/gi
匹配脚本;通过 /<style[^>]*>[\s\S]*?<\/style>/gi
匹配内部样式;
通过 fetch url 加载子应用资源,这里也是需要子应用支持跨域设置的原因
手写一个 importHTML
函数,用来解析子应用模板的脚本、样式、模板:
const STYLE_REG = /<style>(.*)<\/style>/gi;
const SCRIPT_REG = /<script>(.*)<\/script>/gi;
const LINK_REG = /<(link)\s+.*?>/gi;async function imoprtHTML() {let html = await fetch("https://baidu.com");html = await html.text();const ans = html.replace(STYLE_REG, (match) => {// ... 很多逻辑return match;}).replace(SCRIPT_REG, (match) => {// ... 很多逻辑return match;}).replace(LINK_REG, (match) => {// ... 很多逻辑debugger;return match;});
}
源码:
const newSandbox = new WuJie({ name, url, attrs, degradeAttrs, fiber, degrade, plugins, lifecycles });
// ...
const { template, getExternalScripts, getExternalStyleSheets } = await importHTML({url,html,opts: {fetch: fetch || window.fetch,plugins: newSandbox.plugins,loadError: newSandbox.lifecycles.loadError,fiber,},
});
processCssLoader
处理 css-loader
- processCssLoader
- css-loader:执行 css 插件
- getEmbedHTML:将外部 css 转为内联优化性能
返回一个带有 css 样式的 html 模版,后续插入到 shadowroot 中。
问:子应用为什么采用了内联样式而不是 link 标签引入?
答:因为需要对样式经常处理,所以需要将样式请求回来进行处理再放回去,还有一个就是子应用切换后样式需要恢复必须把样式收集起来,内联样式更好收集处理。
export async function processCssLoader(sandbox: Wujie,template: string,getExternalStyleSheets: () => StyleResultList
): Promise<string> {const curUrl = getCurUrl(sandbox.proxyLocation);/** css-loader */const composeCssLoader = compose(sandbox.plugins.map((plugin) => plugin.cssLoader));const processedCssList: StyleResultList = getExternalStyleSheets().map(({ src, ignore, contentPromise }) => ({src,ignore,contentPromise: contentPromise.then((content) => composeCssLoader(content, src, curUrl)),}));const embedHTML = await getEmbedHTML(template, processedCssList);return sandbox.replace ? sandbox.replace(embedHTML) : embedHTML;
}// getEmbedHTML(src/entry.ts)
async function getEmbedHTML(template, styleResultList: StyleResultList): Promise<string> {let embedHTML = template;return Promise.all(styleResultList.map((styleResult, index) =>styleResult.contentPromise.then((content) => {if (styleResult.src) {embedHTML = embedHTML.replace(genLinkReplaceSymbol(styleResult.src),styleResult.ignore? `<link href="${styleResult.src}" rel="stylesheet" type="text/css">`: `<style>/* ${styleResult.src} */${content}</style>`);} else if (content) {embedHTML = embedHTML.replace(getInlineStyleReplaceSymbol(index),`<style>/* inline-style-${index} */${content}</style>`);}}))).then(() => embedHTML);
}
sandbox.active()
在 startApp
中,有: await newSandbox.active()
方法,作用是子应用激活,同步路由,动态修改 iframe 的 fetch(对 fetch 进行重写), 准备 shadow, 准备子应用注入(active
方法在 Wujie 类上 src/sandbox.ts
)
class Wujie {public async active(options: {url: string;sync?: boolean;prefix?: { [key: string]: string };template?: string;el?: string | HTMLElement;props?: { [key: string]: any };alive?: boolean;fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>;replace?: (code: string) => string;}): Promise<void> {const { sync, url, el, template, props, alive, prefix, fetch, replace } = options;// ...初始化配置// 等待 iframe 初始完成(在 src/iframe.ts 中 iframeGenerator进行赋值 => stopIframeLoading 方法)await this.iframeReady;// 处理子应用自定义fetch(重写子应用的fetch方法)const iframeWindow = this.iframe.contentWindow;const iframeFetch = fetch? (input: RequestInfo, init?: RequestInit) =>fetch(typeof input === "string" ? getAbsolutePath(input, (this.proxyLocation as Location).href) : input, init): this.fetch;if (iframeFetch) {iframeWindow.fetch = iframeFetch;this.fetch = iframeFetch;}// 处理子应用路由同步if (this.execFlag && this.alive) {// 当保活模式下子应用重新激活时,只需要将子应用路径同步回主应用syncUrlToWindow(iframeWindow);} else {// 先将url同步回iframe,然后再同步回浏览器urlsyncUrlToIframe(iframeWindow);syncUrlToWindow(iframeWindow);}// inject templatethis.template = template ?? this.template;/* 降级处理 */if (this.degrade) {// ...省略return;}if (this.shadowRoot) {// 预执行有容器,直接插入容器内this.el = renderElementToContainer(this.shadowRoot.host, el);if (this.alive) return;} else {// 预执行无容器,暂时插入iframe内部触发 Web Component 的 connectconst iframeBody = rawDocumentQuerySelector.call(iframeWindow.document, "body") as HTMLElement;this.el = renderElementToContainer(createWujieWebComponent(this.id), el ?? iframeBody);}// 将template渲染到shadowRootawait renderTemplateToShadowRoot(this.shadowRoot, iframeWindow, this.template);// 子应用样式打补丁 1、兼容:root选择器样式到:host选择器上 2、将@font-face定义到shadowRoot外部this.patchCssRules();// inject shadowRoot to appthis.provide.shadowRoot = this.shadowRoot;}
}// 创建 `wujie-app` 组件。
export function createWujieWebComponent(id: string): HTMLElement {const contentElement = window.document.createElement("wujie-app");contentElement.setAttribute(WUJIE_APP_ID, id);contentElement.classList.add(WUJIE_IFRAME_CLASS);return contentElement;
}// 将准备好的内容插入容器
export function renderElementToContainer(element: Element | ChildNode,// 插入的容器selectorOrElement: string | HTMLElement
): HTMLElement {const container = getContainer(selectorOrElement);if (container && !container.contains(element)) {// 有 loading 无需清理,已经清理过了if (!container.querySelector(`div[${LOADING_DATA_FLAG}]`)) {// 清除内容clearChild(container);}// 插入元素if (element) {rawElementAppendChild.call(container, element);}}return container;
}
sandbox.start()
start
启动子应用:1、运行 js 2、处理兼容样式
startApp
的代码中,有: await newSandbox.start()
方法,作用是将解析的脚本插入到子应用 iframe 内,这里如果开启了 fiber
并且浏览器支持,可以在预加载时优化一些性能,在空闲时间去执行该操作
使用的是
requestIdleCallback
,执行insertScriptToIframe
将 脚本插入到 iframe 中。
insertScriptToIframe
在插入脚本时,需要改变脚本的 window
, self
, global
, location
的指向。
if (!iframeWindow.__WUJIE.degrade && !module && attrs?.type !== "importmap") {code = `(function(window, self, global, location) {${code}
}).bind(window.__WUJIE.proxy)(window.__WUJIE.proxy,window.__WUJIE.proxy,window.__WUJIE.proxy,window.__WUJIE.proxyLocation,
);`;
}
class Wujie {public async start(getExternalScripts: () => ScriptResultList): Promise<void> {this.execFlag = true;// 执行脚本const scriptResultList = await getExternalScripts();// 假如已经被销毁了if (!this.iframe) return;const iframeWindow = this.iframe.contentWindow;// 标志位,执行代码前设置iframeWindow.__POWERED_BY_WUJIE__ = true;// 用户自定义代码前const beforeScriptResultList: ScriptObjectLoader[] = getPresetLoaders("jsBeforeLoaders", this.plugins);// 用户自定义代码后const afterScriptResultList: ScriptObjectLoader[] = getPresetLoaders("jsAfterLoaders", this.plugins);// 同步代码const syncScriptResultList: ScriptResultList = [];// async代码无需保证顺序,所以不用放入执行队列const asyncScriptResultList: ScriptResultList = [];// defer代码需要保证顺序并且DOMContentLoaded前完成,这里统一放置同步脚本后执行const deferScriptResultList: ScriptResultList = [];scriptResultList.forEach((scriptResult) => {if (scriptResult.defer) deferScriptResultList.push(scriptResult);else if (scriptResult.async) asyncScriptResultList.push(scriptResult);else syncScriptResultList.push(scriptResult);});// 插入代码前beforeScriptResultList.forEach((beforeScriptResult) => {this.execQueue.push(() =>this.fiber? this.requestIdleCallback(() => insertScriptToIframe(beforeScriptResult, iframeWindow)): insertScriptToIframe(beforeScriptResult, iframeWindow));});// 同步代码syncScriptResultList.concat(deferScriptResultList).forEach((scriptResult) => {this.execQueue.push(() =>scriptResult.contentPromise.then((content) =>this.fiber? this.requestIdleCallback(() => insertScriptToIframe({ ...scriptResult, content }, iframeWindow)): insertScriptToIframe({ ...scriptResult, content }, iframeWindow)));});// 异步代码asyncScriptResultList.forEach((scriptResult) => {scriptResult.contentPromise.then((content) => {this.fiber? this.requestIdleCallback(() => insertScriptToIframe({ ...scriptResult, content }, iframeWindow)): insertScriptToIframe({ ...scriptResult, content }, iframeWindow);});});//框架主动调用mount方法this.execQueue.push(this.fiber ? () => this.requestIdleCallback(() => this.mount()) : () => this.mount());//触发 DOMContentLoaded 事件const domContentLoadedTrigger = () => {eventTrigger(iframeWindow.document, "DOMContentLoaded");eventTrigger(iframeWindow, "DOMContentLoaded");this.execQueue.shift()?.();};this.execQueue.push(this.fiber ? () => this.requestIdleCallback(domContentLoadedTrigger) : domContentLoadedTrigger);// 插入代码后afterScriptResultList.forEach((afterScriptResult) => {this.execQueue.push(() =>this.fiber? this.requestIdleCallback(() => insertScriptToIframe(afterScriptResult, iframeWindow)): insertScriptToIframe(afterScriptResult, iframeWindow));});//触发 loaded 事件const domLoadedTrigger = () => {eventTrigger(iframeWindow.document, "readystatechange");eventTrigger(iframeWindow, "load");this.execQueue.shift()?.();};this.execQueue.push(this.fiber ? () => this.requestIdleCallback(domLoadedTrigger) : domLoadedTrigger);// 由于没有办法准确定位是哪个代码做了mount,保活、重建模式提前关闭loadingif (this.alive || !isFunction(this.iframe.contentWindow.__WUJIE_UNMOUNT)) removeLoading(this.el);this.execQueue.shift()();// 所有的execQueue队列执行完毕,start才算结束,保证串行的执行子应用return new Promise((resolve) => {this.execQueue.push(() => {resolve();this.execQueue.shift()?.();});});}
}
上述流程:
sandbox.destroy()
子应用销毁
class Wujie {/** 销毁子应用 */public destroy() {this.bus.$clear();// thi.xxx = null;// 清除 domif (this.el) {clearChild(this.el);this.el = null;}// 清除 iframe 沙箱if (this.iframe) {this.iframe.parentNode?.removeChild(this.iframe);}// 删除缓存deleteWujieById(this.id);}
}
1.2.5 无界运行流程
1.2.6 实现一个简单的无界
核心:
- 创建 web component
- 创建沙箱(沙箱包含 iframe、shadowRoot)
- 创建 shadowDOM
- 通过请求获取到子应用内容(html、css、js)
- 将 html、css 合并为 template,放入 shadowDOM 中
- 将 js 放入沙箱中执行(需要对 iframe 中的属性进行代理)
主应用(index.html)
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Document</title></head><body><div>主应用内容(不能被子应用样式污染)</div><!-- 子应用容器 --><div id="sub-container"></div><script>console.log("主应用", window.a); // 主应用undefined</script><script>function createWujieAppElement() {class WujieApp extends HTMLElement {async connectedCallback() {// 1. 创建沙箱const sandbox = createSandbox();// 2. 创建shadowDOMsandbox.shadowRoot = this.attachShadow({ mode: "open" });// 3. 通过请求获取到子应用内容(html、css、js)const { template, css, js } = await importHtml("./sub.html");// 4. 将html、css放入shadowDOM中const templateAndCSS = mergeTemplateAndCSS(template, css);injectTemplate(sandbox, templateAndCSS);// 5. 将js放入沙箱中执行runScriptInSandbox(sandbox, js);}}window.customElements.define("wujie-app", WujieApp);const subContainer = document.getElementById("sub-container");subContainer.appendChild(document.createElement("wujie-app"));}// 类似startAppcreateWujieAppElement();// 创建沙箱function createSandbox() {const sandbox = {iframe: createIframe(),shadowRoot: null,};return sandbox;}// 创建iframefunction createIframe() {const iframe = document.createElement("iframe");iframe.src = "about:blank"; // 默认空连接iframe.style.display = "none";document.body.appendChild(iframe);return iframe;}// 通过请求解析html、css、jsasync function importHtml(url) {const html = await Promise.resolve(fetch(url).then((res) => res.text()));const parser = new DOMParser();const doc = parser.parseFromString(html, "text/html");// 提取 HTML(去除 script 和 style 标签)const template = extractHTML(doc);// 提取 CSS 内容const css = extractCSS(doc);// 提取 JS 内容const js = extractJS(doc);return {template,css,js,};}// 提取 HTML 内容(去除 script 和 style 标签)function extractHTML(doc) {const cloneDoc = doc.documentElement.cloneNode(true);const scripts = cloneDoc.querySelectorAll("script");const styles = cloneDoc.querySelectorAll("style");scripts.forEach((script) => script.remove());styles.forEach((style) => style.remove());return new XMLSerializer().serializeToString(cloneDoc);}// 提取 CSS 内容function extractCSS(doc) {const styleElements = doc.querySelectorAll("style");let cssText = "";styleElements.forEach((style) => {cssText += style.innerHTML;});return cssText;}// 提取 JS 内容function extractJS(doc) {const scriptElements = doc.querySelectorAll("script");let jsText = "";scriptElements.forEach((script) => {if (script.src) {// 如果是外链脚本,可能需要异步加载console.warn("外链脚本暂不支持直接提取", script.src);} else {jsText += script.innerHTML + "\n";}});return jsText;}// 合并模板和样式,将 CSS 插入到 HTML 的 <head> 中function mergeTemplateAndCSS(template, css) {const parser = new DOMParser();const doc = parser.parseFromString(template, "text/html");// 创建 style 元素const styleElement = doc.createElement("style");styleElement.textContent = css;// 将 style 插入到 head 中const head = doc.querySelector("head");if (head) {head.appendChild(styleElement);} else {// 如果没有 head,则创建一个并添加到 html 元素中const htmlElement = doc.querySelector("html");const headElement = doc.createElement("head");headElement.appendChild(styleElement);htmlElement.insertBefore(headElement, htmlElement.firstChild);}return new XMLSerializer().serializeToString(doc);}// 将 template 插入到 shadowRoot 中function injectTemplate(sandbox, template) {const warpper = document.createElement("div");warpper.innerHTML = template;sandbox.shadowRoot.appendChild(warpper);}// 运行js(将js放入iframe的head标签中就行)function runScriptInSandbox(sandbox, script) {const iframeWindow = sandbox.iframe.contentWindow;const scriptElement = iframeWindow.document.createElement("script");// 获取head将script插入const headElement = iframeWindow.document.querySelector("head");/*** 子应用中,如果打印window,获取到的是父应用的,* 我们希望在脚本执行之前,有些方法是父应用的,有些方法是子应用的* 例如1:document.querySelector('#sub-text'),就不是iframe的,而是获取shadowRoot的* 例如2:添加弹窗,document.createElement('div').appendChild(div),不能在iframe中,需要代理到主应用* 例如3:路由也需要同步到主应用*/Object.defineProperty(iframeWindow.Document.prototype, "querySelector", {get() {// 如果加载的脚本内部调用了 querySelector,则代理到 shadowRoot 中/*** 问:为什么sandbox.shadowRoot这个影子dom有querySelector属性?* ✅ shadowRoot 是一个 ShadowRoot 对象,它继承自 DocumentFragment 接口,* 在 DOM 标准中,ShadowRoot 同样拥有完整的 DOM 操作能力,包括:querySelectorquerySelectorAllgetElementByIdappendChild*/// 当子应用iframe调用document.querySelector,相当于调用 sandbox.shadowRoot['querySelector']return new Proxy(sandbox.shadowRoot["querySelector"], {apply(target, thisArg, args) {/*** target:原始方法* thisArg:this指向(document)* args:参数*/// thisArg 是 iframe 的 document,去指向 shadowRootreturn thisArg.querySelector.apply(sandbox.shadowRoot, args);},});},});scriptElement.textContent = script;headElement.appendChild(scriptElement);}</script></body>
</html>
子应用(sub.html)
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Document</title><style>div {color: #fff;background-color: blue;}</style></head><body><div id="sub-text">子应用内容</div><script>window.a = 100; // 子应用不会影响到主应用console.log(window.a, "sub.html");const subEle = document.querySelector("#sub-text");console.log(subEle);</script></body>
</html>
2. 路由分发+资源处理
2.1 single-spa
single-spa 是通过**路由劫持(监听路由变化)**实现应用的加载(采用 SystemJS
),提供应用间公共组件加载及公共业务逻辑处理。子应用需要暴露固定的生命周期 bootstrap
、mount
、unmount
接入协议。主应用通过路由匹配实现对子应用生命周期的管理。
single-spa 的核心就是定义了一套协议。协议包含主应用的配置信息和子应用的生命周期,通过这套协议,主应用可以方便的知道在什么情况下激活哪个子应用。
主应用如何导入子应用(需要用户自定义实现,推荐使用 SystemJS + import maps),通信采用 props
<script type="systemjs-importmap">{"imports": {"@react-mf/root-config": "//localhost:9000/react-mf-root-config.js"}}
</script><script>singleSpa.registerApplication({name: "app", // 子应用名app: () => System.import("@react-mf/root-config"), // 如何加载你的子应用activeWhen: "/appName", // url 匹配规则,表示啥时候开始走这个子应用的生命周期customProps: {// 自定义 props,从子应用的 bootstrap, mount, unmount 回调可以拿到authToken: "xc67f6as87f7s9d",},});
</script>
缺点
- 学习成本高(systemjs)
- 无沙箱机制,需要自己实现 js 沙箱和 css 隔离
- 需要对原有的应用进行改造
- 子应用间相同资源重复加载的问题
2.1.1 补充:SystemJS
SystemJS 是一个模块规范,就好像 commonjs、AMD、ES6 等。
搭建 react 项目测试
先采用 webpack 搭建一个可以独立运行的 react 项目来测试:
- 安装依赖
pnpm i react react-dom
pnpm i webpack webpack-cli webpack-dev-server @babel/core @babel/preset-react @babel/preset-env babel-loader html-webpack-plugin -D
- 配置 webpack
const HtmlWebpackPlugin = require("html-webpack-plugin");
const path = require("path");module.exports = (env) => {return {mode: "development",output: {filename: "index.js",path: path.resolve(__dirname, "dist"),// 将子应用打包成类库,在主应用中进行加载,加载的方式采用systemjs// systemjs是一种模块规范libraryTarget: env.production ? "system" : "",},module: {rules: [{test: /\.js$/,exclude: /node_modules/,use: {loader: "babel-loader",},},],},devServer: {port: 3000,},plugins: [// 生成环境不生成html文件,因为最后打包后的资源,也就是js,通过systemjs进行引入!env.production &&new HtmlWebpackPlugin({template: "./public/index.html",}),].filter(Boolean),externals: env.production ? ["react", "react-dom"] : [],};
};
- 新建
.babelrc
文件,添加预设
{"presets": ["@babel/preset-env",["@babel/preset-react",{// 配置react的运行模式 automatic:automatic模式会自动引入react-dom和react-router-dom等依赖"runtime": "automatic"}]]
}
-
新增
public/index.html
文件,添加<div id="root"></div>
-
新增
src/index.js
和src/App.js
// src/index.js
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")).render(<App />);// src/App.js
import React from "react";
export default function App() {return <h1>hello world</h1>;
}
- 配置脚本
// package.json
{"scripts": {"dev": "webpack serve","build": "webpack --env production"}
}
将项目打包成 SystemJS 模块
将上述 react 测试项目执行 pnpm build
进行打包,可以看到打包的内容:通过 System.register()
方法将模块内容进行注册,并返回一个模块对象。
如果在 webpack 中配置了
externals
,将包排出在外后,在System.register()
的第一个参数中会传入模块的依赖包名称,并且在第二个参数返回的{}.setters
会进行模块替换。
- 使用了
externals
// 需要保证参数一的资源加载完毕后,再执行函数
System.register(["react", "react-dom"], function (__WEBPACK_DYNAMIC_EXPORT__, __system_context__) {var __WEBPACK_EXTERNAL_MODULE_react__ = {};var __WEBPACK_EXTERNAL_MODULE_react_dom__ = {};Object.defineProperty(__WEBPACK_EXTERNAL_MODULE_react__, "__esModule", { value: true });Object.defineProperty(__WEBPACK_EXTERNAL_MODULE_react_dom__, "__esModule", { value: true });return {setters: [function (module) {Object.keys(module).forEach(function (key) {// react对象,上面有 useRef, useState 属性,key就是这些属性__WEBPACK_EXTERNAL_MODULE_react__[key] = module[key];});},function (module) {Object.keys(module).forEach(function (key) {__WEBPACK_EXTERNAL_MODULE_react_dom__[key] = module[key];});},],execute: function () {},};
});
- 未使用
externals
System.register([], function (__WEBPACK_DYNAMIC_EXPORT__, __system_context__) {return {execute: function () {},};
});
将 react 项目通过打包,打包成了 systemjs
,后续可以直接使用这个模块了,但是需要通过 systemjs
来进行加载。
如果在主应用中去使用 systemjs 模块
主应用需要安装 systemjs
,通过 System.import()
方法进行加载(加载打包后的模块)。
如果在打包的时候,排出了第三方依赖,需要配置
import maps
通过 cdn 加载第三方资源。
<h1>主应用</h1>
<!-- 子应用挂载点 -->
<div id="root"></div>
<script type="systemjs-importmap">{"imports": {"react": "https://cdn.bootcdn.net/ajax/libs/react/18.2.0/umd/react.development.js","react-dom": "https://cdn.bootcdn.net/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js"}}
</script>
<!-- systemjs 模块加载器 -->
<script src="https://cdn.bootcdn.net/ajax/libs/systemjs/6.15.1/system.min.js"></script>
<script>// 直接加载子应用System.import("../dist/index.js");
</script>
如何手写 systemjs 规范
手写内容:
- system 是如何定义的(可以参考打包后的结果)
System.register(依赖列表,回调函数)
,回调函数返回一个对象,对象包含setters
和execute
。setters
是用来保存加载后的资源,打包内容中 webpack 采用变量替换了execute
是真正打包项目的逻辑(这个项目里也就是子应用真正的渲染逻辑)
setters
举例:例如打包排除了 react
和 react-dom
,在打包后的文件中就会出现两个变量 __WEBPACK_EXTERNAL_MODULE_react__
,__WEBPACK_EXTERNAL_MODULE_react_dom__
用作代替后续项目中需要依赖的这些变量的部分。
// 例如 ReactDOM.xxx => __WEBPACK_EXTERNAL_MODULE_react_dom__.xxx
__WEBPACK_EXTERNAL_MODULE_react_dom__[key] = module[key];
分析:
有一个 System
构造函数,上面有 import
和 register
方法。
整体思路:根据 importmap
来解析出 url 和 对应的模块,通过 load
方法去加载对应的模块(load 是通过创建 script 标签来加载),importmap
的第三方资源在加载后,会在 window
上挂载对应的模块(cdn 资源要是 umd 格式),例如:react 会加载 window.React
。在 System.import("../dist/index.js")
中加载的模块,是打包后的资源,内部是 webpack 通过变量替代了 cdn 加载的模块,加载后需要去替换掉对应的变量。
// 直接加载子应用(异步的)
System.import("../dist/index.js").then(() => {console.log("模块加载完毕");
});// dist/index.js
System.register([], fn);
具体手写代码:
<h1>主应用</h1>
<script type="systemjs-importmap">{"imports": {"react": "https://cdn.bootcdn.net/ajax/libs/react/18.2.0/umd/react.development.js","react-dom": "https://cdn.bootcdn.net/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js"}}
</script>
<div id="root"></div>
<script>class SystemJs {import(id) {// id 是资源的路径,可以是第三方cdn资源路径return Promise.resolve().then(() => {// 1. 解析importMapthis.#processScript();// 2. 去当前路径去找对应资源路径// http://127.0.0.1:5500/mini-systemjs/main/main.html,找到最后一个 / 的位置,前面的就是路径const lastSepIndex = location.href.lastIndexOf("/");// http://127.0.0.1:5500/mini-systemjs/main/const baseUrl = location.href.slice(0, lastSepIndex + 1);// 当前是模拟本地路径,所以这里“写死了“if (id.startsWith("../dist")) {return baseUrl + id;}}).then((id) => {// 这里的id就是上个Promise返回的baseUrl + id/*** id: http://127.0.0.1:5500/mini-systemjs/main/../dist/index.js,* 浏览器会转为 http://127.0.0.1:5500/mini-systemjs/dist/index.js*/// 根据文件路径加载资源return this.#load(id);}).then((register) => {// register: [['react', 'react-dom'], function() {}] 是在load执行完成后,通过resolve传到这里的const [deps, declare] = register;/*** 因为这个 declare 为 function(__WEBPACK_DYNAMIC_EXPORT__, __system_context__) {* return { setters, execute }* }* setters:是用来保存加载后的资源,打包内容中webpack采用变量替换了* execute:是真正资源加载逻辑*/const { setters, execute } = declare(() => {}, {});Promise.all(deps.map((dep, i) => {return this.#load(dep).then(() => {// setters: [function(module) {}] 会传入 module 参数(例如:module -> react)// cdn加载完毕后,会在window上添加属性,例如:window.Reactconst property = this.#getLastGlobalProperty();setters[i](property);});})).then(() => {// 当对应变量替换好后,执行具体渲染逻辑execute();});});}#mapUrl = {};// 解析importMap#processScript() {Array.from(document.querySelectorAll("script")).forEach((script) => {if (script.type === "systemjs-importmap") {const imports = JSON.parse(script.innerHTML).imports;Object.entries(imports).forEach(([key, value]) => {this.#mapUrl[key] = value;});}});}// 加载资源#load(id) {return new Promise((resolve, reject) => {// 通过script来获取资源,不用fetch是因为fetch会跨域const script = document.createElement("script");script.src = this.#mapUrl[id] || id; // this.#mapUrl[id] 去映射表查找url资源,这样就可以支持cdn了script.async = true;// 此时会去执行脚本,在打包的文件中,有System.register方法去执行,接下来要去书写register方法document.head.appendChild(script);script.onload = () => {let _lastRegister = this.#lastRegister;this.#lastRegister = undefined;resolve(_lastRegister);};});}constructor() {this.#saveGlobalProperty();}#set = new Set();// 保存之前的window属性(快照)#saveGlobalProperty() {for (let k in window) {this.#set.add(k);}}// 获取window上最后添加的属性(新增属性,cdn导入后,会在window上添加属性)#getLastGlobalProperty() {for (let k in window) {if (this.#set.has(k)) continue;this.#set.add(k);return window[k];}}/*** @param {string[]} deps 依赖列表* @param {function} declare 声明函数*/register(deps, declare) {// 为了后续在load使用,保存在变量中// 等文件加载完成后,load中会将结果传递到下一个Promise中this.#lastRegister = [deps, declare];}#lastRegister;}const System = new SystemJs();// 直接加载子应用(异步的)System.import("../dist/index.js").then(() => {console.log("模块加载完毕");});
</script>
2.1.2 single-spa 使用
single-spa 是借助 systemjs 来实现模块的加载,通过路由匹配来切换子应用。
主应用:访问 index.html
就去加载 localhost:9000/wifi-root-config.js
资源,wifi-root-config.js
就去加载远程 www.baidu.com
内容。
// index.html
<script type="systemjs-importmap">{"imports": {// umd格式"@wifi/root-config": "//localhost:9000/wifi-root-config.js","@wifi/react": "//localhost:9001/wifi-react.js"}}
</script>
<script>System.import("@wifi/root-config")
</script>// wifi-root-config.js
import { registerApplication, start } from "single-spa";
// 注册应用(可以远程加载资源)
registerApplication({name: "@wifi/root-config", // 子应用名app: () => System.import("www.baidu.com"), // 如何加载你的子应用activeWhen: ["/"],// activeWhen: (location) => location.pathname.startsWith("/"),
});// 注册子应用(本地)
registerApplication({name: "@wifi/react", // 子应用名app: () => System.import("@wifi/react"), // 如何加载你的子应用 需要在 systemjs-importmap 中添加对应的资源activeWhen: ["/react"], // 访问 /react 就去加载react子应用
});start({// 默认是true,表示只有url发生变化时才会重新路由urlRerouteOnly: true,
});
子应用:子应用必须提供接入协议(暴露出对应生命周期)
// wifi-react.js
import React from "react";
import ReactDOM from "react-dom";
import singleSpaReact from "single-spa-react";
import Root from "./root.component";const lifecycles = singleSpaReact({React,ReactDOM,rootComponent: Root,errorBoundary(err, info, props) {return null;},
});
// 接入协议,子应用必须提供接入协议
export const { bootstrap, mount, unmount } = lifecycles;
2.1.3 single-spa 原理
- 预先注册子应用(激活路由、子应用资源、生命周期函数)
- 监听路由的变化,匹配到了激活的路由则加载子应用资源,顺序调用生命周期函数并最终渲染到容器
<script src="https://cdn.bootcdn.net/ajax/libs/single-spa/5.9.3/umd/single-spa.min.js"></script>
<script>// single-spa 基于路由的微前端// 如何接入已经写好的应用,对于 single-spa 而言,需要改写子应用(添加接入协议)/*** 接入协议:* - bootstrap:子应用启动* - mount:子应用挂载* - unmount:子应用卸载*/// cdn 的:singleSpaconst { registerApplication, start } = singleSpa;// app1 模拟子应用const app1 = {// bootstrap可以是一个数组,也可以是一个函数(数组好处:可以同时编写多个函数),函数是一个 promisebootstrap: [async () => console.log("app1 bootstrap1"), async () => console.log("app1 bootstrap2")],mount: async () => {// createApp(App).mount(#root)console.log("app1 mount");},unmount: async () => {console.log("app1 unmount");},};const app2 = {bootstrap: async () => {console.log("app2 bootstrap");},mount: async () => {console.log("app2 mount");},unmount: async () => {console.log("app2 unmount");},};// 参数1:应用名 参数2:加载的那个应用(必须要返回一个promise) 3. 路由匹配规则registerApplication("app1",async () => app1,(location) => location.hash.startsWith("#/app1"));registerApplication("app2",async () => app2,(location) => location.hash.startsWith("#/app2"));start();
</script>
2.2 qiankun
qiankun 是一个基于 single-spa 的微前端实现库。
优点
- 监听路由自动的加载、卸载当前路由对应的子应用
- 完备的沙箱方案
- js 沙箱做了
SnapshotSandbox
、LegacySandbox
、ProxySandbox
三套渐进增强方案 - css 沙箱做了两套
strictStyleIsolation
、experimentalStyleIsolation
两套适用不同场景的方案
- js 沙箱做了
- 路由保持,浏览器刷新、前进、后退,都可以作用到子应用
- 应用间通信简单,全局注入
- 增加资源预加载能力,预先子应用 html、js、css 资源缓存下来,加快子应用的打开速度
缺点
- 基于路由匹配,无法同时激活多个子应用,也不支持子应用保活
- css 沙箱无法绝对的隔离,js 沙箱在某些场景下执行性能下降严重
- 无法支持 vite 等 ESM 脚本运行(qiankun 使用
UMD
格式)
2.2.1 qiankun 使用
分别以 webpack
、vite
和 原生html
作为子应用为例,主子应用有路由和没路由使用有点不同(关键:在于路径是否配置正确)。
主应用 —— 不带路由
主应用采用
vite
+vue
创建,对主应用要求不大。
- 新建
registerMicroApps.ts
文件,用来注册子应用
import { registerMicroApps, start } from "qiankun";registerMicroApps([// 当匹配到 activeRule 的时候,请求获取 entry 资源,渲染到 container 中。{name: "webpack-vue", //子应用名称entry: "//localhost:7001", //子应用入口(html入口)container: "#container", //子应用挂载容器activeRule: "/webpack-vue", //子应用路由匹配规则loader: (loading) => {}, // loading 加载状态},],{// 可选参数beforeLoad: {},beforeMount: {},// ...}
);start();
- 在
main.ts
中引入registerMicroApps.ts
文件即可
import { createApp } from "vue";
import "./style.css";
import App from "./App.vue";
+ import "./registerMicroApps";createApp(App).mount("#app");
- 主应用需要给定一个渲染容器(通过
registerMicroApps
注册子应用的container
参数指定),子应用会渲染到该容器中。
<div id="container"></div>
可以参考下面 webpack 子应用,将子应用先接入到主应用。
主应用 —— 带路由
主应用如果跳转子应用的路由,由于主应用中没有注册这些路由,会有警告,可以在路由表配置:
const routes = [{path: "/webpack-vue/:pathMatch(.*)*",// 出口容器设置单独的页面component: () => import("./container.vue"),},
];
子应用如果是 vue2,对应 vue-router 是 3 的版本,跳转会有报错
具体内容可以看:https://juejin.cn/post/6956827508947812389
解决办法:
在主应用中的路由拦截中,对参数进行改写(vue3 的路由没有这个问题)
router.beforeEach((to, from, next) => {if (!window.history.state.current) window.history.state.current = to.fullPath;if (!window.history.state.back) window.history.state.back = from.fullPath;// 手动修改history的statereturn next();
});
webpack-vue 子应用 —— 不带路由
‼️ 注意:子应用必须要支持跨域,并且要求子应用暴露的方式是 UMD
格式
- 先配置 webpack
这里通过
vue.config.js
进行配置
const { defineConfig } = require("@vue/cli-service");
module.exports = defineConfig({devServer: {port: 7001,headers: {"Access-Control-Allow-Origin": "*",},},configureWebpack: {output: {library: "webpack-vue",// 把微应用打包成 umd 库格式libraryTarget: "umd",// webpack 5 需要把 jsonpFunction 替换成 chunkLoadingGlobal// 主要用于动态加载(懒加载)模块时,定义全局变量名称,以确保多个 Webpack 运行时(runtime)共存时不会冲突。// 如果多个 Webpack 应用同时运行(例如微前端场景),可能会发生全局变量冲突,导致 chunk 加载失败。chunkLoadingGlobal: `webpackJsonp_webpack-vue`,},},
});
- 在 main.ts 中导出对应的接入协议
这个接入协议(bootstrap, mount, unmount)需要是异步的
import { type App as InsApp, createApp } from "vue";
import App from "./App.vue";let app: InsApp;
function render(props: any = {}) {const { container } = props;app = createApp(App);app.mount(container ? container.querySelector("#app") : "#app");
}// 非qiankun环境下
if (!window.__POWERED_BY_QIANKUN__) {render();
}export async function bootstrap() {console.log("vue app bootstraped");
}export async function mount(props: any) {render(props);
}export async function unmount(props: any) {app!.unmount();
}
- 解决
__POWERED_BY_QIANKUN__
类型报错问题
这是因为 ts 的 window 上没有 __POWERED_BY_QIANKUN__
属性,所以需要添加一个全局声明。
新建一个 global.d.ts
文件,内容如下:
declare global {interface Window {__POWERED_BY_QIANKUN__?: boolean;}
}// 添加 export {} 会将文件视为一个模块,这样全局声明就会正确生效。
export {};
- 解决图片不显示问题,新建
public-path.js
文件,并在入口文件导入即可
public-path.js
内容如下:
if (window.__POWERED_BY_QIANKUN__) {// 用于修改运行时的 publicPath,__INJECTED_PUBLIC_PATH_BY_QIANKUN__就是当前子应用自己的路径__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
main.ts
中导入:
+ import './public-path'
import { type App as InsApp, createApp } from 'vue'
import App from './App.vue'
添加之前,图片路径:
/img/vite.svg
添加之后:
http://localhost:7001/img/vite.svg
这里不仅可以解决子应用的图片在主应用路径不正确的问题,还可以解决子应用路由是懒加载,其他页面报错的问题。
子应用路由是懒加载,就需要配置,因为懒加载的路由打包后,跟图片一样,不在入口文件,需要去配置 PublicPath
。
webpack-vue 子应用 —— 带路由
- 配置
router
路由表
这个和正常项目配置路由一样,只不过需要添加路由前缀:createWebHistory(window.__POWERED_BY_QIANKUN__ ? '/webpack-vue/' : '/'),
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";const routes: Array<RouteRecordRaw> = [{path: "/",name: "home",component: () => import(/* webpackChunkName: "home" */ "../views/HomeView.vue"),},{path: "/about",name: "about",component: () => import(/* webpackChunkName: "about" */ "../views/AboutView.vue"),},
];const router = createRouter({history: createWebHistory(window.__POWERED_BY_QIANKUN__ ? "/webpack-vue/" : "/"),routes,
});export default router;
- 修改
main.ts
中的render
方法
function render(props: any = {}) {app = createApp(App)
+ app.use(router)
}
vite 子应用
由于 qiankun
需要子应用的格式为 UMD
,而 vite 开发模式是 ESM
,所以只能使用 vite-plugin-qiankun
这个插件来进行适配。
- 配置 vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import qiankun from "vite-plugin-qiankun";export default defineConfig({plugins: [vue(),qiankun("vite-vue", {// useDevMode:确定为开发模式useDevMode: true,}),],server: {port: 7002,// 允许跨域cors: true,// origin:用于定义开发调试阶段生成资源的 origin,解决静态资源不生效问题origin: `http://localhost:${7002}`,},build: {lib: {entry: "./src/main.ts", // 入口文件name: "vite-vue", // 子应用名称fileName: "vite-vue", // 打包后的文件名formats: ["umd"], // 打包为 UMD 格式},},
});
- 改造
main.ts
由于没有环境变量,需要从 vite-plugin-qiankun
包中进行导入。
import { createApp, type App as AppInstance } from "vue";
import App from "./App.vue";
import "./style.css";
import { renderWithQiankun, qiankunWindow } from "vite-plugin-qiankun/dist/helper";let app: AppInstance | null = null;
function render(props: any = {}) {const { container } = props;app = createApp(App);app.mount(container ? container.querySelector("#app") : "#app");
}if (!qiankunWindow.__POWERED_BY_QIANKUN__) {render();
}renderWithQiankun({mount(props) {render(props);},bootstrap() {console.log("bootstrap");},unmount(_props) {app?.unmount();app!._container.innerHTML = "";app = null;},update() {console.log("update");},
});
补充:UMD 格式
如果要使用 qiankun 改在原生子应用,需要知道一些 UMD
格式的知识。
这里拿 webpack 打包 umd 格式的例子来说明:
- index.js
包含:默认导出和按需导出
const num = 100;
export default num;export const str1 = "str1";
export const str2 = "str2";
- webpack 配置
/** @type {import('webpack').Configuration} */
const config = {entry: "./index.js",output: {filename: "bundle.js",libraryTarget: "umd",library: "Demo",},
};module.exports = config;
- 输出的产物,可以只看浏览器环境部分
root["Demo"] = factory();
root
为 下面的 self
,类型为:Window & typeof globalThis
,那么就可以通过 window["Demo"]
来访问到这个模块。
(function webpackUniversalModuleDefinition(root, factory) {if (typeof exports === "object" && typeof module === "object") module.exports = factory();else if (typeof define === "function" && define.amd) define([], factory);else if (typeof exports === "object") exports["Demo"] = factory();else root["Demo"] = factory();
})(self, () => {// self:Window & typeof globalThisreturn (() => {})();
});
- 浏览器运行结果
window["Demo"] = {default: 100,str1: "str1",str2: "str2",
};
原生子应用
前提:这个原生子应用,需要通过 url 进行访问,不能是本地路径。
<!-- 需要通过 http-server 启动页面,端口设置为 7003,允许跨域 -->
<!-- 或者通过 vscode live-server 插件启动页面 -->
<!-- 这里写好了:通过 npm run dev 启动(http-server --port 7003 --cors) -->
<div id="vanilla-root"></div><script>const root = document.getElementById("vanilla-root");// 导出最终接入协议即可window["sub-vanilla"] = {bootstrap: async () => {console.log("vanilla app bootstrap");},mount: async () => {console.log("vanilla app mount");root.innerHTML = "<h1>vanilla app</h1>";},unmount: async () => {console.log("vanilla app unmount");root.innerHTML = "";},};if (!window.__POWERED_BY_QIANKUN__) {root.innerHTML = "<h1>vanilla app</h1>";}
</script>
⚠️ 注意:对应原生子应用,可能由于渲染速度快的问题,导致主应用容器没有加载好,可以将 start
方法,放到主应用的 mount
方法中。拿 vue 举例:
<template><div id="container"></div>
</template><script setup lang="ts">
import { start } from "qiankun";
import { onMounted } from "vue";
onMounted(() => {if (!window.qiankunStarted) {window.qiankunStarted = true;start();}
});declare global {interface Window {qiankunStarted: boolean;}
}
</script>
手动加载子应用
通过 qiankun 提供的 loadMicroApp
方法,手动加载子应用。
<template><button @click="loadApp">点击加载子应用</button><div ref="containerRef"></div>
</template><script lang="ts" setup>import { loadMicroApp } from "qiankun";import { ref } from "vue";const containerRef = ref<null | HTMLElement>(null);const loadApp = () => {if (!containerRef.value) {return;}loadMicroApp({name: "sub-vanilla",entry: "//localhost:7003",container: containerRef.value as HTMLElement,});};
</script>
2.2.2 主子应用通信
props 方式
主应用在注册子应用时,可以传入 props 参数,子应用在接入协议(生命周期)中,可以拿到传递的 props 参数。
// 主应用
registerMicroApps([{name: "webpack-vue",entry: "//localhost:7001",container: "#container",activeRule: "/webpack-vue",props: {propsData: "propsData",},},
]);// 子应用
export async function mount(props: any) {console.log("vue app mount", props);render(props);
}
actions:initGlobalState(state)
qiankun 提供了 initGlobalState
方法,用于初始化全局状态,并返回一个对象,对象中包含 onGlobalStateChange
和 setGlobalState
方法。
主应用:
import { initGlobalState, MicroAppStateActions } from "qiankun";// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state);
/*** onGlobalStateChange: (callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) => void* 在当前应用监听全局状态,有变更触发 callback,fireImmediately = true 立即触发 callback*/
actions.onGlobalStateChange((state, prev) => {// state: 变更后的状态; prev 变更前的状态console.log(state, prev);
}, true);
// 移除当前应用的状态监听,微应用 umount 时会默认调用
actions.offGlobalStateChange();
子应用:
export function mount(props) {props.onGlobalStateChange((state, prev) => {// state: 变更后的状态; prev 变更前的状态console.log(state, prev);});props.setGlobalState(state);
}
全局事件总线 EventBus
利用 window 对象作为全局通信桥梁,主应用和子应用都可以访问 window,可以在其中挂载一个全局事件中心(如 EventEmitter),用于跨应用通信。
主应用:
- 创建 eventbus 类
// src/utils/EventBus.ts
class EventBus {private events: Record<string, Function[]> = {};on(eventName: string, callback: Function) {if (!this.events[eventName]) {this.events[eventName] = [];}this.events[eventName].push(callback);}emit(eventName: string, data?: any) {const callbacks = this.events[eventName];if (callbacks) {callbacks.forEach((cb) => cb(data));}}off(eventName: string, callback: Function) {const index = this.events[eventName]?.indexOf(callback);if (index !== -1) {this.events[eventName].splice(index!, 1);}}once(eventName: string, callback: Function) {const wrapper = (data: any) => {callback(data);this.off(eventName, wrapper);};this.on(eventName, wrapper);}
}export default new EventBus();
- 挂载到 window
// src/main.ts
import eventBus from "./utils/EventBus";
(window as any).customEventBus = eventBus;
- 监听子应用发送的消息
(window as any).customEventBus.on("from-child", (msg) => {console.log("主应用收到子应用消息:", msg);
});
子应用:
(window as any).customEventBus.emit("from-child", {content: "Hello from child app!",
});
2.2.3 qiankun 样式隔离
css 样式隔离方案:
- css-module:为 CSS 类名生成唯一的哈希值
- scoped:Vue 特有
- css-in-js:自动生成唯一的类名,类名哈希化
- bem: 一种命名规范,bem(block, element, modifier)
- shadow-dom:创建一个隔离的 DOM 树,样式作用域仅限于 Shadow DOM 内部
- iframe:子应用在 iframe 中运行,样式隔离
在 qiankun 中,提供了 experimentalStyleIsolation
和 strictStyleIsolation
两种 API 方案,分别对应 css-module
和 shadow-dom
。
-
experimentalStyleIsolation
:会将import
导入的 css 文件,添加特定选择器,全部添加到<style>
标签中,并插入到子应用的<head>
中,从而实现样式隔离。缺点:子应用的样式不影响主应用,但子应用不能隔离主应用的全局样式。
-
strictStyleIsolation
:通过创建影子 DOM 的方式进行样式隔离。缺点:但是因为是完全隔离,但是如果子应用的 DOM 挂载到外层(例如
body
上),会导致样式不生效。最好的办法就是不挂载到body
上。
⚠️ 注意:在 vite 作为子应用时,通过 import 'style.css'
引入的样式会作用到主应用中,即使开启了影子 DOM 和 CSS Module 也不起作用。(也可以通过不要全局导入样式来解决)
因为 Vite 在开发模式下采用的是 ESM 模块化加载方式,对于 ESM 动态导入的样式文件(如
import './style.css'
)并不会自动处理。因为 qiankun 通过 fetch 来获取子应用资源,我们可以通过请求去看 vite 项目的 url:是没有 css 文件的,而是进入
main.ts
后才进行加载。
而 webpack 会将这个样式文件打包到 js 文件中,并返回给主应用。(css 在下图的
app.js
中)
因此,在 Vite 子应用中直接写
import './style.css'
会将样式插入到全局<head>
中,从而影响主应用。从下图可以明显的看到这个子应用样式表是会加载到主应用中的(所以需要给子应用添加前缀,避免和主应用冲突)
这时候需要借助 postcss 插件,将给子应用项目添加前缀即可。(缺点:会导致独立运行的子项目样式不会生效,因为没有容器)
- 安装
pnpm i postcss-prefix-selector
- vite.config.ts 配置
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import qiankun from "vite-plugin-qiankun";
import prefixer from "postcss-prefix-selector";export default defineConfig({// ...css: {postcss: {plugins: [prefixer({// 配置子应用渲染容器prefix: "#container",transform(prefix, selector) {// 处理选择器转换逻辑return `${prefix} ${selector}`;},}),],},},
});
到这里基本的样式已经实现隔离,但是如果引入了组件库样式,又会出现问题,这里拿 element-plus
为例:
从图中可以看到,之前编写的 postcss
前缀转换函数,将 :root
也添加上了前缀,这个就出现了问题。
⚠️ 这里需要注意:伪类
:root
只能单独使用,表示文档的根元素(即<html>
标签),不能与其它选择器组合使用(如#container:root
)这个写法是错误的,导致组件库组件不能获取到正确的变量。
这里我们只需要在前缀转换函数添加一个一个判断即可:
import prefixer from 'postcss-prefix-selector';
export default defineConfig({// ...css: {postcss: {plugins: [prefixer({prefix: '#container',transform(prefix, selector) {
+ if (selector === ':root') {
+ return selector;
+ }// 处理选择器转换逻辑return `${prefix} ${selector}`;},}),],},},
});
除了上面这种办法,也可以在主应用中也同样引入一份 element-plus
组件库的样式,但是这样使用不是很优雅,不是很推荐。
💣💣💣 这里有发现一个 bug:就是当使用 Message
消息提示组件的时候,由于这个组件库样式,都加了前缀(除了:root
),会导致该组件样式丢失,原因很简单:就是这个组件不是挂载在 prefix: '#container'
这个前缀容器中的,所以在开发的时候,可以将一些能挂载到内部的组件就挂载到内部。
2.2.4 qiankun 沙箱
在前端开发中,为了保证不同应用之间的代码不会相互干扰,我们需要使用 JS 隔离机制,通常称为沙箱。在乾坤框架中,我们使用三种不同的 JS 隔离机制,分别是快照沙箱(SnapshotSandbox
),支持单应用的代理沙箱(LegacySandbox
),支持多应用的代理沙箱(ProxySandbox
)。由于后面两个都是基于 Proxy
来实现的,因此也可以分为快照沙箱和Proxy 代理沙箱。
- 快照沙箱(
SnapshotSandbox
)需要遍历 window 上的所有属性,性能较差。 - ES6 新增的 Proxy,产生了新的沙箱支持单应用的代理沙箱(
LegacySandbox
),它可以实现和快照沙箱一样的功能,但是性能更好,但是缺点是:也会和快照沙箱一样,污染全局的 window,因此它仅允许页面同时运行一个微应用。 - 而多应用的代理沙箱(
ProxySandbox
),可以允许页面同时运行多个微应用,并且不会污染全局的 window。
qiankun 默认使用 ProxySandbox
,可以通过 start.singular
来修改,如果发现浏览器不支持 Proxy
时,会自动优雅降级使用 SnapshotSandbox
。
SnapshotSandbox 快照沙箱
快照沙箱原理:A 应用启动前先保留 Window 属性,应用卸载掉的时候,把 A 修改的属性保存下来。等会新应用 B 进来我就把之前保留的原始未修改的 Window 给 B,应用 A 回来我就把 A 之前修改的属性还原回来。
这种方法比较浪费性能:因为需要给 Window 拍照,后续需要和 Window 进行对比。
简单实现:
class SnapshotSandbox {constructor() {this.windowSnapshot = {};this.modifiedPropsMap = {};}active() {// 当激活的时候,给 window 拍个快照this.windowSnapshot = {};Object.keys(window).forEach((key) => {this.windowSnapshot[key] = window[key];});// 如果是重新调用 active 的时候,表示又一次激活,需要将保存的属性还原回来Object.keys(this.modifiedPropsMap).forEach((key) => {window[key] = this.modifiedPropsMap[key];});}inactive() {// 需要记录全局哪些属性被修改了this.modifiedPropsMap = {};// 如果失活,需要和快照的 window 进行对比,将变化的内容存入modifiedPropsMapObject.keys(window).forEach((key) => {if (window[key] !== this.windowSnapshot[key]) {// 记录修改的属性this.modifiedPropsMap[key] = window[key];// 将 window 属性还原成最初样子window[key] = this.windowSnapshot[key];}});}
}const sandbox = new SnapshotSandbox();
sandbox.active();
window.a = 100;
window.b = 200;
sandbox.inactive();
console.log(window.a, window.b); // undefined undefinded
sandbox.active();
console.log(window.a, window.b); // 100 200
LegacySandbox 单应用的代理沙箱
LegacySandbox
原理:只存储修改或添加的属性,不用给 window 拍照。需要存储新增的属性(addPropsMap
),修改前的属性(modifiedPropsMap
)和记录所有修改后、新增的属性(currentPropsMap
)。后续的操作是通过 Proxy
代理空对象进行处理的,例如:sandbox.proxy.a
是读取的 window 上的属性,sandbox.proxy.a = 100
是需要记录修改或新增的值,并且修改 window 上的属性。
这种方法的优点:性能比快照沙箱好(不用去监听整个 window)。缺点:Proxy
的兼容性不好,如果两个应用同时运行,window 只有一个,可能会有冲突。
modifiedPropsMap
,addPropsMap
是对失活的时候,进行属性还原的
currentPropsMap
是用在激活时候,进行属性还原的
class LegacySandbox {constructor() {/*** modifiedPropsMap,addPropsMap是对失活的时候,进行属性还原的*/// 修改后,需要记录window上该属性的原值this.modifiedPropsMap = {};// 需要记录新增的内容this.addPropsMap = {};/*** currentPropsMap是用在激活时候,进行属性还原的*/// 需要记录所有(不管修改还是新增),对于window上属性的删除就是修改this.currentPropsMap = {};// 创建了一个假 Window 对象,fakeWindow => {}const fakeWindow = Object.create(null);const proxy = new Proxy(fakeWindow, {get: (target, key, receiver) => {// 当取值的时候,直接从 window 上取值return window[key];},set: (target, key, value, receiver) => {if (!window.hasOwnProperty(key)) {// 如果 window 上没有这个属性,则记录新增的属性this.addPropsMap[key] = value;} else if (!this.modifiedPropsMap.hasOwnProperty(key)) {// 如果 window 上有这个属性,并且之前没有修改过,需要记录原 window 上的值this.modifiedPropsMap[key] = window[key];}// 无论新增还是修改,都记录一份(变化后的值)this.currentPropsMap[key] = value;// 修改window上的属性,修改成最新的内容window[key] = value;},});this.proxy = proxy;}active() {// 激活时,恢复之前的内容Object.keys(this.currentPropsMap).forEach((key) => {this.setWindowProps(key, this.currentPropsMap[key]);});}inactive() {// 失活的时候,需要把修改的属性还原回去Object.keys(this.modifiedPropsMap).forEach((key) => {this.setWindowProps(key, this.modifiedPropsMap[key]);});// 如果新增了属性,在失活时需要移除Object.keys(this.addPropsMap).forEach((key) => {this.setWindowProps(key, undefined);});}// 设置window上的属性setWindowProps(key, value) {if (value === undefined) {// 移除后面新增的属性delete window[key];} else {// 变回原来的初始值window[key] = value;}}
}const sandbox = new LegacySandbox();
// 需要去修改代理对象,代理对象setter后会去修改window上的属性
sandbox.proxy.a = 100;
console.log(window.a, sandbox.proxy.a); // 100 100
sandbox.inactive();
console.log(window.a, sandbox.proxy.a); // undefined undefined
sandbox.active();
console.log(window.a, sandbox.proxy.a); // 100 100
ProxySandbox 多应用的代理沙箱
ProxySandbox
原理:产生各自的代理对象,读取的时候从代理对象上取值,没有再去读 window。修改的时候,设置代理对象,只需要修改代理对象即可,不会去操作 window。
优点:不会污染全局的 window。
class ProxySandbox {constructor() {// 控制激活和失活this.running = false;const fakeWindow = Object.create(null);this.proxy = new Proxy(fakeWindow, {get: (target, key) => {// 获取的时候,先从 fakeWindow 上取值,如果取不到,再从 window 上取值return key in target ? target[key] : window[key];},set: (target, key, value) => {if (this.running) {// 激活才去设置target[key] = value;}return true;},});}active() {if (!this.running) this.running = true;}inactive() {this.running = false;}
}const sandbox1 = new ProxySandbox();
const sandbox2 = new ProxySandbox();
sandbox1.active();
sandbox2.active();
// 修改不会影响window,不用去还原window
sandbox1.proxy.a = 100;
sandbox2.proxy.a = 200;
console.log(sandbox1.proxy.a, sandbox2.proxy.a); // 100 200
sandbox1.inactive();
sandbox2.inactive();
sandbox1.proxy.a = 200;
sandbox2.proxy.a = 400;
console.log(sandbox1.proxy.a, sandbox2.proxy.a); // 100 200
子应用在使用时:把 sandbox1.proxy
当作 window
参数传递给子应用,子应用调用 window 上的属性时,会去 sandbox1.proxy
上取值。(LegacySandbox
同理)
(function (window) {console.log(window.a); // 100
})(sandbox1.proxy);
2.2.5 补充:创建 JS 沙箱还有哪些方法?
evel 函数
eval()
函数会将传入的字符串当做 JS 代码进行执行。但是这个函数存在安全问题,为了安全性,通常我们通过 eval 控制代码在特定对象的作用域中运行。
可以通过将自定义对象作为 this
或者使用 with
语句来限制全局作用域。
with
:用于扩展作用域链,允许你在一个特定的对象范围内执行一段代码。它通常用于简化对对象属性的访问。例如:
const obj = {name: "Alice",age: 25, };with (obj) {console.log(name); // 输出: Aliceconsole.log(age); // 输出: 25 }
function createEvalSandbox(code, globalObject) {// 通过 eval 在指定的作用域中执行代码eval(`(function(global) {with(global) {${code}}})(globalObject)`);
}const sandboxGlobal = { name: "wifi" };
const code = `console.log(name);`;createEvalSandbox(code, sandboxGlobal); // 输出: wifi
Function 构造器
和 eval()
一样,Function
构造器也可以在特定的作用域中执行代码。
function createSandbox(code, globalObject) {const sandboxFunction = new Function("global",`with(global) {${code}}`);sandboxFunction(globalObject);
}// 示例
const sandboxGlobal = { name: "wifi" };
const code = `console.log(name);`;createSandbox(code, sandboxGlobal); // 输出: wifi
使用 Object.create() 和 with
通过 Object.create()
创建一个新的对象,并将其作为沙箱的作用域,使用 with
语句使得代码在这个对象的上下文中运行。
function createSandboxWithObjectCreate(code, globalObject) {const sandbox = Object.create(globalObject); // 创建一个新的对象,继承自 globalObjectwith (sandbox) {eval(code); // 使用 eval 执行代码,sandbox 作为作用域}
}// 示例
const sandboxGlobal = { foo: "bar" };
const code = `console.log(foo);`;createSandboxWithObjectCreate(code, sandboxGlobal); // 输出: bar
2.2.6 qiankun 源码分析
我们先从应用注册方法 registerMicroApps
和 启动方法 start
开始分析。
registerMicroApps
这个方法主要是进行应用的注册,在 single-spa
的基础上进行了封装,内部调用的是single-spa
的 registerApplication
方法。
这里主要是 registerApplication
的 app
参数,这个 app
方法不会立即执行,等到路径匹配到后再去执行,其中 await frameworkStartedDefer.promise
需要等待这个 promise 完成后才执行后续内容,这个 promise 完成是在 start
中调用的 frameworkStartedDefer.resolve()
方法(这个 start
方法后续会说),还有个核心是其中的 loadApp
方法。
// Deferred:暴露 Promise 的 resolve 和 reject 方法,使得你可以在类外部调用 resolve() 或 reject() 来控制这个 Promise 的状态。
const frameworkStartedDefer = new Deferred<void>();export function registerMicroApps<T extends ObjectType>(apps: Array<RegistrableApp<T>>, // 本次要注册的应用lifeCycles?: FrameworkLifeCycles<T> // 应用生命周期钩子
) {// 维护一个注册队列,防止应用被重复注册,返回未注册的应用 name属性就是拿来区分的const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));// 最新需要注册的应用microApps = [...microApps, ...unregisteredApps];// 循环注册未注册的应用unregisteredApps.forEach((app) => {// appConfig 剩余配置 例如:{ entry: 'http://localhost:9000/index.html', container: '#subapp-viewport' }// noop:空函数(lodash的)const { name, activeRule, loader = noop, props, ...appConfig } = app;// 基于 single-spa 的注册应用(路由劫持)registerApplication({name,app: async () => {loader(true);// 等待调用 start 方法调用后,才执行下面部分// 需要当前等待Promise调用完成,也就是等到后面执行qiankun的start方法时候,最后会将这个Promise调用resolve方法await frameworkStartedDefer.promise;const { mount, ...otherMicroAppConfigs } = (await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles))();// return 返回的是应用的接入协议return {mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],...otherMicroAppConfigs,};},activeWhen: activeRule,customProps: props,});});// 目前不会执行,会等到路径匹配到后执行app方法
}
Deffer 类:
export class Deferred<T> {promise: Promise<T>;resolve!: (value: T | PromiseLike<T>) => void;reject!: (reason?: any) => void;constructor() {this.promise = new Promise((resolve, reject) => {this.resolve = resolve;this.reject = reject;});}
}
loadApp
loadApp
主要做了下面这些事情:
importEntry
:将 html 文件解析为 template, css, jsimportEntry
的具体使用可以去看这个文章:https://juejin.cn/post/7445090940278276147- 根据样式隔离来生成对应的外层容器(分为两种:
css scoped
和shadowDOM
) - 创建 js 沙箱(
createSandboxContainer
方法) - 在沙箱中执行 js 脚本(
execScripts
方法) - 去“丰富“接入协议,提供给
single-spa
export async function loadApp<T extends ObjectType>(app: LoadableApp<T>,configuration: FrameworkConfiguration = {},lifeCycles?: FrameworkLifeCycles<T>
): Promise<ParcelConfigObjectGetter> {const { entry, name: appName } = app;// 给应用实例添加名字 `${appName}_${globalAppInstanceMap[appName]}`const appInstanceId = genAppInstanceIdByName(appName);const markName = `[qiankun] App ${appInstanceId} Loading`;const {singular = false,sandbox = true,excludeAssetFilter,globalContext = window,...importEntryOpts} = configuration;// 获取html文件,拿到js的执行器// execScripts 这个是执行真正的脚本逻辑const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);// trigger external scripts loading to make sure all assets are ready before execScripts calling// 获取额外的外部脚本,以确保在调用execScripts之前所有外部js都已准备就绪await getExternalScripts();// 单例模式需要保证上一个应用卸载后才能加载新应用if (await validateSingularMode(singular, app)) {await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);}// getDefaultTplWrapper:获取文件内容,对模版进行了处理(将 head 标签转为 qiankun-head)/*** 例如:
原内容:<head><title>Vue App</title><link rel="stylesheet" href="style.css"></head><body><div id="app"></div></body>
输出结果:<div id="__qiankun_microapp_wrapper_for_vue__" data-name="vue" data-version="2.9.8" data-sandbox-cfg="{...}"><qiankun-head><title>Vue App</title><link rel="stylesheet" href="style.css"></qiankun-head><body><div id="app"></div></body></div>*/const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);// shadowDom加载css方式const strictStyleIsolation = typeof sandbox === "object" && !!sandbox.strictStyleIsolation;// scoped作用域css的处理const scopedCSS = isEnableScopedCSS(sandbox);// 创建app的容器(创建div)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 结构已经设置完毕render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, "loading");// 拿到外层的html容器或者shadowRoot节点const initialAppWrapperGetter = getAppWrapperGetter(appInstanceId,!!legacyRender,strictStyleIsolation,scopedCSS,() => initialAppWrapperElement);// windowlet global = globalContext;let mountSandbox = () => Promise.resolve();let unmountSandbox = () => Promise.resolve();// 快照沙箱(true)const useLooseSandbox = typeof sandbox === "object" && !!sandbox.loose;// proxy沙箱const speedySandbox = typeof sandbox === "object" ? sandbox.speedy !== false : true;let sandboxContainer;if (sandbox) {// 创建沙箱容器sandboxContainer = createSandboxContainer(appInstanceId,initialAppWrapperGetter,scopedCSS,useLooseSandbox,excludeAssetFilter,global,speedySandbox);// 用沙箱的代理对象作为接下来使用的全局对象/*** (function(window) {* ...* })(sandbox.proxy)*/global = sandboxContainer.instance.proxy as typeof window;// 沙箱的挂载和卸载// mount:沙箱激活 unmount:沙箱失活mountSandbox = sandboxContainer.mount;unmountSandbox = sandboxContainer.unmount;}// execScripts在沙箱中运行,默认在执行前,需要给global(子应用window,假window),扩展自定义属性// 例如:global.__POWERED_BY_QIANKUN__ = true;等const {beforeUnmount = [],afterUnmount = [],afterMount = [],beforeMount = [],beforeLoad = [],} = mergeWith({}, getAddOns(global, assetPublicPath), lifeCycles, (v1, v2) => concat(v1 ?? [], v2 ?? []));// 这个方法用于将多个Promise合并为1个,通过then去链式调用,去依次执行/*** function() => [async fn1() => {},async fn2() => {},async fn3() => {},] 合并为 fn1.then(fn2).then(fn3) ...* */await execHooksChain(toArray(beforeLoad), app, global);// 根据指定的沙箱环境去执行脚本,execScripts内部通过eval+with实现const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {scopedGlobalVariables: speedySandbox ? cachedGlobals : [],});// 获取到应用导出的接入协议,可以使用了。可以获取window上最后新增的属性,这个属性就是拿到后的协议(因为是采用的UMD格式)// window["App1Name"]const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(scriptExports,appName,global,sandboxContainer?.instance?.latestSetProp);// 发布订阅const { onGlobalStateChange, setGlobalState, offGlobalStateChange }: Record<string, CallableFunction> =getMicroAppStateActions(appInstanceId);// FIXME temporary wayconst 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 remountingif (marks && !marks.length) {performanceMark(markName);}}},async () => {// 如果是单例模式,要保证之前应用的卸载if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {return prevAppUnmountedDeferred.promise;}return undefined;},// 应用程序挂载/重新挂载前的初始包装元素(外层div容器 / shadowDOM)async () => {appWrapperElement = initialAppWrapperElement;appWrapperGetter = getAppWrapperGetter(appInstanceId,!!legacyRender,strictStyleIsolation,scopedCSS,() => appWrapperElement);},// 添加 mount hook, 确保每次应用加载前容器 dom 结构已经设置完毕async () => {const useNewContainer = remountContainer !== initialContainer;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 containerappWrapperElement = createElement(appContent, strictStyleIsolation, scopedCSS, appInstanceId);syncAppWrapperElement2Sandbox(appWrapperElement);}render({ element: appWrapperElement, loading: true, container: remountContainer }, "mounting");},// 挂载沙箱(激活),上面通过 sandboxContainer.mount; 赋值mountSandbox,// 执行beforeMountasync () => execHooksChain(toArray(beforeMount), app, global),// 调用接入协议的mount方法async (props) => mount({ ...props, container: appWrapperGetter(), setGlobalState, onGlobalStateChange }),// finish loading after app mountedasync () => 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 unmountedasync () => {// 单例模式下,在外部可以调用Promise的resolve和rejectif (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() }),unmountSandbox,async () => execHooksChain(toArray(afterUnmount), app, global),async () => {render({ element: null, loading: false, container: remountContainer }, "unmounted");offGlobalStateChange(appInstanceId);// for gc 卸载操作,垃圾回收appWrapperElement = null;syncAppWrapperElement2Sandbox(appWrapperElement);},async () => {// 将promise设置为resolve,表示卸载操作已经完成if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {prevAppUnmountedDeferred.resolve();}},],};if (typeof update === "function") {parcelConfig.update = update;}return parcelConfig;};return parcelConfigGetter;
}
其中创建沙箱:createSandboxContainer
先判断浏览器是否支持 Proxy
, 不支持,创建 SnapshotSandbox
快照沙箱;支持的话,又需要区分是多例还是单例模式,去分别创建 ProxySandbox
(多例) 和 LegacySandbox
(单例)。然后返回 mount
和 unmount
方法,这两个方法和上面 【2.2.4 qiankun 沙箱】中说到的激活和失活是一致的。
export function createSandboxContainer(/** ...参数省略*/) {let sandbox: SandBox;if (window.Proxy) {sandbox = useLooseSandbox? new LegacySandbox(appName, globalContext): new ProxySandbox(appName, globalContext, { speedy: !!speedySandBox });} else {sandbox = new SnapshotSandbox(appName);}// ...省略return {instance: sandbox,// 沙箱被 mount,可能是从 bootstrap 状态进入的 mount,也可能是从 unmount 之后再次唤醒进入 mountasync mount() {sandbox.active();// ...省略},//恢复 global 状态,使其能回到应用加载之前的状态async unmount() {// ...省略sandbox.inactive();},};
}
丰富接入协议可以看下面这个图:
start
这个 start
方法内部也是采用的是 single-spa
的 start
方法。
export function start(opts: FrameworkConfiguration = {}) {// start参数中新增 prefetch预加载,默认为true singular单例模式frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };const { prefetch, urlRerouteOnly = defaultUrlRerouteOnly, ...importEntryOpts } = frameworkConfiguration;// 预加载策略if (prefetch) {doPrefetchStrategy(microApps, prefetch, importEntryOpts);}// 对沙箱来做降级处理frameworkConfiguration = autoDowngradeForLowVersionBrowser(frameworkConfiguration);// 运行 single-spa 的 start 方法startSingleSpa({ urlRerouteOnly });started = true;// 调用成功的promise => 也就是Promise.resolve()frameworkStartedDefer.resolve();
}
可以看一下这个预加载的实现:预加载传参的方式有很多,根据不同情况做了处理
// 预加载
export function doPrefetchStrategy(apps: AppMetadata[],prefetchStrategy: PrefetchStrategy,importEntryOpts?: ImportEntryOpts
) {const appsName2Apps = (names: string[]): AppMetadata[] => apps.filter((app) => names.includes(app.name));// prefetch: trueif (Array.isArray(prefetchStrategy)) {// 数组写法 prefetch: []prefetchAfterFirstMounted(appsName2Apps(prefetchStrategy as string[]), importEntryOpts);} else if (isFunction(prefetchStrategy)) {// 函数写法 prefetch: () => {return []}(async () => {const { criticalAppNames = [], minorAppsName = [] } = await prefetchStrategy(apps);prefetchImmediately(appsName2Apps(criticalAppNames), importEntryOpts);prefetchAfterFirstMounted(appsName2Apps(minorAppsName), importEntryOpts);})();} else {// prefetch: false | 'all' | undefinedswitch (prefetchStrategy) {case true:// 第一个应用加载完后,加载其他应用prefetchAfterFirstMounted(apps, importEntryOpts);break;case "all":prefetchImmediately(apps, importEntryOpts);break;default:break;}}
}
他们会去执行一个函数:prefetchAfterFirstMounted
,这个函数会监听 single-spa:first-mount
自定义事件,这个事件是在应用加载完成后派发的,然后再去加载未加载的应用。
方法中的 prefetch
采用的是 requestIdleCallback
,这个方法会在浏览器空闲时去加载。
function prefetchAfterFirstMounted(apps: AppMetadata[], opts?: ImportEntryOpts): void {// single-spa中默认内部会派发事件(dispatchEvent) single-spa:first-mounted,表示应用已经挂载完成window.addEventListener("single-spa:first-mount", function listener() {// 获取到所有未加载的应用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);}// 将没有加载的应用依次去加载 prefetch:使用requestIdleCallback去预加载notLoadedApps.forEach(({ entry }) => prefetch(entry, opts));// 移除事件监听window.removeEventListener("single-spa:first-mount", listener);});
}// prefetch方法
function prefetch(entry: Entry, opts?: ImportEntryOpts): void {// 如果网络不佳,不去加载if (!navigator.onLine || isSlowNetwork) {return;}requestIdleCallback(async () => {// 通过 import-html-entry 这个插件,替代systemjs,会拉去入口的html文件,去解析出js和css// 预加载入口文件const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);// 获取额外的样式表和脚本requestIdleCallback(getExternalStyleSheets);requestIdleCallback(getExternalScripts);});
}
2.2.7 qiankun 流程总结
- 注册微应用时通过 fetch 请求 HTML entry,然后正则匹配得到内部样式表、外部样式表、内部脚本、外部脚本
- 通过 fetch 获取外部样式表、外部脚本然后与内部样式表、内部脚本按照原来的顺序组合组合之前为样式添加属性选择器(data-微应用名称);将组合好的样式通过 style 标签添加到 head 中。(外部样式表也会被读取到内容后写入
<style>
中) - 创建 js 沙盒:不支持 Proxy 的用 SnapshotSandbox(通过遍历 window 对象进行 diff 操作来激活和还原全局环境),支持 Proxy 且只需要单例的用 LegcySandbox(通过代理来明确哪些对象被修改和新增以便于卸载时还原环境),支持 Proxy 且需要同时存在多个微应用的用 ProxySandbox(创建了一个 window 的拷贝对象,对这个拷贝对象进行代理,所有的修改都不会在 rawWindow 上进行而是在这个拷贝对象上),最后将这个 proxy 对象挂到 window 上面
- 执行脚本:将上下文环境绑定到 proxy 对象上,然后 eval 执行
注册采用的是 single-spa start 启动也是 single-spa 的方法,相比之下,qiankun 新增了下面的功能:
- 预加载功能:利用
requestIdleCallback
进行加载 - 沙箱功能:js 沙箱(创建 sandbox,让
execScript
方法运行在 sandbox 中) 样式隔离(影子 DOM,scoped css) - 获取导出的接入协议(在沙箱中执行的),并进行扩展,然后放入 single-spa 的接入协议中
3. Web Components
3.1 原生 Web Components
原生的 web components 最大好处在于样式完全隔离,上面讲述的无界也是因为样式隔离选择的此方案,但是这个方案也有很多缺点:
- 兼容性:浏览器兼容性差(但主流浏览器都支持)
- 组件间通信复杂::Web Components 的隔离性导致父子应用或兄弟组件间的通信需依赖自定义事件或全局状态管理,增加了复杂度
- 调试困难
在无界部分讲述了 web components 的使用,这里不过多赘述
3.2 Micro App
MicroApp 是京东开源的一款微前端框架,通过 web components + js 沙箱实现(Proxy 沙箱)。
MicroApp 的优点是接入成本低,只需要子应用支持跨域即可。缺点是:兼容性不好,没有降级方案。
MicroApp 的实现原理是:
- 创建 Web Components 组件
- 通过
HTML Entry
获取到 html,将模版放到 webcomponent 中 - css 做作用域隔离,js 做 Proxy 沙箱
(function (window, code) {new Function("window",`with(window) {${code}}`)(window); // 传入进去 proxyWindow,对应 new Function 的第一个参数 "window" })(proxyWindow, code);
- 执行完毕后,应用正常挂载
4. Module Federation:去中心化方案
Module Federation(以下简称 MF) 是 Webpack5 的新特性,借助于 MF, 我们可以在多个独立构建的应用之间,动态地调用彼此的模块。这种运行机制可以轻松地拆分应用,真正做到跨应用的模块共享。
去中心化:没有“基座”概念,任何一个容器可以是远程(remote)也可以是主机(host)。
缺点:没有沙箱机制,无法做到隔离。
- remote 共享模块
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;module.exports = (env = {}) => ({// ...plugins: [new ModuleFederationPlugin({/*** host应用* remotes: {* '@app': 'remote-app1@http://localhost:3001/remoteEntry.js'* }*/// 当前应用的名称,需要全局唯一,也是全局变量名,给 host 拼接 url 使用的name: "remote-app1",// host应用 引入的文件名(http://localhost:3001后面的文件名)filename: "remoteEntry.js",library: { type: 'var', name: 'remote-app1' }, // 共享模块的全局引用exposes: { // 导出的模块,只有在此申明的模块才可以作为远程依赖被使用"./Button": "./src/components/Button",},shared: ['vue'] // 远程加载的模块对应的依赖使用本地项目的依赖}),new HtmlWebpackPlugin({template: path.resolve(__dirname, "./public/index.html"),})],devServer: {port: 3001,}
});
- host 应用
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;module.exports = (env = {}) => ({// ...plugins: [new ModuleFederationPlugin({name: 'app2',filename: "remoteEntry.js",remotes: { // 引入远程应用的导出的模块, name@host address/filename.'@app': 'remote-app1@http://localhost:3001/remoteEntry.js'},shared: ['vue'] // 抽离的依赖与其它应用保持一致}),new HtmlWebpackPlugin({template: path.resolve(__dirname, "./public/index.html"),})],
});
在
shared
中定义公共依赖库,可以避免公共依赖重复加载
有一个库 EMP
也是采用的 Module Federation
,这个库自己实现了一个 cli
,可以快速搭建微前端项目,cli 帮忙简化了webpack的配置文件。
关于 Module Federation
的更多介绍,可以参考:
- https://juejin.cn/post/7502228042644619279
- https://juejin.cn/post/7286376634403192844