webpack学习笔记

959 阅读12分钟

虽然自己是前端工程师,无论是react还是vue项目,基本上都是基于webpack搭建的工程体系,每天一个yarn start启动项目却有点不知所以然,深感惭愧,所以决定学习webpack,本文是学习的记录。

什么是webpack

本质上,webpack 是一个用于现代 JavaScript 应用程序的 静态模块打包工具。当 webpack 处理应用程序时,它会在内部从一个或多个入口点构建一个 依赖图(dependency graph),然后将你项目中所需的每一个模块组合成一个或多个 bundles,它们均为静态资源,用于展示你的内容。

为什么前端项目采用webpack开发?

  • 采用模块化开发
  • 使用新特性保证开发效率
  • 热更新实时监听开发过程
  • 项目打包压缩优化

webpack功能

  • 打包:将不同的资源按模块处理进行打包
  • 静态:打包最终产出静态资源
  • 模块:支持不同规范的模块开发

核心概念

1. 依赖图(dependency graph)

每当一个文件依赖另一个文件时,webpack 都会将文件视为直接存在 依赖关系。这使得 webpack 可以获取非代码资源,如 images 或 web 字体等。并会把它们作为 依赖 提供给应用程序。

当 webpack 处理应用程序时,它会根据命令行参数中或配置文件中定义的模块列表开始处理。 从 入口 开始,webpack 会递归的构建一个 依赖关系图,这个依赖图包含着应用程序中所需的每个模块,然后将所有模块打包为少量的 bundle —— 通常只有一个 —— 可由浏览器加载。

2. 入口(entry)

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

module.exports = {
  entry: './src/index.js',
};

3. 输出(output)

output 属性告诉 webpack 在哪里输出它所创建的 bundle,以及如何命名这些文件。主要输出文件的默认值是 ./dist/main.js,其他生成文件默认放置在 ./dist 文件夹中。

const path = require('path');

module.exports = {
  entry: './path/to/my/entry/file.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'my-first-webpack.bundle.js',
  },
};

4. loader

webpack 只能理解 JavaScript 和 JSON 文件,这是 webpack 开箱可用的自带能力。loader 让 webpack 能够去处理其他类型的文件,并将它们转换为有效 模块,以供应用程序使用,以及被添加到依赖图中。

const path = require('path');

module.exports = {
  output: {
    filename: 'my-first-webpack.bundle.js',
  },
  module: {
    rules: [{ test: /\.txt$/, use: 'raw-loader' }],
  },
};

以上配置中,对一个单独的 module 对象定义了 rules 属性,里面包含两个必须属性:test 和 use。

loader中的use数组按顺序执行,从最后一项往前。

5. 插件(plugin)

loader 用于转换某些类型的模块,而插件则可以用于执行范围更广的任务。包括:打包优化,资源管理,注入环境变量。

const HtmlWebpackPlugin = require('html-webpack-plugin'); // 通过 npm 安装
const webpack = require('webpack'); // 用于访问内置插件

module.exports = {
  module: {
    rules: [{ test: /\.txt$/, use: 'raw-loader' }],
  },
  plugins: [new HtmlWebpackPlugin({ template: './src/index.html' })],
};

在上面的示例中,html-webpack-plugin 为应用程序生成一个 HTML 文件,并自动将生成的所有 bundle 注入到此文件中。

常见的插件有:

  • BundleAnalyzerPlugin:打包体积分析
  • MiniCssExtractPlugin:提取 CSS 到独立 bundle 文件
  • ProgressBarPlugin:编译进度条

在稍后我们还会介绍更多插件。

6. 模式(mode)

webpack5 提供了模式选择,包括开发模式(development)、生产模式(production)、空模式(none),并对不同模式做了对应的内置优化。可通过配置模式让项目性能更优。

7. resolve

resolve 用于设置模块如何解析。

常用配置

  • alias:配置别名,简化模块引入
  • extensions:在引入模块时可不带后缀
  • symlinks:用于配置 npm link 是否生效,禁用可提升编译速度
