详解多页应用 Webpack4 配置优化与踩坑记录

2018-10-16 44 相学长

前言

最近新起了一个多页项目,之前都未使用 webpack4,于是准备上手实践一下。这篇文章主要就是一些配置介绍,对于正准备使用 webpack4 的同学,可以做一些参考。

webpack4 相比之前的 2 与 3,改变很大。最主要的一点是很多配置已经内置,使得 webpack 能“开箱即用”。当然这个开箱即用不可能满足所有情况,但是很多以往的配置,其实可以不用了。比如在之前,压缩混淆代码,需要增加uglify插件,作用域提升(scope hosting)需要增加ModuleConcatenationPlugin。而在 webpack4 中,只需要设置 mode 为 production即可。当然,如果再强行增加这些插件也不会报错。

所以我建议,如果大家想迁移到 webpack4,还是从 0 开始做加法,参考历史,重新做一个配置。而不是从历史的配置里删删减减,再升级为 webpack4。这样 webpack4 的配置会显得更精简。

打包优化

打包优化主要就是多页应用构建时,对所有页面加载的依赖进行合理打包。这个目前业界都已经有了很多实践,包括 webpack4,也有很多文章介绍。我再补充几个不容易注意的小细节。有些点我不详细介绍,不熟悉 webpack 配置的同学可能会不明白,可以搜索对应关键词,网上肯定有非常详细的文章介绍。

首先,构建多页应用,往往会抽离如下几个 chunk 包:

  • common:将被多个页面同时引用的依赖包打到一个 common chunk 中。网上大部分教程是被引入两次即打入 common。我建议可以根据自己页面数量来调整,在我的工程中,我设置引入次数超过页面数量的 1/3 时,才会打入 common 包。
  • dll: 将每个页面都会引用的且基本不会改变的依赖包,如 react/react-dom 等再抽离出来,不让其他模块的变化污染 dll 库的 hash 缓存。
  • manifest: webpack 运行时(runtime)代码。每当依赖包变化,webpack 的运行时代码也会发生变化,如若不将这部分抽离开来,增加了 common 包 hash 值变化的可能性。
  • 页面入口文件对应的page.js

然后我们会给打出的 chunk 包名,注入 contentHash,以实现最大缓存效果。在我们分 chunk 的过程中,最关键的一个思想就是,每次迭代发布,尽量减少 chunk hash 值的改变。这个在业界也有很多非常多的实践,比如这篇文章:https://github.com/pigcan/blog/issues/9

不过在 webpack4 中,我们不用再增加这么多插件啦,一个 optimization 配置完全就能搞定。

我先贴上我的 webpack 的 optimization 配置,然后我再对其做一些介绍,加深大家印象

const commonOptions = {
 chunks: 'all',
 reuseExistingChunk: true
}

export default {
 namedChunks: true,
 moduleIds: 'hashed',
 runtimeChunk: {
  name: 'manifest'
 },
 splitChunks: {
  maxInitialRequests: 5,
  cacheGroups: {
   polyfill: {
    test: /[\\/]node_modules[\\/](core-js|raf|@babel|babel)[\\/]/,
    name: 'polyfill',
    priority: 2,
    ...commonOptions
   },
   dll: {
    test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
    name: 'dll',
    priority: 1,
    ...commonOptions
   },
   commons: {
    name: 'commons',
    minChunks: Math.ceil(pages.length / 3), // 至少被1/3页面的引入才打入common包
    ...commonOptions
   }
  }
 }
}

runtimeChunk

在 webpack4 之前,抽离 manifest,需要使用 CommonsChunkPlugin,配置一个指定 name 属性为'manifest'的 chunk。在 webpack4 中,无需手动引入插件,配置 runtimeChunk 即可。

splitChunks

这个配置能让我们以一定规则抽离想要的包,我们可能会抽好几个包,如 verdor + common,所以 splitChunks 中提供 cacheGroups 字段,cacheGroups 每增加一个 key,就相当于多一个抽包规则。

