现代前端项目搭建指南:打包篇

4,744 阅读15分钟

前言

构建现代前端项目,打包必不可少,通过打包工具我们不仅可以模块化开发还可以整合一系列开发工具,提升开发效率和质量,本文以Webpack@5为例介绍构建现代前端项目所必须的打包知识,目标讲解如何从零开始构建一个完整的打包环境来让读者具备建设现代前端项目打包技术的能力

兼容性要求

  • Webpack:采用最新的Webpack@5.67.0
  • Node.jsWebpack 5Node.js的版本要求至少是10.13.0 (LTS)
  • Webpack@plugin:升级至最新版本
  • Webpack@loader:升级至最新版本

注意:部分loader/plugin可能会有一个beta版本,必须使用它们才能与webpack 5兼容,确保在升级时阅读每个插件loader/plugin的发布说明。还需要注意编译阶段可能出现的弃用警告,可以通过node --trace-deprecation node_modules/webpack/bin/webpack.js命令查看具体是哪个loader/plugin导致的

实践

安装

npm install webpack webpack-cli --save-dev

运行

运行Webpack有两种方式

  • 使用配置文件

    npx webpack [--config webpack.config.js]
    
  • 不使用配置文件

    npx webpack --entry <entry> --output-path <output-path>
    

    例如:

    npx webpack --entry ./first.js --entry ./second.js --output-path /build
    

涉及到大的项目,主要还是以使用配置文件的方式

配置文件

文件实体

默认配置文件

CLI会在项目路径中寻找默认配置文件,按照优先级默认是.webpack/webpackfile > .webpack/webpack.config.js > webpack.config.js

注意:命令行接口(Command Line Interface)参数的优先级,高于配置文件参数。例如,如果将 --mode="production" 传入webpack CLI,而配置文件使用的是 development,则最终会使用 production

自定义配置文件

除了默认配置文件以外,我们可以通过--config指定配置文件

npx webpack --config example.config.js

基础配置

入口(entry)

入口起点(entry point)  指示 Webpack应该使用哪个模块来作为构建其内部 依赖图(dependency graph) 的开始

入口的默认值是 ./src/index.js,但可以通过在 webpack configuration 中配置 entry 属性,来指定一个或多个不同的入口起点

单文件入口写法:

module.exports = {
  entry: './path/to/my/entry/file.js',
};

多文件入口写法:

module.exports = {
  entry: ['./src/file_1.js', './src/file_2.js']
};

还可以采用对象语法,可配置内容更丰富:

module.exports = {
  entry: {
    a2: 'dependingfile.js',
    b2: {
      dependOn: 'a2',
      import: './src/app.js',
    },
  },
};

关于对象语法的具体可配置项可见 文档

