從0實現一個single-spa的前端微服務(中)

沉末_發表於2020-01-15

預備知識

上一篇文章:從0實現一個前端微服務(上)中講到,single-spa的原理就是,將子專案中的link/script標籤和<div id="app"></div>插入到主專案,而這個操作的核心就是動態載入jscss

動態載入js我們使用的是system.js,藉助這個外掛,我們只需要將子專案的app.js暴露給它即可。

本文章基於GitHub上一個single-spa的demo修改,所以最好有研究過這個demo,另外本文的基於最新的vue-cli4開發。

single-spa-vue實現步驟

要實現的效果就是子專案獨立開發部署,順便還能被主專案整合。

新建導航主專案

  1. vue-cli4直接使用vue create nav命令生成一個vue專案。

需要注意的是,導航專案路由必須用 history 模式

  1. 修改index.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">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title>home-nav</title>
   <!-- 配置檔案注意寫成絕對路徑:/開頭,否則訪問子專案的時候重定向的index.html,相對目錄會出錯 -->
   <script type="systemjs-importmap" src="/config/importmap.json"></script>
   <!-- 預請求single-spa,vue,vue-router檔案 -->
   <link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.7/system/single-spa.min.js" as="script" crossorigin="anonymous" />
   <link rel="preload" href="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js" as="script" crossorigin="anonymous" />
   <link rel="preload" href="https://cdn.jsdelivr.net/npm/vue-router@3.0.7/dist/vue-router.min.js" as="script" crossorigin="anonymous" />
   <!-- 引入system.js相關檔案 -->
   <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/system.min.js"></script>
   <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/extras/amd.min.js"></script>
   <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/extras/named-exports.js"></script>
   <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/extras/use-default.min.js"></script>
  </head>
  <body>
    <script>
      (function() {
        System.import('single-spa').then(singleSpa => {
          singleSpa.registerApplication(
            'appVueHistory',
            () => System.import('appVueHistory'),
            location => location.pathname.startsWith('/app-vue-history/')
          )
          singleSpa.registerApplication(
            'appVueHash',
            () => System.import('appVueHash'),
            location => location.pathname.startsWith('/app-vue-hash/')
          )
          singleSpa.start();
        })
      })()
    </script>
    <div class="wrap">
      <div class="nav-wrap">
        <div id="app"></div>
      </div>
      <div class="single-spa-container">
        <div id="single-spa-application:appVueHash"></div>
        <div id="single-spa-application:appVueHistory"></div>
      </div>
    </div>
    <style>
    .wrap{
      display: flex;
    }
    .nav-wrap{
      flex: 0 0 200px;
    }
    .single-spa-container{
      width: 200px;
      flex-grow: 1;
    }
    </style>
  </body>
</html>
複製程式碼
  1. 子專案和公共檔案url的配置檔案config/importmap.json:
{
  "imports": {
    "appVue": "http://localhost:7778/app.js",
    "appVueHistory": "http://localhost:7779/app.js",
    "single-spa": "https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.7/system/single-spa.min.js",
    "vue": "https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js",
    "vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.0.7/dist/vue-router.min.js"
  }
}
複製程式碼

子專案改造

hash模式路由的vue專案

如果是新開發的專案,可以先用vue-cli4生成一個vue專案,路由使用的是hash模式。

1. 安裝外掛(稍後會介紹其作用):

如果是老專案,需要分別安裝一下三個外掛:

npm install systemjs-webpack-interop -S
複製程式碼
npm install single-spa-vue -S
複製程式碼
npm install vue-cli-plugin-single-spa -D
複製程式碼

如果是新專案,則可以使用以下命令:

vue add single-spa
複製程式碼

注意:該命令會改寫你的 main.js,老專案不要用這個命令

該命令做了四事件:

  • (1) 安裝 single-spa-vue 外掛

  • (2) 安裝 systemjs-webpack-interop 外掛,並生成 set-public-path.js

  • (3) 修改main.js

  • (4) 修改webpack配置(允許跨域,關閉熱更新,去掉splitChunks等)

2. 新增兩個環境變數

由於single-spa模式也有開發和生成環境,所以有4種環境:正常開發,single-spa開發,正常打包,single-spa打包。但是我們只需要兩個環境變數檔案即可區分開,分別在在根目錄下新建環境變數檔案:

