简介

微前端概念

  • 微前端的概念来自于后端微服务。
  • 微服务是一种开发软件的架构和组织方法
  • 其中软件由明确定义的 API 进行通信的小型独立服务组成。

微服务的主要思想

  • 将应用分解为小的,互相连接的微服务,一个微服务完成某个特定功能。
  • 每个微服务都有自己的业务逻辑和适配器,不同的微服务,可以使用不同的技术去实现。
  • 使用统一的网关 进行调用。

微服务的主要思路是化繁为简,通过更加细致的划分,使得服务内容更加内聚,服务之间耦合性降低,有利于项目的团队开发和后期维护。把微服务的概念应用到前端就是微前端。

微前端的发展史

  • 2014 年 MartinFowler 和 JamesLewis 共同提出了微服务的概念
  • 2018 年 第一个基于微前端的工具 single-spa 在 github 上开源
  • 2019 年 基于 single-spa 的 qiankun 框架问世
  • 2020 年 Module Federation(Webpack5)把项目中的模块分为本地模块和远程模块,远程模块在运行时异步从所谓的容器中加载。

微前端的特点

  • 技术栈无关 主框架不限制接入应用的技术栈,子应用可自主选择技术栈
  • 独立开发/部署 各团队之间,仓库独立,单独部署,互不依赖
  • 增量升级 当一个应用庞大之后,技术升级或重构相当麻烦,而为应用具备渐进式升级的特性
  • 独立运行时 微应用之间运行时互补依赖,有独立的状态管理。
  • 提升效率 应用越庞大 ->越难以维护 &协作低下,微应用可以很好拆分,提升效率

微前端要考虑的问题

或者说具备哪些能力

  • CSS 隔离 子应用之间样式互不影响,切换时装载和卸载
  • JS 沙箱 子应用之间互不影响,包括全局变量和事件
  • HTML Entry 匹配到子应用路由,先加载子应用入口的 html,解析 html 加载其他静态资源(CSS/JS)
    • Config Entry 的进阶版,但解析消耗留给了用户
  • 按需加载 切换页面才加载相应的资源,进入子应用路由,再去装载运行子应用
  • 公共依赖加载 将一些通用的工具方法、组件、甚至 npm 包抽取出来作为公共依赖,这样可以提升整体项目的体验
  • 父子应用通信 抽离公共依赖后,子应用如何调用父应用方法,父应用如何下发事件

微前端有哪些解决方案

抛开single-spaqiankun我们来看一下微前端有哪些实现方案。

基于 Iframe 完全隔离的方案

优点:

  • 非常简单,几乎无需任何改造
  • 完美隔离,JS/CSS 都是独立的运行环境
  • 不限制使⽤,⻚⾯上可以放多个 iframe 来组合业务

缺点:

  • 页面或状态切换,每次进来都要重新加载,状态不能保留
  • 完全的隔离导致与子应用的交互变得极其困难,无法与主应用进行资源共享
  • iframe 中的弹窗无法突破其自身,比如无法实现全屏弹窗
  • 整个应用全量资源加载,加载太慢

npm 包

将子应用封装成 npm 包,通过组件的方式引入,在性能和兼容性上是最优的方案,但却有一个致命的缺点,每次发版需要通知接入方同步更新,管理非常困难

webpack 构建时方案

纯 Web Component 构建方案

  1. google 推出的浏览器的原子组件,这里简单介绍下。它由三部分组成
  • Custom elements: 自定义元素
  • Shadow DOM: 用于将 DOM 树附加到元素上 (与主文档 DOM 分开) 并控制其关联的功能,不用担心与文档其他部分发生冲突。
  • HTML templates: <template> 和 <slot>元素可以编写不在页面中显示的标记模板,可以作为自定义元素的基础被多次重用
  1. Web Component 有以下优势
  • 技术栈无关 是浏览器原生的组件,任何框架都可以用
  • 独立开发 开发的应用无需与其他任何应用关联
  • 应用间隔离: ShadowDOM 的特性,各个引⼊的微应⽤间可以达到相互隔离的效果
  1. Web Component 不足之处
  • 兼容性 WebComponent 是一组技术的组合,部分特性还是存在一些兼容性问题
  • 成本高 虽然 WebComponent 是浏览器的 API,天生与技术栈无关,但目前使用的范围比较窄,改造起来成本大
  • 开发体验上相对差一些 这里是尤大总结的,WC VS Vue 组件
    • 一个声明式的、高效的模版系统
      • WC 不支持 作用域插槽
    • 一个响应式的,利于逻辑提取和重用的状态管理系统
    • 高性能的 SSR (服务端渲染,客户端激活)
      • WC 暂无服务端渲染方案

