微前端框架 之 single-spa 從入門到精通

前序

目的javascript

  • 會使用single-spa開發項目,而後打包部署上線css

  • 刨析single-spa的源碼原理html

  • 手寫一個本身的single-spa框架前端

過程vue

  • 編寫示例項目html5

  • 打包部署java

  • 框架源碼解讀node

  • 手寫框架react

關於微前端的介紹這裏就再也不贅述了,網上有不少的文章,本文的重點在於刨析微前端框架single-spa的實現原理。jquery

single-spa是一個很好的微前端基礎框架,qiankun框架就是基於single-spa來實現的,在single-spa的基礎上作了一層封裝,也解決了single-spa的一些缺陷。

由於single-spa是一個基礎的微前端框架,瞭解了它的實現原理,再去看其它的微前端框架,就會很是容易了。

提示

  • 先熟悉基本使用,熟悉經常使用的API,可經過示例項目 + 官網相結合來達成

  • 若是基礎比較好,能夠先讀後面的手寫 single-spa 框架部分,再回來閱讀源碼,效果可能會更好

  • 文章中涉及到的全部代碼都在 github(示例項目 + single-spa源碼分析 + 手寫single-spa框架 + single-spa-vue源碼分析)

示例項目

新建項目目錄,接下來的全部代碼都會在該目錄中完成

mkdir micro-frontend && cd micro-frontend
複製代碼

示例代碼都是經過vue來編寫的,固然也能夠採用其它的,好比react或者原生JS

子應用 app1

新建子應用

vue create app1
複製代碼

按圖選擇,去除一切項目不須要的干擾項,後面一路回車,等待應用建立完畢

配置子應用

如下全部的操做都在項目根目錄/micro-frontend/app1下完成

vue.config.js

在項目根目錄下新建vue.config.js文件

const package = require('./package.json')
module.exports = {
  // 告訴子應用在這個地址加載靜態資源,不然會去基座應用的域名下加載
  publicPath: '//localhost:8081',
  // 開發服務器
  devServer: {
    port: 8081
  },
  configureWebpack: {
    // 導出umd格式的包,在全局對象上掛載屬性package.name,基座應用須要經過這個全局對象獲取一些信息,好比子應用導出的生命週期函數
    output: {
      // library的值在全部子應用中須要惟一
      library: package.name,
      libraryTarget: 'umd'
    }
  }
}

複製代碼

安裝single-spa-vue

npm i single-spa-vue -S
複製代碼

single-spa-vue負責爲vue應用生成通用的生命週期鉤子,在子應用註冊到single-spa的基座應用時須要用到

改造入口文件

// /src/main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import singleSpaVue from 'single-spa-vue'

Vue.config.productionTip = false

const appOptions = {
  el: '#microApp',
  router,
  render: h => h(App)
}

// 支持應用獨立運行、部署,不依賴於基座應用
if (!window.singleSpaNavigate) {
  delete appOptions.el
  new Vue(appOptions).$mount('#app')
}

// 基於基座應用,導出生命週期函數
const vueLifecycle = singleSpaVue({
  Vue,
  appOptions
})

export function bootstrap (props) {
  console.log('app1 bootstrap')
  return vueLifecycle.bootstrap(() => {})
}

export function mount (props) {
  console.log('app1 mount')
  return vueLifecycle.mount(() => {})
}

export function unmount (props) {
  console.log('app1 unmount')
  return vueLifecycle.unmount(() => {})
}

複製代碼

更改視圖文件

<!-- /views/Home.vue -->
<template>
  <div class="home">
    <h1>app1 home page</h1>
  </div>
</template>

複製代碼
<!-- /views/About.vue -->
<template>
  <div class="about">
    <h1>app1 about page</h1>
  </div>
</template>
複製代碼

環境配置文件

.env

應用獨立運行時的開發環境配置

NODE_ENV=development
VUE_APP_BASE_URL=/
複製代碼

.env.micro

做爲子應用運行時的開發環境配置

NODE_ENV=development
VUE_APP_BASE_URL=/app1
複製代碼

.env.buildMicro

做爲子應用構建生產環境bundle時的環境配置,但這裏的NODE_ENVdevelopment,而不是production,是爲了方便,這個方便其實single-spa帶來的弊端(js entry的弊端)

NODE_ENV=development
VUE_APP_BASE_URL=/app1
複製代碼

修改路由文件

// /src/router/index.js
// ...
const router = new VueRouter({
  mode: 'history',
  // 經過環境變量來配置路由的 base url
  base: process.env.VUE_APP_BASE_URL,
  routes
})
// ...

複製代碼

修改package.json中的script

{
  "name": "app1",
  // ...
  "scripts": {
    // 獨立運行
    "serve": "vue-cli-service serve",
    // 做爲子應用運行
    "serve:micro": "vue-cli-service serve --mode micro",
    // 構建子應用
    "build": "vue-cli-service build --mode buildMicro"
  },
 	// ...
}

複製代碼

啓動應用

應用獨立運行

npm run serve
複製代碼

固然下面的啓動方式也能夠,只不過會在pathname的開頭加了/app1前綴

npm run serve:micro
複製代碼

做爲子應用運行

npm run serve:micro
複製代碼

做爲獨立應用訪問

子應用 app2