webpack 4之前我们会将库代码和应用代码拆分成两个不同的入口,但到了webpack 4推荐采用 optimization.splitChunks ,不再为不是执行节点的代码创建入口,每个HTML文件一般只使用一个入口起点(具体原因可见该 issue

输出(output)

通过配置 output 选项,告知 webpack 如何向硬盘写入编译文件

注意:即使存在多个 entry 起点,也只能指定一个 output 配置

有几个常用的配置:

  • filename:指定JavaScript文件输出的文件名称
  • path:目录对应一个绝对路径
  • publicPath:设置引用资源时的公共路径前缀,详见
  • clean:在生成文件之前清空 output 目录,是webpack@5.20.0新增的,之前没有该配置时需要用clean-webpack-plugin实现类似功能

devtool

此选项控制是否生成,以及如何生成 source map,在开发环境配置成eval-source-map,较之eval可以最接近原文件,在开发环境可以不配置(默认不生成),如果希望便于追踪错误信息的话可以选用nosources-source-map

注意:devtool的配置会影响cssJavaScriptsource map机制

module

JavaScript

现代前端项目里的JavaScript通常需要做语法转化和polyfills以便可以使用高级语法而不必担心浏览器兼容性问题,为达到上述目标,我们需要使用到Babel,而BabelWebpack中还需要额外安装babel-loader以便和Webpack融合

安装:

$ npm i @babel/core @babel/preset-env babel-loader @babel/plugin-transform-runtime core-js --save-dev -d
$ npm i @babel/runtime --dev

Babel中一个preset就是一组plugin的集合。

在上述安装的npm中有两个比较重要:

  • @babel/preset-env:主要功能是自动按照用户配置来做语法转化(自动引入plugin)和polyfills,是必备的核心包。@babel/preset-env中有两个配置项比较重要:
    • useBuiltIns,该配置控制引入polyfills脚本的方式,一般我们将其配置成useage,表示按照实际是否使用引入polyfills
    • corejs:指定core-js的版本(core-js提供了polyfills的具体实现),该配置只在useBuiltIns配置成useageentry时才起作用。默认情况下仅对稳定的ECMAScript特性提供polyfills,如果希望使用proposals特性,一般推荐配置成{ version: "x", proposals: true }。这里的x表示core-js的版本号,如果你安装的是core-js@3.20,则x可以写成3.20
  • @babel/plugin-transform-runtime@babel/runtime):在编译过程中Babel会用到一些helper函数,默认情况下这些helper函数会打包到每个文件中导致代码冗余。@babel/plugin-transform-runtime会自动识别是否存在helper函数,存在的话将其改为对@babel/runtimehelper函数的引用,从而达到降低代码冗余减小打包体积的目的(关于helper函数具体可以见这个 示例

注意:corejs有时候会写成3,这表示的是3.0,这就意味着无法用到core-js的最新polyfills特性,所以建议结合具体安装的core-js版本使用小版本号,如3.20。此外@babel/preset-env所包含的plugin不小于Stage 3,也就是说 TC39 的内容是不包括的,以为内这部分内容不一定会被所有浏览器实现,假如你想用到 TC39 的语法,则需要自己手动添加 对应的Plugin

样式

对于样式文件,我们通常依次需要postcss-loadercss-loaderstyle-loader,如果用到sass语法,则还需要另外安装sass-loadersass

注意:node-sass因使用C++编写,对Node.js版本有要求,而且对新特性已不再维护,所以不推荐使用。官方推荐使用sassnode-sass内部既支持sass也支持node-sass,具体看使用者安装的是哪个包,具体实现代码

安装:

$ npm i postcss-loader css-loader style-loader sass-loader sass -d --save-dev

由于loader的执行顺序是最后面的先执行,所以放到use数组的顺序恰好相反,是[style-loader , css-loader , postcss-loader , sass-loader]

注意:按官方文档指明,postcss-loader需要在css-loader 和 style-loader 之前使用,但在其他预处理器(例如:sass|less|stylus-loader之后使用

涉及到样式的各个loader用途是:

  • sass-loader:加载 Sass/SCSS 文件并将他们编译为 CSS
  • postcss-loader:使用 PostCSS 处理 CSS
  • css-loader:对 @import 和 url() 进行处理,就像 js 解析 import/require() 一样,具体用法可见 官方示例
  • style-loader:把 CSS 插入到 DOM

处理样式文件最简单的配置如下:

module.exports = {
    ...
    module: {
        rules: [
            ...
            {
                test: /\.(css|scss)$/,
                use: [
                    'style-loader',
                    'css-loader',
                    'postcss-loader',
                    'sass-loader'
                ]
            }
        ]
    }
};

如上的配置已经可以让scss文件顺利编译了,但还可以进行一些优化,首先我们虽然用到了postcss-loader,但并没有进行配置,这使得postcss的一些强大功能并没有利用。此外scss在处理相对路径时有问题(详见),为了解决这个问题我们引入resolve-url-loader于是我们做了迭代:

安装新包:

$ npm i postcss-preset-env resolve-url-loader -d --save-dev

现在我们的配置文件如下:

const path = require('path');

module.exports = {
    ...
    module: {
        rules: [
            ...
            {
                test: /\.(css|scss)$/,
+               include: /src/,
                use: [
                    'style-loader',
                    'css-loader',
                    {
                        loader: 'postcss-loader',
+                       options: {
+                           postcssOptions: {
+                               plugins: ['postcss-preset-env']
+                           }
+                       }
                    },
+                   'resolve-url-loader',
                    'sass-loader'
                ]
            }
        ]
    }
};

添加postcss-preset-env的好处是可以将高级的css语法尽可能做兼容,例如color:#12345678;会转换成color:rgba(18,52,86,0.47059),浏览器兼容性更强。另外postcss-preset-env还自带了autoprefixer特性,可以自动添加前缀

注意:postcss-preset-envautoprefixer需要结合browserslist一块使用,只会给需要兼容的浏览器进行前缀的添加,如果我们在.browserslistrc里没有添加firefox的配置,那么就不会添加-moz-前缀,这点需要牢记。一般autoprefixer未生效都是.browserslistrc的配置有问题

现在运行我们的编译打包命令,可以看到样式内容会保存在js脚本中,然后style-loader创建style标签插入到html文件中,将样式内容打包到html文件中会导致js文件体积变大而且样式渲染延后,我们还是希望样式文件单独提取出来,这里要用到 MiniCssExtractPlugin

注意:基于性能和用户体验考虑,css文件一般放在顶部引入,js文件一般放在底部引入

MiniCssExtractPlugin插件的功能是将css提取到单独文件中,该插件需要Webpack 5,与extract-text-webpack-plugin相比有4个优势:

  • 异步加载
  • 没有重复的编译(性能)
  • 更容易使用
  • 特别针对 CSS 开发

使用之前首先安装:

npm i --save-dev mini-css-extract-plugin -d

用法:

const path = require('path');
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
    mode: 'development',
    devtool: 'inline-source-map',
    entry: './src/index.js',
    output: {
        filename: 'main.js',
        path: path.resolve(__dirname, '../dist')
    },
    module: {
        rules: [
            ...
            {
                test: /\.(css|scss)$/,
                include: /src/,
                use: [
-                   'style-loader',
+                   MiniCssExtractPlugin.loader,
                    'css-loader',
                    {
                        loader: 'postcss-loader',
                        options: {
                            postcssOptions: {
                                plugins: ['postcss-preset-env']
                            }
                        }
                    },
                    'resolve-url-loader',
                    'sass-loader'
                ]
            }
        ]
    },
+   plugins: [new MiniCssExtractPlugin()]
};

这里有两点注意事项:

  • MiniCssExtractPlugin会生成css文件,但如果你希望html文件自动引用生成的css文件则还需要html-webpack-plugin 的配合
  • mini-css-extract-pluginstyle-loader不能同时使用,在上线模式可以使用mini-css-extract-plugin,开发模式可以使用style-loader

完成了对css文件的提取,接下来我们将对css文件内容进行压缩

安装相应的包:

npm install css-minimizer-webpack-plugin --save-dev

修改配置文件:

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
    mode: 'production',
    ...
+    optimization: {
+        minimizer: [
+            '...',
+            new CssMinimizerPlugin()
+        ]
+    }
};

