TONG-H

微前端实践:single-spa+vite

3.3k13Frontendmicro_frontend2025-01-232025-04-02

背景介绍

前段时间微前端实践: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
2
3
4
5
6
7
8
9
10
11
singleSpa.registerApplication({
name: "myApp",
app: () => import("src/myApp/main.js"),
activeWhen: [
"/myApp",
(location) => location.pathname.startsWith("/some/other/path"),
],
customProps: {
some: "value",
},
});

微前端应用通信

微前端应用应当保持隔离, 但在实际应用中共享是不可避免的。比如:函数、组件、业务逻辑、环境变量、API 接口、UI 状态

  • 常见的实现方式:postMessagestorageurlSharedWorker 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)
  • 允许直接从 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 支持

    • importexport 在编译时进行静态解析
    • Tree-Shaking, 打包工具可以在构建时自动删除未使用的导出以优化代码以及减少体积
    • strict mode 为默认开启
    • 作用域隔离由 module scope 天然支持
  • CommonJS (CJS), nodejs 支持

    • requiremodule.exports 在运行时动态解析
  • IIFE (立即调用函数表达式), 浏览器支持

    • 模块打包为自执行函数, 在现代模块系统出现之前使用
  • AMD (异步模块定义), 浏览器支持

    • 需要 RequireJS, 使用其 define 函数定义
  • UMD (通用模块定义), 通用

    • 在浏览器中用作 AMD, 在 Node.js 中用作 CommonJS
    • 在没有模块加载器的环境中自行执行
  • SystemJS, 通用

    • 基于 ESM 规范的模块加载器, 支持动态加载多种格式, 包括 CommonJSAMD
    • 运行时动态加载

实践

官网有的教程我就不赘述了,相关传送: 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
    3
    import { 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
    3
    import 'vue';
    import * as vue from "vue"
    console.log(vue.triggerRef) // 如果没有明确使用这个函数依然会被 tree shaking
    • 在设置共享包的时候,vue 核心包是必需的包,但是这些子包却不一定,如果我们只将 vue 设置为共享包,那么 vite 会默认其子包与 vue 共享一个命名空间,也就是会将它们打包在一起
    • 比如某一个应用使用了@vue/reactivity 里的 triggerRef,但是其他应用没有用到,那么这些应用的共享包就不会有这个函数,所以我们最好把 vue 主包和其子包分开
  • 引用规范: 下面两行代码在构建时生成两个模块名称 pinia Pinia, 生成重复的引用

    1
    2
    import * as pinia from 'pinia'
    import * as Pinia from 'pinia'
  • vite 静态资源处理,在主应用中方位微前端应用的时候,某些静态可能会出现 404 的情况

  • 导入静态文件时, vite 会返回已解析的公共 URL

    1
    2
    import logo from './logo.png'
    console.log(logo);

    通过 new URL(url, import.meta.url), 也可以获取完整的资源地址 然后根据需要更改来源
    server.origin, 定义开发调试阶段生成的资源的 origin

vite 打包配置供参考

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
import { createHash } from 'crypto';
import vitePluginSingleSpa from 'vite-plugin-single-spa'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'

const sharedPackages = ["vue", "vue-router", "axios"]
defineConfig(() => {
plugins: [
// 默认情况下, Vite 将 css 提取到单独的样式文件中, 这是为了优化加载, 实现更好的缓存和样式更新
// 但在 single-spa 微应用中, 只需要一个 js 单个入口点, 可以通过插件将 css 内联到 js, 或者在生命周期中通过操作 dom 的方式添加 style
cssInjectedByJsPlugin(),
// SingleSpa 插件
vitePluginSingleSpa({
type: 'mife',
serverPort: 4101,
spaEntryPoints: path.resolve(__dirname, '../src/main.ts'),
assetFileNames: 'assets/[name]-[hash][extname]'
})
],
// 显式的包含共享包,确保被打包
optimizeDeps: {
include: sharedPackages,
},
build: {
rollupOptions: {
// 对于入口模块, Rollup 在保留模块签名(export 形状) 严格程度, 同上一条, 在共享包的场景下, 我们可能希望固定内部变量名词
preserveEntrySignatures: "strict",
output: {
// 默认情况下, Rollup 为了压缩代码会把内部变量导出为单个字母的变量
minifyInternalExports: false,
chunkFileNames: (preRenderedChunk) => {
const { name, moduleIds } = preRenderedChunk
// // 可用于将共享包与其他模块单独存放, 便于后续重定向
if (sharedPackages.includes(`${name}`)) return `modules/${name}.js`
const modulePaths = moduleIds.sort().join(',')
const hash = createHash('md5').update(modulePaths || "").digest('hex').slice(0, 8);
return `chunks/${name}-${hash}.js`
},
assetFileNames: `assets/[name]-[ext].[ext]`,
entryFileNames: `entry/[name]-[format].js`,
// 创建自定义的公共 chunk
manualChunks: {
...Object.fromEntries(sharedPackages.map((i) => [i, [i]])),
},
},
},
},
}
)

QianKun

特性

QianKun 是目前国内最流行的方案,基于 Single-Spa 构建, 依赖 WebpackModule Federation 动态加载 UMD 或者 SystemJS 模块类型的微前端。

QianKunSingle-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 在微应用失焦的时候会被存起来以便聚焦的时候再用
  • 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

参考文章