《 Webpack实战:入门、进阶与调优》读书笔记

570 阅读19分钟

概念

webpack是JS的模块打包工具,核心是解决模块之间的依赖,把各个模块按照特定的规则和顺序组织在一起,最终合并为一个(或多个)JS文件,这个过程叫模块打包。

模块打包

模块之于程序,就如同细胞之于生物体,是具有特定功能的组成单元,不同的模块具有不同的工作,它们以某种方式联系在一起,共同保证程序的正常运转。

现有的模块标准

CommonJS

CommonJS是由Javascript社区于2009年提出的包含模块、文件、IO、控制台在内的一系列标准。在Node.js的实现中采用了CommonJS标准的一部分,并在其基础上进行了一些调整。我们所说的CommonJS模块和Node.js中的实现并不完全一样,现在一般谈到CommonJS其实是Node.js中的版本,而非它的原始定义。

ES6 Module

2015年6月,由TC39标准委员会正式发布了ES6(ECMAScript6.0),从此JavaScript才具备了模块这一特性。

CommonJs与ES6 Module的区别

1. 动态与静态

CommonJS对模块依赖的解决是“动态的”,而后者是“静态的”。在这里“动态”的含义是,模块依赖关系的建立发生在代码运行阶段;而“静态”则是模块依赖关系的建立发生在代码编译阶段。

2. 值拷贝与动态映射

对导入一个模块时,对于CommonJS来说获取的是一份导出值的拷贝; 而ES6 Module中则是值的动态映射,并且这个映射是只读的。

3. 循环依赖

在CommonJS中,若遇到循环依赖我们没有办法得到预想中的结果。ES6 Module的特性使其可以更好地支持循环依赖,只是需要由开发者来保证当导入的值被使用时已经设置好正确的导出值。

其他类型模块

1. AMD

AMD是英文Asyncchronous Module Definition(异步模块定义)的缩写,与CommonJS和ES6 Module最大的区别在于它加载模块的方式是异步的。

2. CMD

CMD 即Common Module Definition通用模块定义,CMD规范是国内发展出来的

3. AMD与CMD的区别

1、AMD推崇依赖前置,在定义模块的时候就要声明其依赖的模块 2、CMD推崇就近依赖,只有在用到某个模块的时候再去require

4.UMD

严格来说,UMD并不难说是一种模块标准,不如说它是一组模块形式的集合更准确。UMD的全称是Universal Module Definition,也就是通用模块标准。UMD起始就是根据当前全局对象中的值判断目前处于哪种模块环境。(包括AMD、CommonJS)

打包原理

接下来让我们看看一个bundle是如何在浏览器中执行的。

(function (modules) {
	// 模块缓存
	var installedModules = {};
	// 实现require
	function __webpack_require__(moduleId) {
		// ...
	}
}{
	0: function (module, exports, __webpack_require__) {
		module.exports = __webpack_require__("3qiv")
	},
	"3qiv": function (module, exports, __webpack_require__) {
		// index.js内容
	},
	"jkzz": function (module, exports) {
		// calculator.js内容
	}
})
  1. 在最外层的匿名函数中会初始化浏览器执行环境,包括定义installedModules对象、__webpack_require__函数等,为模块的加载和执行做一些准备工作。
  2. 加载入口模块。每个bundle都有且只有一个入口模块,在上面的示例中,index.js是入口模块,在浏览器中会从它开始执行。
  3. 执行模块代码。如果执行到了module.exports则记录下模块的导出值;如果中间遇到require函数(准确地说是__webpack_require__),则会暂时交出执行权,进入__webpack_require__函数体内进行加载其他模块的逻辑。
  4. 在__webpack_require__中会判断即将加载的模块是否存在于installedModules中。如果存在则直接取值,否则回到第3步,执行该模块的代码来获取导出值。
  5. 所有依赖的模块都已执行完毕,最后执行权又回到入口模块。当入口模块的代码执行到结尾,也就意味着整个bundle运行结束。不难看出,第3步和第4步是一个递归的过程。Webpack为每个模块创造了一个可以导出和导入模块的环境,但本质上并没有修改代码的执行逻辑,因此代码执行的顺序与模块加载的顺序是完全一致的,这就是Webpack模块打包的奥秘。 以上是对Webpack打包原理的简单介绍,随着本书学习的深入我们还会介绍更多Webpack的原理,从而使读者对它有更加深入的认识。