module.exports = {
    resolve: {
        extensions: ['.js', '.jsx', '.ts', '.tsx', '.json', '.d.ts'],
        alias: {
          '@': path.resolve(__dirname, 'src')
        },
        symlinks: false,
      }
}

实践

项目初始化

我们从零开始实践,从一个空项目做起。

首先我们创建一个目录,初始化 npm,然后 在本地安装 webpack,接着安装 webpack-cli(此工具用于在命令行中运行 webpack):

mkdir webpack-demo
cd webpack-demo
npm init -y
npm install webpack webpack-cli --save-dev

现在,我们将创建以下目录结构、文件和内容:

project

webpack-demo
 |- package.json
 |- config
    |- webpack.config.js
    |- webpack.dev.js
    |- webpack.prod.js
 |- /src
    |- index.js

使用一个配置文件

在 webpack中,可以无须任何配置,然而大多数项目会需要很复杂的设置,这就是为什么 webpack 仍然要支持 配置文件。这比在 terminal(终端) 中手动输入大量命令要高效的多,所以让我们创建一个配置文件:

webpack.config.js

const path = require('path');
const fs = require('fs');
const appDirectory = fs.realpathSync(process.cwd());
const resolveApp = relativePath => path.resolve(appDirectory, relativePath);

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: resolveApp('dist'),
  },
};

我们定义了入口为src下的index.js,以及输出的bundle路径为dist下的main.js。

webpack-merge

由于开发环境和生产环境插件比较大,但是又有很多公用的配置项,比如入口、出口或者loader等,所以我们需要将这些通用配置项合并到开发和生产环境,我们将使用一个名为 webpack-merge 的工具。通过“通用”配置,我们不必在环境特定的配置中重复代码。

npm install webpack-merge -D

webpack.dev.js和webpack.prod.js,我们先使用空配置

const { merge } = require('webpack-merge')
const config = require('./webpack.config')
module.exports = merge(config, {})

接下来我们过配置文件执行构建:

npx webpack --config config/webpack.dev.js --mode development

运行之后会在项目目录下看到有个dist文件夹,这就是我们打包的输出。

但是每次都输入这么长的命令会显得麻烦,要手动输入环境、配置文件等,我们可以在package.json中先定义好对应环境的启动参数。

cross-env

通过 cross-env 配置环境变量,区分开发环境和生产环境。

npm install cross-env -D 
// 生产环境
npx webpack cross-env NODE_ENV=production webpack --config config/webpack.prod.js
// 开发环境
npx webpack cross-env NODE_ENV=development webpack --config config/webpack.dev.js

NODE_ENV其实就是由nodeJS暴露给执行脚本的一个环境变量,通常用来帮助我们在构建脚本中判断当前是devlopment还是production环境,可以通过 process.env.NODE_ENV 取到值。

我们还希望在开发的时候每次变更代码后webpack都自动帮我们重新编译,所以我们还需要webpack-dev-server。

dev-server

webpack-dev-server是一个使用了express的Http服务器,它的作用主要是为了监听资源文件的改变,该http服务器和client使用了websocket通信协议,只要资源文件发生改变,webpack-dev-server就会实时的进行编译。 安装:

npm install webpack-dev-server -D

修改开发环境配置文件 webpack.dev.js:

module.exports = merge(common, {
  devServer: {
    // 告诉服务器从哪里提供内容,只有在你想要提供静态文件时才需要。
    contentBase: './dist',
  },
})

运行以上的配置

npx webpack cross-env NODE_ENV=development webpack serve --open --config config/webpack.dev.js

发现会报错

[webpack-cli] Invalid options object. Dev Server has been initialized using an options object that does not match the API schema.
- options has an unknown property 'contentBase'. These properties are valid:
  object { allowedHosts?, bonjour?, client?, compress?, devMiddleware?, headers?, historyApiFallback?, host?, hot?, http2?, https?, ipc?, liveReload?, magicHtml?, onAfterSetupMiddleware?, onBeforeSetupMiddleware?, onListening?, open?, port?, proxy?, setupExitSignals?, static?, watchFiles?, webSocketServer? }

