曲线救国:webpack打包优化黑科技

7,910 阅读7分钟

webpack打包遇到的痛点

随着我们项目越来越复杂,我们在用webpack打包的时候,会发现打包的速度越来越慢,最后慢到打包一次要几分钟甚至更多的时间,缓慢的打包速度严重影响效率,那么如何提高打包速度就成为了我们的痛点,一般大家都是用HappyPack、Dellplugin和UglifyJsPlugin(之前是ParallelUglifyPlugin,现在不维护合并到UglifyJsPlugin了)的parallel设为true来提高打包速度,但这样依旧无法解决我们项目庞大而导致打包速度的缓慢问题,实质上打包速度慢的根本原因是因为,每次打包都是要把所有的文件都打包一遍,所以如果我们想提高打包速度,那么我们可以只打包修改的或者新加的文件,本文基于此提供一个方案。

前言

我们使用webpack来打包的时候,会有一个或者多个入口文件,打包到对应的html中,而我们知道打包最耗时的就是对js进行压缩和混淆的UglifyJsPlugin插件,如果我们的项目庞大,入口文件过多,那么打包js的速度将严重缓慢,so我们可以通过一些手段来告诉webpack我们只想打包指定的入口文件,生成对应的html,通过“大事化小”的方式提高打包速度。

一般我们在写webpack的入口文件的时候,我们不会一个一个手动写上去,像这样

 entry: {
    index: './src/views/index/main.js',
    bar: './src/views/bar/main.js',
    ....
  },

这种方式在项目庞大的时候代码管理起来很麻烦,且要手动维护,所以我们会按照某种规则管理我们的文件,然后写一个方法来获取入口文件,比如:

// utils
// 获取入口文件
exports.getEntry = function () {
  let globPath = './src/views/**/index.js'
  return glob.sync(globPath)
    .reduce(function (entry, path) {
      let key = exports.getKey(path)
      entry[key] = path
      return entry
    }, {})
}
// 获取单个入口文件对应的key
exports.getKey = (path) => {
    let startIndex = path.indexOf('views') + 6
    let endIndex = 0
    if(path.indexOf('components') > -1){
        // 如果修改的是组件,注意这里各个页面的组件是放在各自的目录下的
        endIndex = path.indexOf('components') + 1
    } else {
        endIndex = path.lastIndexOf('/')
    }
    return path.substring(startIndex, endIndex)
}

// 获取所有入口文件对应的keys
exports.getKeys = (filesPath) => {
    let result = []
    for(let path of filesPath) {
        let key = export.getKey(path)
        if(result.indexOf(key) === -1) {
            result.push(key)
        }
    }
    return result
}

// 根据入口文件生成HtmlWebpackPlugins
exports.getHtmlWebpackPlugins = () => {
    let entyies = exports.getEntry()
    let keys = Object.keys(entry)
    let plugins = keys
      .map((key) => {
        // ejs模板,要和index.js在同个目录下
        let template = exports.getTemplate(entyies[key])
        let filename = `${__dirname}/dist/${key}.html`
        return new HtmlWebpackPlugin({
          filename,
          template: template,
          inject: true,
          minify: {
            removeComments: true,
            collapseWhitespace: true,
            removeAttributeQuotes: true
          },
          // chunks: globals.concat([key]),
          chunksSortMode: 'dependency',
          excludeChunks: keys.filter(e => e != key)
        })
    })
    return plugins
}

// 获取入口文件对应的模板,模板文件是index.html,本目录没有,会往上级目录找
exports.getTemplate = (path) => {
  path = path.subStr(0, path.lastIndexOf('/'))
  var path = glob.sync(path + '/index.html')
  if(path.length > 0) {
    return path[0]
  } else {
    //取上级目录下的模板文件路径
    if(path.lastIndexOf('/') !== -1) {
      path = path.substr(0, path.lastIndexOf('/'))
      return exports.getTemplate(path)
    }
  }
}

这里,我们的所有入口文件都以index.js为命名,且key为views下到对应index.js的文件路径,例如./src/views/test/index.js的key就是test。根据这个规则,它会自动获取src/view下的所有入口文件index.js,并生成入口文件对应的html。

那么如果我们修改与入口文件同个目录的所有代码,我们希望打包的时候就打包这个入口文件,未修改的其他入口文件统统不打包,这样就可以做到精确打包了,

所以我们约定,我们所有与入口文件相关的所有业务代码都放在入口文件相同的目录下,这样当我们修改了代码以后,我们就只打包修改的代码对应的入口文件。

怎么判断修改或者新建了文件

方法一

修改了哪些代码,用户自己最清楚,我们可以通过运行打包程序时告诉程序我们修改了哪些模块,我们可以使用inquirer来让用户手动输入,也可以通过命令行的方式输入,关于命令行的输入,现在npm命令可以接受参数的输入,在node我们只需要通过process.argv来获取用户输入的参数。

// npm命令通过--接受参数的输入
npm run build -- module
// node通过process.argv来获取
let module = process.argv[2]

这种方式的缺点就是需要用户输入,没有做到自动化。

方法二