webpack@5 中,你可以使用 '...' 语法来扩展现有的 minimizer(即 terser-webpack-plugin),如果不适用,则直接的效果就是JavaScript的压缩混淆功能会丢失

注意:默认情况下CssMinimizerPlugin需要和mode设置成production配合使用才能使css压缩生效,否则需要将optimization.minimize设置为true

图片

之前处理图片资源时,我们会用到raw-loaderurl-loader 或 file-loader,但在webpack@5已经在module内嵌了该功能,不必再安装额外的npm

webpack@5 之前,通常使用:

webpack@5通过添加4种模块类型,替换掉上述三个loader

  • asset/resource: 发送一个单独的文件并导出 URL,之前通过使用 file-loader 实现
  • asset/inline: 导出一个资源的 data URI,之前通过使用 url-loader 实现
  • asset/source: 导出资源的源代码。之前通过使用 raw-loader 实现
  • asset: 在导出一个 data URI 和发送一个单独的文件之间自动选择,之前通过使用 url-loader,并且配置资源体积限制实现

对于图片来说,我们采用asset类型,并配合parser.dataUrlCondition来有条件的进行data URI。对于svg图片我们都设置成asset/inline并额外添加mini-svg-data-uri来自定义dataUrl,这样的好处是可以减小体积

安装:

$ npm i mini-svg-data-uri --save-dev -d

配置:

// 处理图片资源
{
    test: /\.(png|jpg|jpeg|gif)$/i,
    type: 'asset',
    parser: {
        dataUrlCondition: {
            maxSize: 4 * 1024 // 4kb
        }
    },
    generator: {
        filename: '[path]/[name]-[contenthash:8][ext]'
    }
},
// svg特殊处理,获取更小的体积
{
    test: /\.svg$/,
    type: 'asset/inline',
    generator: {
        dataUrl: content => svgToMiniDataURI(content.toString())
    }
}