.env.devSingleSpa檔案(區分正常開發和single-spa模式開發):

NODE_ENV = development
VUE_APP__ENV = singleSpa
複製程式碼

.env.singleSpa檔案(區分正常打包和single-spa模式打包):

NODE_ENV = production
VUE_APP__ENV = singleSpa
複製程式碼

3. 修改入口檔案

single-spa和正常開發模式不一樣的地方僅僅在入口檔案。其中入口檔案中需要引入的外掛(vuex,vue-router,axios,element-ui等)完全一樣,不一樣的地方在於,正常開發是new Vue(options)single-spa則是呼叫singleSpaVue(Vue,options)函式,並且將三個生命週期export

所以我將兩種模式下公共的部分任然寫在main.js,並匯出兩種模式所需的配置物件:

import store from "./store";
import Vue from 'vue';
import App from './App.vue';
import router from './router';

const appOptions = {
  render: (h) => h(App),
  router,
  store,
}

Vue.config.productionTip = false;

export default appOptions;
複製程式碼

新增index.js(正常模式入口檔案) :

import appOptions from './main';
import './main';
import Vue from 'vue';

new Vue(appOptions).$mount('#app');
複製程式碼

新增index.spa.jssingle-spa模式入口檔案) :

import './set-public-path'
import singleSpaVue from 'single-spa-vue';
import appOptions from './main';
import './main';
import Vue from 'vue';

const vueLifecycles = singleSpaVue({
  Vue,
  appOptions
});

const { bootstrap, mount, unmount } = vueLifecycles;

export { bootstrap, mount, unmount };
複製程式碼

其中index.spa.js裡面的set-public-path.js:

import { setPublicPath } from 'systemjs-webpack-interop'
//模組的名稱必須和system.js的配置檔案(importmap.json)中的模組名稱保持一致
setPublicPath('appVueHash')
複製程式碼

