一文搞懂webpack打包优化方案

webpackhot.jpg

写在前面

在现在前端工程化的大背景下,webpack成为了最常用的打包工具之一,有一社区或者优秀团队,也都以Webpack为基础构建自己的脚手架,比如我们所熟知对的vue-cli,umijs等,通常情况下,这些脚手架多多少少会为我们配置好一些关于打包优化的东西,如果你的项目并不复杂,可能很长一段时间你都无法感知打包优化的重要性,如果忽然遇到了打包优化的问题,可能太过让人措手不及,不管你使用的是社区优秀的脚手架,还是自己基于webpack搭建的项目或者脚手架,搞懂webpack打包优化,会让我拥有解决更多高级问题的能力,也会让你的项目更加“丝滑”。

webpack打包优化

打包优化主要从两个方面下手

  • 打包速度,优化打包速度,主要是提升了我们的开发效率,更快的打包构建过程,将让你保持一颗愉悦的心
  • 打包大小,优化打包体积,主要是提升产品的使用体验,降低服务器资源成本,更快的页面加载,将让产品显得更加“丝滑”,同时也可以让打包更快
打包速度优化

当我们做一些较大型项目的打包时,经常会遇到,打包时间过长的
问题,让人焦急不已,那么我们就要采用一些手段来提升webpack的打包。

跟上技术的迭代(webapck,Node, Npm)

如果想要提升打包的速度,将打包技术生态中涉及的技术版本更新将是一个最简单的方式,那么为什么更新版本会提升打包速度呢?
Webpack的每次更新,必然会更新底层的一些打包原理和api来提升打包速度,更新Webpack版本将有助于提升打包速度,同事,webpack又是运行在Node环境下,如果Node版本提升,其运行效率也会提升,那么webpack运行在node之上也会有所提升的,同样,我们使用更新的Npm或者Yarn的包管理工具的话,新的包管理工具会更快的帮我们分析一下包的依赖或者包的引入,这样也会间接的提升webpack的打包速度。

在尽可能少的模块上使用Loader
{ 
    test: /\.js$/,
    loader: 'babel-loader',
}

看上面的代码,是我们在配置bable-loader时的代码,如果这样配置的话,那么整个项目的js文件,都会做babel-loader的转译,但实际上,node_modules中的包都是帮我们转译过的,重复的转译,势必会降低webapck的打包速度,这时候我们就要通过设置babel-loader的作用范围来提升打包速度。

{ 
    test: /\.js$/,
    exclude: /node_modules/,
    loader: 'babel-loader',
}

通过上面的配置,我们就不用再对node_modules中js文件做转译了,当然了,除了exclude选项排除某个范围,我们还可以通过include选项去指定某个范围,比如上面的代码也可以改成

{ 
    test: /\.js$/,
    include: path.resolve(__dirname, '../src'),
    loader: 'babel-loader',
}

所以,我们可以通过合理的使用exclude或者include这样的配置项,去指定某一个loader的执行范围,从而降低了loader的执行频率,loader的编译过程被少量的执行了,那么webpack的打包速度自然也会得到提升。
不光babel-loader,其他loader也是可以通过具体的项目分析,做这样的配置的。

将babel编译过的文件缓存起来

babel-loader为我们提供了cacheDirectory参数,可以参考官网对其做相应配置

Plugin尽可能精简并且可靠

我们应该尽可能少的使用Plugin,并且还要保证其可靠性,举个栗子。
我们在生产环境下的打包一般会需要通过MiniCssExtractPluginOptimizeCSSAssetsPlugin两个插件来做样式代码的分离或者压缩,这也是十分必要的,当然,如果你在本地环境下使用了CSS代码的分离压缩,不但没有必要(因为本地代码只有自己看,也不去在意其是否压缩),反而会降低打包的效率,因为Webpack插件是基于webpack打包过程事件流的,没一个插件的执行,都会消耗性能,降低效率,所以,如果非必要,就不要去使用一些插件了,如果你很有必要去使用某个插件,那么最好是使用Webapck官网提供的插件,因为官方的插件是经过一些专门的性能测试的,相对于第三方的插件来说,性能会高一些,而第三方的插件,很有可能性能得不到保证,降低你的打包速度,所以,在使用一个插件之前,一定要做好选择哦!

resolve参数合理配置
  1. extensions

resolve参数是一个webpack配置项,我们先开介绍一下这个配置项的使用,比如现在有下面的文件目录

|--src
  |--index.js
  |--child.jsx

我们想要在index.js中使用child.jsx可以这样使用

import Child from './child.jsx'

但是我们可以通过配置resolve选项,来达到下面这样的引用方式

