2023-08-16
前端工程化
00
请注意,本文编写于 473 天前,最后修改于 270 天前,其中某些信息可能已经过时。

目录

写作背景
介绍
定义
为啥Webpack难学
与其他构建工具的区别
1. HelloWrold
包介绍
解读 entry output
一些核心配置的概念
2. 加载CSS资源
先来看下module的配置
CSS配置
CSS 预处理器
browserslist
哪些工具依赖browserslist
browserslist配置规则
browserslist配置示例
Postcss
快速上手
postcss-loader
postcss-preset-env
3. 加载其他资源
加载图片
加载字体
4. 认识一些plugin
clean-webpack-plugin
html-webpck-plugin
DefinePlugin
copy-webpack-plugin
5. soure-map
Mode配置
source-map
(none)
eval
[eval-|inline-|hidden-]source-map
nosources-source-map
[cheap-[module-]]source-map
soucemap总结
6. Babel/Typscript/Eslint
使用babel编译ts
代码规范工具
7. devServer & HMR
devServer
HMR原理
devServer配置项
devServer.proxy
8. 环境分离/resolve/context
环境分离方案&demo
resolve模块解析
webpac模块解析的过程
配置目录别名
context
mini-css-extract-plugin
shimming
9.代码分割CodeSpitting
webpack代码分离的三中常用方式
Entry Dependencies
splitChunks
动态导入
代码懒加载
prefetch与preload
optimization.chunkIds
optimization.runtimeThunk
extenals
认识DLL库
10. 代码压缩
Terser
Terser在webpack中的配置
CSS的压缩
HTML文件中代码的压缩
HTTP压缩
11. TreeSharking
usedExports
sideEffects
CSS实现Tree Shaking
Scope Hoisting
总结

网上流传一句话 “webpack工程师 > 前端工程师”
Webpack作为这些年主流的前端构建工具(目前仍是),可以说它前端工程化的核心。
学好Webpack有助于塑造个人优势,塑造简历亮点。
本篇文章将深入讲解webpack绝大部分配置项,核心功能等。包括不限于以下内容

  1. 解读output input
  2. CSS资源加载相关loader
  3. browserlist配置规则及哪些工具支持browserlist
  4. 对字体、图片资源的加载
  5. 介绍常见的plugin
  6. 深入解读devtool sourcemap配置
  7. 讲解代码规范如何配置
  8. 深入讲解devServer配置,包含HMR原理,跨域方案
  9. 详解webpack的模块解析机制
  10. 详解webpack代码分割的三种方式,分包配置,懒加载预加载配置等
  11. 详解HTML/CSS/JS的代码压缩
  12. 详解Webpack中TreeSharking的两种方案

写作背景

这篇文章原写于去年8-9月份,发表在老博客上,因博客迁移,决定所有文章重新翻写整理,主要目的还是为了知识巩固和梳理。这篇文章主要参考 《小码哥深入Webpack5等构建工具(gulp/rollup/vite)》 视频课,包含视频课的大半内容(因为近3/4的内容都是讲webpack)。边听边实践,因为技术在变化,npm包也在升级,所以文章也融入了新的知识点,有些知识点也进行了扩展完善。总之本篇文章会非常的长,介绍了Webpack大部分配置及功能。
这里也友情提示一下,本文所有案例及demo演示 请点击这里

介绍

定义

webpack is a static module bundler for modern javascript applications