/micro-frontend目錄下新建子應用app2,步驟同app1,只需把過程當中出現的'app1'字樣改爲'app2'便可,vue.config.js中的8081改爲8082`

啓動應用,做爲獨立應用訪問

子應用 app3(react)

這部份內容於2020/08/30添加,爲何後來添加這部份內容呢?是由於有同窗但願增長一個react項目的示例,他們在集成react項目時遇到了一些困難,因而找時間就加了這部份內容;發現網上single-spa集成react的示例很是少,僅有的幾個看了下也是對官網示例的抄寫。

示例項目是基於react腳手架cra建立的,整個集成的過程當中難點有兩個:

  • webpack的配置,這部份內容官網有提供

  • 子應用入口的配置,單純看官方文檔的示例項目根本跑不起來,或者即便跑起來也有問題,reactvue的集成還不同,react須要在主項目的配置中也加一點東西,這部分官網配置沒說,是經過single-spa-react源碼看出來的

接下來就開始吧,在/micro-frontend目錄下經過cra腳手架新建子應用app3

安裝 app3

create-react-app app3
複製代碼

如下全部操做都在/micro-frontend/app3目錄下進行

安裝react-router-domsingle-spa-react

npm i react-router-dom single-spa-react -S
複製代碼

打散配置

打散項目的配置,方便更改webpack的配置內容,固然經過react-app-rewired覆寫默認配置應該也是能夠的,官網也有提到,不過我這裏沒試,採用的是直接打散配置

npm run eject
複製代碼

更改 webpack 配置文件

/config/webpack.config.js,官網

  • 刪掉optimization部分,這部分配置和chunk有關,有動態生成的異步chunk存在,會致使主應用沒法配置,由於chunk的名字會變,其實這也是single-spa的缺陷,或者說採用JS entry的缺陷,JS entry建議將全部內容都打成一個bundle - app.js

  • 更改entryoutput部分

{
  ...
  entry: [
      paths.appIndexJs,
    ].filter(Boolean),
  output: {
    path: isEnvProduction ? paths.appBuild : undefined,
    filename: 'js/app.js',
    publicPath: '//localhost:3000',
    jsonpFunction: `webpackJsonp${appPackageJson.name}`,
    library: 'app3',
    libraryTarget: 'umd'
  },
  ...
}
複製代碼

項目入口文件改造

我這裏將可有可無的內容都刪了,只留了/src/index.js/src/index.css

/src/index.js

因爲文章內容太多,字數超出限制,這部分代碼就經過圖片的形式來展現了,若是須要拷貝可去 github

/src/index.css

body {
  text-align: center;
}
複製代碼

啓動子應用

npm run start
複製代碼

瀏覽器訪問localhost:3000

基座應用 layout

/micro-frontend目錄下新建基座應用,爲了簡潔明瞭,新建項目時選擇的配置項和子應用同樣;在本示例中基座應用採用了vue來實現,用別的方式或者框架實現也能夠,好比本身用webpack構建一個項目。

如下操做都在/micro-frontend/layout目錄下進行

安裝single-spa

npm i single-spa -S
複製代碼

改造基座項目

入口文件

// src/main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import { registerApplication, start } from 'single-spa'

Vue.config.productionTip = false

// 遠程加載子應用
function createScript(url) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script')
    script.src = url
    script.onload = resolve
    script.onerror = reject
    const firstScript = document.getElementsByTagName('script')[0]
    firstScript.parentNode.insertBefore(script, firstScript)
  })
}

// 記載函數,返回一個 promise
function loadApp(url, globalVar) {
  // 支持遠程加載子應用
  return async () => {
    await createScript(url + '/js/chunk-vendors.js')
    await createScript(url + '/js/app.js')
    // 這裏的return很重要,須要從這個全局對象中拿到子應用暴露出來的生命週期函數
    return window[globalVar]
  }
}

// 子應用列表
const apps = [
  {
    // 子應用名稱
    name: 'app1',
    // 子應用加載函數,是一個promise
    app: loadApp('http://localhost:8081', 'app1'),
    // 當路由知足條件時(返回true),激活(掛載)子應用
    activeWhen: location => location.pathname.startsWith('/app1'),
    // 傳遞給子應用的對象
    customProps: {}
  },
  {
    name: 'app2',
    app: loadApp('http://localhost:8082', 'app2'),
    activeWhen: location => location.pathname.startsWith('/app2'),
    customProps: {}
  },
  {
    // 子應用名稱
    name: 'app3',
    // 子應用加載函數,是一個promise
    app: loadApp('http://localhost:3000', 'app3'),
    // 當路由知足條件時(返回true),激活(掛載)子應用
    activeWhen: location => location.pathname.startsWith('/app3'),
    // 傳遞給子應用的對象,這個很重要,該配置告訴react子應用本身的容器元素是什麼,這塊兒和vue子應用的集成不同,官網並無說這部分,或者我沒找到,是經過看single-spa-react源碼知道的
    customProps: {
      domElement: document.getElementById('microApp'),
      // 添加 name 屬性是爲了兼容本身寫的lyn-single-spa,原生的不須要,固然加了也不影響
      name: 'app3'
    }
  }
]

// 註冊子應用
for (let i = apps.length - 1; i >= 0; i--) {
  registerApplication(apps[i])
}

new Vue({
  router,
  mounted() {
    // 啓動
    start()
  },
  render: h => h(App)
}).$mount('#app')

複製代碼

App.vue

<template>
  <div id="app">
    <div id="nav">
      <router-link to="/app1">app1</router-link> |
      <router-link to="/app2">app2</router-link>
    </div>
    <!-- 子應用容器 -->
    <div id = "microApp">
      <router-view/>
    </div>
  </div>
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

#nav {
  padding: 30px;
}

#nav a {
  font-weight: bold;
  color: #2c3e50;
}

#nav a.router-link-exact-active {
  color: #42b983;
}
</style>

複製代碼

路由

import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)

const routes = []

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router

複製代碼

啓動基座應用

npm run serve
複製代碼

瀏覽器訪問基座應用

終於看到告終果。

小技巧

有時候single-spa可能會報一些咱們如今沒法理解的錯誤,咱們可能須要去作代碼調試,閱讀源碼時碰到不理解的地方也須要編寫示例 + 單步調試,可是默認的是已經打包壓縮後的代碼,不太方便作這些,你們能夠在node_modules目錄找到single-spa目錄,把目錄下的package.json中的module字段的值改成lib/single-spa.dev.js,這是一個未壓縮的bundle,利於代碼的閱讀的調試,固然須要重啓應用。

子應用也是同樣相似的技巧,由於single-spa-vue就一個文件,能夠直接拷貝出來放到項目的/src目錄下,將main.js中的引入的single-spa-vue改爲當前目錄便可。

打包部署

打包

在各個項目的根目錄下分別執行

npm run build
複製代碼

部署

能夠將打包後的bundle發佈到nginx服務器上,這個nginx服務器能夠是單獨的服務器、或者虛擬機、亦或是docker容器都行,這裏採用serve在本地模擬部署

若是你有條件部署到nginx上,須要注意nginx的代理配置

  • 對於子應用靜態資源的加載只須要攔截相應的前綴將請求轉發到對應子應用的目錄下便可
  • 頁面刷新只須要攔截到主應用便可,主應用內部本身根據activeWhen去掛載對應的子應用

全局安裝 serve

npm i serve -g
複製代碼

在各個項目的根目錄下啓動 serve

serve ./dist -p port
複製代碼

在瀏覽器訪問基座應用的地址,發現獲得和剛纔同樣的結果

single-spa 源碼分析

整個閱讀過程以示例項目爲例,閱讀源碼時必定要多動手寫註釋、作筆記,遇到不理解的地方編寫示例代碼 + console.log + 單步調試,切記不要只看不動手

single-spa 源碼閱讀思惟導圖

這是我在閱讀時整理的一個思惟導圖,源碼中也寫了大量的註釋,你們能夠參照着進行閱讀。Ok !!這就開始吧

從源碼目錄中能夠看到,single-spa是使用rollup來打包的,從rollup.config.js中能夠發現入口是single-spa.js, 打開會發現裏面導出了一大堆東西,有咱們很是熟悉的各個方法,咱們就從registerApplication方法開始

registerApplication 註冊子應用

single-spa/src/applications/apps.js

/** * 註冊應用,兩種方式 * registerApplication('app1', loadApp(url), activeWhen('/app1'), customProps) * registerApplication({ * name: 'app1', * app: loadApp(url), * activeWhen: activeWhen('/app1'), * customProps: {} * }) * @param {*} appNameOrConfig 應用名稱或者應用配置對象 * @param {*} appOrLoadApp 應用的加載方法,是一個 promise * @param {*} activeWhen 判斷應用是否激活的一個方法,方法返回 true or false * @param {*} customProps 傳遞給子應用的 props 對象 */
export function registerApplication( appNameOrConfig, appOrLoadApp, activeWhen, customProps ) {
  /** * 格式化用戶傳遞的應用配置參數 * registration = { * name: 'app1', * loadApp: 返回promise的函數, * activeWhen: 返回boolean值的函數, * customProps: {}, * } */
  const registration = sanitizeArguments(
    appNameOrConfig,
    appOrLoadApp,
    activeWhen,
    customProps
  );

  // 判斷應用是否重名
  if (getAppNames().indexOf(registration.name) !== -1)
    throw Error(
      formatErrorMessage(
        21,
        __DEV__ &&
          `There is already an app registered with name ${registration.name}`,
        registration.name
      )
    );

  // 將各個應用的配置信息都存放到 apps 數組中
  apps.push(
    // 給每一個應用增長一個內置屬性
    assign(
      {
        loadErrorTime: null,
        // 最重要的,應用的狀態
        status: NOT_LOADED,
        parcels: {},
        devtools: {
          overlays: {
            options: {},
            selectors: [],
          },
        },
      },
      registration
    )
  );

  // 瀏覽器環境運行
  if (isInBrowser) {
    // https://zh-hans.single-spa.js.org/docs/api#ensurejquerysupport
    // 若是頁面中使用了jQuery,則給jQuery打patch
    ensureJQuerySupport();
    reroute();
  }
}

複製代碼

sanitizeArguments 格式化用戶傳遞的子應用配置參數

single-spa/src/applications/apps.js

// 返回處理後的應用配置對象
function sanitizeArguments( appNameOrConfig, appOrLoadApp, activeWhen, customProps ) {
  // 判斷第一個參數是否爲對象
  const usingObjectAPI = typeof appNameOrConfig === "object";

  // 初始化應用配置對象
  const registration = {
    name: null,
    loadApp: null,
    activeWhen: null,
    customProps: null,
  };

  if (usingObjectAPI) {
    // 註冊應用的時候傳遞的參數是對象
    validateRegisterWithConfig(appNameOrConfig);
    registration.name = appNameOrConfig.name;
    registration.loadApp = appNameOrConfig.app;
    registration.activeWhen = appNameOrConfig.activeWhen;
    registration.customProps = appNameOrConfig.customProps;
  } else {
    // 參數列表
    validateRegisterWithArguments(
      appNameOrConfig,
      appOrLoadApp,
      activeWhen,
      customProps
    );
    registration.name = appNameOrConfig;
    registration.loadApp = appOrLoadApp;
    registration.activeWhen = activeWhen;
    registration.customProps = customProps;
  }

  // 若是第二個參數不是一個函數,好比是一個包含已經生命週期的對象,則包裝成一個返回 promise 的函數
  registration.loadApp = sanitizeLoadApp(registration.loadApp);
  // 若是用戶沒有提供 props 對象,則給一個默認的空對象
  registration.customProps = sanitizeCustomProps(registration.customProps);
  // 保證activeWhen是一個返回boolean值的函數
  registration.activeWhen = sanitizeActiveWhen(registration.activeWhen);

  // 返回處理後的應用配置對象
  return registration;
}

複製代碼

validateRegisterWithConfig

single-spa/src/applications/apps.js

/** * 驗證應用配置對象的各個屬性是否存在不合法的狀況,存在則拋出錯誤 * @param {*} config = { name: 'app1', app: function, activeWhen: function, customProps: {} } */
export function validateRegisterWithConfig(config) {
  // 異常判斷,應用的配置對象不能是數組或者null
  if (Array.isArray(config) || config === null)
    throw Error(
      formatErrorMessage(
        39,
        __DEV__ && "Configuration object can't be an Array or null!"
      )
    );
  // 配置對象只能包括這四個key
  const validKeys = ["name", "app", "activeWhen", "customProps"];
  // 找到配置對象存在的無效的key
  const invalidKeys = Object.keys(config).reduce(
    (invalidKeys, prop) =>
      validKeys.indexOf(prop) >= 0 ? invalidKeys : invalidKeys.concat(prop),
    []
  );
  // 若是存在無效的key,則拋出一個錯誤
  if (invalidKeys.length !== 0)
    throw Error(
      formatErrorMessage(
        38,
        __DEV__ &&
          `The configuration object accepts only: ${validKeys.join( ", " )}. Invalid keys: ${invalidKeys.join(", ")}.`,
        validKeys.join(", "),
        invalidKeys.join(", ")
      )
    );
  // 驗證應用名稱,只能是字符串,且不能爲空
  if (typeof config.name !== "string" || config.name.length === 0)
    throw Error(
      formatErrorMessage(
        20,
        __DEV__ &&
          "The config.name on registerApplication must be a non-empty string"
      )
    );
  // app 屬性只能是一個對象或者函數
  // 對象是一個已被解析過的對象,是一個包含各個生命週期的對象;
  // 加載函數必須返回一個 promise
  // 以上信息在官方文檔中有提到:https://zh-hans.single-spa.js.org/docs/configuration
  if (typeof config.app !== "object" && typeof config.app !== "function")
    throw Error(
      formatErrorMessage(
        20,
        __DEV__ &&
          "The config.app on registerApplication must be an application or a loading function"
      )
    );
  // 第三個參數,能夠是一個字符串,也能夠是一個函數,也能夠是二者組成的一個數組,表示當前應該被激活的應用的baseURL
  const allowsStringAndFunction = (activeWhen) =>
    typeof activeWhen === "string" || typeof activeWhen === "function";
  if (
    !allowsStringAndFunction(config.activeWhen) &&
    !(
      Array.isArray(config.activeWhen) &&
      config.activeWhen.every(allowsStringAndFunction)
    )
  )
    throw Error(
      formatErrorMessage(
        24,
        __DEV__ &&
          "The config.activeWhen on registerApplication must be a string, function or an array with both"
      )
    );
  // 傳遞給子應用的props對象必須是一個對象
  if (!validCustomProps(config.customProps))
    throw Error(
      formatErrorMessage(
        22,
        __DEV__ && "The optional config.customProps must be an object"
      )
    );
}

複製代碼

validateRegisterWithArguments

single-spa/src/applications/apps.js

// 一樣是驗證四個參數是否合法
function validateRegisterWithArguments( name, appOrLoadApp, activeWhen, customProps ) {
  if (typeof name !== "string" || name.length === 0)
    throw Error(
      formatErrorMessage(
        20,
        __DEV__ &&
          `The 1st argument to registerApplication must be a non-empty string 'appName'`
      )
    );

  if (!appOrLoadApp)
    throw Error(
      formatErrorMessage(
        23,
        __DEV__ &&
          "The 2nd argument to registerApplication must be an application or loading application function"
      )
    );

  if (typeof activeWhen !== "function")
    throw Error(
      formatErrorMessage(
        24,
        __DEV__ &&
          "The 3rd argument to registerApplication must be an activeWhen function"
      )
    );

  if (!validCustomProps(customProps))
    throw Error(
      formatErrorMessage(
        22,
        __DEV__ &&
          "The optional 4th argument is a customProps and must be an object"
      )
    );
}

複製代碼

sanitizeLoadApp

single-spa/src/applications/apps.js

// 保證第二個參數必定是一個返回 promise 的函數
function sanitizeLoadApp(loadApp) {
  if (typeof loadApp !== "function") {
    return () => Promise.resolve(loadApp);
  }

  return loadApp;
}

複製代碼

sanitizeCustomProps

single-spa/src/applications/apps.js

// 保證 props 不爲 undefined
function sanitizeCustomProps(customProps) {
  return customProps ? customProps : {};
}

複製代碼

sanitizeActiveWhen

single-spa/src/applications/apps.js

// 獲得一個函數,函數負責判斷瀏覽器當前地址是否和用戶給定的baseURL相匹配,匹配返回true,不然返回false
function sanitizeActiveWhen(activeWhen) {
  // []
  let activeWhenArray = Array.isArray(activeWhen) ? activeWhen : [activeWhen];
  // 保證數組中每一個元素都是一個函數
  activeWhenArray = activeWhenArray.map((activeWhenOrPath) =>
    typeof activeWhenOrPath === "function"
      ? activeWhenOrPath
      // activeWhen若是是一個路徑,則保證成一個函數
      : pathToActiveWhen(activeWhenOrPath)
  );

  // 返回一個函數,函數返回一個 boolean 值
  return (location) =>
    activeWhenArray.some((activeWhen) => activeWhen(location));
}

複製代碼

pathToActiveWhen

single-spa/src/applications/apps.js

export function pathToActiveWhen(path) {
  // 根據用戶提供的baseURL,生成正則表達式
  const regex = toDynamicPathValidatorRegex(path);

  // 函數返回boolean值,判斷當前路由是否匹配用戶給定的路徑
  return (location) => {
    const route = location.href
      .replace(location.origin, "")
      .replace(location.search, "")
      .split("?")[0];
    return regex.test(route);
  };
}

複製代碼

reroute 更改app.status和執行生命週期函數

single-spa/src/navigation/reroute.js

/** * 每次切換路由前,將應用分爲4大類, * 首次加載時執行loadApp * 後續的路由切換執行performAppChange * 爲四大類的應用分別執行相應的操做,好比更改app.status,執行生命週期函數 * 因此,從這裏也能夠看出來,single-spa就是一個維護應用的狀態機 * @param {*} pendingPromises * @param {*} eventArguments */
export function reroute(pendingPromises = [], eventArguments) {
  // 應用正在切換,這個狀態會在執行performAppChanges以前置爲true,執行結束以後再置爲false
  // 若是在中間用戶從新切換路由了,即走這個if分支,暫時看起來就在數組中存儲了一些信息,沒看到有什麼用
  // 字面意思理解就是用戶等待app切換
  if (appChangeUnderway) {
    return new Promise((resolve, reject) => {
      peopleWaitingOnAppChange.push({
        resolve,
        reject,
        eventArguments,
      });
    });
  }

  // 將應用分爲4大類
  const {
    // 須要被移除的
    appsToUnload,
    // 須要被卸載的
    appsToUnmount,
    // 須要被加載的
    appsToLoad,
    // 須要被掛載的
    appsToMount,
  } = getAppChanges();

  let appsThatChanged;

  // 是否已經執行 start 方法
  if (isStarted()) {
    // 已執行
    appChangeUnderway = true;
    // 全部須要被改變的的應用
    appsThatChanged = appsToUnload.concat(
      appsToLoad,
      appsToUnmount,
      appsToMount
    );
    // 執行改變
    return performAppChanges();
  } else {
    // 未執行
    appsThatChanged = appsToLoad;
    // 加載Apps
    return loadApps();
  }

  // 總體返回一個當即resolved的promise,經過微任務來加載apps
  function loadApps() {
    return Promise.resolve().then(() => {
      // 加載每一個子應用,並作一系列的狀態變動和驗證(好比結果爲promise、子應用要導出生命週期函數)
      const loadPromises = appsToLoad.map(toLoadPromise);

      return (
        // 保證全部加載子應用的微任務執行完成
        Promise.all(loadPromises)
          .then(callAllEventListeners)
          // there are no mounted apps, before start() is called, so we always return []
          .then(() => [])
          .catch((err) => {
            callAllEventListeners();
            throw err;
          })
      );
    });
  }

  function performAppChanges() {
    return Promise.resolve().then(() => {
      // https://github.com/single-spa/single-spa/issues/545
      // 自定義事件,在應用狀態發生改變以前可觸發,給用戶提供搞事情的機會
      window.dispatchEvent(
        new CustomEvent(
          appsThatChanged.length === 0
            ? "single-spa:before-no-app-change"
            : "single-spa:before-app-change",
          getCustomEventDetail(true)
        )
      );

      window.dispatchEvent(
        new CustomEvent(
          "single-spa:before-routing-event",
          getCustomEventDetail(true)
        )
      );
      // 移除應用 => 更改應用狀態,執行unload生命週期函數,執行一些清理動做
      // 其實通常狀況下這裏沒有真的移除應用
      const unloadPromises = appsToUnload.map(toUnloadPromise);

      // 卸載應用,更改狀態,執行unmount生命週期函數
      const unmountUnloadPromises = appsToUnmount
        .map(toUnmountPromise)
        // 卸載完而後移除,經過註冊微任務的方式實現
        .map((unmountPromise) => unmountPromise.then(toUnloadPromise));

      const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);

      const unmountAllPromise = Promise.all(allUnmountPromises);

      // 卸載所有完成後觸發一個事件
      unmountAllPromise.then(() => {
        window.dispatchEvent(
          new CustomEvent(
            "single-spa:before-mount-routing-event",
            getCustomEventDetail(true)
          )
        );
      });

      /* We load and bootstrap apps while other apps are unmounting, but we * wait to mount the app until all apps are finishing unmounting * 這個緣由實際上是由於這些操做都是經過註冊不一樣的微任務實現的,而JS是單線程執行, * 因此天然後續的只能等待前面的執行完了才能執行 * 這裏通常狀況下其實不會執行,只有手動執行了unloadApplication方法纔會二次加載 */
      const loadThenMountPromises = appsToLoad.map((app) => {
        return toLoadPromise(app).then((app) =>
          tryToBootstrapAndMount(app, unmountAllPromise)
        );
      });

      /* These are the apps that are already bootstrapped and just need * to be mounted. They each wait for all unmounting apps to finish up * before they mount. * 初始化和掛載app,其實作的事情很簡單,就是改變app.status,執行生命週期函數 * 固然這裏的初始化和掛載實際上是先後腳一塊兒完成的(只要中間用戶沒有切換路由) */
      const mountPromises = appsToMount
        .filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
        .map((appToMount) => {
          return tryToBootstrapAndMount(appToMount, unmountAllPromise);
        });

      // 後面就沒啥了,能夠理解爲收尾工做
      return unmountAllPromise
        .catch((err) => {
          callAllEventListeners();
          throw err;
        })
        .then(() => {
          /* Now that the apps that needed to be unmounted are unmounted, their DOM navigation * events (like hashchange or popstate) should have been cleaned up. So it's safe * to let the remaining captured event listeners to handle about the DOM event. */
          callAllEventListeners();

          return Promise.all(loadThenMountPromises.concat(mountPromises))
            .catch((err) => {
              pendingPromises.forEach((promise) => promise.reject(err));
              throw err;
            })
            .then(finishUpAndReturn);
        });
    });
  }
}

複製代碼

getAppChanges

single-spa/src/applications/apps.js

// 將應用分爲四大類
export function getAppChanges() {
  // 須要被移除的應用
  const appsToUnload = [],
    // 須要被卸載的應用
    appsToUnmount = [],
    // 須要被加載的應用
    appsToLoad = [],
    // 須要被掛載的應用
    appsToMount = [];

  // We re-attempt to download applications in LOAD_ERROR after a timeout of 200 milliseconds
  const currentTime = new Date().getTime();

  apps.forEach((app) => {
    // boolean,應用是否應該被激活
    const appShouldBeActive =
      app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);

    switch (app.status) {
      // 須要被加載的應用
      case LOAD_ERROR:
        if (currentTime - app.loadErrorTime >= 200) {
          appsToLoad.push(app);
        }
        break;
      // 須要被加載的應用
      case NOT_LOADED:
      case LOADING_SOURCE_CODE:
        if (appShouldBeActive) {
          appsToLoad.push(app);
        }
        break;
      // 狀態爲xx的應用
      case NOT_BOOTSTRAPPED:
      case NOT_MOUNTED:
        if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
          // 須要被移除的應用
          appsToUnload.push(app);
        } else if (appShouldBeActive) {
          // 須要被掛載的應用
          appsToMount.push(app);
        }
        break;
      // 須要被卸載的應用,已經處於掛載狀態,但如今路由已經變了的應用須要被卸載
      case MOUNTED:
        if (!appShouldBeActive) {
          appsToUnmount.push(app);
        }
        break;
      // all other statuses are ignored
    }
  });

  return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}

複製代碼

shouldBeActive

single-spa/src/applications/app.helpers.js

// 返回boolean值,應用是否應該被激活
export function shouldBeActive(app) {
  try {
    return app.activeWhen(window.location);
  } catch (err) {
    handleAppError(err, app, SKIP_BECAUSE_BROKEN);
    return false;
  }
}

複製代碼

toLoadPromise

single-spa/src/lifecycles/load.js

/** * 經過微任務加載子應用,其實singleSpa中不少地方都用了微任務 * 這裏最終是return了一個promise出行,在註冊了加載子應用的微任務 * 歸納起來就是: * 更改app.status爲LOAD_SOURCE_CODE => NOT_BOOTSTRAP,固然還有多是LOAD_ERROR * 執行加載函數,並將props傳遞給加載函數,給用戶處理props的一個機會,由於這個props是一個完備的props * 驗證加載函數的執行結果,必須爲promise,且加載函數內部必須return一個對象 * 這個對象是子應用的,對象中必須包括各個必須的生命週期函數 * 而後將生命週期方法經過一個函數包裹並掛載到app對象上 * app加載完成,刪除app.loadPromise * @param {*} app */
export function toLoadPromise(app) {
  return Promise.resolve().then(() => {
    if (app.loadPromise) {
      // 說明app已經在被加載
      return app.loadPromise;
    }

    // 只有狀態爲NOT_LOADED和LOAD_ERROR的app才能夠被加載
    if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) {
      return app;
    }

    // 設置App的狀態
    app.status = LOADING_SOURCE_CODE;

    let appOpts, isUserErr;

    return (app.loadPromise = Promise.resolve()
      .then(() => {
        // 執行app的加載函數,並給子應用傳遞props => 用戶自定義的customProps和內置的好比應用的名稱、singleSpa實例
        // 其實這裏有個疑問,這個props是怎麼傳遞給子應用的,感受跟後面的生命週期函數有關
        const loadPromise = app.loadApp(getProps(app));
        // 加載函數須要返回一個promise
        if (!smellsLikeAPromise(loadPromise)) {
          // The name of the app will be prepended to this error message inside of the handleAppError function
          isUserErr = true;
          throw Error(
            formatErrorMessage(
              33,
              __DEV__ &&
                `single-spa loading function did not return a promise. Check the second argument to registerApplication('${toName( app )}', loadingFunction, activityFunction)`,
              toName(app)
            )
          );
        }
        // 這裏很重要,這個val就是示例項目中加載函數中return出來的window.singleSpa,這個屬性是子應用打包時設置的
        return loadPromise.then((val) => {
          app.loadErrorTime = null;

          // window.singleSpa
          appOpts = val;

          let validationErrMessage, validationErrCode;

          // 如下進行一系列的驗證,已window.singleSpa爲例說明,簡稱g.s

          // g.s必須爲對象
          if (typeof appOpts !== "object") {
            validationErrCode = 34;
            if (__DEV__) {
              validationErrMessage = `does not export anything`;
            }
          }

          // g.s必須導出bootstrap生命週期函數
          if (!validLifecycleFn(appOpts.bootstrap)) {
            validationErrCode = 35;
            if (__DEV__) {
              validationErrMessage = `does not export a bootstrap function or array of functions`;
            }
          }

          // g.s必須導出mount生命週期函數
          if (!validLifecycleFn(appOpts.mount)) {
            validationErrCode = 36;
            if (__DEV__) {
              validationErrMessage = `does not export a bootstrap function or array of functions`;
            }
          }

          // g.s必須導出unmount生命週期函數
          if (!validLifecycleFn(appOpts.unmount)) {
            validationErrCode = 37;
            if (__DEV__) {
              validationErrMessage = `does not export a bootstrap function or array of functions`;
            }
          }

          const type = objectType(appOpts);

          // 說明上述驗證失敗,拋出錯誤提示信息
          if (validationErrCode) {
            let appOptsStr;
            try {
              appOptsStr = JSON.stringify(appOpts);
            } catch {}
            console.error(
              formatErrorMessage(
                validationErrCode,
                __DEV__ &&
                  `The loading function for single-spa ${type} '${toName( app )}' resolved with the following, which does not have bootstrap, mount, and unmount functions`,
                type,
                toName(app),
                appOptsStr
              ),
              appOpts
            );
            handleAppError(validationErrMessage, app, SKIP_BECAUSE_BROKEN);
            return app;
          }

          if (appOpts.devtools && appOpts.devtools.overlays) {
            // app.devtoolsoverlays添加子應用的devtools.overlays的屬性,不知道是幹嗎用的
            app.devtools.overlays = assign(
              {},
              app.devtools.overlays,
              appOpts.devtools.overlays
            );
          }

          // 設置app狀態爲未初始化,表示加載完了
          app.status = NOT_BOOTSTRAPPED;
          // 在app對象上掛載生命週期方法,每一個方法都接收一個props做爲參數,方法內部執行子應用導出的生命週期函數,並確保生命週期函數返回一個promise
          app.bootstrap = flattenFnArray(appOpts, "bootstrap");
          app.mount = flattenFnArray(appOpts, "mount");
          app.unmount = flattenFnArray(appOpts, "unmount");
          app.unload = flattenFnArray(appOpts, "unload");
          app.timeouts = ensureValidAppTimeouts(appOpts.timeouts);

          // 執行到這裏說明子應用已成功加載,刪除app.loadPromise屬性
          delete app.loadPromise;

          return app;
        });
      })
      .catch((err) => {
        // 加載失敗,稍後從新加載
        delete app.loadPromise;

        let newStatus;
        if (isUserErr) {
          newStatus = SKIP_BECAUSE_BROKEN;
        } else {
          newStatus = LOAD_ERROR;
          app.loadErrorTime = new Date().getTime();
        }
        handleAppError(err, app, newStatus);

        return app;
      }));
  });
}

複製代碼

getProps

single-spa/src/lifecycles/prop.helpers.js

/** * 獲得傳遞給子應用的props * @param {} appOrParcel => app * 如下返回內容其實在官網也都有提到,好比singleSpa實例,目的是爲了子應用不須要重複引入single-spa * return { * ...customProps, * name, * mountParcel: mountParcel.bind(appOrParcel), * singleSpa, * } */
export function getProps(appOrParcel) {
  // app.name
  const name = toName(appOrParcel);
  // app.customProps,如下對customProps對象的判斷邏輯有點多餘
  // 由於前面的參數格式化已經保證customProps確定是一個對象
  let customProps =
    typeof appOrParcel.customProps === "function"
      ? appOrParcel.customProps(name, window.location)
      : appOrParcel.customProps;
  if (
    typeof customProps !== "object" ||
    customProps === null ||
    Array.isArray(customProps)
  ) {
    customProps = {};
    console.warn(
      formatErrorMessage(
        40,
        __DEV__ &&
          `single-spa: ${name}'s customProps function must return an object. Received ${customProps}`
      ),
      name,
      customProps
    );
  }

  const result = assign({}, customProps, {
    name,
    mountParcel: mountParcel.bind(appOrParcel),
    singleSpa,
  });

  if (isParcel(appOrParcel)) {
    result.unmountSelf = appOrParcel.unmountThisParcel;
  }

  return result;
}

