webpack学习笔记
虽然自己是前端工程师,无论是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,可以看到控制台重新输出了:
这显然是刷新了页面而重新输出了,并没有我们想要的热更新效果,也就是保持页面状态的情况更新有改动的模块,这是因为我们还没告诉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的原理大致这样的:构建 bundle 的时候,加入一段 HMR runtime 的 js 和一段和服务沟通的 js 。文件修改会触发 webpack 重新构建,服务器通过向浏览器发送更新消息,浏览器通过 jsonp 拉取更新的模块文件,jsonp 回调触发模块热替换逻辑。
我们可以在dev-tools看到ws的通信过程:
具体的原理可以参考这篇文章: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
参考:
- webpack中文文档
- 拉钩2021最新webpack5
- 学习 Webpack5 之路(基础篇)
- 学习 Webpack5 之路(实践篇)
- 学习 Webpack5 之路(优化篇)
- Webpack 转译 Typescript 现有方案
一些问题