第3章 前端构建工具 本章全面介绍前端开发中最流行和最常见的模块化构建工具,包括Webpack、Rollup、Lerna、Vite工具的原理和开发实践。通过本章读者可以全面掌握各种构建工具的使用场景、优缺点和用法。 3.1前端构建工具介绍 前端构建工具能帮助前端开发人员把编写的Less、SASS等代码编译成原生CSS,也可以将多个JavaScript文件合并及压缩成一个JavaScript文件,对前端不同的资源文件进行打包,它的作用就是通过将代码编译、压缩、合并等操作,来减少代码体积,减少网络请求,方便在服务器上运行。 3.1.1为什么需要构建工具 随着前端开发项目的规模越来越大,业务模块和代码模块也越来越复杂,因此在项目开发过程中需要高效的构建工具帮助开发者解决项目中的痛点问题。下面列举几个企业项目开发中的痛点问题: (1) 在大型的前端项目中,浏览器端的模块化存在两个主要问题,第一是效率问题,精细的模块化(更多的JS文件)带来大量的网络请求,从而降低了页面访问效率; 第二是兼容性问题,浏览器端不支持CommonJS模块化,而很多第三方库使用了CommonJS模块化。 (2) 在大型前端项目开发中,需要考虑很多非业务问题,如执行效率、兼容性、代码的可维护性、可拓展性,团队协作、测试等工程问题。 (3) 在浏览器端,开发环境和线上环境的侧重点完全不一样。 开发环境: ■ 模块划分得越精细越好; ■ 不需要考虑兼容性问题; ■ 支持多种模块化标准; ■ 支持NPM和其他包管理器下载的模块; ■ 能解决其他工程化的问题。 线上环境: ■ 文件越少越好,减少网络请求; ■ 文件体积越小越好,传输速度快; ■ 兼容所有浏览器; ■ 代码内容越乱越好; ■ 执行效率越高越好。 开发环境和线上环境面临的情况有较大差异,因此需要一个工具能够让开发者专心地书写开发环境的代码,然后利用这个工具将开发时编写的所有代码转化为运行时所需要的资源文件。这样的工具称为构建工具,如图31所示。 图31构建工具的作用 3.1.2构建工具的功能需求 前端构建工具的本质是要解决前端整体资源文件的模块化,并不单指JS模块化,随着JavaScript在企业中大规模应用,复杂的前端项目越来越需要通过构建工具来帮助实现以下几方面的功能要求。 1. 模块打包器(Module Bundler) (1) 解决模块JS打包问题。 (2) 可以将零散的JS代码整合到一个JS文件中。 2. 模块加载器(Loader) (1) 对于存在兼容问题的代码,可以通过引入对应的解析编译模块进行编译。 (2) 对各种代码进行编译前的预处理。 3. 代码拆分(Code Splitting) (1) 将应用中的代码按需求进行打包,避免因将所有的代码打包成一个文件而使文件过大的问题。 (2) 可以将应用加载初期所需代码打包在一起,而其余的代码在后续执行中按需加载,实现增量加载或渐进加载。 (3) 可以避免出现文件过大或文件太碎的问题。 4. 支持不同类型的资源模块 解决前端各种静态资源的打包。 3.1.3前端构建工具演变 随着前端开发规模的增大,也不断推动前端构建工具链的向前发展,这里可以形象地总结为了以下几个阶段: 石器时代、青铜时代、白银时代、黄金时代。 石器时代(纯手工): 需要纯手工打包构建、预览文件、刷新文件; 代表是Ant脚本+YUI Compressor,如图32所示,由Yahoo所发展的一套JavaScript与CSS压缩工具,可以协助网页开发者生成最小化的网页。 图32YUI Compressor 青铜时代(脚本式): 通过编写bash或Node.js任务脚本,实现命令式的热更新(HMR)和自动打包,代表为Grunt、Gulp,如图33所示。 图33Grunt和Gulp 白银时代(Bundle): 通过集成式构建工具完成热更新(HMR),处理兼容和编译打包。打包代表为Babel、Webpack、Rollup、Parcel,如图34所示。 图34白银时代的打包工具代表 黄金时代(Bundleless): 通过浏览器解析原生ESM模块实现Bundleless的开发预览及热更新(HMR),不打包发布或采用Webpack等集成式工具兼容打包,以保证兼容性,代表为ESBuild、Snowpack、Vite,如图35所示。 图35黄金时代的打包工具代表 说明: ESBuild是采用Go语言编写的bundler,对标tsc和Babel,只负责ts和js文件的转换。 3.1.4NPM与Yarn、PNPM NPM是随同Node一起安装的包管理工具,用于Node模块管理(包括安装、卸载、管理依赖等)。 Yarn是由Facebook、Google、Exponent和Tilde联合推出的一个新的JS包管理工具,用来代替NPM及其他模块管理器现有的工作流程,并且保持了对NPM代理的兼容性。它在与现有工作流程功能相同的情况下保证了操作更快、更安全和更可靠。 PNPM是一个速度快、磁盘空间高效的软件包管理器。PNPM使用内容可寻址文件系统将所有模块目录中的所有文件存储在磁盘上。当使用NPM或Yarn时,如果有100个项目使用lodash,则磁盘上将有100个lodash副本。当使用PNPM时,lodash将存储在内容可寻址的存储中。 这3个主流包管理器的图标如图36所示。 图36主流包管理器 PNPM官方网站为https://pnpm.io/。Yarn官方网站为https://yarnpkg.com/,中文网站为http://yarnpkg.cn/zhHans/。 1. 安装方式 Yarn的安装方式,命令如下: #全局安装Yarn npm install --global yarn #全局安装PNPM npm install -g pnpm PNPM的安装方法,命令如下: #通过 NPM 安装 npm install -g pnpm #通过 NPX 安装 npx pnpm add -g pnpm #升级 #一旦安装了PNPM,就无须再使用其他软件包管理器进行升级,可以使用PNPM升级自己 pnpm add -g pnpm 2. 常用命令比较 NPM、Yarn、PNPM的操作命令差别不大,具体可以参考表31。 表31NPM、Yarn、PNPM命令对比 功 能 说 明 NPM Yarn PNPM 创建package.json npm init yarn init pnpm init 安装本地依赖包 npm install、npm i yarn pnpm install、pnpm i 安装并运行依赖包,保存至package.json文件中 npm install save yarn add pnpm add 安装开发依赖包,并保存至package.json文件中 npm install savedev yarn add dev pnpm add D 更新本地依赖包 npm update yarn upgrade pnpm up pnpm upgrade 卸载本地依赖包 npm uninstall yarn remove pnpm remove 续表 功 能 说 明 NPM Yarn PNPM 安装全局依赖包 npm install g yarn global add pnpm add g 更新全局依赖 npm update g yarn global upgrade pnpm upgrade global 查看全局依赖包 npm ls g yarn global list pnpm list 卸载全局依赖包 yarn global remove npm uninstall g pnpm remove global 清除缓存 npm cache clean yarn cache clean — 3. 选择哪个包管理器 如何选择合适的包管理器,表32列举了部分内容的对比情况,供大家选择。 表32NPM和Yarn、PNPM功能对比 功 能 说 明 NPM Yarn PNPM 团队 Node.js官方 Facebook Zoltan Kochan Full stack web developer 工作流 完整 完整 完整 包下载速度 NPM 5.0版本后快 并行下载,快 快 monorepo NPM v7.x以上版本支持 支持 支持 3.2Webpack Webpack是由德国开发者Tobias Koppers开发的模块加载器,如图37所示。Webpack最初主要想解决代码拆分的问题,而这也是Webpack今天受欢迎的主要原因。随着Web应用规模越写越大,移动设备越来越普及,拆分代码的需求与日俱增。如果不拆分代码,就很难实现期望的性能。 图37Webpack框架Logo 2014年,Facebook的Instagram的前端团队分享了他们在对前端页面加载进行性能优化时用到了Webpack的Code Splitting(代码拆分)功能。随即Webpack被广泛传播使用,同时开发者也给Webpack社区贡献了大量的Plugin(插件)和Loader(转换器)。 3.2.1Webpack介绍 Webpack是一个现代JavaScript应用程序的静态模块打包器(Module Bundler)。当Webpack处理应用程序时,它会递归地构建一个依赖关系图(Dependency Graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个包。 1. Webpack的优点 Webpack相比较其他构建工具,具有以下几个优点。 (1) 智能解析: 对CommonJS、AMD、CMD等支持得很好。 (2) 代码拆分: 创建项目依赖树,每个依赖都可拆分成一个模块,从而可以按需加载。 (3) Loader: Webpack核心模块之一,主要处理各类型文件编译转换及Babel语法转换。 (4) Plugin(插件系统): 强大的插件系统,可实现对代码压缩、分包chunk、模块热替换等,也可实现自定义模块、对图片进行base64编码等,文档非常全面,自动化工作都有直接的解决方案。 (5) 快速高效: 开发配置可以选择不同环境的配置模式,可选择打包文件使用异步I/O和多级缓存提高运行效率。 (6) 微前端支持: Module Federation也对项目中如何使用微型前端应用提供了一种解决方案。 (7) 功能全面: 最主流的前端模块打包工具,支持流行的框架打包,如React、Vue、Angular等,社区全面。 2. Webpack的工作方式 Webpack的工作方式是把项目当作一个整体,通过一个给定的主文件(如index.js),Webpack将从这个主文件开始找到项目的所有依赖文件,使用Loader处理它们,最后打包为一个(或多个)浏览器可识别的JavaScript文件,如图38所示。 图38Webpack运行方式 3.2.2Webpack安装与配置 接下来,详细介绍如何安装和配置Webpack工具。 1. 安装Node.js 推荐到Node.js官网下载stable版本。NPM作为Node.js包管理工具在安装Node.js时已经顺带安装好了。 注意: 保持Node.js和Webpack的版本尽量新,可以提升Webpack打包速度。 2. 更新Node.js 如果想更新Node.js版本,则可以使用n模块,命令如下: node -v#首先查看当前Node版本 npm info node#可以查看Node版本信息 npm install -g n#安装n模块 sudo n stable#安装稳定版本 sudo n latest#或者安装最新版本 3. 更新NPM 如果需要更新NPM,则可以执行的命令如下: sudo npm install npm@latest -g 4. 项目创建及初始化 初始化一个Webpack编译的项目,命令如下: mkdir webpack-demo#首先创建一个文件夹 cd webpack-demo#进入文件夹 npm init#初始化项目,使项目符合Node.js的规范,也可以用npm init -y #这一步会在文件夹中生成package.json文件,这个文件描述了 #Node项目的一些信息 目录结构如下: webpack-demo/ ├─── node_modules/ ├─── src/ │ └─── main.js#entry 入口文件 ├─── webpack.config.js#Webpack 配置文件 ├─── package-lock.json └─── package.json#已安装 Webpack、Webpack-cli 5. Webpack安装与卸载 注意: Webpack安装需要同时安装Webpack和Webpackcli这两个模块。 npm install webpack webpack-cli -g#全局安装 npm uninstall webpack webpack-cli -g #全局卸载 npm install webpack webpack-cli -D#在项目中安装 webpack npm install webpack@4.46.0 webpack-cli -D#安装指定版本 npx webpack -v#在项目中安装查看Webpack版本号 npm info webpack#查看Webpack历史版本 6. 通过配置文件使用Webpack 配置文件规定了Webpack该如何打包,而执行npx webpack ./main.js进行打包使用的则是Webpack提供的默认配置文件。 配置webpack.config.js文件,配置如下: //webpack.config.js const path = require('path'); module.exports = { mode: 'production', entry: './src/main.js', output: { filename: 'bundle.js', path: path.resolve(dirname, 'dist') } } 默认模式为mode:production,如果mode被配置为production,则打包出的文件会被压缩,如果mode被配置为development,则不会被压缩。 entry的意思是这个项目要打包,以及从哪一个文件开始打包。打包输出中Chunk Names配置的main就是entry中的main。简写模式如下: entry: { main: './main.js' } output的意思是打包后的文件放在哪里: (1) output.filename指打包后的文件名。 (2) output.path指打包后的文件放到哪一个文件夹下,是一个绝对路径。需要引入Node中的path模块,然后调用这个模块的resolve方法。 Webpack配置文件的作用是设置配置的参数,提供给Webpackcli,Webpack从main.js文件开始打包,打包生成的文件放到bundle文件夹下,生成的文件名叫作bundle.js。如果运行npx webpack命令,则会按照配置文件进行打包。 Webpack默认的配置文件名为webpack.config.js,如果要使用自定义名字(例如my.webpack.js作为配置文件名),则可以用指令npx webpack config my.webpack.js实现。 7. 配置package.json NPM scripts原理: 当执行npm run xx命令时,实际上运行的是package.json文件中的xx命令。在scripts标签中使用Webpack,会优先到当前项目的node_modules中查找是否安装了Webpack(和直接使用Webpack命令时到全局查找是否安装Webpack不同),命令如下: "scripts": { "dev": "npx webpack" }, 如果运行dev命令,则会自动执行webpack命令。最后可以直接运行npm run dev命令进行Webpack打包。 执行npm run dev命令后,在命令行中会输出编译完成后的提示信息,效果如图39所示。 图39Webpack编译提示 3.2.3Webpack基础 Webpack的模块打包工具通过分析模块之间的依赖,最终将所有模块打包成一份或者多份代码包,供 HTML 直接引用。 1. 核心概念 Webpack最核心的概念如下。 (1) Entry: 入口文件,Webpack会从该文件开始进行分析与编译。 (2) Output: 出口路径,打包后创建包的文件路径及文件名。 (3) Module: 模块,在Webpack中任何文件都可以作为一个模块,会根据配置使用不同的Loader进行加载和打包。 (4) Chunk: 代码块,可以根据配置将所有模块代码合并成一个或多个代码块,以便按需加载,提高性能。 (5) Loader: 模块加载器,进行各种文件类型的加载与转换。通过不同的Loader,Webpack有能力调用外部的脚本或工具,实现对不同格式文件的处理,例如分析及将scss转换为css,或者把ES6+文件(ES6和ES7)转换为现代浏览器兼容的JS文件,对React的开发而言,合适的Loader可以把React中用到的JSX文件转换为JS文件。 (6) Plugin: 拓展插件,可以通过Webpack相应的事件钩子,介入打包过程中的任意环节,从而对代码按需修改。 2. 常见加载器(Loader) Webpack仅仅提供了打包功能和一套文件处理机制,然后通过生态中的各种Loader和Plugin对代码进行预编译和打包。 Loader的作用: (1) Loader让Webpack能够去处理那些非JavaScript文件。 (2) Loader专注实现资源模块加载从而实现模块的打包。 将常用的加载器分成以下三类。 (1) 编译转换类: 将资源模块转换为JS代码。以JS形式工作的模块,如cssloader。 (2) 文件操作类: 将资源模块复制到输出目录,同时将文件的访问路径向外导出,如fileloader。 (3) 代码检查类: 对加载的资源文件(一般是代码)进行校验,以便统一代码风格,提高代码质量,一般不会修改生产环境的代码。 下面介绍最常用的几种加载器: 解析ES6+、处理JSX、CSS/Less/SASS样式、图片与字体。 3. 解析ES6+ 在Webpack中解析ES6需要使用Babel,Babel是一个JavaScript编译器,可以实现将ES6+转换成浏览器能够识别的代码。 Babel在执行编译时,可以依赖.babelrc文件,当设置依赖文件时,会从项目的根目录下读取.babelrc的配置项,.babelrc配置文件主要是对预设(presets)和插件(plugins)进行配置。 下面介绍如何在Webpack中使用Babel。 安装依赖,命令如下: npm i @babel/core @babel/preset-env babel-loader -D 注意: Babel 7推荐使用@babel/presetenv套件来处理转译需求。顾名思义,preset即“预制套件”,包含了各种可能用到的转译工具。之前的以年份为准的preset已经废弃了,现在统一用这个总包。同时,Babel已经放弃开发stage*包,以后的转译组件都只会放进presetenv包里。 配置webpack.config.js文件的Loader,配置如下: "scripts": { module: { rules: [ { test: /.js$/, use: 'babel-loader' } ] } 在根目录创建.babelrc,并配置presetenv对ES6+语法特性进行转换,配置如下: { "presets": [ [ "@babel/preset-env", { //对ES6模块文件不进行转化,以便使用Tree Shaking、sideEffects等 "modules": false, } ] ], "plugins": [] } 注意: Babel默认只转换新的JavaScript句法(syntax),而不转换新的API,例如Iterator、Generator、Set、Maps、Proxy、Reflect、Symbol、Promise等全局对象,以及一些定义在全局对象上的方法(例如Object.assign)都不会转码。 转译新的API,需要借助polyfill方案去解决,可使用@babel/polyfill或@babel/plugintransformruntime,二选一即可。 4. @babel/polyfill 本质上@babel/polyfill是corejs库的别名,随着corejs@3的更新,@babel/polyfill无法从2过渡到3,所以@babel/polyfill已经被放弃,可查看corejs 3的更新。 安装依赖包,命令如下: npm i @babel/polyfill -D .babelrc文件需写入配置,而@babel/polyfill 不用写入配置,会根据useBuiltIns参数去决定如何被调用,配置如下: { "presets": [ [ "@babel/preset-env", { "useBuiltIns": "entry", "modules": false, "corejs": 2,//新版本的@babel/polyfill包含了core-js@2和core-js@3版本, //所以需要声明版本,否则Webpack运行时会报warning,此处暂时使用 //core-js@2版本(末尾会附上@core-js@3怎么用) } ] ] } 配置参数说明如下。 (1) Modules: "amd"|"umd"|"systemjs"|"commonjs"|"cjs"|"auto"|false。 默认值为auto。用来转换ES6的模块语法。如果使用false,则不会对文件的模块语法进行转换。 如果要使用Webpack中的一些新特性,例如,Tree Shaking和sideEffects,就需要设置为false,对ES6的模块文件不进行转换,因为这些特性只对ES6模块有效。 (2) useBuiltIns: "usage"|"entry"|false,默认值为false。 false: 需要在JS代码的第一行主动通过import'@babel/polyfill'语句将@babel/polyfill整个包导入(不推荐,能覆盖到所有API的转译,但体积最大)。 entry: 需要在JS代码的第一行主动通过import'@babel/polyfill'语句将browserslist环境不支持的所有垫片导入(能够覆盖到'hello'.includes('h')这种句法,足够安全且代码体积不是特别大)。 usage: 项目里不用主动通过import导入,会自动将代码里已用到的且browserslist环境不支持的垫片导入(但是检测不到'hello'.includes('h')这种句法,对这类原型链上的句法问题不会进行转译,书写代码需注意)。 (3) targets用来配置需要支持的环境,不仅支持浏览器,还支持Node。如果没有配置targets选项,就会读取项目中的browserslist配置项。 (4) loose的默认值为false,如果presetenv中包含的Plugin支持loose的设置,则可以通过这个字段来统一设置。 5. 解析React JSX JSX是React框架中引入的一种JavaScript XML扩展语法,它能够支持在JS中编写类似HTML的标签,本质上来讲JSX就是JS,所以需要Babel和JSX的插件presetreact支持解析。 安装React及@babel/presetreact,命令如下: npm i react react-dom @babel/preset-react -D 配置解析React的presets,配置如下: module.exports = { entry: {}, output: {}, resolve: { //要解析的文件的扩展名 extensions: [".js", ".jsx", ".json"], //解析目录时要使用的文件名 mainFiles: ["index"], }, module: { rules: [ { test: /\.(js|jsx)$/, exclude: /(node_modules|bower_components)/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env', '@babel/preset-react'] } } }, ] }, }; 6. 解析CSS 传统上会在HTML文件中引入CSS代码,借助webpack styleloader和cssloader可以在.js文件中引入CSS文件并让样式生效,如果需要使用预处理脚本,如LESS,则需要安装lessloader。 (1) cssloader: 用于加载.css文件并转换成commonJS对象。 (2) styleloader: 将样式通过style标签插入head中。 安装依赖cssloader和styleloader,命令如下: npm i style-loader css-loader -D Webpack配置项添加Loader配置,其中由于Loader的执行顺序是从右向左执行的,所以会先进行CSS的样式解析后执行style标签的插入,配置如下: { test:/.css$/, use: [ 'style-loader', 'css-loader' ] } lessloader将.less转换成.css。安装lessloader依赖并添加Webpack配置,配置如下: npm i less less-loader -D { test:/.less$/, use: [ 'style-loader', 'css-loader', 'less-loader' ] } 7. 解析图片和字体 Webpack提供了两个Loader来处理二进制格式的文件,如图片和字体等,urlloader允许有条件地将文件转换为内联的base64 URL(当文件小于给定的阈值时),这会减少小文件的HTTP请求数。如果文件大于该阈值,则会自动交给fileloader处理。 1) fileloader fileloader用于处理文件及字体。安装fileloader依赖并配置,配置如下: #安装file-loader npm i file-loader -D #webapck.config配置 { test: /\.(png|svg|jpg|jpeg|gif)$/, use: 'file-loader'},{ test:/\.(woff|woff2|eot|ttf|otf|svg)/, use:'file-loader' } 2) urlloader urlloader也可以处理文件及字体,对比fileloader的优势是可以通过配置,将小资源自动转换为base64。 安装urlloader依赖并配置Webpack,配置如下: { test: /\.(png|svg|jpg|jpeg|gif)$/, use: [ { loader:'url-loader', options: { limit:10240 } } ] } 8. 常见插件(Plugin) 插件的目的是为了增强Webpack的自动化能力,Plugin可解决其他自动化工作,如清除dist目录、将静态文件复制至输出代码、压缩输出代码,常见的场景如下: (1) 实现自动在打包之前清除dist目录(上次的打包结果)。 (2) 自动生成应用所需要的HTML文件。 (3) 根据不同环境为代码注入类似API地址这种可能变化的部分。 (4) 将不需要参与打包的资源文件复制到输出目录。 (5) 压缩Webpack打包完成后输出的文件。 (6) 自动将打包结果发布到服务器以实现自动部署。 9. 文件指纹 文件指纹的作用: (1) 在前端发布体系中,为了实现增量发布,一般会对静态资源加上md5文件后缀,保证每次发布的文件都没有缓存,同时对于未修改的文件不会受发布的影响,最大限度地利用缓存。 (2) 简单地来讲“文件指纹”的应用场景是在项目打包时使用(上线),在项目开发阶段用不到。 这里简单介绍一下3种不同的hash表示方式。 (1) hash: 与整个项目的构建相关,当有文件修改时,整个项目构建的hash值就会更新。 (2) chunkhash: 和Webpack打包的chunk相关,不同的entry会生成不同的chunkhash,一般用于.js文件的打包。 (3) contenthash: 根据文件内容来定义hash,如果文件内容不变,则contenthash不变。例如.css文件的打包,当修改了.js或.html文件但没有修改引入的.css样式时,文件不需要生成新的hash值,所以可适用于.css文件的打包。 注意: 文件指纹不能和热更新一起使用。 1) .js文件指纹设置: chunkhash 代码如下: #webpack.dev.js module.export = { entry: { index: './src/demo.js', search: './src/search.js' }, output: { path: path.resolve(dirname,'dist'), filename: '[name][chunkhash:8].js' }, } 2) .css文件指纹: contenthash 由于上面方式通过style标签将css插入head中并没有生成单独的.css文件,因此可以通过mincssextractplugin插件将css提取成单独的.css文件,并添加文件指纹。 安装依赖minicssextractplugin,命令如下: npm i mini-css-extract-plugin -D 配置.css文件指纹,配置如下: const MiniCssExtractPlugin = require('mini-css-extract-plugin') module.export = { module: { rules: [ { test:/\.css$/, use: [ MiniCssExtractPlugin.loader, 'css-loader', ] }, ] }, plugins: [ new MiniCssExtractPlugin({ filename: '[name][contenthash:8].css' }) ] } 3) 图片文件指纹设置: hash 其中,hash对应的是文件内容的hash值,默认由md5生成,不同于前面所讲的hash值,配置如下: module.export = { module:{ rules: [ { test: /\.(png|svg|jpg|jpeg|gif)$/, use: [{ loader:'file-loader', options: { name: 'img/[name][hash:8].[ext]' } }], } ] } } 代码压缩,这里介绍两个插件: .css文件压缩和.html文件压缩。 (1) .css文件压缩: optimizecssassetswebpackplugin。 安装optimizecssassetswebpackplugin和预处理器cssnano,命令如下: npm i optimize-css-assets-webpack-plugin cssnano -D 配置Webpack,配置如下: const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin') module.export = { plugins: [ new OptimizeCssAssetsPlugin({ assetNameRegExp: /\.css$/g, cssProcessor: require('cssnano') }) ] } (2) .html文件压缩: htmlwebpackplugin。 安装htmlwebpackplugin插件,命令如下: npm i html-webpack-plugin -D 配置Webpack,配置如下: const HtmlWebpackPlugin = require('html-webpack-plugin') module.export = { plugins: [ new HtmlWebpackPlugin({ template: path.join(dirname,'src/search.html'),//使用模板 filename: 'search.html',//打包后的文件名 chunks: ['search'], //打包后需要使用的chunk(文件) inject: true, //默认注入所有静态资源 minify: { html5:true, collapsableWhitespace: true, preserveLineBreaks: false, minifyCSS: true, minifyJS: true, removeComments: false } }), ] } 10. 跨应用代码共享(Module Federation) Module Federation使JavaScript应用得以在客户端或服务器上动态运行另一个包的代码。Module Federation主要用来解决多个应用之间代码共享的问题,可以更加优雅地实现跨应用的代码共享。 1) 三个概念 首先,要理解三个重要的概念,如表33所示。 表33三个重要的概念 模 块 名 称 属 性 描 述 webpack 一个独立项目通过Webpack打包编译而产生资源包 remote 一个暴露模块供其他Webpack构建消费的Webpack构建 host 一个消费其他remote模块的Webpack构建 一个Webpack构建可以是remote(服务的提供方),也可以是host(服务的消费方),还可以同时扮演服务提供者和服务消费者的角色,这完全看项目的架构。 2) host与remote两个角色的依赖关系 任何一个Webpack构建既可以作为host消费方,也可以作为remote提供方,区别在于职责和Webpack配置的不同。 3) 案例讲解 一共有三个微应用: libapp、componentapp、mainapp,角色分别如表34所示。 表34三个微应用的关系 模 块 名 称 属 性 描 述 libapp 作为remote,暴露了两个模块react和reactdom componentapp 作为remote和host,依赖libapp暴露了一些组件供mainapp消费 mainapp 作为host,依赖libapp和componentapp 图310libapp模块目录结构 下面分别创建三个微应用的项目: libapp模块提供其他模块所依赖的核心库,如libapp对外提供react和reactdom两个类库模块。 步骤1: 创建libapp模块的项目,目录结构如图310所示。 该模块需要安装两个类库,即react和reactdom,命令如下: #安装核心模块 npm i react react-dom -S #安装开发依赖模块 npm install concurrently serve webpack webpack-cli -D 步骤2: 配置Webpack编译配置,配置如下: const {ModuleFederationPlugin} = require('webpack').container const path = require('path'); module.exports = { entry: "./index.js", mode: "development", devtool:"hidden-source-map", output: { publicPath: "http://localhost:3000/", clean:true }, module: { }, plugins: [ new ModuleFederationPlugin({ name: "lib_app", filename: "remoteEntry.js", exposes: { "./react":"react", "./react-dom":"react-dom" } }) ], }; 这里通过ModuleFederationPlugin插件设置暴露的库,详细信息如表35所示。 表35ModuleFederationPlugin属性 属性名称 属 性 描 述 name 必选,唯一ID,作为输出的模块名,使用时通过${name}/${expose}的方式使用 library 必选,这里的library.name作为umd的library.name remotes 可选,表示作为host时,去消费哪些remote exposes 可选,表示作为remote时,export哪些属性被消费 shared 可选,优先用host的依赖,如果host没有,则再用自己的 步骤3: 配置package.json文件中的运行脚本,脚本如下: "scripts": { "webpack": "webpack --watch", "serve": "serve dist -p 3000", "start": "concurrently \"npm run webpack\" \"npm run serve\"" }, 步骤4: 除去生成的map文件,有4个文件,如图311所示,输出具体的编译文件如下: main.js remoteEntry.js react_index.js react-dom_index.js 步骤5: 在命令行中,输入npm serve命令启动libapp项目,启动后在3000端口浏览。 http://localhost:3000/ componentapp模块对外提供组件库,如Button、Dialog、Logo基础组件。 步骤1: 创建componentapp模块,目录结构如图312所示。 图311libapp模块打包输出目录 图312componentapp模块目录 步骤2: 创建Button、Dialog、Logo三个React组件。 Button.jsx组件,代码如下: //Button.jsx import React from 'lib-app/react'; export default function(){ return } Dialog.jsx组件,代码如下: //Dialog.jsx import React from 'lib-app/react'; export default class Dialog extends React.Component { constructor(props) { super(props); } render() { if(this.props.visible){ return (

