webpack整合笔记优化

1,425 阅读6分钟

软件工程领域经验 —— 不要过早优化,会增加复杂度,优化效果也不理想。 一般是当项目发展到一定规模后,性能问题随之而来,这时再去分析然后对症下药,才可能达到理想的优化效果。

一 多进程配置

01 HappyPack (现在已很少更新)

通过多线程来提升webpack打包速度的工具 在打包工程中有一项非常耗时的工作,就是使用loader将各种资源进行转译处理。比如babel-loader / ts-loader。HappyPack适用于那些转译任务比较重的工程,非常适合处理此类转译处理。 而对其他的如scss-loader / less-loader 本身耗时并不多的工程则效果一般。

单个loader的优化

使用HappyPack替换原来的loader,并将原有的那个通过HappyPack插件传进去 初始配置

reules: [
    {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'babel-loader',
        options: {
            presets: ['react']
        }
    }
]

使用HappyPack配置

const HapplyPack = require('happypack')
module: {
    reules: [{
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'happypack/loader'
    }]
},
plugins: [
    new HappyPack({
        loaders: [{
            loader: 'babel-loader',
            options: {
                presets: ['react']
            }
        }]
    })
]

多个loader的优化

在使用HappyPack优化多个loader时,需要为每个loader配置有个id,否则HapplyPack无法知道rules与plugins如何一一对应。

const HapplyPack = require('happypack')

module: {
    reules: [{
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'happypack/loader?id=js'
    },{
        test: /\.ts$/,
        exclude: /node_modules/,
        loader: 'happypack/loader?id=ts'
    }]
},
plugins: [
    new HappyPack({
        id: 'js',
        loaders: [{
            loader: 'babel-loader',
            options: { } // babel options
        }]
    }),
    new HappyPack({
        id: 'ts',
        loaders: [{
            loader: 'ts-loader',
            options: { } // ts options
        }]
    })
]

02 thread-loader / parallel-webpack多进程打包

thread-loader(推荐) 原理:每次webpack解析一个模块,thread-loader会将它的依赖分配给worker线程中

module.exports = smp.wrap({
    module: {
        rules: [{
            test: /.js$/,
            use: [{
                    loader: 'thread-loader',
                    options: {
                        workers: 3
                    }
                },
                'babel-loader',
                // 'esline-loader'
            ]
        }]
    }
})

03 多进程并行压缩

terser-webpack-plugin 开启parallel参数(webpack4默认推荐,支持ES6压缩)

const TerserPlugin = require('terser-webpack-plugin')
module.exports = {
    optimization: {
        minimizer: [
            new TerserPlugin({
                parallel: 4  // CPU数量 可输入 false true
            })
        ]
    }
}

二 缩小打包作用域

从宏观角度看,提升性能的方法无非两种:增加资源或缩小范围。增加资源就是哲更多CPU和内存来缩短执行任务的时间; 缩小范围则是针对任务本身,比如去掉冗余的流程,尽量不做重复性的工作等。

01 exclude 和 include

对于JS来说,一般要把hode_modules目录排除掉 两者同时存在时exclude优先级更高,正由于此,可对include中的子目录进行排除

rules: [
    {
        exclude: /src/lib/,  //排除src中的lib目录
        include: 'src'
    }
]

02 减少文件搜索范围

  • 优化resolve.modules配置(减少模块搜索层级)
  • 优化resolve.mainFields配置
  • 优化resolve.extensions配置
  • 合理使用alias

webpack.base.js

{
    resolve: {
        extensions: ['.js', '.jsx'],   // import时可省略的扩展名
                                       // 逻辑类写入,资源类在import时显示写入    
        mainFiles: ['index', 'child'],  // import时可省略的文件名
        alias: {
            react: path.resolve(__dirname, './node_modules/react/dist/react.min.js'),
            UI: path.resolve(__dirname, '../src/UIComponents')  // 减少使用
        }
    },
    module: {
        module: {
            noParse: /node_modules\/(jquey\.js)/   //排除不需要解析的模块
        },
        rules: [{
            test:/\.jsx?$/,  // 同时匹配js 和 jsx文件
            include: path.resolve(__dirname, '../src'), 
            use: [{
                loader: 'babel-loader'
            }]
        }]
    }
    
}

