當「SPA」應用遇上了膨脹的專案

WirelessSprucetec發表於2018-12-18

作者:defghy

背景

隨著專案的成長,單頁spa逐漸包含了許多業務線

  • 商城系統
  • 售後系統
  • 會員系統

當專案頁面超過一定數量(150+)之後,會產生一系列的問題

  • 可擴充套件性

專案編譯的時間(啟動server,修改程式碼)越來越長,而每次除錯關注的可能只是其中1、2個頁面

  • 需求衝突

所有的需求都定位到當前git,需求過多導致測試環境經常排隊

基於以上問題有了對git進行拆分的技術需求。具體如下

目標

  • 依然是spa

由於改善的是開發環境,當然不希望拆分專案影響使用者體驗。如果完全將業務線拆分成2個獨立頁面,那麼使用者在業務線之間跳轉時將不再流暢,因為所有框架以及靜態資源都會在頁面切換的時候過載。因此要求跳轉業務線的時候依然停留在spa內部,不重新整理頁面,共用同一個頁面入口;

  • 業務線頁面不再重複載入資源

因為大部分業務線需要用到的框架(vue, vuex…), 公共元件(dialogtoast)都已經在spa入口載入過了,不希望業務線重複載入這些資源。業務線專案中應該只包含自己獨有的資源,並能使用公共資源;

  • 業務線之間資源完全共享

業務線之間應該能用router互相跳轉,能訪問其他業務線包括全域性的store

需求如上,下面介紹的實現方式

技術框架

  • vue: 2.4.2
  • vue-router: 2.7.0
  • vuex: 2.5.0
  • webpack: 4.7.0

實現

假設要從主專案拆分一個業務線 hello 出來

  • 主專案:包含系統核心頁面 + 各種必須框架(vue, vuex…)
  • hello專案:包含hello自己內部的業務程式碼

跳轉hello頁面流程

  1. 使用者訪問業務線頁面 路由 #/hello/index
  2. 主專案router未匹配,走公共*處理;
  3. 公共router判定當前路由為業務線hello路由,請求hello的入口bundle js
  4. hello入口js執行過程中,將自身的router與store註冊到主專案;
  5. 註冊完畢,標記當前業務線hello為已註冊;
  6. 之後路由呼叫next。會自動繼續請求 #/hello/index對應的頁面chunk(js,css)頁面跳轉成功;
  7. 此時hello已經與主專案完成融合,hello可以自由使用全部的store,使用router可以自由跳轉任何頁面。done

需要的功能就是這些,下面分步驟看看具體實現

請求業務線路由(步驟1)

第一次請求#/hello/index時,此時router中所有路由無法匹配,會走公共*處理

/** 主專案 **/const router = new VueRouter({ 
routes: [ ... // 不同路由預設跳轉連結不同 {
path: '*', async beforeEnter(to, from, next) {
// 業務線攔截 let isService = await service.handle(to, from, next);
// 非業務線頁面,走預設處理 if(!isService) {
next('/error');

}
}
} ]
});
複製程式碼

業務線初始化(步驟2、步驟3)

首先需要一個全域性的業務線配置,存放各個業務線的入口js檔案

const config = { 
"hello": {
"src": [ "http://local.aaa.com:7000/dist/dev/js/hellobundle.js" ]
}, "其他業務線": {...
}
}複製程式碼

此時需要利用業務線配置,判斷當前路由是否屬於業務線,是的話就請求業務線,不是返回false

/** 主專案 **/// 業務線接入處理export const handle = async (to, from, next) =>
{
let path = to.path || "";
let paths = path.split('/');
let serviceName = paths[1];
let cfg = config[serviceName];
// 非業務線路由 if(!cfg) {
return false;

} // 該業務線已經載入 if(cfg.loaded) {
next();
return true;

} for(var i=0;
i<
cfg.src.length;
i++) {
await loadScript(cfg.src[i]);

} cfg.loaded = true;
next(to);
// 繼續請求頁面 return true;

}複製程式碼

有幾點需要注意

  • 一般業務線配置存放在後端,此處為了說明直接列出
  • 業務線只載入1次,loaded為判定條件。載入過的話直接進行next
  • 當第1次業務線載入成功,此時主專案已經包含了 #/hello/index 的路由,此時next可以正常跳轉。原因見下一節