複製代碼

smellsLikeAPromise

single-spa/src/lifecycles/lifecycle.helpers.js

// 判斷一個變量是否爲promise
export function smellsLikeAPromise(promise) {
  return (
    promise &&
    typeof promise.then === "function" &&
    typeof promise.catch === "function"
  );
}

複製代碼

flattenFnArray

single-spa/src/lifecycles/lifecycle.helpers.js

/** * 返回一個接受props做爲參數的函數,這個函數負責執行子應用中的生命週期函數, * 並確保生命週期函數返回的結果爲promise * @param {*} appOrParcel => window.singleSpa,子應用打包後的對象 * @param {*} lifecycle => 字符串,生命週期名稱 */
export function flattenFnArray(appOrParcel, lifecycle) {
  // fns = fn or []
  let fns = appOrParcel[lifecycle] || [];
  // fns = [] or [fn]
  fns = Array.isArray(fns) ? fns : [fns];
  // 有些生命週期函數子應用可能不會設置,好比unload
  if (fns.length === 0) {
    fns = [() => Promise.resolve()];
  }

  const type = objectType(appOrParcel);
  const name = toName(appOrParcel);

  return function (props) {
    // 這裏最後返回了一個promise鏈,這個操做彷佛沒啥必要,由於不可能出現同名的生命週期函數,因此,這裏將生命週期函數放數組,沒太理解目的是啥
    return fns.reduce((resultPromise, fn, index) => {
      return resultPromise.then(() => {
        // 執行生命週期函數,傳遞props給函數,並驗證函數的返回結果,必須爲promise
        const thisPromise = fn(props);
        return smellsLikeAPromise(thisPromise)
          ? thisPromise
          : Promise.reject(
              formatErrorMessage(
                15,
                __DEV__ &&
                  `Within ${type} ${name}, the lifecycle function ${lifecycle} at array index ${index} did not return a promise`,
                type,
                name,
                lifecycle,
                index
              )
            );
      });
    }, Promise.resolve());
  };
}