noParse忽略打的文件里不应该包含import / require define等模块化语句,不然会导致构建出的代码中包含无法在浏览器环境下执行的模块化语句

合理配置rule的查找范围

在rule配置上,有test / include / exclude 三个可控制范围的配置 最佳实践是:

  • 只在 test 和文件名匹配中使用正则表达式
  • 在include和exclude中使用绝对路径数组
  • 尽量避免exclude,更倾向于使用include

03 Cache 充分利用缓存提升二次构建速度

有些loader会有一个cache配置项,用来在编译代码后同时保存一份缓存,在执行下次编译前会检测缓存。这相当于实际编译的只有变化了的文件,整体速度上会有提升。

在webpack5中添加了一个新的配置项"cache:{type: "filesystem"}",它会在全局启用一个文件缓存。

目前的解决办法是,当我们更新了任何node_modules中的模块或者webpack的配置后,手动修改cache.version来让缓存过期。

缓存思路:

babel-loader 开启缓存

rules: [
 {
   test: /\.js$/,
   use: ['babel-loader?cacheDirectory=true', 'eslint-loader']
 }
]

terser-webpack-plugin 开启缓存

optimization: {
   minimizer: [
     new TerserPlugin({
       parallel: true,
       cache: true
     })
   ]
 },

使用cache-loader 或者 hard-source-webpack-plugin

const HardSourceWebpackPlugin = require('hard-source-webpack-plugin')
plugins:[
    new HardSourceWebpackPlugin()
]
 

三 动态链接库与DllPlugin

动态链接库是早期windows系统由于受限于内存空间较小的问题而出现的内存优化方法。为了减小内存消耗,可将子程序存储为一个可执行文件,当被许多程序调用时只在内存中生成和使用同一个实例。 DllPlugin借鉴了这种思路,对于第三方模块或不常变化的模块,可将它们预先编译和打包,然后在项目实际构建过程中直接取用即可。

01 vendor配置

首先需要为动态链接库单独创建一个webpack配置文件,比如命名为webpack.dll.js用来区别过程本身的配置文件webpack.config.js

webpack.dll.js

const path = require('path')
const webpack = require('webpack')
const dllAssetPath = path.join(__dirname, './dist/dll')
module.exports = {
    context: process.cwd(),
    resolve: {
        extensions: ['.js', '.jsx', '.json', '.less', '.css'],
        modules: [__dirname, 'node_modules']
    },
    entry: {
        vendors: ['lodash'],
        react: ['react', 'react-dom', 'redux', 'react-redux'],
        jquery: ['jquery', 'jquery-ui']
    },
    output: {
        filename: '[name]_[hash].dll.js',
        path: dllAssetPath,
        library: '[name]_[hash]'   // 挂载到全局变量
    },
    plugins: [
        new webpack.DllPlugin({     // 生成映射文件
            name: '[name]_[hash]',           // 导出的dll library名字,需要与output.library对应
            path: path.resolve(dllAssetPath, '[name].manifest.json') 
            // 资源清单绝对路径,业务代码打包时将会使用这个清单进行模块索引
        })
    ]
    
}

02 vendor打包

接下来就要打包vendor并生成资源清单了。为了后续运行方便,可在package.json中配置npm scripts

package.json

{
    "scripts": {
        "build:dll": "webpack --config webpack.dll.js"
    }
}

npm run build:dll 独立打包三方模块 打包后会生成dll目录,里面有vendor.js 和 mainifest.json。前者包含了库代码,后者是资源清单。

03 链接到业务代码

使用与DllPlugin配套的插件DllReferencePlugin,它起到一个索引和链接的作用。在工程的webpack配置文件中(webpack.config.js),通过DllReferencePlugin来获取刚刚打包好的资源清单,然后在页面中添加vendor.js的引用就可以了。 AddAssetHtmlWebpackPlugin可动态为html挂载dll webpack.config.js

plugins: [
    new AddAssetHtmlWebpackPlugin({
        filepath: path.resolve(dllAssetPath, '[name]_[hash].dll.js')  // 挂载dll.js
    }),
    new webpack.DllReferncePlugin({
        manifest: require(path.join(dllAssetPath, '[name].manifest.jon'))
    })
]

