App下載

基于 Vue 技術(shù)棧的微前端方案實(shí)踐

猿友 2020-09-09 10:58:20 瀏覽數(shù) (3451)
反饋

文章來(lái)源于公眾號(hào):程序員成長(zhǎng)指北 ,作者mcuking

前幾天看到了 微前端在美團(tuán)外賣(mài)的實(shí)踐,感覺(jué)和筆者所在團(tuán)隊(duì)實(shí)踐了一年多的微前端方案非常類(lèi)似,只不過(guò)我們是基于 Vue 技術(shù)棧的,所以也想總結(jié)一篇文章分享給大家。因?yàn)楣P者文筆不算太好,其中借用了一些美團(tuán)文章的一些總結(jié)性的文字,還請(qǐng)見(jiàn)諒哦~

背景介紹

對(duì)于大型前端項(xiàng)目,比如公司內(nèi)部管理系統(tǒng)(一般包括 OA、HR、CRM、會(huì)議預(yù)約等系統(tǒng)),如果將所有業(yè)務(wù)放在一個(gè)前端項(xiàng)目里,隨著業(yè)務(wù)功能不斷增加,就會(huì)導(dǎo)致如下這些問(wèn)題:

  • 代碼規(guī)模龐大,導(dǎo)致編譯時(shí)間過(guò)長(zhǎng),開(kāi)發(fā)、打包速度越來(lái)越慢
  • 項(xiàng)目文件越來(lái)越多,導(dǎo)致查找相關(guān)文件變得越來(lái)越困難
  • 某一個(gè)業(yè)務(wù)的小改動(dòng),導(dǎo)致整個(gè)項(xiàng)目的打包和部署

方案介紹

preload-routesasync-routes 是目前筆者所在團(tuán)隊(duì)使用的微前端方案,最終會(huì)將整個(gè)前端項(xiàng)目拆解成一個(gè)主項(xiàng)目和多個(gè)子項(xiàng)目,其中兩者作用如下:

  • 主項(xiàng)目:用于管理子項(xiàng)目的路由切換、注冊(cè)子項(xiàng)目的路由和全局 Store 層、提供全局庫(kù)和方法
  • 子項(xiàng)目:用于開(kāi)發(fā)子業(yè)務(wù)線業(yè)務(wù)代碼,一個(gè)子項(xiàng)目對(duì)應(yīng)一個(gè)子業(yè)務(wù)線,并且包含兩端(PC + Mobile)代碼和復(fù)用層代碼(項(xiàng)目分層中的非視圖層)

結(jié)合筆者之前的采用分層架構(gòu)實(shí)現(xiàn)復(fù)用非視圖代碼的方式(感興趣的話請(qǐng)參考筆者之前的文章 前端分層架構(gòu)實(shí)踐心得),完整的方案如下:

基于 Vue 技術(shù)棧的微前端方案實(shí)踐

如圖所示,將整個(gè)前端項(xiàng)目按照業(yè)務(wù)線拆分出多個(gè)子項(xiàng)目,每個(gè)子項(xiàng)目都是獨(dú)立的倉(cāng)庫(kù),只包含了單個(gè)業(yè)務(wù)線的代碼,可以進(jìn)行獨(dú)立開(kāi)發(fā)和部署,降低了項(xiàng)目維護(hù)的復(fù)雜度。

采用這套方案,使得我們的前端項(xiàng)目不僅保有了橫向上(多個(gè)子項(xiàng)目)的擴(kuò)展性,又擁有了縱向上(單個(gè)子項(xiàng)目)的復(fù)用性。那么這套方案具體是怎么實(shí)現(xiàn)的呢?下面就詳細(xì)說(shuō)明方案的實(shí)現(xiàn)機(jī)制。

在講解之前,首先明確下這套方案有兩種實(shí)現(xiàn)方式,一種是預(yù)加載路由,另一種是懶加載路由,可以根據(jù)實(shí)際需求選擇其中一個(gè)即可。接下來(lái)就分別介紹這兩種方式的實(shí)現(xiàn)機(jī)制。