主流的微前端框架

基于sigle-spa 的路由劫持方法

sigle-spa 官网

  • 微前端系统有一个主应用和 N 个子应用组成。子应用要在主应用中注册(路由规则、各种资源、公共依赖等)
  • 路由劫持 跳转或首屏进入匹配到子应用路由,先加载主应用,主应用运行后再加载子应用,
  • 提供子应用生命周期管理 (注册、挂在、卸载)其中加载微应用的方法要自己写

生命周期

  • load 当应用匹配路由时就会加载脚本(非函数,只是一种状态)
  • bootstrap 引导函数 (对接 html,应用内容首次挂载到页面前调用)
  • mount 挂在函数
  • unmount 卸载函数(移除事件绑定等内容)
  • unload 非必要(unload 之后会重新启动 bootstrap 流程;借助 unload 可实现热更新)。

qiankun

官方文档

  • qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。
  • 通过import-html-entry包解析HTML获取对资源进行解析、加载。
  • 通过对执行环境的修改,它实现了 JS 沙箱、样式隔离等特性。

乾坤的运行流程

脚手架搭建

  • npx create-react-app micro-main-app 创建主应用
  • npx vue create micro-sub-app-vue 创建子应用

主应用接入

  • src/index.js 增加以下内容
1
2
3
4
5
6
7
8
9
10
import { registerMicroApps, start } from "qiankun";
registerMicroApps([
{
name: "vueApp",
entry: "//localhost:3001",
container: "#micro-container",
activeRule: "/app-vue",
},
]);
start();
  • src/app.jsx mock 路由跳转
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import { BrowserRouter as Router, Link } from "react-router-dom";
import { Menu } from "antd";
import "./App.css";
import { useState } from "react";
const menus = [
{
key: "/",
title: "主页",
path: "/",
label: <Link to="/">React主应用</Link>,
},
{
key: "/app-vue",
route: "/app-vue",
title: "vue微应用",
label: <Link to="/app-vue">vue微应用</Link>,
},
];

function App() {
const [selectedKey, setSelectKey] = useState(window.location.pathname);

let style = {
width: "100vw",
height: "100vh",
};

return (
<Router>
{/* <h1>主应用启动成功</h1> */}
<div className="App">
<Menu
selectedKeys={[selectedKey]}
style={{
width: 256,
}}
theme="dark"
mode="inline"
items={menus}
onSelect={(e) => setSelectKey(e.key)}
></Menu>
{selectedKey === "/" ? (
<div
style={Object.assign(
{
display: "flex",
marginTop: "10vh",
fontSize: "40px",
justifyContent: "center",
},
style
)}
>
React主应用
</div>
) : (
<div id="micro-container" style={style}></div>
)}
</div>
</Router>
);
}

export default App;

vue 子应用接入

  • vue.config.js 增加 devServer:{port: '3001'}
  • src/app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
render();
}

export async function bootstrap() {
console.log("[vue] app bootstraped");
}
export async function mount(props) {
console.log("[vue] props from main framework mount", props);
render(props);
}
export async function unmount() {
instance.$destroy();
instance = null;
}

启动主应用和子应用

配置 nginx

在生产环境中,我们一般通过 nginx 配置静态资源的访问,

  • 配置主应用资源及子应用资源可访问
  • 所有前端路由都找主应用的 index.html
  • 匹配到子应用路由,主应用去加载子应用的 index.html,解析 html 并运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const Koa = require("koa");
const serve = require("koa-static");
const path = require("path");
const fs = require("fs");

const app = new Koa();

// 主应用
app.use(serve(path.resolve(__dirname, "./example/micro-main-app/build")));
// 子应用 资源文件
const app1Files = serve(
path.resolve(__dirname, "./example/micro-sub-app-vue/dist/")
);
app.use(async function (ctx, next) {
if (/^\/app-vue\//.test(ctx.req.url) && path.extname(ctx.req.url)) {
// 加载子应用资源
ctx.req.url = ctx.req.url.replace(/\/app-vue/, "");
return await app1Files.apply(this, [ctx, next]);
} else {
// 前端路由都走主应用
let text = await new Promise((resolve, reject) => {
fs.readFile(
path.resolve(__dirname, "./example/micro-main-app/build/index.html"),
"utf-8",
function (error, data) {
if (error) return reject(error);
resolve(data);
}
);
});
ctx.body = text;
next();
}
});

