微前端实践:single-spa+vite
背景介绍
前段时间微前端实践:single-spa+vite的方式对项目进行了整合,也用 使用 Gitlab CI/CD 自动打包和部署微前端
微前端目前已经是非常成熟的前端技术,类似于微服务模式。允许使用不同的框架和技术栈,独立开发以及部署不同的模块。这也表示一个大型的应用可以拆分成小型的 “app”,在运行时再根据需要去整合以及加载这些 “app”。
优点很明显:
- 可以拆分巨石应用,也可以用于整合小应用
- 对灰色部署友好,可以渐进式替换或重写项目的某些部分
- 可以共享通用库, 比如 react / vue 这类包,可以加载一次后在多个微前端中复用
在着手开始做之前调研了一些比较流行的解决方案,也尝试过 QianKun
, 最后衡量之下使用了 single-spa
,我的场景是整合小型应用,它们的技术栈非常相似,都是 vite + vue,由于都是vue所以重合的包非常多
Single-Spa
Single-Spa 的文档非常完善,生态也很丰富。官方对目前流行的大部分框架都有整合方案,也提出了一套最佳实践方案, 基本可以覆盖到实践过程中出现的大部分问题和需求。
workflow
Single-Spa 通过监听 location
的变化, 匹配路由并加载相应的微前端应用
每个微前端应用都是独立的, 和目前 spa 应用没有区别,只是入口文件从html
文件变成了 js
文件。每个微应用需要提供 dom
容器 + 入口文件 + 生命周期
1 | singleSpa.registerApplication({ |
微前端应用通信
微前端应用应当保持隔离, 但在实际应用中共享是不可避免的。比如:函数、组件、业务逻辑、环境变量、API 接口、UI 状态
- 常见的实现方式:
postMessage
、storage
、url
、SharedWorker
WebWorker
,import / export
,global store
(比如redux
,pinia
) props
, 在微前端挂载的时候可以通过customProps
传递 token 之类的参数- 推荐可以使用事件发射器的模式
- rxjs, 是一个很成熟的响应式的库
- 使用 (CustomEvents)[developer.mozilla.org/en-US/docs/Web/Events/Creating_and_triggering_events] 来实现发布/订阅模式
css 隔离
css 隔离有很多种方式
命名约定
,唯一前缀或遵循特定命名格式CSS Modules
, CSS class 在编译期间由打包工具转换为唯一的、本地范围的类名Styled Components
, 在运行时动态创建样式元素。Shadow Dom
封装 HTML 和 CSS
import map
在浏览器中支持
裸说明符
, 旧版本浏览器需要polyfill
(例如systemjs
,es-module-shims
), 并手动映射依赖项- 裸说明符(bare specifier) 是指通过模块名称导入该模块, 而不是通过文件路径、URL 或协议, 比如
import 'react'
- 在 nodejs 中, 根据
package.json
映射node_modules
来解析裸说明符
- 在浏览器中, 打包工具在构建时将
裸说明符
映射到特定文件,或使用import map
(例如vite
)
- 裸说明符(bare specifier) 是指通过模块名称导入该模块, 而不是通过文件路径、URL 或协议, 比如
允许直接从
CDN
导入模块允许自定义模块的解析方式, 例如重定向到不同版本或实现的包
根据范围对特定模块进行不同的映射
1
2
3
4
5
6
7
8
9
10{
"imports": {
"react": "https://cdn.skypack.dev/react@17"
},
"scopes": { // Inside /app/, it resolves to version 18.
"/app/": {
"react": "https://cdn.skypack.dev/react@18"
}
}
}
模块类型
in-browser
/build-time
模块in-browser
模块- 模块在运行时由浏览器直接加载, 无需任何预处理或捆绑, 例如
<script type="module">
- 模块在运行时由浏览器直接加载, 无需任何预处理或捆绑, 例如
build-time
模块- 需要经过预处理或捆绑的模块
ES 模块
(ESM), 浏览器和 nodejs 支持import
和export
在编译时进行静态解析Tree-Shaking
, 打包工具可以在构建时自动删除未使用的导出以优化代码以及减少体积strict mode
为默认开启- 作用域隔离由
module scope
天然支持
CommonJS
(CJS), nodejs 支持require
和module.exports
在运行时动态解析
IIFE
(立即调用函数表达式), 浏览器支持- 模块打包为自执行函数, 在现代模块系统出现之前使用
AMD
(异步模块定义), 浏览器支持- 需要
RequireJS
, 使用其define
函数定义
- 需要
UMD
(通用模块定义), 通用- 在浏览器中用作
AMD
, 在 Node.js 中用作CommonJS
- 在没有模块加载器的环境中自行执行
- 在浏览器中用作
SystemJS
, 通用- 基于 ESM 规范的模块加载器, 支持动态加载多种格式, 包括
CommonJS
和AMD
- 运行时动态加载
- 基于 ESM 规范的模块加载器, 支持动态加载多种格式, 包括
实践
官网有的教程我就不赘述了,相关传送: vue, vite,建议先过一遍
- 共享通用包,在主应用程序以及微应用中,谁先加载共享包,就把共享包注册到 import map 中, 那么后续的子应用的对应引用都将被 import map 重定向到已经加载的资源
1
2
3
4
5
6
7
8
9
10
11
12
13// import map, 可以在某个应用加载完共享包后设置 import map, 将其余的应用的重定向到当前应用的包地址
const setImportMap = (myModulePath) => {
const importmap = document.createElement("script")
importmap.type = "importmap"
const apps = ["app1", "app2"]
const json = {
imports: {
...Object.fromEntries(apps.map(i => ([`/micoFrontendApps/${i}/modules/`, `${myModulePath}`]))),
}
}
importmap.innerText = JSON.stringify(json)
document.querySelector("head")?.appendChild(importmap)
}
关于包在 import map 中的引用有两种方式可以参考
如果一个包在项目里接近于全量引入,那么可以考虑在
import map
中设置 cdn 来重定向,这是最简单的方式如果相反,项目只用到了部分功能,那么我们可能不想全量引入,我们可能需要在打包时通过去除不必要的导出缩小加载的大小,缩短运行时的加载时间,这时我们就可以不改变当下的
npm install & import
的方式,在打包的时候做一些处理来构建通用包(vite配置在最后)设置 vue 作为共享包,
vue
有一些内部工具包,比如vue/runtime-core
等,为了使用方便,vue 导入并重新导出了这些工具包里的 module, 这样我们就可以直接从 vue 导入1
2
3import { computed, triggerRef, getCurrentScope, customRef} from "vue"
// `triggerRef`, `getCurrentScope`, `customRef` 并不直接属于 vue 核心包
import { computed, triggerRef, getCurrentScope, customRef} from "@vue/reactivity"- 比如这些工具函数
triggerRef
,getCurrentScope
,customRef
, 我们可以直接从 vue 导入,但其实它们并不直接属于 vue 核心包,即使使用了* as
语法或者副作用导入(Side-Effect Imports
),如果没有明确使用依然会被tree shaking
1
2
3import 'vue';
import * as vue from "vue"
console.log(vue.triggerRef) // 如果没有明确使用这个函数依然会被 tree shaking- 在设置共享包的时候,vue 核心包是必需的包,但是这些子包却不一定,如果我们只将 vue 设置为共享包,那么 vite 会默认其子包与 vue 共享一个命名空间,也就是会将它们打包在一起
- 比如某一个应用使用了
@vue/reactivity
里的triggerRef
,但是其他应用没有用到,那么这些应用的共享包就不会有这个函数,所以我们最好把 vue 主包和其子包分开
- 比如这些工具函数
引用规范: 下面两行代码在构建时生成两个模块名称
pinia
Pinia
, 生成重复的引用1
2import * as pinia from 'pinia'
import * as Pinia from 'pinia'vite 静态资源处理,在主应用中方位微前端应用的时候,某些静态可能会出现 404 的情况
导入静态文件时, vite 会返回已解析的公共 URL
1
2import logo from './logo.png'
console.log(logo);通过
new URL(url, import.meta.url)
, 也可以获取完整的资源地址 然后根据需要更改来源server.origin
, 定义开发调试阶段生成的资源的 origin
vite 打包配置供参考
1 | import { createHash } from 'crypto'; |
QianKun
特性
QianKun
是目前国内最流行的方案,基于 Single-Spa
构建, 依赖 Webpack
的 Module Federation
动态加载 UMD
或者 SystemJS
模块类型的微前端。
QianKun
在 Single-Spa
之上,添加了很多开箱即用的特性
- 以 html 为入口文件, 每个微前端都是一个典型的前端 app(以html 为入口)。如果是像我一样,是整合小型 app, 那么就不需要更改微前端的包结构。
- 内置
跨应用通信
、js 沙箱
和css 隔离
整合 vite 打包的应用, 相关issue
QianKun
作为非常成熟的方案,一开始也是我的首选,但我们项目的应用都是使用的 vite,而QianKun
的很多特性都是依靠于Webpack Module Federation
带来的, 和 vite 无法很好的结合在一起, 所以最后还是使用了single-spa
。- 社区是有一些成熟的插件可以试试,但随着
vite
,QianKun
及其依赖的不断更新,会出现一些新的问题,可以多看看插件的 issue。
js 沙箱
ProxySandbox
LegacySandbox
- 这两个都是使用
Proxy
LegacySandbox
是单例模式,即一个页面只有一个微应用时,sandbox.loose
- 使用
Proxy
包装window
, 并且监听window
上的操作,把操作分为新增和更新然后存进不同的 map 里 - 避免了 diff 的操作,也有了恢复环境的依据
- 使用
ProxySandbox
多例模式,页面有多个微应用时- 使用 proxy 为每一个微应用分配一个
fakeWindow
, 当修改全局变量的时候,操作的是fakeWindow
, 这样就不会影响原生window
,使用的时候会优先从fakeWindow
,找不到再去原生window
fakeWindow
在微应用失焦的时候会被存起来以便聚焦的时候再用
- 使用 proxy 为每一个微应用分配一个
- 这两个都是使用
SnapshotSandbox
, 当浏览器不支持Proxy
时- 在微前端挂载时记录一个
window
的快照,也就是浅拷贝 window。当微前端卸载时,QianKun
将当前window
与快照进行diff
比较, 并将window
恢复到其原始状态
- 在微前端挂载时记录一个
webpack 模块联盟(webpack module federation)
- 每个微前端应用都捆绑其所有依赖项。在浏览器中, 如果下载了任何共享依赖项, 则后续微前端应用将重用该共享依赖项, 而无需重新下载
web components
- 通过将 DOM 树附加到某个 element, 并将该树的内部内容隐藏在页面中运行的 JavaScript 和 CSS 中。因此, 样式和行为的自然封装
- 应该非常适合小型单页 app,或者封装可重复使用的 UI 组件。如果是构建和管理大型复杂的应用可能会很麻烦
update-20250323
micro-app by JD, renders based on web-component-like, but not realized by web-component
- Lifecycle hooks are invoked by the main app
- built-in event bus for communication, proxy for js sandbox, ShadowDom for dom isolation, adds a prefix to dom container element When a micro-app is mounted
- route system
- supports history mode and hash mode
- provides built-in methods to allow navigation between different micro-apps
- supports independent sub-app routing, meaning each micro-app can manage its own routes without interference from the main application
- single-spa uses
meta-router
which sits above individual micro-frontends, meaning the main application controls which micro-app is loaded based on the current route
参考文章
更新时间:2025-01-23
转载请注明来源,欢迎指出任何有错误或不够清晰的表达