Webpack 4 構建大型項目實踐 / 微前端

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

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

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

項目自己不能去踩一些沒法優化的坑,已知兩坑:超多頁( html-Webpack-plugin 熱更新時更新全部頁面)和動態加載未指明明確路徑(打包目錄下全部頁面)—— Webpack 4 構建大型項目實踐 / 優化前端

這一節咱們將一塊兒來了解一種全(網)新的方案,把項目拆分爲一個基礎模塊和 N 個業務模塊,基礎模塊做爲業務模塊插槽(約定好模塊對接接口),業務模塊則獨立開發和更新,而且業務模塊使用時爲懶加載,在性能和上文提到的模塊獨立部署方式相近,且有兩個優點:vue

  1. 用戶體驗和單頁項目一致(自己就是單頁)。java

  2. 負責不一樣模塊的小組技術棧甚至代碼風格是一致的,能更好應對緊急狀況下的人員調動。node

    雖然可能由不一樣小組負責不一樣業務模塊,但技術選型、代碼風格和打包都依賴於基礎模塊,因此規範方面都是能夠在基礎模塊嚴格控制的。webpack

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

需求假設

假設 D 項目爲一個面向我的用戶的商城項目,功能複雜且性能要求很高,我的中心功能還須要提供完整源碼給隔壁項目組。 而後團隊內展開討(Y)論(Y):git

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 端口部署這個測試項目,而後查看項目在切換模塊時的表現。

附加

  • 2019/09/11 更新,我用本文所講方案重構了我的主頁,便於你們查看實際效果
  • 2019/09/19 更新,看到每日優鮮供應鏈前端團隊微前端改造文章後才知道這種思想能夠稱爲微前端,故文章名由「模塊懶加載」修正爲「微前端」,這已是第二次更名了,想告訴你們這種思想又不知道怎麼描述\捂臉
相關文章
相關標籤/搜索