預備知識
上一篇文章:從0實現一個前端微服務(上)中講到,single-spa
的原理就是,將子專案中的link/script
標籤和<div id="app"></div>
插入到主專案,而這個操作的核心就是動態載入js
和css
。
動態載入js
我們使用的是system.js
,藉助這個外掛,我們只需要將子專案的app.js
暴露給它即可。
本文章基於GitHub上一個single-spa的demo修改,所以最好有研究過這個demo
,另外本文的基於最新的vue-cli4
開發。
single-spa-vue實現步驟
要實現的效果就是子專案獨立開發部署,順便還能被主專案整合。
新建導航主專案
vue-cli4
直接使用vue create nav
命令生成一個vue
專案。
需要注意的是,導航專案路由必須用 history 模式
- 修改
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>
複製程式碼
- 子專案和公共檔案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.js
(single-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.js
是dist-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
模式下路由跳轉只會修改url
的hash
值,不會修改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
可以配合webpack
的externals
屬性,將這些模組配置成外鏈,然後實現按需載入:
當然了,你也可以直接用script
標籤將這些公共的js
全部引入,但是這樣會造成浪費,比如說子專案A用到了vue-router
和axios
,但是沒用到vuex
,子專案A重新整理,則還是會請求vuex
,就很浪費,system.js
則會按需載入。
同時,子專案打包成umd
格式,system.js
可以實現按需載入子專案。
systemjs-webpack-interop 外掛有什麼作用(GitHub地址)
上一篇文章中講到,直接引入子專案的js/css
可以呈現出子系統,但是動態生成的HTML
中,img/video/audio
等檔案的路徑是相對的,導致載入不出來。而解決辦法1是:修改vue-cli4
的 publicPath
設定為完整的絕對路徑http://localhost:8080/
即可。
這個外掛作用就是將子專案的publicPath
暴露出來給system.js
,system.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
所需要的三個週期事件:bootstrap
,mount
,unmount
。
在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
。
注意事項及其他細節
- 環境變數
部署的時候除入口檔案(app.js
)外,其他的路由檔案都複用了正常打包的檔案,所以環境變數需要由入口檔案注入到全域性使用。
index.spa.js
檔案:
appOptions.store.commit('setSingleSpa',true);
複製程式碼
- 子專案開發最好設定固定埠
避免頻繁修改配置檔案,設定一個固定的特殊埠,儘量避免埠衝突。
- single-spa 關閉熱更新
開發模式仍正常開發,但是single-spa
聯調需要關閉熱更新,否則本地websocket
會一直報failed
。
single-spa
開發中我發現熱更新正常生效。
- index.html裡面的外部檔案引入url需要寫成絕對路徑
配置檔案注意寫成絕對路徑,否則訪問子專案的時候路由重定向回主專案的index.html
,裡面的url相對目錄會出錯。
home-nav/public/index.html
:
<script type="systemjs-importmap" src="/config/importmap.json"></script>
複製程式碼
- 如何實現“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
}
})
}
複製程式碼
- 如何避免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只對這個專案生效了。
- 如何避免js衝突
首先得規範開發:在元件的destroy
生命週期去掉全域性的屬性/事件,其次還有個辦法就是在子專案載入之前對window
物件做一個快照,然後在解除安裝的時候恢復之前的狀態。
- 子專案如何通訊
可以藉助localstorage
和自定義事件通訊。localstorage
一般用來共享使用者的登陸資訊等,而自定義事件一般用於共享實時資料,例如訊息數量等。
//1、子元件A 建立事件並攜帶資料
const myCustom = new CustomEvent("custom",{ detail: { data: 'test' } });
//2、子元件B 註冊事件監聽器
window.addEventListener("custom",function(e){
//接收到資料
})
//3、子元件A觸發事件
window.dispatchEvent(myCustom);
複製程式碼
- 如何控制子系統的許可權
其中一個辦法就是沒許可權的系統直接隱藏入口導航,然後就是直接輸入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…
-
目前存在的問題
-
子專案之間路由跳轉沒法去掉
url
的hash
值,例如從'/app1/#/home'
跳轉到'/app2/'
時,hash值仍會被帶上:'/app2/#/'
,目前看無影響,但是有可能會影響到子專案的路由判斷。 -
子專案之間即使是同一技術棧也沒法統一框架版本,雖然目前是有將公共框架抽離出來的操作,但是實際工作中可能比較難控制。
-
專案整體開發除錯的時候,如果A專案是開發環境,而B專案是打包環境,路由來回切換則會報錯,兩個都是開發環境,或者兩個都是生產環境則不會。(原因未知)
-
-
下一步計劃
- 研究阿里的
qiankun
框架 react
專案改造和angular
專案改造,雖然原理類似,但是細節還是會不同
- 研究阿里的
最後,感謝大家閱讀,祝大家新年快樂!
有什麼問題歡迎指出,下一篇文章已更新:從0實現一個single-spa的前端微服務(下)