app.listen(8000, () => {
console.log("app start at port 8000");
});

常见问题

动态加/卸载

主应用通过 loadMicroApp / unloadMicroApp 方法,子应用直接导出异步函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 主应用
import { loadMicroApp, unloadMicroApp } from 'qiankun';

loadMicroApp({
name: 'sub-app',
entry: '//localhost:8080',
container: '#sub-app-container',
});

unloadMicroApp('sub-app');

// 子应用
export async function mount / unmount() {
// 子应用的挂载逻辑
}
主子通信

主应用通过 onGlobalStateChange方法来监听子应用的状态变化
子应用通过 window.__POWERED_BY_QIANKUN__ 全局变量判断当前应用是否运行在 qiankun 微前端环境中,通过 window.parent 访问主应用的全局对象

1
2
3
4
5
6
7
8
9
10
11
12
13
// 主应用
import { onGlobalStateChange } from 'qiankun';

onGlobalStateChange((state, prev) => {
// 子应用状态变化的回调函数
});

// 子应用
if (window.__POWERED_BY_QIANKUN__) {
// 子应用运行在 qiankun 微前端环境中
}

window.parent.postMessage({ type: 'message', data: 'hello' }, '*');
子子通信

子应用中,通过 window.dispatchEvent方法触发自定义事件

1
2
3
4
5
6
window.dispatchEvent(new CustomEvent('message', { detail: 'hello' }));
// 在其他子应用中,可以通过 window.addEventListener 方法来监听自定义事件,例如:

window.addEventListener('message', (event) => {
console.log(event.detail); // 'hello'
});
路由跳转

主应用中,可以通过 setMatchedPath 方法设置当前子应用的路由路径,通过 onGlobalStateChange 方法来监听子应用的路由变化
子应用通过 history.pushState 方法

1
2
3
4
5
6
7
8
9
10
11
// 主应用
import { setMatchedPath, onGlobalStateChange } from 'qiankun';

setMatchedPath('/path');

onGlobalStateChange((state, prev) => {
console.log(state.matchedPath); // '/path'
});

// 子应用
history.pushState(null, null, '/path');
样式隔离

主应用通过 prefetch 方法来预加载子应用的样式,通过 mount 方法的 sandbox 参数来开启样式隔离
子应用通过 CSS ModulesCSS-in-JS 等技术来实现样式隔离

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 主应用
import { prefetch, loadMicroApp } from 'qiankun';

prefetch('//localhost:8080/index.css');

loadMicroApp({
name: 'sub-app',
entry: '//localhost:8080',
container: '#sub-app-container',
sandbox: { strictStyleIsolation: true },
});

// 子应用
import styles from './index.module.css';

function App() {
return <div className={styles.container}>Hello World</div>;
}

Micro App

京东出的一款基于 Web Component 原生组件进行渲染的微前端框架

优点:

  • 简单:只需一行代码,实现微前端,如此简单;
  • 无关技术栈:任何框架皆可使用;
  • 静态资源补全;
  • JS 沙箱;
  • 样式隔离;
  • Qiankun 微前端框架的优势他都有;

EMP

基于 Webpack5 Module Federation 搭建的微前端方案

优点:

  • 依赖自动管理,可以共享 Host 中的依赖,版本不满足要求时自动 fallback 到 Remote 中依赖;
  • 共享模块粒度自由掌控,小到一个单独组件,大到一个完整应用。既实现了组件级别的复用,又实现了微服务的基本功能;
  • 共享模块非常灵活,模块中所有组件都可以通过异步加载调用;

缺点:

  • 无法做到多框架兼容等微前端方案的痛点;
  • 基于 Webpack5 Module Federation,需要统一 Webpack5 技术;
  • 文档资料,社区不够活跃;

Garfish

字节跳动