报错内容表示webpack不认识'contentBase'这个参数,是因为现在默认安装的webpack-dev-server的版本是v4,相比v3有了不少的改动,具体可以看v3 到 v4 的迁移指南.

而我们的contentBase以及 contentBasePublicPath/serveIndex/watchContentBase/watchOptions/staticOptions都被移动到了 static配置项: webpack.dev.js:

devServer: {
        // 告诉服务器从哪里提供内容,只有在你想要提供静态文件时才需要。
        static: {
            directory: './dist'
        },
    },

最后修改 package.json:

"scripts": {
    "build": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
    "start": "cross-env NODE_ENV=development webpack serve --open --config config/webpack.dev.js"
  }

至此,我们就可以通过 npm run start从开发配置项启动项目,或者是npm run build 从生成配置构建项目了。

HtmlWebpackPlugin

执行打包后只生产了一个main.js,我们可以使用HtmlWebpackPlugin自动生成一个引入我们打包的main.js的HTML文件。

npm install html-webpack-plugin -D 

修改通用环境配置文件 webpack.config.js:

module.exports = {
  plugins: [
    // 生成html,自动引入所有bundle
    new HtmlWebpackPlugin({
      title: '起步',
    }),
  ],
}

loader

loader让 webpack 能够去处理其他类型的文件,并将它们转换为有效模块。

我们先从常见的css-loader讲起。

css-loader

我们在src下新建一个css文件: index.css

.hello{
    color:red
}

我们动态创建一个dom节点:

index.js

function hello() {
    let h2 = document.createElement('h2');
    h2.innerHTML = 'Hello Webpack';
    h2.className = 'hello';
    return h2;
}

document.body.appendChild(hello());

运行后会报错:

You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
> .hello {
|   color: red;
| }

因为webpack现在还不认识css,我们需要安装css-loader让webpack转化为它认识的模块,并用style-loader将转化的模块插入到dom的style节点。

npm install css-loader style-loader -D

webpack.config.js

module: {
        rules: [
            {
                test: /\.css$/,
                include: fs.realpathSync(process.cwd()),
                use: [
                    // 将 JS 字符串生成为 style 节点
                    'style-loader',
                    // 将 CSS 转化成 CommonJS 模块
                    'css-loader'
                ]
            }
        ],
}

这里use的顺序很重要,webpack会从数组的最后一项逐个往前执行,一定要用css-loader再用style-loader。

再次运行项目,看到打开的html中的字体变成了红色,说明webpack成功识别了css并应用到了html中。

css预处理则是需要在css-loader之前执行对应的预处理器的loader,比如sass-loader。

npm install sass sass-loader -D

{
    test: /\.(sass|scss)$/,
    include: fs.realpathSync(process.cwd()),
    use: [
        'style-loader',
        'css-loader',
        
        'sass-loader'
    ]
}

browserlist、postcss

我们知道,css在不同的浏览器平台、版本之间也是有兼容性的,所以我们就会有两个问题:如何兼容css以及兼容哪些平台。

先说兼容哪些平台,一般来说我们会有两种情况,一种是明确指出要兼容的目标平台或者版本,另一种的兼容市面上的主流浏览器和版本。

caniuse.com是一个用于查看浏览器对各种新特性的兼容情况的网站。

而browserslist可以帮助我们自动查询caniuse,我们只需要配置好相应的目标平台即可。webpack脚手架在安装的时候就帮我们安装了browserlist,我们可以在packpackage.json或者在项目根目录下创建.browserslistrc。

比如:

package.json

{
  "browserslist": [
    "> 1%",
     "last 2 version",
    "not dead"
  ]
}

表示查询市场占有率大于1%的浏览器,最新的两个版本,not dead表示不是不再维护的浏览器。

这时候我们已经解决了兼容平台的问题,接下来是如何实现兼容,我们使用postcss。

postcss是一个利用JavaScript转换样式的工具,需要配合各种插件对css进行处理。