输入你的昵称:

); }else{ return null; } } } Logo.jsx组件,代码如下: //Logo.jsx import React from 'lib-app/react'; import pictureData from './girl.jpg' export default function(){ return } 步骤3: 需要以异步的方式导入index.js模块,所以这里创建了bootstrap.js模块,在index.js文件中通过import导入bootstrap.js文件,代码如下: #Bootstrap.js import App from "./App"; import ReactDOM from 'lib-app/react-dom'; import React from 'lib-app/react' ReactDOM.render(, document.getElementById("app")); 在index.js文件中导入bootstrap.js文件,代码如下: import("./bootstrap.js") 步骤4: webpack.config文件的配置如下: const {ModuleFederationPlugin} = require('webpack').container; const HtmlWebpackPlugin = require("html-webpack-plugin"); const path = require('path'); module.exports = { entry: "./index.js", mode: "development", devtool:"hidden-source-map", output: { publicPath: "http://localhost:3001/", clean:true }, resolve:{ extensions: ['.jsx', '.js', '.json','.css','.scss','.jpg','jpeg','png',], }, module: { rules: [ { test:/\.(jpg|png|gif|jpeg)$/, loader:'url-loader' }, { test: /\.css$/i, use: ["style-loader", "css-loader"], }, { test: /\.jsx?$/, loader: "babel-loader", exclude: /node_modules/, options: { presets: ["@babel/preset-react"], }, }, ], }, plugins: [ new ModuleFederationPlugin({ name: "component_app", filename: "remoteEntry.js", exposes: { "./Button":"./src/Button.jsx", "./Dialog":"./src/Dialog.jsx", "./Logo":"./src/Logo.jsx" }, remotes:{ "lib-app":"lib_app@http://localhost:3000/remoteEntry.js" } }), new HtmlWebpackPlugin({ template: "./public/index.html", }) ], }; 图313创建mainapp模块 步骤5: 启动模块。 在该子项目下运行npm run start命令打开浏览器: localhost: 3001,可以看到组件正常工作。 mainapp模块为三个模块中的主模块,该模块依赖componentapp模块的基础组件,同时也依赖libapp模块。 步骤1: 创建mainapp模块,目录结构如图313所示。 步骤2: 创建App.jsx文件,编写界面,代码如下: #App.jsx import React from 'lib-app/react'; import Button from 'component-app/Button' import Dialog from 'component-app/Dialog' import Logo from 'component-app/Logo' export default class App extends React.Component{ constructor(props) { super(props) this.state = { dialogVisible:false } this.handleClick = this.handleClick.bind(this); this.HanldeSwitchVisible = this.HanldeSwitchVisible.bind(this); } handleClick(ev){ console.log(ev); this.setState({ dialogVisible:true }) } HanldeSwitchVisible(visible){ this.setState({ dialogVisible:visible }) } render(){ return (