實(shí)現(xiàn)機(jī)制

預(yù)加載路由方式

preload-routes

1.子項(xiàng)目按照 vue-cli 3 的 library 模式進(jìn)行打包,以便后續(xù)主項(xiàng)目引用

注:在 library 模式中, Vue 是外置的。這意味著包中不會(huì)有 Vue ,即便你在代碼中導(dǎo)入了 Vue 。如果這個(gè)庫(kù)會(huì)通過(guò)一個(gè)打包器使用,它將嘗試通過(guò)打包器以依賴(lài)的方式加載 Vue ;否則就會(huì)回退到一個(gè)全局的 Vue 變量。

2.在編譯主項(xiàng)目的時(shí)候,通過(guò) InsertScriptPlugin 插件將子項(xiàng)目的入口文件 main.js 以 script 標(biāo)簽形式插入到主項(xiàng)目的 html 中

注:務(wù)必將子項(xiàng)目的入口文件 main.js 對(duì)應(yīng)的 script 標(biāo)簽放在主項(xiàng)目入口文件 app.jsscript 標(biāo)簽之上,這是為了確保子項(xiàng)目的入口文件先于主項(xiàng)目的入口文件代碼執(zhí)行,接下來(lái)的步驟就會(huì)明白為什么這么做。

再注:本地開(kāi)發(fā)環(huán)境下項(xiàng)目的入口文件編譯后的 main.js 是保存在內(nèi)存中的,所以磁盤(pán)上看不見(jiàn),但是可以訪問(wèn)。

InsertScriptPlugin 核心代碼如下:

compiler.hooks.compilation.tap('InsertScriptWebpackPlugin', compilation => {
  compilation.hooks.htmlWebpackPluginBeforeHtmlProcessing.tap(
    'InsertScriptWebpackPlugin',
    htmlPluginData => {
      const {
        assets: { js }
      } = htmlPluginData;
      // 將傳入的 js 以 script 標(biāo)簽形式插入到 html 中
      // 注意:需要將子項(xiàng)目的入口文件 main.js 放在主項(xiàng)目入口文件 app.js 之前,因?yàn)樾枰禹?xiàng)目提前將自己的 route list 注冊(cè)到全局上
      js.unshift(...self.files);
    }
  );
});

3.主項(xiàng)目的 html 要訪問(wèn)子項(xiàng)目里的編譯后的 js / css 等資源,需要進(jìn)行代理轉(zhuǎn)發(fā)

  • 如果是本地開(kāi)發(fā)時(shí),可以通過(guò) webpack 提供的 proxy,例如:

const PROXY = {
  '/app-a/': {
    target: 'http://localhost:10241/'
  }
};

  • 如果是線上部署時(shí),可以通過(guò) nginx 轉(zhuǎn)發(fā)或者將打包后的主項(xiàng)目和子項(xiàng)目放在一個(gè)文件夾中按照相對(duì)路徑引用。

4.當(dāng)瀏覽器解析 html 時(shí),解析并執(zhí)行到子項(xiàng)目的入口文件 main.js,將子項(xiàng)目的 route list 注冊(cè)到 Vue.share.routes 上,以便后續(xù)主項(xiàng)目將其合并到總的路由中。

子項(xiàng)目 main.js 代碼如下:(為了盡量減少首次主項(xiàng)目頁(yè)面渲染時(shí)加載的資源,子項(xiàng)目的入口文件建議只做路由掛載)

import Vue from 'vue';
import routes from './routes';


const share = (Vue.__share__ = Vue.__share__ || {});
const routesPool = (share.routes = share.routes || {});


// 將子項(xiàng)目的 route list 掛載到 Vue.__share__.routes 上,以便后續(xù)主項(xiàng)目將其合并到總的路由中
routesPool[process.env.VUE_APP_NAME] = routes;