複製代碼

toUnloadPromise

single-spa/src/lifecycles/unload.js

const appsToUnload = {};
/** * 移除應用,就更改一下應用的狀態,執行unload生命週期函數,執行清理操做 * * 其實通常狀況是不會執行移除操做的,除非你手動調用unloadApplication方法 * 單步調試會發現appsToUnload對象是個空對象,因此第一個if就return了,這裏啥也沒作 * https://zh-hans.single-spa.js.org/docs/api#unloadapplication * */ 
export function toUnloadPromise(app) {
  return Promise.resolve().then(() => {
    // 應用信息
    const unloadInfo = appsToUnload[toName(app)];

    if (!unloadInfo) {
      /* No one has called unloadApplication for this app, * 不須要移除 * 通常狀況下都不須要移除,只有在調用unloadApplication方法手動執行移除時纔會 * 執行後面的內容 */
      return app;
    }

    // 已經卸載了,執行一些清理操做
    if (app.status === NOT_LOADED) {
      /* This app is already unloaded. We just need to clean up * anything that still thinks we need to unload the app. */
      finishUnloadingApp(app, unloadInfo);
      return app;
    }

    // 若是應用正在執行掛載,路由忽然發生改變,那麼也須要應用掛載完成才能夠執行移除
    if (app.status === UNLOADING) {
      /* Both unloadApplication and reroute want to unload this app. * It only needs to be done once, though. */
      return unloadInfo.promise.then(() => app);
    }

    if (app.status !== NOT_MOUNTED) {
      /* The app cannot be unloaded until it is unmounted. */
      return app;
    }

    // 更改狀態爲 UNLOADING
    app.status = UNLOADING;
    // 在合理的時間範圍內執行生命週期函數
    return reasonableTime(app, "unload")
      .then(() => {
        // 一些清理操做
        finishUnloadingApp(app, unloadInfo);
        return app;
      })
      .catch((err) => {
        errorUnloadingApp(app, unloadInfo, err);
        return app;
      });
  });
}