index.html

<body>
    <script src="dist/dll/vendor.js"></script>
    <script src="dist/app.js"></script>
</body>

当页面执行到vendor.js时,会声明dllExample全局变量。而manifest相当于我们注入app.js的资源地图,app.js会先通过name字段找到名为dllExample的library,再进一步获取其内部模块。这就是在webpack.dll.js中给DllPlugin的name和output.ligrary赋相同值的原因。如果页面报"变量dllExample不存在" 的错误,那么有可能是没有指定正确的 output.library,或者忘记了在业务代码前加载vendor.js

多个dll文件配置 webpack.base.js

const plugins = [
    new HtmlWebpackPlugin({
        template: 'src/index.html'
    }),
    new CleanWebpackPlugin(['dist'],{
        root: path.resolve(__dirname, '../')
    }),
    new webpack.HashedModuleIdsPlugin()  // 解决id问题
]

const files = fs.readirAsync(path.resolve(__dirname, '../dll'))  // 分析dll文件个数
files.forEach(file => {
    if(/.*.dll.js/.test(file)){
        plugins.push(new AddAssetHtmlWebpackPlugin({
            filepath: path.resolve(__dirname, '../dll', file)  // 挂载dll.js
        }))
    }
    if(/.*.manifest.json/.test(file)){
        plugins.push(new DllReferencePlugin({
            manifest: path.resolve(__dirname, '../dll', file)  // 挂载映射文件
        }))
    }
})

module.exports = {
    plugins
}

04 潜在问题

manifest.json中每个模块都有一个id,业务代码在引用vendor中模块时也引用这个id。当我们更改vendor时这个数字id也会随之变化。本来vendor中不应该受到影响的模块却改变了它们的id。解决这个问题的方法即在打包vendor时添加上HashedModuleIdsPlugin

plugins: [
    new webpack.HashedModuleIdsPlugin()
]

05 预编译资源CDN引入

思路:将react/react-dom基础包通过cdn引入,不打入bundle中 方法:使用html-webpack-externals-plugin

const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin');
{
    plugins: [
        new HtmlWebpackExternalsPlugin({
            externals: [
                {
                    module: 'react',
                    entry: 'https://.../react.min.js',
                    global: 'React',
                },
                {
                    module: 'react-dom',
                    entry: 'https://.../react-dom.min.js',
                    global: 'ReactDOM',
                },
            ],
        }),
    ]
}

缺点:实际项目中会有许多基础包如react,react-dom,redux等等以及通用业务包。在html中会有许多script。

四 Tree Shaking

ES6 Module依赖关系的构建是在代码编译是而非运行时。基于这项特性webpack提供了tree shaking功能,它可在打包过程中帮助我们检测工程中没有被引用过的模块,在uglify阶段被擦除掉,这部分代码被称为“死代码”

要求:必须是ES6的语法,CJS的方式不支持

package.json

{
    "sideEffects": [
        "@babel/polly-fill", // 防止babel polly-fill被shaking掉
        "*.css"              // 对css文件不做tree shaking
    ],   
}

01 依赖关系构建

webpack默认支持,在.babelrc里设置modules:false即可(production mode的情况下默认开启) 如果我们在工程中使用了babel-loader,那么一定要通过配置来禁用它的模块依赖解析。因为如果由babel-loader来做依赖解析,webpack接收到的就都是转化过的CommonJS形式的模块,无法进行tree-shaking。禁用代码如下:

rules:[{
    test: /\.js$/,
    exclude: /node_modules/,
    use: [{
        loader: 'babel-loader',
        options: {
            presets: [
                // 这里一定要加上modules: false
                [@babel/preset-env, {modules: false}]
            ]
        }
    }]
}]

02 使用压缩工具去除死代码

tree shaking 本身只是为死代码添加上标记,真正去除死代码是通过压缩工具来进行的。使用terser-webpack-plugin即可。 在webpack4之后版本,将mode设置为produciton也可达到相同效果。