5.繼續(xù)向下解析 html,解析并執(zhí)行到主項(xiàng)目 main.js 時(shí),從 Vue.share.routes 獲取所有子項(xiàng)目的 route list,合并到總的路由表中,然后初始化一個(gè) vue-router實(shí)例,并傳入到 new Vue 內(nèi)

相關(guān)關(guān)鍵代碼如下

// 從 Vue.__share__.routes 獲取所有子項(xiàng)目的 route list,合并到總的路由表中
const routes = Vue.__share__.routes;


export default new Router({
  routes: Object.values(routes).reduce((acc, prev) => acc.concat(prev), [
    {
      path: '/',
      redirect: '/app-a'
    }
  ])
});

到此就實(shí)現(xiàn)了單頁(yè)面應(yīng)用按照業(yè)務(wù)拆分成多個(gè)子項(xiàng)目,直白來(lái)說(shuō)子項(xiàng)目的入口文件 main.js 就是將主項(xiàng)目和子項(xiàng)目聯(lián)系起來(lái)的橋梁。

另外如果需要使用 vuex,則和 vue-router 的順序恰好相反(先主項(xiàng)目后子項(xiàng)目):

1.首先在主項(xiàng)目的入口文件中初始化一個(gè) store 實(shí)例 new Vuex.Store,然后掛在到 Vue.__share__.store

