single-spa 概述
single-spa 是一个实现微前端架构的框架。
在 single-spa 框架中有三种类型的微前端应用:
- single-spa application / parcel:微前端架构中的微应用,可以使用 vue、react、angular 等框架。
- single-spa Application 和路由相关联的,根据路由决定访问哪个微应用。
- single-spa Parcel 的使用方式和前者一样,区别是这种类型的微应用不和路由进行关联,它主要是用于跨应用共享 UI 组件的
- single-spa root config:创建微前端容器应用,通过容器应用加载和管理普通的微应用。
- utility modules:公共模块应用,非渲染组件,用于跨应用共享 javascript 逻辑的微应用。
使用 create-single-spa 脚手架创建容器应用
初始化项目
# 创建工作目录,存放每个微应用(实际开发中每个微应用一般都是放在不同的开发人员的电脑中)
mkdir workspace
cd workspace
# 全局安装脚手架
npm i create-single-spa -g
# 脚手架创建微应用
create-single-spa
# 如果不想将脚手架安装到全局,可以使用 npx 运行脚手架
# npx create-single-spa
? Directory for new project container # 创建项目的文件夹(默认 ./)
? Select type to generate single-spa root config # 创建什么类型的应用
? Which package manager do you want to use? npm # 使用什么工具安装 package
? Will this project use Typescript? No # 是否使用 TS
? Would you like to use single-spa Layout Engine No # 是否使用 single-spa 布局引擎
? Organization name (can use letters, numbers, dash or underscore) study # 组织名称
组织名称可以理解为团队名称,微前端架构允许多团队共同开发应用,组织名称可以标识应用由哪个团队开发。
应用名称的命名规则为 @组织名称/项目名称
,比如 @study/todos
安装完后启动应用:
cd container
npm start
访问:http://localhost:9000/,看到 Welcome 欢迎页面即表示成功。
容器应用默认代码解析
容器应用默认应该不包含任何页面,但是在 single-spa 的容器应用启动后显示了 Welcome 欢迎页面,这是因为在 single-spa 的容器应用中默认注册了一个微应用,名为 @single-spa/welcome
,下面解析一下 single-spa 容器应用默认代码。
src 目录用于存放源代码文件:
-
index.ejs
是模板文件 -
study-root-config.js
是应用入口文件
注意:在整个微前端项目中只有一个模板文件,也就是说,其它微应用是不包含模板文件的。
xxx-root-config.js
// container\src\study-root-config.js
// 引入两个方法:
// - registerApplication: 用于注册微应用
// - start: 用来启动微前端应用
import { registerApplication, start } from 'single-spa'
/**
* 注册一个微应用(默认的 Welcome 欢迎页面)
* name {String} - 微应用名称 `@组织名称/项目名称`
* app {() => <Function | Promise>} - 一个返回加载的模块或 Promise 的函数
* activeWhen - 指定微应用在什么条件下激活
*/
registerApplication({
// welcome 微应用名称
name: '@single-spa/welcome',
// 通过 systemjs 引用打包好的微应用模块代码
app: () => System.import('https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js'),
// 使用数组,指定首页路由下激活
activeWhen: ['/']
})
// registerApplication({
// name: "@study/navbar",
// app: () => System.import("@study/navbar"),
// activeWhen: ["/"]
// });
// 启动当前应用
// start 方法必须在 single-spa 的配置文件中调用
// 调用 start 之前,应用会被加载,但不会初始化、挂载或卸载
start({
// 是否可以通过 history.pushState() 和 history.replaceState() 更改触发 single-spa 路由
// true: 不允许; false: 允许
// 默认是 false
// 在某些情况下,将此设置为true可以提高性能
urlRerouteOnly: true
})
index.ejs
以下拆分并解析主要内容。
引入 single-spa 和配置预加载:
<!-- 引入公共模块地址 -->
<script type="systemjs-importmap">
{
"imports": {
"single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js"
}
}
</script>
<!-- 预加载 single-spa -->
<link rel="preload" href="https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js" as="script">
引入 systemjs 模块加载器(区分了开发环境,为了引入压缩版本):
<!-- 引入模块加载器 -->
<!-- isLocal(Boolean) 表示是否是本地开发环境 -->
<% if (isLocal) { %>
<!-- 开发环境 引入未压缩版本 -->
<!-- systemjs 模块加载器 -->
<script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/system.js"></script>
<!-- systemjs 用来解析 AMD (浏览器优先)模块的插件(如果不使用 AMD 模块可以不引入) -->
<script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/extras/amd.js"></script>
<% } else { %>
<!-- 其它环境 引入压缩版本 -->
<script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/system.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/extras/amd.min.js"></script>
<% } %>
引入 root-config 容器应用模块,并通过 system.import()
加载:
<!-- 引入容器应用模块 -->
<!-- 开发环境指定本地地址 -->
<% if (isLocal) { %>
<script type="systemjs-importmap">
{
"imports": {
"@study/root-config": "//localhost:9000/study-root-config.js"
}
}
</script>
<% } %>
<!-- 加载容器应用模块 -->
<script>
System.import('@study/root-config');
</script>
或者不使用 import-map,直接引入:
<!-- 加载容器应用模块 -->
<script>
System.import('./study-root-config.js');
</script>
引入浏览器调试工具(single-spa)并使用(需安装浏览器插件):
官方介绍:
single-spa-inspector | single-spasingle-spa-inspector | single-spa
<!-- 调试工具:用于覆盖通过 import-map 设置的 JavaScript 模块地址 -->
<script src="https://cdn.jsdelivr.net/npm/import-map-overrides@2.2.0/dist/import-map-overrides.js"></script>
<!-- 调试工具。可以通过浏览器调试工具(single-spa-Inspector)更改注册的微应用模块的地址 -->
<!-- 例如,将线上环境的模块地址更改为开发环境的模块地址 -->
<import-map-overrides-full show-when-local-storage="devtools" dev-libs></import-map-overrides-full>
创建不基于框架的微应用
创建一个不基于框架(如 React、Vue)的微应用,只能手动创建,无法用 create-single-spa 脚手架创建。
在 workspace 目录下创建文件夹 foo 存放微应用。
1、添加 package.json
依赖的版本号同上面创建的容器应用中对应依赖一样:
{
"name": "foo",
"version": "1.0.0",
"description": "",
"main": "webpack.config.js",
"scripts": {
"start": "webpack serve"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@babel/core": "^7.15.0",
"single-spa": "^5.9.3",
"webpack": "^5.51.0",
"webpack-cli": "^4.8.0",
"webpack-config-single-spa": "^5.0.0",
"webpack-dev-server": "^4.0.0",
"webpack-merge": "^5.8.0"
}
}
npm install
安装依赖。
2、添加 webpack.config.js 配置文件
// foo\webpack.config.js
const { merge } = require('webpack-merge')
// 引入 single-spa 的默认 webpack 配置
const singleSpaDefaults = require('webpack-config-single-spa')
module.exports = () => {
const defaultConfig = singleSpaDefaults({
// 微应用组织名称
orgName: 'study',
// 微应用项目名称
projectName: 'foo'
})
return merge(defaultConfig, {
devServer: {
port: 9001
}
})
}
3、添加入口文件
入口文件的命名规则为 <orgName>-<projectName>.js
,当前为 src/study-foo.js
。
single-spa 框架要求在每个微应用的入口文件中必须导出 3 个返回 Promise 的生命周期函数。
这三个周期函数都是应用级别的,分别为启动、挂载和卸载,容器应用要通过微应用提供的三个周期函数执行微应用的启动、挂载和卸载。
// foo\src\study-foo.js
let fooContainer = null
// 启动
export async function bootstrap() {
console.log('应用正在启动')
}
// 挂载
export async function mount() {
console.log('应用正在挂载')
fooContainer = document.createElement('div')
fooContainer.id = 'fooContainer'
fooContainer.innerHTML = 'Hello Foo'
}
// 卸载
export async function unmount() {
console.log('应用正在卸载')
}
当前还不会触发卸载,因为还没有路由功能,添加路由功能后,从当前应用跳转到另一个应用的时候就会触发卸载。
4、在容器应用中注册微应用
container/src/study-root-config.js
添加代码:
registerApplication({
name: '@study/foo',
app: () => System.import('@study/foo'),
activeWhen: ['/foo']
})
container/src/index.ejs
中配置 foo 微应用引入地址:
<!-- 引入容器应用模块 -->
<!-- 开发环境指定本地地址 -->
<% if (isLocal) { %>
<script type="systemjs-importmap">
{
"imports": {
"@study/root-config": "//localhost:9000/study-root-config.js",
"@study/foo": "//localhost:9001/study-foo.js"
}
}
</script>
<% } %>
5、启动微应用
npm start
启动微应用,访问容器应用页面 http://localhost:9000/foo
,页面中显示了 Hello Foo
内容。
但是 Welcome 微应用也在当前路由显示了,这是因为 /foo
同样匹配到了 /
,修改注册配置以完全匹配 /
地址:
registerApplication({
name: '@single-spa/welcome',
app: () => System.import('https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js'),
// 使用数组,指定首页路由下激活
// activeWhen: ['/']
// 使用一个返回 Boolean 的函数指定激活条件
activeWhen: location => location.pathname === '/'
})
再次访问 /foo
就不会显示 Welcome 微应用了。
创建基于 React 框架的微应用
使用 create-single-spa 可以创建基于 React、Vue、Angular 框架的微应用。
1、创建微应用
在 workspace 目录下打开命令行工具:
create-single-spa
? Directory for new project todos
? Select type to generate single-spa application / parcel
? Which framework do you want to use? react
? Which package manager do you want to use? npm
? Will this project use Typescript? No
? Organization name (can use letters, numbers, dash or underscore) study
? Project name (can use letters, numbers, dash or underscore) todos
2、修改启动端口
修改 package.json 启动脚本:
"scripts": {
"start": "webpack serve --port 9002",
...
}
npm start
启动微应用。
3、在容器应用中注册微应用
container/src/study-root-config.js
添加代码:
registerApplication({
name: '@study/todos',
app: () => System.import('@study/todos'),
activeWhen: ['/todos']
})
container/src/index.ejs
中配置 foo 微应用引入地址:
<!-- 引入容器应用模块 -->
<!-- 开发环境指定本地地址 -->
<% if (isLocal) { %>
<script type="systemjs-importmap">
{
"imports": {
"@study/root-config": "//localhost:9000/study-root-config.js",
"@study/foo": "//localhost:9001/study-foo.js",
"@study/todos": "//localhost:9002/study-todos.js"
}
}
</script>
<% } %>
4、配置 react 相关模块引入地址
single-spa 创建的 React 微应用默认不会打包 react 和 react-dom 模块,它认为这两个应该是公共模块,所以需要在容器应用中手动指定这两个模块的引入地址。
<!-- 引入公共模块地址 -->
<script type="systemjs-importmap">
{
"imports": {
"single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js",
"react": "https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.production.min.js",
"react-dom": "https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.production.min.js"
}
}
</script>
5、查看效果
现在访问 localhost:9000/todos
看到页面中显示 @study/todos is mounted!
就表示该应用已经注册好了。
6、代码解析
todos 中的源代码文件主要是两个:
- study-todos.js 入口文件
- root.component.js 根组件文件
// todos\src\study-todos.js
import React from 'react'
import ReactDOM from 'react-dom'
import singleSpaReact from 'single-spa-react'
import Root from './root.component'
// 用于创建基于 React 的微应用
const lifecycles = singleSpaReact({
// 传递 react 相关模块
React,
ReactDOM,
// 传递根组件
rootComponent: Root,
// 错误边界处理
errorBoundary(err, info, props) {
// Customize the root error boundary for your microfrontend here.
// 可以编写 jsx
return null
}
})
// singleSpaReact 方法返回的对象包含管理应用的三个周期函数
export const { bootstrap, mount, unmount } = lifecycles
// todos\src\root.component.js
export default function Root(props) {
// props.name 即注册微应用时指定的微应用名称(name)
return <section>{props.name} is mounted!</section>
}
7、挂载位置
默认微应用的根组件会挂载在 body 目录下 id 为 single-spa-application:<微应用名称>
的节点上:
要想自定义挂载位置可以在创建微应用的时候指定挂载节点:
// todos\src\study-todos.js
// 用于创建基于 React 的微应用
const lifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: Root,
// 指定根组件的挂载节点: 一个返回 DOM 的函数
domElementGetter: () => document.getElementById('root'),
errorBoundary(err, info, props) {
return <div>发生错误时此处内容将会被渲染</div>
}
})
还要在模板文件 container\src\index.ejs
中添加挂载节点:
<div id="root"></div>
再次查看页面:
基于 React 框架的微前端应用配置路由
1、创建组件
// todos\src\Home.js
const Home = () => {
return <div>Home works</div>
}
export default Home
// todos\src\About.js
const About = () => {
return <div>About works</div>
}
export default About
2、修改根组件
注意:这里使用的是 v5 版本的 api
// todos\src\root.component.js
import React from 'react'
import { BrowserRouter, Route, Link, Redirect, Switch } from 'react-router-dom'
import Home from './Home'
import About from './About'
export default function Root(props) {
return (
<BrowserRouter basename="/todos">
<div>
<Link to="/home">Home</Link>
<Link to="/about">About</Link>
</div>
<Switch>
<Route path="/home">
<Home />
</Route>
<Route path="/about">
<About />
</Route>
<Route path="/">
<Redirect to="/home" />
</Route>
</Switch>
</BrowserRouter>
)
}
3、配置公共模块
react-router-dom 应该作为公共模块被引入,修改 container\src\index.ejs
<!-- 引入公共模块地址 -->
<script type="systemjs-importmap">
{
"imports": {
"single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js",
"react": "https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.production.min.js",
"react-dom": "https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.production.min.js",
"react-router-dom": "https://cdn.jsdelivr.net/npm/react-router-dom@5.3.0/umd/react-router-dom.min.js"
}
}
</script>
webpack 配置禁止打包 react-router-dom:
// todos\webpack.config.js
const { merge } = require("webpack-merge");
const singleSpaDefaults = require("webpack-config-single-spa-react");
module.exports = (webpackConfigEnv, argv) => {
const defaultConfig = singleSpaDefaults({
orgName: "study",
projectName: "todos",
webpackConfigEnv,
argv,
});
return merge(defaultConfig, {
// 禁止打包 react-router-dom
externals: ['react-router-dom']
});
};
4、重新启动
修改配置需要重新启动 todos 应用 npm start
创建基于 Vue 框架的微应用
1、创建微应用
创建 Vue 框架微应用的过程中,在输入组织名称后,create-single-spa 会下载 vue-cli 工具,下载完成后使用 vue-cli 继续创建 Vue 项目,创建完成后回到 create-single-spa 创建应用流程中继续填写项目名称:
create-single-spa
? Directory for new project todos
? Select type to generate single-spa application / parcel
? Which framework do you want to use? react
? Which package manager do you want to use? npm
? Will this project use Typescript? No
? Organization name (can use letters, numbers, dash or underscore) study
# 下载 vue-cli 后继续创建 vue 项目,只提示选择使用的 vue 版本,本例选择 vue2.x
# > Default ([Vue 2] babel, eslint)
2、配置 vue 相关公共模块
single-spa 在创建 Vue 微应用的时候并未将 vue 和 vue-router 作为公共模块配置,需要手动修改配置。
首先配置 webpack 不打包 vue 和 vue-router,在 realworld 目录下添加文件 vue-config-js
:
// realworld\vue.config.js
module.exports = {
chainWebpack: config => {
// 禁止打包的模块
config.externals(['vue', 'vue-router'])
}
}
配置公共模块引入地址:
<!-- 引入公共模块地址 -->
<script type="systemjs-importmap">
{
"imports": {
"single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js",
"react": "https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.production.min.js",
"react-dom": "https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.production.min.js",
"react-router-dom": "https://cdn.jsdelivr.net/npm/react-router-dom@5.3.0/umd/react-router-dom.min.js",
"vue": "https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js",
"vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.5.3/dist/vue-router.min.js"
}
}
</script>
注意:该 CDN 地址加载的 vue 和 vue-router 模块是 AMD 类型的,所以请确保 systemjs 的 AMD 模块解析器被引入。
3、启动项目
修改 realworld/package.json
:
"scripts": {
"start": "vue-cli-service serve --port 9003",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"serve:standalone": "vue-cli-service serve --mode standalone"
},
启动项目 npm start
4、注册微应用
// container\src\study-root-config.js
registerApplication({
name: '@study/realworld',
app: () => System.import('@study/realworld'),
activeWhen: ['/realworld']
})
5、引入应用
Vue 项目下并没有 src/study-realword.js
文件,它的入口源文件是 src/main.js
,项目打包后仍会生成名为 js/app.js
的入口文件。
要想知道微应用的入口文件,也可以访问启动应用后的根目录,single-spa 会提示入口文件:
在容器应用中注册微应用:
<!-- 引入容器应用模块 -->
<!-- 开发环境指定本地地址 -->
<% if (isLocal) { %>
<script type="systemjs-importmap">
{
"imports": {
"@study/root-config": "//localhost:9000/study-root-config.js",
"@study/foo": "//localhost:9001/study-foo.js",
"@study/todos": "//localhost:9002/study-todos.js",
"@study/realworld": "//localhost:9003/js/app.js"
}
}
</script>
<% } %>
访问 http://localhost:9000/realworld
查看效果
6、屏蔽报错
现在虽然 Vue 微应用已经运行到容器中,不过控制台还是报错:
Refused to connect to 'http://xxx.xxx.xxx.xxx:9003/sockjs-node/info?t=1642400465303' because it violates the following Content Security Policy directive: "connect-src https: localhost:* ws://localhost:*".
这是因为模板文件中设置了 Content-Security-Policy
的 connect-src
,它限制了能通过脚本接口加载的 URL。
而 vue-cli 创建的项目默认会向本地 ip(而不是 localhost)发送 websocket 请求,地址如下:
http://xxx.xxx.xxx.xxx:9003/sockjs-node/info?t=1642400465303
ws://xxx.xxx.xxx.xxx:9003/sockjs-node/938/ddz3z0b0/websocket
可以将本地地址添加进去:http://xxx.xxx.xxx.xxx:* ws://xxx.xxx.xxx.xxx:*
。
也可以直接将 Content-Security-Policy
注释掉。
基于 Vue 框架的微前端应用配置路由
realworld/src/main.js
是基于 Vue 框架的微应用的入口文件:
// realworld\src\main.js
import Vue from 'vue'
import singleSpaVue from 'single-spa-vue'
import VueRouter from 'vue-router'
import App from './App.vue'
Vue.use(VueRouter)
Vue.config.productionTip = false
// 路由组件
const Bar = { template: '<div>Bar works</div>' }
const Baz = { template: '<div>Baz works</div>' }
// 路由规则
const routes = [
{ path: '/bar', component: Bar },
{ path: '/baz', component: Baz }
]
// 路由实例
const router = new VueRouter({
routes,
mode: 'history',
base: '/realworld'
})
// 创建基于 Vue 的微应用
const vueLifecycles = singleSpaVue({
// 传入 vue 模块
Vue,
// 应用配置
appOptions: {
// 路由
router,
// 渲染组件
render(h) {
return h(App, {
// 向组件中传递的数据
props: {
name: this.name
// single-spa props are available on the "this" object. Forward them to your component as needed.
// https://single-spa.js.org/docs/building-applications#lifecyle-props
// if you uncomment these, remember to add matching prop definitions for them in your App.vue file.
/*
name: this.name,
mountParcel: this.mountParcel,
singleSpa: this.singleSpa,
*/
}
})
}
}
})
// 导出必要的生命周期函数
export const bootstrap = vueLifecycles.bootstrap
export const mount = vueLifecycles.mount
export const unmount = vueLifecycles.unmount
修改 App.vue 组件:
<template>
<div id="app">
<h1>{{ name }}</h1>
<div>
<router-link to="/bar">Bar</router-link>
<router-link to="/baz">Baz</router-link>
</div>
<router-view />
</div>
</template>
<script>
export default {
name: 'App',
props: ['name']
}
</script>
<style></style>
创建 Parcel 应用
Parcel 是用来创建跨框架、跨应用的公共 UI 的。
Parcel 可以使用任意 single-spa 支持的框架(如 React、Vue 等),它也是单独的应用,需要单独启动,但是它不关联路由。
Parcel 应用的模块访问地址也需要被添加到 import-map 中,其它微应用通过 System.import
方法加载该模块。
下面创建一个公共的导航模块,并在其它微应用中使用它。
1、创建基于 React 框架的 Parcel 微应用
create-single-spa
? Directory for new project navbar
? Select type to generate single-spa application / parcel
? Which framework do you want to use? react
? Which package manager do you want to use? npm
? Will this project use Typescript? No
? Organization name (can use letters, numbers, dash or underscore) study
? Project name (can use letters, numbers, dash or underscore) navbar
2、编写导航组件
// navbar\src\root.component.js
import { BrowserRouter, Link } from 'react-router-dom'
export default function Root(props) {
return (
<BrowserRouter>
<div>
<Link to="/">@single-spa/welcome</Link>
{' | '}
<Link to="/foo">@study/foo</Link>
{' | '}
<Link to="/todos">@study/todos</Link>
{' | '}
<Link to="/realworld">@study/realworld</Link>
</div>
</BrowserRouter>
)
}
3、禁止打包 react-router-dom
// navbar\webpack.config.js
const { merge } = require('webpack-merge')
const singleSpaDefaults = require('webpack-config-single-spa-react')
module.exports = (webpackConfigEnv, argv) => {
const defaultConfig = singleSpaDefaults({
orgName: 'study',
projectName: 'navbar',
webpackConfigEnv,
argv
})
return merge(defaultConfig, {
// modify the webpack config however you'd like to by adding to this object
externals: ['react-router-dom']
})
}
4、修改脚本&启动应用
修改启动端口:
"scripts": {
"start": "webpack serve --port 9004",
...
}
npm start
启用应用
5、容器应用中引入模块
当前应用不和路由进行关联,所以不需要在容器应用中注册应用,只需要配置引入地址:
<!-- 引入容器应用模块 -->
<!-- 开发环境指定本地地址 -->
<% if (isLocal) { %>
<script type="systemjs-importmap">
{
"imports": {
"@study/root-config": "//localhost:9000/study-root-config.js",
"@study/foo": "//localhost:9001/study-foo.js",
"@study/todos": "//localhost:9002/study-todos.js",
"@study/realworld": "//localhost:9003/js/app.js",
"@study/navbar": "//localhost:9004/study-navbar.js"
}
}
</script>
<% } %>
6、在 React 应用中使用
在 React 应用中使用 Parcel 应用,需要使用 single-spa 提供的组件,将 System.import
加载的模块对象传递给组件的 config
属性:
import Parcel from 'single-spa-react/parcel'
<Parcel config={System.import('@study/navbar')} />
修改代码:
// todos\src\root.component.js
import React from 'react'
import Parcel from 'single-spa-react/parcel'
import { BrowserRouter, Route, Link, Redirect, Switch } from 'react-router-dom'
import Home from './Home'
import About from './About'
export default function Root(props) {
return (
<BrowserRouter basename="/todos">
<Parcel config={System.import('@study/navbar')} />
<div>
<Link to="/home">Home</Link>
<Link to="/about">About</Link>
</div>
<Switch>
<Route path="/home">
<Home />
</Route>
<Route path="/about">
<About />
</Route>
<Route path="/">
<Redirect to="/home" />
</Route>
</Switch>
</BrowserRouter>
)
}
访问 http://localhost:9000/todos
查看效果。
7、在 Vue 应用中使用
在 Vue 应用中使用 Parcel 应用,也是使用 single-spa 提供的组件,将加载模块的对象传递给组件,还需要向组件从传递一个方法 mountRootParcel
,该方法用于挂载 Parcel。
<!-- realworld\src\App.vue -->
<template>
<div id="app">
<Parcel :config="parceConfig" :mountParcel="mountParcel" />
<h1>{{ name }}</h1>
<div>
<router-link to="/bar">Bar</router-link>
<router-link to="/baz">Baz</router-link>
</div>
<router-view />
</div>
</template>
<script>
import Parcel from 'single-spa-vue/dist/esm/parcel'
import { mountRootParcel } from 'single-spa'
export default {
name: 'App',
components: { Parcel },
props: ['name'],
data() {
return {
parceConfig: window.System.import('@study/navbar'),
mountParcel: mountRootParcel
}
}
}
</script>
<style></style>
**注意1:**在 Vue 微应用中使用 System
要使用 window.System
。
**注意2:**这里加载了模块 single-spa 的 mountRootParcel
方法,打包时会将 single-spa 一起打包。而容器应用已经将 single-spa 作为公共模块引入,所以 Vue 应用需要配置不打包 single-spa。
// realworld\vue.config.js
module.exports = {
chainWebpack: config => {
// 禁止打包的模块
config.externals(['vue', 'vue-router', 'single-spa'])
}
}
修改配置后需要重新启动 Vue 应用。
创建跨框架共享的 JavaScript 逻辑
utility modules 用于放置跨应用共享的 JavaScript 逻辑,它也是独立的应用,需要单独构建,单独启动。
创建 utility modules 微应用
create-single-spa
? Directory for new project tools
? Select type to generate in-browser utility module (styleguide, api cache, etc)
? Which framework do you want to use? none
? Which package manager do you want to use? npm
? Will this project use Typescript? No
? Organization name (can use letters, numbers, dash or underscore) study
? Project name (can use letters, numbers, dash or underscore) tools
修改启动命令
"scripts": {
"start": "webpack serve --port 9005",
...
}
容器应用中引入模块
utility modules 同 Parcel 一样,是在微应用中使用,不需要在容器应用中注册,只需要在容器应用中引入即可。
<!-- 引入容器应用模块 -->
<!-- 开发环境指定本地地址 -->
<% if (isLocal) { %>
<script type="systemjs-importmap">
{
"imports": {
"@study/root-config": "//localhost:9000/study-root-config.js",
"@study/foo": "//localhost:9001/study-foo.js",
"@study/todos": "//localhost:9002/study-todos.js",
"@study/realworld": "//localhost:9003/js/app.js",
"@study/navbar": "//localhost:9004/study-navbar.js",
"@study/tools": "//localhost:9005/study-tools.js"
}
}
</script>
<% } %>
在应用中导出方法
在 utility modules 应用入口文件中导出的方法,可以被其它应用使用。
// tools\src\study-tools.js
// Anything exported from this file is importable by other in-browser modules.
export function sayHello(who) {
console.log(`%c${who} sayHello`, 'color:skyblue')
}
在 React 应用中使用
// todos\src\Home.js
import { useEffect, useState } from 'react'
// 创建一个自定义工具函数用于加载 tools
function useToolsModule() {
const [toolsModule, setToolsModule] = useState()
useEffect(() => {
System.import('@study/tools').then(setToolsModule)
})
return toolsModule
}
const Home = () => {
const toolsModule = useToolsModule()
if (toolsModule) {
// 调用 tools 应用导出的方法
toolsModule.sayHello('@study/todos')
}
return <div>Home works</div>
}
export default Home
在 Vue 应用中使用
添加一个按钮,点击后调用 tools 中的方法。
<!-- realworld\src\App.vue -->
<template>
<div id="app">
<Parcel :config="parceConfig" :mountParcel="mountParcel" />
<h1>{{ name }}</h1>
<div>
<router-link to="/bar">Bar</router-link>
<router-link to="/baz">Baz</router-link>
<button @click="handleClick">button</button>
</div>
<router-view />
</div>
</template>
<script>
import Parcel from 'single-spa-vue/dist/esm/parcel'
import { mountRootParcel } from 'single-spa'
export default {
name: 'App',
components: { Parcel },
props: ['name'],
data() {
return {
// 注意这里要使用 window.System
parceConfig: window.System.import('@study/navbar'),
mountParcel: mountRootParcel
}
},
methods: {
async handleClick() {
// 注意这里要使用 window.System
const toolsModule = await window.System.import('@study/tools')
toolsModule.sayHello('@study/realworld')
}
}
}
</script>
<style></style>
实现跨应用通信
在微前端框架中,一般通过发布订阅模式实现应用间的通信和状态共享。
实现跨应用通信可以借助这两个工具:
- utility modules:实现跨应用共享 JS 逻辑
- RxJS:提供发布订阅模式的功能,它是单独的库,与框架无关,可以在任何框架中使用。
在容器应用中引入 RxJS
<!-- 引入公共模块地址 -->
<script type="systemjs-importmap">
{
"imports": {
"single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js",
"react": "https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.production.min.js",
"react-dom": "https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.production.min.js",
"react-router-dom": "https://cdn.jsdelivr.net/npm/react-router-dom@5.3.0/umd/react-router-dom.min.js",
"vue": "https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js",
"vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.5.3/dist/vue-router.min.js",
"rxjs": "https://cdn.jsdelivr.net/npm/rxjs@7.5.2/dist/bundles/rxjs.umd.min.js"
}
}
</script>
在 utilty modules 中导出方法
RxJS 的 ReplaySubject
方法可以广播历史消息。
当一个应用发布消息时,另一个应用可能还未加载,发布历史消息,可以保证应用动态加载后仍可以接受到消息。
// tools\src\study-tools.js
// Anything exported from this file is importable by other in-browser modules.
import { ReplaySubject } from 'rxjs'
export function sayHello(who) {
console.log(`%c${who} sayHello`, 'color:skyblue')
}
// 实例化一个 ReplaySubject 对象
// 它缓存一组值,除了向现有订阅者发送新值外,还会立即向新订阅者发送缓存的值
export const sharedSubject = new ReplaySubject()
在 React 应用中订阅/发布消息
注意:订阅消息的同时也要考虑取消订阅。
// todos\src\Home.js
import { useEffect, useState } from 'react'
// 创建一个自定义工具函数用于加载 tools
function useToolsModule() {
const [toolsModule, setToolsModule] = useState()
useEffect(() => {
System.import('@study/tools').then(setToolsModule)
})
return toolsModule
}
const Home = () => {
const toolsModule = useToolsModule()
// 组件挂载完成后调用
useEffect(() => {
let subjection = null
if (toolsModule) {
// 调用 tools 应用导出的方法
toolsModule.sayHello('@study/todos')
// 订阅消息
subjection = toolsModule.sharedSubject.subscribe(console.log)
}
// 返回清理函数,用于在组件卸载时取消订阅
return () => subjection && subjection.unsubscribe()
}, [toolsModule])
return (
<div>
Home works
<button onClick={() => toolsModule.sharedSubject.next('Hello')}>发布消息</button>
</div>
)
}
export default Home
在 Vue 应用中订阅/发布消息
<!-- realworld\src\App.vue -->
<template>
<div id="app">
<Parcel :config="parceConfig" :mountParcel="mountParcel" />
<h1>{{ name }}</h1>
<div>
<router-link to="/bar">Bar</router-link>
<router-link to="/baz">Baz</router-link>
<button @click="handleClick">button</button>
</div>
<router-view />
</div>
</template>
<script>
import Parcel from 'single-spa-vue/dist/esm/parcel'
import { mountRootParcel } from 'single-spa'
export default {
name: 'App',
components: { Parcel },
props: ['name'],
data() {
return {
subjection: null,
// 注意这里要使用 window.System
parceConfig: window.System.import('@study/navbar'),
mountParcel: mountRootParcel
}
},
methods: {
async handleClick() {
// 注意这里要使用 window.System
const toolsModule = await window.System.import('@study/tools')
toolsModule.sayHello('@study/realworld')
}
},
async mounted() {
const toolsModule = await window.System.import('@study/tools')
// 订阅消息
this.subjection = toolsModule.sharedSubject.subscribe(console.log)
},
beforeDestroy() {
// 取消订阅
this.subjection.unsubscribe()
},
}
</script>
<style></style>
测试
访问 React 的 todos 应用页面,点击**“发送消息”**按钮,控制台打印了消息内容,这是 todos 应用订阅的处理事件。
然后再切换到 Vue 的 realworld 应用页面,组件挂载后,控制台也会打印消息内容,这是 realworld 应用定于的处理事件。
说明 ReplaySubject 广播了历史消息,其它动态加载的应用也可以在加载后收到广播的历史消息,从而可以实现跨应用通信和状态共享。
布局引擎 Layout Engine
布局引擎允许使用组件的方式声明顶层路由,即访问什么地址激活什么应用,这种方式类似 React 中配置路由的方式。
布局引擎还提供了更加便捷的路由 API 来注册应用,从而改变现在通过多次调用 registerApplication
注册应用的方式。
安装布局引擎
在 workspace/container 容器应用下安装:
# 本例安装的版本是 single-spa-layout@2.0.1
npm install single-spa-layout
构建路由
在模板文件中添加一个 <template>
元素,在里面配置路由,之后在 js 文件中需要获取这个 <template>
元素。
<body>
<template id="single-spa-layout">
<single-spa-router>
<!-- 不包含在 route 组件下的应用是公共模块,会在每个页面都显示 -->
<application name="@study/navbar"></application>
<!-- 包含在 route 组件下的应用会在指定路由下显示 -->
<!-- 应用会在 path 指定的路由下显示 -->
<!-- default 是默认路由(/) -->
<route default>
<application name="@single-spa/welcome"></application>
</route>
<route path="foo">
<application name="@study/foo"></application>
</route>
<route path="todos">
<application name="@study/todos"></application>
</route>
<route path="realworld">
<application name="@study/realworld"></application>
</route>
</single-spa-router>
</template>
<main></main>
<div id="root"></div>
<!-- 加载容器应用模块 -->
<script>
System.import('@study/root-config')
// System.import('./study-root-config.js');
</script>
<!-- 调试工具。可以通过浏览器调试工具(single-spa-Inspector)更改注册的微应用模块的地址 -->
<!-- 例如,将线上环境的模块地址更改为开发环境的模块地址 -->
<import-map-overrides-full show-when-local-storage="devtools" dev-libs></import-map-overrides-full>
</body>
@single-spa/welcome
应用当前是在注册时指定的引入地址,要在 import-map 中添加一下:
<!-- 引入容器应用模块 -->
<!-- 开发环境指定本地地址 -->
<% if (isLocal) { %>
<script type="systemjs-importmap">
{
"imports": {
"@study/root-config": "//localhost:9000/study-root-config.js",
"@study/foo": "//localhost:9001/study-foo.js",
"@study/todos": "//localhost:9002/study-todos.js",
"@study/realworld": "//localhost:9003/js/app.js",
"@study/navbar": "//localhost:9004/study-navbar.js",
"@study/tools": "//localhost:9005/study-tools.js",
"@single-spa/welcome": "https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js"
}
}
</script>
<% } %>
获取路由信息 && 注册应用
现在已经通过组件的方式配置了路由,可以将它理解为一种语法糖,可以通过它获取一个数组,数组中包含的每个对象都是一个有效的 registerApplication
方法的参数,这样就可以在 js 文件中使用了:
// container\src\study-root-config.js
import { registerApplication, start } from 'single-spa'
import { constructRoutes, constructApplications } from 'single-spa-layout'
// 获取路由配置对象
// constructRoutes 会将路由配置解析成一个对象,其中包含用于注册应用的数组
const routes = constructRoutes(document.getElementById('single-spa-layout'))
// 获取路由信息数组
// constructApplications 会解析 routes,返回一个数组,数组的每个元素都是 registerApplication 方法接受的参数对象 `{name,app,activeWhen}`
const applications = constructApplications({
routes,
loadApp({ name }) {
return System.import(name)
}
})
// 遍历路由信息注册应用
applications.forEach(registerApplication)
start({
urlRerouteOnly: true
})
现在访问 todos 和 realworld 可以看到两个 navbar,即表示成功。