Webpack
Setup
npm install webpack webpack-cli webpack-dev-server -D
npx webpack
npx webpack serve --open
Mode
development
,即开发模式。此时代码没有tree shaking
、也没有经过压缩处理。production
,即生产模式,是配置的默认值。此时打包ES
模块时默认开启tree shaking
,代码经过压缩处理。
Entry
webpack
能够从一个入口模块出发,递归查找所有被依赖的模块,将其打包生成构建产物。通常来说一个入口文件对应一个构建产物js
(称之为chunk
),但是通过代码分割技术(见后续章节),一个入口文件是能够对应多个构建产物js
(多个chunk
,主要那个chunk
被称为initial-chunk
,其余的都被称为non-initial-chunk
)的。
// 单文件入口
entry: './src/index.js',
entry: {
home: './src/index.js'
}
// 多文件入口
entry: {
home: './src/index.js',
test: './src/test.js'
},
Output
用来指示构建产物的存放路径和文件名等信息
entry: {
home: './src/index.js',
test: './src/test.js'
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'js/[name].[contenthash].bundle.js', // initial-chunk的文件名
chunkFilename: '[contenthash].js', // non-initial-chunk的文件名
clean: true, // 每次构建清空构建目录,以前用clean-webpack-plugin实现
},
publicPath
一般本地开发时该字段取默认值即可,而在进行生产环境部署时,我们通常会将静态资源部署到TOS中并借助CDN实现资源的缓存,因此此时publicPath
通常为该TOS的地址,如:
module.exports = {
output: {
publicPath: 'https://tos.xxx.com/yyy/'
}
}
此时我们构建后生成的HTML页面中是通过类似这样的形式引用静态资源的
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<script defer src="https://tos.xxx.com/yyy/js/main.5883839b305c23966b80.js"></script></head>
<body>
<div id="root">hello</div>
</body>
</html>
library
// webpack.config.js
output: {
library: {
type: 'umd',
}
},
Module
Webpack默认只能理解JavaScript模块之间的引用关系,为了引用非JavaScript文件我们需要通过loader来将目标文件转化为我们可以理解的内容。
需要注意的是loader
的执行顺序是从右往左,如['style-loader', 'css-loader']
表示当被依赖的模块是css
文件时,会先将css
文件内容传给css-loader
处理,处理后的结果再传给style-loader
处理,最终处理的结果会被依赖该css
文件的模块所使用。
loader
style-loader
主要用于动态生成style标签实现样式的插入。
css-loader
The
css-loader
interprets@import
andurl()
likeimport/require()
and will resolve them.
除此之外还提供了CSS Modules的能力(默认情况下只有.module.css
的文件才能使用该功能,可通过options.modules: true
来令所有css
文件都能这样引用)
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
}
/* style.module.css */
.Root {
background: 'pink'
}
import { Root } from './style.module.css'
function App() {
return <div className={Root}></div>
}
sass-loader
npm i sass sass-loader -D
{
test: /\.s?css$/i,
use: ['style-loader', 'css-loader', 'sass-loader'],
}
@svgr/webpack
用于将SVG转化为React组件。
npm i @svgr/webpack -D
{
test: /\.svg$/i,
issuer: /\.[jt]sx?$/,
use: ['@svgr/webpack'],
}
import Star from './star.svg'
const Example = () => (
<div>
<Star />
</div>
)
esbuild-loader
{
test: /\.(t|j)sx?$/,
loader: 'esbuild-loader',
options: {
loader: 'tsx', // Or 'ts' if you don't need tsx
target: 'es2015',
},
},
asset modules
webpack5通过asset modules内置了Webpack4中raw-loader
、url-loader
、file-loader
的功能
type/resource
等同于
file-loader
module.exports = {
module: {
rules: [
// webpack5
{
test: /\.png/,
type: 'asset/resource',
generator: {
filename: 'static/[hash][ext][query]',
},
},
// webpack4 使用file-loader实现
{
test: /\.png$/,
use: [
{
loader: 'file-loader',
},
],
},
]
},
}
import mainImage from './images/main.png';
img.src = mainImage; // '/dist/151cfcfa1bd74779aadb.png'
type/inline
等同于
url-loader
module.exports = {
module: {
rules: [
// webpack5
{
test: /\.svg/,
type: 'asset/inline'
},
// webpack4 使用url-loader实现
{
test: /\.svg$/,
use: [
{
loader: 'url-loader',
options: {
limit: 8192, // 资源大小大于该值时自动换成file-loader处理
}
},
],
},
]
}
}
import svg from './images/default.svg';
el.style.background = `url(${svg})`; // url(data:image/svg+xml;base64,xxxxxx)
type
根据资源的大小自动选择type/resource
和type/inline
,url-loader
其实也内置了file-loader
,以前也是一样通过url-loader
根据资源的大小选择不同的处理方式
type/source
等同于
raw-loader
module.exports = {
module: {
rules: [
// webpack5
{
test: /\.txt/,
type: 'asset/source'
},
// webpack4 使用raw-loader实现
{
test: /\.txt$/,
use: [
{ loader: 'raw-loader' },
],
},
]
}
}
Hello world
import txt from './hello.txt'
console.log(txt) // hello world
Plugins
插件,顾名思义,就是对webpack
功能进行拓展。
内置插件
DefinePlugin
该插件在编译时对源码中的变量进行替换。
module.exports = {
plugins: [
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),
});
],
};
ProvidePlugin
Automatically load modules instead of having to
import
orrequire
them everywhere.
- 作用一:在每个TSX文件中都手动引入React会显得比较麻烦,可以通过该插件自动加载模块。
module.exports = {
plugins: [
new webpack.ProvidePlugin({
React: 'react',
})
],
};
- 作用二:Webpack5不再默认提供Node核心模块的Poyfill,因此需要我们自行解决。其中对于像
process
、Buffer
这类的Node内置变量我们可以通过该插件来提供Poyfill,而对于import buffer from 'buffer'
、import stream from 'stream'
这样的模块我们需要使用resolve.fallback
来提供Poyfill
module.exports = {
plugins: [
new webpack.ProvidePlugin({
process: 'process/browser',
Buffer: ['buffer/', 'Buffer'], // 相当于 require('buffer/').Buffer
}),
]
}
第三方插件
html-webpack-plugin
每次构建时都根据模板HTML文件生成新的HTML文件,并会自动引入我们打包后的JS产物。
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpackConfig = {
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'public/index.html'),
}),
],
};
Resolve
extensions
module.exports = {
resolve: {
extensions: [".js", ".mjs", ".cjs", ".jsx", ".tsx"]
}
}
import test from './app' // 检索各种后缀,如app.mjs、app.cjs
alias
导入模块时的别名。
const path = require('path');
module.exports = {
resolve: {
alias: {
Test: path.join(__dirname, 'src/test/'),
},
},
};
import Test from 'Test/index.js' // src/test/index.js
fallback
当解析一个模块失败时提供一个向后兼容的选项。一种常见的情况是项目所引用的第三方库引用了Node内置模块,此时我们需要将其替换成对应的可用模块。
module.exports = {
resolve: {
fallback: {
stream: require.resolve('stream-browserify'), // npm i stream-browserify
buffer: require.resolve('buffer/') // npm i buffer
}
}
}
mainFields
Devtool
构建的时候生成sourceMap
module.exports = {
devtool: 'source-map'
};
source-map
在构建产物index.js
同目录下生成index.js.map
,同时index.js
末尾会附上//# sourceMappingURL=index.js.map
function A() {}
//# sourceMappingURL=index.js.map
Inline-source-map
将sourceMap
通过内联的方式附在构建产物index.js
的末尾
function A() {}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2Zxxxxxxxxxx
eval-source-map
构建产物index.js
内部实现变成通过eval
执行模块对应的代码,并在eval
的末尾内联sourceMap
(热知识,eval
可以在代码末尾内联sourceMap
来方便eval
执行出错时进行调试)
// index.js 伪代码
var __webpack_modules__ = {
138: () => {
eval(
"const test = __webpack_require__(4)\n\ntest()//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2Zxxxxxxxxxx\n//# sourceURL=webpack-internal:///138\n"
);
},
4: (module) => {
eval(
"module.exports = function test(a) {\n let arr = [];\n console.log(arr[4].age);\n return 'test'\n}//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2Zxxxxxxxxxx\n//# sourceURL=webpack-internal:///4\n"
);
},
},
另外使用eval-source-map
构建的产物可以在浏览器source
的webpack-internal://
一栏看到每个模块的源码
DevServer
static
module.exports = {
devServer: {
open: true,
port: 9100,
static: {
directory: path.join(__dirname, 'dist'),
publicPath: "/",
}
}
}
Hot Module Replacement
Webpack中存在两个容易混淆的概念,Live Reloading(对应配置中的liveReload
字段)和Hot Module Replacement(对应配置中的hot
字段,简称为HMR,又被称为热加载Hot Reloading)
- Live Reloading。当监听到任何依赖中的文件修改后,通知浏览器重新刷新页面,此时页面状态全部丢失。
- Hot Module Replacement。浏览器与本地服务器之间建立WebSocket连接,当检测到本地文件修改时服务器将主动通知浏览器,浏览器将会获取修改后的新模块进行局部替换,从而实现状态的保存。
可以看出HMR在Live Reloading的基础上做了进一步体验提升,默认情况下Webpack会开启HMR(即hot: true
),此时需要在业务代码中手动实现新模块的接收与替换(即module.hot.accept
),如果我们没有实现该功能,Webpack则会自动降级成Live Reloading,即刷新完整的页面。
不同种类的项目中,模块替换的实现自然存在着差异,拿React项目举例的话一下代码实现了一个非常简陋的HMR,此时当我们修改Child.tsx时,浏览器会主动向服务器发送xxx.hot-update.json
和xxx.hot.js
请求获取新模块的内容。
if (module.hot) {
module.hot.accept('./Child.tsx', function() {
ReactDOM.render(<App />, document.getElementById('root'));
})
}
当然这样的实现是很脆弱的,因此建议使用React官方提供的实现。以前通常使用React-Hot-Loader
来实现,但是现在推荐使用最新的React Fast Refresh
proxy
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:9100', // 把接口代理给本地后端服务器
changeOrigin: true,
},
},
}
}
Optimization
runtimeChunk
module.exports = {
optimization: {
runtimeChunk: 'single',
},
}
splitChunksPlugin
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
maxInitialRequests: Infinity,
minSize: 15000,
cacheGroups: {
babel: {
test: /[\\/]node_modules[\\/]@babel[\\/]/,
minChunks: 1,
chunks: 'all',
name: 'babel',
priority: 20,
},
}
},
},
}
Externals
import _ from 'lodash'
console.log(_)
假设我们的代码是这样的,在某些情况下我们可能希望webpack
构建的时候不把lodash
也打包进去。事实上这种场景是很常见的。
场景一:我们的HTML
已经通过外链引用了lodash
,所以构建产物自然不希望包括lodash
,这样做的好处是我们能够在开发过程中通过代理来将外链的lodash
替换成本地的lodash
代码,方便开发调试。
场景二:我们正在开发一个插件,通过yarn add lodash -P
把lodash
作为一个peer dependencies
安装,这意味着对于该插件的使用者需要自行安装lodash
,我们需要使用externals
来把lodash
从构建的产物中排除。
module.exports = {
externals: {
lodash: '_'
}
}
场景三:我们正在开发一个后端库,与前端库相比后端库其实并不需要将库所依赖的第三库模块(node_modules
)和Node
内置模块也打包,通常我们会用webpack-node-externals
来排除这些模块
const nodeExternals = require('webpack-node-externals');
module.exports = {
externalsPresets: { node: true }, // webpack5,对于webpack4的target: node
externals: [nodeExternals()]
}
Tree Shaking
Tree Shaking是JavaScript上下文中经常出现的术语,用于表示DCE(dead-code elimination),它依赖于ES模块所提供的静态导入和导出语法import
和export
。通过实现Tree Shaking,能够剔除代码中未使用或无法触达的代码,从而减小产物的体积实现性能的优化。
Tree Shaking最先是由Rollup引入的概念,后面在Webpack中也得到了实现。Webpack中开发模式下默认不开启Tree Shaking,生产模式下默认开启Tree Shaking,而我们又知道两个模式的区别在于一些配置项的默认值不同,因此我们也可以自行配置来实现Tree Shaking。
先说结论,在开发模式下也可以通过开启optimization
的usedExports
、minimize
、concatenateModules
这三个配置项来达到生产模式下默认提供的Tree Shaking效果,那么接下来我们只需了解这几个配置项分别做了什么事情即可。
通过开启usedExports
选项,Webpack构建时会在产物中通过形如/* unused harmony export <name> */
的注释标识出未被使用到的导出,再通过开启minimize
选项,默认会通过terser
来优化代码并将这些标识出来的未用代码剔除,最后再通过concatenateModules
实现模块的连接,暂且不提。
但即使开启了Tree Shaking,构建产物中依然可能存在一些我们所不期望的代码,这是因为通常模块内不仅包含导入和导出,还可能存在一些副作用(如函数的直接调用等),而通常Tree Shaking会采取保守的策略在最终的产物中包含这些副作用的代码以避免潜在的问题。拿以下的简单例子来说,在index.tsx
文件中我们引入了App组件但并没使用,因此相关代码会被Tree Shaking剔除,但在test.tsx
中存在着memo
这个高阶函数的调用,这种函数的直接调用会被视为副作用并且会被保留在最终的产物当中。
import App, { test } from './test'
console.log(test())
import React from 'react'
function App() {
return <div>app</div>
}
export function test() {
return 'test'
}
export default React.memo(App)
如果我们能确信某些副作用是完全的内部副作用,即可以被安全的移除的内容,那么我们可以将相关的语句或者模块标识为Pure或sideEffects: false
,从而在Tree Shaking的时候把这些无需引用的代码剔除,实现进一步的减小产物的体积。
还是以上述的代码为例,只需要在合适的语句前添加/*#__PURE__*/
注释即可有效的剔除无用的代码,我们能够观察到构建后代码的数量得到有效的减少。
export default /*#__PURE__*/React.memo(App)
除了这个方法,我们还可以在package.json
中的sideEffects
中表明哪些文件存在副作用。拿第三方库ahooks
举例,它的配置是"sideEffects": false
,表明模块不存在外部副作用(即可能没有副作用,或者是内部副作用,不会影响外部逻辑)。再拿antd
举例,它的配置如下:
{
"sideEffects": [
"dist/*",
"es/**/style/*",
"lib/**/style/*",
"*.less"
],
}
一般来说CSS
文件的引用方式都形如import './style.css'
,这种是很明显有外部副作用,如果把这些样式相关的代码都剔除肯定会影响应用的展示效果。
代码分割
常见的代码分割方式有以下几种
- 使用多入口而非单一入口构建
- 使用
splitChunksPlugin
把公共依赖或是第三方库(如lodash
、Jquery
)提取到一个单独的chunk
中 - 使用
import()
动态加载模块
动态加载
webpack
中每个文件都是一个模块。
从一个entry
文件开始打包所依赖的所有模块,可以得到一个包括一个thunk
的thunkGroup
如果有多个entry
,那么打包之后得到的是多个thunkGroup
,每个thunkGroup
包括一个thunk
。
包括一个thunk
的thunkGroup
听起来有点奇怪,什么时候包括多个thunk
呢?通常是使用动态加载import()
时
// webpack.config.js
entry: './src/index.js'
// index.js
import('./test.js').then(() => {
ReactDOM.render(
<App />,
document.querySelector('#root')
)
})
通过webpack
,我们的dist
会生成两个js
文件,或者说是两个main.js
和[id].js
(这里的id
是个随机数字)。
这里的/dist/main.js
称为initial thunk
;/dist/[id].js
称为non-initial thunk
。
其中initial thunk
的名字可以在output.filename
中指定;而non-initial thunk
的名字可以在output.chunkFileName
中指定,除此之外也可以使用Magic comment
来指定,如:
// index.js
import(
/* webpackChunkName: "akara" */
'./test.js'
).then(() => {
ReactDOM.render(
<App />,
document.querySelector('#root')
)
})
这样我们得到的non-initial thunk
文件名就是akara.js
现在,在我们的index.html
引入main.js
时,main.js
会自动地加载akara.js
文件。
原理
webpack
可以打包ES
模块和CommonJS
模块。
webpack
把每个文件模块都当成一个对象var module = { exports: {}}
。并通过对文件模块的解析来给该对象赋予属性,如ES
模块对应的形式如
// ES模块 a.js
export default function() {
console.log('111')
}
export function A() {
console.log('222')
}
// 打包后对应的对象
var module = {
exports: {
default: function() { console.log('111') }, // 严格来讲这里是getter
A: function() { console.log('222') }, // 同理,此处为了看起来简单
}
}
而由于CommonJS
模块没有默认导出,所以对应的打包后对象也不存在default
属性。
// CommonJS模块 b.js
module.exports.A = function() {
console.log('111');
}
module.exports.B = function() {
console.log('222');
}
// 打包后对应的对象
var module = {
exports: {
A: function() { console.log('111') },
B: function() { console.log('222') },
}
}
当我们在webpack
导入模块时,require
返回模块整体导出module.exports
;import * as xxx from
也可以整体导入模块,或者是导入模块的不同导出接口,包括default
接口。
至于如何分辨属于何种模块,则根据module.__esModule
判断,这个属性是由__webpack_require__.r
定义的。