2.然后在子項(xiàng)目的 App.vue 中獲取到 Vue.__share__.store 并調(diào)用 store.registerModule(‘a(chǎn)pp-x', store),將子項(xiàng)目的 store 作為子模塊注冊(cè)到 store

懶加載路由方式

async-routes

懶加載路由,顧名思義,就是說(shuō)等到用戶點(diǎn)擊要進(jìn)入子項(xiàng)目模塊,通過(guò)解析即將跳轉(zhuǎn)的路由確定是哪一個(gè)子項(xiàng)目,然后再異步去加載該子項(xiàng)目的入口文件 main.js(可以通過(guò) systemjs 或者自己寫(xiě)一個(gè)動(dòng)態(tài)創(chuàng)建 script 標(biāo)簽并插入 body 的方法)。加載成功后就可以將子項(xiàng)目的路由動(dòng)態(tài)添加到主項(xiàng)目總的路由里了。

1.主項(xiàng)目 router.js 文件中定義了在 vue-router 的 beforeEach 鉤子去攔截路由,并根據(jù)即將跳轉(zhuǎn)的路由分析出需要哪個(gè)子項(xiàng)目,然后去異步加載對(duì)應(yīng)子項(xiàng)目入口文件,下面是核心代碼:

const cachedModules = new Set();


router.beforeEach(async (to, from, next) => {
  const [, module] = to.path.split('/');


  if (Reflect.has(modules, module)) {
    // 如果已經(jīng)加載過(guò)對(duì)應(yīng)子項(xiàng)目,則無(wú)需重復(fù)加載,直接跳轉(zhuǎn)即可
    if (!cachedModules.has(module)) {
      const { default: application } = await window.System.import(
        modules[module]
      );


      if (application && application.routes) {
        // 動(dòng)態(tài)添加子項(xiàng)目的 route-list
        router.addRoutes(application.routes);
      }


      cachedModules.add(module);
      next(to.path);
    } else {
      next();
    }
    return;
  }
});

2.子項(xiàng)目的入口文件 main.js 僅需要將子項(xiàng)目的 routes 暴露給主項(xiàng)目即可,代碼如下:

import routes from './routes';


export default {
  name: 'javascript',
  routes,
  beforeEach(from, to, next) {
    console.log('javascript:', from.path, to.path);
    next();
  }
};

注意:這里除了暴露 routes 方法外,另外又暴露了 beforeEach 方法,其實(shí)就是為了支持通過(guò)路由守衛(wèi)對(duì)子項(xiàng)目進(jìn)行頁(yè)面權(quán)限限制,主項(xiàng)目拿到這個(gè)子項(xiàng)目的 beforeEach,可以在 vue-routerbeforeEach 鉤子執(zhí)行,具體代碼請(qǐng)參考 async-routes。

除了主項(xiàng)目和子項(xiàng)目的交互方式不同,代理轉(zhuǎn)發(fā)子項(xiàng)目資源、vuex store 注冊(cè)等和上面的預(yù)加載路由完全一致。

優(yōu)缺點(diǎn)

下面談下這套方案的優(yōu)缺點(diǎn):

優(yōu)點(diǎn)

  • 子項(xiàng)目可單獨(dú)打包、單獨(dú)部署上線,提升了開(kāi)發(fā)和打包的速度
  • 子項(xiàng)目之間開(kāi)發(fā)互相獨(dú)立,互不影響,可在不同倉(cāng)庫(kù)進(jìn)行維護(hù),減少的單個(gè)項(xiàng)目的規(guī)模
  • 保持單頁(yè)應(yīng)用的體驗(yàn),子項(xiàng)目之間切換不刷新
  • 改造成本低,對(duì)現(xiàn)有項(xiàng)目侵入度較低,業(yè)務(wù)線遷移成本也較低
  • 保證整體項(xiàng)目統(tǒng)一一個(gè)技術(shù)棧

缺點(diǎn)

  • 主項(xiàng)目和子項(xiàng)目需要共用一個(gè) Vue 實(shí)例,所以無(wú)法做到某個(gè)子項(xiàng)目單獨(dú)使用最新版 Vue (例如 Vue3)或者 React

部分問(wèn)題解答

1.如果子項(xiàng)目代碼更新后,除了打包部署子項(xiàng)目之外,還需要打包部署主項(xiàng)目嗎?

不需要更新部署主項(xiàng)目。這里有個(gè) trick 上文忘記提及,就是子項(xiàng)目打包后的入口文件并沒(méi)有加上 chunkhash,直接就是 main.js(子項(xiàng)目其他的 js 都有 chunkhash)。也就是說(shuō)主項(xiàng)目只需要記住子項(xiàng)目的名字,就可以通過(guò) subapp-name/main.js 找到子項(xiàng)目的入口文件,所以子項(xiàng)目打包部署后,主項(xiàng)目并不需要更新任何東西。

2.針對(duì)第二個(gè)問(wèn)題中子項(xiàng)目入口文件 main.js 不使用 chunkhash 的話,如何防止該文件始終被緩存呢?

可以在靜態(tài)資源服務(wù)器端針對(duì)子項(xiàng)目入口文件設(shè)置強(qiáng)制緩存為不緩存,下面是服務(wù)器為 nginx 情況的相關(guān)配置:

location / {
    set $expires_time 7d;
    ...
    if ($request_uri ~* \/(contract|meeting|crm)-app\/main.js(\?.*)?$) {
        # 針對(duì)入口文件設(shè)置 expires_time -1,即expire是服務(wù)器時(shí)間的 -1s,始終過(guò)期
        set $expires_time -1;
    }
    expires $expires_time;
    ...
}

待完善

  • 可以通過(guò)寫(xiě)一個(gè)腳手架來(lái)自動(dòng)生成子項(xiàng)目以及相關(guān)的配置

結(jié)尾

如果沒(méi)有在一個(gè)大型前端項(xiàng)目中使用多個(gè)技術(shù)棧的需求,還是很推薦筆者目前團(tuán)隊(duì)實(shí)踐的這個(gè)方案的。另外如果是 React 技術(shù)棧,也是可以按照這種思想去實(shí)現(xiàn)類(lèi)似的方案的。

以上就是W3Cschool編程獅關(guān)于基于 Vue 技術(shù)棧的微前端方案實(shí)踐的相關(guān)介紹了,希望對(duì)大家有所幫助。

0 人點(diǎn)贊