Webpack

690 阅读8分钟

本文内容来自三十分钟掌握Webpack性能优化

image.png

什么是webpack

它是一个模块打包器,它分析各个模块的依赖关系,最终打包成我们常见的静态文件,.js、.css、.jpg、.png等文件。

webpack相关知识点

  • HMR

  • resolve

  • optimization

  • dll

  • webpack5

  • production

  • development

  • eslint

  • babel

  • pwa

  • loader

  • plugin

  • devtool

  • 性能优化

  • tree shaking

  • code split

  • caching

  • lazy loading

  • library

  • shimming

webpack基本原理

第一步:

  • webpack分析依赖是从一个入口文件 entry 开始分析。

  • 从entry出发,webpack就会通过这个文件的路径读取entry文件的信息(读取到的本质其实是字符串),包含entry文件和它所依赖的所有依赖dependencies模块。

  • webpack会把读取到entry相关的信息转成AST(抽象语法树)。

第二步:

  • 通过广度优先遍历dependencies数组,全部转成AST,同时通过mapping对应依赖项和该项的id。

  • 最后生成一个依赖图谱,所有模块的依赖分别完成。

第三步:

  • 现在已经能拿到依赖图谱,我们再通过调用bundle函数,其实bundle函数就是返回我们构造的字符串,拿到字符串,我们把字符串导出成bundle.js。

webpack性能优化

1. 构建速度优化

  • resolve:可以加快文件读取速度,因为会影响tree-shaking,所有对整体性比较强的库设置resolve,如react.min.js。
resolve: {
    // 默认值为node_modules,直接到node_modules里找依赖包,避免层层查找。
    modules: [path.resolve(__dirname, 'node_modules')],
    // 设置尽量少的值可以减少入口文件的搜索步骤
    mainFields: ['main'],
    // 使webpack直接使用库的min文件,避免库内解析
    alias: {
        'react': path.resolve(__dirname, './node_modules/react/dist/react.min.js'),
    }
}
  • noParse:字段告诉Webpack不必解析哪些文件,可以用来排除对非模块化库文件的解析。
module: {
    noParse:[/jquery|chartjs/, /react\.min\.js$/],
}
  • 配置loader时,通过test、exclude、include缩小搜索范围

    匹配.js文件的时候,需要加 babel-loader,用来转ES6的代码。exclude用来排除打包某些文件中的代码。

 module: {
    rules: [
        {
            test: /\.js$/,
            loader: 'babel-loader',
            exclude: /node_modules/,
        }
    ]
},
  • 使用DllPlugin减少基础模块编译次数,原理是把依赖的基础模块抽离出来打包到dll文件中,如react、react-dom,只要这些模块版本不升级,就只需被编译一次。

    1. 使用DllPlugin配置一个webpack.dll.config.js来构建dll文件
    // webpack.dll.config.js
    const path = require('path');
    const DllPlugin = require('webpack/lib/DllPlugin');
    module.exports = {
       entry:{
           react:['react','react-dom'],
           polyfill:['core-js/fn/promise','whatwg-fetch']
       },
       output:{
           filename:'[name].dll.js',
           path:path.resolve(__dirname, 'dist'),
           library:'_dll_[name]',  //dll的全局变量名
       },
       plugins:[
           new DllPlugin({
               name:'_dll_[name]',  //dll的全局变量名
               path:path.join(__dirname,'dist','[name].manifest.json'), //描述生成的manifest文件
           })
       ]
    }
    

    需要注意DllPlugin的参数中name值必须和output.library值保持一致,并且生成的manifest文件中会引用output.library值。

    最终构建出的文件:

     |-- polyfill.dll.js
     |-- polyfill.manifest.json
     |-- react.dll.js
     └── react.manifest.json
    
    1. 在主config文件里使用DllReferencePlugin插件引入xx.manifest.json文件
    //webpack.config.json
    const path = require('path');
    const DllReferencePlugin = require('webpack/lib/DllReferencePlugin');
    module.exports = {
        entry:{ main:'./main.js' },
        //... 省略output、loader等的配置
        plugins:[
            new DllReferencePlugin({
                manifest:require('./dist/react.manifest.json')
            }),
            new DllReferenctPlugin({
                manifest:require('./dist/polyfill.manifest.json')
            })
        ]
    }
    

    最终构建生成main.js

  • 使用HappyPack开启多进程Loader转换

    在整个构建流程中,最耗时的就是Loader对文件的转换操作了,而运行在Node.js之上的Webpack是单线程模型 的,也就是只能一个一个文件进行处理,不能并行处理。HappyPack可以将任务分解给多个子进程,最后将结果发给主进程。JS是单线程模型,只能通过这种多进程的方式提高性能。

    npm i -D happypack
    
    // webpack.config.json
    const path = require('path');
    const HappyPack = require('happypack');
    
    module.exports = {
        //...
        module:{
            rules:[
                {
                    test:/\.js$/use:['happypack/loader?id=babel']
                    exclude:path.resolve(__dirname, 'node_modules')
                },
                {
                    test:/\.css/,
                    use:['happypack/loader?id=css']
                }
            ],
            plugins:[
                new HappyPack({
                    id:'babel',
                    loaders:['babel-loader?cacheDirectory']
                }),
                new HappyPack({
                    id:'css',
                    loaders:['css-loader']
                })
            ]
        }
    }
    
  • 使用ParallelUglifyPlugin开启多进程压缩JS文件

