1. 程式人生 > >vue單頁(spa)前端git工程拆分實踐

vue單頁(spa)前端git工程拆分實踐

... userinfo computed 返回 assets 攔截 用戶訪問 line 重復加載

背景

隨著項目的成長,單頁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的設置隔離了各個業務線
入口文件

技術分享圖片

依賴

技術分享圖片

技術分享圖片

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就夠了。


以上

原文地址:https://segmentfault.com/a/1190000017124192

vue單頁(spa)前端git工程拆分實踐