资源输入输出

配置资源入口

webpack通过context和entry配置项这两个配置项来共同决定入口文件的路径。

module.exports = {
	context: path.join(__dirname, './src'),
	entry: './scripts/index.js'
}

context

可以理解为资源入口的路径前缀,在配置时必须使用绝对路径的形式。

entry

entry的配置可以有多种形式:字符串、数组、对象、函数,

配置资源出口

module.exports = {
	entry: './index.js',
	output: {
		filename: 'bundle.js',
		path: path.join(__dirname, 'dist'),
		publicPath: '/assets/'
	}
}

filename

作用是控制输出资源的文件名,字符串形式。

filename不仅仅是bundle的名字,还可以是一个相对路径,如不存在该路径,webpack会在输出资源时创建该目录。如filename: "/.js/bundle.js"

动态文件名

webpack支持使用一种类似模板语言的形式动态地生成文件名

  1. hash:指代webpack此次打包所有资源生成的hash,与chunk内容直接相关
  2. chunkhash:指代当前chunk内容的hash,与chunk内容直接相关
  3. id:指代当前chunk的id
  4. query:指代filename配置项中的query

path

指定资源的输出位置,要求值必须为绝对路径

publicPath

用来指定间接资源的请求位置(如异步js、图片等),有三种形式:

  • HTML相关:可以将publicPath指定为HTML的相对路径,将HTML的所在路径+publicPath
  • Host相关:值以‘/’开始,代表此时publicPath是以当前页面的host name为基础路径的。
  • CDN相关,这里使用绝对路径配置

预处理器

一切皆模块

对于webpack来说,所有这些静态资源都是模块,我们可以像加载一个JS文件一样去加载它们。

webpack本身只认识JavaScript,对于其他类型的资源必须先定义一个或多个loader对其进行转译,输出为webpack能够接收的形式再继续进行,因此loader做的实际上是一个预处理器的工作。

loader概述

每个loader本质上都是一个函数,用公式表达为output=loader(input)

loader可以是链式的,我们可以对一种资源设置多个loader,第一个loader的输入是文件源码,之后所有loader的输入都为上一个loader的输出。公式表达为output=loaderA(loaderB(loaderC(input))),例style标签=style-loader(css-loader(sass-loader(SCSS)))

简单的loader源码结构。

module.exports = function loader(content, map, meta) {
	var callback  = this.async();
	var result = handle(content, map, meta);
	callback(
		null, // error
		result.content, // 转换后的内容
		result.map, // 转换后的source-map
		result.meta // 转换后的AST
	)
}

如何引入一个loader

首先从npm安装loader模块

在module对象中处理loader配置,module.rules代表例模块的处理规则。

test

可接收一个正则表达式或者一个元素为正则表达式的数组,只有正则匹配上的模块才会使用这条规则

use

可接收一个数组,数组包含该规则所使用的loader。

webpack打包时是按照数组从后往前的顺序将资源交给loader处理的,因此要把最后生效的放在前面。

loader options

loader作为预处理器通常会给开发者提供一些配置项,在引入loader的时候可以通过options将它们传入。

exclude与include

  1. 用来排除或包含指定目录下的模块,可接收正则表达式或者字符串(文件绝对路径),以及由它们组成的数组。
  2. exclude与include同时存在,exclude优先级更高

resource与issuer

  1. 可用于更加精确地确定模块规则的作用范围
  2. 在webpack中,我们认为被加载模块是resource,而加载者是issuer

enforce

  1. 用来指定一个loader的种类,只接收“pre”或“post”两种字符串类型的值。
  2. webpack中的loader按照执行顺序可分为pre、inline、normal、post四种类型,直接定义的loader都属于normal类型,inline官方已经不推荐使用,而pre/post则需要使用enforce来指定。
  3. 值为“pre”,代表它将在所有正常loader之前执行。
  4. 值为“post”,代表它将在所有loader之后执行。
  5. 实际上,保证loader的顺序也可以达到同样的效果。配置enforce的目的是使模块规则更加清晰。

常用loader

babel-loader

用来处理ES6+并将其编译为ES5。

  1. babel-loader:是使Babel与Webpack协同工作的模块
  2. @babel/core:它是Babel编译器的核心模块
  3. @babel/preset-env:它是Babel官方推荐的预置器,可根据用户设置的目标环境自动添加所需的插件和补丁来编译ES6+代码。