postcss-preset-env是一个css插件集合,可以将现代 CSS 转换为大多数浏览器可以理解的内容,并根据目标浏览器或运行时环境确定所需的 polyfill。

npm install -D postcss postcss-loader postcss-preset-env

修改通用环境配置文件 webpack.config.js:

module: {
        rules: [
            {
                test: /\.css$/,
                include: fs.realpathSync(process.cwd()),
                use: [
                    'style-loader',
                    'css-loader',
                    {
                        loader: 'postcss-loader',
                        options: {
                            postcssOptions: {
                                plugins: ['postcss-preset-env']
                            }
                        }
                    }
                ]
            },
            {
                test: /\.(scss|sass)$/,
                include: fs.realpathSync(process.cwd()),
                use: [
                    'style-loader',
                    'css-loader',
                    {
                        loader: 'postcss-loader',
                        options: {
                            postcssOptions: {
                                plugins: ['postcss-preset-env']
                            }
                        }
                    },
                    'sass-loader'
                ]
            },
 
        ],
    },

也可以另外建立postcss.config.js配置postcss-loader。

最后还需要介绍一下css-loader的一个配置项,importLoaders——允许你配置在 css-loader 之前有多少 loader 应用于 @imported 资源与 CSS 模块 导入。

什么意思呢?比如说你有个css文件import了另一个css文件,此时按照loader的解析流程,可能是这样子的:sass-loader-->postcss-loader-->css-loader,此时已经到了css-loader这一层,解析import的文件只会按顺序走下去到style-loader,那么如果我们希望import的css资源也能往前使用loader解析,那么就需要在css-loader配置importLoaders,这是个number数值,默认是0,值表示需要往前执行多少个loader。

{
            loader: "css-loader",
            options: {
              importLoaders: 2,
              // 0 => no loaders (default);
              // 1 => postcss-loader;
              // 2 => postcss-loader, sass-loader
            },
},

那么以上便是在webpack中加载css的一些配置项了,接下来我们介绍一些其他常见资源的loader。

file-loader

我们在项目中还会用到图片的加载,可能是js文件中引入,也可能是css中引入, 如果是webpack4,你可能会见到这样子的loader配置:

{
    test: /\.(png|jpg|gif)$/,
    use: [
      {
        loader: 'file-loader',
        options: {},
      },
    ],
}

webpack5通过添加 4 种新的模块类型,来替换所有这些 loader:

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

配置如下:

{
    test: /\.(svg|png|jpe?g|gif)$/,
    type: 'asset/inline'
}

asset/resource 可以指定要复制和放置资源文件的位置,打包后可以在资源的引用中看到引用路径为: http://localhost:8080/xxxx.png

asset/inline 会将文件转换为内联的 base-64 URL,这会减少小文件的 HTTP 请求数。 打包后可以在资源的引用中看到引用路径为: data:image/png;base64,xxxxxx

我们也可以配置一个阈值指定使用asset/resource或者是asset/inline

{
    test: /\.(svg|png|jpe?g|gif)$/,
    type: 'asset',
    parser: {
        dataUrlCondition: {
            maxSize: 40 * 1024
        }
    }
}

以上配置了大于40kb的图片会使用asset/resource,否则使用asset/inline

babel-loader

浏览器只认识js文件,并且在不同的平台还有兼容性的问题。而我们写代码希望采用最高效率的新语法和模板文件(比如es6+的最新语法和jsx、vue),那么就要有一个转义器,把浏览器不认识的代码转换成浏览器可以识别的代码,这就是babel。

Babel 是一个工具链,主要用于将采用 ECMAScript 2015+ 语法编写的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。下面列出的是 Babel 能为你做的事情:

  • 语法转换
  • 通过 Polyfill 方式在目标环境中添加缺失的特性(通过第三方 polyfill 模块,例如 core-js,实现)
  • 源码转换 (codemods)
@babel/preset-env

安装

npm install -D babel-loader @babel/core @babel/preset-env webpack

webpack.config.js

{
      test: /\.m?js$/,
      exclude: /(node_modules|bower_components)/,
      use: {
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env']
        }
      }
}

