Single-SPA源码分析

834 阅读5分钟

Single-SPA简单使用案例

<!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>
    let {registerApplication, start} = singleSpa;

//子应用中需要暴露三个钩子函数
    let app1 = {
        boostrap: [
            async () => {
              console.log('app1 启动-1')
            },
            async () => {
              console.log('app1 启动-2')
            }
          ],
        mount: async () => {
          console.log('app1 mount')
        },
        unmount:  async () => {
          console.log('app1 unmount')
        },
      };
      let app2 = {
        boostrap: [
            async () => {
              console.log('app2 启动-1')
            },
            async () => {
              console.log('app2 启动-2')
            }
          ],
        mount: async () => {
          console.log('app2 mount')
        },
        unmount:  async () => {
          console.log('app2 unmount')
        },
      }

      const customProps = {name: 'wq'}

      registerApplication(
        'app1', 
        async () => app1,
        location => location.hash == '#/a', //路径匹配后会加载应用
        customProps  //自定义属性
      ),
      registerApplication(
        'app2', 
        async () => app2,
        location => location.hash == '#/b', //路径匹配后会加载应用
        customProps  //自定义属性
      )

      start();
    
  </script>
</body>
</html>

由上可知,需要先在父应用中注册子应用,等路径匹配时,再加载子应用。子应用对外暴露三个钩子函数boostrap,mount, unmount.

Single-SPA源码分析

状态机

single-spa基于子应用的状态来进行加载,挂载,卸载等操作,在进行相应操作后,需要更改状态,不停的进行状态流转。

首先来看一张状态流转图

image.png

在子应用的不同阶段,有不同的状态。

registerApplication方法

调用此方法进行应用注册,也就是将应用保存起来

const apps = []; //这里用于存放所有的应用
function registerApplication(appName, loadApp, activeWhen, customProps) {
    //注册子应用
  const registeraction = {
    name: appName,
    loadApp,
    activeWhen,
    customProps,
    status: NOT_LOADED
  }
  //保存到数组中,后续可以在数组里筛选需要的app是加载还是卸载还是挂载
  apps.push(registeraction);
  
  //需要加载应用 注册完毕后,需要进行应用的加载
  //后续切换路由,要再次做这些事,single-spa的核心
  reroute();
}

reroute方法是single-spa的核心,看一下reoute方法是怎么实现的:

function reroute() {
    //首先,我们需要先知道需要加载,需要挂载,需要卸载的应用
    const {appsToLoad, appsToMount, appsToUnmount} = getAppChanges();
    
    //确认了需要加载的app之后,进行加载
    return loadApps()
    
    function loadApps() {
        const loadPromises = appsToLoad.map(toLoadPromise);
        return Promise.all(loadPromises); 
    }
}

function toLoadPromise() {
    //需要返回一个Promise
    return Promise.resolve().then(() => {
      
       if(app.status !== NOT_LOADED) {
          //只有当应用是NOT_LOADED的时候才需要加载
          return app;
        }
        
       return app.loadApp().then((val) => {
            //应用加载完成后
            let { boostrap, mount, unmount } = val;
            app.status = NOT_BOOTSTRAPPED; //改变应用状态
             //获取应用的钩子方法,接入协议
             //因为钩子函数可能为数组,因此需要打平
            app.boostrap = flattenFnArray(boostrap);
            app.mount = flattenFnArray(mount);
            app.unmount = flattenFnArray(unmount);
            return app;
        })
        
        
    })
}

function flattenFnArray() {
    fns = Array.isArray(fns) ? fns : [fns]
    
    //数组中的promise需要按序调用
    return function(customProps) {
        //异步串行
        return fns.reduce((resultPromise, fn) => resultPromise.then(() => fn(customProps)), Promise.resolve())
    }
    
}

function shouldBeActive(app) {
    //判断此应用是否应该被激活
    return app.activeWhen(window.location) //路由匹配上了,就需要激活
}

function getAppChanges() {

  const appsToLoad = [];
  const appsToMount = [];
  const appsToUnmount = [];

   //这里的apps就是registerApplication中存放所有子应用的地方
  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;
      default:
        break;
    }
  });


  return {appsToLoad, appsToMount, appsToUnmount}
}

总结:registerApplication方法就是保存应用,并预加载被激活的子应用

start方法

//开始启动主应用
let start = false;
function start() {
    start = true;
    reroute();
}

start方法获取加载的应用,执行对应的生命周期钩子

回到reroute方法

function reroute() {
    //首先,我们需要先知道需要加载,需要挂载,需要卸载的应用
    const {appsToLoad, appsToMount, appsToUnmount} = getAppChanges();
    
    //在启动阶段,执行子应用的生命周期钩子
    if(start) {
        return perfromAppChanges()
    }
    
    //确认了需要加载的app之后,进行加载
        return loadApps()
    
    function loadApps() {
        const loadPromises = appsToLoad.map(toLoadPromise);
        return Promise.all(loadPromises); 
    }
    
    function perfromAppChanges() {
        //需要调用bootrap,调用mount和unmount
        
        //加载应用,toLoadPromise中需要增加LOADING_SOURCE_CODE状态,避免重复加载
        //tryBootstrapAndMount方法执行钩子函数
        appsToLoad.map(app => toLoadPromise(app).then(app, tryBootstrapAndMount(app)))
    }
}