由于@babel/preset-env会将ES6 Module转化为CommonJS的形式,这会导致Webpack中的tree-shaking特性失效。将@babel/preset-env的modules配置项设置为false会禁用模块语句的转化,而将ES6 Module的语法交给Webpack本身处理。

ts-loader

用于连接webpack与typescript的模块。

typescript本身的配置并不在ts-loader中,而是必须要放在工程目录下的tsconfig.json中

html-loader

用于将HTML文件转化为字符串并进行格式化,这使得我们可以把一个HTML片段通过JS加载进来。

handlebars-loader

用于处理handlebars模板,在安装时要额外安装handlebars。

file-loader

用于打包文件类型的资源,并返回其publicPath。

url-loader

与file-loader作用类型,唯一的不同在于用户可以设置一个文件大小的阀值,当大于该阀值时与file-loader一样返回publicPatj,而小鱼该阀值时则返回文件base64形式编码。

vue-loader

用于处理vue组件,vue-loader可以将组件的模板、JS、CSS进行拆分。

自定义loader

  1. 可以通过this.cacheable启用缓存
  2. 通过安装 loader-utils获取options配置
  3. 通过安装source-map来处理sourcemap

样式处理

分离样式文件

使用extract-text-webpack-plugin来处理style标签插入的样式

样式预处理

Sass与SCSS

Sass-loader就是将SCSS语法编译为CSS,因此在使用时还要搭配css-loader和style-loader。loader本身只是编译核心库与Webpack的连接器,因此这里我们需要安装sass-loader、node-loader,而后者是真正用来编译SCSS的,sass-loader只是起到黏合的作用。

Less

Less同样是对CSS的一种扩展,与SCSS类型,也需要安装loader和其本身的编译模块。less-loader

PostCSS

严格地说,PostCSS并不能算是一个CSS的预编译器,它只是一个编译插件的容器。它的工作模式是接收样式源代码并交由编译插件处理,最后输出CSS。需要在项目的根目录下创建一个postcss.config.js。

  1. 自动前缀。与Autoprefixer结合,根据caniuse.com上的数据,为CSS自动添加厂商前缀。
  2. stylelint,是一个CSS的质量检测工具,就像eslint一样,我们可以为其添加各种规则,来统一项目的代码风格,确保代码质量。
  3. CSSNext,让我们在应用中使用最新的CSS语法特性。

CSS Modules

让CSS拥有模块化的特点。

  1. 每个CSS文件中的样式都拥有单独的作用域,不会和外界发生命名冲突。
  2. 对CSS进行依赖管理,可以通过相对路径引入CSS文件。
  3. 可以通过compose轻松复用其他CSS模块。

只要开启css-loader中的modules配置项即可。并可以在options中配置localldentName配置项,用于指明CSS代码中的类名会如何来编译。配置规则可以是[name]_[local]_[hash:base64:5]

代码分片 Code splitting

实现高性能应用其中最重要的一点就是尽可能地让用户每次只加载必要的字资源,优先级不太高的资源则采用延迟加载等技术渐进式地获取,这样可以保证页面的首屏速度。

使用CommonChunkPlugin来提取公共模块

对于单入口应用,可以用来提取第三方类库及业务中不常更新的模块,单独为它们创建一个入口即可。

  1. 设置提取范围。在plugin配置中增加chunks配置,标识为只会从配置里面提取公共模块。

  2. 配置minChunks,设置提取规则。

    • 当值为数字,设置为n时,只有该模块被n个入口同事引用才会进行提取。
    • 值为Infinity是无穷,代表提取的阈值无限高。第一个意义是,只想让webpack提取特定的几个模块,即可以通过数组型入口传入。意义二是当指定minChunks为Infinity时,为了生成一个没有任何模块而仅仅包含webpack初始化环境的文件,即manifest。
    • 值为函数,可以更细粒度地控制公共模块。
  3. hash与长效缓存,将运行时的代码单独提取出来作为manifest的CommonsChunkPlugin。至此app.js的变化将只会影响manifest,而他是一个很小的文件。也就是设置上述的minChunks: 'Infinity'

  4. CommonsChunkPlugin的不足。

    • 一个CommonsChunkplugin只能提取一个vendor,假如想提取多个vendor,则需要配置多个插件,增加重复代码。
    • 增加manifest实际上会使浏览器多加载一个资源。
    • CommonChunkPlugin在提取公共模块的时候会破坏掉原有chunk中模块的代码依赖关系,导致难以进行更多的优化。比如在异步Chunk场景下,并不会按照预期正常工作。