框架特性:

  • 🌈 丰富高效的产品特征
    • Garfish 微前端子应用支持任意多种框架、技术体系接入
    • Garfish 微前端子应用支持「独立开发」、「独立测试」、「独立部署
    • 强大的预加载能力,自动记录用户应用加载习惯增加加载权重,应用切换时间极大缩短
    • 支持依赖共享,极大程度的降低整体的包体积,减少依赖的重复加载
    • 内置数据收集,有效的感知到应用在运行期间的状态
    • 支持多实例能力,可在页面中同时运行多个子应用提升了业务的拆分力度
  • 📦 高扩展性的核心模块
    • 通过 Loader 核心模块支持 HTML entry、JS entry 的支持,接入微前端应用简单易用
    • Router 模块提供了路由驱动、主子路由隔离,用户仅需要配置路由表应用即可完成自主的渲染和销毁,无需关心内部逻辑
    • Sandbox 模块为应用的 Runtime 提供运行时隔离能力,能有效隔离 JS、Style 对应用的副作用影响
    • Store 提供了一套简单的通信数据交换机制
  • 🎯 高度可扩展的插件机制
    • 提供业务插件满足各种定制需求

微前端的原理

监听路由变化

  • 路由变化时匹配子应用
  • 执行子应用的生命周期
  • 加载子应用

路由有两种模式 hash 路由和 history 路由

hash 路由

  • hash 路由,路由改变不请求服务端,监听 window.addEventListener('hashchange', onHashChange)
  • 改变 URL 的方式有以下几种 ,都会触发 hashchange 事件
    • 通过浏览器前进后退改变 URL
    • 通过标签改变 URL
      • 补充:在 vue-router 中通过 router-link 跳转不会出发 hashchange 事件,这里要想其它办法,
    • 通过 window.location 改变 URL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 监听hashchange
window.addEventListener("hashchange", handleUrlChange);
function handleUrlChange() {
doLifeCycle();
}
function doLifeCycle() {
console.log(
"todo",
`
判断路由改变是否从一个微服务到另一个微服务,
如果是,则卸载当前应用,加载下一个应用,执行前后应用的生命周期
`
);
}
// 上述方法不支持拦截 vue-router 中的 router-link
// 采用事件代理处理a标签
document.body.addEventListener("click", function (e) {
if (e.target.tagName !== "A") return;
const href = e.target.getAttribute("href");
if (/#\//.test(href)) {
doLifeCycle();
}
});

history 路由

history 有 .pushState .replaceState .go .forward .back五个方法,single-spa 只对前两个方法做了代理。

history 提供类似 hashchange 事件的 popstate 事件,但 popstate 事件有些不同:

  • 通过浏览器前进后退改变 URL 时会触发 popstate 事件,
  • 通过pushStatereplaceState或标签改变 URL 不会出发 popstate 事件。好在可以拦截pushStatereplaceState的调用和标签点击事件来检测 URL 变化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 拦截浏览器前进后退
window.addEventListener("popstate", function (e) {
doLifeCycle();
});

// 拦截history跳转
const originPush = history.pushState;
history.pushState = (...args) => {
originPush.apply(window.history, args);
doLifeCycle();
};
window.history.replaceState = (...args) => {
originReplace.apply(window.history, args);
doLifeCycle();
};

function doLifeCycle() {
console.log(
"todo",
`
判断路由改变是否从一个微服务到另一个微服务,
如果是,则卸载当前应用,加载下一个应用,执行前后应用的生命周期
`
);
}

HTML 解析

html 解析使用的是一个 npm 包 import-html-entry

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
export const loadHTML = async (app) => {
const { container, entry } = app;

const { template, getExternalScripts, getExternalStyleSheets } =
await importEntry(entry);
const dom = document.querySelector(container);

if (!dom) {
throw new Error("容器不存在");
}

dom.innerHTML = template;

await getExternalStyleSheets();
const jsCode = await getExternalScripts();

jsCode.forEach((script) => {
const lifeCycle = runJS(script, app);
if (lifeCycle) {
app.bootstrap = lifeCycle.bootstrap;
app.mount = lifeCycle.mount;
app.unmount = lifeCycle.unmount;
}
});

return app;
};

样式隔离

在 qiankun 中有如下配置可以设置子应用的样式隔离