import Child from './child'

如下:

module.exports = {
    resolve: {
        extensions: ['.js', '.jsx']
    },
}

上面的意思是,我们遇到'./child'这样的字段后,会去当前目录下查找'js'后缀的文件,没有找到再去查找'jsx'后缀的文件,这样我们就可以省去在引用的过程中写前缀了,但是,有些同学可能会不合理的配置resolve,比如

module.exports = {
    resolve: {
        extensions: ['css','jpg','.js', '.jsx']
    },
}

如果像上面这样配置,那么在你引入一个文件的时候,就会按照上面的裂变挨个的去查找,实际上,这样是有性能损耗的
所以,一般情况下,我们只有遇到js或者jsx或者vue等等这样逻辑型文件的时候才去配置到resolve中,像css这样的文件就不去配置了这样,不但开发起来方便一些,同事性能上也会得到一些平衡。

  1. mainFiles

在平时开发中大家一定也遇到过这样的引用

import Child from './components/'

这时候,会自动找到'components'文件夹下的'index.js'文件,假如我们现在的文件目录如下

|--src
  |--components
    |--child.jsx
  |--index.jsx

我们在index.jsx中想要通过

import Child from './components/'

上面这种引用方式引入'components'下的‘child.jsx’文件,那么我们可以做下面这样的配置

module.exports = {
  resolve: {
      extensions: ['.js', '.jsx'],
      +++  mainFiles: ['index', 'child']
  },
}

这样,我们在引用一个文件夹时,他就会默认去找下面的index.js找不到再去找child.js了。
但是,这样又会带来性能问题,通过上面的配置后,每次我们引入一个路径的话,都会去做一遍文件的匹配,所以我们要根据自己的需要,平衡好性能和开发方便后再做相应的配置,一般来说,我们不需要配置这个项

  1. alias

在一些社区脚手架中,我们还会见到下面这样的引用方式

import Child from '@/component/'

其配置如下

module.exports = {
    resolve: {
        extensions: ['.js', '.jsx'],
        +++ alias: {
            '@': path.resolve(__dirname, '../src')
        }
    },
}

意思是,我们用‘@’代替了根目录下的src目录,这样你会在开发的时候提升一些开发效率。同样,他也会带来一些性能上的问题,所以,大家依然需要平衡好开发效率和打包效率,有针对性的去使用
通过上面,举了三个栗子,说明了resolve配置项对于开发效率的提升帮助,同事他也具有一点的性能问题,大家在使用的过程中,要在做好平衡,按照需要去做相应的配置。

使用DllPlugin提高打包速度

我先对我手上一个简单的项目做个打包,记录下打包时间如下

buildtime99.png

基本时间稳定在1500ms,我们暂认定当前情况下的打包速度为1300ms,我的代码现在是这样的

import React from 'react'
import ReactDom from 'react-dom'
import _ from 'lodash'

const App = () => {
    return (
        <div>
            <div>{_.join(['hello','world'], ' ')}</div>
        </div>
    )
}
ReactDom.render(<App/>, document.getElementById('root'))

其中像react,react-dom,lodash这样的库,是基本不会改变的,但是现在,我们每一次打包都要对其进行分析,都要消耗一定的时间,于是我们就想,可以把第三方库单独打包为一个文件,只在第一次打包的时候做分析,后面就使用第一次打包的结果这样就可以提高打包速度了,我们以这个为思路,展开这次的优化。

  1. 配置第三方库单独打包

我们再创建一个webpack.dll.js的配置文件,内容如下

const path = require('path')
module.exports = {
    mode: 'production',
    entry: {
        vendors: ['react', 'react-dom', 'lodash']
    },
    output: {
        filename: '[name].dll.js',
        path: path.resolve(__dirname, '../dll'),
        library: '[name]'
    }
}

上面的意思是,我们将几个第三方库做单独的打包,并以Library的形式导出,这时候会在根目录下生成一个'dll'的文件。我们期望将该文件在最终生成的index.html中以全局变量的形式引入。所以还需要在原有的打包配置中,配置一个插件,来动态的引入我们生成的第三方库,因为现在的第三方库是以Library的形式存在于项目中,并以一个‘vendors’变量全局暴露。这样我们就可以以全局变量的形式访问第三方库

  1. 配置add-asset-html-webpack-plugin

我们安装这个webapck插件,并配置如下

module.exports = {
  plugins: [
        new AddAssetHtmlWebpackPlugin({
            filepath: path.resolve(__dirname, '../dll/vendors.dll.js')
        })
  ]
}