2. 优化开发体验

开发过程中修改源码后,需要自动构建和刷新浏览器,以查看效果。这个过程可以使用Webpack实现自动化,Webpack负责监听文件的变化,DevServer负责刷新浏览器。

  • DevServer使用自动刷新

  • 开启模块热替换HMR

3. 优化输出质量 - 压缩文件体积

  • UglifyJSPlugin:去掉JS中无效代码、去掉日志输入代码、缩短变量名等优化。

  • UglifyESPlugin:直接运行ES6代码时,对ES6代码进行压缩。

  • 压缩CSS:

    css-loader?minimize

    PurifyCSSPlugin

  • 启动Tree Shaking:剔除用不上的死代码,它正常工作的前提是代码必须采用ES6的模块化语法

4. 优化输出质量 - 加速网络请求

4.1 使用CDN加速静态资源加载
  1. CND加速的原理

    CDN通过将资源部署到世界各地,使得用户可以就近访问资源,加快访问速度。要接入CDN,需要把网页的静态资源上传到CDN服务上,在访问这些资源时,使用CDN服务提供的URL。

    由于CDN会为资源开启长时间的缓存,例如用户从CDN上获取了index.html,即使之后替换了CDN上的index.html,用户那边仍会在使用之前的版本直到缓存时间过期。业界做法:

    • HTML文件:放在自己的服务器上且关闭缓存,不接入CDN。

    • 静态的JS、CSS、图片等资源:开启CDN和缓存,同时文件名带上由内容计算出的Hash值,这样只要内容变化hash就会变化,文件名就会变化,就会被重新下载而不论缓存时间多长。

    另外,HTTP1.x版本的协议下,浏览器会对于向同一域名并行发起的请求数限制在4~8个。那么把所有静态资源放在同一域名下的CDN服务上就会遇到这种限制,所以可以把他们分散放在不同的CDN服务上,例如JS文件放在js.cdn.com下,将CSS文件放在css.cdn.com下等。这样又会带来一个新的问题:增加了域名解析时间,这个可以通过dns-prefetch来解决 来缩减域名解析的时间。形如 //xx.com 这样的URL省略了协议,这样做的好处是,浏览器在访问资源时会自动根据当前URL采用的模式来决定使用HTTP还是HTTPS协议。

  2. 总之,构建需要满足以下几点:

  • 静态资源导入的URL要变成指向CDN服务的绝对路径的URL

  • 静态资源的文件名需要带上根据内容计算出的Hash值

  • 不同类型资源放在不同域名的CDN上