也可以把babel的配置写在babel.config.js

babel-loader同样会根据Browserslist用来确定需要转译的 JavaScript 特性。

polyfill

Babel 包含一个可自定义的 regenerator runtime 和 core-js 的 polyfill。 它会仿效一个完整的 ES2015+ 环境,并意图运行于一个应用中而不是一个库/工具。这个 polyfill 会在使用 babel-node 时自动加载。

polyfill在代码中的作用主要是用已经存在的语法和api实现一些浏览器还没有实现的api,对浏览器的一些缺陷做一些修补。例如Array新增了includes方法,我想使用,但是低版本的浏览器上没有,我就得做兼容处理。

因为babel的转译只是语法层次的转译,例如箭头函数、解构赋值、class,对一些新增api以及全局函数 (例如:Promise)无法进行转译,这个时候就需要在代码中引入babel-polyfill,让代码完美支持ES6+环境。

webpack4会自动填充polyfill,webpack5需要手动引入,这可以让我们更自由的控制打包体积。

安装

npm install  core-js regenerator-runtime

webpack.config.js

{
    test: /\.m?js$/,
    exclude: /(node_modules|bower_components)/,
    use: {
        loader: 'babel-loader',
        options: {
            presets: [['@babel/preset-env', {
                // false:不对当前的js处理做polyfill的填充
                // usage:根据用户源代码当中所使用到的新语法进行填充
                // entry:根据当取筛选处理的浏览器决定填充内容
                useBuiltIns: "usage",
                // 默认值是2,但我们安装的版本是3
                corejs: {
                    version: "3"
                }
            }]]
        }
    }
}

插件

loader是转换特定的类型,在读取文件的时候使用,而插件可以做更多的事情。

比如上文介绍的HtmlWebpackPlugin,会自动生成一个html,引入所有的bundle文件。

我们再多介绍几个插件:

CleanWebpackPlugin

clean-webpack-plugin是一个清除文件的插件。在每次打包后,磁盘空间会存有打包后的资源,再次打包的时候,我们需要先把本地已有的打包后的资源清空,来减少它们对磁盘空间的占用。插件clean-webpack-plugin就可以帮我们自动做这个事情。

安装

npm install -D clean-webpack-plugin

在webpack.config.js文件中引入并使用

// 引入clean-webpack-plugin插件
const {CleanWebpackPlugin} = require('clean-webpack-plugin')
……

plugins: [
    new CleanWebpackPlugin(),
],

其实在webpack5中可以直接在output配置clean字段达到同样的效果,不需要这个插件:

output: {
    filename: 'main.js',
    path: resolveApp('dist'),
    clean: true,
}
CopyWebpackPlugin

打包时会涉及资源拷贝,我们不希望webpack对其进行打包,所以我们需要CopyWebpackPlugin。

npm install -D copy-webpack-plugin
const CopyPlugin = require("copy-webpack-plugin");

module.exports = {
  plugins: [
    new CopyPlugin({
      patterns: [
        { from: "source", to: "dest" },
        { from: "other", to: "public" },
      ],
    }),
  ],
};
DefinePlugin

DefinePlugin 允许在 编译时 将你代码中的变量替换为其他值或表达式。这在需要根据开发模式与生产模式进行不同的操作时,非常有用。例如,如果想在开发构建中进行日志记录,而不在生产构建中进行,就可以定义一个全局常量去判断是否记录日志。这就是 DefinePlugin 的发光之处,设置好它,就可以忘掉开发环境和生产环境的构建规则

由于本插件会直接替换文本,因此提供的值必须在字符串本身中再包含一个 实际的引号 。通常,可以使用类似 '"production"' 这样的替换引号,或者直接用 JSON.stringify('production')。

官网示例:

new webpack.DefinePlugin({
  PRODUCTION: JSON.stringify(true),
  VERSION: JSON.stringify('5fa3b9'),
  BROWSER_SUPPORTS_HTML5: true,
  TWO: '1+1',
  'typeof window': JSON.stringify('object'),
  'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
});