4. 修改打包配置(vue.config.js

single-spa模式和正常模式只有入口檔案不同,其他的都一樣。也就是說打包之後,只有app.js檔案不同,那麼其他的檔案是否可以複用,能否實現一次打包,即可部署兩種模式?

答案是可以的:打包的時候我先執行sing-spa的打包,然後執行正常模式打包,最後將single-spa打包生成的app.js檔案拷貝到正常打包的檔案根目錄下。這樣只需要拿著dist目錄部署即可,single-spa不需要做任何修改即可同步更新。

需要注意的是檔案不能帶有hash值了,檔案沒了hash值就需要伺服器自己生成hash值來設定快取了。

const CopyPlugin = require('copy-webpack-plugin');

const env = process.env.VUE_APP__ENV; // 是否是single-spa
const modeEnv = process.env.NODE_ENV; // 開發環境還是生產環境

const config = {
  productionSourceMap: false,//去掉sourceMap
  filenameHashing: false,//去掉檔名的hash值
};

const enteyFile = env === 'singleSpa' ? './src/index.spa.js' : './src/index.js';
//正常打包的app.js在js目錄下,而single-spa模式則需要在根目錄下。
//打包時會從dist-spa/js目錄將app.js拷貝到正常打包的根目錄下,所以不用管,只需要判斷single-spa的開發模式即可
const filename = modeEnv === 'development' ? '[name].js' : 'js/[name].js';

chainWebpack = config => {
  config.entry('app')
    .add(enteyFile)
    .end()
    .output
      .filename(filename);
  if(env === 'singleSpa'){
    //vue,vue-router不打包進app.js,使用外鏈
    config.externals(['vue', 'vue-router'])
  }
}

if(env === 'singleSpa'){
  Object.assign(config, {
    outputDir: 'dist-spa',
    devServer: {
      hot: false,//關閉熱更新
      port: 7778
    },
    chainWebpack,
  })
}else{
  Object.assign(config, {
    chainWebpack,
    configureWebpack: modeEnv === 'production' ? {
      plugins: [
        //將single-spa模式下打包生成的app.js拷貝到正常模式打包的主目錄
        new CopyPlugin([{ 
          from: 'dist-spa/js/app.js',
          to: '' 
        }])
      ],
    } : {},
  })
}

module.exports = config;
複製程式碼

打包後的檔案效果:

從0實現一個single-spa的前端微服務(中)

其中js/app.js是正常模式生成的,而與index.html同目錄的app.jsdist-spa/js/app.js拷貝過來的,是single-spa模式的入口檔案,其他的檔案複用。

5. 修改打包命令(package.json

single-spa模式下開發/打包都需要改動環境變數,將正常的build命令修改成:按順序打包兩次,就可以實現和原來一樣打包部署流程。

"scripts": {
    "spa-serve": "vue-cli-service serve --mode devSingleSpa",
    "serve": "vue-cli-service serve",
    "spa-build": "vue-cli-service build --mode singleSpa",
    "usual-build": "vue-cli-service build",
    "build": "npm run spa-build && npm run usual-build",
    "lint": "vue-cli-service lint"
},
複製程式碼

single-spa開發使用npm run spa-serve,正常開發不變。

打包任然使用npm run build,然後將dist目錄下的檔案部署到子專案伺服器即可。

history模式路由的vue專案

由於我們給子專案路由強行加了不同字首(/app-vue-history),在hash模式是沒問題的,因為hash模式下路由跳轉只會修改urlhash值,不會修改path值。history模式則需要告訴vue-router/app-vue-history/是專案路由字首,跳轉只需要修改這後面的部分,否則路由跳轉會直接覆蓋全部路徑。那麼這個配置項就是base屬性:

const router = new VueRouter({
  mode: "history",
  base: '/',//預設是base
  routes,
});
複製程式碼

辦法也很簡單,判斷下環境變數,single-spa模式下base屬性是/app-vue-history,正常模式則不變。

但是由於我們打包後複用了除app.js以外的檔案,所以只有入口檔案才能區分開環境,解決辦法是:

router/index.js路由檔案不匯出例項化的路由物件,而匯出一個函式:

const router = base => new VueRouter({
  mode: "history",
  base,
  routes,
});
複製程式碼

並且main.js不再引入路由檔案,改成在入口檔案分別引入。

正常模式的入口檔案index.js:

import router from './router';

const baseUrl = '/';
appOptions.router = router(baseUrl);
複製程式碼

single-spa模式的入口檔案index.spa.js:

import router from './router';

const baseUrl = '/app-vue-history';
appOptions.router = router(baseUrl);
複製程式碼

部分原理淺析

sysyem.js的作用及好處

system.js的作用就是動態按需載入模組。假如我們子專案都使用了vue,vuex,vue-router,每個專案都打包一次,就會很浪費。system.js可以配合webpackexternals屬性,將這些模組配置成外鏈,然後實現按需載入:

從0實現一個single-spa的前端微服務(中)

從0實現一個single-spa的前端微服務(中)

當然了,你也可以直接用script標籤將這些公共的js全部引入,但是這樣會造成浪費,比如說子專案A用到了vue-routeraxios,但是沒用到vuex,子專案A重新整理,則還是會請求vuex,就很浪費,system.js則會按需載入。

同時,子專案打包成umd格式,system.js可以實現按需載入子專案。

systemjs-webpack-interop 外掛有什麼作用(GitHub地址

上一篇文章中講到,直接引入子專案的js/css可以呈現出子系統,但是動態生成的HTML中,img/video/audio等檔案的路徑是相對的,導致載入不出來。而解決辦法1是:修改vue-cli4publicPath 設定為完整的絕對路徑http://localhost:8080/即可。

這個外掛作用就是將子專案的publicPath暴露出來給system.jssystem.js根據專案名稱匹配到配置檔案(importmap.json),然後解析配置的url,將字首賦給publicPath

那麼publicPath如何動態設定呢?webpack官網中給出的辦法是:webpack 暴露了一個名為 __webpack_public_path__ 的全域性變數,直接修改這個值即可。

systemjs-webpack-interop部分原始碼截圖(public-path-system-resolve.js):

從0實現一個single-spa的前端微服務(中)

所以這也是為什麼single-spa的入口檔案app.js要和index.html目錄一致,因為他直接擷取了app.js的路徑作為了publicPath

single-spa-vue 外掛有什麼作用 (GitHub地址

這個外掛的主要作用是幫我們寫了single-spa所需要的三個週期事件:bootstrapmountunmount

mount週期做的事情就是生成我們需要的<div id="app"></div>,當然了,id的名稱它是根據專案名取得:

從0實現一個single-spa的前端微服務(中)

然後就是在這個div裡面例項化vue:

從0實現一個single-spa的前端微服務(中)

所以如果我們想讓子專案內容在我們自定義的區域(預設插入到body),其中一個辦法是將div寫好:

home-nav/public/index.html:

從0實現一個single-spa的前端微服務(中)

另一個辦法就是修改這部分程式碼,讓他插入到我們想要插入的地方,而不是body

unmount週期它解除安裝了例項化的vue並且清空了DOM,想要實現keep-alive效果我們得修改這部分程式碼(後面有介紹)

vue-cli-plugin-single-spa 外掛的作用(GitHub地址

這個外掛主要是用於命令vue add single-spa執行時,覆蓋你的main.js並且生成set-public-path.js,同時修改你的webpack配置。但是執行npm install vue-cli-plugin-single-spa -D命令時,它只會覆蓋你的webpack配置。

其修改webpack配置的原始碼:

module.exports = (api, options) => {
  options.css.extract = false
  api.chainWebpack(webpackConfig => {
    webpackConfig
      .devServer
      .headers({
        'Access-Control-Allow-Origin': '*',
      })
      .set('disableHostCheck', true)
    
    webpackConfig.optimization.delete('splitChunks')
    webpackConfig.output.libraryTarget('umd')
    webpackConfig.set('devtool', 'sourcemap')
  })
}
複製程式碼

回到最初的起點,我們實現single-spa最重要的事:動態引入子專案的js/css,但是你發現沒有,全程都只看到js的引入,絲毫沒有提及css,那麼css檔案咋辦?答案就是options.css.extract = false

從0實現一個single-spa的前端微服務(中)

vue-cli3官網中介紹,這個值為false,就是不單獨生成css檔案,和js檔案打包到一起,這讓我們只需要關心js檔案的引入即可,但是也為css汙染問題埋下了坑。

另一個配置就是允許跨域,同時還有文章開頭提及的system.js要求子專案打包成umd形式,也是它配置的。

還有一個比較關鍵的配置:webpackConfig.optimization.delete('splitChunks'),正常情況下,我們打包之後的檔案除了入口檔案app.js,還有一個檔案是chunk-vendors.js,這個檔案裡面包含了一些公共的第三方外掛,這樣一來,子專案就有兩個入口檔案(或者說得同時載入這兩個檔案),所以只能去掉splitChunks

注意事項及其他細節

  1. 環境變數

部署的時候除入口檔案(app.js)外,其他的路由檔案都複用了正常打包的檔案,所以環境變數需要由入口檔案注入到全域性使用。

index.spa.js檔案:

appOptions.store.commit('setSingleSpa',true);
複製程式碼
  1. 子專案開發最好設定固定埠

避免頻繁修改配置檔案,設定一個固定的特殊埠,儘量避免埠衝突。

  1. single-spa 關閉熱更新

開發模式仍正常開發,但是single-spa聯調需要關閉熱更新,否則本地websocket會一直報failed

從0實現一個single-spa的前端微服務(中)

single-spa開發中我發現熱更新正常生效。

  1. index.html裡面的外部檔案引入url需要寫成絕對路徑

配置檔案注意寫成絕對路徑,否則訪問子專案的時候路由重定向回主專案的index.html,裡面的url相對目錄會出錯。

home-nav/public/index.html:

<script type="systemjs-importmap" src="/config/importmap.json"></script>
複製程式碼
  1. 如何實現“keep-alive”

檢視single-spa-vue原始碼可以發現,在unmount生命週期,它將vue例項destroy(銷燬了)並且清空了DOM。要想實現keep-alive,我們只需要去掉destroy並且不清空DOM,然後自己使用display:none來隱藏和顯示子專案的DOM即可。

function unmount(opts, mountedInstances) {
  return Promise
    .resolve()
    .then(() => {
      mountedInstances.instance.$destroy();
      mountedInstances.instance.$el.innerHTML = '';
      delete mountedInstances.instance;

      if (mountedInstances.domEl) {
        mountedInstances.domEl.innerHTML = ''
        delete mountedInstances.domEl
      }
    })
}
複製程式碼
  1. 如何避免css汙染

我們使用配置css.extract = true之後,css不再單獨生成檔案,而是打包到js裡面,生成的樣式包裹在style標籤裡面,子專案解除安裝之後,樣式檔案並沒有刪除,樣式多了就可能造成樣式汙染。

從0實現一個single-spa的前端微服務(中)

解決辦法:

辦法1:命名規範 + css-scope + 去掉全域性樣式

辦法2:解除安裝應用的時候去掉樣式的style標籤(待研究)

如果一定要寫全域性變數,可以用類似“換膚”的辦法解決:在子專案給body/html加一個唯一的id(正常開發部署用),然後這個全域性的樣式前面加上這個id,而single-spa模式則需要修改single-spa-vue,在mount週期給body/html加上這個唯一的id,在unmount週期去掉,這樣就可以保證這個全域性css只對這個專案生效了。

  1. 如何避免js衝突

首先得規範開發:在元件的destroy生命週期去掉全域性的屬性/事件,其次還有個辦法就是在子專案載入之前對window物件做一個快照,然後在解除安裝的時候恢復之前的狀態。

  1. 子專案如何通訊

可以藉助localstorage和自定義事件通訊。localstorage一般用來共享使用者的登陸資訊等,而自定義事件一般用於共享實時資料,例如訊息數量等。

//1、子元件A 建立事件並攜帶資料
const myCustom = new CustomEvent("custom",{ detail: { data: 'test' } });
//2、子元件B 註冊事件監聽器
window.addEventListener("custom",function(e){
  //接收到資料
})
//3、子元件A觸發事件
window.dispatchEvent(myCustom);
複製程式碼
  1. 如何控制子系統的許可權

其中一個辦法就是沒許可權的系統直接隱藏入口導航,然後就是直接輸入url進入,還是會載入子專案,但是子專案判斷無許可權之後顯示一個403頁面即可。可以看到子系統對應的入口檔案是寫在一個json檔案裡面的,那麼總不能所有人都能讀取到這個json吧,或者說想實現不同許可權的使用者的json配置不同。

我們可以動態生成script標籤:

//在載入模組之前先生成配置json
function insertNewImportMap(newMapJSON) {
  const newScript = document.createElement('script')
  newScript.type = 'systemjs-importmap';
  newScript.innerText = JSON.stringify(newMapJSON);
  const test = document.querySelector('#test')
  test.insertAdjacentElement('beforebegin',newScript);
}
//內容從介面獲取
const devDependencies = {
  imports: {
    "navbar": "http://localhost:8083/app.js",
    "app1": "http://localhost:8082/app.js",
    "app2": "http://localhost/app.js",
    "single-spa": "https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.7/system/single-spa.min.js",
    "vue": "https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js",
    "vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.0.7/dist/vue-router.min.js"
  }
}
insertNewImportMap(devDependencies);
複製程式碼

總結

如果不想自己搭建node靜態檔案伺服器,給大家推薦一個軟體:XAMPP

文章中的完整demo檔案地址:github.com/gongshun/si…

  1. 目前存在的問題

    • 子專案之間路由跳轉沒法去掉urlhash值,例如從'/app1/#/home'跳轉到'/app2/'時,hash值仍會被帶上:'/app2/#/',目前看無影響,但是有可能會影響到子專案的路由判斷。

    • 子專案之間即使是同一技術棧也沒法統一框架版本,雖然目前是有將公共框架抽離出來的操作,但是實際工作中可能比較難控制。

    • 專案整體開發除錯的時候,如果A專案是開發環境,而B專案是打包環境,路由來回切換則會報錯,兩個都是開發環境,或者兩個都是生產環境則不會。(原因未知)

  2. 下一步計劃

    • 研究阿里的qiankun框架
    • react專案改造和angular專案改造,雖然原理類似,但是細節還是會不同

最後,感謝大家閱讀,祝大家新年快樂!

有什麼問題歡迎指出,下一篇文章已更新:從0實現一個single-spa的前端微服務(下)

相關文章