我们知道git可以知道用户修改过哪些文件和新建了哪些文件,那么利用这点我们就可以知道哪些文件修改过,哪些文件是新增的,我们针对修改过和新增的文件进行打包,未改动的忽略,如此我们便可以做到针对性的打包,而避免了全量打包的漫长过程。
我们知道,当我们使用git status命令的时候,git会给我们这样的提示:

modified: xxx/xx/xx.js
modified: yyy/yy/yy.js

Untracked files:
  (use "git add <file>..." to include in what will be committed)

        xxx/xx/index.js
        yy/yy/index.js

so,我们可以通过写一些正则表达式,把这些修改的和新增的文件给匹配出来,然后针对性的进行一些处理从而得出是哪个入口文件的内容需要重新打包。下面基于方法二,讲下如何做。

第一步:使用shelljs模块获取git status打印出来的字符串

1.安装shelljs 
npm install shelljs --save-dev
2.获取git status打印出来的字符串
let shell = require('shelljs')
const result = shell.exec('git status')

第二步:匹配出修改的文件列表

// build.js
let modifiedFiles = []
// 匹配modified: 后面的修改的文件路径
match = result.match(/modified:\s+(.+)/g)
for(let i = 0, len = match.length; i < len; i++) {
    // 匹配views下修改的文件
    if(/src\/(views|components)/.test(match[i])) {
        let path = match[i].match(/\s+(.+)/)[1]
        modifiedFiles.push(path)  
    }
}

第三步:匹配出新增的文件列表

这里我以src/views目录下,入口文件以index.js为例

// build.js
// 获取新加的文件
let addFiles = []
// 获取新建的文件列表字符串
let r = /(?<=\(use "git add <file>\.\.\." to include in what will be committed\))((\n|\t|.)+)/.test(result)
// 获取新加文件路径
if(r) {
    let addFilesListStr = RegExp.$1
    // 匹配src/views下的文件
    match = addFilesListStr.match(/\n*\t+(src\/views\/.+)\n+/g)
    for(let i = 0, len = match.length; i < len; i++) {
        // 去掉回车换行
        let path = match[i].replace(/(\t|\n)/g, '')
        // 这里根据你的项目来定义,我这边的项目入口是index.js,
        // 所以这样设置,如果新增的文件没有index.js入口文件则下面的glob就匹配不出来
        let paths = glob.sync(`${path}/**/index.js`)
        for(let path of paths) {
            addFiles.push(path)
        }
    }
}

第四步:针对性打包/增量打包

有了第二步获取的修改文件的路径,经过一些处理,我们就可以知道哪些入口key修改了,然后打包的时候就只打包这些修改的key对应的入口文件

// utils.js
exports.getModifiedEntry = (modifiedFiles) => {
   let modifiedKeys = exports.getKeys(modifiedFiles)
   let modifiedEntry = {}
   // 全量entry
   let webpackEntry = exports.getEntry()
   for(let key of modifiedKeys) {
    modifiedEntry[key] = webpackEntry[key]
   }
   return modifiedEntry
}

获取新建文件的entry,通过git我们可以获取新加的文件列表,然后根据文件列表我们获取新加的entry,所以我们扩展getEntry方法,但传入参数为文件列表的时候,我们从新加的文件列表中获取新建的entry

/**
*
*files参数为第三步获取的新加文件列表
*/
// utils.js
exports.getEntry = function (files) {
  // 从新加的文件列表中获取新建的entry
  if (files) {
    let entry = {}
    for (let path of files) {
      let key = exports.getKey(path)
      entry[key] = './' + path
    }
    return entry
  }
  let globPath = './src/views/**/index.js'
  return glob.sync(globPath)
    .reduce(function (entry, path) {
      let key = exports.getKey(path)
      entry[key] = path
      return entry
    }, entry)
}

最后我们要根据修改文件列表和新加的文件列表生成HtmlWebpackPlugins以打包对应的html

// 根据入口配置获取对应的htmlWebpackPlugin
// utils.js
exports.getHtmlWebpackPlugins = (entry) => {
    let keys = Object.keys(entry)
    let plugins = keys
      .map((key) => {
        let template = exports.getTemplate(entry[key])
        let filename = `${__dirname}/dist/${key}.html`
        return new HtmlWebpackPlugin({
          filename,
          template: template,
          inject: true,
          minify: {
            removeComments: true,
            collapseWhitespace: true,
            removeAttributeQuotes: true
          },
          chunksSortMode: 'dependency'
        })
    })
    return plugins
}

// build.js
var utils = require('utils')
let newEntry = {}
Object.assign(newEntry, addEntry, modifiedEntry)
htmlWebpackPlugins = utils.getHtmlWebpackPlugins(newEntry)

其他问题

如果我们修改了一些全局的代码,比如各个组件依赖的js,css等等,这个时候需要进行全量打包了,那么候我们可以通过参数告诉程序我们要全量打包,参照方法一,通过npm run build -- all

// build.js
var utils = require('utils')
let isBuildAll = process.argv[2] === 'all'
if(isBuildAll) {
    // 全量打包
    let entry = utils.getEntry()
    let plugins = utils.getHtmlWebpackPlugins(entry)
    webpackConfig.plugins = webpackConfig.plugins
        .concat(plugins)
}

以上是我在开发中遇到打包十分缓慢的一种解决方案,简略的代码请查看我的git: github.com/VikiLee/acc…