Webpack实战(入门、进阶与调优)
第1章 简介
何为webpack:
Webpack是一个开元的JS模块打包工具,其最核心的功能是解决模块之间的依赖,把各个模块按照特定的规则和顺序组织在一起,最终合并为一个JS文件,这个过程就叫做模块打包。
为什么需要webpack:
应用规模大了以后,必须借助一定的工具,否则人工维护代码的成本将逐渐变得难以承受,学会使用工具可以让开发效率成倍的提升。
何为模块:
在设计程序结构时,把所有代码都堆到一起是非常糟糕的做法。更好的组织方式按照特定的功能将其拆分为多个代码段,每个代码段实现一个特定的目的。你可以对其进行独立的设计、开发和测试,最终通过接口来将它们组合到一起,这就是基本的模块化思想。
引入多个js文件到页面中的缺点:
①需要手动维护js的加载顺序。页面的多个script之间通常会有依赖关系,但由于这种依赖关系是隐式的,除了添加注释以外很难清晰地指明谁依赖了谁,这样当加载文件过多的时候就会出现问题。
②每一个script标签,都意味着需要向服务器请求一次静态资源,在HTTP2还没出现的时期,建立连接的成本是很高的,过多的请求会严重拖慢网页的渲染速度。
③每个script标签中,全局作用域,如果没有进行任何处理而直接在代码中进行变量或者函数声明,就会造成全局作用域污染。
模块化则解决了上述的所有问题:
①通过导入和导出与我们可以清晰模块之间的依赖关系。
②模块可以借助工具来打包,在页面上只需要加载整合后的资源文件,减少了网络开销。
③多个模块之间的作用域是隔离的彼此不会有命名冲突。
09年开始js社区开始进行模块化尝试,并依次出现了AMD、CommonJS、CMD等解决方案。但这些都是社区提出的,并不能算语言本身的特性。而在2015年。ES6正式定义了模块标准,这门语言在诞生20年之后终于有人模块这一概念。
ES6模块标目前已经得到了大多数现代浏览器的支持,但在实际应用方面还需要一段时间,有以下原因:
①无法使用 code splitting 和 tree shaking(webpack的两个重要特性)
②大多数npm模块还是CommonJS的形式,而浏览器并不支持其语法,因此这些包没有办法直接使用。
③仍需考虑个别浏览器及平台的兼容性问题。
模块打包工具的两种工作方式:
①将存在依赖关系的模块按照特定规则合并为单个JS文件,一次全部加载进页面中。
②在页面初始时加载一个入口模块,其他模块异步地进行加载。
目前社区中比较流行的打包模块有Webpack,Parcel,Rollup等。
为什么选择webpack?对比同类模块打包工具,webpack具备哪些优势?
①Webpack默认支持多种模块标准,包括AMD、CommonJS,以及最新的ES6模块,而其他工具大多数只能支持一到两种。这对于使用多种模块标准的工程非常有用,Webpack会帮助我们处理不同类型模块之间的依赖关系。
②Webpack有完备的代码分割解决方案。它可以分割打包后的资源,首屏只加载必要的部分,不太重要的功能放到后面动态地加载。这对于资源体积较大的应用来说尤为重要,可以有效地减小资源体积,提升首页渲染速度。
③Webpack可以处理各种类型的资源。除了js外,webpack还可以处理样式,模板,图片等,而开发者需要做的仅仅是导入它们。比如你可以从js文件导入一个CSS或者PNG,而这一切最终都可以由loader来处理。
④Webpack拥有庞大的社区支持。除了webpack核心库以外,还有无数开发者为它编写周边的插件和工具,绝大多数需求都可以找到已有解决方案。
安装:
webpack,对操作系统没有要求,唯一的依赖就是Node.js
webpack对node版本是有一定要求的,推荐使用LTS版本。LTS版本是node在当前阶段较为稳定的版本。该版本中不会包含太多激进的特性。
安装好node,使用 Node.js 的包管理器 npm 来安装 Webpack,安装模块方式有两种:全局安装,本地安装。
两种安装方式利弊及其特点:
全局安装的好处是,npm会帮我们绑定一个命令行环境变量,一次安装处处运行;本地安装则会添加其成为项目中的依赖,只能在项目内部使用。
建议本地安装,有以下原因:
①如果采用全局安装,那么在与他人进行项目协作的时候,由于每个人系统中webpack版本不同,可能会导致输出结果不一样。
②部分依赖于webpack的插件会调用项目中webpack的内部模块,这种情况下仍需要进行本地安装,而全局本地都有,则容易造成混淆。
npx webpack --entry=./index.js --output-filename=bundle.js -mode=development
第一个参数:entry 是资源打包的入口。webpack从这里开始进行模块依赖的查找,的到项目中的两个js模块,并通过它们来生成最终产物。
第二个参数:output-filenam 是输出资源名。打包后生成的dist目录下,包含一个bundle.js就是webpack的打包结果。
最后的参数:mode 指的是打包模式。Webpack为开发者提供了 development、production、none三种模式,除了none模式都会自动添加适合当前模式的一系列配置,为了减少工作量,开发中选择development模式即可。
scripts 是 npm 提供的脚本命令功能,在这里可以直接使用由模块添加的命令。(比如 webpack 取代之前的 npx webpack)
使用默认的目录配置:
工程源代码放在 /src 中,
输出资源放在 /dist 中。
对于资源输出目录来说 webpack ,默认是 /dist ,我们不需要做任何改动。
同时webpack默认源代码入口就是 src/index.js , 因此按照此目录顺序,可以省略掉 entry 的配置了。
虽然目录命名并不是强制的,但还是建议遵循统一命名规范,这样会使得大体结构比较清晰,也利于多人协作。
1.4.4 使用配置文件
通过 module.exports 导出一个对象,也就是打包时被webpack接收的配置对象。先前在命令行中输入的一大串参数就都要改为 key-value 的形式放在这个对象下。
目前该对象包含两个关于资源输入资源输出的属性——entry 和 output 。
entry就是我们的资源入口,output则是一个包含更多详细配置的对象。
之前的参数 --output-filename 和 --output-path 现在都成为了 output 下面的属性。filename,和先前一样都是bundle.js,不需要改动,而path和之前是有所区别的,webpack 对于 output.path 的要求是使用绝对路径(从系统根目录开始的完整路径),之前命令行中为了简洁都是相对路径。
而在webpack.config.js 中,我们通过调用node.js的路径拼装函数——path.join,将_dirname (Node.js 内置的全局变量,值为当前文件所在的绝对路径)与dist(输出目录)连接起来,得到最终的输出目录。
1.4.5 webpack-dev-server
安装指令中的--save-dev参数是将webpack-dev-server作为工程的devDependencies(开发环境依赖)记录在package.json中。
这样做是因为webpack-dev-server仅仅在本地开发环境中才用到。
假如工程上线时要进行依赖安装,就可以通过 npm install --production 过滤掉 devDependencies 中的冗余模块,从而加快安装和发布的速度。
为了便捷地启动 webpack-dev-server,我们再package.json中添加一个dev指令:
然后还需要对 " webpack-dev-server " 进行配置。编辑webpack.config.js 如下:
我们在配置中添加了一个 devServer 对象,它是专门用来放 webpack,dev-server 配置的。webpack-dev-server 可以看做是一个服务者,它的主要工作就是接收浏览器请求,然后将资源返回。当服务启动时,会先让 Webpack 进行模块打包并将资源准备好(在示例中就是bundle.js)。
当 webpack-dev-server 接收到浏览器的资源请求时。它会首先进行 URL 地址校验。如果该地址是资源服务地址(上面配置的publicPath),就会从 Webpack 的打包结果中寻找该资源并返回浏览器。反之,如果请求不属于资源服务地址,则直接读取硬盘中的源文件并将其返回。
综上,总结出 webpack-dev-server 的两大职能:
①令Webpack进行模块打包,并处理打包结果的资源请求。
②作为普通的 Web Server ,处理静态资源文件请求。
webpack-dev-server,不是像直接用webpack开发那样每次都会生成bundle.js ,而 webpack-dev-server 只是将打包结果放在内存中,并不会写入实际的bundle.js ,在每次 webpack-dev-server 接收到请求时都只是将内存中的打包结果返回给浏览器。
webpack-dev-server 还有一项很便捷的特性就是 live-reloading(自动刷新)。
当webpack-dev-server发现工程源文件进行了更新操作就会自动刷新浏览器,显示更新后的内容。
之后会讲到,hot-module-replacement(模块热替换),我们始终不需要刷新浏览器就能获取到更新之后的内容。
1.5 小结
webpack的功能,它可以处理模块之间的依赖,将它们串联起来合并为单一的JS文件。
安装webpack一般选择本地安装,这样可以使团队开发时共用一个版本,并且可以让其他插件直接获取webpack的内部模块。
配置本地开发环境可以借助 npm scripts 来维护命令行脚本,当打包脚本参数过多时,我们需要将其转换为 webpack.config.js ,用文件的方式维护复杂的 webpack 配置。
webpack-dev-server 的作用启动一个本地服务,可以处理打包资源与静态资源的请求。它的 live-reloading 功能可以监听文件变化,自动刷新界面提高开发效率。
第2章 模块打包
CommonJS
导出是一个模块向外暴露自身的唯一方式。CommonJS中,通过 module.exports 进行导出
导入,CommonJS中使用 require 进行模块导入
module 对象用来存放其信息,其 loaded 属性用于记录该模块是否被加载过。第一次被加载和执行后会被置为 true ,后面再次加载时检查到 module.loaded 为 true ,则不会再执行模块代码了。
ES6 Module
ES6 Module 会自动采取严格模式 (所以要将未开启严格模式的代码转换为ESM要注意此点)
使用 export 命令导出模块,命名导出 和 默认导出
命名导出可以用as关键字改变名字,导入导出的时候都可以。
默认导出就是 export default ,可以理解为 对外输出了一个名为 default 的变量。
使用 import 语法导入模块。
导入多个变量可以用 import * as <myMoudle> 把所有导入的变量作为属性值添加到 <myModule>
默认导出 import 后面的名字可以自由指定,它指代了导出文件默认导出的值。
CommonJS 和 ES6 Module 的区别?
commonJS 对模块依赖的解决是动态的,依赖建立在代码发生阶段。require 指定路径可以动态指定。
ES6Module 对模块依赖关系的建立是在代码的编译阶段。声明式的导入、导出语句,不支持导入路径是表达式。
ESM相对于CommonJS的优点:
①死代码检测和排除。可以用静态分析工具检测出那些模块没有被调用过,打包的时候可以去除,减小资源体积。
②模块变量类型检查。js属于动态类型语言,不会在代码执行前检查类型错误。ESM的静态模块结构有助于确保模块之间传递的值和接口类型是正确的。
③编译器优化。在CommonJS等动态模块系统中,无论怎么导入都是一个对象,但是esm可以支持直接导入变量,减少了引用层级,程序效率更高。
值拷贝与动态映射
在导入模块时,
CommonJS获取的是一份导出值的拷贝;
ES6Module中则是动态映射。
在产生循环依赖的时候CommonJS会输出{}空对象,ESModule会输出undefined。
但是因为ESModule为动态映射,如果我们保证当导入的值被使用时已经设置好正确的导出值,就可以解决循环依赖产生的问题。
AMD标准:
define函数来定义,同步加载模块标准语法更加冗长,异步加载方式比较混乱,容易造成回调地狱,已经很少使用了。
UMD:
是一组模块形式的集合,UMD一般先判断AMD环境,也就是检查全局环境下是否有define函数。而通过AMD定义的模式是不支持CommonJS和ESM的,使用webpack的时候可以更改下UMD的判断顺序。
加载npm模块:
与其它语言相比,js缺乏标准库。
当开发者需要解析URL,日期解析等常见问题的时候,只能自己封装,
npm包管理器为开发者带来便捷,npm 可以让开发者在其它平台上找到由他人开发和发布的库。
很多语言都有包管理器,比如 JAVA 的 maven , Ruby 的 gem。
目前JavaScript的有两个主流包管理器,npm 和 yarn 。
每一个npm模块都有一个入口。当我们加载一个模块时,实际上就是加载该模块的入口文件。这个入口被维护在模块内部 package.json 文件的 main 字段中。
当加载模块时,实际上加载的是 node_module/lodash/lodash.js
除了直接加载外,也可以通过 <package_name>/<path> 的形式单独加载模块内部的某个JS文件。
import all from "lodash/fp/all.js"
这样webpack在打包的时候,也只是打包这一个引入文件,不会打包全部的lodash库,可以减小打包资源的体积。
模块打包原理:
bundle在浏览器上运行:
①最外层匿名函数会初始化浏览器执行环境,包括定义 installedModules对象、_wepack_require_ 函数等,为模块加载和执行做准备工作。
②加载入口模块,每个bundle.js 都只有一个入口模块
③执行模块代码,如果执行到了 module.exports 则记录下模块导出值;如果遇到 require 函数(_webpack_require_),则会暂时交出执行权。进入 _webpack_require_ 内加载其它模块的逻辑。
④_webpack_require_ 内会判断即将加载的模块是或否存在于 installedModule 中。存在直接取值,否则回到第3步,执行该模块的代码获取导出值。
⑤所有依赖加载完毕,执行权回到入口模块。
第三步和第四部是一个递归的过程。webpack为每个模块创造了一个可以导出和导入的环境,本质上没有修改代码的执行逻辑,因此代码执行顺序和模块加载顺序是完全一致的,这就是webpack打包的奥秘。
小结:
CommonJS 和 ESModule 主要区别在于:
前者建立模块依赖关系是在运行时,后者是编译时。
导入方面,CommonJS 是值拷贝,ESModule导入的是只读的变量映射。
esm通过其静态特性可以进行编译过程中的优化,并且具备处理循环依赖的能力。
第3章 资源输入输出
entry 入口 -> 进入 各个module 形成一个 chunk ,由 chunk -> 打包得到 bundle
工程中可以定义多个入口,每个入口都会产生一个结果资源。所以,entry 与 bundle 存在着对应关系。
某些特殊情况,一个入口也可能产生多个chunk,并最终生成多个bundle。
配置资源入口:
webpack 通过 cotext 和 entry 这两个配置项共同决定入口文件路径。
配置入口实际做了两件事:
确定入口模块位置,告诉webpack从哪里开始进行打包。
定义 chunk name 。如果工程只有一个入口,那么默认其 chunk name 为 “main” ;如果工程有多个入口,需要为每个入口定义 chunk name , 来作为该 chunk 的唯一标识。
context 可以理解为资源入口的路径前缀,在配置时要求必须使用绝对路径的形式。
//二者效果相同 module.exports = { context:path.join(_dirname,'./src'), entry:'./scripts/index.js', } module.exports = { context:path.join(_dirname,'./src/scripts'), entry:'./index.js', }
配置 context 的主要目的是让 entry 的编写更加简洁,尤其是在多入口的情况下。context 可以省略,默认值为当前工程的根目录。
entry 与context只能为字符串不同,entry的配置可以是多种形式的:字符串、数组、对象、函数。可以根据不同的需求场景来选择。
字符串类型: module.exports = { entry:'./src/index.js', output:{ filename:'dundle.js' }, mode:'development' , } 数组类型入口: 传入一个数组的作用是将多个资源预先合并,在打包时 webpack 会将数组中的最后一个元素作为实际入口路径。如: module.exports = { entry:['babel-polyfill','./src/idex.js'], } 上面的配置等同于: //webpack.config.js module.exports = { entry:'./src/index.js' } //index.js import 'babel-polyfill'; 对象类型入口: 如果想要定义多入口,则必须使用对象形式。对象的属性名(key)是 chunk name ,属性值(value)是入口路径。如: module.exports = { entry:{ // chunk name 为 index,入口路径为 ./src/index.js index:'./src/index.js', // chunk name 为 lib,入口路径为 ./src/lib.js lib:'./src/lib.js' } } 函数类型入口: 用函数定义时,只需要返回上面任意形式即可。 module.exports = { entry:()=>'./src/index.js', } 传入函数的优点在于我们可以在函数体内添加一些动态逻辑来获取工程的入口。另外,函数也支持返回一个 Promise 对象来进行异步操作。
3.2.3 实例
单页应用:对于SPA来说,一般定义单一入口即可
module.exports = { entry:'./src/app.js', }
配置资源出口:
module.export = { entry:'./src/app.js', output:{ filename:'./js/bundle.js',
path:'../'
} }
filename不仅是名字,还是webpack生成的相对路径,即使没有webpack也会生成。path,可以单独配置导出的位置,默认dist。
filename模板变量配置:
[name]:指定chunk name
[hash]:指代此次打包所有资源的hash
[chunkhush]:指代当前chunk内容的hash
[id]:指代当前chunk的id
[query]:指代filename配置项中的query
实际工程中,使用的较多的是[name],它与chunk是一一对应的关系,且可读性较高。如果要控制客户端缓存,最好还是加上[chunkhash],每个chunk产生的[chunkhash]只与自身内容有关,单个chunk内容的改变不会影响其它资源,可以最精确的让客户端得到缓存。
output:{ filename:'[name]@[chunkhash].js' }
3.3.3 publicPath 指定资源的请求位置:
html、host、CDN
webpack-dev-serve 也有publicpath配置,指定的是静态资源服务路径。
module.export = { entry:'./src/app.js', output:{ filename:'./js/bundle.js', path:'../' }, devServer:{ publicPath:"./assets", port:3000 } }
本章小结:
本章介绍了资源输入输出流程,以及相关配置项context、entry、output。
在配置打包入口时,context相当于路径前缀,entry是入口文件路径。单入口的chunk name不可更改,多入口的话必须为每一个chunk指定chunk name。
当第三方依赖较多时,我们可以用提取 vendor 的方法将这些模块打包到一个单独的bundle中,以更有效地利用客户端缓存,加快页面渲染速度。
path和publicPath的区别在于path指定的资源是输出位置,而publicPath指定的是间接资源的请求位置。
第4章 预处理器
loader :的字面意思是装载器,在webpack中它的实际功能则更像是预处理器。webpack本身只识别js,对于其它资源必须先定义一个或者多个loader进行转译,输出为webpack能接收的形式再继续进行,因此loader实际做的是一个预处理的工作。
模块具有高内聚性和可复用性结构,通过“webpack”一切皆“模块”思想,我们可以将模块的这些特征应用到每一种静态资源上,从而设计和实验出更加健壮的系统。
loader 本身就是一个函数,在该函数中对接收到的内容进行转换,然后返回转换后的结果(可能包含source map 和 AST对象)。
module.exports = function loader (content,map,meta){ var callback = this.async(); var result = handler(content,map,meta); callback( null, //error result.content, //转化后的内容 result.map, //转化后的 source-map result.meta //转化后的AST ) }
loader 都是一些第三方npm模块,webpack本身不包含任何loader,一次使用loader第一步就是从npm安装它。 npm install XX-loader
将loader引入工程的具体配置:
module.exports = { //... module:{ rules:[{ test:/\.xx$/, use:['XX-loader'], }] } } //loader的相关配置都在对象module中, //其中module.rules代表模块处理规则, //每条规则可以包含很多配置项,这里我们只使用了最重要的两项。 //test:可接收一个正则表达式,或者元素为恒泽表达式的数组,只有正则配上的模块才会使用这条规则。 //use:可接收一个数组,数组包含该规则所使用的loader。