Webpack 4 構建大型專案實踐 / 微前端

莫得鹽發表於2019-08-23

本文所用示例的倉庫地址: gayhub

改了名後想到可能會引起誤會,故提醒下注意把和路由懶載入模組懶載入區分開來,前者針對的是元件,而後者模組的含義趨於子系統。

通過上一節的優化,我們已經有了從零構建中小型單頁專案的能力,但如果專案模組足夠多,進一步優化將變得困難重重。所以即便 VUE 是一個單頁框架,你也可以在網上搜尋到大量多頁架構配置(當然這其中部分原因是業務要求),它在原理上和各個電商網站採用的模組獨立部署方式(購物車 cart.taobao.com 、訂單 buyertrade.taobao.com)類似,不同點在於開發時有同一套基礎環境以及部署時通常不會部署到單獨的子域名(判斷 url 轉發請求)。但我們前面就講到,多頁架構無法使用路由的 history 模式以及在開發時會遇到嚴重效能缺陷:

專案本身不能去踩一些無法優化的坑,已知兩坑:超多頁( html-Webpack-plugin 熱更新時更新所有頁面)和動態載入未指明明確路徑(打包目錄下所有頁面)—— Webpack 4 構建大型專案實踐 / 優化

這一節我們將一起來了解一種全(網)新的方案,把專案拆分為一個基礎模組和 N 個業務模組,基礎模組作為業務模組插槽(約定好模組對接介面),業務模組則獨立開發和更新,並且業務模組使用時為懶載入,在效能和上文提到的模組獨立部署方式相近,且有兩個優勢:

  1. 使用者體驗和單頁專案一致(本身就是單頁)。

  2. 負責不同模組的小組技術棧甚至程式碼風格是一致的,能更好應對緊急情況下的人員調動。

    雖然可能由不同小組負責不同業務模組,但技術選型、程式碼風格和打包都依賴於基礎模組,所以規範方面都是可以在基礎模組嚴格控制的。

本節例子在《 Webpack 4 構建大型專案實踐 / 優化》例子基礎上進行修改

需求假設

假設 D 專案為一個面向個人使用者的商城專案,功能複雜且效能要求很高,個人中心功能還需要提供完整原始碼給隔壁專案組。 然後團隊內展開討(Y)論(Y):

xcoder-a: 該專案功能需求不是很多,即使做成單頁也能完全能滿足效能需求。

xcoder-b: 這個“個人中心功能還需要提供完整原始碼”這個是嘛意思。

xleader : 他們希望能直接把我們的個人中心繫統嵌入到他們的單頁應用中,這點已經和他們討論過,url 跳轉的方式不符合他們預期,所以讓是我們把個人中心的原始碼提供給他們,他們在自己專案中部署個人中心,再把請求轉發到我們後臺。另外因為現在只是第一期,所以看到的需求不是很多,但後續肯定還會增加各種各樣的功能留住使用者以及刺激購物,比如積分兌換商品、消費等級銘牌什麼的,所以擴充套件性還是要考慮到。

xxxxx-PM: 我們希望做成“小淘寶”,我的意思是不一定要有淘寶的所有功能,但我們要把精髓的部分吸納到專案中。

xcoder-a: ...

xcoder-c: 他們不想通過 url 跳轉的方式,那就是說他們結構也想是單頁,兩個單頁專案之間想共用業務模組,我覺得不可能。

xcoder-b: 很玄幻!

xleader : 其實最初他們的提議我也拒絕了,但後面我們研究發現只要有合理的結構,共用業務模組也是能實現的。不過不是給他們原始碼,因為給原始碼涉及到依賴整理、程式碼更新等問題,所以我們是把打包後的完整模組給他們。

xcoder-a: 明白了,是指基礎工程和業務模組有統一的介面,就像樂高積木一樣,業務模組可以嵌入到基礎工程也可以取出來。

xleader : 對的,業務模組和基礎工程只要約定了介面,就可以完全獨立開發,業務模組的嵌入或者拔出不會對專案產生任何影響。

原理講解

我們想要實現的其實就是在程式執行初始狀態下只載入基礎模組,使用者使用某個功能時才動態把功能對於模組下載到瀏覽器,且為了模組在多個專案中共用,這些專案應該保留有一致的模組介面。玩過沙盒類遊戲的朋友可能更容易理解,當我們想玩某個非官方地圖時,我們就需要去額外裝該地圖的 Mod ,這個 Mod 就是這裡講的模組( module )。原理並不複雜,但我們可以發現普通的單頁專案的打包結構( vue-cli )有以下兩點無法實現:

  1. 業務模組無法獨立打包
  2. 基礎模組沒辦法在打包後載入其他獨立業務模組