意思是我们通过上面这个插件,就可以为生成的index.html引入我们单独打包的第三方库,配置成功后,启动项目你会发现源码中已经引入‘vendors.dll.js’了。

html.png

并且在也可以全局访问一个‘vendors’变量(因为我们是以Library的形式打包,并暴露出一个vendors变量)
到这里,我们实现了一个第三方模块只打包一次的目标,但是现在还不能满足我们最初的,‘第三方模块只打包一次,且以后每次都使用’的目标,现在我们的项目中,其中还是使用的'node_modules'里面的内容,那么怎么才能让业务代码使用我们处理过的第三方模块呢?

  1. 使用Dllplugin做分析

我们使用Dllplugin生成一个映射,操作如下
对webpack.dll.js做下修改

const path = require('path')
const webpack = require('webpack')
module.exports = {
    mode: 'production',
    entry: {
        vendors: ['react', 'react-dom', 'lodash']
    },
    output: {
        filename: '[name].dll.js',
        path: path.resolve(__dirname, '../dll'),
        library: '[name]'
    },
    plugins: [
        new webpack.DllPlugin({
            name: '[name]',
            path: path.resolve(__dirname, '../dll/[name].manifest.json')
        })
    ]
}

我们配置一个Dllplugin插件,需要注意的是DllPlugin中的name属性,一定要个output中的library属性一致,意思是,我们要对生成的library做一个分析分析的结果放到dll下的‘vendors.manifest.json’中。这时候再运行dll打包,就会看到这个'vendors.manifest.json'文件了。
到这里我们想利用上面生成的全局变量,和现在生成的映射文件,我们是否可以实现在业务代码中,如果发现引用的模块是来自我们处理过的第三方模块,就使用我们已经打包过的包,反之才从node_modules中取

  1. 配置DllReferencePlugin

要想实现上面的设想,我们还需要在打包配置文件中,做DllReferencePlugin插件的配置

module.exports = {
  plugins: [
        new webpack.DllReferencePlugin({
            manifest: path.resolve(__dirname, '../dll/vendors.manifest.json')
        })
   ]
}

做了上面的配置后,我们打包时的原理变成了这样:在打包时,当遇到第三方模块,他会去到映射文件中去找是否存在于我们单独打包的第三方库中,如果存在,就从上面操作中暴露的全局变量中取,如果不存在,才从node_moudules中取,这时候,我们做一下打包时间对比


dllplugin.png

时间变成了900多毫秒,可以把上面的配置注释掉,再去看一下打包时间


nodll.png

时间又变成了1400多毫秒,由此可见,使用DllPlugin对于性能的提升还是很明显的。
这个配置项讲的有点绕,下面针对这个插件的配置,我们做个小总结

  • 通过dll配置文件单独将第三方库打包为一个library形式,暴露一个全局变量出来
  • 通过DllPlugin插件,对打包文件做一个分析,生成一个映射文件
  • 在项目打包配置文件中,配置AddAssetHtmlWebpackPluginDllReferencePlugin,将映射关系引入进index.html中

主要操作就是上面的三点了。下面我再对这个插件做一点扩展,上面我们是把三个第三方模块都打包到了,其实我们可以分开打包

module.exports = {
    entry: {
        lodash: ['lodash'],
        react: ['react', 'react-dom']
    },
}

分开后,自然生成的library文件不一样了,映射文件也不一样了,所以我们还得再业务打包文件中做出更改

module.exports = {
  plugins: [
        new AddAssetHtmlWebpackPlugin({
            filepath: path.resolve(__dirname, '../dll/lodash.dll.js')
        }),
        new AddAssetHtmlWebpackPlugin({
            filepath: path.resolve(__dirname, '../dll/react.dll.js')
        }),
        new webpack.DllReferencePlugin({
            manifest: path.resolve(__dirname, '../dll/lodash.manifest.json')
        }),
        new webpack.DllReferencePlugin({
            manifest: path.resolve(__dirname, '../dll/react.manifest.json')
        })
  ]
}

大家一定也发现了,其实这样的配置看起来是很臃肿的,于是我们可以这样修改我们的配置

const plugins = [ // 定义一个数组,将基础的插件写入
    new HtmlWebpackPlugin({
        template: 'src/index.html'
    }),
    new CleanWebpackPlugin(),
    new webpack.ProvidePlugin({
        $: 'jquery'
    })
]
// 利用NodeJs文件模块,分析dll文件夹下的文件,并动态插入
const files = fs.readdirSync(path.resolve(__dirname, '../dll'))
console.log(files) // 可以在这里查看结果感受一下
files.forEach(file => {
    if(/.*\.dll.js/.test(file)) {
        plugins.push(
            new AddAssetHtmlWebpackPlugin({
                filepath: path.resolve(__dirname, '../dll', file)
            })            
        )
    }
    if(/.*\.manifest.json/.test(file)) {
        plugins.push(
            new webpack.DllReferencePlugin({
                manifest: path.resolve(__dirname, '../dll', file)
            })           
        )
    }
})

