single-spa实现原理
single-spa使用
index.html
html
<!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>Document</title>
</head>
<body>
<script src="https://cdn.bootcdn.net/ajax/libs/single-spa/5.9.3/umd/single-spa.min.js"></script>
<script>
// 微前端 就是可以加载不同的应用 基于路由的微前端
// 如何接入已经写好的应用 对于singlespa而言,我们需要改写子应用 (接入协议) bootstrap, mount, unmount
// /a /b
let { registerApplication, start } = singleSpa
let app1 = {
bootstrap: [
async () => console.log('app1 bootstrap1'),
async () => console.log('app1 bootstrap2')
],
mount: [
async (props) => {
// new Vue().$mount()...
console.log('app1 mount1', props)
},
async () => {
// new Vue().$mount()...
console.log('app1 mount2')
}
],
unmount: async (props) => {
console.log('app1 unmount')
}
}
let app2 = {
bootstrap: async () => console.log('app2 bootstrap1'),
mount: [
async () => {
// new Vue().$mount()...
return new Promise((resolve, reejct) => {
setTimeout(() => {
console.log('app2 mount')
resolve()
}, 1000)
})
}
],
unmount: async () => {
console.log('app2 unmount')
}
}
// 当路径是#/a 的时候就加载 a应用
// 所谓的注册应用 就是看一下路径是否匹配,如果匹配则“加载”对应的应用
registerApplication('a', async () => app1, location => location.hash.startsWith('#/a'), { a: 1 })
registerApplication('b', async () => app2, location => location.hash.startsWith('#/b'), { a: 1 })
// 开启路径的监控,路径切换的时候 可以调用对应的mount unmount
start()
</script>
<script>
</script>
</body>
</html>
使用http-server启动服务
bash
npm i http-server -g
# 在 index.html所在目录
http-server
# 启动服务 http://127.0.0.1:8080
应用加载与卸载
bash
访问 http://127.0.0.1:8080/#/a
app1 bootstrap1
app1 bootstrap2
app1 mount1 {a: 1, name: 'a', singleSpa: {…}, mountParcel: ƒ}
app1 mount2
访问 http://127.0.0.1:8080/#/b
app1 unmount
app2 bootstrap1
app2 mount
访问 http://127.0.0.1:8080/#/a
app2 unmount
app1 mount1 {a: 1, name: 'a', singleSpa: {…}, mountParcel: ƒ}
app1 mount2
single-spa基础结构
- registerApplication: 所谓的注册应用 就是看一下路径是否匹配,如果匹配则“加载”对应的应用
- start:开启路径的监控,路径切换的时候 可以调用对应的mount unmount
single-spa/application/app.js
javascript
export function registerApplication() {
}
single-spa/start.js
javascript
export function start() {
}
single-spa/sindle-spa.js
javascript
export { registerApplication } from "./application/app.js"; // 根据路径加载应用
export { start } from "./start.js"; // 开启应用 挂载组件
index.html
diff
<!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>Document</title>
</head>
<body>
+ <!-- <script src="https://cdn.bootcdn.net/ajax/libs/single-spa/5.9.3/umd/single-spa.min.js"></script> -->
+ <script type="module">
// 微前端 就是可以加载不同的应用 基于路由的微前端
// 如何接入已经写好的应用 对于singlespa而言,我们需要改写子应用 (接入协议) bootstrap, mount, unmount
// /a /b
+ // let { registerApplication, start } = singleSpa
+ import { registerApplication, start } from './single-spa/single-spa.js'
let app1 = {
bootstrap: [
async () => console.log('app1 bootstrap1'),
async () => console.log('app1 bootstrap2')
],
mount: [
async (props) => {
// new Vue().$mount()...
console.log('app1 mount1', props)
},
async () => {
// new Vue().$mount()...
console.log('app1 mount2')
}
],
unmount: async (props) => {
console.log('app1 unmount')
}
}
let app2 = {
bootstrap: async () => console.log('app2 bootstrap1'),
mount: [
async () => {
// new Vue().$mount()...
return new Promise((resolve, reejct) => {
setTimeout(() => {
console.log('app2 mount')
resolve()
}, 1000)
})
}
],
unmount: async () => {
console.log('app2 unmount')
}
}
// 当路径是#/a 的时候就加载 a应用
+ // 所谓的注册应用 就是看一下路径是否匹配,如果匹配则“加载”对应的应用
registerApplication('a', async () => app1, location => location.hash.startsWith('#/a'), { a: 1 })
registerApplication('b', async () => app2, location => location.hash.startsWith('#/b'), { a: 1 })
+ // 开启路径的监控,路径切换的时候 可以调用对应的mount unmount
start()
</script>
<script>
</script>
</body>
</html>
将注册应用分类
single-spa/application/app.js
javascript
import { reroute } from "../navigation/reroute.js";
import { NOT_LOADED } from "./app.helpers.js"
export const apps = []
export function registerApplication(appName,loadApp,activeWhen,customProps){
const registeration = {
name:appName,
loadApp,
activeWhen,
customProps,
status:NOT_LOADED
}
apps.push(registeration)
// 我们需要给每个应用添加对应的状态变化
// 未加载 -》 加载 -》挂载 -》 卸载
// 需要检查哪些应用要被加载,还有哪些应用要被挂载,还有哪些应用要被移除
reroute(); // 重写路由
}
single-spa/application/app.helpers.js
处理应用状态的方法
javascript
import { apps } from "./app.js";
// app status
export const NOT_LOADED = 'NOT_LOADED'; // 没有被加载
export const LOADING_SOURCE_CODE = 'LOADING_SOURCE_CODE'; // 路径匹配了 要去加载这个资源
export const LOAD_ERROR = 'LOAD_ERROR'
// 启动的过程
export const NOT_BOOTSTRAPED = 'NOT_BOOTSTRAPED'; // 资源加载完毕了 需要启动,此时还没有启动
export const BOOTSTRAPING = 'BOOTSTRAPING'; // 启动中
export const NOT_MOUNTED = 'NOT_MOUNTED'; // 没有被挂载
// 挂载流程
export const MOUNTING = 'MOUNTING'; // 正在挂载
export const MOUNTED = 'MOUNTED'; // 挂载完成
// 卸载流程
export const UNMOUNTING = 'UNMOUNTING'; // 卸载中
// 看一下这个应用是否正在被激活
export function isActive(app){
return app.status === MOUNTED; // 此应用正在被激活
}
// 看一下此应用是否被激活
export function shouldBeActive(app){
return app.activeWhen(window.location)
}
export function getAppChanges(){
const appsToLoad = []
const appsToMount = []
const appsToUnmount = []
apps.forEach((app)=>{
let appShouldBeActive = shouldBeActive(app)
switch(app.status){
case NOT_LOADED:
case LOADING_SOURCE_CODE:
// 1) 标记当前路径下 哪些应用要被加载
if(appShouldBeActive){
appsToLoad.push(app)
}
break;
case NOT_BOOTSTRAPED:
case BOOTSTRAPING:
case NOT_MOUNTED:
// 2) 当前路径下 哪些应用要被挂载
if(appShouldBeActive){
appsToMount.push(app)
}
break;
case MOUNTED:
// 3) 当前路径下 哪些应用要被卸载
if(!appShouldBeActive){
appsToUnmount.push(app)
}
break
default:
break;
}
})
return {appsToLoad,appsToMount,appsToUnmount}
}
single-spa/navigation/reroute.js
javascript
import { getAppChanges } from "../application/app.helpers.js";
// 后续路径变化 也需要走这里, 重新计算哪些应用被加载或者卸载
export function reroute(event) {
// 获取app对应的状态 进行分类
const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges()
console.log('appsToLoad', appsToLoad);
// 访问 http://127.0.0.1:8080/#/a [{name: 'a', customProps: {…}, status: 'NOT_LOADED', loadApp: ƒ, activeWhen: ƒ}]
// 访问 http://127.0.0.1:8080/#/b [{name: 'b', customProps: {…}, status: 'NOT_LOADED', loadApp: ƒ, activeWhen: ƒ}]
}
应用启动、挂载与卸载
single-spa/start.js
javascript
import { reroute } from "./navigation/reroute.js";
export let started = false; // 默认没有调用start方法
export function start(){
started = true; // 用户启动了
reroute()
}
single-spa/navigation/reroute.js
javascript
import { getAppChanges, shouldBeActive } from "../application/app.helpers.js";
import { toBootstrapPromise } from "../lifecycles/bootstrap.js";
import { toLoadPromise } from "../lifecycles/load.js";
import { toMountPromise } from "../lifecycles/mount.js";
import { toUnmountPromise } from "../lifecycles/unmount.js";
import { started } from "../start.js";
// 后续路径变化 也需要走这里, 重新计算哪些应用被加载或者写在
let appChangeUnderWay = false;
let peopleWaitingOnAppChange = []
export function reroute(event) {
// 如果多次触发reroute 方法我们可以创造一个队列来屏蔽这个问题
if(appChangeUnderWay){
return new Promise((resolve,reject)=>{
peopleWaitingOnAppChange.push({
resolve,reject
})
})
}
// 获取app对应的状态 进行分类
const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges()
// 加载完毕后 需要去挂载的应用
if(started){
appChangeUnderWay = true
// 用户调用了start方法 我们需要处理当前应用要挂载或者卸载
return performAppChange();
}
// 先拿到应用去加载 -》
return loadApps();
function loadApps() {
// 应用的加载
return Promise.all(appsToLoad.map(toLoadPromise))// 目前我们没有调用start
}
function performAppChange(){
// 将不需要的应用卸载掉, 返回一个卸载的promise
// 1) 稍后测试销毁逻辑
const unmountAllPromises = Promise.all(appsToUnmount.map(toUnmountPromise))
// 流程加载需要的应用 -》 启动对应的应用 -》 卸载之前的 -》 挂载对应的应用
// 2) 加载需要的应用(可能这个应用在注册的时候已经被加载了)
// 默认情况注册的时候 路径是 /a , 但是当我们start的时候应用是/b
const loadMountPromises = Promise.all(appsToLoad.map(app=> toLoadPromise(app).then(app=>{
// 当应用加载完毕后 需要启动和挂载,但是要保证挂载前 先卸载掉来的应用
return tryBootstrapAndMount(app,unmountAllPromises)
})));
// 如果应用 没有加载 加载 -》启动挂载 如果应用已经加载过了 挂载
const MountPromises = Promise.all(appsToMount.map(app=> tryBootstrapAndMount(app,unmountAllPromises)))
function tryBootstrapAndMount(app,unmountAllPromises){
if(shouldBeActive(app)){
// 保证卸载完毕在挂载
return toBootstrapPromise(app).then(app=> unmountAllPromises.then(()=> toMountPromise(app)))
}
}
return Promise.all([loadMountPromises,MountPromises]).then(()=>{ // 卸载完毕后
appChangeUnderWay = false;
if(peopleWaitingOnAppChange.length > 0){
peopleWaitingOnAppChange = []; // 多次操作 我缓存起来,。。。。
}
})
}
}
single-spa/lifecycles/bootstrap.js
javascript
import { BOOTSTRAPING, NOT_BOOTSTRAPED, NOT_MOUNTED } from "../application/app.helpers.js";
export function toBootstrapPromise(app){
return Promise.resolve().then(()=>{
if(app.status !== NOT_BOOTSTRAPED){
// 此应用加载完毕了
return app;
}
app.status = BOOTSTRAPING
return app.bootstrap(app.customProps).then(()=>{
app.status = NOT_MOUNTED;
return app
})
})
}
single-spa/lifecycles/load.js
javascript
import { LOADING_SOURCE_CODE, NOT_BOOTSTRAPED, NOT_LOADED } from "../application/app.helpers.js"
function flattenArrayToPromise(fns) {
fns = Array.isArray(fns) ? fns : [fns]
return function(props){ // redux
return fns.reduce((rPromise,fn)=>rPromise.then(()=>fn(props)), Promise.resolve())
}
}
export function toLoadPromise(app){
return Promise.resolve().then(()=>{
if(app.status !== NOT_LOADED){
// 此应用加载完毕了
return app;
}
app.status = LOADING_SOURCE_CODE; // 正在加载应用
// loadApp 对于之前的内容 System.import()
return app.loadApp(app.customProps).then(v=>{
const {bootstrap,mount,unmount} = v;
app.status = NOT_BOOTSTRAPED;
app.bootstrap = flattenArrayToPromise(bootstrap);
app.mount = flattenArrayToPromise(mount);
app.unmount = flattenArrayToPromise(unmount);
return app
})
})
}
single-spa/lifecycles/mount.js
javascript
import { MOUNTED, NOT_MOUNTED } from "../application/app.helpers.js";
export function toMountPromise(app){
return Promise.resolve().then(()=>{
if(app.status !== NOT_MOUNTED){
return app;
}
return app.mount(app.customProps).then(()=>{
app.status = MOUNTED;
return app
})
})
}
single-spa/lifecycles/unmount.js
javascript
import { MOUNTED, NOT_MOUNTED, UNMOUNTING } from "../application/app.helpers.js"
export function toUnmountPromise(app){
return Promise.resolve().then(()=>{
if(app.status !== MOUNTED){
return app;
}
app.status = UNMOUNTING;
// app.unmount 方法用户可能写的是一个数组。。。。。
return app.unmount(app.customProps).then(()=>{
app.status = NOT_MOUNTED;
})
})
}
重写路由监听方法
navigation/navigation-event.js
javascript
// 对用户的路径切换 进行劫持,劫持后,重新调用reroute方法,进行计算应用的加载
import { reroute } from "./reroute.js";
function urlRoute() {
reroute(arguments)
}
window.addEventListener('hashchange', urlRoute)
window.addEventListener('popstate', urlRoute); // 浏览器历史切换的时候会执行此方法
// 但是当路由切换的时候 我们触发single-spa的addEventLister, 应用中可能也包含addEventLister
// 需要劫持原生的路由系统,保证当我们加载完后再切换路由
const capturedEventListeners = {
hashchange: [],
popstate: []
}
const listentingTo = ['hashchange','popstate']
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
window.addEventListener = function(eventName,callback){
// 有要监听的事件, 函数不能重复
if(listentingTo.includes(eventName) && !capturedEventListeners[eventName].some(listener=>listener === callback)){
return capturedEventListeners[eventName].push(callback)
}
return originalAddEventListener.apply(this,arguments)
}
window.removeEventListener = function(eventName,callback){
// 有要监听的事件, 函数不能重复
if(listentingTo.includes(eventName) ){
capturedEventListeners[eventName] = capturedEventListeners[eventName].filter(fn=> fn!== callback)
return
}
return originalRemoveEventListener.apply(this,arguments)
}
export function callCaptureEventListeners(e){
if(e){
const eventType = e[0].type;
if(listentingTo.includes(eventType)){
capturedEventListeners[eventType].forEach(listener => {
listener.apply(this,e)
});
}
}
}
function patchFn(updateState,methodName){
return function(){
const urlBefore = window.location.href;
const r = updateState.apply(this,arguments); // 调用此方法 确实发生了路径的变化
const urlAfter = window.location.href;
if(urlBefore !== urlAfter){
// 手动派发popstate事件
window.dispatchEvent(new PopStateEvent("popstate"))
}
return r;
}
}
window.history.pushState = patchFn(window.history.pushState,'pushState')
window.history.replaceState = patchFn(window.history.replaceState,'replaceState')
single-spa/navigation/reroute.js
diff
import { getAppChanges, shouldBeActive } from "../application/app.helpers.js";
import { toBootstrapPromise } from "../lifecycles/bootstrap.js";
import { toLoadPromise } from "../lifecycles/load.js";
import { toMountPromise } from "../lifecycles/mount.js";
import { toUnmountPromise } from "../lifecycles/unmount.js";
import { started } from "../start.js";
+import './navigation-event.js'
+import { callCaptureEventListeners } from "./navigation-event.js";
// 后续路径变化 也需要走这里, 重新计算哪些应用被加载或者写在
let appChangeUnderWay = false;
let peopleWaitingOnAppChange = []
export function reroute(event) {
// 如果多次触发reroute 方法我们可以创造一个队列来屏蔽这个问题
if(appChangeUnderWay){
return new Promise((resolve,reject)=>{
peopleWaitingOnAppChange.push({
resolve,reject
})
})
}
// 获取app对应的状态 进行分类
const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges()
// 加载完毕后 需要去挂载的应用
if(started){
appChangeUnderWay = true
// 用户调用了start方法 我们需要处理当前应用要挂载或者卸载
return performAppChange();
}
// 先拿到应用去加载 -》
return loadApps();
function loadApps() {
// 应用的加载
return Promise.all(appsToLoad.map(toLoadPromise)).then(callEventListener)// 目前我们没有调用start
}
function performAppChange(){
// 将不需要的应用卸载掉, 返回一个卸载的promise
// 1) 稍后测试销毁逻辑
const unmountAllPromises = Promise.all(appsToUnmount.map(toUnmountPromise))
// 流程加载需要的应用 -》 启动对应的应用 -》 卸载之前的 -》 挂载对应的应用
// 2) 加载需要的应用(可能这个应用在注册的时候已经被加载了)
// 默认情况注册的时候 路径是 /a , 但是当我们start的时候应用是/b
const loadMountPromises = Promise.all(appsToLoad.map(app=> toLoadPromise(app).then(app=>{
// 当应用加载完毕后 需要启动和挂载,但是要保证挂载前 先卸载掉来的应用
return tryBootstrapAndMount(app,unmountAllPromises)
})));
// 如果应用 没有加载 加载 -》启动挂载 如果应用已经加载过了 挂载
const MountPromises = Promise.all(appsToMount.map(app=> tryBootstrapAndMount(app,unmountAllPromises)))
function tryBootstrapAndMount(app,unmountAllPromises){
if(shouldBeActive(app)){
// 保证卸载完毕在挂载
return toBootstrapPromise(app).then(app=> unmountAllPromises.then(()=> toMountPromise(app)))
}
}
return Promise.all([loadMountPromises,MountPromises]).then(()=>{ // 卸载完毕后
+ callEventListener();
appChangeUnderWay = false;
if(peopleWaitingOnAppChange.length > 0){
peopleWaitingOnAppChange = []; // 多次操作 我缓存起来,。。。。
}
})
}
+ function callEventListener(){
+ callCaptureEventListeners(event)
+ }
}
index.html
diff
<!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>Document</title>
</head>
<body>
+ <!-- <a href="#/a">a应用</a>
+ <a href="#/b">b应用</a> -->
<!-- <script src="https://cdn.bootcdn.net/ajax/libs/single-spa/5.9.3/umd/single-spa.min.js"></script> -->
<script type="module">
// 微前端 就是可以加载不同的应用 基于路由的微前端
// 如何接入已经写好的应用 对于singlespa而言,我们需要改写子应用 (接入协议) bootstrap, mount, unmount
// /a /b
import { registerApplication, start } from './single-spa/single-spa.js'
// let { registerApplication, start } = singleSpa
let app1 = {
bootstrap: [
async () => console.log('app1 bootstrap1'),
async () => console.log('app1 bootstrap2')
],
mount: [
async (props) => {
// new Vue().$mount()...
console.log('app1 mount1', props)
},
async () => {
// new Vue().$mount()...
console.log('app1 mount2')
}
],
unmount: async (props) => {
console.log('app1 unmount')
}
}
let app2 = {
bootstrap: async () => console.log('app2 bootstrap1'),
mount: [
async () => {
// new Vue().$mount()...
return new Promise((resolve, reejct) => {
setTimeout(() => {
console.log('app2 mount')
resolve()
}, 1000)
})
}
],
unmount: async () => {
console.log('app2 unmount')
}
}
// 当路径是#/a 的时候就加载 a应用
// 所谓的注册应用 就是看一下路径是否匹配,如果匹配则“加载”对应的应用
registerApplication('a', async () => app1, location => location.hash.startsWith('#/a'), { a: 1 })
registerApplication('b', async () => app2, location => location.hash.startsWith('#/b'), { a: 1 })
// 开启路径的监控,路径切换的时候 可以调用对应的mount unmount
start()
+ // 这个监控操作 应该被延迟到 当应用挂挂载完毕后再行
+ window.addEventListener('hashchange', function () {
+ console.log(window.location.hash, 'p----')
+ })
+ // window.addEventListener('popstate',function(){
+ // console.log(window.location.hash,'p----')
+ // })
+ </script>
+ <a onclick="go('#/a')">a应用</a>
+ <a onclick="go('#/b')">b应用</a>
+ <script>
+ function go(url) { // 用户调用pushState replaceState 此方法不会触发逻辑reroute
+ history.pushState({}, null, url)
+ }
+ </script>
</body>
</html>
javascript
// 访问 http://127.0.0.1:8080/#/a
app1 bootstrap1
app1 bootstrap2
app1 mount1 {a: 1}
app1 mount2
// 改变路径 http://127.0.0.1:8080/#/b
app1 unmount
app2 bootstrap1
app2 mount (1s后)
// 浏览器回退到 http://127.0.0.1:8080/#/a
app2 unmount
app1 mount1 {a: 1}
app1 mount2
#/a p----
// 浏览器前进到 http://127.0.0.1:8080/#/b
app1 unmount
app2 mount
总结
注册应用要暴露三个接入协议,之后拦截我们的路由系统,当路由切换时我们去加载对应应用的介入协议方法,从而实现应用的挂在和卸载