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

預備知識

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

動態加載js咱們使用的是system.js,藉助這個插件,咱們只須要將子項目的app.js暴露給它便可。css

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

single-spa-vue實現步驟

要實現的效果就是子項目獨立開發部署,順便還能被主項目集成。前端

新建導航主項目

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

須要注意的是,導航項目路由必須用 history 模式 vue

  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模式。java

1. 安裝插件(稍後會介紹其做用):

若是是老項目,須要分別安裝一下三個插件:node

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

若是是新項目,則可使用如下命令:react

vue add single-spa
複製代碼

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

該命令作了四事件:ios

  • (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;
複製代碼

打包後的文件效果:

其中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屬性,將這些模塊配置成外鏈,而後實現按需加載:

固然了,你也能夠直接用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):

因此這也是爲何single-spa的入口文件app.js要和index.html目錄一致,由於他直接截取了app.js的路徑做爲了publicPath

single-spa-vue 插件有什麼做用 (GitHub地址

這個插件的主要做用是幫咱們寫了single-spa所須要的三個週期事件:bootstrapmountunmount

mount週期作的事情就是生成咱們須要的<div id="app"></div>,固然了,id的名稱它是根據項目名取得:

而後就是在這個div裏面實例化vue:

因此若是咱們想讓子項目內容在咱們自定義的區域(默認插入到body),其中一個辦法是將div寫好:

home-nav/public/index.html:

另外一個辦法就是修改這部分代碼,讓他插入到咱們想要插入的地方,而不是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

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

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標籤裏面,子項目卸載以後,樣式文件並無刪除,樣式多了就可能形成樣式污染。

解決辦法:

辦法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通常用來共享用戶的登錄信息等,而自定義事件通常用於共享實時數據,例如消息數量等。

//一、子組件A 建立事件並攜帶數據
const myCustom = new CustomEvent("custom",{ detail: { data: 'test' } });
//二、子組件B 註冊事件監聽器
window.addEventListener("custom",function(e){
  //接收到數據
})
//三、子組件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項目改造,雖然原理相似,可是細節仍是會不一樣

最後,感謝你們閱讀,祝你們新年快樂!

有什麼問題歡迎指出,下一篇文章預計年後更新了,須要大量實踐總結。

相關文章
相關標籤/搜索