optimization.SplitChunks

Webpack4为了改进CommonChunkPlugin而重新涉及和实现的代码分片特性,比之更强大,更简单易用。

默认提取条件

  1. 提取后的chunk可被共享或者来自node_modules目录.
  2. 提取后的javascript chunk体积大于30kb,css chunk体积大于50kb.
  3. 在按需加载过程中,并行请求的资源最大值小于等于5.
  4. 在首次加载时,并行请求的资源数量最大值小于等于3

默认的异步提取

配置代码如下:

var obj = {
	splitChunks: {
		chunks: "async",
		minSize: {
			javascript: 30000,
			style: 50000,
		},
		maxSize: 0,
		minChunks: 1,
		maxAsyncRequests: 5,
		maxInitialRequests: 3,
		automaticNameDelimiter: '~',
		name: true,
		cacheGroups: {
			vendors: {
				test: /[\\/]node_modules[\\/]/,
				priority: -10,
			},
			default: {
				minChunks: 2,
				priority: -20,
				reuseExistingChunk: true,
			},
		},
	},
}
  1. 匹配模式。通过chunks可以配置SplitChunks的工作模式。 *值为"async",只提取异步chunk。 *值为"initial",只对入口chunk生效。 *值为"all",则上述两种模式同时开启。

  2. 匹配条件。miniSize、miniChunks、maxAsyncRequests、maxInitialRequest都属于匹配条件。

  3. 命名。配置项name默认为true,意味着可以根据cacheGroups和作用范围自动为新生成的chunk命名,并以automaticNameDelimiter分隔。

  4. cacheGroups。分离chunks时的规则。默认情况下有两种规则vendors和default。vendors用于提取所有node_modules中符合条件的模块,default则作用于被多次引用的模块。

资源异步加载

使用import()函数,并且该import函数不必须在顶层,可以在任何希望的时候调用。

异步chunk配置,通过在webpack中配置output.chunkFilename为[name].js,用来指定异步的文件名。并且在调用时使用注释配置名字,如import(/*webpackChunkName: "a"*/ './a.js')

生产环境配置

生产环境中我们关注的是如何让用户更快地加载资源、压缩资源、如何添加环境变量优化打包、如何最大限度地利用缓存等。

环境配置的封装,开发环境与生产环境

两种配置方法。

  1. 使用相同的配置文件,在配置里判断不同的环境类型。
  2. 为不同的环境创建各自的配置文件,如webpack.production.js、webpack.development.js、webpack.common.js。

配置mode项

webpack4中增加里配置项mode,可以直接切换打包模式。

环境变量

可以使用DefinePlugin进行设置,最终设置的是全局变量ENV。

但如果设置了mode: production,则Webpack已经设置好了NODE_ENV,不需要再人为添加了。

source map

source map指的是将编译、打包、压缩后的代码映射回源代码的过程。有了source map,再利用浏览器调试工具dev tools,就可以对代码进行排查。

在webpack.config.js中添加devtool: "source-map",即可生成source map。目前生成source-map可以有多种形式,详见webpack官网

安全问题。当有了source map也就意味着任何人可以通过浏览器的开发者工具都可以看到工程源码。webpack提供了hidden-source-mapnosources-source-map两种策略来提升map的安全性,只不过这样在浏览器中就看不到map文件,需要利用第三方服务,将map文件传到上面。

资源压缩

压缩JS

压缩JavaScript大多数时候使用的工具有两个,一个是UglifyJS(Webpack3已集成),另一个是terser(Webpack4已集成),后者支持ES6+代码的压缩,更加面向未来,因此Webpack4中默认使用了terser 的插件terser-webpack-plugin。

压缩CSS

压缩css文件的前提是使用extract-text-webpack-plugin或mini-css-extract-plugin将样式提取出来,接着使用optimize-css-assets-webpack-plugin来进行压缩,这个插件本质上是使用的压缩器cssnano,当然也可以通过配置切换其它的。