字体

字体文件比较简单,直接按url处理即可

配置:

{
    test: /\.(woff|woff2|eot|ttf|otf)$/i,
    type: 'asset/resource',
}

HTML

通常在一个项目里,我们要用到html文件,希望得到打包文件后html文件可以自动引用这些资源,对此我们需要HtmlWebpackPlugin

安装:

$ npm i --save-dev html-webpack-plugin -d

首先假设我们的文件目录如下:

📦src
 ┣   📂static
 ┃   ┣   📂img
 ┃   ┃   ┗   📂brand
 ┃   ┃   ┃   ┗   📂home
 ┃   ┃   ┃   ┃   ┗   📜bg.png
 ┃   ┣   📂js
 ┃   ┃   ┗   📂brand
 ┃   ┃   ┃   ┗   📂home
 ┃   ┃   ┃   ┃   ┗   📜index.js
 ┃   ┗   📂style
 ┃   ┃   ┗   📂brand
 ┃   ┃   ┃   ┗   📂home
 ┃   ┃   ┃   ┃   ┗   📜index.scss
 ┗   📂views
 ┃   ┗   📜template.html

这是一个多页程序,html模板只有一份,放在views目录下;imagescssjs则分别放在imgstylejs文件夹下,而且按页面划分。我们希望生成的代码也是按照上述结构进行生成

首先处理htmlHtmlWebpackPlugin插件有以下几个配置最为关键:

  • filename:生成的html路径和名称,这里我们配置成'view/brand/home.html'(因为我们当前只有一个页面,实际当中我们需要用程序遍历,动态的进行new HtmlWebpackPlugin()
  • template:模板文件,这里固定path.resolve(__dirname, '../src/views/template.html')
  • chunks:入口文件的名称(entryChunkName),在多页情况下,entry必定是 对象语法
  • inject:决定了js脚本的插入位置,一般设置成bodycss的位置不可配置,固定在head标签内)
  • minifyhtml压缩配置,采用的是 html-minifier-terser具体配置

HtmlWebpackPlugin的完整配置如下:

new HtmlWebpackPlugin({
    filename: 'view/brand/home.html',
    template: path.resolve(__dirname, '../src/views/template.html'),
    chunks: ['src/static/js/brand/home/index'],
    inject: 'body',
    cache: true,
    minify: {
        collapseWhitespace: true,
        keepClosingSlash: true,
        removeComments: true,
        removeRedundantAttributes: true,
        removeScriptTypeAttributes: true,
        removeStyleLinkTypeAttributes: true,
        useShortDoctype: true
    }
})

如果我们希望输出的目录结构和原本的目录结构保持一致,则需要做以下工作:

  • JavaScriptentry采用对象语法且entryChunkName包含路径,如:
    entry: {
        'src/static/js/brand/home/index': './src/static/js/brand/home/index.js'
    }
    
  • css:对于样式文件,如果你采用了MiniCssExtractPlugin插件,则需要配置其filename,例如在我们的示例里如此配置:
    new MiniCssExtractPlugin({
        filename: arg => `${arg.chunk.name.replace('js', 'style')}.css`
    })
    
  • image:对于图片文件,我们在module中配置generator.filename,具体如下:
    {
        test: /\.(png|jpg|jpeg|gif)$/i,
        type: 'asset',
        parser: {
            dataUrlCondition: {
                maxSize: 4 * 1024 // 4kb
            }
        },
        generator: {
            filename: '[path]/[name]-[contenthash:8][ext]'
        }
    }
    

多页

上面做的只有一个页面,实际中多页程序往往不止一个页面,我们做下优化与拓展,假定我们src下的的目录结构如下

我们将目录结构设计成js/模块名/页面名/index.js的形式,然后利用 glob 进行文件的查询

首先安装:

npm i glob -d --save-dev

因为此时有多个页面,所以需要动态生成entry以及HtmlWebpackPlugin对象:

// getEntry.js

const path = require('path');
const glob = require('glob');