Buttons:

) } } 步骤3: 配置Webpack,配置如下: const {ModuleFederationPlugin} = require('webpack').container const HtmlWebpackPlugin = require("html-webpack-plugin"); const path = require('path'); module.exports = { entry: "./index.js", mode: "development", devtool:"hidden-source-map", output: { publicPath: "http://localhost:3002/", clean:true }, resolve:{ extensions: ['.jsx', '.js', '.json','.css','.scss','.jpg','jpeg','png',], }, module: { rules: [ { test:/\.(jpg|png|gif|jpeg)$/, loader:'url-loader' }, { test: /\.jsx?$/, loader: "babel-loader", exclude: /node_modules/, options: { presets: ["@babel/preset-react"], }, }, ], }, plugins: [ new ModuleFederationPlugin({ name: "main_app", remotes:{ "lib-app":"lib_app@http://localhost:3000/remoteEntry.js", "component-app":"component_app@http://localhost:3001/remoteEntry.js" }, }), new HtmlWebpackPlugin({ template: "./public/index.html", }) ], }; 步骤4: 启动编译器后,通过localhost: 3002端口查看,效果如图314所示。 图314跨应用代码共享效果 11. 开发运行构建 使用Webpack的Webpackdevserver插件可以帮助开发者快速搭建一个代码运行环境,Webpackdevserver提供的热更新功能极大地方便了代码编译后进行预览。 1) 文件监听: watch 在Webpackcli中提供了watch工作模式,这种模式下项目中的文件会被监视,一旦这些文件发生变化就会自动重新运行打包任务。 Webpack开启监听模式有以下两种方式: (1) 启动Webpack命令时带上watch参数。 (2) 在配置webpack.config.js文件中设置watch: true。 缺点是每次都需要手动刷新浏览器,需要自己使用一些http服务,例如使用httpserver轮询判断文件的最后编辑时间是否发生变化,一开始有个文件的修改时间,这个修改时间先存储起来,下次再有修改时就会和上次修改时间进行比对,发现不一致时不会立即告诉监听者,而是把文件缓存起来,等待一段时间,等待期间内如果有其他变化,则会把变化列表一起构建,并生成到bundle文件夹。 可通过Webpack添加配置或者CLI添加配置的方式开启监听模式,该方式在源码变化时需要每次手动刷新浏览器。 Webpack配置的代码如下: module.export = { watch: true } 除了可通过watch参数的配置方式开启监听外,也可通过定制watch模式选项的形式watchOptions来定制监听配置,配置如下: module.export = { watch: true, //只有开启了监听模式才有效 watchOptions: { ignored: /node_modules/,//默认为空,设置不监听的文件或文件夹 aggregateTimeout: 300, //默认为300ms,即监听变化后需要等待的执行时间 poll:1000 //默认为1000ms,通过轮询的方式询问系统指定文件 //是否发生变化 } } Webpackdevserver 是 Webpack 官方推出的一个开发工具,它提供了一个开发服务器,并且集成了自动编译和自动刷新浏览器等一系列功能的安装指令,如图315所示。 图315热更新的大概流程 Webpack Compiler将JavaScript编译成输出的bundle.js文件。 HMR Server将热更新的文件输出到HMR Runtime。 Bundle Server通过提供服务器的形式,提供浏览器对文件的访问。 HMR Runtime在开发打包阶段将构建输出文件注入浏览器,更新文件的变化。 当启动Webpackdevserver阶段时,将源码在文件系统进行编译,通过Webpack Compiler编译器打包,并将编译好的文件提交给Bundle Server服务器,Bundle Server即可以服务器的方式供浏览器访问。 当监听到源码发生变化时,经过Webpack Compiler的编译后提交给HMR Server,一般通过websocket实现监听源码的变化,并通过JSON数据的格式通知HMR Runtime,HMR Runtime对bundle.js文件进行改变并刷新浏览器。 相比于watch不能自动刷新浏览器,Webpackdevserver的优势就明显了。Webpackdevserver构建的内容会存放在内存中,所以构建速度更快,并且可自动地实现浏览器的自动识别并做出变化,其中Webpackdevserver需要配合Webpack内置的HotModuleReplacementPlugin插件一起使用。 安装依赖Webpackdevserver并配置启动项,命令如下: #安装 npm i webpack-dev-server -D //package.json "scripts": { "dev": "webpack-dev-server --open" } 配置Webpack,其中Webpackdevserver一般在开发环境中使用,所以需将mode模式设置为development,配置如下: const webpack = require('webpack') plugins: [ new webpack.HotModuleReplacementPlugin() ], devServer: { contentBase: path.join(dirname,'dist'),//监听dist文件夹下的内容 hot: true//启动热更新 } 2) 清理构建目录: cleanwebpackplugin 由于每次构建项目前并不会自动地清理目录,会造成输出文件越来越多。这时就得手动清理输出目录的文件。 借助cleanwebpackplugin插件清除构建目录,默认会执行删除output值的输出目录。 安装cleanwebpackplugin插件并配置,命令如下: npm i clean-webpack-plugin -D 在webpack.config.js文件中配置,代码如下: const { CleanWebpackPlugin } = require('clean-webpack-plugin') module.export = { plugins: [ new CleanWebpackPlugin() ] } 3.2.4Webpack进阶 可以通过Webpack插件进行打包内容分析,优化编译速度,减少构建包体积,同时通过接口编写自己的Loader和Plugin插件。 1. 项目分析 通过Webpackbundleanalyzer可以看到项目各模块的大小,对各模块可以按需优化。 该插件通过读取输出文件夹(通常是dist)中的stats.json文件,把该文件可视化展现。便于直观地比较各个包文件的大小,以达到优化性能的目的。 安装Webpackbundleanalyzer,命令如下: npm install --save-dev webpack-bundle-analyzer 在webpack.config.js文件中导入Webpackbundleanalyzer 模块,在plugins数组中实例化插件,配置如下: const path = require("path") var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin module.exports = { mode: "development", entry: "./src/main.js", output: { filename: "bundle.js", path: path.resolve(dirname, "dist") }, plugins:[ new BundleAnalyzerPlugin() ] } 2. 编译阶段提速 编译模块阶段的效率提升,下面的操作都是在Webpack编译阶段实现的。 1) IgnorePlugin: 忽略第三方包指定目录 IgnorePlugin是Webpack的内置插件,其作用是忽略第三方包指定目录。 有的依赖包,除了项目所需的模块内容外,还会附带一些多余的模块,例如moment模块会将所有本地化内容和核心功能一起打包。 下面案例通过配置的Webpackbundleanalyzer来查看使用IgnorePlugin后对moment模块的影响,Webpack的配置如下: const webpack = require('webpack') //webpack.config.js module.exports = { //... plugins: [ //忽略 moment模块下的 ./locale 目录 new webpack.IgnorePlugin({ resourceRegExp: /^\.\/locale$/, contextRegExp: /moment$/, }), ] } 2) DllPlugin和DllReferencePlugin提高构建速度 在使用Webpack进行打包时,对于依赖的第三方库,例如React、Redux等不会修改的依赖,可以让它和自己编写的代码分开打包,这样做的好处是每次更改本地代码文件时,Webpack只需打包项目本身的文件代码,而不会再去编译第三方库,第三方库在第一次打包时只打包一次,以后只要不升级第三方包,Webpack就不会对这些库打包,这样可以快速地提高打包速度,因此为了解决这个问题,DllPlugin和DllReferencePlugin插件就产生了。 DLLPlugin能把第三方库与自己的代码分离开,并且每次文件更改时,它只会打包该项目自身的代码,所以打包速度会更快。 DLLPlugin插件在一个额外独立的Webpack设置中创建一个只有dll的bundle,也就是说,在项目根目录下除了有webpack.config.js文件,还会新建一个webpack.dll.config.js文件。webpack.dll.config.js的作用是除了把所有的第三方库依赖打包到一个bundle的dll文件里面,还会生成一个名为manifest.json的文件。该manifest.json文件的作用是让DllReferencePlugin映射到相关的依赖上去。 DllReferencePlugin插件在webpack.config.js文件中使用,该插件的作用是把刚刚在webpack.dll.config.js文件中打包生成的dll文件引用到需要的预编译的依赖上来。什么意思呢?就是说在webpack.dll.config.js文件中打包后会生成vendor.dll.js文件和vendormanifest.json文件,vendor.dll.js文件包含所有的第三方库文件,vendormanifest.json文件会包含所有库代码的一个索引,当在使用webpack.config.js文件打包DllReferencePlugin插件时,会使用该DllReferencePlugin插件读取vendormanifest.json文件,看一看是否有该第三方库。vendormanifest.json文件只有一个第三方库的一个映射。 第一次使用webpack.dll.config.js文件时会对第三方库打包,打包完成后就不会再打包它了,然后每次运行webpack.config.js文件时,都会打包项目中本身的文件代码,当需要使用第三方依赖时,会使用DllReferencePlugin插件去读取第三方依赖库,所以说它的打包速度会得到一个很大的提升。 在项目中使用DllPlugin和DllReferencePlugin,其使用步骤如下。 在使用之前,首先看一下项目现在的整个目录架构,架构如下: Demo#工程名 | |--- dist#打包后生成的目录文件 | |--- node_modules #所有的依赖包 | |--- js #存放所有的js文件 | | |-- main.js#js入口文件 | |--- webpack.config.js #Webpack配置文件 | |--- webpack.dll.config.js #打包第三方依赖的库文件 | |--- index.html#html文件 | |--- package.json 因此需要在项目根目录下创建一个 webpack.dll.config.js 文件,配置的代码如下: const path = require('path'); const webpack = require('webpack'); module.exports = { entry: { //library中配置要处理的第三方库的名称 library: [ 'react', 'react-dom' ] }, output: { filename: '[name]_[chunkhash].dll.js',//library.dll.js文件中暴露出的库的名称 path: path.join(dirname, 'build/library'),//打包后文件输出的位置 library: '[name]_[hash]' //库暴露出来的名字,可以参考打包组件和 //基础库 }, plugins: [ new webpack.DllPlugin({ name: '[name]_[hash]',//生成一个文件映射json名字 path: path.join(dirname, 'build/library/[name].json') //保存的位置 }) ]}; 切换到webpack.config.js 配置,引入文件,代码如下: //引入 DllReferencePlugin const DllReferencePlugin = require('webpack/lib/DllReferencePlugin'); 然后在插件中使用该插件,代码如下: plugins: [ new webpack.DllReferencePlugin({ context: dirname, manifest: require('./build/library/') }) ], 最后一步就是构建代码了,先生成第三方库文件,运行命令如下: webpack --config webpack.dll.config.js 3. 优化构建体积 1) 摇树优化(Tree Shaking) Webpack 4.0后通过开启mode: production即可开启摇树优化功能。 Tree Shaking摇掉代码中未引用部分(deadcode),production模式下会自动在使用Tree Shaking打包时除去未引用的代码,其作用是优化项目代码。 2) 删除无效的CSS PurgeCSS是一个能够通过字符串对比来决定移除不需要的CSS的工具。PurgeCSS通过分析内容和CSS文件,首先将CSS文件中使用的选择器与内容文件中的选择器进行匹配,然后会从CSS中删除未使用的选择器,从而生成更小的CSS文件。对于PurgeCSS的配置因项目的不同而不同,它不仅可以作为Webpack的插件,还可以作为postcss的插件。一般与glob、globall配合使用。 安装purgecsswebpackplugin,命令如下: npm i purgecss-webpack-plugin -D 配置Webpack,配置如下: //webpack.prod.js const glob = require('glob') const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const PurgecssPlugin = require('purgecss-webpack-plugin') const PATHS = { src: path.join(dirname,'src')} module.exports ={ module:{ rules: [ { test: /.css$/, use: [ MiniCssExtractPlugin.loader, 'css-loader' ] }, ] }, plugins: [ new MiniCssExtractPlugin({ filename: '[name]_[contenthash:8].css' }), new PurgecssPlugin({ path: glob.sync('${PATHS.src}/**/*', {nodir:true})//绝对路径 }), ]} 4. 编写自定义Loader Loader是一种打包的方案。可以定义一种规则,告诉Webpack当它遇到某种格式的文件后,去求助相应的Loader。有些时候需要一些特殊的处理方式,这就需要自定义一些Loader。 一个简单的Loader通过编写一个简单的JavaScript模块,并将模块导出即可。接收一个source当前源码,并返回新的源码即可。 Loader分为同步Loader和异步Loader。如果单个处理,则可以直接使用同步模式处理后直接返回,但如果需要多个处理,就必须在异步Loader中使用this.async()告诉当前的上下文context,这是一个异步的Loader,需要Loader Runner等待异步处理的结果,在异步处理完之后再调用this.callback()传递给下一个Loader执行。 编写同步Loader,代码如下: //同步 Loader module.exports = function(source, sourceMap, meta) { return source } 对于异步Loader,使用 this.async 获取 callback()函数。 编写异步Loader,代码如下: //异步Loader module.exports = function(source) { const callback = this.async(); setTimeout(() => { const output = source.replace(/World/g, 'Webpack5'); callback(null, output); }, 1000); } 执行构建,Webpack会等待一秒,然后输出构建内容,通过Node.js执行构建后的文件,输出如下: Webpack5 1) 编写一个简单的Loader Webpack默认只能识别JavaScript模块,在实际项目中会有.css .less .scss .txt .jpg .vue等文件,这些都是Webpack无法直接识别打包的文件,都需要使用Loader来直接或者间接地进行转换成可以供Webpack识别的JavaScript文件。 图316自定义loader目录 创建一个简单的Loader,命名为helloloader。helloloader的作用是让Webpack识别.hello扩展名的模块,并进行转换打包。 第1步: 创建目录loaderdemo。创建文件如图316所示,这里创建一个test.hello自定义文件,helloloader需要能够识别该模块,并进行打包。 第2步: 编写helloloader,代码如下: module.exports = function loader(source) { source = "hello loader!" return 'export default ${ JSON.stringify(source) }'; //返回值 }; 第3步: 编写main.js打包入口代码。main.js是打包的入口文件,因为要尽可能简单,所以这个文件只做一件事,即加载.hello文件,并显示到页面,代码如下: #main.js import data from './test.hello'; function test() { let element = document.getElementById('app'); console.log(data); element.innerText = data; } test(); 第4步: 编写index.html文件。main.js文件的代码逻辑很简单,就是获取页面中的一个id为app的元素,并将.hello中的值显示在元素中,代码如下: #index.html 自定义hello-loader
第5步: 这里暂时不对test.hello文件进行特殊处理,所以test.hello文件里面的代码可以随便写,输出如下: //里面随便写 第6步: 在webpack.config.js文件中配置规则,完整的打包配置如下: const path = require('path'); module.exports= { entry: "./main.js", mode: "development", output: { filename: "bundle.js", path: path.resolve(dirname, "output") }, module: { rules: [ { test: /\.hello$/,//需要加载的文件类型,正则匹配 use: [path.resolve(dirname, './loader/hello-loader.js'),] //我们的loader文件 } ] } } 第7步: 编译打包。在项目目录下直接执行webpack命令,如图317所示,打包的文件输出在output目录下,输出如下: webpack 图317编译自定义Loader 第8步: 在浏览器中运行index.html文件,效果如图318所示。 图318Loader的运行效果 图319自定义styleloadert和lessloader 2) 编写一个自定义的lessloader 下面自定义一个lessloader和styleloader。将编写的less经过这两个Loader处理之后使用style标签插入页面的head标签内。 为了测试自定义Loader,创建测试目录,结构如图319所示。 第1步: 定义一个.less文件,这里命名为index.less,代码如下: @red:red; @yellow:yellow; @baseSize:20px; body{ background-color: @red; color:@yellow; font-size: @baseSize; } 第2步: 新建一个lessloader.js文件,用于处理.less文件,代码如下: let less = require('less'); function loader(source) { const callback = this.async(); less.render(source) .then((output)=>{ callback(null, output.css); }, (err)=>{ //handler err }) } module.exports = loader; 这里处理的业务很简单,就是获得原始的.less文件的内容,将.less文件通过less.render编译为.css文件并传递给下一个Loader即可。 注意: 这里需要安装less模块,命令为npm install less D。 关键的代码如下: this.async告诉当前上下文这是一个异步的Loader,需要loader runner等待less.render异步处理的结果。 less.render接收less源码,并返回一个promise,在返回的promise中等待less.render处理完.less文件之后,使用callback将处理的结果返给下一个Loader。 第3步: 新建一个styleloader.js文件,代码如下: //Webpack 自定义Loader function loader(source) { source = JSON.stringify(source); const root = process.cwd(); const resourcePath = this.resource; const origin = resourcePath.replace(root, ''); let style = ' let style = document.createElement('style'); style.innerHTML = ${source}; style.setAttribute('data-origin', '${origin}'); document.head.appendChild(style); '; return style } module.exports = loader 使用JSON.stringify将接收到的.css文件变为一个可编辑字符串。process.cwd文件用于获取当前工程根目录。this.resource通过this上的该属性可获取当前处理的源文件的绝对路径。 之后创建一个style标签,将编译完之后的.css代码插入style标签内,并自定义一个dataorigin属性,用来标记当前文件在工程中的相对路径。 最后返回一个可执行的JS字符串给bundle.js。 第4步: 新建index.html文件,引用bundle.js文件,代码如下: 自定义less-loader 第5步: 编译并运行。在项目目录下直接执行webpack命令,如图320所示,打包的文件输出在dist目录下。 webpack 图320编译输出 通过浏览器查看效果,如图321所示。 图321使用自定义styleloader的效果 5. 编写自定义插件Plugin 插件件随Webpack构建的初始化到最后文件生成的整个生命周期,插件的目的是解决Loader无法实现的其他事情。另外,插件没有像Loader那样的独立运行环境,所以插件只能在Webpack里面运行。 Webpack通过Plugin机制让其更加灵活,以适应各种应用场景。在Webpack运行的生命周期中会广播出许多事件,Plugin可以监听这些事件,在合适的时机通过Webpack提供的API改变输出结果。 (1) 创建一个最基础的Plugin,代码如下: //采用ES6 class ExamplePlugin { constructor(option) { this.option = option } apply(compiler) {} } 以上就是一个最基本的Plugin结构。Webpack Plugin最为核心的便是这个apply()方法。 Webpack执行时,首先会生成插件的实例对象,之后会调用插件上的apply()方法,并将compiler对象(Webpack实例对象,包含Webpack的各种配置信息等)作为参数传递给apply()方法。 之后便可以在apply()方法中使用compiler对象去监听Webpack在不同时刻触发的各种事件进行想要的操作了。 接下来看一个简单的示例,代码如下: class plugin1 { constructor(option) { this.option = option console.log(option.name + '初始化') } apply(compiler) { console.log(this.option.name + ' apply被调用') //在Webpack的emit生命周期上添加一种方法 compiler.hooks.emit.tap('plugin1', (compilation) => { console.log('生成资源到 output 目录之前执行的生命周期') }) } } class plugin2 { constructor(option) { this.option = option console.log(option.name + '初始化') } apply(compiler) { console.log(this.option.name + ' apply被调用') //在Webpack的afterPlugins生命周期上添加一种方法 compiler.hooks.afterPlugins.tap('plugin2', (compilation) => { console.log('Webpack设置完初始插件之后执行的生命周期') }) } } module.exports = { plugin1, plugin2 } 定义Webpack配置文件,代码如下: const path = require("path"); const {plugin1,plugin2} = require("./plugins/plugin1") module.exports = { mode:"development", entry: { lib: "./src/index.js", }, output: { path: path.join(__dirname, "build"), filename: "[name].js", }, plugins: [ new plugin1({ name: 'plugin1' }), new plugin2({ name: 'plugin2' }) ], }; 编译后输出的结果如下: //执行Webpack命令后输出的结果如下/* plugin1初始化 plugin2初始化 plugin1 apply被调用 plugin2 apply被调用 Webpack设置完初始插件之后执行的生命周期 生成资源到 output 目录之前执行的生命周期 */ 首先Webpack会按顺序实例化plugin对象,之后再依次调用plugin对象上的apply()方法。 也就是对应输出: plugin1初始化、plugin2初始化、plugin1 apply被调用、plugin2 apply被调用。 Webpack在运行过程中会触发各种事件,而在apply()方法中能接收一个compiler对象,可以通过这个对象监听到Webpack触发各种事件的时刻,然后执行对应的操作函数。这套机制类似于Node.js的EventEmitter,总体来讲就是一个发布订阅模式。 在compiler.hooks中定义了各式各样的事件钩子,这些钩子会在不同的时机被执行,而上面代码中的compiler.hooks.emit和compiler.hooks.afterPlugin这两个生命周期钩子,分别对应了设置完初始插件及生成资源到output目录之前这两个时间节点,afterPlugin是在emit之前被触发的,所以输出的顺序更靠前。 (2) 编写一个输出所有打包目录文件列表的插件,这个插件在构建完相关的文件后,会输出一个记录所有构建文件名的list.md文件,代码如下: class myPlugin { constructor(option) { this.option = option } apply(compiler) { compiler.hooks.emit.tap('myPlugin', compilation => { let filelist = '构建后的文件: \n' for (var filename in compilation.assets) { filelist += '- ' + filename + '\n'; } compilation.assets[list.md'] = { source: function() { return filelist }, size: function() { return filelist.length } } }) } } 在Webpack的emit事件被触发之后,插件会执行指定的工作,并将包含了编译生成资源的compilation作为参数传入函数。可以通过compilation.assets获得生成的文件,并获取其中的filename值。 (3) Compiler和Compilation。上面在开发Plugin时最常用的两个对象就是Compiler和Compilation,它们是Plugin和Webpack之间的桥梁。 Compiler和Compilation的含义如下: ■ Compiler对象包含了Webpack环境所有的配置信息,包含options、loaders、plugins信息,这个对象在Webpack启动时被实例化,它是全局唯一的,可以简单地把它理解为Webpack实例; ■ Compilation对象包含了当前的模块资源、编译生成资源、变化的文件等。当Webpack以开发模式运行时,每当检测到一个文件变化,一次新的Compilation将被创建。Compilation对象也提供了很多事件回调供插件进行扩展。通过Compilation也能读取Compiler对象。 Compiler和Compilation的区别在于: Compiler代表整个Webpack从启动到关闭的生命周期,而Compilation只代表一次新的编译。 3.3Rollup Rollup是下一代ES6模块化工具,它最大的亮点是利用ES6模块设计,生成更简洁、更简单的代码。Rollup更适合构建JavaScript库,如图322所示。 图322Rollup框架Logo 3.3.1Rollup介绍 Rollup是一个JavaScript模块打包器,可以将小块代码编译成大块复杂的代码,例如library或应用程序。 Rollup对代码模块使用新的标准化格式,这些标准都包含在JavaScript的ES6版本中,而不是以前的特殊解决方案,如CommonJS和AMD。ES6模块可以使开发者自由、无缝地使用最喜爱的library中的那些最有用的独立函数,而项目不必携带其他未使用的代码。ES6模块最终还是要由浏览器原生实现,但当前Rollup可以使开发者提前体验。 1. Rollup的优缺点 Rollup将所有资源放到同一个地方,一次性加载,利用Tree Shaking特性来剔除未使用的代码,减少冗余。它有以下优点: (1) 配置简单,打包速度快。 (2) 自动移除未引用的代码(内置Tree Shaking)。 但是它也有以下几个不可忽视的缺点: (1) 开发服务器不能实现模块热更新,调试烦琐。 (2) 浏览器环境的代码分割时依赖AMD。 (3) 加载第三方模块比较复杂。 2. Rollup与Webpack及Parcel的区别 Rollup的主要功能是对JS进行编译打包,这和Webpack有本质区别。Webpack是一个通用的前端资源打包工具,它不仅可以处理JS,也可以通过Loader来处理CSS、图片、字体等各种前端资源,还提供了Hot Reload等方便前端项目开发的功能。如果不是开发一个JS框架,则Webpack显然会是一个更好的选择,Parcel更适于开发和测试环境,开发者无须任何配置就可以使用Parcel进行打包,如表36所示。 表36Rollup与Webpack及Parcel的区别 Webpack Rollup Parcel 文件类型 JS/CSS/HTML等多种类型的文件(配置Loader) 主要是JS文件 JS/CSS/HTML等多种类型的文件 多入口 支持(只能是JS文件) 支持(配置Plugin) 支持.html Library类型 不支持ESM 支持ESM 不支持ESM Code Splitting 支持 支持 支持 Tree Shaking 支持 支持 支持 HMR 支持(需要配置) 支持(需要配置) 支持(开发环境自动启动) TypeScript 支持 支持 支持 构建体积 Rollup { return count + 1; }; module.exports = { count, add, }; //main.js //报错 import add from "./add"; console.log("foo", add.count); 由于ES6模块导入时默认会去找default,因此这里打包会报错,这时就需要用到rollupplugincommonjs插件进行转换,配置如下: import commonjs from "rollup-plugin-commonjs"; export default { input: "./main.js", output: { file: "bundle.js", format: "iife", }, plugins: [commonjs()], }; 4. 模块热更新 Rollup本身不支持启动开发服务器,可以通过rolluppluginserve第三方插件来启动一个静态资源服务器,代码如下: import serve from "rollup-plugin-serve"; export default { input: "./main.js", output: { file: "dist/bundle.js", format: "iife", }, plugins: [serve("dist")], }; 不过由于其本质上是一个静态资源的服务器,因此不支持模块热更新。 5. Tree Shaking 由于Rollup本身支持ES6模块化规范,因此不需要额外配置即可进行Tree Shaking。 6. 代码分割 Rollup代码分割和Parcel一样,也是通过按需导入的方式,但是输出的格式不能使用iife,因为iife自执行函数会把所有模块放到一个文件中,可以通过amd或者cjs等其他规范,代码如下: export default { input: "./main.js", output: { //输出文件夹 dir: "dist", format: "amd", }, }; 这样通过import()动态导入的代码就会单独分割到独立的JS中,在调用时按需引入。不过对于这种amd模块的文件,不能直接在浏览器中引用,必须通过实现AMD标准的库加载,例如Require.js。 7. 多入口打包 多入口打包默认会提取公共模块,意味着会执行代码拆分,所以格式不能是iife格式,需要使用多入口打包方式,配置如下: export default { //这两种方式都可以 //input: ['src/index.js', 'src/album.js'], input: { foo: 'src/index.js', bar: 'src/album.js' }, output: { dir: 'dist', format: 'amd' } } 3.4ESBuild ESBuild是一个用Go语言编写的JavaScrip、TypeScript打包工具,如图323所示。ESBuild有两大功能,分别是bundler与minifier,其中bundler用于代码编译,类似babelloader、tsloader; minifier用于代码压缩,类似terser。 图323ESBuild构建工具Logo 大多数前端打包工具是基于JavaScript实现的,而ESBuild则选择使用Go语言编写,两种语言各自有其擅长的场景,但是在资源打包这种CPU密集型场景下,Go语言天生具有多线程运行能力,因此更具性能优势。 ESBuild官方网址为https://esbuild.github.io/。 1. ESBuild介绍 ESBuild是由Figma的CTO(Evan Wallace)基于Go语言开发的一款打包工具,相比传统的打包工具,主打性能优势在于构建速度上可以快10~100倍。 说明: Figma是一个基于浏览器的协作式UI设计工具。Figma Inc.创立于2012年10月1日,总部位于美国加州旧金山,开发了多人协作界面设计工具,可使整个团队的设计过程在一个在线工具中进行。 ESBuild项目的主要目标是: 开辟一个构建工具性能的新时代,创建一个易用的现代打包器。现在很多工具内置了它,例如熟知的Vite、Snowpack。借助ESBuild优异的性能,Vite更如虎添翼,编译打包速度显著提升。 ESBuild的主要特征有以下几点: (1) 打包时极致的速度,不需要缓存。 (2) 支持Source Maps。 (3) 支持压缩、支持插件。 (4) 支持ES6和CommonJS模块。 (5) 支持ES6模块的Tree Shaking。 (6) 提供JavaScript和Go的API。 (7) 支持TypeScript和JSX的语法。 2. ESBuild使用场景 1) 代码压缩工具 ESBuild的代码压缩功能非常优秀,其性能比传统的压缩工具高一个量级以上。Vite在2.6版本也官宣在生产环境中直接使用ESBuild来压缩JS和CSS代码。 2) 第三方库Bundler Vite在开发阶段使用ESBuild进行依赖的预打包,将所有用到的第三方依赖转换成ESM格式Bundler产物,并且未来有用到生产环境的打算。 3) 小程序编译 对于小程序的编译场景,也可以使用ESBuild来代替Webpack,大大提升编译速度,对于AST的转换则通过ESBuild插件嵌入SWC实现,从而实现快速编译。 4) Web构建 Web场景就显得比较复杂了,对于兼容性和周边工具生态的要求比较高,例如低浏览器语法降级、CSS预编译器、HMR等,如果要用纯ESBuild来做,则还需要补充很多能力。 3. ESBuild安装与配置 #本地安装,或者全局安装 npm install esbuild #查看版本 .\node_modules\.bin\esbuild --version 4. ESBuild快速上手 下面通过一个简单的Demo演示ESBuild如何编译打包React项目。 首先需要安装React、ReactDOM 库,命令如下: npm install react react-dom -S 创建一个App.jsx组件页面,代码如下: import React from 'react' import ReactDOM from 'react-dom' let Greet = () =>

Hello, ESBuild!

ReactDOM.render(,document.querySelector("#app")) 在package.json文件中添加一个编译命令,命令如下: "scripts": { "build": "esbuild App.jsx --bundle --outfile=out.js" }, 执行脚本命令,命令如下: npm run build 图324Vite构建工具Logo 3.5Vite Vite是Vue框架的作者尤雨溪为Vue 3开发的新的构建工具,目的是替代Webpack,其原理是利用现代浏览器已经支持ES6的动态import,当遇到import时会发送一个HTTP请求去加载文件,Vite会拦 截这些请求,进行预编译,省去了Webpack冗长的打包时间,提升开发体验。Vite构建工具的Logo如图324所示。 Vite借鉴了Snowpack,在生产环境使用Rollup打包。相比Snowpack,它支持多页面、库模式、动态导入、自动polyfill等。 Vite开源网址为https://github.com/vitejs/vite,Vite官方网址为https://vitejs.dev/。 3.5.1Vite介绍 Vite解决了Webpack开发阶段Dev Server冷启动时间过长,HMR(热更新)反应速度慢的问题。早期的浏览器基本上不支持ES Module,这个时候需要使用Webpack、Rollup、Parcel等打包构建工具来提取、处理、连接和打包源码,但是当项目变得越来越复杂,模块数量越来越多时,特别是在开发过程中,启动一个Dev Server所需要的时间也会变得越来越长,当编辑代码、保存、使有HRM功能时,可能也要花费几秒才能反映到页面中。这种开发体验是非常耗时的,同时体验也非常差,而Vite就是为解决这种开发体验上的问题的。总体来讲Vite有以下优点: (1) 去掉打包步骤,快速地冷启动。 (2) 及时进行模块热更新,不会随着模块变多而使热更新变慢。 (3) 真正按需编译。 3.5.2Vite基本使用 Vite不仅支持Vue 3项目构建,同时也支持其他的前端流行框架的项目构建,目前支持的框架有vanilla、vanillats、vue、vuets、react、reactts、preact、preactts、lit、litts、svelte、sveltets。 注意: Vite需要Node.js版本不低于12.0.0版,然而,有些模板需要依赖更高的Node版本才能正常运行,当包管理器发出警告时,需要注意升级Node版本。 1. Vite构建Vue项目 安装Vite,同时使用Vue 3模板构建项目,命令如下: #npm 6.x npm create vite@latest my-vue-app --template vue #npm 7+,extra double-dash is needed npm create vite@latest my-vue-app -- --template vue #yarn yarn create vite my-vue-app --template vue #pnpm pnpm create vite my-vue-app -- --template vue 命令执行的结果如图325所示。 图325创建项目成功提示 接下来,进入myvueapp目录,执行yarn命令安装依赖包,再执行yarn dev命令启动项目,如图326所示。 在浏览器中预览的效果如图327所示。 图326执行yarn dev命令启动项目 图327预览Vue 3+Vite项目效果 2. Vite构建React项目 安装Vite,同时使用React模板构建项目,命令如下: #npm 6.x npm create vite@latest my-vue-app --template react #npm 7+,extra double-dash is needed npm create vite@latest my-vue-app -- --template react #yarn yarn create vite my-vue-app --template react #pnpm pnpm create vite my-vue-app -- --template react 创建的项目结构如图328所示。 在项目目录下,执行yarn dev命令,启动项目,在浏览器中预览效果,如图329所示。 图328Vite+React项目的目录结构 图329预览Vite+React项目效果 3.5.3Vite原理 在介绍Vite原理之前,需要先了解打包模式(Bundle)和无打包模式(Bundleless)。Vite借鉴了Snowpack,采用无打包模式。无打包模式只会编译代码,不会打包,因此构建速度极快,与打包模式相比时间缩短了90%以上。 1. 打包vs无打包构建 2015年之前,前端开发需要打包工具来解决前端工程化构建的问题,主要原因在于网络协议HTTP 1.1标准有并行连接限制,浏览器方面也不支持模块系统(如CommonJS包不能直接在浏览器运行),同时存在代码依赖关系与顺序管理问题。 但随着2015年ESM标准发布后,网络通信协议也发展到多路并用的HTTP 2标准,目前大部分浏览器已经支持了HTTP 2标准和浏览器的ES Module,与此同时,随着前端工程体积的日益增长与亟待提升的构建性能之间的矛盾越来越突出,无打包模式逐渐发展兴起。 打包模式与无打包模式对比如表39所示。 表39打包模式与无打包模式对比 Bundle(Webpack) Bundleless(Vite/Snowpack) 启动时间 长,需要完成打包项目 短,只启动Server按需加载 构建时间 随项目体积线性增长 构建时间复杂度O(1) 加载性能 打包后加载对应的Bundle 请求映射至本地文件 缓存能力 缓存利用率一般,受split方式影响 缓存利用率近乎完美 文件更新 重新打包 重新请求单个文件 调试体验 通常需要SourceMap进行调试 不强依赖SourceMap,可单文件调试 生态 非常完善 目前相对不成熟,但是发展很快 2. Vite分为开发模式和生产模式 开发模式: Vite提供了一个开发服务器,然后结合原生的ESM,当代码中出现import时,发送一个资源请求,Vite开发服务器拦截请求,根据不同的文件类型,在服务器端完成模块的改写(例如单文件的解析、编译等)和请求处理,实现真正的按需编译,然后返回浏览器。请求的资源在服务器端按需编译返回,完全跳过了打包这个概念,不需要生成一个大的包。服务器随启随用,所以开发环境下的初次启动是非常快的,而且热更新的速度不会随着模块增多而变慢,因为代码改动后,并不会有打包的过程。 Vite本地开发服务器所有逻辑基本依赖中间件实现,如图330所示,中间件拦截请求之后,主要负责以下内容: 图330拦截不同的资源请求,实时编译转换 (1) 处理ESM语法,例如将业务代码中的import第三方依赖路径转换为浏览器可识别的依赖路径。 (2) 对.ts、.vue等文件进行即时编译。 (3) 对Sass/Less等需要预编译的模块进行编译。 (4) 和浏览器端建立socket连接,实现HMR。 生产模式: 利用Rollup来构建源码,Vite将需要处理的代码分为以下两大类。 第三方依赖: 这类代码大部分是纯JavaScript代码,而且不会经常变化,Vite会通过prebundle的方式来处理这部分代码。Vite 2使用ESBulid来构建这部分代码,ESBuild是基于Go语言实现的,处理速度会比用JavaScript写的打包器快10~100倍,这也是Vite为什么在开发阶段处理代码很快的一个原因。 业务代码: 通常这部分代码不是纯的JavaScript(例如JSX、Vue等)代码,经常会被修改,而且也不需要一次性全部加载(可以根据路由进行代码分割后加载)。 由于Vite使用了原生的ESM,所以Vite本身只要按需编译代码,然后启动静态服务器就可以了。只有当浏览器请求这些模块时,这些模块才会被编译,以便动态加载到当前页面中。