一. single-spa
介绍
微前端架构是行业流行的解决方案,主要有以下两个优点:
- 技术栈无关
- 协作效率高,如独立构建,独立部署
single-spa
是一个微前端框架,其核心原理是使用路由劫持,当路由与子应用匹配时会执行加载子应用逻辑。
二. 技术方案
2.1 整体架构
2.1.1 主应用
主应用作为整个框架基座,负责提供基本页面布局和注册子应用。
2.1.1.1 基本布局
页面结构根据业务诉求做调整,核心逻辑是提供一个子应用挂载节点。
javascript
function App() {
return (
<div>
<header>
<h1>Master</h1>
</header>
<div>
<nav>
<ul>
<li onClick={() => history.pushState(null, '', '/home')}>home</li>
<li onClick={() => history.pushState(null, '', '/account')}>
account
</li>
</ul>
</nav>
<main>
<div id='subapp' />
</main>
</div>
</div>
)
}
2.1.1.2 注册子应用
首先定义一个single-spa.config.js
文件,然后在入口文件引入。
javascript
import { registerApplication, start } from 'single-spa'
registerApplication(
'@qianqian/home',
() => System.import('@qianqian/home'),
location => location.pathname.startsWith('/home'),
)
registerApplication(
'@qianqian/account',
() => System.import('@qianqian/account'),
location => location.pathname.startsWith('/account'),
)
start()
其次在主应用的html
文档添加一个script
标签,核心逻辑是注册子应用模块可执行文件引用路径。
html
<!-- 引入systemjs -->
<script src="/common/system.0579ab.js"></script>
<!-- 注册子应用模块可执行文件引用路径 -->
<script type="systemjs-importmap">
{
"imports": {
"@qianqian/account": "http://localhost:8001/main.js",
"@qianqian/home": "http://localhost:8002/main.js"
}
}
</script>
2.1.2 子应用
子应用入口文件暴露生命周期钩子函数,如bootstrap
,mount
和unmount
。
javascript
let root = null
const lifecycles = {
bootstrap(opts) {
return Promise.resolve()
},
mount(opts) {
return new Promise(resolve => {
root = createRoot(document.querySelector('#subapp'))
root.render(<App />)
resolve()
})
},
unmount(opts) {
return new Promise(resolve => {
if (root) {
root.unmount()
root = null
}
resolve()
})
},
}
export const { bootstrap, mount, unmount } = lifecycles
2.2 整体流程
整体执行流程如图所示,首先先注册子应用路由,然后添加路由拦截逻辑。
当有路由与子应用匹配时,执行加载子应用逻辑,判断当前子应用是否首次加载,如果是需要先获取子应用入口可执行文件,然后调用bootstrap
生命周期函数,接着调用mount
生命周期函数。
当路由变化加载别的子应用时则需要先调用当前子应用的unmount
生命周期函数。
2.3 构建产物
子应用构建产物类型有两种: module
和system
,可以通过webpack output.libraryTarget
配置。
产物类型 | 说明 |
---|---|
module |
优点是可以直接使用浏览器支持ESM 模块化机制,缺点是webpack 不支持热更新,本地开发体验不友好 |
system |
优点是支持热更新,缺点是需要额外引入systemjs 进行模块解析 |
2.4 缺点
2.4.1 代码分割
子应用不支持使用webpack.optimization
配置,因为single-spa
架构设计理念是子应用提供入口执行文件,主应用获取该文件并执行,如果子应用使用了webpack.optimization
配置,那么js
产物会分割成多个chunk
,那入口执行文件的逻辑就不完整,主应用执行该文件就不能正常触发子应用业务逻辑。
相对的,single-spa
推荐使用webpack.externals
配置。
2.4.2 mini-css-extract-plugin
子应用不支持使用mini-css-extract-plugin
,原因参考2.2.1
2.4.3 js
沙盒和css
隔离
single-spa
没有js
沙盒和css
隔离机制,需要额外使用其它方案处理。
css
隔离常见方案:
css moudle
styled component
shadow DOM
js
沙盒常见方案:
- 快照沙箱
Proxy
沙箱
三. 实现single-spa
3.1 定义registerApplication
registerApplication
方法用于注册子应用,入参如下:
appName
: 子应用名称loadApp
: 获取子应用入口可执行文件activeWhen
: 判断是否加载子应用customProps
: 传递子应用自定义属性
javascript
const apps = []
function registerApplication(appName, loadApp, activeWhen, customProps) {
apps.push({
appName,
loadApp,
activeWhen,
customProps,
status: NOT_LOADED,
})
}
3.2 定义start
start
方法主要添加路由拦截逻辑和调用加载子应用方法。
javascript
function patchedUpdateState(updateState) {
return function () {
const urlBefore = window.location.href
const result = updateState.apply(this, arguments)
const urlAfter = window.location.href
if (urlBefore !== urlAfter) {
window.dispatchEvent(
new PopStateEvent('popstate', { state: window.history.state }),
)
}
return result
}
}
// 路由拦截
function patchHistoryApi() {
window.addEventListener('popstate', () => {
reroute()
})
window.history.pushState = patchedUpdateState(window.history.pushState)
}
function start() {
patchHistoryApi()
reroute()
}
3.3 reroute
处理子应用加载逻辑。核心逻辑如下:
- 根据当前路由判断需要加载/卸载子应用
- 调用要卸载子应用的
unmount
生命周期函数 - 调用要加载子应用的
bootstrap
和mount
生命周期函数
javascript
function reroute() {
const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges()
performAppChanges()
function performAppChanges() {
return Promise.resolve().then(() => {
const unmountPromises = appsToUnmount.map(toUnmountPromise)
const unmountAllPromise = Promise.all(unmountPromises)
const loadThenMountPromises = appsToLoad.map(app =>
toLoadPromise(app).then(app =>
tryToBootstrapAndMount(app, unmountAllPromise),
),
)
const mountPromises = appsToMount.map(app =>
tryToBootstrapAndMount(app, unmountAllPromise),
)
// 先执行子应用卸载逻辑,再执行子应用加载逻辑
return unmountAllPromise.then(() =>
Promise.all(loadThenMountPromises.concat(mountPromises)),
)
})
}
}
3.3.1 getAppChanges
根据子应用的status
和activeWhen
方法判断是否加载/卸载。
javascript
function shouldBeActive(app) {
return app.activeWhen(window.location)
}
function getAppChanges() {
const appsToUnmount = []
const appsToLoad = []
const appsToMount = []
apps.forEach(app => {
const appShouldBeActive = shouldBeActive(app)
switch (app.status) {
case NOT_LOADED:
case LOADING_SOURCE_CODE:
if (appShouldBeActive) appsToLoad.push(app)
break
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (appShouldBeActive) appsToMount.push(app)
break
case MOUNTED:
if (!appShouldBeActive) appsToUnmount.push(app)
break
}
})
return {
appsToLoad,
appsToMount,
appsToUnmount,
}
}
3.3.2 lifecycle
子应用生命周期函数处理逻辑。
3.3.2.1 load
获取子应用入口可执行文件暴露的生命周期钩子函数。
javascript
function toLoadPromise(app) {
return Promise.resolve().then(() => {
if (app.loadPromise) return app.loadPromise
if (app.status !== NOT_LOADED) return app
app.status = LOADING_SOURCE_CODE
return (app.loadPromise = Promise.resolve().then(() => {
const loadPromise = app.loadApp(app.customProps)
return loadPromise.then(lifecycle => {
app.status = NOT_BOOTSTRAPPED
app.bootstrap = lifecycle.bootstrap
app.mount = lifecycle.mount
app.unmount = lifecycle.unmount
delete app.loadPromise
return app
})
}))
})
}
3.3.2.2 bootstrap
子应用首次加载时调用。
javascript
function toBootstrapPromise(app) {
return Promise.resolve().then(() => {
if (app.status !== NOT_BOOTSTRAPPED) return app
app.status = BOOTSTRAPPING
return app.bootstrap(app.customProps).then(() => {
app.status = NOT_MOUNTED
return app
})
})
}
3.3.2.3 mount
子应用每次加载时调用。
javascript
function toMountPromise(app) {
return Promise.resolve().then(() => {
if (app.status !== NOT_MOUNTED) return app
app.status = MOUNTING
return app.mount(app.customProps).then(() => {
app.status = MOUNTED
return app
})
})
}
3.3.2.4 unmount
子应用每次卸载时调用。
javascript
function toUnmountPromise(app) {
return Promise.resolve().then(() => {
if (app.status !== MOUNTED) return app
app.status = UNMOUNTING
return app.unmount(app.customProps).then(() => {
app.status = NOT_MOUNTED
return app
})
})
}
四. 踩坑
4.1 webpack resolve.modules
配置
在使用pnpm
构建monorepo
项目时,需要注意子项目webpack resolve.modules
配置中的node_modules
不要使用绝对路径,否则会导致依赖模块解析错误。原因是pnpm
对于项目没有直接声明引用的依赖是存放在node_modules/.pnpm
目录下。
javascript
// error
module.exports = {
// ...
resolve: {
modules: [path.resolve(__dirname, 'src'), path.resolve(__dirname), 'node_modules'],
},
}
// right
module.exports = {
// ...
resolve: {
modules: [path.resolve(__dirname, 'src'), 'node_modules'],
},
}
五. 总结
single-spa
是一个优秀的微前端框架,其核心理念值得借鉴学习。但是single-spa
也存在一些不足之处,如对子应用的代码产物有比较多的限制,上手成本也相对较高,需要对模块化规范有比较深的了解。代码仓库
创作不易,如果文章对你有帮助,那点个小小的赞吧,你的支持是我持续更新的动力!