module.exports = () => {
    const entrySrcList = glob.sync(`${path.resolve(__dirname, '../../src/static/js/**/index.js')}`);
    const entry = {};
    if (entrySrcList && entrySrcList.length) {
        entrySrcList.forEach(src => {
            entry[src.slice(src.indexOf('src'), src.length)] = src;
        });
    }
    return entry;
};
// getHtmlWebpackPlugins.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = entry => Object.keys(entry).map(chunk => new HtmlWebpackPlugin({
    filename: `view${chunk.match(/src\/static\/js(.*)\/index.js/)[1]}.html`,
    template: path.resolve(__dirname, '../../src/views/template.html'),
    chunks: [chunk],
    inject: 'body',
    cache: true,
    minify: {
        collapseWhitespace: true,
        keepClosingSlash: true,
        removeComments: true,
        removeRedundantAttributes: true,
        removeScriptTypeAttributes: true,
        removeStyleLinkTypeAttributes: true,
        useShortDoctype: true
    }
}));

开发配置

本地开发的时候,需要启动一个server以便提供页面访问能力,首先安装相应包:

$ npm i webpack-dev-server --save-dev -d

针对最简单的情况,我们只需要添加HotModuleReplacementPlugin以及配置devServer即可,我们创建一个单独的文件webpack.dev.js,其内容如下:

const webpack = require('webpack');
const { merge } = require('webpack-merge');
const config = require('./webpack.config');

const port = 8000 + Math.floor(Math.random() * 100);
const host = 'local.test.com';

module.exports = merge(config, {
    plugins: [new webpack.HotModuleReplacementPlugin()],
    devServer: {
        compress: true,
        historyApiFallback: false,
        port,
        host,
        open: {
            target: 'view/brand/home.html',
            app: {
                name: 'chrome'
            }
        }
    }
});

这里我们设置的域名是local.test.com,需要注意的是要配置好host文件,让这个域名指向127.0.0.1

webpack-dev-server有3和4两个版本,配置的参数不同,如果出现Invalid options object. Dev Server has been initialized using an options object that does not match the API schema.的报错,需要排查下自己的配置和webpack-dev-server的版本是否吻合

如果在开发过程中涉及到诸如类似ajax请求映射的问题,还需要用到proxy配置项

打包性能优化

DLL

配置

在我们这个示例里(多页应用)实现DLL需要用到3个插件,分别是:

  • webpack.DllPlugin:单独一个配置文件,专门用来生成dll打包文件
  • webpack.DllReferencePlugin:源码中对打包进dll模块的引用改为了引用dll包中的模块
  • add-asset-html-webpack-plugin:如果使用了html-webpack-plugin则该插件会将dll包插入到生成的html文件中。如果webpack.DllPlugin生成的目标目录不是部署时候的目录(这很常见,因为部署目录每次build都会被clean,而dll生成一次后一般不需要再次生成),那么可以用该插件的outputPath配置将资源(这里其实就是dll打包文件)拷贝到指定的目录

生成DLL的完整配置:

const path = require('path');
const webpack = require('webpack');
const clearDir = require('./utils/clearDir');

// 在webpack@5.66.0配置output.clean会导致manifest.json丢失,所以手动清空dll文件夹
clearDir(path.resolve(__dirname, './dll'));

module.exports = {
    mode: 'production',
    resolve: {
        extensions: ['.js', '.jsx']
    },
    entry: {
        lib: [
            'react', 'react-dom', 'prop-types',
            'axios', 'highcharts', 'redux', 'redux-thunk'
        ]
    },
    output: {
        path: path.join(__dirname, 'dll'),
        filename: '[name].[contenthash].dll.js',
        library: '[name]_[fullhash]'
    },
    plugins: [
        new webpack.DllPlugin({
            path: path.join(__dirname, 'dll', '[name]-manifest.json'),
            name: '[name]_[fullhash]'
        })
    ]
};

注意:在webpack@5.66.0我们一旦设置了output.cleantrue,则manifest.json会丢失(怀疑是clean的时机不对),所以我们采用手动方式清空dll文件夹

使用DLL的完整配置

/*
 * Created by king at 2022-1-20 21:13:49
 * Copyright (c) 2022
 */