webpack 是一个静态的模块化打包工具, 为现代的JavaScript应用程序。

  • bundler打包工具:webpack可以将帮助我们进行打包,所以它是一个打包工具;
  • static静态资源:将代码打包成最终的静态资源(部署到静态服务器);
  • module模块化:webpack默认支持各种模块化开发,ESMCommonJSAMD等;
  • modern现代的:现代前端开发面临各种各样的问题,才催生了webpack的出现和发展;
    • 手动处理`css js兼容性问题
    • 资源文件处理
    • 开发效率(热更新、代码规范、代码错误提示)
    • 性能优化,TreeSharking 资源压缩合并等
    • CICD,代码压缩发布调试等
    • .......

为啥Webpack难学

  • 配置项繁杂无从下手,不知道如何验证;
  • 配置项之前也存在依赖甚至耦合问题;
  • webpack及生态下相关工具变迁很频繁,一个包的大版本往往伴随着很多api的变更;
  • 前端工程化领域的知识点非常多,如模块化、babelpostcsseslinttypescriptrollup等,这些工具是干什么的,之间有什么依赖,如何搭配使用等,让人头晕目眩迷失方向;
  • 项目开发中我们常用vue-cli/react-createapp,虽然这些脚手架都号称开箱即用,但具体如何配置,及面对业务的特殊性和复杂性无从下手(不知道cli配置项意味着什么,不知道cli配置项如何映射到webpack.config.js);
  • 就像脱离框架不知道怎样写js,离开cli不知道webpack怎么用,比如如何在原生项目中使用webpack,如何在vue项目中使用react

与其他构建工具的区别

Rollup

  • rollup是一个模块化打包工具,它默认只处理ES Module,当然也可以是通过添加Plugin处理其他模块(cjs, amd)。
  • rollup相比webpack功能要少很多,配置简单,构建后的代码易读,通常使用webpack构建应用程序,使用rolup构建类库

Vite
Vite号称 下一代的前端开发与构建工具

  • 官方是这样解释的,虽然有webpackrollupParcel等工具极大的改善了前端工程化的体验,但是面对大型项目JS代码指数增长,这些工具还是会遇到性能瓶颈,热更新及构建都比较耗时,然而vite要比上述工具快点多。(当Vite为什么快,具体怎样解决这些问题不属于本篇文章的范畴)
  • 虽然官方对Vite介绍的很美好,但是Webpack流行的这些年(2015-2022)沉淀了大量的企业项目,你说Vite能取代它吗?另外友情提示一下Vite的配置和Webpack的配置很像,高手都是借力打力,不会刻意重复造轮子,有造轮子的零件就不会自己再造零件。

Gulp

  • gulp是一个基于流(pipeline模式)的自动化构建工具
  • 它是一个工具包,可以帮你自动化和增加你的工作流
GulpWebpack
理念定义一系列任务然后执行,基于流的自动化构建工具模块化打包工具
底层原理pipeline设计模式基于tapable的微内核架构
优缺点gulp思想更加的简单、易用,
更适合编写一些自动化的任务
对于大型项目还是使用Webpack
gulp默认也是不支持模块化的

1. HelloWrold

包介绍

npm i webpack webpack/cli

  • webpack webpack核心 可类比babel的@babel/core 可以通过编程的方式运行webpack
  • webpack-cli 提供webpac命令行运行 可类比babel的 @babel/cli
    • 默认会在pkg所在目录寻找webpack.config.js, 如果没有会使用内置的配置
    • 命令行参数优先于配置文件,--config可以指定配置文件,关于命令行参数仅是语法糖而已,不是我们学习的重点
  • webpack默认支持JSJSON模块,如果想加载文件需要添加对应的loader
  • JS模块,默认支持 ESMCJSAMD

解读 entry output

javascript
const path = require('path'); module.exports = { entry: "./src/index.js", output: { filename: 'bundle.js', // path: './dist' // 必须是绝对路径 path: path.resolve(__dirname, './dist') } } // 执行 `npx webpack` 生成 `dist/bundle.js` // 如果想让bundle.js运行还需要用一个html引用该文件, // 借助vscode插件live-server或 http-server命令在浏览器上打开页面
  • entry 打包入口,可以类比 rollupinput,值可以是 StringArrayObject
    • String  单入口打包
    • Array   也是单入口打包, 会合并成一个文件,导出以最后一个文件为准
    • Object  多入口打包,需要将output.filename配合使用 filename: "[name].js"
  • output 打包后的文件输出配置
    • path      输出文件的目录, 必须是绝对路径,
    • filename  输出文件的文件名, 这里有一些占位符

output占位符

  • id    加载模块的序号
  • name   与enrty的文件名相对应
  • hash   整合项目的hash,每个文件的hash都是一样,只要有一个文件改动,所有资源文件的hash都会跟着变更,
  • chunkhash 根据entry进行解析,生成相应的hash, 每个文件的hash不一样,主文件变更不影响依赖文件的hash
  • contenthash 与chunhash区别很小,通常在生产环境需要CSS单独抽取成一个包,就用contenthash这样css改变,不改变js文件的hash
  • [ext]    处理文件扩展名
  • <length>  hash长度,默认32位字符
  • [path]   文件相对于webpack的路径

这里给一些示例:

  • output.filename: "[name]-[chunkhash:8].js"
  • url-loader 中的配置 options.name: "img/[name].[hash:8].[ext]"

一些核心配置的概念

  • loader  模块转换器,处理某种类型的文件
  • module  在webpack中万物皆模块,loader就在module里面配置,一个文件可能需要多个loader处理。module的配置非常灵活,在下面的CSS章节讲解
  • plugins  作用于webpack整个构建过程,webpack有一个生命周期钩子概念,plugin可以在webpack运行在某一阶段帮你做一些事情。(这些钩子底层是使用tapable实现的)
  • devtool   目标代码与源码之间的映射关系 即sourcemap
  • devServer  开发环境一些配置 如热更新,接口代理等
  • optimization 性能优化相关 如treeSharking 代码分割 DII缓存等
  • resolve   配置别名 省略文件后缀外部资源配置等
  • mode     webpack4新增的, 有三个值 none production development

2. 加载CSS资源

先来看下module的配置

javascript
module: { rules: [ { test: /\.xxx$/, // 指定匹配的文件类型 必须是正则表达式 use: [ // 一种类型的文件可能需要多个经过多个loader进行处理 // loader 的执行顺序是从后到前 { loader: 'xxx-load', // 指定使用的loader // 该loader的配置项,不同loader配置项不同需要查看官方文档 options: {} }, { // 。。。 其他loader } ] } ] } // 1. 如果loader没有配置项可简写为 'xxx-load' // 2. 如果该类型的文件只需要一个loader处理 则use可以简写成 ['xxx-loader'] // 3. 还可以用 loader: 'xxx-loader' 它是 Rule.use:[{loader}] 的简写

CSS配置

如果我们直接在js文件中引入csswebpack编译会报错

image.png

报错提示我们添加一个合适的loader处理该类型的文件

javascript
module: { rules: [ { test: /\.css$/, use: [ { loader: 'style-loader' }, { loader: 'css-loader' } ] } ] }
  • css-loader  只负责解析css插入到js文件中
  • style-loader 将js文件中的css代码插入到dom

通过添加loader处理其他资源只是其一方法(也是最主要的方式),事实上webpack有三种方式处理其他资源,以css为例子

  • 内敛方式 import"style-loader!css-loader!./css/style.css";
  • cli方式  --module-bind
    • 该方式在webpack5文档中已经废弃了

CSS 预处理器

javascript
// 配置less npm i -D less less-loader { test: /\.less$/, use: [ { loader: 'style-loader' }, { loader: 'css-loader' }, { loader: 'less-loader' }, ] }, // 配置scss npm i -D dart-sass sass sass-loader { test: /\.scss$/, use: [ { loader: 'style-loader' }, { loader: 'css-loader' }, { loader: 'sass-loader' }, ] }
  • 关于sassdart-sassnode-sass两种编译器可选,官方推荐 dart-sass
    • 先有的node-sass 后来才有 dart-sass
    • dart-sass性能好 (node-sass实时编译,dart-sass 保存时编译)
    • node-sass国内经常出现安装失败,node-sassnode版本有依赖关系

然后我们尝试在css中引入less @import "./test.less"发现 test.less并未生效,这时候则要使用css-loader配置项了

javascript
{ test: /\.css$/, use: [ { loader: 'style-loader' }, { loader: 'css-loader', options: { importLoaders: 1 // 后面有几个loader配置几 } }, { loader: 'less-loader' }, ] },

browserslist

我们知道babel为我们提供了将es6+代码编译为es5(目标浏览器可以运行的)代码,那样CSS有没有类似的工具吗?
-- 肯定是有的

postcss@babel/core
postcss-cli@babel/cli
postcss-preset-env@babel/preset-env

它和babel一样也会读取.browserslitrc文件或pkg.browserslist字段
再说postcss之前,我们先来看一下browserslist

哪些工具依赖browserslist

**browserslist**是一个不同的前端工具之间,共享目标浏览器和Node.js的版本配置
以下工具在使用时会读取browserslist配置

Q: 我们知道市场上有大量的浏览器,它们的市场占有率多少?我们要不要兼容?
A: 可以使用caniuse查询 点这里

Q:browserlist如何根据配置计算需要兼容的目标浏览器呢?
A: 它使用的是caniuse-lite的工具,这个工具的数据来自于caniuse的网站上;

browserslist配置规则

browserslist 可以通过.browserslistrc文件或者pkg.browserslist字段配置
可以通过browserslist命令 检查配置是否正确 也可以在线查询
例如 npx browserslist ">1%, last 2 version, not dead"

和前面的工具(lesssasswebpack)一样,browserslist也有cli
npm i -D browserslist

配置项介绍

  • defaults 默认配置> 0.5%, last 2 versions, Firefox ESR, not dead
  • 5% 通过全局使用情况统计信息选择的浏览器版本。 可以使用>=<<=这些符号。
    • 5% in US:使用美国使用情况统计信息。它接受两个字母的国家/地区代码。
    • > 5% in alt-AS:使用亚洲地区使用情况统计信息。有关所有区域代码的列表,请参见caniuse-lite/data/regions
    • > 5% in my stats:使用自定义用法数据。
    • > 5% in browserslist-config-mycompany stats:使用自定义的数据 browserslist-config-mycompany/browserslist-stats.json
    • cover 99.5%:提供覆盖率的最受欢迎的浏览器。
    • cover 99.5% in US:与上述相同,但国家/地区代码由两个字母组成。
    • cover 99.5% in my stats:使用自定义用法数据。
  • dead:24个月内没有官方支持或更新的浏览器。现在是IE 10IE_Mob 11BlackBerry 10BlackBerry 7Samsung 4OperaMobile 12.1
  • last 2 versions:每个浏览器的最后2个版本。
    • last 2 Chrome versions:最近2个版本的Chrome浏览器。
    • last 2 major versionslast 2 iOS major versions:最近2个主要版本的所有次要/补丁版本。
  • node 10node 10.4:选择最新的Node.js10.x.x10.4.x版本。
    • current node:使用当前环境中的Node.js版本。
    • maintained node versions:所有Node.js版本,仍由 Node.js Foundation维护。
  • iOS 7:直接使用iOS浏览器版本7。
    • Firefox > 20Firefox的版本高于20, >=<并且<=也可以使用。它也可以与Node.js一起使用。
    • ie 6-8:选择一个包含范围的版本。
    • Firefox ESR:最新的Firefox ESR版本。
    • PhantomJS 2.1PhantomJS 1.9:选择类似于PhantomJS运行时的Safari版本。
  • extends browserslist-config-mycompany:从browserslist-config-mycompanynpm包中查询
  • supports es6-module:支持特定功能的浏览器。 es6-module这是“我可以使用” 页面feat的URL上的参数。有关所有可用功能的列表,请参见 。caniuse-lite/data/features
  • browserslist config:在Browserslist配置中定义的浏览器。在差异服务中很有用,可用于修改用户的配置,例如 browserslist config and supports es6-module
  • since 2015last 2 years:自2015年以来发布的所有版本(since 2015-03以及since 2015-03-10)。
  • unreleased versionsunreleased Chrome versionsAlphaBeta版本。
  • not ie <= 8:排除先前查询选择的浏览器。

Q: 我们编写了多个条件之后,多个条件之间是什么关系呢?
A:image.png

browserslist配置示例

  1. pc端web项目
text
>1% last 2 version not dead
  1. 移动端项目 假设要兼容android4.4和ios8系统
text
Android >= 4.4 ios >= 8
  1. 移动端项目 假设要兼容android7.0和ios12以上的系统
text
chrome >= 52 ios >= 12

这里有个疑问,为什了android 7.0不能通过Android >= 7这种配置?
这要要了解下android的webview, 可以看我的另篇文章 android系统webview, 上述配置 android 7.0系统的webview大致对应 chrome52版本
可以通过以下两个链接去匹配android4.4以上系统的webview对应chrome的版本

Postcss

postcss 是一个通过JS来进行CSS的转换和适配,比如增加浏览器前缀,浏览器样式初始化,将CSS新语法转换成目标浏览器可以支持的CSS,但是实现这些功能要借助postcss插件。

  • npm i -D postcss postcss-cli

快速上手

css
.content { user-select: none; display: flex; color: red; }
  • 运行 npx postcss --use autoprefixer -o ./dist/end.css ./src/css/start.css

  • 编译后的代码如下

css
.content { -webkit-user-select: none; -moz-user-select: none; user-select: none; display: flex; color: red; } /*# sourceMappingURL=data:application/json;base64,eyJ2ZXJ......
  • 如果你想看到flex前缀的效果,可以配置.browserslist如下
txt
Android >= 4 firefox > 20
  • 再次执行命令
css
.content { -webkit-user-select: none; -moz-user-select: none; user-select: none; display: -webkit-box; display: -moz-box; display: flex; color: red; } /*# sourceMappingURL=data:application/json;base64,eyJ2ZXJ......
  • autoprefixer 在线查询

  • 我们以可以使用postcss配置文件 postcss.config.js

js
module.exports = { plugins: [ require('autoprefixer'), ] }
  • 执行 npx postcss -o ./dist/end.css ./src/css/start.css 得到一样的效果

真实的项目中我们肯定不会用命令行对css进行处理,以上演示只是为了快速了解上手postcss

postcss-loader

webpack中添加postcss-loader来集成postcss的功能

javascript
{ loader: 'postcss-loader', options: { postcssOptions: { plugins: [ require('autoprefixer') ] } } }

postcss-preset-env

目前来说autoprefixer已经过时了,被postcss-preset-env替代了 它处理实现了autoprefixer的功能还可以帮助我们将一些现代的css转换成大部分浏览器可以识别的css 比如使用了16进制颜色这里有8位

  • env.css
css
.content { background-color: #12345678; }
  • postcss.config.js
js
module.exports = { plugins: [ require('postcss-preset-env'), ] }
  • 执行 npx postcss -o ./dist/env.css ./src/css/env.css

  • dist/env.css

css
.content { background-color: rgba(18,52,86,0.47059); }

总结cssloader配置顺序, 以scss为例

js
module.exports = { // ...其他配置项 module: { rules: [ { test: /\.scss$/, use: [ 'style-loader', // 或者其他的 CSS 注入 loader { loader: 'css-loader', options: { // 解决.css文件中引入.scss文件编译问题 importLoaders: 2 } }, 'postcss-loader' // 这里不写配置项会读取 .postcss.config.js 'sass-loader', // 先执行它,loader从下到上执行 ] } ] } };

3. 加载其他资源

加载图片

  • src/index.js
js
import "./css/index.css"; import smallImageUrl from './img/38.jpg' const smallImage = new Image(); smallImage.src = smallImageUrl; document.body.appendChild(smallImage); const bigImage = document.createElement('div'); bigImage.className = 'big-img'; document.body.appendChild(bigImage);
  • src/css/index.css
css
.big-img { background-image: url("../img/295.png"); background-size: contain; height: 200px; width: 200px; display: inline-block; background-color: red; }
  • webpack module.rules增加代码如下
js
{ test: /\.(je?pg|png|gif)$/i, // webpack5 不再需要file-loader url-loader raw-loader // 它内置了 资源模块类型 asset module type type: 'asset', generator: { // 也可以配置 output.assetModuleFilename: 'img/[name].[chunkhash:6][ext]' filename: 'img/[name].[hash:6][ext]' }, parser: { dataUrlCondition: { maxSize: 100 * 1024, } } }

webpack5 新增了静态文件类型asset module type
取代了file-loader url-loader``raw-loader

  • asset/resource 发送一个单独的文件并导出URL, 替代file-loader
  • asset/inline 导出一个资源的data URI (base64) ,替代 url-loader
  • asset/source 导出资源的源代码, 替代raw-loader
  • asset 可以配置资源大于某个值,使用单独文件方式,反之使用base64方式,取代url-loader + limit配置

尽管file-loaderurl-loader不用了,但是很多项目中还是存在这两个loader,这里有必要再简单说一下

  • file-loader 用来处理静态资源,当我们只是包资源文件移动到打包目录就需要用file-loader
javascript
{ test: /\.(je?pg|png|gif)$/, use: [ { loader: 'file-loader', options: { // name: "img/[name].[hash:6].[ext]", name: "[name].[hash:6].[ext]", outputPath: 'img' } } ] }
  • url-loader 默认将静态资源以base64的方式引入, 可通过配置项limit设定小文件走base64, 大文件走url, 网上也有中说法:它是file-loader的加强版
javascript
{ test: /\.(je?pg|png|gif)$/, use: [ { loader: 'url-loader', options: { name: "[name].[hash:6].[ext]", outputPath: 'img', limit: 50 * 1024, // 50kb 一般配置10kb都顶天了,这里只是为了演示 } } ] }

注意事项

  1. 使用url-loaderfile-loader 要安装css-loader@5 若最新的css-loader6.7.1,则资源文件无法正确运行

加载字体

  • src/index.js
js
// 创建一个i元素, 设置一个字体 import "./font/iconfont.css"; const iEl = document.createElement("i"); iEl.className = "iconfont icon-ashbin why_icon"; document.body.appendChild(iEl);
  • font/iconfont.css

  • font/iconfont.eot

  • font/iconfont.ttf

  • font/iconfont.ttf2

  • 配置loader

json
{ test: /\.ttf|eot|woff2?$/i, type: "asset/resource", generator: { filename: "font/[name].[hash:6][ext]" } }

4. 认识一些plugin

  • plugin 作用于webpack的生命周期中,执行更广泛的任务 如打包优化、资源管理、环境变量注入等

image.png

clean-webpack-plugin

之前我们每次打包都需要手动清一下dist目录
或者使用其他辅助方式 如 rm -rf ./dist && npx webpack
这种方式的缺点是一旦我们webpack的配置output.path改变,命令也需要跟着改变
clean-webpack-plugin就不存在上述问题

  • webpack.config.js
js
const { CleanWebpackPlugin } = require('clean-webpack-plugin'); module.exports = { // ... plugins: [ new CleanWebpackPlugin(), ] }

html-webpck-plugin

前面构建后的静态文件想要运行,我们手动写了一个html导入,这是不太规范的,我们希望能够自动化构建。

  • 自动导入打包资源
  • 根据模版生成html 如配置title icon

示例

  • public/index.html (内容copy自vue-cli项目)
html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width,initial-scale=1.0"> <link rel="icon" href="<%= BASE_URL %>favicon.ico"> <title><%= htmlWebpackPlugin.options.title %></title> </head> <body> <noscript> <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> </noscript> <div id="app"></div> <!-- built files will be auto injected --> </body> </html>
  • favicon.ico (copy自vue-cli项目)

  • 配置wepack.config.js

js
const HtmlWebpackPlugin = require('html-webpack-plugin'); const { DefinePlugin } = require('webpack'); module.exports = { plugins: [ // 1. 如果不配置template选项会使用该插件默认的模版 // new HtmlWebpackPlugin({ // title: 'webpack html 模版', // }), new HtmlWebpackPlugin({ title: 'webpack html 模版', // 对应html <%= htmlWebpackPlugin.options.title %> template: './public/index.html' }), new DefinePlugin({ BASE_URL: JSON.stringify('./') }) ] }
  • <% 变量 %>ejs模版语法, html-webpack-plugin使用的是ejs模版动态填充数据
  • HtmlWebpackPlugin传入json数据 在模版中通过 htmlWebpackPlugin.options.xxx获取
  • <%= BASE_URL %> 这个是全局变量需要使用DefinePlugin传值,DefinePluginwebpack内置的插件

DefinePlugin

  • DefinePlugin 定义的全局变量除了在html中使用,还可以在js中使用,通常用来做按配置打包
  • webpack.config.js
js
module.exports = { entry: ["./src/index.js", "./src/define-plugin.js"], plugins: [ new DefinePlugin({ ENV: JSON.stringify('development') }) ] }
  • src/define-plugin.js
js
if(ENV === 'development') { console.log('你将在控制台看到这段内容') } else { console.log('这段内容既不会在控制台打印,也不会在bundle.js找到'); } // 上述代码会经过DefinePlugin处理编译成如下代码 // console.log('你将在控制看看到这段内容')

copy-webpack-plugin

上述的案例有个问题就是 favicon.ico 没能正确加载,
favicon.icoindex.html一样在 public目录,
在vue-cli项目中,public目录的文件除了index.html都要copy到dist目录,
这时候就用到了copy-webpack-plugin

  • webpack.config.js
js
new CopyWebpackPlugin({ patterns: [ { from: 'public', globOptions: { ignore: [ "**/.DS_Store", "**/index.html" ] } } ] })

5. soure-map

再说source-map之前简单介绍下mode

Mode配置

Mode配置选项,可以告知webpack使用响应模式的内置优化

  • 默认值是production(什么都不设置的情况下);
  • 可选值有:'none' | 'development' | 'production'

image.png

developmentproduction的区别

image.png

source-map

mdn-source-map介绍
最初source-map生成的文件带下是原始文件的10倍,第二版减少了约50%,第三版又减少了50%,所以目前一个 133kb的文件,最终的source-map的大小大概在300kb。

image.png

准备工作

  • src/js/index.js
js
import '../css/index.css'; import {add} from './math' console.log('add(1,2,3) ', add(1,2,3));
  • src/js/math.js
js
export function add(...arr) { console.log(wesd); return arr.reduce((sum, item) => sum + item, 0); }
  • src/css/index.css
css
html, body { height: 100%; width: 100%; } body { background-color: aquamarine; }

(none)

mode: 'production'的默认值 不填devtools,它的默认值就是(none)

image.png

image.png

  • 无法定位报错是哪个js
  • 无法定位css来自哪个文件

eval

mode: 'development' 的默认值

image.png

image.png

  • 可以错误是哪个js文件及定位到报错行列,但是js文件不太干净
  • 无法定位到css样式文件

image.png

  • eval的原理是浏览器对eval代码字符串后面的 //# sourceURL= 有特殊的解析

[eval-|inline-|hidden-]source-map

source-map

image.png

image.png

  • 可定位到js错误所在文件及行列号,文件内容与源码一致
  • 可定位到样式文件

image.png

eval-source-map

  • source-map效果一样,只是不单独生成source-map文件,而是把source-map放在eval函数中

image.png

inline-source-map

  • source-map效果一样,不单独生成source-map文件, 而是放到打包文件末尾。

image.png

hidden-source-map

  • source-map效果一致,只是隐藏了source-map链接

(删除了 //# sourceMappingURL=bundle.js.map

nosources-source-map

image.png

  • 会生成source-map,能定位到错误文件,但看不到内容(没有sourcesContent

[cheap-[module-]]source-map

cheap是廉价的意思 会生成source-map但更高效一些因为他没有列映射

image.png

cheap-source-mapcheap-module-source-map很类似,但是对源自loader的source-map处理会更好,比如当你使用babel的时候,你会发现后者的source-map更友好

cheap-source-mapcheap-module-source-map 对比准备工作

  • webpack.config.js增加loader
js
{ test: /\.js$/, use: [ { loader: 'babel-loader', options: { presets: [ '@babel/preset-env' ] } } ] }
  • index.js
js
// CommonJS导出内容, es module导入内容 import {dateFormat} from './CommonJS.js'; // es module导出内容, CommonJS导入内容 const {add} = require('./math'); console.log('add(1,2,3) ', add(1,2,3)); console.log('dateFormat() ', dateFormat()); console.log(123+x);
  • CommonJS.js math.js自己准备吧

image.png

image.png

  • 此外后者 cheap-module-source-map还有一个优势,能定位到css文件

soucemap总结

事实上webpack给我们提供了26个值!
别慌,因为他们是可以组合的 [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map

  • [inline-|hidden-|eval]:三个值时 三选一或不选 共4种
  • nosources:可选值;              共2种
  • cheap: 可选值,并且可以跟随module的值;     共3种

423=24+eval+(none)缺省值=264 * 2 * 3 = 24种 + eval + (none)缺省值 = 26种
那么我们应该选择哪一种呢?

  • 开发测试阶段 source-map或者cheap-module-source-map
  • 发布阶段   (none)hidden-source-map 更推荐 (none)

6. Babel/Typscript/Eslint

关于babel/eslint,在我的其他文章中有细节的讲解(《Babel深入浅出》 《Eslint深入浅出》),但是这里还是大致提一嘴核心知识点。 关于TS,笔者也有单独的篇幅讲TS,这里把这些知识点用webpack串起来。

使用babel编译ts

  1. babeltypescript都可以翻译es6+,推荐使用@babel/preset-typescript翻译es6,原因是
    1. typescript只翻译es6句法,不提供es6API的垫片,这是致命的缺点
    2. babel更灵活,可以根据目标浏览器按需转译,而TS只能转移到指定ECMA版本
    3. @babel/preset-typescript的主要缺点是丢失了类型检测,可以使用typescript单独做类型检测 npx tsc --noEmit --watch
    4. 。。。 还有其他细节 不重要了
  2. 关于babel支持ts 有的项目使用的是 @babel/plugin-transform-typescript,笔者更推荐使用@babel/preset-typescript, 理由是后者包含前者

image.png

代码规范工具

  1. ESLint Prettier EditorConfig三者之间关系
    1. EditorConfig提供配置磨平不同编辑器代码书写是的差异,作用于代码书写和预览阶段
      1. 比如,缩紧用空格还是tab ?几个空格长度?
    2. Prettier 处理代码风格问题,作用于代码保存和提交阶段,格式化快捷键
      1. 如关键字距离,逗号样式,一行最大长度,是否可以是用空格和tab等等
    3. Eslint 不仅是关注代码风格,还关注代码质量
      1. 代码质量,如是否定义了未使用的变量,引用了为定义的变量,永远执行不到的代码等等 可以避免低级错误
      2. 关于代码风格这一块做的不全且只支持JS/TSPrettier更全面,且还支持HTML/CSS/Markdown 等文件
      3. 一般通过命令npx eslint --fix src格式化代码
  2. 三者之间是否有冲突?如何解决?
    1. 有冲突,最笨的解决方案是相同作用的配置保持一致
    2. PrettierEditorConfig冲突
      1. 首先看一下配置优先级 Prettier 配置 > editorconfig 配置 > Prettier 默认值
      2. editorconfig作用的文件类型更广,相交属性交给editorConfig管理,其他属性交给prettier管理
    3. ESLintPreitter冲突
      1. 直接使用代码最佳实践,如 eslint-config-airbnbeslint-config-standard这时候可以不使用prettier
      2. 使用eslint-plugin-prettier关闭ESLint中与Prettier交叉的配置,
      3. 或者使用eslint-plugin-prettier插件,将prettier融合到eslint

示例代码

  1. 配置 .editorconfig 文件
    vscode需安装插件
ini
# https://editorconfig.org # 已经是顶层配置文件,不必继续向上搜索 root = true [*] # 编码字符集 charset = utf-8 # 缩进风格是空格 indent_style = space # 一个缩进占用两个空格,因没有设置tab_with,一个Tab占用2列 indent_size = 2 # 换行符 lf end_of_line = lf # 文件以一个空白行结尾 insert_final_newline = true # 去除行首的任意空白字符 trim_trailing_whitespace = false [*.md] insert_final_newline = false trim_trailing_whitespace = false
  1. 配置 .prettierrc.json
    vscode要安装对应插件
json
{ "printWidth": 100, "tabWidth": 2, "semi": true, "singleQuote": true, "quoteProps": "as-needed", "useTabs": false }
  1. 配置.eslintrc.js
  • 可以通过 npx eslint --init 生成模版
  • prettier融入eslint
    • npm install -D eslint-config-prettier eslint-plugin-prettier
    • npm install -D --save-exact prettier
js
module.exports = { env: { browser: true, es2021: true, }, extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier', ], plugins: ['@typescript-eslint', 'prettier'], overrides: [], parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 'latest', }, rules: { 'prettier/prettier': 'error', }, };
  1. 配置 eslint-loader
  • webpack.config.js代码片段
json
{ test: /\.[j|t]s$/, exclude: /node_modules/, use: [ "babel-loader", "eslint-loader", ] }
  • 如果遇到报错 TypeError: Cannot read property 'getFormatter' of undefined
  • 降级eslint版本到7 npm i -D eslint@7

7. devServer & HMR

前面的打包后的文件都是依靠 http-server 命令或者 VSCode插件live-server来运行,而且每次修改代码后都需要手动打包效率较低。本段落我们来自己搭建本地服务器 官方给出了三种可选的方式

  • webpack配置项watch 监控文件发生改变会重新编译
    • 命令行增加参数 --watch
    • 配置文件增加配置 watch: true
  • webpack-dev-server 内置了live-serversocket热更新
    • 可通过 npx webpack-dev-servernpx webpack server 启动
    • 后面详细说
  • webpack-dev-middlewares
    • 如果觉得前一种方式不够灵活不能满足你的需求,则考虑下这个工具
    • 它是一个封装器,它可以把webpack处理后的文件发送到server
    • 你可以用express启动一个服务,webpack-dev-middlewares会生成一个express中间件

示例代码

  1. pkg.script 增加 server: 'node server.js'
  2. server.js内容如下
js
const express = require('express'); const webpack = require('webpack'); const webpackDevMiddleware = require('webpack-dev-middleware'); const config = require('./webpack.config'); const app = express(); const compiler = webpack(config); const middleware = webpackDevMiddleware(compiler); app.use(middleware); app.listen(9081, function () { console.log('webpack-dev-middlewares start by express on port 9081'); });

devServer

  • webpack-dev-server启动后不会输出任何文件
  • 它是把文件写入到内存里了 (它使用了一个库 memory-fs

HMR 是什么?

  • Hot Module Replacement 模块热替换
  • 在应用运行过程中,模块增删改无需刷新整个页面

相对live server的优势

  • 应用状态不会丢失,如输入框文字
  • 仅替换模块,不刷新页面,效率高

webpack-dev-server 可以通过配置项devServer.hot: true 开启HMR, 但实测发现, css改动触发了热更新, 但JS改动还是会刷新页面, 这个原因也很简单,css很好处理 删除style标签再插入即可,而JS的处理比较麻烦,比如输入框文字,内存变量等,它不知道怎样做,webpack提供了 module.hot.accept方法,你监听哪些文件,哪些文件就知道怎么热更而不刷新页面

示例代码

  • index.js 入口文件
js
import createComp from './js/comp'; const comp = createComp('h2', { class: 'content' }, 'hello webpack'); document.body.appendChild(comp); if (module.hot) { module.hot.accept('./js/comp', () => { console.log('./js/comp模块热更新了'); document.body.removeChild(document.body.querySelector('.content')!); const comp = createComp('h2', { class: 'content' }, texts.at(-1)); document.body.appendChild(comp); }); }
  • js/comp.js
js
function createComp( tag = 'div', attrs = {}, children = '' ) { const ele = document.createElement(tag); Object.entries(attrs).forEach(([k, v]) => { ele.setAttribute(k, v); }); if (typeof children === 'string') { ele.appendChild(document.createTextNode(children)); } else { children.forEach((child) => { if (typeof child === 'string') { ele.appendChild(document.createTextNode(child)); } else { ele.appendChild(createComp(child.tag, child.attrs, child.children)); } }); } return ele; } export default createComp;

对于框架而言,早已提供了解决方案

配置Vue热更新

  1. 代码准备
  • npm i vue@2
  • index.js 增加代码 import './js/loadVue';
  • js/loadVue.js
js
import Vue from 'vue'; import App from './app.vue'; const vueRoot = document.createElement('div'); document.body.appendChild(vueRoot); new Vue({ render: (h) => h(App), }).$mount(vueRoot);
  • js/app.vue
vue
<template> <div class="vue-comp"> <div>{{message}}</div> <input type="text" v-model="message"> </div> </template> <script> export default { data() { return { message: '这是一个vue组件' } } } </script> <style> .vue-comp { border: 1px dotted blue; padding: 20px; text-align: center; display: flex; flex-direction: column; } </style>
  1. 修改webpack配置项
  • 增加loader npm i vue-loader@15 vue-template-compiler
js
{ test: /\.vue$/, use: 'vue-loader' }
  • 还需要添加plugin
    • import VueLoaderPlugin from 'vue-loader/lib/plugin';
    • plugins 增加 new VueLoaderPlugin(),
  • 如果想省略文件后缀名,配置resolve.extensions字段 增加 .vue

配置React热更新

  1. 代码准备
  • index.js 增加 import './js/loadReact';
  • js/loadReact.jsx
jsx
import React from 'react'; import ReactDom from 'react-dom'; import ReactApp from './reactApp.jsx'; const reactRoot = document.createElement('div'); document.body.appendChild(reactRoot); ReactDom.render(<ReactApp />, reactRoot);
  • js/reactApp.jsx
jsx
import React, { Component } from 'react'; import '../css/reactapp.scss'; export default class ReactApp extends Component { constructor() { super(); this.state = { message: 'Hello React', }; } render() { return ( <div className="react-app"> <div>{this.state.message}</div> </div> ); } }
  • css/reactapp.scss
scss
.react-app { background-color: red; padding: 20px; color: white; font-size: 40px; }
  1. 配置webpack
  • npm i react react-dom@17 -S && npm i -D @babel/preset-react react-refresh @pmmmwh/react-refresh-webpack-plugin
  • 配置babel.config.js
    • presets增加 '@babel/preset-react'
    • plugins增加 'react-refresh/babel'
  • 配置webpack.config.js
    • 修改 .js的loader test: /\.(js|jsx)$/i,
    • 增加 plugins new ReactRefreshPlugin(),

HMR原理

image.png

webpack-dev-server 提供两个服务, 一个静态资源的服务(express), 一个Socket服务

  1. 用户访问浏览器,会加载静态资源
  2. 建立socket链接
  3. 监听改动的文件,通过socket发送信号(变更文件路径及hash值),
  4. 客户端接受消息,通过hash比对,是否需要拉取代码,拉取代码通过dom插入script标签eval函数执行代码

devServer配置项

webpack-dev-server v4 相对 v3发生了很多变更 https://github.com/webpack/webpack-dev-server/blob/master/migration-v4.md

  • 先提一嘴 output.publicPath该文件指定index.html引用静态资源的基准路径
    • 静态资源引用cdn地址或者应用为网站的二级目录,需要配置该选项
  • devServer.publicPath 该服务指定本地服务所在的文件夹,建议与output.publicPath一致
    • v4版本已变更为 devServer.devMiddleware.publicPath
  • devServer.contentBase v4版本已变更为 devServer.static.directiry
    • 如果你在html里面有引入外部资源 ,比如 lib/a.js
    • 你需要配置该值为 'lib'
    • 此时你在index.html上通过因为该资源<script src='a.js'></script> 则可以正常加载
  • hotOnly当前代码编译失败,是否刷新整个页面 (默认是会刷新)
    • v4 版本请使用 hot: 'only'
  • host设置主机地址
    • 默认值是localhost是个域名,会被解析成127.0.0.1
    • 127.0.0.1是一个回环地址,表示我们主机自己发出去的包直接被自己接受
      • 正常的数据库包经常 应用层 - 传输层 - 网络层 - 数据链路层 - 物理层 ;
      • 而回环地址,是在网络层直接就被获取到了,是不会经常数据链路层和物理层的;
      • 比如我们监听127.0.0.1时,在同一个网段下的主机 中,通过ip地址是不能访问的;
    • 0.0.0.0 监听ipV4所有的地址,再根据端口找到不同的应用程序
      • 也就是说可以通过host配置域名,随便什么域名都可以访问
      • 然而 v4 配置这个参数无效,具体原因以后再研究
  • port open 不解释
  • compress是否为静态文件开启gzip compression v4默认值true
  • historyApiFallback 它用于解决SPA页面在路由跳转后,进行页面刷新时,返回404的错误。
    • boolean值: 默认值是 falsetrue表示刷新页面遇到404错误则返回index.html内容
    • object类型值 , 可以配合rewrites属性
    • 事实上devServer中实现historyApiFallback功能是通过connect-history-api-fallback库的

devServer.proxy

前后端分离项目, 往往遇到跨域问题,可以通过这个选项解决跨域问题

准备一个接口

  1. 创建一个abc.json文件 内容你随便填
  2. 在该目录下启动 http-server -p 8080
  3. 浏览器访问 http://localhost:8080/abc.json 正常返回

在项目里增加测试代码

javascript
import axios from 'axios'; axios .get('http://localhost:8080/abc.json') .then(console.log) .catch(console.error);

启动项目在浏览器可以看到跨域问题

image.png

devServer.proxy对象是一个 key-value 的键值对,key表示路径,value可以是stringjson
当为string时,key建议不要给空字符串,所以我们配置如下 "/api": "http://localhost:8080"
同时修改请求path axios.get('/api/abc.json')
这时候其实请求的是 http://localhost:8080/api/abc.json该路径其实不存在

image.png

我们需要想办法把 /api去掉, 这时就要把value配置成json

javascript
proxy: { // "/api": "http://localhost:8080", "/api": { target: "http://localhost:8080", pathRewrite: { "^/api": "" }, } }
  • target:表示的是代理到的目标地址
  • pathRewrite: 修改路径

这时候可以正常拿到数据了

image.png

value为对象时,还有一些其他值

  • secure 默认情况下不接收转发到https的服务器上,如果希望支持,可以设置为false
  • changeOrigin它表示是否更新代理后请求的headershost地址
    • 默认false
    • 设置为true时, 在上例中 等于把host请求头从 'localhost:9081' 修改为'localhost:8080'host请求头与跨域有关)

8. 环境分离/resolve/context

当配置信息越来越多,所有配置信息都放在一个文件中,这个文件会越来越不容易维护
有些配置是仅开发时候的,有些配置是仅生产环境才使用的,基于此我们对配置最好进行划分,方面维护和管理。

环境分离方案&demo

  • 方案1: pkg.script 处命令区分,走不同入口配置文件
  • 方案2: pkg.script 处传递参数区分环境把将该环境与公共配置聚合。

image.png

基于前面一个章节我整理了下webpack配置

代码较多,但改动点并不多,我简述下改动点

  • webpack.config.js导出一个函数,参数来自命令行,区分开发还是生产环境,赋值给 process.env.NODE_ENV
  • 仅开发环境的配置 devServerReactRefreshPluginbabel配置增加插件react-refresh/babel modedevelopment
  • 仅生产环境的配置CleanWebpackPlugin CopyWebpackPlugin modeproduction

resolve模块解析

  • resolve用于设置模块如何被解析,它可以帮助webpack从每一个require/import语句中找到合适的模块代码,
  • webpack使用 enhanced-resolve 来解析文件路径;

webpac模块解析的过程

第一步: 获取绝对路径
webpack能解析三种文件路径

  • 绝对路径
    • 直接使用路径加载资源
  • 相对路径
    • 使用import/require的资源文件所在的目录被认为是上下文路径
    • import/require中给定的相对路径,会拼接此上下文路径,来生成模块的绝对路径;
  • 模块路径
    • resolve.modules中指定的所有目录检索模块;
      • 默认是 ['node_modules'],所以默认会从node_modules中查找文件;
    • 我们可以通过设置别名alias的方式来替换初始模块路径

第二步: 判断该路径是文件还是文件夹
webpack如何确认一个资源文件? 首先判断是文件还是文件夹

  1. 如果是文件
    1. 如果具有扩展名,则直接打包文件
    2. 否则将使用resolve.extensions选项作为文件扩展名解析;
  2. 如果是一个文件夹
    1. 会根据 resolve.mainFiles配置选项中指定的文件顺序查找
      1. resolve.mainFiles的默认值是['browser', 'module', 'main']
      2. 寻找package.json, 递归往上查找,找到为止
      3. 依次读取pkg.browser pkg.module pkg.main字段,找到为止
      4. 上述两步如果找不到就用index拼接
      5. 在根据 resolve.extensions来解析扩展名

resolve.extensions解析到文件时自动添加扩展名

  • 默认值 ['.wasm', '.mjs', '.js', '.json'];
  • 我们可以根据需要添加 .vue .jsx .ts``.tsx

配置目录别名

resolve.alias 配置目录别名

  • webpack配置目录别名
js
alias: { '@': path.resolve(__dirname, './src'), }

比如有一个文件src/js/a.js则可通过 import '@/js/a.js';方式引入

context

context的作用是用于解析入口和加载器loader 为了解释清楚这个配置我们来看一个场景

  1. 项目根目录下有 package.json和webpack.config.js
  2. webpack配置的入口 entry: './src/index.js'
  3. 这时候我们把webpack.config.js移动到config目录,

这时候entry不用修改是可以运行的,如果修改entry为 '../src/index.js' 反而不能运行了 原因是 entry的解析是相对于context的,context的默认值是package.json所在目录

mini-css-extract-plugin

一般开始的时候我们使用style-loader,但是生产环境中我们通常把css单独打包到一个文件中。
MiniCssExtractPlugin可以帮助我们将css提取到一个独立的css文件中,该插件需要在webpack4+才可以使用

示例代码如下

  • 配置loader
js
{ test: /\.s?css/, use: [ isProduction? MiniCssExtrctPlugin.loader : 'style-loader', { loader: 'css-loader', options: { importLoaders: 2, }, }, 'postcss-loader', 'sass-loader', ], }
  • 配置plugin
js
if(isProduction) { plugins.push(new MiniCssExtrctPlugin({ filename: "css/[name].[hash:8].css", // chunkFilename: "css/[name].[hash:8].css", })) }

shimming

shimming用来给我们的代码加一些垫片来处理一些问题 比如 我们使用了一个abc.js的第三方库,但是这个库依赖lodash.js,我们的项目也有使用loadsh但通过import使用,也就是window对象下没有 _这个对象。那我们就需要 ProvidePlugin来实现shimming效果。

示例代码如下

  1. 准备代码
  • js/shimming
js
// import axios, { get } from "axios"; axios.get("http://123.207.32.32:8000/home/multidata").then((res) => { console.log(res); }); get("http://123.207.32.32:8000/home/multidata").then((res) => { console.log(res); }); /* eslint-enable */
  1. 配置webpack plugins新增内容
js
// ProvidePlugin 是webpack内置插件 new ProvidePlugin({ axios: "axios", get: ["axios", "get"] })
  1. 测试下看看

9.代码分割CodeSpitting

代码分割是指 将代码分离到不同的bundle中,之后我们可以按需加载,或者并行加载这些文件;

webpack代码分离的三中常用方式

  • 入口起点: 使用entry配置手动分离代码;
  • 防止重复: 使用Entry Dependencies或者SplitChunksPlugin去重和分离代码;
  • 动态导入:通过模块的内敛函数调用来分离代码

在讲解代码分离之前,我准备了下代码 点这里,代码还是基于上一个段落的配置,删除了资源加载、vue、react的支持

Entry Dependencies

多入口打包 也是属于一种代码分割

json
entry: { pageA: "./src/pageA.ts", pageB: "./src/pageB.ts" },
  • 注意多入口打包要修改output.filename的 值为 "[name].bundle.js"
  1. 抽离公共的lodash
json
entry: { pageA: { import: "./src/pageA.ts", dependOn: 'lodash' }, pageB: { import: "./src/pageB.ts", dependOn: 'lodash' }, lodash: 'lodash' },
  1. 抽离公共包 loadsh dayjs
json
entry: { pageA: { import: "./src/pageA.ts", dependOn: ['lodash', 'dayjs'] }, pageB: { import: "./src/pageB.ts", dependOn: ['lodash', 'dayjs'] }, lodash: 'lodash', dayjs: 'dayjs' },
  • 前面的方案会打出 两个公共文件 loadsh.bundle.jsdayjs.bundle.js
  1. 抽离公共包, 多包合并
json
entry: { pageA: { import: "./src/pageA.ts", dependOn: 'shared' }, pageB: { import: "./src/pageB.ts", dependOn: 'shared' }, shared: ['lodash', 'dayjs'], },

splitChunks

  • splitChunks.chunks 该属性共有3个值
    • initial 同步、 async异步、all 同步+异步
  • minSize  代码分割的最小单位默认是 20Kb20*1024
  • maxSize  分割出来的包最大限制
    • minSize 优先于 maxSize
    • 假如某个 npm 包大于 maxSize 他会单独打出来,不会再拆分
  • minChunks  最少引用几次,才参与分包
  • cacheGroups 缓存组,

cacheGroups结构

ts
interface CacheGroups { // key可以随便定义 [key: string]: { test: RegExp; filename: string; // 可以使用占位符如 "[id]_venders.js" name: stringFunction; // string不可以写占位符 priority:number; // 通常为负数哦,谁大谁优先 // 。。。 }; }

看一个示例

json
cacheGroups: { // 我们通常将第三方包单独打出来 叫venders.js // 第三方包通常不会改变,可以更好的利用强缓存 venders: { test: /[\\/]node_modules[\\/]/, filename: "[id]_venders.js", priority: -10 }, // 将某类文件单独提取出来 // aa: { // test: /aa_/, // filename: "[id]_aa.js", // // name: "aa.js" // }, // default: { // minChunks: 1, // filename: "common_[id].js", // priority: -20 // } }

react脚手架splitChunks配置

js
splitChunks: { chunk: 'all', name: false, }

vue3脚手架splitChunks配置

js
venders: { name: 'chunk-venders', test: /[\\/]node_modules[\\/]/, priority: -10, chunks: 'initial', } common: { name: 'chunk-common', priority: -20, minChunks: 2, chunks: 'initial', reuseExistingChunk: true, }

动态导入

webpack实现了两种动态导入的语法

  • 第一种,使用ECMAScript中的 import() 语法来完成,也是目前推荐的方式;
  • 第二种,使用webpack遗留的 require.ensure,目前已经不推荐使用;
javascript
const isDev = process.env.NODE_ENV === 'development'; if (isDev) { import( /* webpackChunkName: 'my-vconsole' */ 'vconsole' ).then((module) => { new module.default(); }); // require.ensure([], () => { // const Vconsole = require('vconsole'); // new Vsonsole(); // }); }

import()语法,可以使用webpack魔法注释

  • /* webpackChunkName: 'vconsole' */
  • /* webpackPrefetch: true */

注意:动态导入的代码需要配置 output.chunkFilenamechunkFilename: 'chunk_[id]_[name].js'

代码懒加载

  • js/element.ts
ts
const element = document.createElement('div'); element.innerHTML = 'Hello Element'; export default element;
  • 入口文件 pageB.ts
ts
const button = document.createElement('button'); button.innerHTML = '加载元素'; button.addEventListener('click', () => { // prefetch -> 魔法注释(magic comments) /* webpackPrefetch: true */ /* webpackPreload: true */ import( /* webpackChunkName: 'element' */ /* webpackPrefetch: true */ './js/element' ).then(({ default: element }) => { document.body.appendChild(element); }); }); document.body.appendChild(button);
  • 打包后,浏览器预览,打开网页
  • 点击button 在开发者工具 network js可以看到element.js的加载

prefetch与preload

prefetch 预获取 preload 预加载
将来某些导航下可能需要的资源 当前导航下可能需要资源
只是把资源下载下来,暂时不运行(后面用到的时候在运行)
设置、命中缓存;
正确使用 preload、prefetch 不会导致重复请求
在父 chunk 加载结束后,空闲的时候开始加载。 在父 chunk 加载时,以并行方式开始加载。
prefetch chunk 在浏览器闲置时下载 中等优先级,并立即下载

一般开发中都是使用于预获取prefetch,它的兼容性要比preload好很多

image.png

更多关于 预获取

加载原理 分两步

  1. 先下载资源,下载完行并不会执行 <link rel="preload" href="./a.js" as="script">
  2. 通过创建script标签的形式执行它
js
var script = document.createElement('script'); script.src = './a.js'; doucment.head.appendChild(script);

在webpack中通过代码魔法注释开启预获取和预加载

  • /* webpackPrefetch: true */
  • /* webpackPreload: true */

optimization.chunkIds

该字段告知webpack模块的id采用什么算法生成。
有三个比较常见的值

  • natural 按数字的顺序使用id
  • named development模式下的默认值,一个可读的名称id
  • deterministic 确定性的,在不同的编译中确定的id
    • production模式的默认值 这个也是前面演示splitChunks为什么会有id的原因
    • webpack4 没有这个值

最佳实践: 开发模式使用named ,生产模式使用deterministic

optimization.runtimeThunk

配置runtime相关的代码是否能抽取到一个单独的chunk

  • runtime相关的代码指的是,对模块进行解析、加载模块信息相关的代码。即包含静态资源的mainfest.json,也包含它们之间的引用关系

Q: 为什么要抽离runtime ?
抽离出来后,有利于浏览器的缓存策略

  • 一般我们将首屏同步加载的js文件叫做bundle,把异步加载的js文件叫做chunk
  • 那么bundle怎么知道要加载哪些chunk呢? 肯定是在bundle文件中有chunk文件的引用,
  • 假如修改了pageA页面,那么pageA-chunk.jshash值肯定发生改变 ==> 在bundle中的引用也发生改变 ==> 进而bundle.js文件的hash也发生改变, 有没办法只重新加载 pageA-chunk.js呢? 这就用到了runtimeThunkbundle.js通过runtime.js找到pageA-chunk.js, 这样bundle.js就不用重新加载了
  • 但有人肯定反对说你不是还要加载runtime.js,这不还是要加载两个文件了?
  • 但是runtime包一般都很小,可以借助react-dev-utils/InlineChunkHtmlPlugin(也可以自己写一个plugin)将其内容打包到index.html里面。 因为生产环境下index.html通常设置为协商缓存或不缓存,其他资源设置为强缓存)这样就做到了chunk变更,只加载chunk.js更大程度上利用了缓存。

案例如下

  1. 代码准备
  • src/index.js
js
const button = document.createElement('button'); button.innerHTML = '加载元素'; button.addEventListener('click', () => { import( /* webpackChunkName: 'element' */ './element' ).then(({ default: element }) => { document.body.appendChild(element); }); }); document.body.appendChild(button);
  • element.js
js
const element = document.createElement('div'); element.innerHTML = 'Hello Element'; export default element;
  1. 配置webpack
  • webpack.config.js
js
const HtmlWebpackPlugin = require('html-webpack-plugin'); const {DefinePlugin} = require('webpack'); const path = require('path'); const { CleanWebpackPlugin } = require('clean-webpack-plugin'); const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin') module.exports = { mode: 'none', // devtool: 'source-map', entry: './src/index.js', output: { path: path.join(__dirname, './dist'), filename: '[name].[contenthash:6].js', chunkFilename: "[name].[contenthash:6].chunk.js", publicPath: './' }, optimization: { chunkIds: 'named', runtimeChunk: { name: function(entrypoint) { return `runtime-${entrypoint.name}` } } }, plugins: [ new CleanWebpackPlugin(), new HtmlWebpackPlugin({ template: './index.html', title: 'webpack 代码分割 runtime', }), new DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify("development"), }), new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime-.*.js/]), ] }
  1. 测试 感兴趣的同学可以试试看

runtime方案二 手写个plugin

  • chainWebpack函数增加如下代码
js
config.plugin('html-inline') .use(InlineChunkHtmlPlugin, [/runtime-.*.js/])
  • InlineChunkHtmlPlugin
js
class InlineChunkHtmlPlugin { constructor(tests) { this.tests = tests; } apply(compiler) { compiler.hooks.afterEmit.tap('InlineChunkHtmlPlugin', (compilation) => { let filePath = Object.keys(compilation.assets) .find(key => this.tests.test(key)); const fileContent = compilation.assets[filePath]?._value; const htmlPath = compilation.assets['index.html']?.existAt; if(!filePath || !htmlPath) return; filePath = compilation.options.output.path + filePath; let htmlData = fs.readFileSync(htmlData,'utf-8'); htmlData = htmlData.replace( /\<body\>/, `<script>${fileContent}</script><body>` ); fs.outputFileSync(htmlPath, htmlData); }) } }

extenals

补充知识: CDN
CDN称之为内容分发网络(Content Delivery Network或Content Distribution Network,缩写:CDN)

  • 它通过利用相互连接的网络系统,从最靠近用户的服务器返回资源
  • 更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户
  • 来提供高性能、可扩展性及低成本的网络内容传递给用户

在开发中,我们使用CDN主要是两种方式

  • 方式一:打包的所有静态资源(除去入口index.html),放到CDN服务器,用户所有资源都是通过CDN服务器加载的;
  • 方式二:一些第三方资源放到CDN服务器上;

通常一些比较出名的开源框架都会将打包后的源码放到一些比较出名的、免费的CDN服务器上:

  • 国际上使用比较多的是unpkgJSDelivrcdnjs
  • 国内也有一个比较好用的CDN是bootcdn

在项目中,我们如何去引入这些CDN呢?

  • 第一,在打包的时候我们不再需要对类似于lodash或者dayjs这些库进行打包;
  • 第二,在html模块中,我们需要自己加入对应的CDN服务器地址;

一般有一些库或者项目历史原因,我们通过htmlscript标签引用cdn地址
但是开发环境如果通过外链加载资源首屏加载要慢很多(尽管资源在cdn上)
lodash举例,我们在开发时加载本地资源, 打包后加载cdn上的lodash

配置示例如下:

  • webpack.config.json
js
const path = require('path'); const { CleanWebpackPlugin } = require('clean-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = function (env) { const isProduction = env.production; process.env.NODE_ENV = isProduction ? 'production' : 'development'; const externals = {}; if (isProduction) { // window._ externals.lodash = "_"; } return { mode: process.env.NODE_ENV, entry: './src/index.js', output: { filename: 'bundle.js', path: path.join(__dirname, './dist') }, externals, plugins: [ new CleanWebpackPlugin(), new HtmlWebpackPlugin({ template: './index.html', title: 'webpackp cdn', }), ] } }
  • src/index.js 测试
js
import _ from 'lodash'; console.log(_.join(['hello', 'webpack']));

TIP: 静态资源部署到cdn 可以修改output.publicPath 实现

认识DLL库

  • DLL全称是(Dynamic Link Library), 是为软件在windows电脑中实现共享函数的一种扩展方式
  • webpack中,它指的是我们可以将不经常改变的代码,抽取成一个共享的库
  • 这个库在之后编译的过程中,会被引入到其他项目的代码中;

具体步骤如下
1. 打包dll

  • pkg.script "dll": "webpack --config ./webpack.dll.js",
  • webpack.dll.js
js
const path = require('path'); const webpack = require('webpack'); const TerserPlugin = require('terser-webpack-plugin'); module.exports = { entry: { react: ["react", "react-dom"] }, output: { path: path.resolve(__dirname, "./dll"), filename: "dll_[name].js", library: 'dll_[name]' }, optimization: { minimizer: [ new TerserPlugin({ extractComments: false }) ] }, plugins: [ new webpack.DllPlugin({ name: "dll_[name]", path: path.resolve(__dirname, "./dll/[name].manifest.json") }) ] }

2. 使用dll

  • pkg.script "serve": "webpack serve --env development"
  • 配置webpack.config.js的plugin增加
js
new webpack.DllReferencePlugin({ context: path.join(__dirname, "./dll"), manifest: path.join(__dirname, "./dll/react.manifest.json") }), new AddAssetHtmlPlugin({ publicPath: '/', filepath: path.join(__dirname, './dll/dll_react.js') })

注意: 在升级到webpack4之后,ReactVue脚手架都移除了DLL库 犹大说,webpack4已经提供了足够性能,不需要DLL

10. 代码压缩

Terser

  • Terser 目前主流的压缩js代码的工具 支持ES6+
  • 早期使用 uglify-js来压缩、混淆JavaScript代码,但是目前已经不再维护,它不支持ES6+语法

Q:为什么需要压缩混淆代码?
A: 1)代码保护 2)缩小体积,性能提升

Terser和其他工具一样,可以在命令行使用
terser [input files] [options]
terser js/file1.js -o foo.min.js -c -m

官方文档

Terser主要有两个配置选项 CompressMangle

Compress option:

  • arrows   class或者object中的函数,转换成箭头函数;
  • arguments 将函数中使用arguments[index]转成对应的形参名称;
  • dead_code 移除不可达的代码(tree shaking);

Mangle option

  • toplevel     默认值是false,顶层作用域中的变量名称,进行丑化(转换);
  • keep_classnames 默认值是false,是否保持依赖的类名称;
  • keep_fnames   默认值是false,是否保持原来的函数名称;

Terser在webpack中的配置

  • production模式下,默认就是使用TerserPlugin来处理我们的代码的
  • webpack5内置了 terser-webpack-plugin
  • 如果对默认配置不满意可以覆盖配置

配置 terser

js
optimization: { minimize: true, // 只有打开minimize, minimizer配置才有意义 minimizer: [ new TerserPlugin({ parallel: true, // 使用多进程并发运行以提高构建速度 extractComments: false, // 是否将注释移除 terserOptions: { compress: { arguments: true, dead_code: true }, mangle: true, // 非顶层变量 被混淆 // 在 mangle: true 的前提下 配置toplevel: true 顶层变量被混淆 toplevel: true, keep_classnames: true, keep_fnames: true } }) ] }

示例代码

js
/** * 测试 terserOptions.mangle: true * 测试 terserOptions.toplevel */ function myAlert (arg) { alert(arg); } myAlert('abc') /** * 测试 terserOptions.compress.arguments: true * 将 console.log(arguments[0] + arguments[1]); 转换成 console.log(num1 + num2); */ function mySum(num1, num2) { console.log(arguments[0] + arguments[1]); } mySum(2, 3); /** * 测试 terserOptions.compress.dead_code * 移除不可达代码 * 实测,不管配置不配置都会将不可代码移除 * */ if(false) { console.log('不可达代码,将被移除'); } /** * 测试 terserOptions.keep_classnames: true, */ class Persion { } new Persion();

CSS的压缩

  • CSS压缩通常是去除无用的空格等,因为很难去修改选择器、属性的名称、值等;
  • 通常使用 css-minimizer-webpack-plugin
  • 它是使用 使用cssnano工具来优化、压缩CSS(也可以单独使用)
  • css压缩通常与css独立出来mini-css-extract-plugin一样都是在生产环境配置,一起使用

使用示例

  1. npm i css-minimizer-webpack-plugin mini-css-extract-plugin -D
  2. webpak.config.js plugins增加如下配置
js
new MiniCssExtractPlugin({ filename: "css/[name].[hash:8].css", }), new CSSMinimizerPlugin({ parallel: true, })

HTML文件中代码的压缩

之前的章节讲述了使用HtmlWebpackPlugin插件来生成HTML的模版,事实上它还有一些其他的配置:

  • inject: 设置打包资源插入的位置
    • true false body head
  • cache: 设置为true,只有当文件改变是才会生成新的文件(默认值是true
  • minify: 默认会使用一个插件 html-minifier-terser
javascript
new HtmlWebpackPlugin({ template: "./index.html", title: 'webpack 代码压缩', // inject: "body" cache: true, // 当文件没有发生任何改变时, 直接使用之前的缓存 minify: isProduction ? { removeComments: true, // 是否要移除html中注释 // 是否移除多余的属性 // <input type="text"> type 默认值就是 text --> <input> removeRedundantAttributes: true, // 是否移除一些空属性 removeEmptyAttributes: true, // 移除空格 collapseWhitespace: true, // 移除style标签中的多余属性 // <link rel="stylesheet" type="text/css" href="" /> -> <link rel="stylesheet" href="" /> removeStyleLinkTypeAttributes: true, // 压缩css minifyCSS: true, // 压缩js // minifyJS: true, minifyJS: { mangle: { // 这里的配置同 terser-webpack-plugin toplevel: false } }, // more options: // https://github.com/kangax/html-minifier#options-quick-reference }: false }),

HTTP压缩

  • http压缩是一种在服务端和客户端之间的,以改进传输速度和带宽利用率的方式;
  • http压缩的流程
    • 第一步: HTTP数据在向服务器发送前就已经被压缩了;(可以在webpack中完成)
    • 第二步:兼容的浏览器在向客户端发送请求是,会告诉服务器自己支持哪些压缩格式
      image.png
    • 第三步: 服务器在浏览器支持的压缩格式下,直接返回对应的压缩文件,并且在相应头中高速浏览器;
      image.png
  • 目前支持的压缩格式
    • compress
    • deflate 基于deflate算法的压缩,使用zlib数据格式封装
    • gzip   GNU zip格式, 是目前使用比较广泛的压缩算法;
    • br    一种新的开源压缩算法,专为HTTP内容的编码而设计;

11. TreeSharking

Tree Shaking 摇树 把树上的枯树叶摇下来, 在计算机中表示消除死代码(dead_code

  • 最早的想法起源于LISP,用于消除未调用的代码(纯函数无副作用)
  • LISP这种语言虽然消亡了,但是treeSharking却被其他语言所继承

JavaScript进行Tree Shaking是源自打包工具rollup

  • 这是因为c依赖于ES Module的静态语法分析
  • webpack2正式内置支持了ESM模块,和检测未使用模块的能力;
  • webpack4正式扩展了这个能力,并且通过package.jsonsideEffects属性作为标记,告知webpack在哪些文件有副作用编译时不能进行Tree Shaking优化
  • webpack5中,也提供了对部分CommonJStree shaking的支持;哪里文件可以安全的删除掉;

webpack实现Tree Shaking采用了两种不同的方案

  • usedExports:通过标记某些函数是否被使用,之后通过Terser 来进行优化的;
  • sideEffects:跳过整个模块/文件,直接查看该文件是否有副作用;

usedExports

示例演示

  1. 准备代码
  • src/index.js
js
import {add} from './usedExports'; console.log(add(2,3));
  • src/usedExports.js
js
export function add(a, b) { return a+b; } export function muti(a, b) { return a * b; }
  1. 配置webpack.config.js
js
optimization: { // usedExports: 目的是标注出来哪些函数是没有被使用 unused usedExports: true, // production minimize: false, },
  1. 打包 查看效果 image.png

事实上,optimization.minimize 默认不会移除未使用的代码,它会生成标注哪些导出没有被使用
再通过 terser-webpack-plugin 移除未使用的代码

修改配置如下

js
optimization: { // usedExports: 目的是标注出来哪些函数是没有被使用 unused usedExports: true, // production minimize: true, // 打开 minimizer: [ // 新增 new TerserPlugin({ parallel: true, // 使用多进程并发运行以提高构建速度 extractComments: false, // 是否将注释 terserOptions: { compress: { dead_code: true }, mangle: false, } }) ] },

image.png

再次查看生成的文件没有了muti方法

假如在配置usedExports: false

image.png muti又回来了

sideEffects

前面的usedExports方案具有局限性,我们来看个例子

  1. 代码准备
  • src/index.js 新增 import './useExports_format';
  • useExports_format.js
js
import './useExports_format'; import {add} from './usedExports'; console.log(add(2,3));
  1. 修改webpack配置(基于前面的配置不变)
  • mode: 'development',
  • devtool: 'source-map',
  1. 打包

image.png

我们来分析下:

  1. 虽然引入了import 'useExports_format.js' 但是并没用执行useExports_format.js 中的任何代码
  2. 打包文件不含useExports_format.js 的内容,但有其文件地址的引用

我们能不能去掉无效的文件引用呢? 这是就用到了 sideEffects 它在package.json中配置(默认值是true

  • "sideEffects": false"

再次构建后,找不到了useExports_format.js的引用
这时候假如往useExports_format.js增加以下两行代码

  • console.log('1233');
  • console.log(window.a++);

之前可以被移除,但现在useExports_format.js具有副作用,如果移除会产生隐患,那么我们怎样告知webpack哪些文件有副作用呢?

我们再来看个示例

  1. 代码准备
  • src/sideEffect_abc.js
js
export function abc() { return "abc"; } window.abc = 'abc'
  • src/index.js 增加代码 import './sideEffect_abc';
  1. 配置pkg
json
{ "sideEffects": [ "./src/sideEffect_abc.js" ] }
  1. 打包

image.png

这时候 abc方法被移除了,有副作用的代码留了下来,
注意:通过import './style.css'引入的代码也会被识别为js语句,进而被sideEffects进行treeSharking处理, 所以pkg.sideEffects数组通常有 "**.css"

总结: 开发过程中推荐写纯模块无副作用的代码,所以配置 pkg.sideEffectsfalse, csstreeSharking通过另一种方式处理(在loader中配置sideEffects

CSS实现Tree Shaking

一般pkg.sideEffects不用考虑css, 通过webpack.config.js 中的 rules 配置sideEffects: true,
告知webpack不对css进行tree-sharking

js
module: { rules: [ { test: /\.css$/i, use: [ CssExtractPlugin.loader, 'css-loader', ], sideEffects: true, } ] }

如果确实想要对CSS进行tree-sharking可以采用另一种方案
PurgeCSS 可以用来检测移除未使用css代码

补充说明: 还有一个工具 PurifyCss 也可以用作csstree-sharking 但这个工具已经很多年没有维护了

示例演示

  1. 代码准备
  • src/index.js
js
import './style.css'; const ele = document.createElement('h3'); ele.className = 'title'; ele.innerText = '测试css tree sharking'; document.body.appendChild(ele);
  • src/style.css
css
html, body { padding: 0; margin: 0; height: 100%; width: 100%; background-color: aqua; } .title { font-size: 30px; } .unusedcss { color: blue; }
  1. webpack配置 新增plugin
js
new PurgeCssPlugin({ paths: glob.sync(`${path.join(__dirname, "./src")}/**/*`, {nodir: true}), safelist: function() { return { standard: ["html"] } } })
  1. 打包验证
    1. .unusedcss 将被移除
    1. 如果注释 standard: ["html"], html将被移除

Scope Hoisting

比如utils.js用个add方法 只在main.js用到,那么可以把add方法挪到main.js里面, 这个叫作用于提升
作用于提升减少了代码调用链路,也减少了代码量,提升了性能

  • 作用域仅在esmodule下有效
  • 放心webpack会计算,能不能进行作用域提升

配置作用域提升很简单,只需要webpack.config.jsplugins新增

  • new webpack.optimize.ModuleConcatenationPlugin()

该插件是webpack内置的

比如有如下代码

  • index.js
js
import {add, muti} from './mymath'; console.log(add(2,3)); console.log(muti(2,3));
  • mymath.js
js
export function add(a, b) { return a+b; } export function muti(a, b) { return a * b; }

image.png

上图可以看出 mymath.js中的add muti方法被挪到和调用js所在的代码片段一起了。

总结

能看到这里非常感谢,我们在讲一些干货,一些可以作为项目亮点的示例

一般项目中我是使用脚手架构建项目,但官方的脚手架已经非常完善了,比如

  1. mode:'production'已经做了treeSharking、 代码压缩、Scope Hoisting 项目启动时也做了缓存策略进而提升启动速度
  2. 脚手架已经帮我们配置了各种loader 如.scss.ts.vue.jsx.tsx资源的加载
  3. 脚手架已经做了HMR、环境分离。。。

那么在构建工具方面我们还能做哪些事情呢?

  1. 配置browserslist, 一般脚手架的配置比较前沿,假如有一个移动端(C端)项目要求兼容iOS 12Andoird 7.0, browserslist如何配置呢?
    • 注:本人在一个uni-app项目中通过将browserslist从android4.4 ios10 调整到 android7 ios12, h5的打包体积降低了超过10%, 有兴趣的同学可以试试看
    • 建议browserslist每半年升级一次,可用过公司业务埋点数据分析具体兼容到目标浏览器的哪个版本。
  2. 配置分包策略(模块按需加载)和资源预获取,提升首屏体验
  3. 配置runtime。该方案可以减少打包后hash的变更频率,在发版后最大程度利用强缓存,也能提升一些首屏体验
  4. 修复脚手架bug。如本人在uni-app项目中,发现打包后的代码含有很多CSS注释,这些注释没引入一次文件就会残留一次, 本人通过使用patch-package.js覆盖uni-appcssnano配置,移除了这些CSS注释
  5. CICD流程优化, 如打包后上传资源到CDN,发送邮件通知

本文作者:郭敬文

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!