缓存

资源hash

我们通常使用chunkhash来作为文件版本号,因为它回为每一个chunk单独计算一个hash。

输出动态HTML

使用html-webpack-plugin在打包结束后自动将资源名同步到html里。

使chunk id更稳定。

webpack3以下的版本中,webpack为每个模块指定的id是按照数字递增的,当有新的模块插入进来时就会导致其它模块的id发生变化。而webpack4以后已经修改了模块id的生成机制,使用hash来标记每个模块,也就不会有该问题了。

bundle体积监控和分析

  1. vscode中有个插件Import Cost可对引入模块的大小进行监测。
  2. webpack-bundle-analyzer,它能够帮助我们分析bundle的构成。

打包优化

重述一条软件工程领域的经验,不要过早优化,在项目的初期不要看到任何优化点就拿来驾到项目中,这样不但增加了复杂度,优化的效果也不会太理想,一般是项目发展到一定规模后,性能问题随之而来,这时再去分析然后对症下药,才有可能达到理想的优化效果。

HappyPack

是一个通过多线程来提升Webpack打包速度的工具。

缩小打包作用域

从宏观角度来看,提升性能的方法无非两种:增加资源或者缩小范围。增加资源是指使用更多的CPU和内存,用更多的计算能力来缩短执行任务的时间。缩小范围则是针对任务本身,比如去掉冗余的流程,尽量不做重复性的工作等。

配置exclude、include。

一般来说要把node_modules目录排除掉。另外当规则有重叠的部分时,则exclude的优先级更高。

配置noParse。

有些库是希望Webpack完全不要去解析的,即不希望应用loader规则,库的内部也不会有对其他模块的依赖,那么这时可以使用noParse对其进行忽略。

配置ignorePlugin。

完全排除一些模块,即使被引用来也不会被打包近资源文件中。

配置Cache。

有些loader会有cache配置项,用来在编译代码后同时保存一份缓存,在执行下一次编译前会先检查源码文件是否有变化,如果没有就直接采用缓存。Webpack5中添加了一个新配置项cache: {type: "filesystem"},它会在全局启用一个文件缓存。

动态链接库与DLLPlugin,对于一些不常变化的模块,将它们预先编译和打包,然后在项目实际构建中直接取用即可。

tree shaking。

ES6 Module依赖关系的构建是在代码编译时而非运行时。基于此在打包过程中,将没有被引用过的代码进行标记,并在资源压缩时从bundle中去除。

有些库是使用CommonJS形式导出的,是在运行时才能确认是否为“死代码”,所以不能被tree shaking

另外,需要设置babel-loader中的modules: false,因为如果不设置,Webpack接收到的就都是转化过的CommonJs形式的模块,无法进行tree-shaking。

开发环境调优

Webpack开发效率插件

1. webpack-dashboard

会在构建结束后在控制台输出一些打包相关的信息。

2. webpack-merge。

用于合并webpack的配置

3. speed-measure-webpack-plugin。

简称SMP,SMP会分析在整个打包过程中各个loader和plugin上耗费的时间,助于找出构建过程中的性能瓶颈。

4. size-plugin。

帮助监控资源体积的变化,今早发现问题。

热模块替换

确保项目是基于webpack-dev-server或者webpack-dev-middle进行开发的,Webpack本身命令行并不支持HMR。

原理:HMR的核心是客户端从服务端拉取更新后的资源(准确地说,HMR拉取的不是整个资源文件,而是chunk diff,即chunk需要更新的部分。)

打包工具对比

webpack

  1. webpack默认支持多种模块打包,AMD、CommonJS、ES6。
  2. Webpack有完备的代码分割(code splitting)解决方案。
  3. Webpack可以处理各种类型的资源。除了JS外,还有样式、模版、甚至图片等。
  4. Webpack有庞大的社区支持,除了Webpack核心库外,还有无数开发者来为它编写周边插件和工具。

Rollup

  1. Rollup更专注于JavaScript的打包。
  2. tree shaking也是基于对ES6 Modules的静态分析。
  3. 可通过配置output.format开发者可以选择输出资源的模块形式。

3.Parcel

Parcel在打包速度的优化上主要做了3件事。

  1. 利用worker来并行执行任务。
  2. 文件系统缓存。
  3. 资源编译处理流程优化,再有就是零配置。