複製代碼

finishUnloadingApp

single-spa/src/lifecycles/unload.js

// 移除完成,執行一些清理動做,其實就是從appsToUnload數組中移除該app,移除生命週期函數,更改app.status
// 但應用不是真的被移除,後面再激活時不須要從新去下載資源,,只是作一些狀態上的變動,固然load的那個過程仍是須要的,這點可能須要再確認一下
function finishUnloadingApp(app, unloadInfo) {
  delete appsToUnload[toName(app)];

  // Unloaded apps don't have lifecycles
  delete app.bootstrap;
  delete app.mount;
  delete app.unmount;
  delete app.unload;

  app.status = NOT_LOADED;

  /* resolve the promise of whoever called unloadApplication. * This should be done after all other cleanup/bookkeeping */
  unloadInfo.resolve();
}

複製代碼

reasonableTime

single-spa/src/applications/timeouts.js

/** * 合理的時間,即生命週期函數合理的執行時間 * 在合理的時間內執行生命週期函數,並將函數的執行結果resolve出去 * @param {*} appOrParcel => app * @param {*} lifecycle => 生命週期函數名 */
export function reasonableTime(appOrParcel, lifecycle) {
  // 應用的超時配置
  const timeoutConfig = appOrParcel.timeouts[lifecycle];
  // 超時警告
  const warningPeriod = timeoutConfig.warningMillis;
  const type = objectType(appOrParcel);

  return new Promise((resolve, reject) => {
    let finished = false;
    let errored = false;

    // 這裏很關鍵,以前一直奇怪props是怎麼傳遞給子應用的,這裏就是了,果真和以前的猜測是同樣的
    // 是在執行生命週期函數時像子應用傳遞的props,因此以前執行loadApp傳遞props不會到子應用,
    // 那麼設計估計是給用戶本身處理props的一個機會吧,由於那個時候處理的props已是{ ...customProps, ...內置props }
    appOrParcel[lifecycle](getProps(appOrParcel))
      .then((val) => {
        finished = true;
        resolve(val);
      })
      .catch((val) => {
        finished = true;
        reject(val);
      });

    // 下面就沒啥了,就是超時的一些提示信息
    setTimeout(() => maybeTimingOut(1), warningPeriod);
    setTimeout(() => maybeTimingOut(true), timeoutConfig.millis);

    const errMsg = formatErrorMessage(
      31,
      __DEV__ &&
        `Lifecycle function ${lifecycle} for ${type} ${toName( appOrParcel )} lifecycle did not resolve or reject for ${timeoutConfig.millis} ms.`,
      lifecycle,
      type,
      toName(appOrParcel),
      timeoutConfig.millis
    );

    function maybeTimingOut(shouldError) {
      if (!finished) {
        if (shouldError === true) {
          errored = true;
          if (timeoutConfig.dieOnTimeout) {
            reject(Error(errMsg));
          } else {
            console.error(errMsg);
            //don't resolve or reject, we're waiting this one out
          }
        } else if (!errored) {
          const numWarnings = shouldError;
          const numMillis = numWarnings * warningPeriod;
          console.warn(errMsg);
          if (numMillis + warningPeriod < timeoutConfig.millis) {
            setTimeout(() => maybeTimingOut(numWarnings + 1), warningPeriod);
          }
        }
      }
    }
  });
}