<1> 是打包上需要解決的問題,<2> 是程式碼邏輯需要解決的問題(包括統一介面和處理載入邏輯)

問題 1 需要依據程式碼結構新增一個打包命令,且配置 libraryTarget 屬性把檔案打包稱一個庫(具體值為 umd amd 還是 commonjs 由你的模組記載方式決定),用於打包特定的模組以及模組依賴。打包後生成的庫檔案需要一個 xx.js 作為入口,也是載入模組時需要載入的檔案。

問題 2 則需要保證模組載入方式不被 Webpack 識別,因為一旦 Webpack 識別就會把程式碼打包到基礎工程,我們將採用 script 引入 requirejs 的方式來解決。這個問題其實困擾過我們一段時間,因為 Webpack 支援 ES6 、 AMD 和 CommonJS 模組標準,我們似乎沒辦法讓模組避免被打包,直到想通了在標準支援之前,還需要通過語法分析識別出這屬於什麼標準。舉個例子, requirejs 實現的是 AMD 標準,但 Webpack 只認識 require 函式,如果我們使用 requirejs 函式來載入模組,Webpack 只會把它當作尋常函式處理。

程式碼實現

程式碼調整主要分為兩步:業務模組獨立打包、基礎模組和業務模組對接,分別對應解決上文講的兩個問題。