hello的入口entry.js做的工作(步驟4)

為了節省資源,hello業務線不再重複打包vuevuex等主專案已經載入的框架。

那麼為了hello能正常工作,需要主專案將以上框架傳遞給hello,方法為直接將相關變數掛在到window

/** 主專案 **/import Vue from 'vue';
import {
default as globalRouter
} from 'app/router.js';
2個需要動態賦值import {
default as globalStore
} from 'app/vuex/index.js';
import Vuex from 'vuex'// 掛載業務線資料function registerApp(appName, {
store, router
}) {
if(router) {
globalRouter.addRoutes(router);

} if(store) {
globalStore.registerModule(appName, Object.assign(store, {
namespaced: true
}));

}
}window.bapp = Object.assign(window.bapp || {
}, {
Vue, Vuex, router: globalRouter, store: globalStore, util: {
registerApp
}
});
複製程式碼

注意registerApp這個方法,此方法為hello與主專案融合的掛載方法,由業務線呼叫。

上一步已經正常執行了hello的entry.js,那我們看看hello在entry中幹了什麼:

/** hello **/import App from 'app/pages/Hello.vue';
// 路由器根例項import {APP_NAME
} from 'app/utils/global';
import store from 'app/vuex/index';
let router = [{
path: `/${APP_NAME
}
`, name: 'hello', meta: {
title: '頁面測試', needLogin: true
}, component: App, children: [ {
path: 'index', name: 'hello-index', meta: {
title: '商品列表'
}, component: resolve =>
require.ensure([], () =>
resolve(require('app/pages/goods/Goods.vue').default), 'hello-goods')
}, {
path: 'newreq', name: 'hello-newreq', meta: {
title: '新品頁面'
}, component: resolve =>
require.ensure([], () =>
resolve(require('app/pages/newreq/List.vue').default), 'hello-newreq')
}, ]
}]window.bapp &
&
bapp.util.registerApp(APP_NAME, {router, store
});
複製程式碼

注意幾點

  • APP_NAME是業務線的唯一標識,也就是hello
  • 業務線有自己內部的routerstore
  • 業務線主動呼叫registerApp,將自己的router和store與主專案融合
  • store融合的時候需要新增namespace: true,因為此時整個hello業務線store成為了globalStore的一個module
  • addRoutesregisterModule是router與store的動態註冊方法
  • 路由的name需要和主專案保持唯一

業務線配置更新

業務線配置需要在hello每次編譯完成後更新,更新分為本地除錯更新線上更新

  • 本地除錯更新只需要更新一個本地配置檔案service-line-config.json,然後在請求業務線config時由主專案讀取該檔案返回給js。
  • 線上更新更為簡單,每次釋出編譯後,將當前入口js+md5的完整url更新到後端

以上,看到使用webpack-plugin比較適合當前場景,實現如下

class ServiceUpdatePlugin { 
constructor(options) {
this.options = options;
this.runCount = 0;

} // 更新本地配置檔案 updateLocalConfig({srcs
}) {
....
} // 更新線上配置檔案 uploadOnlineConfig({files
}) {
....
} apply(compiler) {
// 除錯環境:編譯完畢,修改本地檔案 if(process.env.NODE_ENV === 'dev') {
// 本地除錯沒有md5值,不需要每次重新整理 compiler.hooks.done.tap('ServiceUpdatePlugin', (stats) =>
{
if(this.runCount >
0) {
return;

} let assets = stats.compilation.assets;
let publicPath = stats.compilation.options.output.publicPath;
let js = Object.keys(assets).filter(item =>
{
// 過濾入口檔案 return item.startsWith('js/');

}).map(path =>
`${publicPath
}
${path
}
`);
this.updateLocalConfig({srcs: js
});
this.runCount++;

});

} // 釋出環境:上傳完畢,請求後端修改 else {
compiler.hooks.uploaded.tap('ServiceUpdatePlugin', (upFiles) =>
{
let entries = upFiles.filter(file =>
{
return file &
&
file.endsWith('js') &
&
file.includes('js/');

});
this.uploadOnlineConfig({files: entries
});
return;

})
}
}
}複製程式碼

注意,uploaded事件由我們專案組的靜態資源上傳plugin發出,會傳遞當前所有上傳檔案完整路徑。需要等檔案上傳cdn完畢才可更新業務線