複製代碼

toUnmountPromise

single-spa/src/lifecycles/unmount.js

/** * 執行了狀態上的更改 * 執行unmount生命週期函數 * @param {*} appOrParcel => app * @param {*} hardFail => 索引 */
export function toUnmountPromise(appOrParcel, hardFail) {
  return Promise.resolve().then(() => {
    // 只卸載已掛載的應用
    if (appOrParcel.status !== MOUNTED) {
      return appOrParcel;
    }
    // 更改狀態
    appOrParcel.status = UNMOUNTING;

    // 有關parcels的一些處理,沒使用過parcels,因此unmountChildrenParcels = []
    const unmountChildrenParcels = Object.keys(
      appOrParcel.parcels
    ).map((parcelId) => appOrParcel.parcels[parcelId].unmountThisParcel());

    let parcelError;

    return Promise.all(unmountChildrenParcels)
      // 在合理的時間範圍內執行unmount生命週期函數
      .then(unmountAppOrParcel, (parcelError) => {
        // There is a parcel unmount error
        return unmountAppOrParcel().then(() => {
          // Unmounting the app/parcel succeeded, but unmounting its children parcels did not
          const parentError = Error(parcelError.message);
          if (hardFail) {
            throw transformErr(parentError, appOrParcel, SKIP_BECAUSE_BROKEN);
          } else {
            handleAppError(parentError, appOrParcel, SKIP_BECAUSE_BROKEN);
          }
        });
      })
      .then(() => appOrParcel);

    function unmountAppOrParcel() {
      // We always try to unmount the appOrParcel, even if the children parcels failed to unmount.
      return reasonableTime(appOrParcel, "unmount")
        .then(() => {
          // The appOrParcel needs to stay in a broken status if its children parcels fail to unmount
          if (!parcelError) {
            appOrParcel.status = NOT_MOUNTED;
          }
        })
        .catch((err) => {
          if (hardFail) {
            throw transformErr(err, appOrParcel, SKIP_BECAUSE_BROKEN);
          } else {
            handleAppError(err, appOrParcel, SKIP_BECAUSE_BROKEN);
          }
        });
    }
  });
}

複製代碼

tryToBootstrapAndMount

single-spa/src/navigation/reroute.js

/** * Let's imagine that some kind of delay occurred during application loading. * The user without waiting for the application to load switched to another route, * this means that we shouldn't bootstrap and mount that application, thus we check * twice if that application should be active before bootstrapping and mounting. * https://github.com/single-spa/single-spa/issues/524 * 這裏這個兩次判斷仍是很重要的 */
function tryToBootstrapAndMount(app, unmountAllPromise) {
  if (shouldBeActive(app)) {
    // 一次判斷爲true,纔會執行初始化
    return toBootstrapPromise(app).then((app) =>
      unmountAllPromise.then(() =>
        // 第二次, 兩次都爲true纔會去掛載
        shouldBeActive(app) ? toMountPromise(app) : app
      )
    );
  } else {
    // 卸載
    return unmountAllPromise.then(() => app);
  }
}

複製代碼

toBootstrapPromise

single-spa/src/lifecycles/bootstrap.js

// 初始化app,更改app.status,在合理的時間內執行bootstrap生命週期函數
export function toBootstrapPromise(appOrParcel, hardFail) {
  return Promise.resolve().then(() => {
    if (appOrParcel.status !== NOT_BOOTSTRAPPED) {
      return appOrParcel;
    }

    appOrParcel.status = BOOTSTRAPPING;

    return reasonableTime(appOrParcel, "bootstrap")
      .then(() => {
        appOrParcel.status = NOT_MOUNTED;
        return appOrParcel;
      })
      .catch((err) => {
        if (hardFail) {
          throw transformErr(err, appOrParcel, SKIP_BECAUSE_BROKEN);
        } else {
          handleAppError(err, appOrParcel, SKIP_BECAUSE_BROKEN);
          return appOrParcel;
        }
      });
  });
}

複製代碼

toMountPromise

single-spa/src/lifecycles/mount.js

// 掛載app,執行mount生命週期函數,並更改app.status
export function toMountPromise(appOrParcel, hardFail) {
  return Promise.resolve().then(() => {
    if (appOrParcel.status !== NOT_MOUNTED) {
      return appOrParcel;
    }

    if (!beforeFirstMountFired) {
      window.dispatchEvent(new CustomEvent("single-spa:before-first-mount"));
      beforeFirstMountFired = true;
    }

    return reasonableTime(appOrParcel, "mount")
      .then(() => {
        appOrParcel.status = MOUNTED;

        if (!firstMountFired) {
          // single-spa其實在不一樣的階段提供了相應的自定義事件,讓用戶能夠作一些事情
          window.dispatchEvent(new CustomEvent("single-spa:first-mount"));
          firstMountFired = true;
        }

        return appOrParcel;
      })
      .catch((err) => {
        // If we fail to mount the appOrParcel, we should attempt to unmount it before putting in SKIP_BECAUSE_BROKEN
        // We temporarily put the appOrParcel into MOUNTED status so that toUnmountPromise actually attempts to unmount it
        // instead of just doing a no-op.
        appOrParcel.status = MOUNTED;
        return toUnmountPromise(appOrParcel, true).then(
          setSkipBecauseBroken,
          setSkipBecauseBroken
        );

        function setSkipBecauseBroken() {
          if (!hardFail) {
            handleAppError(err, appOrParcel, SKIP_BECAUSE_BROKEN);
            return appOrParcel;
          } else {
            throw transformErr(err, appOrParcel, SKIP_BECAUSE_BROKEN);
          }
        }
      });
  });
}

複製代碼

start(opts)

single-spa/src/start.js

let started = false
/** * https://zh-hans.single-spa.js.org/docs/api#start * 調用start以前,應用會被加載,但不會初始化、掛載和卸載,有了start能夠更好的控制應用的性能 * @param {*} opts */
export function start(opts) {
  started = true;
  if (opts && opts.urlRerouteOnly) {
    setUrlRerouteOnly(opts.urlRerouteOnly);
  }
  if (isInBrowser) {
    reroute();
  }
}

export function isStarted() {
  return started;
}

if (isInBrowser) {
  // registerApplication以後若是一直沒有調用start,則在5000ms後給出警告提示
  setTimeout(() => {
    if (!started) {
      console.warn(
        formatErrorMessage(
          1,
          __DEV__ &&
            `singleSpa.start() has not been called, 5000ms after single-spa was loaded. Before start() is called, apps can be declared and loaded, but not bootstrapped or mounted.`
        )
      );
    }
  }, 5000);
}

複製代碼

監聽路由變化

single-spa/src/navigation/navigation-events.js

如下代碼會被打包進bundle的全局做用域內,bundle被加載之後就會自動執行。這句提示不須要的話可自動忽略