4.2 多页面应用提取页面间公共代码,以利用缓存
  • webpack4之前利用 CommonsChunkPlugin 插件来进行公共模块提取

  • webpack4之后利用 SplitChunksPlugin 插件来进行公共模块提取

module.exports = {
    optimization: {
        splitChunks: {
            cacheGroups: {
                vendor: {
                    name: "vendor",
                    test: /[\\/]node_modules[\\/]/,
                    chunks: "all",
                    priority: 10 // 优先级
                },
                common: {
                    name: "common",
                    test: /[\\/]src[\\/]/,
                    minSize: 1024,
                    chunks: "all",
                    priority: 5
                }
            }
        }
    },
};

5. 优化输出质量 - 提升代码运行时的效率

5.1 使用Prepack提前求值 (不太成熟)
5.2 使用Scope Hoisting

6. 使用输出分析工具webpack-bundle-analyzer

7、其他Tips

  • 配置babel-loader时,use: [‘babel-loader?cacheDirectory’] cacheDirectory用于缓存babel的编译结果,加快重新编译的速度。另外注意排除node_modules文件夹,因为文件都使用了ES5的语法,没必要再使用Babel转换。

  • 配置externals,排除因为已使用<script>标签引入而不用打包的代码,noParse是排除没使用模块化语句的代码。

    假设我们开发了一个自己的库,里面引用了lodash这个包,经过webpack打包的时候,发现如果把这个lodash包打入进去,打包文件就会非常大。那么我们就可以externals的方式引入。也就是说,自己的库本身不打包这个lodash,需要用户环境提供。

    externals: {
       "lodash": {
          commonjs: "lodash", //如果我们的库运行在Node.js环境中,import _ from 'lodash'等价于const _ = require('lodash')
          commonjs2: "lodash", //同上
          amd: "lodash", //如果我们的库使用require.js等加载,等价于 define(["lodash"], factory);
          root: "_" //如果我们的库在浏览器中使用,需要提供一个全局的变量‘_’,等价于 var _ =   (window._) or (_);
        }
    }
    

    总得来说,externals配置就是为了使import _ from 'lodash'这句代码,在本身不引入lodash的情况下,能够在各个环境都能解释执行。

  • 配置performance参数可以输出文件的性能检查配置。

  • 配置profile:true,是否捕捉Webpack构建的性能信息,用于分析是什么原因导致构建性能不佳。

  • 配置cache:true,是否启用缓存来提升构建速度。

  • 可以使用url-loader把小图片转换成base64嵌入到JS或CSS中,减少加载次数。

  • 通过imagemin-webpack-plugin压缩图片,通过webpack-spritesmith制作雪碧图。

  • 开发环境下将devtool设置为cheap-module-eval-source-map,因为生成这种source map的速度最快,能加速构建。在生产环境下将devtool设置为cheap-module-source-map。

webpack性能优化实践

加快打包速度

1> loader中exclude与include是用来排除或包含指定目录下的模块。 例如,babel-loader设置exclude node_modules,node_modules中的JS文件已经编译为ES5,没必要再进行转换。exclude和include同时存在,exclude优先级更高。

2> babel-loader中设置cacheDirectory,用于缓存babel的编译结果,加快重新编译的速度。

3> @babel/preset-env的modules配置项设置为false会禁用模块语句的转化,否则将导致tree-shaking失效。

4> commonsChunksPlugin 和 splitChunks 可以减少重模块打包,提升打包速度,减小资源体积。

提升请求效率

1> output中publicPath,可以使用CDN链接,用户可以更快地请求到资源

减小资源文件体积

1> uglify.js 和 terser.js

参考文章

理解webpack原理,手写一个100行的webpack