在网上很多教程中,dll 往往是专门再加一个 webpack 配置,使用 DllPlugin 来构建 dll 库,再在自己项目工程的 webpack 中利用 DllReferencePlugin 来映射 dll 库。虽然这样构建速度会快不少,但是,哎,是真 TM 烦.....

我是一个很怕烦的人,我情愿在 webpack4 中利用 splitChunks,配好规则,再抽离对应的 dll 包。当然这个大家可以自己根据实际情况选择方案。

除了 dll 与 common 两个 chunk,我还加了一个 polyfill。这是因为我们用的某些新的库或者使用某些 ES6+语法(如 async/await)需要 runtime 垫片。比如我工程中使用了 react16,需要增加Map/Set/requestAnimationFrame (https://reactjs.org/docs/javascript-environment-requirements.html)那我必须在 dll 库加载之前增加 polyfill,因此我将所有 core-js 与 babel 引入的包专门打进 polyfill,保证后续加载的 chunk 能执行。priority字段用来配置 chunk 的引入优先级,一般的项目应该都是 polyfill > dll > common > page。

splitChunks 中配置项maxInitialRequests表示在一个入口(entry)中,最大初始请求 chunk 数(不包含按需加载的,即 dom 中 script 引入的 chunk),默认值是 3。我现在 cacheGroups 中已经有三个,又因为配置了 runtimeChunk,会打出 manifest,故而总共有 4 个 chunk 包,超出了默认 3 个,因此需要重新配置值。

moduleIds

稍微了解过 webpack 运行机制的同学会知道,项目工程中加载的 module,webpack 会为其分配一个 moduleId,映射对应的模块。这样产生的问题是一旦工程中模块有增删或者顺序变化,moduleId 就会发生变化,进而可能影响所有 chunk 的 content hash 值。只是因为 moduleId 变化就导致缓存失效,这肯定不是我们想要的结果。

在 webpack4 以前,通过 HashedModuleIdsPlugin 插件,我们可以将模块的路径映射成 hash 值,来替代 moduleId,因为模块路径是基本不变的,故而 hash 值也基本不变。

但在 webpack4 中,只需要optimization的配置项中设置 moduleIds 为 hashed 即可。

namedChunks

除了 moduleId,我们知道分离出的 chunk 也有其 chunkId。同样的,chunkId 也有因其 chunkId 发生变化而导致缓存失效的问题。由于manifest与打出的 chunk 包中有chunkId相关数据,所以一旦如“增删页面”这样的操作导致 chunkId 发生变化,可能会影响很多的 chunk 缓存失效。

在 webpack4 以前,通过增加NamedChunksPlugin,使用 chunkName 来替换 chunkId,实现固化 chunkId,保持缓存的能力。在 webpack4 中,只需在optimization的配置项中设置 namedChunks 为 true 即可。

css 相关

在 webpack4 以前,使用 extract-text-webpack-plugin 插件将 css 从 js 包中分离出来单独打包。在 webpack 中则需要换成 MiniCssExtractPlugin。并且在生产环境或者需要 HMR(模块热替换)时,要用 MiniCssExtractPlugin.loader 替换 style-loader。

注意,这里有个坑。由于开发环境我们会配置热更新,css 的热更新目前MiniCssExtractPlugin.loader自身还待支持,故而还需要增加 css-hot-loader。 切记,css-hot-loader一定不能在生产环境下使用。否则每次构建过程所有 js chunk 包的 contentHash 值都会不一致,进而导致所有 js 缓存失效。 因为生产环境增加这个配置不会有任何报错,页面也能正常构建,故而容易忽视。

简化多页应用的入口文件

使用react/vue等框架的同学知道,我们一般需要一个入口index.js,如这样:

import React from 'react'
import ReactDOM from 'react-dom'
import App from './app'

ReactDOM.render(<App />, document.getElementById('root'))

如果你还需要使用dva,或者给所有 react 页面增加一个 layout 功能的话,可能就会变成这样:

import React from 'react'
import dva from 'dva'
import Model from './model'
import Layout from '~@/layout'
import App from './app'

const app = dva()
app.router(() => (
 <Layout>
  <App />
 </Layout>
))
app.model(Model)
app.start(document.getElementById('root'))

如果每个页面都这样,略略有点儿难受,因为程序员最怕写重复的东西了。但是它又必须要有,没办法抽离成一个单独文件。因为这个是入口文件,而多页工程,每个页面必须要有自己的入口文件,即使他们长得一模一样。于是,我们的资源目录就会是这样:

- src
 - layout.js
 - pages
  - pageA
   - index.js
   - app.js
   - model.js
  - pageB
   - index.js
   - app.js
   - model.js

因为所有的 index 都一样,我理想中的页面的入口文件仅仅需要app.js就好,像这样:

- src
 - layout.js
 - pages
  - pageA
   - app.js
   - model.js
  - pageB
   - app.js
   - model.js

作为一名前端开发工程师,Node 对于我们来说,应该是熟练运用的工具,而不是仅仅拿别人已经封装好的各类工具。

在这个问题中,我们大可以在 webpack 构建前,通过Node的文件系统(File System),对应我们的每个页面,通过同一个入口文件模板,创建一些临时入口文件:

- src
 - .entires
  - pageA.js
  - pageB.js
 - layout.js
 - pages

然后将这些临时文件,作为 webpack 的 entry 配置。代码如下:

const path = require('path')
const fs = require('fs')
const glob = require('glob')
const rimraf = require('rimraf')
const entriesDir = path.resolve(process.cwd(), './src/.entries')
const srcDir = path.resolve(process.cwd(), './src')

// 返回webpack entry配置
module.exports = function() {
 if (fs.existsSync(entriesDir)) {
  rimraf.sync(entriesDir)
 }
 fs.mkdirSync(entriesDir)
 return buildEntries(srcDir)
}

function buildEntries(srcDir) {
 return getPages(srcDir).reduce((acc, current) => {
  acc[current.pageName] = buildEntry(current)
  return acc
 }, {})
}
// 获取页面数据,只考虑一级目录
function getPages(srcDir) {
 const pagesDir = `${srcDir}/pages`
 const pages = glob.sync(`${pagesDir}/**/app.js`)
 return pages.map(pagePath => {
  return {
   pageName: path.relative(pagesDir, p).replace('/app.js', ''), // 取出page文件夹名
   pagePath: pagePath
  }
 })
}
// 构建临时入口文件
function buildEntry({ pageName, pagePath }) {
 const fileContent = buildFileContent(pagePath)
 const entryPath = `${entriesDir}/${pageName}.js`
 fs.writeFileSync(entryPath, fileContent)
 return entryPath
}
// 替换模板中的 App 模块地址,返回临时入口文件内容
function buildFileContent(pagePath) {
 return `
  import React from 'react'
  import dva from 'dva'
  import Model from './model'
  import Layout from '~@/layout'
  import App from 'PAGE_APP_PATH'

  const app = dva()
  app.router(() => (
   <Layout>
    <App />
   </Layout>
  ))
  app.model(Model)
  app.start(document.getElementById('root'))
 `.replace(PAGE_APP_PATH, pagePath)
}

这样一来,我们就简单的去掉了重复的入口文件,还增加了一个 layout 的功能。这只是简单的代码,实际项目可能还有多级目录,多个 model 等等,需要自己再定制啦。

webpack4出来已经挺久了,文章写的有点儿滞后了,所以很多我觉得应该大家都明白的地方就没详细写了。如果还有什么疑问的话,欢迎评论~~

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

展开阅读全文

更多Javascript文章

瀑布流布局并自动加载实现代码
Mar 12 34
有关于JS构造函数的重载和工厂方法
Apr 07 42
EXTjs4.0的store的findRecord的BUG演示代码
Jun 08 36
把多个JavaScript函数绑定到onload事件处理函数上的方法
Sep 04 44
vue环境搭建简单教程
Nov 07 54
关于layui表单中按钮自动提交的解决方法
Sep 09 43
Vue页面切换和a链接的本质区别详解
Nov 12 52
手机访问当前页面