function toLoadPromise(app) {
  return Promise.resolve().then(() => {
    //获取应用的钩子方法,接入协议
    if(app.status !== NOT_LOADED) {
      //只有当他是NOT_LOADED的时候才需要加载
      return app;
    }

    app.status = LOADING_SOURCE_CODE;

    return app.loadApp().then((val) => {
      let { boostrap, mount, unmount } = val;
      app.status = NOT_BOOTSTRAPPED;
      app.boostrap = flattenFnArray(boostrap);
      app.mount = flattenFnArray(mount);
      app.unmount = flattenFnArray(unmount);
      return app;
    }) 
  })
}

function tryBootstrapAndMount(app) {
    return Promise.resolve().then(() => {
        if(shouldBeActive(app)) {
            //先执行bootrap,再执行mount
            return toBoostrapPromise(app).then(toMountPromise)
        }
    })
}

function toBoostrapPromise() {
    return Promise.resolve().then(() => {
        if(app.status !== NOT_BOOTSTRAPPED) {
          return app;
        }
        app.status = BOOSTRAPPING; //正在启动
        
        //执行钩子函数
        return app.boostrap(app.customProps).then(() => {
            app.status = NOT_MOUNTED;
            return app;
        })
    })
}

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;
    })
  })
}


start()方法就完成了

在路由切换时,需要能够挂载和卸载子应用。在片段标识符变化时(片段标识符指 URL 中 # 号和它以后的部分),会触发hashchange事件,而在history.back(),history.go()执行时,会触发popstate事件(注意:history.pushState(), history.replaceState()不会触发popstate事件,需要手动触发)

此外,要注意子应用中也有可能有路由系统,需要保证先加载父应用,再加载子应用。因此需要劫持window.addEventListener,先保存popstate,hashchange的处理事件。


function urlRoute() {
  reroute();
}

window.addEventListener('hashchange', urlRoute)
window.addEventListener('popstate', urlRoute)

const routerEventListeningTo = ['hashchange', 'popstate'];

const capturedEventListener = {
  hashchange: [],  //什么时候调用?父应用加载完子应用后调用
  popstate: []
}

const originalAddEventListener = window.addEventListener;
const originalRemoveListener = window.removeEventListener;

window.addEventListener = function (eventName, fn) {
    if(routerEventListeningTo.includes(eventName) && !capturedEventListener[eventName].some(l => l === fn)) {
        //避免重复监听
        return capturedEventListener[eventName].push(fn);
    }
    return originalAddEventListener.apply(this, arguments) 
}

window.removeEventListener = function(eventName, fn) {
  if(routerEventListeningTo.includes(eventName)) {
    return capturedEventListener[eventName] = capturedEventListener[eventName].filter(l => l !== fn);
  }

  return originalRemoveListener.apply(this, arguments)
}


//如果使用的是history.pushState,可以实现页面跳转,但是不会触发popstate

//解决historyApi调用时可以触发popstate
history.pushState = function() {
  //触发popstate事件
  window.dispatchEvent(new PopStateEvent('popstate'))
}

再次回到reroute方法中,reroute中在路由切换的时候,还需要卸载失活的子应用

function reroute() {
    //首先,我们需要先知道需要加载,需要挂载,需要卸载的应用
    const {appsToLoad, appsToMount, appsToUnmount} = getAppChanges();
    
    //在启动阶段,执行子应用的生命周期钩子
    if(start) {
        return perfromAppChanges()
    }
    
    //确认了需要加载的app之后,进行加载
        return loadApps()
    
    function loadApps() {
        const loadPromises = appsToLoad.map(toLoadPromise);
        return Promise.all(loadPromises); 
    }
    
    function perfromAppChanges() {
        //需要调用bootrap,调用mount和unmount
        
        //应用启动了,需要卸载不需要的,挂载需要的
        const unmountPromises = Promise.all(appsToUnmount.map(toUnmountPromise)); //先卸载应用
        
        //tryBootstrapAndMount方法执行钩子函数
        appsToLoad.map(app => toLoadPromise(app).then(app, tryBootstrapAndMount(app, unmountPromises)))
        
        //有可能start()异步调用的,如果此时已经加载完成,处于要挂载的阶段,直接挂载就行了
        appsToMount.map((app) => tryBootstrapAndMount(app, unmountPromises))
    }
}

function toUnmountPromise() {
    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;
        })
    })
}


//tryBootstrapAndMount中也需要改造一下,需要等旧的子应用卸载之后,才能挂载新的应用
function tryBootstrapAndMount(app, unmountPromises) {
    return Promise.resolve().then(() => {
        if(shouldBeActive(app)) {
            //先执行bootrap,再执行mount
            return toBoostrapPromise(app).then((app) => {
                
                return unmountPromises.then(() => {
                    //执行保存的事件处理函数
                    capturedEventListener.hashchange.forEach(fn => fn())
                    capturedEventListener.popstate.forEach(fn => fn())
                     return toMountPromise(app)
                })
               
            })
        }
    })
}

至此,整个single-spa的大致工作原理就完成了,在加载和卸载的过程中,大量运用到promise的知识