HMR

模块热替换(hot module replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允许在运行时更新所有类型的模块,而无需完全刷新。本页面重点介绍其实现,而 概念 页面提供了更多关于它的工作原理以及为什么它有用的细节。

开启HMR webpack.config.js

devServer: {
    ...
    hot: true,
}

我们写一个hmr.js,输出点东西

console.log("no-hmr");

在入口文件引入hmr.js,并且启动devServer,可以在浏览器的控制台看到输出的"no-hmr":

hmr-1 将代码中的输出改为hmr,可以看到控制台重新输出了:

hmr-2

这显然是刷新了页面而重新输出了,并没有我们想要的热更新效果,也就是保持页面状态的情况更新有改动的模块,这是因为我们还没告诉webpack这个模块需要热更新,我们在index.js配置:

在入口文件index.js加入:

if (module.hot) {
    module.hot.accept('./hmr.js', function () {
        console.log('Accepting the updated printMe module!');
    })
}

再次修改hmr.js,输出点东西

console.log("yes-hmr");

可以看到这次有了热更新的效果:

hmr-3 关于hmr的原理大致这样的:构建 bundle 的时候,加入一段 HMR runtime 的 js 和一段和服务沟通的 js 。文件修改会触发 webpack 重新构建,服务器通过向浏览器发送更新消息,浏览器通过 jsonp 拉取更新的模块文件,jsonp 回调触发模块热替换逻辑。

我们可以在dev-tools看到ws的通信过程:

image.png 具体的原理可以参考这篇文章:Webpack HMR 原理解析

一些容易混淆的配置

output的publicPath和devServer的publicPath

老版本的devServer有一个publicPath,对应现在版本的配置是static下的publicPath。

output中的publicPath

output的publicPath会为我们所有的资源都应用上publicPath设置的值,然后再接上资源对应转换出来的路径, 比如说我配置:

output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/wb',
    clean: true
}

npm run start后,默认打开的http://localhost:8080是没有东西的,要打开http://localhost:8080/wb,在head标签中可以看到:

<script defer="" src="/wb/out.js"></script>

我们看到的/wb/就是我们在output中设置的值,然后打包之后,它就会加在了js输出的路径上面,成为out.js的基础路径。

更多

静态资源的处理可以分生产和开发环境,开发环境我们可以在devserver配置静态文件的地址,生产环境可以用copyPlugin来拷贝静态资源。

typescript

使用ts仍然是要配置loader,我们有2个选择,ts-loader和@babel/preset-typescript。

ts-loader

安装

npm install ts-loader -D

初始化项目中ts的配置文件 tsconfig.json

tsc --init

配置

module: {
    rules: [
        {
            test: /\.ts$/,
            use: {
                loader: 'ts-loader'
            }
        }
    ]
}

故意写一个有错误的ts文件

const test = (s:string)=>{};
test(12)

运行打包后可以看到打包失败,报错内容:

TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

改正后不再报错。

说明ts-loader成功运行,但是此时有一个问题,就是没有babel的转译,如果我们要在ts文件做一些polyfill就不行了,这时候我们可以看一下另一个ts转译方案:

@babel/preset-typescript

安装

npm install -D @babel/preset-typescript

配置

module: {
    rules: [
        {
            test: /\.ts$/,
            use: {
                    loader: 'babel-loader',
                    options: {
                        presets: [['@babel/preset-typescript', { 
                            useBuiltIns: "usage",
                            corejs: {
                                version: "3"
                            }
                        }]]
                    }
                }
        }
    ]
}

仍然使用一个有语法错误的ts后打包,可以看到是直接打包成功的,所以babel的问题就在于不能对语法进行检测,让我们提早发现代码的错误。

有一种方案,可以让我们在编译之前检测错误,在package.json中修改:

"scripts": {
    "build": "tsc --noEmit && cross-env NODE_ENV=production webpack --config config/webpack.prod.js",

  }

在编译之前先检测代码的错误

打包工具的前景

  • snowpack
  • vite

参考:

一些问题