这样我们就不用手写的,如果你的dll于变动,只需要重新打包dll即可,不用再手动修改插件了。

thread-loader和happypack

因为受限于Node的单线程运行,所以webpack的打包也是单线程的,使用HappyPack可以将Loader的同步执行转为并行,从而执行Loader时的编译等待时间
同时也可以使用webpack4官网提供的thread-loader来对有些耗时的loader做相应的处理,这里我将不再带大家熟悉其API,可以到对应的官网去参照其使用方法。

合理使用Source Map

Source Map为我们打包后的代码和源码提供了一种个映射关系,但是Source Map也会造成一些性能的问题,为了同时兼顾打包性能和开发调试方便,请使用合理的Source Map配置,这里可以参考我之前关于Source Map的讲解SourceMap配置

开发环境内存编译

我们知道,我们在本地的项目中,一般使用dev Server在本地起一个服务,而使用dev Server是不需要将dist文件打包进硬盘的,而是打包进内存里,从内存里读取文件的速度肯定是比硬盘快的多的,因为平时大家有意无意的已经这么实践,这里还是要提一下,知道其中的优化点

开发环境无用插件剔除

有些Webpack插件是针对于线上打包模式的,比如代码压缩,比如CSS分离压缩等,但是如果你在本地环境使用了这样的插件,将降低你的打包速度,同时有些插件在本地模式下使用,也是没有意义的,比如代码压缩。

降低打包体积

降低打包体积,不仅可以让打包后的项目运行更快,还可以对打包速度有所提升。我在下面将做详细的介绍

上面为大家介绍了几种提升打包速度的方法,用来优化我们本地开发的效率,其中将到的DllPlugin也是内容比较多,需要主要的是,这个插件仅在开发环境下生效,并且在开发中,随着后续weebpack版本的更新,可能会引入一些缓存机制,到时候DllPlugin就不再使用了,这里我们大篇幅介绍他,希望大家能认识到并熟悉这种方式,用不用看大家


打包大小优化

上面提到,打包大小的优化主要对于产品的体验有很大的提升,那么我们有哪些手段可以控制打包的大小,从而让产品运行很流畅呢?

tree shaking

我们知道,webapck4默认在production模式下开启tree Shaking,用来删除调那些无效的引入,从而减小打包代码的体积,当然你也可以尝试在本地模式下配置,不过没啥太实际的作用,具体可参考我之前关于Tree Shaking的讲解文章。

代码压缩

webpack4在production模式下默认开启代码压缩。这一点大家要知道

代码分割

我们可以使用代码分割,将固定不变的一些代码如node_moudles中的代码单独打包,从而降低main.js的大小,利用浏览器的缓存机制,提高首屏加载的速度。具体的代码,可以看我之前关于Split code的讲解文章文章一文章二

按需加载

按需加载,也是个比较大的概念了,我举几个常见的按需加载场景。

  1. polyfill按需加载

我们知道,polyfill实际是一种webpack shaming方案,如果我们不做处理,将是全量的引入所有的转译语法,但实际项目中,我们不一定都用的到,这时候需要做一下按需加载的配置,可以配置@babel/preset-envuseBuiltIns:usage,具体的内容可以参考我之前关于babel的文章讲解babel

  1. UI组件库的按需加载

现在社区的大部分组件都是支持按需加载配置,或者tree shaking的,这样我们就不需要将整个UI库引入了,因为你可能项目中用不到所有的,具体我们可以参考babel-plugin-import的使用方法,或者组件库推荐的按需加载方案

  1. 路由按需加载

路由的按需加载也叫路由懒加载,也就是,只有当我们访问到该页面时,才加载该页面的资源,这个方案其实不影响打包大小,算是一种代码分割的方案,我们通过异步的加载路由下对应的组件资源,利用代码分割单独打包。这里可以自己去看一下,不同的框架对应的路由懒加载方案

写在后面

本文用很大的篇幅介绍了Webapck的性能优化,尽量避免知识点过散,不利于总结,其实,关于webpack的打包优化方案,还有好多,甚至到webpack5的时候,webpack的打包性能又会优化不少,像上面提到的DllPlugiin可能将不再使用,随着技术的更新,Webpack优化的手段也将越来越丰富,大家可以根据自己的需要去拓展更多的优化手段。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345