業務模組獨立打包

  1. /build 新增 Webpack.mod.conf.js

     const Webpack = require('Webpack')
     const {
       CleanWebpackPlugin
     } = require('clean-Webpack-plugin')
     const TerserJSPlugin = require("terser-Webpack-plugin")
     const OptimizeCSSAssetsPlugin = require("optimize-css-assets-Webpack-plugin")
     const MiniCssExtractPlugin = require('mini-css-extract-plugin')
     const config = require('./config')
     const {
       resolve
     } = require('./utils')
    
     const generateModConfig = mod => {
    
       const WebpackConfig = {
         mode: 'production',
         devtool: config.production.sourceMap ?
           'cheap-module-source-map' : 'none',
         entry: resolve(`src/modules/${mod}/index.js`),
         output: {
           path: resolve(`modules/${mod}`),
           publicPath: `modules/${mod}`,
           filename: `${mod}.js`,
           chunkFilename: '[name].[contentHash:5].chunk.js',
           library: `_${mod}`,
           // 匯出 umd 模組 ,以便允許 AMD 和 CommonJS 模組庫使用,本文用到的 requirejs 就是實現 AMD 標準的一個庫
           libraryTarget: 'umd'
         },
         resolve: {
           alias: {
             '@': resolve('src'),
             '@mod-a': resolve('src/modules/mod-a'),
             '@mod-b': resolve('src/modules/mod-b')
           }
         },
         optimization: {
           minimizer: [
             new TerserJSPlugin({
               parallel: true // 開啟多執行緒壓縮
             }),
             new OptimizeCSSAssetsPlugin({})
           ],
           splitChunks: {
             chunks: 'all',
             minSize: 20000,
             maxSize: 0,
             minChunks: 1,
             maxAsyncRequests: 5,
             maxInitialRequests: 3,
             automaticNameDelimiter: '/',
             name: true,
             cacheGroups: {
               vendors: {
                 test: /[\\/]node_modules[\\/]/,
                 priority: -10
               },
               default: {
                 minChunks: 2,
                 priority: -20,
                 reuseExistingChunk: true
               }
             }
           }
         },
         plugins: [
           new CleanWebpackPlugin(),
           new MiniCssExtractPlugin({
             filename: 'css/[name].[contenthash:5].css',
             chunkFilename: 'css/[name].[contenthash:5].css'
           }),
           new Webpack.BannerPlugin({
             banner: `@auther 莫得鹽\n@version ${
               require('../package.json').version
               }\n@info hash:[hash], chunkhash:[chunkhash], name:[name], filebase:[filebase], query:[query], file:[file]`
           })
         ]
       }
    
       if (config.production.bundleAnalyzer) {
         const BundleAnalyzerPlugin = require('Webpack-bundle-analyzer')
           .BundleAnalyzerPlugin
         WebpackConfig.plugins.push(new BundleAnalyzerPlugin())
       }
    
       return WebpackConfig
     }
    
     module.exports = generateModConfig
    
    複製程式碼
  2. 修改 /build/build.js,加入 mod 模式

     const Webpack = require('Webpack')
     const chalk = require('chalk')
     const Spinner = require('cli-spinner').Spinner
     const {
       generateWebpackConfig,
       WebpackStatsPrint
     } = require('./utils')
    
     // 環境傳參
     const env = process.argv[2]
     // 生產環境
     const production = env === 'production'
     // 模組環境
     const mod = env === 'mod'
    
     if (production) {
       let config = generateWebpackConfig('production')
    
       let spinner = new Spinner('building: ')
       spinner.start()
    
       Webpack(config, (err, stats) => {
         if (err || stats.hasErrors()) {
           WebpackStatsPrint(stats)
    
           console.log(chalk.red('× Build failed with errors.\n'))
           process.exit()
         }
    
         WebpackStatsPrint(stats)
    
         spinner.stop()
    
         console.log('\n')
         console.log(chalk.cyan('√ Build complete.\n'))
         console.log(
           chalk.yellow(
             '  Built files are meant to be served over an HTTP server.\n' +
             '  Opening index.html over file:// won\'t work.\n'
           )
         )
       })
     } else if (mod) {
       const mods = process.argv.splice(3)
       mods.forEach(modName => {
         let config = generateWebpackConfig('mod', modName)
    
         let spinner = new Spinner(`${modName} building: `)
         spinner.start()
    
         Webpack(config, (err, stats) => {
           if (err || stats.hasErrors()) {
             WebpackStatsPrint(stats)
    
             console.log(chalk.red(${modName} build failed with errors.\n`))
             process.exit()
           }
    
           WebpackStatsPrint(stats)
    
           spinner.stop()
    
           console.log('\n')
           console.log(chalk.cyan(`√ ${modName} build complete.\n`))
           console.log(
             chalk.yellow(
               '  Module should be loaded by base project.\n'
             )
           )
         })
       })
     } else {
       module.exports = generateWebpackConfig('development')
     }
    
    複製程式碼
  3. 修改 /build/uitils.js 中的 generateWebpackConfig 函式

    /**
    * @description 根據不同環境生成不同 Webpack 配置檔案
    * @param {String} env 環境
    * @param {String} modName mod 名, mod 環境下特有屬性
    */
    const generateWebpackConfig = (env, modName = '') => {
      process.env.NODE_ENV = env
      console.log('modName:', modName)
      if (env === 'production') {
        return merge(require('./Webpack.base.conf'), require('./Webpack.prod.conf'))
      } else if (env === 'mod') {
        return merge(require('./Webpack.base.conf'), require('./Webpack.mod.conf')(modName))
      } else {
        return merge(require('./Webpack.base.conf'), require('./Webpack.dev.conf'))
      }
    }
    
    複製程式碼
  4. /package.json 中新增命令方便日常使用

    {
      "scripts": {
        "mod": "node build/build.js mod",
      }
    }
    複製程式碼

    通過 yarn mod {modNameA} {modNameB} {...} 呼叫命令, modNameAmodNameB 為需要打包的模組名

  5. 統一 API 模組只匯出 router 、 store 、 國際化等模組,在基礎模組使用它們時,基礎模組通過相應的熱載入方式把他們加入到當前專案中。這裡只展示模組標準匯出檔案(也就是打包入口)程式碼,其餘程式碼可到 github 中檢視。

    /src/modules/mod-a/index.js

    import router from './router/index.js'
    import store from './store/index.js'
    
    export default {
      router,
      store,
    }
    複製程式碼

然後我們執行 yarn mod mod-a 就可以在 /modules/mod-a 資料夾下找到模組 A 的打包產物,它有這樣的結構:

modules
  ├─ mod-a           # 模組 A
    ├─ mod-a.js      # 模組 A 標準出/入口
    ├─ function-a    # 功能 A
      ├─ page-a.js   # 功能 A 關聯頁面 A
      ├─ page-b.js   # 功能 A 關聯頁面 B
    ├─ function-b    # 功能 B
    ├─ ...
  ├─ mod-b           # 模組 B
  ├─ ...
複製程式碼

基礎模組和業務模組對接

要使用打包好的模組,有兩個核心點:

  1. 統一路由規則,在路由中存在當前未下載模組時需要下載模組,在頁面點選特定模組也可以作為模組下載依據。
  2. 載入模組後後通過 vue-router 的 addRoutes 函式動態新增路由,通過 vuex 的 registerModule 函式動態註冊 store 模組,如果某些模組中匯出內容對於的外掛未提供動態註冊方法,則需要自己 hack ,當然如果自己時間充足最好是給外掛提 PR 。

假設我們已經約定了路由規則,即如果匹配到 /mod/xxx 則這個路由屬於 xxx 模組,如果模組是初次載入則下載 xxx 模組,然後通過介面和模組內容動態註冊 router 和 store ,下面是處理約定路由邏輯的程式碼。 src/router/index.js

import Vue from 'vue'
import Router from 'vue-router'
import store from '@/store'
import {
  splitModName,    // 正則匹配分離模組名
  getModResources, // 呼叫介面獲取模組名對應的擁有許可權的路由
  generateRoutes   // 通過介面獲取的路由和載入模組中路由與元件的對映,生成 vue-router 須路由結構
} from '../utils/module'

Vue.use(Router)

const router = new Router({
  mode: 'history',
  routes: [{
    path: '/',
    name: 'index',
    component: () =>
      import( /* WebpackChunkName: "views/index" */ '@/views/index/main.vue')
  }]
})

// 記錄註冊過的模組
let registeredRouterRecord = []

/**
 * @description 檢查模組是否註冊
 * @param {String} modName 模組名
 */
const isModRegistered = modName => {
  return registeredRouterRecord.includes(modName)
}

/**
 * @description 註冊模組
 * @param {String} modName 模組名
 */
const regeisterMod = modName => {
  getModResources(modName).then(res => {
    console.log('res:', res)

    // generate routes
    generateRoutes(modName, res.router).then(appendRoutes => {
      console.log('appendRoutes:', appendRoutes)
      // register router
      router.addRoutes(appendRoutes)
    })

    // register store
    store.registerModule(modName, res.store)

    registeredRouterRecord.push(modName)
  })
}

router.beforeEach((to, from, next) => {
  console.log(to, from)
  let modName = splitModName(to.path)
  // 非基礎模組 + 模組未註冊 = 需要註冊模組
  if (modName && !isModRegistered(modName)) {
    regeisterMod(modName)
  }
  next()
})

export default router

複製程式碼

src/utils/module/index.js

/**
 * @description 模組載入相關函式
 * @author luwuer
 */

import {
  getRoutes
} from '@/utils/api/base'

/**
 * @description 分離模組名
 * @param {String} path 路由路徑
 */
const splitModName = path => {
  // 本例中路由規定為 /mod/{modName} ,如 /mod/a/xxx 對應模組名為 mod-a
  if (/\/mod\/(\w+)/.test(path)) {
    return 'mod-' + RegExp.$1
  }
  return ''
}

/**
 * @description 取得模組有許可權的路由 + 模組路由和元件對映關係 = 需要動態新增的路由
 * @param {String} modName 模組名
 */
const generateRoutes = (modName, routerMap) => {
  return getRoutes(modName).then(data => {
    return data.map(route => {
      route.component = routerMap[route.name]
      route.name = `${modName}-${route.name}`
      return route
    })
  })
}

/**
 * @description 獲取模組打包後的標準入口 JS 檔案
 * @param {String} modName
 */
const getModResources = modName => {
  if (process.env.NODE_ENV === 'development') {
    // 開發環境用 es6 模組載入方式,方便除錯
    return import(`@/modules/${modName}/index.js`).then(res => {
      return res
    })
  } else {
    return new Promise((resolve, reject) => {
      requirejs(['/modules/' + modName + '/' + modName + '.js'], mod => {
        resolve(mod)
      })
    })
  }
}

export {
  splitModName,
  generateRoutes,
  getModResources
}

複製程式碼

非核心點的程式碼調整在文章中並未提及,文章只是闡述一種架構思想,如果你有興趣建議去 github 檢視完整示例

該結構下,工程的完整打包流程為如下所示,其中 yarn dll 只有第一次打包時需要、 yarn mod xxxxxx 業務模組改變後才需要、 yarn base 在基礎模組改變後才需要。

yarn dll
yarn mod {modName1} {modName2} {...}
yarn base
複製程式碼

成果演示

用 nginx 在本地 80 埠部署這個測試專案,然後檢視專案在切換模組時的表現。

Webpack 4 構建大型專案實踐 / 微前端

附加

  • 2019/09/11 更新,我用本文所講方案重構了個人主頁,便於大家檢視實際效果
  • 2019/09/19 更新,看到每日優鮮供應鏈前端團隊微前端改造文章後才知道這種思想可以稱為微前端,故文章名由“模組懶載入”修正為“微前端”,這已經是第二次改名了,想告訴大家這種思想又不知道怎麼描述\捂臉

相關文章