Webpack 4 构建大型项目实践 / 微前端

3,252 阅读8分钟

本文所用示例的仓库地址: 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 端口部署这个测试项目,然后查看项目在切换模块时的表现。

附加

  • 2019/09/11 更新,我用本文所讲方案重构了个人主页,便于大家查看实际效果
  • 2019/09/19 更新,看到每日优鲜供应链前端团队微前端改造文章后才知道这种思想可以称为微前端,故文章名由“模块懒加载”修正为“微前端”,这已经是第二次改名了,想告诉大家这种思想又不知道怎么描述\捂脸