说明:在拉钩前前后后,反反复复地学习了几遍webpack, 终于把webpack相关脉络理清楚了。
总的笔记思路是:
- 什么是webpack(what)?
- webpack是怎么实现的(how)?
- webpack vs 开发效率(souceMap, HRM)
- webpack vs 打包结果优化:缩小体积(Tree-Shaking, SideEffects)
- webpack vs 打包结果优化:物极必反,适当拆分 (Code Splitting)
- webpack vs 打包速度优化:提速 (hackpack,分环境配置)
- webpack vs rollup vs parcel (横向对比)
懒得排版:
Q&A
Q: 什么原因让webpack出现?什么需求?背景
A:
- 能够将散落的模块打包到一起;
- 能够编译代码中的新特性;
- 能够支持不同种类的前端资源模块。
Q: 如何让VSCode在你编写webpack.config.js文件时有智能提示呢?
A:
// -------用ES Modules
// ./webpack.config.js
// 一定记得运行 Webpack 前先注释掉这里。
// import { Configuration } from 'webpack'
/**
* @type {Configuration}
*/
const config = {
entry: './src/index.js',
output: {
filename: 'bundle.js'
}
}
module.exports = config
// ------用typeScript的特性
// ./webpack.config.js
/** @type {import('webpack').Configuration} */
const config = {
entry: './src/index.js',
output: {
filename: 'bundle.js'
}
}
module.exports = config
- webpack.config.js配置文件是在node环境用到的,所以需要按照CommonJS的方式编写导入导出
- 正常是以.js文件为入口,通过
import './style.css'
去加载其他类型的模块
其实 Webpack 不仅是建议我们在 JavaScript 中引入 CSS,还会建议我们在代码中引入当前业务所需要的任意资源文件。因为真正需要这个资源的并不是整个应用,而是你此时正在编写的代码。这就是 Webpack 的设计哲学。
loader 机制,加载不同的资源
- webpack 本身默认只能处理js,只要一个默认的js-loader,如果需要处理其他类型的文件,需要配置其他的loader,比如css-loader,ts-loader.
- 配置的时候,module里面配置use 的时候,不仅可以配置模块名称,还可以配置路径,这点和node里面的require()是一样的。
// ./webpack.config.js
module.exports = {
entry: './src/main.js',
output: {
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.md$/,
// 直接使用相对路径
use: './markdown-loader'
}
]
}
}
- loader 其实就是一个js函数,接受加载到的文件内容,返回处理后的文件内容,同时返回的内容必须是js语法,可以通过
module.exports
或者exports default
导出,当然这里指最终使用的是js语法,如果你有多个loader,那么前面的loader可以不用返回js内容,多个loader配合,只要确保最后loader打包的内容就js语法内容就行。
// ./markdown-loader.js
const marked = require('marked')
module.exports = source => {
// 1. 将 markdown 转换为 html 字符串
const html = marked(source)
// html => '<h1>About</h1><p>this is a markdown file.</p>'
// 2. 将 html 字符串拼接为一段导出字符串的 JS 代码
const code = `module.exports = ${JSON.stringify(html)}`
return code
// code => 'export default "<h1>About</h1><p>this is a markdown file.</p>"'
}}
plugin机制 除了加载之外的其他自动化工作
- 实现自动在打包之前清除 dist 目录(上次的打包结果);
clean-webpack-plugin
- 自动生成应用所需要的 HTML 文件;
html-webpack-plugin
- 根据不同环境为代码注入类似 API 地址这种可能变化的部分;
- 拷贝不需要参与打包的资源文件到输出目录;
copy-webpack-plugin
- 压缩 Webpack 打包完成后输出的文件;
- 自动发布打包结果到服务器实现自动部署。
- 自定义插件:Webpack 要求我们的插件必须是一个函数或者是一个包含 apply 方法的对象
接收一个 compiler 对象参数,这个对象是 Webpack 工作过程中最核心的对象
// ./remove-comments-plugin.js
// ./remove-comments-plugin.js
class RemoveCommentsPlugin {
apply (compiler) {
compiler.hooks.emit.tap('RemoveCommentsPlugin', compilation => {
// compilation => 可以理解为此次打包的上下文
for (const name in compilation.assets) {
if (name.endsWith('.js')) {
const contents = compilation.assets[name].source()
const noComments = contents.replace(/\/\*{2,}\/\s?/g, '')
compilation.assets[name] = {
source: () => noComments,
size: () => noComments.length
}
}
}
})
}
}
webpack核心工作过程
入口 -> 解析各种文件引入,形成依赖树 -> 根据依赖树,借助loader,打包 -> 在打包过程中,如果有plugin,即hooks 就执行 -> 输出到dist目录。
webpack 提高开发效率
原始流程:编写源代码 → Webpack 打包 → 运行应用 → 浏览器查看
期待的流程:
- 首先,它必须能够使用 HTTP 服务运行而不是文件形式预览。这样的话,一来更接近生产环境状态,二来我们的项目可能需要使用 AJAX 之类的 API,以文件形式访问会产生诸多问题。
- 其次,在我们修改完代码过后,Webpack 能够自动完成构建,然后浏览器可以即时显示最新的运行结果,这样就大大减少了开发过程中额外的重复操作,同时也会让我们更加专注,效率自然得到提升。
- 最后,它还需要能提供 Source Map 支持。这样一来,运行过程中出现的错误就可以快速定位到源代码中的位置,而不是打包后结果中的位置,更便于我们快速定位错误、调试应用。
- webpack
--watch
+serve
静态文件服务器
流程是:修改代码 → Webpack 自动打包 → 手动刷新浏览器 → 预览运行结果
- webpack
--watch
+BrowserSync
流程是:修改代码 → Webpack 自动打包 → 自动刷新浏览器 → 预览运行结果
- 操作烦琐,我们需要同时使用两个工具,那么需要了解的内容就会更多,学习成本大大提高;
- 效率低下,因为整个过程中, Webpack 会将文件写入磁盘,BrowserSync 再进行读取。过程中涉及大量磁盘读写操作,必然会导致效率低下。
- 官方开发工具
webpack-dev-server
//安装 webpack-dev-server
$ npm install webpack-dev-server --save-dev
//运行 webpack-dev-server
$ npx webpack-dev-server
webpack-dev-server 为了提高工作速率,它并没有将打包结果写入到磁盘中,而是暂时存放在内存中,内部的 HTTP Server 也是从内存中读取这些文件的。这样一来,就会减少很多不必要的磁盘读写操作,大大提高了整体的构建效率。
- devServer 配置
devServer: {
contentBase: path.join(__dirname, 'dist'),
compress: true,
port: 9000,
proxy: {
'/api': {
target: 'https://api.github.com',
pathRewrite: {
'^/api': '' // 替换掉代理地址中的 /api
},
changeOrigin: true // 确保请求 GitHub 的主机名就是:api.github.com
}
}
// ...
// 详细配置文档:https://webpack.js.org/configuration/dev-server/
}
contentBase 属性可以是一个字符串或者数组, 所以开发阶段就先不用copy-webpack-plugin了,因为:在开发过程中,我们会频繁重复执行打包任务,假设这个目录下需要拷贝的文件比较多,如果每次都需要执行这个插件,那打包过程开销就会比较大,每次构建的速度也就自然会降低
webpack 调试
- source map
在压缩文件的最后一行,添加 //# sourceMappingURL=jquery-3.4.1.min.map, Chrome 会根据 map 生成源文件。可以在生成的源文件上面进行debug
webpack中配置source-map
// ./webpack.config.js
module.exports = {
devtool: 'source-map' // source map 设置
}
各种模式的对比图
- eval
js中,eval() 可以指定字符串执行的执行环境,
具体就是在 eval 函数执行的字符串代码中添加一个注释,
注释的格式:
# sourceURL=./path/to/file.js,
这样的话这段代码就会执行在指定路径下。
在 eval 模式下,Webpack 会将每个模块转换后的代码都放到 eval 函数中执行,并且通过 sourceURL 声明对应的文件路径,这样浏览器就能知道某一行代码到底是在源代码的哪个文件中。
因为在 eval 模式下并不会生成 Source Map 文件,所以它的构建速度最快,但是缺点同样明显:它只能定位源代码的文件路径,无法知道具体的行列信息
特点:这种名字中带有 module 的模式,解析出来的源代码是没有经过 Loader 加工的,而名字中不带 module 的模式,解析出来的源代码是经过 Loader 加工后的结果
一般用:cheap-module-eval-source-map
(一般watch, 代码只需定位到行,一般用vue 所以需要module)
- HMR 模块热更新
刷新时,保留状态
使用
// ./webpack.config.js
const webpack = require('webpack')
module.exports = {
// ...
devServer: {
// 开启 HMR 特性,如果资源不支持 HMR 会 fallback 到 live reloading
hot: true
// 只使用 HMR,不会 fallback 到 live reloading
// hotOnly: true
},
plugins: [
// ...
// HMR 特性所需要的插件
new webpack.HotModuleReplacementPlugin()
]
}
样式的热更新很简单,是因为,样式有后面新新加的样式可以覆盖前面的样式的特性,处理起立简单,热更新只要新加上去就行。 ---style-loader 做的
图片也是简单替换
js 得手动编写 热更新的 代码
HMR的api
// ./main.js
// ... 原本的业务代码
module.hot.accept('./editor', () => {
// 当 ./editor.js 更新,自动执行此函数
console.log('editor 更新了~~')
})
那也就是说一旦这个模块的更新被我们手动处理了,就不会触发自动刷新;反之,如果没有手动处理,热替换会自动 fallback(回退)到自动刷新。
hot 方式,如果热替换失败就会自动回退使用自动刷新,而 hotOnly 的情况下并不会使用自动刷新
Webpack 打包结果优化
- Tree Shaking(去掉没用到的代码)--- production 模式会自动开启
Tree-shaking 并不是指 Webpack 中的某一个配置选项,而是一组功能搭配使用过后实现的效果
启用:
// ./webpack.config.js
module.exports = {
// ... 其他配置项
optimization: {
// 模块只导出被使用的成员
usedExports: true,
// 压缩输出结果
minimize: true,
// 尽可能合并每一个模块到一个函数中
concatenateModules: true,
sideEffects: true
}
}
usedExports - 打包结果中只导出外部用到的成员; minimize - 压缩打包结果。
usedExports 的作用就是标记树上哪些是枯树枝、枯树叶; minimize 的作用就是负责把枯树枝、枯树叶摇下来。
concatenateModules 配置的作用就是尽可能将所有模块合并到一起输出到一个函数中,这样既提升了运行效率
// 指定不能treeShaking的副作用代码(影响全局的)
"sideEffects": [
"./src/extend.js",
"*.css"
]
// or
"sideEffects": false
sideEffects 会先检查这个模块所属的 package.json 中的 sideEffects 标识,以此来判断这个模块是否有副作用,如果没有副作用的话,这些没用到的模块就不再被打包。换句话说,即便这些没有用到的模块中存在一些副作用代码,我们也可以通过 package.json 中的 sideEffects 去强制声明没有副作用
(webpack.config.js中,sideEffects 是表示是否开启这个功能, package.json是表面:当前模块是否有副作用代码-让 Webpack 放心大胆地去“干”。 )
!! Tree-shaking 前提是 ESModule !! babel-loader(可能需要设置不转换es6, 才能生效)
- 物极必反 - 代码分包Code Splitting
前面的打包:是将多个模块打包到一个,并且已经通过Tree-shaking尽量将打包结果缩小了,但是,最终物极必反,项目大起来,最终打包的结果还是很大,可能到4~5M, 所以,这种时候,要开始“逆行”,进行拆分。-- Code Splitting
- 一般有一下两种拆分方式。
- 根据业务不同配置多个打包入口,输出多个打包结果;(意味着弄成多个单页面应用)
- 结合 ES Modules 的动态导入(Dynamic Imports)特性,按需加载模块。
- 多入口,多出口
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
entry: {
index: './src/index.js',
album: './src/album.js'
},
output: {
filename: '[name].bundle.js' // [name] 是入口名称
},
// ... 其他配置
plugins: [
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/index.html',
filename: 'index.html',
chunks: ['index'] // 指定使用 index.bundle.js
}),
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/album.html',
filename: 'album.html',
chunks: ['album'] // 指定使用 album.bundle.js
})
]
}
但如果只是这样:如果一些公用组件,就会被重复打包, 还要开启splitChunks 提取公共组件,示例只是其中一种配置,文档
// ./webpack.config.js
module.exports = {
entry: {
index: './src/index.js',
album: './src/album.js'
},
output: {
filename: '[name].bundle.js' // [name] 是入口名称
},
optimization: {
splitChunks: {
// 自动提取所有公共模块到单独 bundle
chunks: 'all'
}
}
// ... 其他配置
}
- 动态导入 ESM 的import().then()
不用太多配置,只需要在引入模块的时候,采用import() 语法就会自动分包了,不过,如果需要对分包进行命名,就需要使用
魔法注释了
// 魔法注释
import(/* webpackChunkName: 'posts' */'./posts/posts')
.then(({ default: posts }) => {
mainElement.appendChild(posts())
})
除此之外,魔法注释还有个特殊用途:如果你的 chunkName 相同的话,那相同的 chunkName 最终就会被打包到一起,例如我们这里可以把这两个 chunkName 都设置为 components,然后再次运行打包,那此时这两个模块都会被打包到一个文件中
提高构建速度+优化打包结果(生产)
- 将配置文件分类:development vs production vs common (开发阶段注重效率和调试方便,生产环境注重代码运行效率:体积大小)
- 使用
[happypack](https://github.com/amireh/happypack)
启用多进程打包提高打包速度
一些production自动启用的有用的插件plugins:
- DefinePlugin
DefinePlugin 是用来为我们代码中注入全局成员的。在 production 模式下,默认通过这个插件往代码中注入了一个 process.env.NODE_ENV
// ./webpack.config.js
const webpack = require('webpack')
module.exports = {
// ... 其他配置
plugins: [
new webpack.DefinePlugin({
// 值要求的是一个代码片段
API_BASE_URL: JSON.stringify('https://api.example.com')
})
]
}
- Mini CSS Extract Plugin
对于 CSS 文件的打包,一般我们会使用 style-loader 进行处理,这种处理方式最终的打包结果就是 CSS 代码会内嵌到 JS 代码中。 mini-css-extract-plugin 是一个可以将 CSS 代码从打包结果中提取出来的插件
// ./webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module.exports = {
mode: 'none',
entry: {
main: './src/index.js'
},
output: {
filename: '[name].bundle.js'
},
module: {
rules: [
{
test: /\.css$/,
use: [
// 'style-loader', // 将样式通过 style 标签注入
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin()
]
}
使用了上面的插件之后,打包分离出了CSS文件,但之前我们开启的压缩,只是针对js 文件,所以需要下面的插件来压缩CSS文件
- Optimize CSS Assets Webpack Plugin
使用这个插件来压缩我们的样式文件。
// ./webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
module.exports = {
mode: 'none',
entry: {
main: './src/index.js'
},
output: {
filename: '[name].bundle.js'
},
module: {
rules: [
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin(),
new OptimizeCssAssetsWebpackPlugin()
]
}
可能你会在这个插件的官方文档中发现,文档中的这个插件并不是配置在 plugins 数组中的,而是添加到了 optimization 对象中的 minimizer 属性中。具体如下:
// ./webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
module.exports = {
mode: 'none',
entry: {
main: './src/index.js'
},
output: {
filename: '[name].bundle.js'
},
optimization: {
minimizer: [
new OptimizeCssAssetsWebpackPlugin()
]
},
module: {
rules: [
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin()
]
}
其实也很简单,如果我们配置到 plugins 属性中,那么这个插件在任何情况下都会工作。而配置到 minimizer 中,就只会在 minimize 特性开启时才工作。
所以 Webpack 建议像这种压缩插件,应该我们配置到 minimizer 中,便于 minimize 选项的统一控制。
但是这么配置也有个缺点,此时我们再次运行生产模式打包,打包完成后再来看一眼输出的 JS 文件,此时你会发现,原本可以自动压缩的 JS,现在却不能压缩了。
因为我们设置了 minimizer,Webpack 认为我们需要使用自定义压缩器插件,那内部的 JS 压缩器就会被覆盖掉。我们必须手动再添加回来。
内置的 JS 压缩插件叫作 terser-webpack-plugin
// ./webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
const TerserWebpackPlugin = require('terser-webpack-plugin')
module.exports = {
mode: 'none',
entry: {
main: './src/index.js'
},
output: {
filename: '[name].bundle.js'
},
optimization: {
minimizer: [
new TerserWebpackPlugin(),
new OptimizeCssAssetsWebpackPlugin()
]
},
module: {
rules: [
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin()
]
}
VS其他打包工具 Rollup.js
提供一个高效的 ES Modules 打包器,充分利用 ES Modules 的各项特性,构建出结构扁平,性能出众的类库
$ npm i rollup --save-dev
npx rollup
// ./rollup.config.js
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'es' // 输出格式
}
}
在这个配置当中我们导出了一个数组,数组中的每个成员都是一个单独的打包配置,这样 Rollup 就会分别按照每个配置单独打包。这一点与 Webpack 非常相似
// ./rollup.config.js
// 所有 Rollup 支持的格式
const formats = ['es', 'amd', 'cjs', 'iife', 'umd', 'system']
export default formats.map(format => ({
input: 'src/index.js',
output: {
file: `dist/bundle.${format}.js`,
format
}
}))
- 使用插件
Rollup 自身的功能就只是 ES Modules 模块的合并,如果有更高级的要求,例如加载其他类型的资源文件或者支持导入 CommonJS 模块,又或是编译 ES 新特性,这些额外的需求 Rollup 同样支持使用插件去扩展实现
Webpack 中划分了 Loader、Plugin 和 Minimizer 三种扩展方式,而插件是 Rollup 的唯一的扩展方式。
@rollup/plugin-json 加载json文件
// ./rollup.config.js
import json from '@rollup/plugin-json'
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'es'
},
plugins: [
json()
]
}
@rollup/plugin-node-resolve 加载NPM模块
Rollup 默认只能够按照文件路径的方式加载本地的模块文件,对于 node_modules 目录中的第三方模块,并不能像 Webpack 一样,直接通过模块名称直接导入。
// ./rollup.config.js
import json from '@rollup/plugin-json'
import resolve from '@rollup/plugin-node-resolve'
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'es'
},
plugins: [
json(),
resolve()
]
}
@rollup/plugin-commonjs 支持import 导入 CommonJS
Code splitting
用动态导入语法就能实现按需加载的代码分割 import().then()
不过配置文件的输出需要改一下
// ./rollup.config.js
export default {
input: 'src/index.js',
output: {
// file: 'dist/bundle.js', // code splitting 输出的是多个文件
dir: 'dist',
format: 'es'
}
}
输出格式问题
目前采用的输出格式是 es,所以自动分包过后,得到的代码还是使用 ES Modules 实现的动态模块加载
解决这个问题的办法就是修改 Rollup 打包输出的格式。目前所有支持动态导入的输出格式中,只有 amd 和 system 两种格式打包的结果适合于浏览器环境
- 通过以上的探索,我们发现 Rollup 确实有它的优势:
- 输出结果更加扁平,执行效率更高;
- 自动移除未引用代码;
- 打包结果依然完全可读。
但是它的缺点也同样明显:
- 加载非 ESM 的第三方模块比较复杂;
- 因为模块最终都被打包到全局中,所以无法实现 HMR;
- 浏览器环境中,代码拆分功能必须使用 Require.js 这样的 AMD 库.
综合以上特点,我们发现如果我们开发的是一个应用程序,需要大量引用第三方模块,同时还需要 HMR 提升开发体验,而且应用过大就必须要分包。那这些需求 Rollup 都无法满足。
而如果我们是开发一个 JavaScript 框架或者库,那这些优点就特别有必要,而缺点呢几乎也都可以忽略,所以在很多像 React 或者 Vue 之类的框架中都是使用的 Rollup 作为模块打包器,而并非 Webpack。
VS其他打包工具 Parcel
零配置,只需要简单的几个命令
入口建议是 .html