最近在学习前端工程化,以下是对《webpack实战 入门、进阶与调优》-居玉皓。这本书所做的笔记。非常好的一本书,可以了解到很多前端工程化方面的知识,推荐。
文章目录
-
- 1.webpack简介
-
-
- [1.1 何为webpack](#1.1 何为webpack)
- [1.2 为什么需要webpack](#1.2 为什么需要webpack)
-
- [1.2.1 何为模块](#1.2.1 何为模块)
- [1.2.2 javascript中的模块](#1.2.2 javascript中的模块)
- [1.2.3 模块打包工具](#1.2.3 模块打包工具)
- [1.2.4 为什么选择webpack](#1.2.4 为什么选择webpack)
- [1.3 安装](#1.3 安装)
- [1.4 小结](#1.4 小结)
-
- 2.模块打包
-
-
- [2.1 CommonJS](#2.1 CommonJS)
-
- [2.1.1 模块](#2.1.1 模块)
- [2.1.2 导出](#2.1.2 导出)
- [2.1.3 导入](#2.1.3 导入)
- [2.2 ES6 Module](#2.2 ES6 Module)
-
- [2.2.1 模块](#2.2.1 模块)
- [2.2.2 导出](#2.2.2 导出)
- [2.2.3 导入](#2.2.3 导入)
- [2.2.4 复合写法](#2.2.4 复合写法)
- [2.3 CommonJS与ES6 Module的区别](#2.3 CommonJS与ES6 Module的区别)
-
- [2.3.1 动态与静态](#2.3.1 动态与静态)
- [2.3.2 值复制与动态映射](#2.3.2 值复制与动态映射)
- [2.3.3 循环依赖](#2.3.3 循环依赖)
- [2.4 加载其他类型的模块](#2.4 加载其他类型的模块)
-
- [2.4.1 非模块化文件](#2.4.1 非模块化文件)
- [2.4.2 AMD](#2.4.2 AMD)
- [2.4.3 UMD](#2.4.3 UMD)
- [2.4.4 加载npm模块](#2.4.4 加载npm模块)
- [2.5 模块打包原理](#2.5 模块打包原理)
- [2.6 小节](#2.6 小节)
-
- 3.资源的输入和输出
-
-
- [3.1 资源处理流程](#3.1 资源处理流程)
- [3.2 配置资源入口](#3.2 配置资源入口)
-
- [3.2.1 context](#3.2.1 context)
- [3.2.2 entry](#3.2.2 entry)
- [3.2.3 实例](#3.2.3 实例)
- [3.3 配置资源出口](#3.3 配置资源出口)
-
- [3.3.1 filename](#3.3.1 filename)
- [3.3.2 path](#3.3.2 path)
- [3.3.3 publicPath](#3.3.3 publicPath)
- [3.3.4 实例](#3.3.4 实例)
- [3.4 小节](#3.4 小节)
-
- 4.预处理器
-
-
- [4.1 一切皆模块](#4.1 一切皆模块)
- [4.2 loader概述](#4.2 loader概述)
- [4.3 loader配置](#4.3 loader配置)
-
- [4.3.1 loader的引入](#4.3.1 loader的引入)
- [4.3.2 链式loader](#4.3.2 链式loader)
- [4.3.3 更多配置](#4.3.3 更多配置)
- [4.4 常用loader介绍](#4.4 常用loader介绍)
-
- [4.4.1 babel-loader](#4.4.1 babel-loader)
- [4.4.2 ts-loader](#4.4.2 ts-loader)
- [4.4.3 html-loader](#4.4.3 html-loader)
- [4.4.4 handlebars-loader](#4.4.4 handlebars-loader)
- [4.4.5 file-loader](#4.4.5 file-loader)
- [4.4.6 url-loader](#4.4.6 url-loader)
- [4.5 自定义loader](#4.5 自定义loader)
- [4.6 小节](#4.6 小节)
-
- 5.样式处理
-
-
- [5.1 分离样式文件](#5.1 分离样式文件)
-
- [5.1.1 **extract-text-webpack-plugin**](#5.1.1 extract-text-webpack-plugin)
- [5.1.2 多样式文件的处理](#5.1.2 多样式文件的处理)
- [5.1.3 mini-css-extract-plugin](#5.1.3 mini-css-extract-plugin)
- [5.2 样式预处理](#5.2 样式预处理)
-
- [5.2.1 Sass与SCSS](#5.2.1 Sass与SCSS)
- [5.3 PostCSS](#5.3 PostCSS)
-
- [5.3.1 PostCSS与Webpack](#5.3.1 PostCSS与Webpack)
- [5.3.2 自动前缀](#5.3.2 自动前缀)
- [5.3.3 stylelint](#5.3.3 stylelint)
- [5.3.4 CSSNext](#5.3.4 CSSNext)
- [5.4 CSS Modules](#5.4 CSS Modules)
- [5.5 小节](#5.5 小节)
-
- 6.代码分片
-
-
- [6.1 通过入口划分代码](#6.1 通过入口划分代码)
- [6.2 CommonsChunkPlugin](#6.2 CommonsChunkPlugin)
-
- [6.2.1 提取vendor](#6.2.1 提取vendor)
- [6.2.2 设置提取范围](#6.2.2 设置提取范围)
- [6.2.3 配置提取规则](#6.2.3 配置提取规则)
- [6.2.4 hash与长效缓存](#6.2.4 hash与长效缓存)
- [6.2.5 CommonsChunkPlugin的不足](#6.2.5 CommonsChunkPlugin的不足)
- [6.3 optimization.SplitChunks](#6.3 optimization.SplitChunks)
- [6.4 资源异步加载](#6.4 资源异步加载)
-
- [6.4.1 import()](#6.4.1 import())
- [6.4.2 异步chunk的配置](#6.4.2 异步chunk的配置)
- [6.5 小节](#6.5 小节)
-
- 7.生产环境配置
-
-
- [7.1 环境配置的封装](#7.1 环境配置的封装)
- [7.2 开启production模式](#7.2 开启production模式)
- [7.3 环境变量](#7.3 环境变量)
- [7.4 source-map](#7.4 source-map)
-
- [7.4.1 source-map原理](#7.4.1 source-map原理)
- [7.4.2 source-map配置](#7.4.2 source-map配置)
- [7.4.3 source-map安全](#7.4.3 source-map安全)
- [7.5 资源压缩](#7.5 资源压缩)
-
- [7.5.1 javascript压缩](#7.5.1 javascript压缩)
- [7.5.2 压缩CSS](#7.5.2 压缩CSS)
- [7.6 缓存](#7.6 缓存)
-
- [7.6.1 资源hash](#7.6.1 资源hash)
- [7.6.2 输出动态html](#7.6.2 输出动态html)
- [7.6.3 使chunk id更稳定](#7.6.3 使chunk id更稳定)
- [7.7 bundle体积监控和分析](#7.7 bundle体积监控和分析)
- [7.8 小节](#7.8 小节)
-
- 8.打包优化
-
-
- [8.1 HappyPack](#8.1 HappyPack)
-
- [8.1.1 工作原理](#8.1.1 工作原理)
- [8.2 缩小打包作用域](#8.2 缩小打包作用域)
-
- [8.2.1 exclude和include](#8.2.1 exclude和include)
- [8.2.2 noParse](#8.2.2 noParse)
- [8.2.3 IgnorePlugin](#8.2.3 IgnorePlugin)
- [8.2.4 缓存](#8.2.4 缓存)
- [8.3 动态链接库与DIIPlugin](#8.3 动态链接库与DIIPlugin)
- [8.4 去除死代码](#8.4 去除死代码)
-
- [8.4.1 ES6 Module](#8.4.1 ES6 Module)
- [8.4.2 使用webpack进行依赖关系构建](#8.4.2 使用webpack进行依赖关系构建)
- [8.4.3 使用压缩工具去除死代码](#8.4.3 使用压缩工具去除死代码)
- [8.5 小节](#8.5 小节)
-
- 9.开发环境调度
-
-
- [9.1 Webpack开发效率插件](#9.1 Webpack开发效率插件)
- [9.2 热模块替换](#9.2 热模块替换)
-
- [9.2.1 开启HMR](#9.2.1 开启HMR)
- [9.2.2 HMR原理](#9.2.2 HMR原理)
-
- 10.webpack打包机制
-
-
- [10.1 总览](#10.1 总览)
- [10.2 准备工作](#10.2 准备工作)
- [10.3 缓存加载](#10.3 缓存加载)
- [10.4 模块打包](#10.4 模块打包)
-
- [10.4.1 Complier](#10.4.1 Complier)
- [10.4.2 Compilation](#10.4.2 Compilation)
- [10.4.3 Resolver](#10.4.3 Resolver)
- [10.4.4 Module Factory](#10.4.4 Module Factory)
- [10.4.5 Parser](#10.4.5 Parser)
- [10.4.6 模块渲染](#10.4.6 模块渲染)
- [10.5 webpack插件](#10.5 webpack插件)
-
- [10.5.1 Tapable](#10.5.1 Tapable)
- [10.5.2 插件的协同模式](#10.5.2 插件的协同模式)
-
1.webpack简介
1.1 何为webpack
webpack是一个开源的javascript模块打包工具,其最核心的功能是解决模块之间的依赖,把各个模块按照制定的规则和顺序组织在一起,最终合并成一个js文件(有时会有多个)。这个过程叫模块打包。
1.2 为什么需要webpack
当应用的规模大了之后,就必须借助一定的工具,否则人工维护代码的成本将逐渐变得难以承受。
1.2.1 何为模块
比如,在一个工程中引入一个日期处理的npm包,或者编写一个提供工具方法的JS文件,这些包和文件都可以称为模块。
1.2.2 javascript中的模块
在过去很长一段时间,javascript这门语言并没有模块这一概念。如果工程中有多个JS文件,我们只能通过script标签将它们一个一个的插入页面中。
为什么javascript没有模块呢?追溯历史原因,javascript之父Brendan Eich最初设计这门语言是的定位是一个小型的脚本语言,用来实现网页上的一些简单的动态特性,远没有考虑到如今的复杂场景。
随着技术的发展,我们发现引入多个script文件到页面中有很多缺点:
- 需要手动维护javascript的加载顺序。页面的多个script之间通常会有依赖关系,但由于这种关系是隐式的,除了添加注释以外很难清晰的指明谁依赖了谁,所以当页面中加载的文件过多时很容易出现问题。
- 每一个script标签都意味着需要向服务器请求一次静态资源,在http2还没有出现的时期,建立连接的成本很高,过多的请求会拖慢网页的渲染速度。
- 每个script标签,顶层的作用域即全局作用域,没有任何处理直接在代码中进行变量或函数声明会污染全局作用域。
模块化解决了以上的问题。
- 通过导入和导出语句我们可以清晰地看到模块间的依赖关系。
- 模块可以借助工具来进行打包,所以在页面中只需要加载合并后的资源文件,减少了网络开销。
- 多个模块之间的作用域是隔离的,彼此不会有命名冲突。
ES6模块标准目前已经得到了大多数现代浏览器的支持,但在实际应用方面还需要等待一段时间,主要有以下几点原因:
- 无法使用代码分片(code splitting)和删除死代码(tree shaking)。
- 大多数npm模块还是CommonJS的形式,而浏览器并不支持其语法,因此这些包没有办法直接拿来用。
- 仍然需要考虑个别浏览器及平台的兼容性问题。
1.2.3 模块打包工具
模块打包工具(module bundler)的任务是解决模块间的依赖,使其打包后的结果能运行到浏览器上。
它的主要工作分为2种:
- 将存在依赖关系的模块按照特定规则合并为单个js文件,一次全部加载进页面中。
- 在页面初试时加载一个入口模块,异步加载其他模块。
当前社区中比较流行的模块打包工具有webpack、vite、parcel、rollup等。
1.2.4 为什么选择webpack
对比同类模块打包工具,webpack具备以下几点优势:
- webpack默认支持多种模块标准,包括AMD、CommonJS以及最新的ES6模块,其他工具大多只支持一到两种。
- webpack有完备的代码分片解决方案。字面意思去理解,它可以分割打包后的资源,在首屏只加载必要的部分,将不太重要的功能放到后面动态加载。这对于资源体积较大的应用来说尤为重要,可以有效的减小资源体积,提升首页资源速度。
- webpack可以处理各种类型的资源。除了javascript以外,webpack还可以处理样式、模版,甚至图片。
- webpack拥有庞大的社区支持。除了webpack核心库以外,还有无数的开发者来为它编写周边插件和工具。
1.3 安装
安装模块的方式有两种:一种是全局安装,一种是本地安装。全局安装webpack的好处是npm会帮我们绑定一个命令环境变量,一次安装、处处运行;本地安装webpack则会添加其为项目的依赖,只能在项目的内部使用。
这里建议本地安装:
- 如果选择全局安装,那么在与他人进行项目协作的时候,由于每个人系统中的webpack版本不同,可能导致输出的结果不一致。
- 部分依赖于webpack的插件会调用项目中webpack的内部模块,这种情况下仍然在项目本地安装webpack,而如果全局和本地都有,则容易造成混淆。
1.4 小结
Webpack的功能:它可以处理模块之间的依赖,将它们串联起来合并成单一的JS文件。
配置本地开发环境可以借助 npm scripts
来维护命令行脚本,当打包脚本参数过多时,我们需要将其转化成webpack.config.js,用文件的方式维护复杂的webpack配置。
Webpack-dev-server的作用是启动一个本地服务,可以处理打包资源和静态文件的请求。它的live-reloading功能可以监听文件变化,自动刷新页面来提升开发效率。
2.模块打包
模块之于程序,就如同细胞之于生物,是具有特定功能的组成单元。不同模块负责不同的工作,它们以某种方式联系在一起,共同保证程序的正常运转。
2.1 CommonJS
CommonJS是javascript社区于2009年提出的包含模块、文件、IO、控制台在内的一系列标准。Node.js的实现中采用了CommonJS的标准的一部分,并在其基础上进行了一些调整。我们所说的CommonJS模块和Node.js中的实现并不完全一样,现在一般谈到CommonJS其实是Node.js中的版本,而非它的原始定义。
CommonJS最初是只为服务端设计,直到有了Browserify---一个运行在Node.js环境下的模块打包工具,它可以将CommonJS模块打包为浏览器可以运行的单个文件。
不仅如此,借助Node.js的包管理器,npm开发者还可以获取他人的代码库,或者将自己的代码发布上去供他人使用。
2.1.1 模块
CommonJS中规定每个文件都是一个模块。将一个javascript文件直接通过script标签插入页面中与封装成CommonJS模块的最大不同在于,前者的顶层作用域是全局作用域,在进行变量及函数声明时会污染全局环境;而后者会形成一个属于模块自身的作用域,所有变量及函数只有自己能访问,对外是不可见的。
js
// calculator.js
var name = 'calculator.js';
// index.js
var name = 'index.js';
require('./calculator.js');
console.log(name)
2.1.2 导出
导出是一个模块向外暴露自身的唯一方式。CommonJS中通过module.exports可以导出模块中的内容。
js
// 写法1
module.exports = {
name: 'calculator',
add: function (a, b) {
return a + b;
}
}
// 写法2
exports.name = 'calculator';
exports.add = function (a, b) {
return a + b;
} // 相当于在module.exports对象上添加一个属性
CommonJS模块内部会使用一个module对象存放当前模块的信息。module.exports用来指定该模块要对外暴露哪些内容。
注意⚠️点:
- 不能直接给exports赋值,否则会失效。
- 导出时module.exports与exports不可混用。
- 导出语句不代表模块的末尾。
2.1.3 导入
在CommonJS中使用require语法进行导入。
js
// calculator.js
exports.name = 'calculator';
exports.add = function (a, b) {
return a + b;
}
// index.js
var name = 'index.js';
const calculator = require('./calculator.js');
const sum = calculator.add(2, 3);
console.log(sum);
console.log(name);
使用require导入一个模块的两种情况:
- 该模块未曾被加载过。这时会首先加载该模块,然后获取该模块最终导出内容。
- 该模块已经被加载过。这时该模块的代码不会再次运行,而是直接获取该模块上一次导出的内容。
js
// calcular.js
console.log('running calcular.js')
exports.name = 'calculator';
exports.add = function (a, b) {
return a + b;
}
// index.js
const add = require('./calculator.js').add;
const sum = add(2, 3);
const name = require('./calculator.js').name;
console.log('sum', sum);
console.log('name', name);
running calcular.js
sum 5
name calculator
模块会有一个module对象来存放其信息,这个对象有一个属性loaded用于记录该模块是否被加载过。loaded的值默认为false,在模块第一次被加载和执行后会置为true,后面再次加载会检查到module.loaded=true,则不会再次执行模块代码。
2.2 ES6 Module
2.2.1 模块
js
// calcular.js
export default {
name: 'calcular',
add: function (a, b) {
return a + b;
}
}
// index.js
import calculator from "./calculator.js";
const sum = calculator.add(2, 3);
console.log(sum);
ES6 Module也是将每个文件作为一个模块,每个模块拥有自身的作用域,不同的是导入、导出语句。ES6版本将import和export保留关键字加入了进来。
ES6 Module会自动采用严格模式。也就是说,ES6 Module中不管开头是否有use strict
都会采用严格模式。
2.2.2 导出
在ES6 Module中使用export命令来导出模块。export有两种形式:
- 命名导出
js
// 写法1
export const name = 'calculator';
export const add = function (a, b) {
return a + b;
}
// 写法2
const name = 'calculator';
const add = function (a, b) {
return a + b;
}
export { name, add };
使用命名导出时,可以通过as关键字对变量重新命名。
js
const name = 'calculator';
const add = function (a, b) {
return a + b;
}
export { name, add as getSum };
- 默认导出
js
export default {
name: 'calcular',
add: function (a, b) {
return a + b;
}
}
export default理解为对外输出了一个名为default的变量。
js
// 导出字符串
export default 'this is calculator.js';
// 导出class
export default class {... };
// 导出匿名函数
export default function(){...}
2.2.3 导入
- 命名导入
js
// calculator.js
const name = 'calculator';
const add = function (a, b) {
return a + b;
}
export { name, add };
// index.js
import { add, name } from "./calculator.js";
const sum = add(2, 3);
console.log(sum)
console.log(name);
加载带有命名导出的模块时,import后面跟一对大括号来将导入的变量名包裹起来,并且这些变量名应该与导出的变量名完全一致。导入变量的效果相当于在当前作用域下声明了这些变量,并且不可对其进行更改,也就是所有导入的变量都是可读的。
- 默认导入
js
import myCalculator from './calculator.js'; // myCalculator自由指定
const sum = myCalculator.add(2, 3)
console.log(sum);
2.2.4 复合写法
import React, { Component } from 'react';
这里的React对应的是该模块的默认导出,而Component则是命名导出的一个变量。
2.3 CommonJS与ES6 Module的区别
2.3.1 动态与静态
CommonJS与ES6 Module最本质的区别在于前者对模块依赖的解决是"动态的",而后者是"静态的"。
这里的"动态"含义是,模块依赖关系的建立发生在代码运行阶段;而"静态"则表示模块依赖关系的建立发生在代码编译阶段。
CommonJS:require的模块路径可以动态指定,支持传入一个表达式,甚至可以通过if语句判断是否加载某个模块。因此,在CommonJS模块被执行前,我们没有办法确定明确的依赖关系,模块的导入、导出发生在代码的运行阶段。
ES6 Module:导入、导出语句都是声明式的,它不支持将表达式作为导入路径,并且导入、导出语句必须位于模块的顶层作用域。因此我们说,ES6 Module是一种静态的模块结构,在ES6代码的编译阶段就可以分析出模块的依赖关系。
它相对于CommonJS来说具备以下几点优点:
- 死代码检测和排除。可以使用静态分析工具检测哪些模块没有被调用过。比如,在引入工具类库时,工程往往只用到了一部分组件或接口,但可能会将代码完整的加载进来。未调用到的模块代码永远不会被执行,也就成了死代码。通过静态分析可以在打包时去掉这些未曾使用的模块,以减小打包体积。
- 模块变量类型检查。javascript属于动态类型语言,不会在代码执行前检查类型错误。ES6 Module的静态模块结构有助于确保模块之间传递的值或接口类型是正确的。
- 编译器优化。在CommonJS等动态模块系统中,无论采用哪种方式,本质上导入的都是一个对象,而ES6 Module支持直接导入变量,减少了引用层级,程序效率更高。
2.3.2 值复制与动态映射
在导入一个模块时,对于CommonJS来说获取的是一份导出值的副本;而ES6 Module中则是值的动态映射,这个映射是可读的。
js
// calculator.js
var count = 0;
module.exports = {
count: count,
add: function (a, b) {
count += 1;
return a + b;
}
}
// index.js
var count=require('./calculator.js').count;
var add=require('./calculator.js').add;
console.log(count); // 0 这里的count是calculator.js中count的副本
add(2,3)
console.log(count); // 0 calculator.js中变量值的改变不会对这里的副本造成影响
count+=1;
console.log(count); // 1 副本的值可以更改
js
// calculator.js
let count = 0;
const add = function (a, b) {
count += 1;
return a + b;
};
export { count, add };
// index.js
import { count, add } from './calculator.js';
console.log(count); // 0 对calculator.js中count的映射
add(2, 3);
console.log(count); // 1 实时反映calculator.js中count值的变化
count += 1 // TypeError: Assignment to constant variable.
console.log(count)
2.3.3 循环依赖
循环依赖是指模块A依赖于模块B,同时模块B依赖于模块A。
js
// a.js
import { foo } from './b.js';
foo();
// b.js
import { bar } from './a.js';
bar();
实际开发中循环依赖有时会在我们不经意间产生,因为当工程的复杂度上升到足够大时,就容易出现隐藏的循环依赖关系。
js
// foo.js
const bar = require('./bar.js')
console.log('value of bar:', bar);
module.exports = 'this is foo.js';
// bar.js
const foo = require('./foo.js');
console.log('value of foo:', foo);
module.exports = 'this is bar.js';
// index.js
require('./foo.js')
value of foo: {}
value of bar: this is bar.js
2.4 加载其他类型的模块
除了主要的CommonJS和ES6 Module,除此之外,在开发中,还有其他的类型的模块,目前AMD、UMD等模块使用的场景已经不多了。
2.4.1 非模块化文件
非模块化文件指的是并不遵循任何一种模块标准的文件。如:script标签中引入的jQuery及其各种插件。
2.4.2 AMD
AMD(Asynchronous Module Definition,异步模块定义)是由javascript社区提出的专注于支持浏览器端模块化的标准。从名字可以看出它与CommonJS和ES6 Module最大的区别在于它加载模块的方式是异步的。
js
define('getSum', ['calculator'], function (math) {
return function (a, b) {
console.log('sum:' + calculator.add(a, b));
}
})
require(['getSum'], function (getSum) {
getSum(a, b);
})
在AMD中使用define函数定义模块,它可以接收3个参数。第1个参数是当前模块的id,相当于模块名;第2个参数是当前模块的依赖;第3个参数用来描述模块的导出值,可以是函数或对象。和CommonJS类似,AMD使用require函数来加载模块,只不过采用异步的形式。require的第1个参数指定了加载的模块,第2个参数是当前加载完成后执行的回调函数。
通过AMD这种形式定义模块的好处在于其模块加载是非阻塞性的,当执行到require函数时并不会停下来去执行被加载的模块,而是继续执行require后面的代码,使得模块加载操作并不会阻塞浏览器。
2.4.3 UMD
UMD并不是一种模块标准,而是一组模块形式的集合。它的目标是使一个模块能运行在各种环境下,不论是CommonJS、AMD,还是非模块化的环境。
2.4.4 加载npm模块
javascript是一种缺乏标准库的语言。而npm提供了这样一种方式,可以让开发者在其平台上找到由他人所开发和发布的库并安装到项目中,从而快速解决问题,这就是npm作为包管理器为开发者带来的便利。
javascript最主流的包管理器有两个------npm和yarn。两者的仓库是共通的,只是在使用上有所区别。
2.5 模块打包原理
面对工程中成百上千个模块,Webpack究竟是如何将它们有序地组织在一起,并按照我们预想的顺序运行在浏览器上的呢?
js
// index.js
const calculator = require('./calculator.js');
const sum = calculator.add(2, 3);
console.log('sum', sum)
// calculator.js
module.exports = {
add: function (a, b) {
return a + b;
}
}
上面的代码经webpack打包后会成为以下形式
js
// 立即执行匿名函数
(function (modules) {
// 模块缓存
var installedModules = {};
// 实现require
function __webpack_require__(moduleId) {
...
}
// 执行入口模块的加载
return __webpack_require__(__webpack_require__.s = 0);
}) ({
// modules:以key-value的形式存储所有被打包的模块
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内容
}
})
上面的bundle分为以下几个部分:
- 最外层匿名函数。它用来包裹整个bundle,并构成自身的作用域。
- installedModules对象。每个模块只在第一次被加载的时候执行,之后其导出值就被存储到这个对象里面,当再次被加载的时候webpack会直接从这里取值,而不会重新执行该模块。
__webpack_require__
函数。对模块加载的实现,在浏览器中可以通过调用__webpack_require__
(module_id)来完成模块导入。- modules对象。工程中所有产生依赖关系的模块都会以key-value的形式放在这里。
接下来让我们看看bundle是如何在浏览器中执行的。
- 在最外层匿名函数中初始化浏览器执行环境,包括定义installedModules对象、
__webpack_require__
函数等,为模块的加载和执行做一些准备工作。 - 加载入口模块。每个bundle都有且只有一个入口模块。上面的示例中,index.js是入口模块,在浏览器中会从它开始执行。
- 执行模块代码。如果执行到了module.exports则记录下模块的导出值;如果中间遇到require函数,则会暂时交出执行权,进入
__webpack_require__
函数体内进行加载其他模块的逻辑。 - 在
__webpack_require__
中判断即将加载的模块是否存在于installedModules中,如果存在直接取值,否则回到第3步,执行该模块的代码来获取导出值。 - 所以依赖的模块都执行完毕,最好执行权又回到入口模块。当入口模块的代码执行完毕,也就意味着整个bundle运行结束。
第3步和第4步是一个递归的过程。webpack为每个模块创建了一个可以导出和导入模块的环境,但本质上并没有修改代码的执行逻辑,因此代码执行的顺序与模块加载的顺序是完全一致的,这也是webpack模块打包的奥秘。
2.6 小节
CommonJS和ES6 Module是目前使用较为广泛的模块标准。它们的主要区别在于前者是在运行时建立模块依赖关系,后者是在编译时建立;在模块导入方面,CommonJS导入的是值副本,ES6 Module导入的是只读的变量映射;ES6 Module通过静态特征可以进行编译过程中的优化,并且具备处理循环依赖的能力。
3.资源的输入和输出
3.1 资源处理流程
webpack的资源处理流程:一切流程的最开始,我们需要指定一个或多个入口(entry),也就是告诉webpack具体从源码目录下的哪个文件开始打包。这些依赖关系的模块会在打包时被封装成一个chunk。(chunk的字面意思是代码块,在webpack中可以理解成被抽象和包装后的一些模块。)
根据配置不同,一个工程打包时可能产生一个或多个chunk。
webpack从入口文件开始检索,并将具有依赖关系的模块生成一颗依赖树,最终得到一个chunk。我们一般将由这个chunk得到的打包产物称为bundle。
3.2 配置资源入口
webpack通过context和entry这两个配置项来共同决定入口文件的路径。在配置入口时,我们实际做了两件事:
- 确定入口模块位置。告诉webpack从哪里开始进行打包。
- 定义chunk name。如果工程只有一个入口,那么默认chunk name为main;如果有多个入口,为每个入口定义chunk name。
3.2.1 context
资源入口的路径前缀,配置时要求必须使用绝对路径的形式。
js
module.exports = {
context: path.join(__dirname, './src'),
entry: './script/index.js',
};
module.exports = {
context: path.join(__dirname, './src/script'),
entry: './index.js',
};
配置context的目的是让entry的编写更加简洁,尤其是在多入口的情况下。
3.2.2 entry
与context只能是字符串不同。entry的配置可以有多种形式:字符串、数组、对象、函数。
3.2.3 实例
- 单页应用(SPA)
js
module.exports = {
entry: './src/app.js'
}
在webpack默认配置中,当一个bundle大于250KB时(压缩前),webpack会发出警告。
- 提取vendor
假如工程只生成一个JS文件并且它的体积很大,一旦代码产生更新,即便只有一点点改动,用户都要重新下载整个资源文件,这对于页面的性能是非常不友好的。
为解决这个问题,我们可以使用提取vendor的方法。vendor的字面意思是"供应商",在webpack中则一般指工程所使用的库、框架等第三方模块集成打包而产生的bundle。
js
module.exports = {
context: path.join(__dirname, './src'),
entry: {
app: './src/app.js',
vendor: ['react', 'react-dom', 'react-router']
}
}
通过这样的配置,app.js 产生的bundle将只包含业务模块,其依赖的第三方模块将会被抽取出来生成一个新的bundle,从而达到我们提取vendor的目的。由于vendor仅仅包含第三方模块,这部分不会经常变动,因此可以有效地利用客户端缓存,在用户后续请求页面时加快整体的渲染速度。
- 多页应用
js
module.exports = {
entry: {
pageA: './src/pageA.js',
pageB: './src/pageB.js',
pageC: './src/pageC.js',
}
}
module.exports = {
entry: {
pageA: './src/pageA.js',
pageB: './src/pageB.js',
pageC: './src/pageC.js',
vendor: ['react', 'react-dom']
}
}
3.3 配置资源出口
js
const path = require('path');
module.exports = {
entry: './src/app.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'assets'),
publicPath: '/dist/'
}
}
output对象里可以包含数十个配置项。
3.3.1 filename
filname的作用是控制输出资源的文件名,其形式为字符串。filename可以不仅仅是bundle的名字,还可以是一个相对路径,即使路径中的目录不存在也没关系,因为webpack会在输出资源时创建该目录。
3.3.2 path
path可以指定资源输出的位置,要求值必须为绝对路径。
3.3.3 publicPath
从功能上来说,path用来指定资源的输出位置,publicPath则用来指定资源的请求位置。
- 输出位置:打包完成后资源产生的目录,一般将其指定为工程中的dist目录;
- 请求位置:由JS或CSS所请求的间接资源路径。页面中的资源分为两种,一种是由html页面直接请求的;另一种是由JS或CSS来发送请求的间接资源。publicPath的作用就是指定这部分间接资源的请求位置。
3.3.4 实例
- 单入口
对于单入口的场景来说,通常不必设置动态的output.filename,直接指定输出的文件名即可。
js
const path = require('path');
module.exports = {
entry: './src/app.js',
output: {
filename: 'bundle.js',
},
devServer: {
publicPath: '/dist/'
}
}
- 多入口
js
const path = require('path');
module.exports = {
entry: {
pageA: './src/pageA.js',
pageB: './src/pageB.js',
pageC: './src/pageC.js',
},
output: {
filename: '[name].js',
},
devServer: {
publicPath: '/dist/'
}
}
通过output.filename中的[name]变量替代chunk name,使最终生成的资源为pageA.js和pageB.js。如果是生产环境下的配置,我们还可以把[name].js改为[name]@[chunkhash].js。
3.4 小节
在配置打包入口时,context相当于路径前缀,entry是入口文件路径。若为单入口,则chunk name不可更改,若为多入口,则必须为每一个chunk指定chunk name。
当第三方依赖较多时,我们可以用提取vendor的方法将这些模块打包到一个单独的bundle中,以更有效地利用客户端缓存,加快页面渲染速度。
path和publicPath的区别在于path指定的是资源的输出位置,而publicPath指定的是间接资源的请求位置。
4.预处理器
4.1 一切皆模块
模块是具有高内聚性及可复用性的结构,通过webpack"一切皆模块"的思想,我们可以将模块的这些特性应用到每一种静态资源上面,从而设计和实现出更加健壮的系统。
4.2 loader概述
loader是webpack中的一个核心概念,我们可以将其理解为一个代码转换的工具。每个loader本质上都是一个函数,可以表示为以下的形式:
output = loader(input)
这里的input可能是工程源文件的字符串,也可能是一个loader转化后的结果,output则包括了转化后的代码、source-map和AST对象。
loader可以是链式的。如工程中编译SCSS时,我们可能需要如下loader:
Style标签=style-loader(css-loader(sass-loader(SCSS)))
为了阐释loader是如何工作的,下面来看一下loader的源码结构:
js
module.exports = function loader(content, map, meta) {
var callback = this.async();
var result = handler(content, map, meta);
callback(
null,
result.content,
result.map,
result.meta
)
}
可以看出loader本身就是一个函数,该函数会对接收到的内容进行转换,然后返回转换后的结果。
4.3 loader配置
webpack本身只认识javascript,对于其他类型的资源,我们必须预先定义一个或多个loader对其进行转译,输出为webpack能够接收的形式再继续进行,因此loader做的实际是一个预处理的工作。
4.3.1 loader的引入
js
module.exports = {
// ...
module: {
rules: [{
test: /\.css$/,
use: ['css-loader'],
}],
},
};
与loader相关的配置都在module对象中,其中module.rules代表了模块的处理规则。每条规则内部可以包含很多配置,这里我们只使用了最重要的两项------test和use。
- test可接收一个正则表达式或一个元素为正则表达式的数组,只有正则匹配上的模块才会使用这条规则。
- use可接收一个数组,数组包含该规则所使用的loader。
4.3.2 链式loader
很多时候,在处理某一类资源时我们都需要使用多个loader。如,对于SCSS类型的资源来说,我们需要sass-loader来处理其语法,将其编译成CSS;接着再用css-loader处理CSS的各类加载语法;最后使用style-loader来将样式字符串包装成style标签插入页面。
js
module.exports = {
// ...
module: {
rules: [{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
}],
}
};
Style-loader加到了css-loader前面,这是因为Webpack在打包时是按照数组从后往前的顺序将资源交给loader处理的,因此要把最后生效的放在前面。
4.3.3 更多配置
- exclude和include。用于排除或包含指定目录下的模块,可接收正则表达式或者字符串,或者由它们组成的数组。
- resource和issuer。用于更加精准地确定模块规则的作用范围。
- enforce。用来指定一个loader种类,只接收pre或post两种字符串类型的值。enforce的值为pre,表示它将在所以正常loader之前执行,保证其检测的代码不是被其他loader更改过的;类似的,如果其一个loader需要在所有loader之后执行,我们可以指定enforce为post。
4.4 常用loader介绍
4.4.1 babel-loader
Babel-loader用于处理ES6+并将其编译成ES5,它使我们能够在工程中使用最新的语言特性,同时不必特别关注这些特性在不同平台的兼容问题。
npm install babel-loader @babel/core @babel/preset-env -D
- Babel-loader:它是Babel与webpack协同工作的模块。
- @babel/core:它是Babel编译器的核心模块。
- @babel/preset-env:它是Babel官方推荐的预置器,可以根据用户设置的目标环境自动添加所需的插件和补丁来编译ES6+代码。
js
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
presets: [[
'env', {
module: false
}
]]
}
}
}
],
}
- 由于该规则中的babel-loader会对所有的js后缀文件生效,所以我们需要特别排除掉node_modules目录,否则会令babel-loader编译其中所有的模块,严重拖累打包的速度,甚至改变第三方模块的原有行为;
- 对于babel-loader本身我们添加了cacheDirectory配置项,它会启用缓存机制,在重复打包未改变过的模块时防止二次编译,加快打包的速度。cacheDirectory可以接收一个字符串类型的路径来作为缓存路径,这个值也可以为true,此时其缓存目录会指向node_modules/.cache/babel-loader。
- 由于@babel/preset-env会将ES6 Module转化为CommonJS的形式,这会导致webpack中的tree-shaking特性失效。将@babel/preset-env的modules配置项设置为false会禁用模块语句的转化,而将ES6 Module的语法交给webpack本身处理。
4.4.2 ts-loader
ts-loader与babel-loader的性质类似,它是用于连接webpack与typescript的模块。
4.4.3 html-loader
html-loader用于将html文件转化为字符串并进行格式化,使得我们可以把一个html片段通过JS加载进来。
4.4.4 handlebars-loader
handlebars-loader用于处理handlebars模块,在安装时要额外安装handlebars。handlebars文件加载后得到的是一个函数,可以接收一个变量对象并返回最终的字符串。
4.4.5 file-loader
file-loader用于打包文件类型的资源,并返回其publicPath。
4.4.6 url-loader
url-loader的作用与file-loader类似,唯一的不同在于,url-loader允许用户设置一个文件大小的值,当大于该值时它会与file-loader一样返回publicPath,而小于该值时则返回base64形式的编码。
4.5 自定义loader
- loader初始化
我们将实现一个loader,它会为所有JS文件启用严格模式(use strict
)。
- 启用缓存
当文件输入和其依赖没有发生变化时,应该让loader直接使用缓存,而不是重复进行转换的工作。在we bpack中使用this.cacheable进行控制,修改我们的loader。
- 获取options
loader配置项通过use.options传进来。
- source- map
开启source- map可以便于我们在浏览器的开发者工具中查看源代码。
4.6 小节
loader就像webpack的翻译官。webpack本身只能接收javascript,为了使其能够处理其他类型的资源,必须使用loader将资源转译为webpack能够理解的形式。
在配置loader时,实际上定义的是模块规则(module.rules),它主要关注两件事:该规则对哪些模块生效(test、exclude、include配置),使用哪些loader(use配置)。loader可以是链式的,并且每一个loader都允许拥有自己的配置项。
loader本质是一个函数。第一个loader的输入是源文件,之后所有loader的输入是上一个loader的输出,最后一个loader则直接输出到webpack。
5.样式处理
除了javascript之外,在打包方面另一个重要的工作就是样式处理。
5.1 分离样式文件
上一章提到style-loader
与css-loader
,通过JS引用CSS文件的方式打包样式,可以清晰的描述模块间的依赖关系。
处理工程中的单纯CSS文件。webpack社区有专门的插件:extract-text-webpack-plugin (适用于webpack4之前的版本)和mini-css-extract-plugin(适用于webpack4及以上版本),它们都是专门用于提取样式到CSS文件的。
5.1.1 extract-text-webpack-plugin
js
module.exports = {
entry: './src/index.js',
output: { filename: 'main.js' },
mode: 'development',
devServer: {
publicPath: '/dist/',
},
module: {
rules: [{
test: /\.css$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: 'css-loader'
}),
},
],
},
plugins: [
new ExtractTextPlugin('bundle.css')
]
};
在module.rules中设置处理CSS文件的规则,其中的use字段并没有直接传入loader,而是使用插件的extract方法包了一层。内部的fallback 属性用于指定当前插件无法提取样式时所采用的loader,use用于指定在提取样式之前采用哪些loader来预先处理。
plugins用于接收一个插件数组,可以使用webpack内部提供的一些插件,也可以加载外部插件。
5.1.2 多样式文件的处理
js
// foo.js
import './foo-style.css';
document.writeln('foo.js')
// bar.js
import './bar-style.css';
document.writeln('bar.js')
// foo-style.css
body {
background-color: #eee;
}
// bar-style.css
body{
color: #09c;
}
假如我们有foo.js和bar.js,并且它们分别引用了foo-style.css与bar-style.css,现在通过配置使它们输出各自的CSS文件。
js
module.exports = {
entry: (
foo: './src/foo.js',
bar: './src/bar.js'
),
output: (
filename: '[name].js',
),
mode: 'development',
module: {
rules: [
{
test: /\.css$/,
use: ExtractTextPlugin({
fallback: 'style-loader',
use: 'css-loader'
})
}
]
},
plugins: [
new ExtractTextPlugin('[name].css')
]
}
使用[name].css来动态生成CSS文件名,这里的[name]和在output.filename中的意义一样,都是指代chunk的名字。
5.1.3 mini-css-extract-plugin
extract-text-webpack-plugin的升级版,它拥有更丰富的特性和更好的性能。
mini-css-extract-plugin的特性,最重要的是它支持按需加载CSS文件.
5.2 样式预处理
样式预处理指开发过程中经常会使用一些样式预编译语言,如SCSS、Less等,在项目打包过程中再将这些预编译语言转换成CSS。借助这些语言强大和便捷的特性,可以降低项目的开发和维护成本。
5.2.1 Sass与SCSS
Sass-loader就是将SCSS语法编译为CSS,因此在使用时通常还要搭配css-loader和style-loader。类似于我们安装babel-loader时还要安装babel-core,loader本身只是编译核心库于webpack的连接器,因此这里除了sass-loader以外还要安装sass,sass是真正用来编译SCSS的,而sass-loader只是起到黏合的作用。
如果我们想在浏览器的调试工具里查看源码,需要分别为sass-loader和css-loader单独添加source-map的配置项。
js
module: {
rules: [
{
test: /\.scss$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
sourceMap: true
}
}, {
loader: 'sass-loader',
options: {
sourceMap: true
}
}
]
}
]
}
5.3 PostCSS
PostCSS并不能算是一个CSS的预编译器,它只是一个编译插件的容器。它的工作模式是接收样式源代码并交由编译插件处理,最后输出CSS文件。
5.3.1 PostCSS与Webpack
使用posts-loader可以轻松地将PostCSS与Webpack连接起来。posts-loader可以与css-loader结合使用,也可以单独使用,也就是说不配置css-loader也可以达到相同的效果。唯一不同的是,单独使用posts-loader时不建议使用css中的@import语句,否则会产生冗余代码。
除此之外,posts-loader要求必须有一个单独的配置文件。
5.3.2 自动前缀
PostCSS的一个最广泛的应用场景就是与Autoprefixer结合,为CSS自动添加厂商前缀。
5.3.3 stylelint
stylelint是一个CSS的质量检测工具,就像eslint一样,我们可以为其添加各种规则,来统一项目的代码风格,确保代码质量。
5.3.4 CSSNext
PostCSS可以与CSSNext结合使用,让我们在应用中使用最新的CSS语法特性。
5.4 CSS Modules
CSS Modules是近年来比较流行的一种开发模式,其理念就是把CSS模块化,让CSS也拥有模块的特点。
- 每个CSS文件中的样式都拥有单独的作用域,不会和外界发生命名冲突。
- 对CSS进行依赖管理,可以通过相对路径引入CSS文件。
- 可以通过composes轻松复用其他CSS模块。
5.5 小节
通过SCSS、Less等预编译样式语言来提升开发效率,降低代码复杂度。PostCSS包含的很多功能强大的插件,可以让我们使用更新的CSS特性,获得更好的浏览器兼容性。CSS Modules可以让CSS模块化,避免样式冲突。
6.代码分片
实现高性能应用的重要一点是尽可能的让用户每次只加载必要的资源,对于优先级不太高的资源则采用延迟加载等技术获取,这样可以保证页面的首屏速度。代码分片是webpack作为打包工具所特有的一项技术,通过这项技术,我们可以把代码按照特定的形式进行拆分,使用户不必一次加载全部代码,而是按需加载。
6.1 通过入口划分代码
通常web应用中会有一些库和工具是不常变动的,我们可以把它们放在一个单独的入口中,由该入口产生的资源不会经常更新,从而有效地利用客户端缓存,让用户不必在每次请求页面时都重新加载。
js
module.exports = {
entry: {
app: './app.js',
lib: ['lib-a', 'lib-b', 'lib-c']
}
}
这种拆分方法主要适合那些将接口绑定在全局对象上的库,因为业务代码中的模块无法直接引用库中的模块,二者属于不同的依赖。
6.2 CommonsChunkPlugin
CommonsChunkPlugin是webpack 4之前内部自带的插件。它可以将多个Chunk中公共的部分提取出来,公共模块的提取可以为项目带来如下收益:
- 开发过程中减少了重复模块的打包,可以提升开发速度。
- 减小整体资源体积。
- 合理分片后的代码可以更有效地利用客户端缓存。
6.2.1 提取vendor
js
module.exports = {
entry: {
app: './app.js',
vendor: ['react']
},
output: {
filename: '[name].js',
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
filename: 'vendor.js'
})
]
}
6.2.2 设置提取范围
通过CommonsChunkPlugin中的chunks配置项可以规定从哪些入口提取公共模块。
js
module.exports = {
entry: {
a: './a.js',
b: './b.js',
c: './c.js'
},
output: {
filename: '[name].js',
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'commons',
filename: 'commons.js',
chunks: ['a', 'b']
})
]
}
6.2.3 配置提取规则
CommonsChunkPlugin的默认规则是只要一个模块被两个入口chunk所使用就会被提取出来。
然而现实情况是,有些时候我们不希望所有的公共模块都被提取出来。可以通过CommonsChunkPlugin的minChunks配置项来设置提取的规则。该配置项非常灵活,支持多种输入形式。
- 数字
minChunks可以接收一个数字,当设置minChunks为n时,只有该模块被n个入口同时引用才会进行提取。
- Infinity
Infinity代表提取规则的值无限高,也就是说所有模块都不会被提取。
该配置项的作用有两个。一是和上面的情况类似,即我们只想让webpack提取特定模块,并将这些模块通过数组形式入口传入,这种好处是完全可控;另一个作用是指定minChunks为Infinity,这样可以生成一个不包含任何模块而仅负责初始化webpack环境的javascript文件,这个文件我们通常称为mainfest。
- 函数
minChunks支持传入一个函数,它可以让我们更细粒度地控制公共模块。Webpack打包过程中的每个模块都会经过这个函数的处理,当函数的返回值是true时进行提取。
6.2.4 hash与长效缓存
当我们使用该插件提取公共模块时,提取后的资源内部不仅仅是模块的代码,往往还包含webpack的运行时。webpack的运行时指的是初始化环境的代码,如创建模块缓存对象、声明模块加载函数等。
一般我们会使用chunkhash作为资源的版本号来优化客户端的缓存,版本号改变会导致用户频繁地更新资源,即便它们的内容并没有发生变化也会更新。
6.2.5 CommonsChunkPlugin的不足
- 一个CommonsChunkPlugin只能提取一个vendor,假如我们想提取多个vendor则需要配置多个插件,这会增加很多重复的配置代码。
- 前面我们提到的manifest实际上会使浏览器多加载一个资源,这对于页面渲染速度是很不友好的。
- 由于内部设计上的一些缺陷,CommonsChunkPlugin在提取公共模块的时候会破坏掉原有Chunk中模块的依赖,导致难以进行更多的优化。
6.3 optimization.SplitChunks
optimization.SplitChunks是webpack 4为了改进CommonsChunkPlugin而重新设计和实现的代码分片特性。
- optimization.SplitChunks替代了CommonsChunkPlugin,并指定了chunks的值为all,表示SplitChunks将会对所有的chunks生效。
- mode是webpack 4中新增的配置项,可以针对当前所处环境自动添加对应的一些webpack配置。
6.4 资源异步加载
资源异步加载主要解决的问题是,当模块数量过多、资源体积过大时,可以延迟加载一些暂时使用不到的模块。这样可以使用户在页面初次渲染的时候下载尽可能小的资源,等到恰当的时机再去触发加载后续的模块。因此一般也把这种方法叫作按需加载。
6.4.1 import()
webpack中有两种异步加载的方式------import函数和require.ensure。
与正常ES 6中import语法不同,通过import函数加载的模块及其依赖会被异步加载,并返回一个Promise对象。
import函数还有一个比较重要的特性。ES6 Module中要求import必须出现在代码的顶层作用域,而webpack的import函数则可以在任何我们希望的时候调用。
6.4.2 异步chunk的配置
在webpack配置中添加output.chunkFilename,用来指定异步chunk的文件名。其命名规则与output.filename基本一致,不过异步chunk默认没有名字,其默认值是[id].js。
6.5 小节
webpack代码分片的几种方式:合理规划入口、使用CommonsChunkPlugin或SplitChunks,以及资源异步加载。借助这些方式我们可以有效地缩小资源体积,更好地利用缓存,给用户提供更好的体验。
7.生产环境配置
生产环境中,资源打包将会遇到许多新的问题。在生产环境中我们关注的是如何让用户更快地加载资源、涉及如何压缩资源、如何添加环境变量优化打包、如何最大限度地利用缓存等。
7.1 环境配置的封装
生产环境与开发环境不同,比如要设置模式、环境变量,为文件添加chunkhash作为版本号。
- 使用相同的配置文件。
webpack不管在什么环境下打包都使用webpack.config.js,只是在构建开始前将当前所属环境作为一个变量传进去,然后在webpack.config.js中通过各种判断条件来决定具体使用哪个配置。
js
// package.json
{
...
"scripts": {
"dev": "webpack-dev-server",
"build": "ENV=production webpack"
},
...
}
// webpack.config.js
const ENV = process.env.ENV
const isProd = ENV === 'production';
module.exports = {
output: {
filename: isProd ? 'bundle@[chunkhash].js' : 'bundle.js',
},
mode: ENV
}
- 为不同环境创建各自的配置文件。
比如,我们可以单独创建一个生产环境webpack.production.config.js,开发环境则可以叫webpack.development.config.js。
这种方法存在一个问题,即两个文件肯定会有重复的部分。当需要修改这部分内容时,必须同时对两个文件进行修改,增加了维护的成本。这种情况下,可以将公共的配置提取出来,比如我们单独创建一个webpack.common.config.js。
7.2 开启production模式
Webpack 4中增加了一个mode配置项,让开发者可以通过它来直接切换打包模式。
js
module.exports = {
mode: 'production'
}
意味着当前处于生产环境模式,webpack会自动添加许多适用于生产环境的配置项,减少了人为手动的工作。
7.3 环境变量
通常我们需要为生产环境和本地环境添加不同的环境变量,在webpack中可以使用DefinePlugin进行配置。
js
module.exports = {
entry: './app.js',
output: {
filename: 'bundle.js'
},
mode: 'production',
plugins: [
new webpack.DefinePlugin({
ENV: JSON.stringify('production') // json.stringify如不添加,字符串将会以变量的形式出现。
})
]
}
通过DefinePlugin设置ENV环境变量,最终页面上输出的将会是字符串production。
许多框架和库都采用process.env.NODE_ENV作为一个区别开发和生产环境的变量。process.env是Node.js用于存放当前进程环境变量的对象;而NODE_ENV则可以让开发者指定当前的运行时环境,当它的值为production时即代表当前为生产环境,库和框架在打包时发现当前环境为生产环境后就可以去掉一些开发环境的代码。
7.4 source-map
source-map指的是将编译、打包、压缩后的代码映射回源代码的过程。经过webpack打包压缩后的代码基本上已经不具备可读性,此时若代码抛出一个错误,要想回溯它的调用栈是非常困难的。有了source-map,再加上浏览器调试工具,要做到这一点非常容易。
7.4.1 source-map原理
webpack对于工程源代码的每一步处理都有可能会改变代码的位置、结构,甚至所处文件,因此每一步都需要生成对应的source-map。若我们启用devtool配置项,source-map就会跟随源代码一步步被递归,直到生成最后的map文件。这个文件的名称默认就是打包后的文件名加上.map,如bundle.js.map。
map文件有时会很大,但不用担心,只要不打开开发者工具,浏览器是不会加载这些文件的,因此对于普通用户来说并没有影响。但是使用source-map会有一定的安全隐患,即任何人都可以通过开发者工具看到工程源代码。
7.4.2 source-map配置
js
module.exports = {
// ...
devtool: 'source-map'
}
webpack支持多种source-map的形式。除了配置为devtool:'source-map'以外,还可以根据不同的需求选择cheap-source-map、eval-source-map等。通常它们都是source-map的一些简略版本,因为生成完整的source-map会延长整体的构建时间,如果对打包的速度要求比较高,建议选择一个简化版的source-map。
7.4.3 source-map安全
webpack提供hidden-source-map及no source-source-map两种策略来提升source-map的安全性。
7.5 资源压缩
在将资源发布到线上环境前,我们通常都会进行代码压缩,或者叫uglify,意思是移除多余的空格、换行及执行不到的代码,缩短变量名,在执行结果不变的前提下将代码替换为更短的形式。一般正常的代码在uglify之后整体体积都会显著缩小。同时,uglify之后的代码基本上不可读,在一定程度上提升了代码的安全性。
7.5.1 javascript压缩
常见的压缩javascript文件的工具有两个,一个是UglifyJS,另一个是terser。后者支持ES6+代码的压缩,更面向于未来。
7.5.2 压缩CSS
压缩CSS文件的前提是使用extract-text-webpack-plugin或mini-css-extract-plugin将样式提取出来,接着使用optimize-css-assets-webpack-plugin来进行压缩,这个插件本质上使用的是压缩器cssnano。
7.6 缓存
缓存指重复利用浏览器已经获取过的资源。合理地使用缓存是提升客户端性能的一个关键因素。具体的缓存策略由服务器来决定,浏览器会在资源过期前一直使用本地缓存进行响应。
假如开发者想要对代码进行bug修复,并希望立即更新更新到所有用户浏览器上,而不让浏览器用户使用旧的缓存资源,应该怎么做?此时最好的方法是更改资源的URL,这样可迫使所有客户端都去下载最新的资源。
7.6.1 资源hash
一个常用的方法是在每次打包的过程中对资源的内容计算一次hash,并作为版本号存放在文件名中,如[email protected]。bundle是文件本身的名字,@后面跟的是文件内容的hash值,每当代码发生变化时相应的hash也会变化。
7.6.2 输出动态html
资源名的改变也意味着html中引用路径的改变。每次改动后都要手动地去维护它是很困难的,理想的情况是在打包结束后自动把最新的资源名同步过去。
7.6.3 使chunk id更稳定
7.7 bundle体积监控和分析
为保证良好的用户体验,我们可以对打包输出的bundle体积进行持续监控,以防止不必要的冗余模块被添加进来。
VS Code中有一个插件Import Cost可以帮助我们对引入模块的大小进行实时监测。另一个很有用的工具是webpack-bundle-analyzer,它能够帮助我们分析一个bundle的构成。
7.8 小节
在开发环境中我们可能关注的是打包速度,在生产环境中我们关注的则是输出的资源体积以及如何优化客户端缓存来缩短页面渲染时间。介绍了设置生产变量、压缩代码、监控资源体积等方法。缓存的控制主要依赖于从chunk内容生成hash作为版本号,并添加到资源文件名中,使资源更新后可以立即被客户端获取到。
source-map对于追溯线上问题十分重要,但也存在安全隐患。
8.打包优化
目的是让打包速度更快,输出的资源更小。
软件工程领域的经验------不要过早优化,在项目的初期不要看到任何优点就拿来加到项目中,这样不但会增加复杂度,优化的效果也不会太理想。一般是当项目发展到一定规模后,性能问题随之而来,这时再去分析然后对症下药,才有可能达到理想的优化效果。
8.1 HappyPack
HappyPack是一个通过多线程来提升webpack打包速度的工具。
8.1.1 工作原理
打包过程中有一项非常耗时的工作,就是使用loader对各种资源进行转译处理。最常见的包括使用babel-loader转译ES6+语法和使用ts-loader转译typescript。
简单地将代码转译的工作流程概括如下:
- 从配置中获取打包入口。
- 匹配loader规则,并对入口模块进行转译。
- 对转译后的模块进行依赖查找。
- 对新找到的模块重复进行步骤2和步骤3,直到没有新的依赖模块。
2~3是一个递归的过程,webpack需要一步步获取更深层级的资源,然后逐个进行转译。问题在于webpack是单线程的,假设一个模块依赖于其他几个模块,则webpack必须对这些模块逐个进行转译。这些任务彼此之间没有任何依赖关系,却必须串行地执行。HappyPack恰恰以此为切入点,它的核心特性是开启多个线程,并行地对不同模块进行转译,从而充分利用本地的计算资源来提升打包速度。
8.2 缩小打包作用域
宏观角度来看,提升性能的方法无非两种:增加资源或者缩小范围。
增加资源是指使用更多CPU和内存,用更多的计算能力来缩短执行任务的时间;缩小范围则是针对任务本身,比如去掉冗余的流程,尽量不做重复性的工作等。前面的HappyPack属于增加资源。
8.2.1 exclude和include
exclude和include规则有重叠的部分时,exclude的优先级更高。
8.2.2 noParse
对于有些库,我们希望webpack完全不要去进行解析,即不希望应用任何loader规则,库的内部也不会有对其他模块的依赖,那么这时可以使用noParse实现。
8.2.3 IgnorePlugin
exclude和include是确定loader的规则范围,noParse是不去解析但仍会打包到bundle中。最后我们再看一个插件IgnorePlugin,它可以完全排除一些模块,被排除的模块即使被引用了也不会被打包进资源文件中。
8.2.4 缓存
使用缓存也可以有效减少webpack的重复工作,进而提升打包效率。可以令webpack将已经进行过预编译的文件保存到一个特定的目录中。下一次接收到打包指令时,可以去查看源文件是否有改动,如没有改动则直接使用缓存即可,中间的各种预编译步骤可以跳过。
Webpack 5引入了一个新的缓存配置项。在默认情况下,它会在开发模式中开启,在生产模式下禁用。可以通过以下方式来强制开启或关闭:
js
module.exports = {
// ...
caches: true
}
通过true或false控制的其实只是webpack基于内存的缓存。webpack还支持另一种基于文件系统的缓存,这种缓存机制必须要强制开启才会生效,开启的配置如下:
js
module.exports = {
// ...
caches: {
type: 'filename'
}
}
8.3 动态链接库与DIIPlugin
动态链接库是早期windows系统由于受限于当时计算机内存空间较小的问题而出现的一种内存优化的方法。当一段相同的子程序被多个程序调用时,为了减少内存消耗,可以将这段子程序存储为一个可执行文件,只在内存中生成和使用同一个实例。
DIIPlugin借鉴了动态链接库的这种思路,对于第三方模块或者一些不常变化的模块,可以将它们预先编译和打包,然后在项目实际构建过程中直接取用即可。当然,通过DIIPlugin实际生成的还是JS文件而不是动态链接库。在打包vendor的时候,还会附加生成一份vendor的模块清单,这份清单将会在工程业务模块打包时起到链接和索引的作用。
8.4 去除死代码
ES6 Module依赖关系的构建是在代码编译时而非运行时,基于这项特性webpack提供了去除死代码(tree shaking)功能,它可以在打包过程中帮助我们检测工程中是否有没被引用过的模块,这份代码将永远无法被执行到,因此也被称为"死代码"。webpack会对这部分代码进行标记,并在资源压缩时将它们从最终的bundle中去掉。
8.4.1 ES6 Module
去除死代码只能对ES6 Module生效。
8.4.2 使用webpack进行依赖关系构建
在工程中使用babel-loader,那么一定要通过配置来禁用它的模块依赖解析。因为如果有babel-loader来做解析,webpack接收到的就是转化过的CommonJS形式的模块,无法对死代码进行去除。
8.4.3 使用压缩工具去除死代码
webpack提供的去除死代码的功能本身只是为死代码添加标记,真正去除死代码是通过压缩工具来进行的,如terser-webpack-plugin即可。
8.5 小节
这一章介绍加快打包速度、减小资源体积的一些方法。对于一些对性能要求高的项目来说这些方法可以起到一定的作用。最后需要强调的是,每一种优化策略都有其使用场景,并不是任何一点放在一切项目中都有效。我们在发现性能的问题时,还是要根据现有情况分析出瓶颈在哪里,然后对症下药。
9.开发环境调度
webpack作为打包工具的重要使命之一就是提升效率。
9.1 Webpack开发效率插件
以下是几个使用较广的插件,从不同的方面对webpack的能力进行增强。
- Webpack-dashboard
webpack每次构建结束后都会在控制台输出一些打包相关的信息,但是这些信息是以列表展示的,有时会不够直观。webpack-dashboard就是用来更好地展示这些信息的,
- Webpack-merge
对于需要配置多种打包环境的项目来说,webpack-merge是一个非常实用的工具。
- Speed-measure-webpack-plugin
Speed-measure-webpack-plugin插件(简称SMP)。SMP可以分析出webpack整个打包过程中在各个loader和plugin上耗费的时间,这将有助于我们找出构建过程中的性能瓶颈。
- size-plugin
一般而言,随着项目的开发,产出的资源会越来越大,最终生成的资源会逐渐变得臃肿起来。size-plugin插件可以帮助我们监控资源体积的变化,尽早地发现问题。
9.2 热模块替换
早期调试代码的方式基本都是改代码------刷新网页查看结果------再改代码,这样反复地修改和测试。
后来,一些web开发框架和工具提供了更便捷的方式------只要监测到代码改动就会自动重新构建,然后触发网页刷新。这种一般称为live reload。
webpack在live reload的基础上又进了一步,可以让代码在网页不刷新的前提上得到最新的改动,甚至可以让我们不需要重新发起请求就能看到更新后的效果。这就是模块热替换(Hot Module Replacement,HMR)功能。
9.2.1 开启HMR
HMR需要手动开启,并且有一些必要条件。
首先确保项目是基于webpack-dev-server或者webpack-dev-middle进行开发的,webpack本身的命令行并不支持HMR。
js
module.exports = {
// ...
plugins: [
new webpack.HotModuleReplacementPlugin()
],
devServer: {
hot: true
}
}
上面的配置产生的结果是webpack会为每个模块绑定一个module.hot对象,这个对象包含了HMR的api。调用HMR API有两种方式,一种是手动地添加这部分代码;另一种是借助一些现成的工具,比如react-hot-loader、vue-loader等。
9.2.2 HMR原理
在开启HMR的状态下进行开发,资源的体积会比原本的大很多,这时因为webpack为了实现HMR而注入了很多相关代码。
在本地开发环境下,浏览器是客户端,webpack-dev-server(WDS)相当于我们的服务端。HMR的核心就是客户端从服务端拉取更新后的资源(准确来说,HMR拉取的不是整个资源文件,而是chunk diff,即chunk需要更新的部分。)
第1步就是确定浏览器什么时候去拉取这些更新。这需要WDS对本地源文件进行监听。实际WDS与浏览器之间维护了一个websocket,当本地资源发生变化时,WDS会向浏览器推送更新事件,并带上这次构建的hash,让客户端与上一次资源进行对比。通过对比hash可以防止冗余更新的出现。因为很多时候源文件的更改并不一定代表构建结果的更改。
这也同时解释了为什么当我们开启多个本地页面时,代码改动后所有页面都会更新。当然webscoket并不是只有开启HMR才会有,live reload其实也是依赖这个而实现的。
有了恰当的拉取资源的时机,下一步就是知道拉取什么。这部分信息并没有包含在刚刚的websocket中,因为刚刚我们只是想知道这次构建的结果是不是和上次一样。现在客户端已经知道新的构建结果和当前有所差别,那么它会向WDS发起一个请求来获取更改文件的列表,即哪些模块有了改动。通常这个请求的名字为[hash].hot-update.json。
现在客户端已经获取大了chunk的更新,但是又遇到了一个非常重要的问题,即客户端获取到这些增量更新之后要如何处理呢?哪些状态需要保留,哪些需要更新?这些不属于webpack的工作,但是它提供了相关的API,如module.hot.accept,可以供开发者使用这些API针对自身场景进行处理。像react-hot-loader和vue-loader也就是借助这些API来实现的HMR。
10.webpack打包机制
10.1 总览
控制台在执行webpack的打包命令之后都发生了什么呢?
项目中的源代码得到了处理,其中可能有一些语法转译。配置好的插件会协调工作,最后生成一系列静态资源并放置到了一个目录下。
从宏观角度看它是一个函数,输入是一个个有依赖关系的模块,而输出的是静态资源。
10.2 准备工作
在开工之前,webpack会先进行一次配置项的检查。我们也可以单独运行webpack configtest命令来进行该检查。配置项可以通过命令行获取,也可以通过配置文件获取。当两者相同时,命令行中的优先级更高。
webpack对配置项是有白名单的,也就是说所有传进去的配置都必须是有效的,当配置项中有无效字段时,webpack会终止打包并给出错误提示。
10.3 缓存加载
验证配置正确后,webpack还有一个重要的预先工作,就是加载缓存。webpack 5中的缓存有两种,一种是内存中,一种是文件系统中的。在build模式下,仅可以使用文件系统缓存。因为在每次构建结束后,webpack使用的内存都会被释放掉,而文件系统缓存是可以一直存在的。使用文件系统的好处就在于,即便是新打开一个命令行重新开始编译,webpack也能找到之前的缓存,从而加快构建速度。
对于内存缓存来说,主要是在webpack内部进行管理的,我们无法过多介入。而文件系统缓存,则在大多数情况下需要开发者介入。webpack提供了多种管理缓存的方法,分别是:
- 打包依赖(build dependencies)
- 缓存名称(cache name)
- 缓存版本(cache version)
在启用文件系统缓存后,webpack会把缓存放在一个特定的目录中。每当检测到源代码有所改动时,就会在该目录中生成一个新版本的缓存。
10.4 模块打包
10.4.1 Complier
Complier是webpack内部处于核心地位的一个类。当我们执行打包命令时,webpack会创建一个Complier实例。它相当于webpack内外连接的桥梁,会接收外部传进来的配置项,同时也向外暴露诸如run、watch等重要的方法。
无论是build模式下还是watch模式下,都仅仅会创建一个Complier实例,这也是我们在watch模式下修改webpack的配置时新的配置不会生效的原因。只有在我们停下当前控制台的进程在重新启动webpack时,这些配置才会随着新的Complier实例初始化而生效。
在webpack内部,Complier控制着总任务流,并把这些任务分配给其他模块来做。这里的其他模块大多是插件(Plugin)。
Complier与插件的协作模式并不是Complier直接调用这些插件,而是由Complier暴露出工作流程中的Hook(钩子),让插件监听这些Hook,这些适当的时机完成相应的工作。
10.4.2 Compilation
Compilation也是webpack中处于核心地位的一个类。与Complier类似于总指挥官的角色相比,Compilation则类似于总管的角色,事无巨细,管理着更为底层的任务,比如创建依赖关系图(Dependency Graph)、单个模块的处理以及后面提到的模块渲染等,每一个具体的步骤都是由Compilation来控制的。
Compilation的工作模式与Complier类似,也提供非常多的hook让其他模块监听和处理更为细小的事物。
10.4.3 Resolver
初始化Complier和Compilation实例是打包流程的开始,下一步是开始构建我们的依赖关系图。
当然整个流程还是会从webpack配置中的入口开始。webpack会首先拿到入口路径,然后尝试找到对应的入口文件。这个寻找的过程就是由Resolver来实现的。当然不仅仅是入口文件,由入口文件所获取到的依赖关系都需要Resolver来找到实际的文件路径。
Resolver找到文件后,将会返回一个对象,里面包含了resolve行为的所有信息,包括源代码中的引用路径、最后实际找到的文件路径以及其上下午等。
10.4.4 Module Factory
Module Factory(模块工厂),它的最主要作用就是产出模块。Module Factory的工作模式也类似于一个函数。它接收的是Resolver提供的reolve行为信息,返回的则是一个模块对象,这个模块对象中将包含其源代码。
另外Module Factory也参与了模块级别的流程调度,它暴露出了很多Hook,以便对单个模块进行处理。
10.4.5 Parser
从Module Factory中得到的源代码是各种各样的,可能包含最新的ECMAScript特性,也可能是使用异步加载的语法引入的其他模块,甚至完全是一个新的语言。面对如此多类型的源代码,我们必须让他变成webpack能理解的形式才能进行下一步的处理,而这就是Parser(解析器)的工作。
Parser接收由Module Factory产出的模块对象,通过webpack配置的模块处理规则,让不同类型的源代码经过loader的处理最终都变成javascript。
源代码转译为javascript之后,Parser还要进行另一部分重要工作,就是再将javascript转化成抽象语法树(AST),并进一步寻找文件依赖。
js
// calculator.js
let count = 0;
const add = function (a, b) {
count += 1;
return a + b;
};
export { count, add };
// bar.js
import { add } from './calculator.js'
add(2, 3)
document.writeln('bar.js')
假设bar.js为工程的入口文件,当Parser处理到该文件时,并不是直接通过运行bar.js的代码获知它与calculator.js存在依赖关系,而是将其解析成AST,再对AST进行分析。
10.4.6 模块渲染
在由入口index.js开始找到的所有的模块都处理完毕之后,webpack会把这些模块封装在一起,并生成一个chunk。这些chunk都等待着被转化为最后的代码,而webpack实现这最后一步方法就是模块渲染。
对于不同类型的模块以及模块之间的依赖关系,webpack内部都有相应的模块。根据依赖关系图以及前面各个步骤所得到的模块信息,webpack所要做的就是组织和拼装模块,再把实际模块相关的内容填进去,最后就"渲染"出了我们所看到的目标代码。
10.5 webpack插件
10.5.1 Tapable
Tapable是整个webpack插件系统的核心。tap本身有"窃听""监听"的含义,Tapable的意思则是"可以被监听的"。Tapable是webpack中的一个类,所有由这个类所产生的实例都是可以被监听的。而所有webpack中的插件,甚至包括Complier和Compilation,都继承了Tapable类。