const TerserPlugin = require('terser-webpack-plugin')
module.exports = {
    optimization: {
        minimizer: [
            new TerserPlugin({
                cache: true,                  // 加快二次构建速度
                parallel: 4,                  // 多线程压缩 CPU数量 可输入 false true
                terserOptions:{
                    comments: false,
                    compress: {
                        unused: true// 移除无用代码
                        drop_debugger: true,  // 移除debugger
                        drop_console: true,   // 移除console
                        dead_code: true       // 移除无用代码        
                    }
                }
            })
        ]
    }
}

03 擦除无用CSS

webpack3 使用 mini-css-extract-plugin提取CSS

然后PurgecssPlugin压缩

const path = require('path')
const glob = require('glob')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const PurgecssPlugin = require('purgecss-webpack-plugin')

const PATHS = {
    src: path.join(__dirname, 'src')
}

module.exports = {
    module: {
        rules: [
            {
                test:/\.css$/,
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader,
                        options: {
                            publicPath: '../'  // 指定异步css加载路径
                        }
                    },
                    "css-loader"
                ]
            }
        ],
        plugins: [
            new MiniCssExtractPlugin({
                filename: '[name].css',             // 同步加载的css资源名
                chunkFilename: '[id].css'           // 异步加载的css资源名
            }),
            new PurgecssPlugin({
                paths: glob.sync(`$(PATHS.src)/**/*`,{nodir: true})
            })
        ]
    }
}

webpack4 使用 splitChunks提取CSS

然后OptimizeCssAssetsWebpackPlugin压缩,这个插件本质上使用的是压缩器cssnano webpack.prod.js

const prodConfig = {
    optimization: {
        splitChunks: {
            cacheGroups: {
                styles: {    // 单入口 单css
                    name: 'styles',
                    test: /\.css$/, // 只要是css文件就打包到styles中
                    chunks: 'all',
                    enforce: true // 忽略默认配置项
                },
                mainStyles: {  // 多入口时,main文件下的css打包的main.css中
                    name: 'main',
                    test: (m,c,entry="main") => m.costructor.name ==='CssModule' && recursiveIssuer(m) === entry,
                    chunks: 'all',
                    enforce: true // 忽略默认配置项
                },
                adminStyles: {  // 多入口时,main文件下的css打包的main.css中
                    name: 'admin',
                    test: (m,c,entry="admin") => m.costructor.name ==='CssModule' && recursiveIssuer(m) === entry,
                    chunks: 'all',
                    enforce: true // 忽略默认配置项
                }
            }
        },
        minimizer: [
            new OptimizeCssAssetsWebpackPlugin({}) // 压缩css
        ]   
    }
}

五 异步加载JS业务模块与CSS模块

01 JS业务模块

optimization: [
splitChunks: {
    chunks: 'async'  // 默认为async
}
]

默认为async是因为同步代码只是优化的缓存,但是缓存只优化了第二次打开的速度。 第一次的速度才是真正需要优化的 —— 即优化代码使用率 在浏览器中cmd+shift+p 输入 coverage,查看代码使用率 click.js

function handleClick(){
    const element = docoument.creareElement('div');
    element.innerHTML = 'Dell Lee';
    document.body.appendChild(element)
}
export default handleClick;

index.js

document.addElentListener('click', ()=>{
  import(/*webpackPrefetch:true*/ './click.js').then(({defalut: func})=>{
      func() // 此次第一次打开时会被优化
  })  
})

当前前端高性能重点关注代码使用率而不是缓存 /webpackPrefetch:true/ 指定模块在网络空闲时加载(嘘~悄悄地) /webpackLoad:true/ 与主业务文件一起加载

02 异步加载CSS模块 (见CSS章)

六 图片优化

01 压缩

要求:基于node库的imagemin或者tintypng API 使用:配置image-webpack-loader

return {
    test: /\.(png|svg|gif|blob)$/,
    use: [{
        loader: 'file-loader',
        options: {
            name: `${filename}img/[name]${hash}.[ext]`
        }
    },{
        loader: 'image-webpack-loader',
        options: {
            mozjpeg: {
                progressive: true,
                quality: 65
            },
            optipng: {
                enabled: false,
            }
            pngquant: {
                quality: '65-90',
                speed: 4
            },
            gifsicle: {
                interlaced: false
            },
            webp: {
                quality: 75
            }
        }
    }]
}