const path = require('path');
const webpack = require('webpack');
const svgToMiniDataURI = require('mini-svg-data-uri');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin');
const getEntry = require('./utils/getEntry');
const getHtmlWebpackPlugins = require('./utils/getHtmlWebpackPlugins');
const dllMainFest = require('./dll/lib-manifest.json');

// 记录上一次的progress信息,如果重复则不打印
let preProgressMessage = '';

// 获取entry
const entry = getEntry();

// 获取HtmlWebpackPlugins插件
const HtmlWebpackPlugins = getHtmlWebpackPlugins(entry);

module.exports = {
    mode: 'development',
    devtool: 'inline-source-map',
    entry,
    output: {
        filename: '[name]-[contenthash:8]].js',
        path: path.resolve(__dirname, '../dist'),
        clean: true
    },
    resolve: {
        extensions: ['.js', '.jsx', '.json', '.scss', '.css']
    },
    module: {
        rules: [
            {
                test: /\.(js|jsx)$/,
                exclude: /(node_modules|bower_components)/,
                use: {
                    loader: 'babel-loader'
                }
            }, {
                test: /\.(css|scss)$/,
                include: /src/,
                use: [
                    // 'style-loader',
                    MiniCssExtractPlugin.loader,
                    'css-loader',
                    {
                        loader: 'postcss-loader',
                        options: {
                            postcssOptions: {
                                plugins: ['postcss-preset-env']
                            }
                        }
                    },
                    'resolve-url-loader',
                    'sass-loader'
                ]
            },
            // 处理图片资源
            {
                test: /\.(png|jpg|jpeg|gif)$/i,
                type: 'asset',
                parser: {
                    dataUrlCondition: {
                        maxSize: 4 * 1024 // 4kb
                    }
                },
                generator: {
                    filename: '[path]/[name]-[contenthash:8][ext]'
                }
            },
            // svg特殊处理,获取更小的体积
            {
                test: /\.svg$/,
                type: 'asset/inline',
                generator: {
                    dataUrl: content => svgToMiniDataURI(content.toString())
                }
            },
            // 字体
            {
                test: /\.(woff|woff2|eot|ttf|otf)$/i,
                type: 'asset/resource'
            }
        ]
    },
    plugins: [
        // 自定义打印进度
        new webpack.ProgressPlugin({
            handler(percentage, message, ...args) {
                const curProgressMessage = `${Math.floor(percentage * 100)}% ${message} ${args && args.join ? args.join(' ') : ''}`;
                if (curProgressMessage !== preProgressMessage) {
                    // eslint-disable-next-line
                    console.log(curProgressMessage);
                    preProgressMessage = curProgressMessage;
                }
            }
        }),
        // 将 CSS 提取到单独的文件中
        new MiniCssExtractPlugin({
            filename: arg => `${arg.chunk.name.replace('js', 'style')}.css`
        }),
        ...HtmlWebpackPlugins,
        new webpack.DllReferencePlugin({
            context: path.resolve(__dirname, '../'),
            manifest: dllMainFest
        }),
        new AddAssetHtmlPlugin({
            filepath: path.resolve(__dirname, './dll/*.dll.js'),
            outputPath: '../dist/src/static/js',
            publicPath: '../../src/static/js'
        })
    ],
    optimization: {
        moduleIds: 'deterministic',
        runtimeChunk: {
            name: 'src/static/js/runtime-bunld'
        },
        minimizer: [
            '...',
            new CssMinimizerPlugin()
        ]
    }
};

AddAssetHtmlPluginfilepath支持Glob语法,我们生成DLL文件的时候可以contenthash作为文件名,这样可以保证文件的唯一性

注意:使用的时候需要先生成DLL,然后再使用。同时留意创建AddAssetHtmlPlugin实例时publicPath参数的写法,该参数会影响到生成的html文件引用dll打包结果的路径

性能对比

按照Webpack官网的说法,DllPlugin 和 DllReferencePlugin配合将公共代码提取到DLL中,在实现bundles拆分的同时也大幅提升了构建的速度