/** * 監聽路由變化 */
if (isInBrowser) {
  // We will trigger an app change for any routing events,監聽hashchange和popstate事件
  window.addEventListener("hashchange", urlReroute);
  window.addEventListener("popstate", urlReroute);

  // Monkeypatch addEventListener so that we can ensure correct timing
  /** * 擴展原生的addEventListener和removeEventListener方法 * 每次註冊事件和事件處理函數都會將事件和處理函數保存下來,固然移除時也會作刪除 * */ 
  const originalAddEventListener = window.addEventListener;
  const originalRemoveEventListener = window.removeEventListener;
  window.addEventListener = function (eventName, fn) {
    if (typeof fn === "function") {
      if (
        // eventName只能是hashchange或popstate && 對應事件的fn註冊函數沒有註冊
        routingEventsListeningTo.indexOf(eventName) >= 0 &&
        !find(capturedEventListeners[eventName], (listener) => listener === fn)
      ) {
        // 註冊(保存)eventName 事件的處理函數
        capturedEventListeners[eventName].push(fn);
        return;
      }
    }

    // 原生方法
    return originalAddEventListener.apply(this, arguments);
  };

  window.removeEventListener = function (eventName, listenerFn) {
    if (typeof listenerFn === "function") {
      // 從captureEventListeners數組中移除eventName事件指定的事件處理函數
      if (routingEventsListeningTo.indexOf(eventName) >= 0) {
        capturedEventListeners[eventName] = capturedEventListeners[
          eventName
        ].filter((fn) => fn !== listenerFn);
        return;
      }
    }

    return originalRemoveEventListener.apply(this, arguments);
  };

  // 加強pushstate和replacestate
  window.history.pushState = patchedUpdateState(
    window.history.pushState,
    "pushState"
  );
  window.history.replaceState = patchedUpdateState(
    window.history.replaceState,
    "replaceState"
  );

  if (window.singleSpaNavigate) {
    console.warn(
      formatErrorMessage(
        41,
        __DEV__ &&
          "single-spa has been loaded twice on the page. This can result in unexpected behavior."
      )
    );
  } else {
    /* For convenience in `onclick` attributes, we expose a global function for navigating to * whatever an <a> tag's href is. * singleSpa暴露出來的一個全局方法,用戶也能夠基於它去判斷子應用是運行在基座應用上仍是獨立運行 */
    window.singleSpaNavigate = navigateToUrl;
  }
}

複製代碼

patchedUpdateState

single-spa/src/navigation/navigation-events.js

/** * 經過裝飾器模式,加強pushstate和replacestate方法,除了原生的操做歷史記錄,還會調用reroute * @param {*} updateState window.history.pushstate/replacestate * @param {*} methodName 'pushstate' or 'replacestate' */
function patchedUpdateState(updateState, methodName) {
  return function () {
    // 當前url
    const urlBefore = window.location.href;
    // pushstate或者replacestate的執行結果
    const result = updateState.apply(this, arguments);
    // pushstate或replacestate執行後的url地址
    const urlAfter = window.location.href;

    // 若是調用start傳遞了參數urlRerouteOnly爲true,則這裏不會觸發reroute
    // https://zh-hans.single-spa.js.org/docs/api#start
    if (!urlRerouteOnly || urlBefore !== urlAfter) {
      urlReroute(createPopStateEvent(window.history.state, methodName));
    }

    return result;
  };
}

複製代碼

createPopStateEvent

single-spa/src/navigation/navigation-events.js

function createPopStateEvent(state, originalMethodName) {
  // https://github.com/single-spa/single-spa/issues/224 and https://github.com/single-spa/single-spa-angular/issues/49
  // We need a popstate event even though the browser doesn't do one by default when you call replaceState, so that
  // all the applications can reroute. We explicitly identify this extraneous event by setting singleSpa=true and
  // singleSpaTrigger=<pushState|replaceState> on the event instance.
  let evt;
  try {
    evt = new PopStateEvent("popstate", { state });
  } catch (err) {
    // IE 11 compatibility https://github.com/single-spa/single-spa/issues/299
    // https://docs.microsoft.com/en-us/openspecs/ie_standards/ms-html5e/bd560f47-b349-4d2c-baa8-f1560fb489dd
    evt = document.createEvent("PopStateEvent");
    evt.initPopStateEvent("popstate", false, false, state);
  }
  evt.singleSpa = true;
  evt.singleSpaTrigger = originalMethodName;
  return evt;
}

複製代碼

urlReroute

single-spa/src/navigation/navigation-events.js

export function setUrlRerouteOnly(val) {
  urlRerouteOnly = val;
}

function urlReroute() {
  reroute([], arguments);
}

複製代碼

小結

以上就是對整個single-spa框架源碼的解讀,相信讀到這裏你會有不同的理解吧,固然第一遍讀完你有可能有點懵,我當時就是這樣,這時候就須要那句古話了,書讀百遍,其義自現(乾了這碗雞湯)

整個框架的源碼讀完之後,你會發現:single-spa的原理其實很簡單,它就是一個子應用加載器 + 狀態機的結合體,並且具體怎麼加載子應用仍是基座應用提供的;框架裏面維護了各個子應用的狀態,以及在適當的時候負責更改子應用的狀態、執行相應的生命週期函數

想一想框架好像也不復雜,對吧??那接下來就來實現一個本身的single-spa框架吧

手寫 single-spa 框架

通過上面的閱讀,相信對single-spa已經有必定的理解了,接下來就來實現一個本身的single-spa,就叫lyn-single-spa吧。

咱們好像只須要實現registerApplicationstart兩個方法並導出便可。

寫代碼以前,必須理清框架內子應用的各個狀態以及狀態的變動過程,爲了便於理解,代碼寫詳細的註釋,但願你們看完之後均可以實現一個本身的single-spa

// 實現子應用的註冊、掛載、切換、卸載功能

/** * 子應用狀態 */
// 子應用註冊之後的初始狀態
const NOT_LOADED = 'NOT_LOADED'
// 表示正在加載子應用源代碼
const LOADING_SOURCE_CODE = 'LOADING_SOURCE_CODE'
// 執行完 app.loadApp,即子應用加載完之後的狀態
const NOT_BOOTSTRAPPED = 'NOT_BOOTSTRAPPED'
// 正在初始化
const BOOTSTRAPPING = 'BOOTSTRAPPING'
// 執行 app.bootstrap 以後的狀態,表是初始化完成,處於未掛載的狀態
const NOT_MOUNTED = 'NOT_MOUNTED'
// 正在掛載
const MOUNTING = 'MOUNTING'
// 掛載完成,app.mount 執行完畢
const MOUNTED = 'MOUNTED'
const UPDATING = 'UPDATING'
// 正在卸載
const UNMOUNTING = 'UNMOUNTING'
// 如下三種狀態這裏沒有涉及
const UNLOADING = 'UNLOADING'
const LOAD_ERROR = 'LOAD_ERROR'
const SKIP_BECAUSE_BROKEN = 'SKIP_BECAUSE_BROKEN'

// 存放全部的子應用
const apps = []

/** * 註冊子應用 * @param {*} appConfig = { * name: '', * app: promise function, * activeWhen: location => location.pathname.startsWith(path), * customProps: {} * } */
export function registerApplication (appConfig) {
  apps.push(Object.assign({}, appConfig, { status: NOT_LOADED }))
  reroute()
}

// 啓動
let isStarted = false
export function start () {
  isStarted = true
}

function reroute () {
  // 三類 app
  const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges()
  if (isStarted) {
    performAppChanges()
  } else {
    loadApps()
  }

  function loadApps () {
    appsToLoad.map(toLoad)
  }

  function performAppChanges () {
    // 卸載
    appsToUnmount.map(toUnmount)
    // 初始化 + 掛載
    appsToMount.map(tryToBoostrapAndMount)
  }
}

/** * 掛載應用 * @param {*} app */
async function tryToBoostrapAndMount(app) {
  if (shouldBeActive(app)) {
    // 正在初始化
    app.status = BOOTSTRAPPING
    // 初始化
    await app.bootstrap(app.customProps)
    // 初始化完成
    app.status = NOT_MOUNTED
    // 第二次判斷是爲了防止中途用戶切換路由
    if (shouldBeActive(app)) {
      // 正在掛載
      app.status = MOUNTING
      // 掛載
      await app.mount(app.customProps)
      // 掛載完成
      app.status = MOUNTED
    }
  }
}

/** * 卸載應用 * @param {*} app */
async function toUnmount (app) {
  if (app.status !== 'MOUNTED') return app
  // 更新狀態爲正在卸載
  app.status = MOUNTING
  // 執行卸載
  await app.unmount(app.customProps)
  // 卸載完成
  app.status = NOT_MOUNTED
  return app
}