1
2
3
4
5
6
start({
sandbox: {
strictStyleIsolation: true,
experimentalStyleIsolation: true,
},
});
  • strictStyleIsolation 为每一个子应用包裹ShadowDOM节点,从而确保微应用的样式不会对全局造成影响。
  • experimentalStyleIsolation 改写子应用的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围
    • .hello ——> div[data-qiankun="vueApp"] .hello
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>使用Web Component 隔离样式</title>
<style>
.hello {
background-color: aquamarine;
border: 1px solid #ddd;
margin: 10px;
padding: 20px;
font-size: 30px;
}
</style>
</head>
<body>
<div class="hello">主应用</div>
<div class="sub-hello">使用了子应用的class</div>
<div id="container"></div>
<script>
function createElement(appContent) {
const container = document.createElement("div");
container.innerHTML = appContent;

const appElement = container.firstElementChild;
const { innerHTML } = appElement;
appElement.innerHTML = "";
let shadow;

if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: "open" });
} else {
shadow = appElement.createShadowRoot();
}
shadow.innerHTML = innerHTML;
return appElement;
}

const subApp = `<div>
<style>.sub-hello {color: blue}</style>
<p class="sub-hello">子应用</p>
<div>`;
// document.getElementById('container').innerHTML = subApp;
document.getElementById("container").appendChild(createElement(subApp));
</script>
</body>
</html>

JS 沙盒环境

在 qiankun 的实现中,包含了两种沙箱,分别为

  • Proxy 沙箱
  • 快照沙箱
    当浏览器不支持 Proxy 会降级为快照沙箱

快照沙箱 SnapshotSandbox

  • 基于数据 diff 备份和还原 window。
  • 性能较差,主要用于不支持 Proxy 的低版本浏览器,而且也只适应单个子应用

单例沙箱 legacySandbox

legacySandbox 设置了三个参数记录全局变量

  • addedPropsMapInSandbox 沙箱新增的全局变量
  • modifiedPropsOriginalValueMapInSandbox 沙箱更新的全局变量
  • currentUpdatedPropsValueMap 持续记录更新的(新增和修改的)全局变量。

1
2
3
4
5
6
7
8
9
window.sex = "男";
let LegacySandbox = new Legacy();
((window) => {
// 激活沙箱
LegacySandbox.active();
window.age = "22";
window.sex = "女";
console.log("激活", window.sex, window.age, LegacySandbox);
})(LegacySandbox.proxy);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class Legacy {
constructor() {
this.addedPropsMapInSandbox = {};
this.modifiedPropsOriginalValueMapInSandbox = {};
this.currentUpdatedPropsValueMap = {};
const rawWindow = window;
const fakeWindow = Object.create(null);
this.sandboxRunning = true;

const proxy = new Proxy(fakeWindow, {
set: (target, prop, value) => {
if (this.sandboxRunning) {
if (!rawWindow.hasOwnProperty(prop)) {
this.addedPropsMapInSandbox[prop] = value;
} else if (!this.modifiedPropsOriginalValueMapInSandbox[prop]) {
const originValue = rawWindow[prop];
this.modifiedPropsOriginalValueMapInSandbox[prop] = originValue;
}
this.currentUpdatedPropsValueMap[prop] = value;
rawWindow[prop] = value;
return true;
}
return true;
},
get: (target, prop) => {
return rawWindow[prop];
},
});
this.proxy = proxy;
}

active() {
if (!this.sandboxRunning) {
for (const key in this.currentUpdatedPropsValueMap) {
window[key] = this.currentUpdatedPropsValueMap[key];
}
}
this.sandboxRunning = true;
}

inactive() {
for (const key in this.modifiedPropsOriginalValueMapInSandbox) {
window[key] = this.modifiedPropsOriginalValueMapInSandbox[key];
}
for (const key in this.addedPropsMapInSandbox) {
delete window[key];
}
this.sandboxRunning = false;
}
}

多例沙箱 proxySandbox

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class ProxySandbox {
active() {
this.sandboxRunning = true;
}
inactive() {
this.sandboxRunning = false;
}
constructor() {
const rawWindow = window;
const fakeWindow = {};
const proxy = new Proxy(fakeWindow, {
set: (target, prop, value) => {
if (this.sandboxRunning) {
target[prop] = value;
return true;
}
},
get: (target, prop) => {
// 如果fakeWindow里面有,就从fakeWindow里面取,否则,就从外部的window里面取
let value = prop in target ? target[prop] : rawWindow[prop];
return value;
},
});
this.proxy = proxy;
}
}

// 测试用例
window.sex = "男";
let proxy1 = new ProxySandbox();
((window) => {
proxy1.active();
console.log("修改前proxy1的sex", window.sex);
window.sex = "111";
console.log("修改后proxy1的sex", window.sex);
})(proxy1.proxy);
console.log("外部window.sex", window.sex);