但也有一些声音认为,鉴于高版本Webpack在缓存方面已经有了长足进步,一些诸如vuereact等项目已经放弃使用DLL技术(详见)。DLL技术究竟是否过时,我们以多页程序为例进行了一些测试,结论是在页数且DLL内容较大时,构建速度依然具有优势。另外在Webpack的官方文档中DLL依然作为推荐技术可以使用(详见

我们对使用DLL与否做了一组对照测试,测试条件如下:

  • 机器采用MacBook Pro
  • Webpack版本是5.66.0
  • 采用development模式
  • 项目是多页程序,页面数量162
  • 两组对比测试,各进行10次(由于缓存技术,构建速度会越来越快)
  • DLL内含reactreact-domprop-typesaxioshighchartsreduxredux-thunk

使用DLL与不使用DLL的耗时对比:

序号使用DLL耗时(ms)不使用DLL耗时(ms)
11183725473
21053718068
31277919004
41302620380
51103318337
61726520309
71114019726
81319817315
91171518129
101355317805

SplitChunksPlugin

CommonsChunkPlugin 曾被用来避免模块之间的重复依赖,但是不可能再做进一步的优化。从 webpack@4开始移除了 CommonsChunkPlugin,取而代之的是 optimization.splitChunks。该插件主要用于提取公共依赖以及打包文件的拆分,目的是去除重复依赖以及防止单个文件过大(在http@2中降低单个文件大小,增加文件数量有助于提升加载速度)

注意:实现代码模块化的一个核心要点是一个模块只能被初始化一次,否则模块的全局共享性全局共享就会失效,而很多模块依赖于此。如果入口只有一个,那么这不成问题,但如果存在多个入口,则代码一定要将optimization.runtimeChunk设置为single(会多出一个runtime.bundle.js文件),具体介绍 详见

如果不设置performance.hints的话,单个文件的体积超过一个数值后(在我们的实验版本中是244KB)则会给出警告,Webpack建议对文件进行拆分。我们通过配置optimization.splitChunks来指定文件拆分逻辑,详见 文档 值得注意的是不设置cacheGroups也会起作用,只是采用的是默认配置

如果设置了optimization.splitChunks,需要留意设置好chunkFilename,保证非入口的chunk文件也能生成到如愿的目录下

完整代码

源码

.browserslistrc

ie > 9
chrome > 38
firefox > 20

.babelrc

{
    "presets": [
        ["@babel/preset-env", {
            "useBuiltIns": "usage",
            "corejs": {
                // 指定采用最新版本的core-js
                "version": "3.20",
                // 支持采用proposals特性
                "proposals": true
            }
        }],
        "@babel/preset-react"
    ],
    "plugins": [
        // 将帮助函数从每个文件中提取出来
        "@babel/plugin-transform-runtime"
    ]
}

webpack.dll.js

const path = require('path');
const webpack = require('webpack');
const clearDir = require('./utils/clearDir');

// 在webpack@5.66.0配置output.clean会导致manifest.json丢失,所以手动清空dll文件夹
clearDir(path.resolve(__dirname, './dll'));

module.exports = {
    // mode: "development || "production",
    mode: 'production',
    resolve: {
        extensions: ['.js', '.jsx']
    },
    entry: {
        lib: [
            'react', 'react-dom', 'prop-types',
            'axios', 'highcharts', 'redux', 'redux-thunk'
        ]
    },
    output: {
        path: path.join(__dirname, 'dll'),
        filename: '[name].[contenthash].dll.js',
        library: '[name]_[fullhash]'
    },
    plugins: [
        new webpack.DllPlugin({
            path: path.join(__dirname, 'dll', '[name]-manifest.json'),
            name: '[name]_[fullhash]'
        })
    ]
};

注意: webpack.config.jsmode设置为production不能让dll文件也是production(压缩、混淆……),必须在webpack.dll.js里设置mode设置为production才行,我们这里将react打包进dll文件,所以开发时用development版本,正式上线时用production版本,不然有些调试信息就无法显示了

webpack.config.js

const path = require('path');
const webpack = require('webpack');
const svgToMiniDataURI = require('mini-svg-data-uri');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin');
const getEntry = require('./utils/getEntry');
const getHtmlWebpackPlugins = require('./utils/getHtmlWebpackPlugins');
const dllMainFest = require('./dll/lib-manifest.json');

// 记录上一次的progress信息,如果重复则不打印
let preProgressMessage = '';

// 获取entry
const entry = getEntry();

// 获取HtmlWebpackPlugins插件
const HtmlWebpackPlugins = getHtmlWebpackPlugins(entry);

module.exports = {
    mode: 'development',
    devtool: 'inline-source-map',
    entry,
    output: {
        filename: '[name]-[contenthash:8].js',
        path: path.resolve(__dirname, '../dist'),
        clean: true
    },
    resolve: {
        extensions: ['.js', '.jsx', '.json', '.scss', '.css']
    },
    module: {
        rules: [
            {
                test: /\.(js|jsx)$/,
                exclude: /(node_modules|bower_components)/,
                use: {
                    loader: 'babel-loader'
                }
            }, {
                test: /\.(css|scss)$/,
                include: /src/,
                use: [
                    // 'style-loader',
                    MiniCssExtractPlugin.loader,
                    'css-loader',
                    {
                        loader: 'postcss-loader',
                        options: {
                            postcssOptions: {
                                plugins: ['postcss-preset-env']
                            }
                        }
                    },
                    'resolve-url-loader',
                    'sass-loader'
                ]
            },
            // 处理图片资源
            {
                test: /\.(png|jpg|jpeg|gif)$/i,
                type: 'asset',
                parser: {
                    dataUrlCondition: {
                        maxSize: 4 * 1024 // 4kb
                    }
                },
                generator: {
                    filename: '[path]/[name]-[contenthash:8][ext]'
                }
            },
            // svg特殊处理,获取更小的体积
            {
                test: /\.svg$/,
                type: 'asset/inline',
                generator: {
                    dataUrl: content => svgToMiniDataURI(content.toString())
                }
            },
            // 字体
            {
                test: /\.(woff|woff2|eot|ttf|otf)$/i,
                type: 'asset/resource'
            }
        ]
    },
    plugins: [
        // 自定义打印进度
        new webpack.ProgressPlugin({
            handler(percentage, message, ...args) {
                const curProgressMessage = `${Math.floor(percentage * 100)}% ${message} ${args && args.join ? args.join(' ') : ''}`;
                if (curProgressMessage !== preProgressMessage) {
                    // eslint-disable-next-line
                    console.log(curProgressMessage);
                    preProgressMessage = curProgressMessage;
                }
            }
        }),
        // 将 CSS 提取到单独的文件中
        new MiniCssExtractPlugin({
            filename: arg => `${arg.chunk.name.replace('js', 'style')}.css`
        }),
        ...HtmlWebpackPlugins,
        new webpack.DllReferencePlugin({
            context: path.resolve(__dirname, '../'),
            manifest: dllMainFest
        }),
        new AddAssetHtmlPlugin({
            filepath: path.resolve(__dirname, './dll/*.dll.js'),
            outputPath: '../dist/src/static/js',
            publicPath: '../../src/static/js'
        })
    ],
    optimization: {
        moduleIds: 'deterministic',
        runtimeChunk: {
            // 提取runtime内容
            name: 'src/static/js/runtime-bunld'
        },
        minimizer: [
            '...',
            new CssMinimizerPlugin()
        ]
    }
};

webpack.dev.js

/*
 * Created by king at 2022-1-20 22:16:12
 * Copyright (c) 2022
 */

const webpack = require('webpack');
const { merge } = require('webpack-merge');
const { app } = require('./config/app');
const config = require('./webpack.config');

const port = 8000 + Math.floor(Math.random() * 100);
const host = 'local.test.com';

module.exports = merge(config, {
    plugins: [new webpack.HotModuleReplacementPlugin()],
    devServer: {
        compress: true,
        historyApiFallback: false,
        port,
        host,
        open: {
            target: 'view/brand/home.html',
            app: {
                name: app
            }
        }
    }
});

命令

生成DLL

$ webpack --config ./webpack/webpack.dll.js

生成打包结果

$ webpack --config ./webpack/webpack.config.js

本地开发

$ webpack serve --config ./webpack/webpack.dev.js

总结

本文重点介绍重点在于构建一套通用打包体系,包括一些细节,在内容取舍上没有涉及到诸如模块联邦等使用场景不够通用的新特性。此外诸如esbuild等新兴技术并未采用,而是采用了比较成熟可靠的Webpack,这点读者可以自行考量。在下一篇文章中我们会给大家介绍现代前端项目的另一个重要内容,即代码质量保障,包括单元测试以及代码规范等

相关阅读