/** * 加載子應用 * @param {*} app */
async function toLoad (app) {
  if (app.status !== NOT_LOADED) return app
  // 更改狀態爲正在加載
  app.status = LOADING_SOURCE_CODE
  // 加載 app
  const res = await app.app()
  // 加載完成
  app.status = NOT_BOOTSTRAPPED
  // 將子應用導出的生命週期函數掛載到 app 對象上
  app.bootstrap = res.bootstrap
  app.mount = res.mount
  app.unmount = res.unmount
  app.unload = res.unload
  // 加載完之後執行 reroute 嘗試掛載
  reroute()
  return app
}

/** * 將全部的子應用分爲三大類,待加載、待掛載、待卸載 */
function getAppChanges () {
  const appsToLoad = [],
    appsToMount = [],
    appsToUnmount = []
  
  apps.forEach(app => {
    switch (app.status) {
      // 待加載
      case NOT_LOADED:
        appsToLoad.push(app)
        break
      // 初始化 + 掛載
      case NOT_BOOTSTRAPPED:
      case NOT_MOUNTED:
        if (shouldBeActive(app)) {
          appsToMount.push(app)
        } 
        break
      // 待卸載
      case MOUNTED:
        if (!shouldBeActive(app)) {
          appsToUnmount.push(app)
        }
        break
    }
  })
  return { appsToLoad, appsToMount, appsToUnmount }
}

/** * 應用須要激活嗎 ? * @param {*} app * return true or false */
function shouldBeActive (app) {
  try {
    return app.activeWhen(window.location)
  } catch (err) {
    console.error('shouldBeActive function error', err);
    return false
  }
}

// 讓子應用判斷本身是否運行在基座應用中
window.singleSpaNavigate = true
// 監聽路由
window.addEventListener('hashchange', reroute)
window.history.pushState = patchedUpdateState(window.history.pushState)
window.history.replaceState = patchedUpdateState(window.history.replaceState)
/** * 裝飾器,加強 pushState 和 replaceState 方法 * @param {*} updateState */
function patchedUpdateState (updateState) {
  return function (...args) {
    // 當前url
    const urlBefore = window.location.href;
    // pushState or replaceState 的執行結果
    const result = Reflect.apply(updateState, this, args)
    // 執行updateState以後的url
    const urlAfter = window.location.href
    if (urlBefore !== urlAfter) {
      reroute()
    }
    return result
  }
}

複製代碼

看着是否是很簡單,加註釋也才200行而已,固然,這只是一個簡版的single-spa框架,沒什麼健壯性可言,但也正由於簡單,因此更能說明single-spa框架的本質。

single-spa-vue 源碼分析

single-spa-vue負責爲vue應用生成通用的生命週期鉤子,這些鉤子函數負責子應用的初始化、掛載、更新(數據)、卸載。

import "css.escape";

const defaultOpts = {
  // required opts
  Vue: null,
  appOptions: null,
  template: null
};

/** * 判斷參數的合法性 * 返回生命週期函數,其中的mount方法負責實例化子應用,update方法提供了基座應用和子應用通訊的機會,unmount卸載子應用,bootstrap感受沒啥用 * @param {*} userOpts = { * Vue, * appOptions: { * el: '#id', * store, * router, * render: h => h(App) * } * } * return 四個生命週期函數組成的對象 */
export default function singleSpaVue(userOpts) {
  // object
  if (typeof userOpts !== "object") {
    throw new Error(`single-spa-vue requires a configuration object`);
  }

  // 合併用戶選項和默認選項
  const opts = {
    ...defaultOpts,
    ...userOpts
  };

  // Vue構造函數
  if (!opts.Vue) {
    throw Error("single-spa-vue must be passed opts.Vue");
  }

  // appOptions
  if (!opts.appOptions) {
    throw Error("single-spa-vue must be passed opts.appOptions");
  }

  // el選擇器
  if (
    opts.appOptions.el &&
    typeof opts.appOptions.el !== "string" &&
    !(opts.appOptions.el instanceof HTMLElement)
  ) {
    throw Error(
      `single-spa-vue: appOptions.el must be a string CSS selector, an HTMLElement, or not provided at all. Was given ${typeof opts .appOptions.el}`
    );
  }

  // Just a shared object to store the mounted object state
  // key - name of single-spa app, since it is unique
  let mountedInstances = {};

  /** * 返回一個對象,每一個屬性都是一個生命週期函數 */
  return {
    bootstrap: bootstrap.bind(null, opts, mountedInstances),
    mount: mount.bind(null, opts, mountedInstances),
    unmount: unmount.bind(null, opts, mountedInstances),
    update: update.bind(null, opts, mountedInstances)
  };
}

function bootstrap(opts) {
  if (opts.loadRootComponent) {
    return opts.loadRootComponent().then(root => (opts.rootComponent = root));
  } else {
    return Promise.resolve();
  }
}

/** * 作了三件事情: * 大篇幅的處理el元素 * 而後是render函數 * 實例化子應用 */
function mount(opts, mountedInstances, props) {
  const instance = {};
  return Promise.resolve().then(() => {
    const appOptions = { ...opts.appOptions };
    // 能夠經過props.domElement屬性單獨設置自應用的渲染DOM容器,固然appOptions.el必須爲空
    if (props.domElement && !appOptions.el) {
      appOptions.el = props.domElement;
    }

    let domEl;
    if (appOptions.el) {
      if (typeof appOptions.el === "string") {
        // 子應用的DOM容器
        domEl = document.querySelector(appOptions.el);
        if (!domEl) {
          throw Error(
            `If appOptions.el is provided to single-spa-vue, the dom element must exist in the dom. Was provided as ${appOptions.el}`
          );
        }
      } else {
        // 處理DOM容器是元素的狀況
        domEl = appOptions.el;
        if (!domEl.id) {
          // 設置元素ID
          domEl.id = `single-spa-application:${props.name}`;
        }
        appOptions.el = `#${CSS.escape(domEl.id)}`;
      }
    } else {
      // 固然若是沒有id,這裏會自動生成一個id
      const htmlId = `single-spa-application:${props.name}`;
      appOptions.el = `#${CSS.escape(htmlId)}`;
      domEl = document.getElementById(htmlId);
      if (!domEl) {
        domEl = document.createElement("div");
        domEl.id = htmlId;
        document.body.appendChild(domEl);
      }
    }

    appOptions.el = appOptions.el + " .single-spa-container";

    // single-spa-vue@>=2 always REPLACES the `el` instead of appending to it.
    // We want domEl to stick around and not be replaced. So we tell Vue to mount
    // into a container div inside of the main domEl
    if (!domEl.querySelector(".single-spa-container")) {
      const singleSpaContainer = document.createElement("div");
      singleSpaContainer.className = "single-spa-container";
      domEl.appendChild(singleSpaContainer);
    }

    instance.domEl = domEl;

    // render
    if (!appOptions.render && !appOptions.template && opts.rootComponent) {
      appOptions.render = h => h(opts.rootComponent);
    }

    // data
    if (!appOptions.data) {
      appOptions.data = {};
    }

    appOptions.data = { ...appOptions.data, ...props };

    // 實例化子應用
    instance.vueInstance = new opts.Vue(appOptions);
    if (instance.vueInstance.bind) {
      instance.vueInstance = instance.vueInstance.bind(instance.vueInstance);
    }

    mountedInstances[props.name] = instance;

    return instance.vueInstance;
  });
}

// 基座應用經過update生命週期函數能夠更新子應用的屬性
function update(opts, mountedInstances, props) {
  return Promise.resolve().then(() => {
    // 應用實例
    const instance = mountedInstances[props.name];
    // 全部的屬性
    const data = {
      ...(opts.appOptions.data || {}),
      ...props
    };
    // 更新實例對象上的屬性值,vm.test = 'xxx'
    for (let prop in data) {
      instance.vueInstance[prop] = data[prop];
    }
  });
}

// 調用$destroy鉤子函數,銷燬子應用
function unmount(opts, mountedInstances, props) {
  return Promise.resolve().then(() => {
    const instance = mountedInstances[props.name];
    instance.vueInstance.$destroy();
    instance.vueInstance.$el.innerHTML = "";
    delete instance.vueInstance;

    if (instance.domEl) {
      instance.domEl.innerHTML = "";
      delete instance.domEl;
    }
  });
}

複製代碼

結語

到這裏就結束了,文章比較長,寫這篇文章也花費了好幾天的時間,可是感受真的很好,收穫滿滿,特別是最後手寫框架部分。

也給各位同窗一個建議,必定要勤動手,不動筆墨不讀書,當你真的把框架寫出來時,那個感受是隻看源碼徹底所不能比擬的,檢驗你是否真的懂框架原理的最好辦法,就是看你可否寫一個框架出來

願同窗們也能收穫滿滿!!

共同窗習,共同進步~~

github

相關文章
相關標籤/搜索