imagemin 优点

  • 有很多定制选项

  • 可引入更多第三方优化插件,如pngquant

  • 可处理多种图片格式

  • pngquant: 是一款png压缩器,通过将图像转换为具有alpha通道的更高效的8位png格式,可显著减小文件大小。

  • pngcrush:主要目的是通过尝试不同的压缩级别和png过滤方法来降低png IDAT数据流的大小。

  • optipng:其设计灵感来自pngcrush。optipng可将图像文件重新压缩为更小尺寸,而不会丢失任何信息

  • tinypng:也是将24位png文件转化为更小有索引的8位图片,同时所有非必要的metadata也会被剥离掉

02 postCSS做作雪碧图

npm i postcss-sprites postcss-loader -D postcss.config.js

plugins: [
    postcssSprites({
        // 在这里制定了从哪加载的图片被主动使用css sprite
        // 可约定好一个目录名称规范,防止全部图片都被处理
        spritePath: './src/assets/img/'
    })
]

webpack.config.js

{
    test: /\.css$/,
    use: [
        MiniCssExtractPlugin.loader,
        'css-loader',
        {
            loader: 'postcss-loader'
        }
    ]
}
.bg-img02{
    background: url(./assets/img/small-02.png) no-repeat;
}
.bg-img03{
    background: url(./assets/img/small-03.png) no-repeat;
}
.bg-img04{
    background: url(./assets/img/small-04.png) no-repeat;
}

打包后生成了一个png文件,css内被替换成了 scc sprite写法

.bg-img02 {
    background-image: url(99b0de3534d3e852ea4ce83b15cbad60.png); background-position: 0px 0px;
    background-size: 320px 320px;
}
.bg-img03 {
    background-image: url(99b0de3534d3e852ea4ce83b15cbad60.png); background-position: -160px 0px;
    background-size: 320px 320px;
}
.bg-img04 {
    background-image: url(99b0de3534d3e852ea4ce83b15cbad60.png); background-position: 0px -160px;
    background-size: 320px 320px;
}

03 对于大图片可使用 image-webpack-loader来压缩图片

image-webpack-loader不能将图片嵌入应用,所以必须和url-loader以及svg-url-loader一起使用,为了避免同时将它复制粘贴到两个规则中,我们使用enforce:'true'作为单独的规则涵盖在这个loader

rules: [
    {
        test: /\.(jpe?g|png|gif|svg)$/,
        loader: 'image-webpack-loader',
        enforce: 'pre'  // 这会应用该loader, 在其它之前
    }
]

七 动态polyfill


方案 优点 缺点 是否采用
babel-polyfill React16官方推荐 1. 包体积200k+,难以单独抽离Map,set。 2. 项目里react是单独引用 的cdn,如果要用它,需要单独构建一份放在react前加载
babel-plugin-transform-runtime 能只polyfill用到的类或方法,相对体积较小 不能polyfil原型上的方法,不适用业务项目的复杂开发环境
自己写Map,Set的polyfill 定制化高,体积小 1.重复造轮子,容易在日后成坑
polyfill-service 只给用户返回需要的polyfill,社区维护 部分国内奇葩浏览器UA可能无法识别(但可降级返回全部polyfill) 推荐

polyfill-service 原理 识别User Agent, 下发不同的Polyfill

polyfill.io 官方提供的服务

polyfill.io/v3/polyfill…

八 其他代码级别优化技巧

  • 合理划分代码职责,适当使用按需加载方案
  • 善用webpack-bundle-analyzer插件,帮助分析webpack打包后的模块依赖关系
  • 设置合理的SplitChunks分组
  • 对于一些UI组件库 AntDesign / ElementUi等,可使用bable-plugin-import这类工具进行优化
  • 使用lodash / momentjs 类库,不要一股脑引入,要按需引入,momentjs可用date-fns来代替
  • 合理使用hash占位符,防止hash重复出示,导致文件名变化从而HTTP缓存过期
  • 合理使用polyfill,防止多余代码
  • 使用ES6语法,尽量不使用副作用代码,以加强Tree-Shaking效果
  • 使用webpack的scope hiisting(作用域提升)功能 scope hiisting是指webpack 通过ES6语法静态分析,分析出模块证据的依赖关系,尽可能地把模块放到同一个函数中 开启方式
optimization: {
    concatenateModules: true
}