之後在webpack中使用即可

/** hello **/{ 
... plugins: [ // 業務線js md5更新 new McServiceUpdatePlugin({
app_name, configFile: path.resolve(process.cwd(), '../mainProject/app/service-line-config.json')
}) ], ...
}複製程式碼

注意本地除錯時業務線config是主專案才會用到的,因此直接更新主專案目錄下的配置檔案

除錯釋出

基於上面的plugin,有以下效果

除錯過程如下:
  1. 啟動主專案server(埠7777);
  2. 啟動hello業務線server(埠7000),此時啟動成功會同時更新本地檔案service-line-config.json;
  3. 訪問hello頁面,載入本地配置後,載入7000埠提供的靜態資源(如http://local.aaa.com:7000/dist/dev/js/hellobundle.js)
釋出test過程如下:
  1. 執行 npm run test
  2. 執行過程中會上傳檔案並更新test環境業務線配置
  3. 此時訪問test環境頁面已經更新

可以看到hello釋出是比主專案更加輕量的,這是因為業務線只更新介面,但是主專案要釋出還需要更新html的web服務

小結

至此已經完成了一開始的主體需求,訪問業務線頁面後,業務線頁面會和主專案頁面合併成為1個新的spa,spa內部store和router完全共享。

可以看到主要利用了vue家族的動態註冊方法。下面是一些過程中遇到的問題和解決思路

遇到的問題與解決

hello業務線的wepback打包

  • 業務線需要獨立的打包名稱空間
  • 為了能與主專案區分,會給hello業務線的bundle重新命名,增加了業務線名稱字首
  • 入口檔案越少越好,因此刪除了一些打包配置
    • 刪除了vendor: 主要第三方庫由主專案載入
    • 刪除了dll: dll資源由主專案載入
    • 刪除了runtime(manifest)配置: 各業務線將各自處理依賴載入
/** hello **/{ 
... entry: {
[app_name + 'bundle']: path.resolve(SRC, `entry.js`)
}, output: {
publicPath: `http://local.aaa.com:${PORT
}
${devDefine.publicPath
}
`, library: app_name // 業務線名稱空間
}, ... optimization: {
runtimeChunk: false, // 依賴處理與bundle合併 splitChunks: {
cacheGroups: false // 業務線不分包
}
}, ...
}複製程式碼

注意library的設定隔離了各個業務線入口檔案

image

依賴

image
image

router拆分問題

最開始使用/:name來做公共處理。

但是發現router的優先順序按照陣列的插入順序,那麼後插入的hello路由優先順序將一直低於/:name路由。

之後使用*做公共處理,將一直處於兜底,問題解決。

store拆分

hello的store做為globalStore的一個module註冊,需要標註 namespaced: true,否則拿不到資料;

store使用基本和主專案一致:

/** hello **/let { 
Vuex
} = bapp;
// 全域性store獲取let {
mapState: gmapState, mapActions: gmapActions, createNamespacedHelpers
} = Vuex;
// 本業務線store獲取const {
mapState, mapActions
} = createNamespacedHelpers(`${APP_NAME
}
/feedback`)export default {
... computed: {
...gmapState('userInfo', {
userName: state =>
state.userName
}), ...gmapState('hello/feedback', {
helloName2: state =>
state.helloName
}), ...mapState({
helloName: state =>
state.helloName
})
},
}複製程式碼

介面拆分

雖然前端工程拆分了,但是後端介面依然是走相同的域名,因此可以給hello暴露一個生成介面引數的公共方法,然後由hello自己組織。

公共利用

可以直接使用全域性元件mixinsdirectives,可以直接使用font。區域性的相關內容需要拷貝到hello或者暴露給hello才可用。圖片完全無法複用

本地server工具

主專案由於需要對request有比較精細的操作,因此是我們自己實現的express來本地除錯。

但是hello工程的唯一作用是提供本地當前的js與css,因此使用官方devServer就夠了。


以上,感謝閱讀

原文連結: tech.meicai.cn/detail/75, 也可微信搜尋小程式「美菜產品技術團隊」,乾貨滿滿且每週更新,想學習技術的你不要錯過哦。

來源:https://juejin.im/post/5c18b5f15188252dcb31072a

相關文章