最近开始学习webpack,所谓好记性不如烂笔头,现在开始日常笔记。
这次打算换个方式记笔记,我会以各种webpack问题为切入点进行记录。
1. 什么是webpack
webpack是一个对JS模块进行打包的开源工具,其最核心的功能是解决模块之间的依赖,把各个模块按照特定的规则和顺序组织在一起,最终合并为一个或多个JS文件。这个过程就叫模块打包。
2.模块打包原理是什么
2.1 webpack打包结果
回答这个问题之前,首先看一个简单的webpack打包结果(bundle),看看它是如何将有依赖关系的模块串联一起的:
// 最外层立即执行匿名函数。用来包裹整个bundle,并构成自己的作用域
(function(modules) {
// 用于模块缓存。被加载过的模块存储到这个对象里面
var installedModules = {
};
// 执行入口模块的加载
function __webpack_require__(moduleId) {
// 代码部分
}
// 执行入口模块加载 ./src/index.js是我们入口文件
return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
({
// 以下为打包的模块,它是以Key-value的形式存储被打包的模块,Key是模块id,value是匿名函数包裹的模块实体
"./src/calcute.js":
(function(module, exports) {
// 模块的内容
}),
"./src/index.js":
(function(module, exports, __webpack_require__) {
// 打包入口
const add = __webpack_require__(/*! ./calcute */ "./src/calcute.js").add
const count = __webpack_require__(/*! ./calcute */ "./src/calcute.js").count
})
});
2.2 bundle在浏览器中执行的顺序
接着了解一下webpack打包结果(bundle)是如何在浏览器中执行的:
1.在最外层的匿名函数会初始化浏览器执行的环境,为模块的加载和执行做一些准备工作。
2.加载入口模块,浏览器会从入口模块开始,一个bundle只有一个入口模块。
3.执行模块代码,如果执行到了module.exports则记录下模块的导出值;如果中间有_webpack_require_函数,需要引入其他模块,则会暂时交出执行权,进入加载其他模块的逻辑。
4.在_webpack_require_函数中会判断即将加载的模块是否
加载过,如果加载过,这直接去installedModules
取值,否则回到第三步,执行该模块的代码来获取导出值。
5.所有依赖的模块执行完毕之后,最后执行权回到入口模块。当入口模块的代码执行到结果,也就代表整个bundle运行结束。
2.3 webpack打包原理
通过了解整个流程之后,就可以开始回答这个小结的问题,webpack打包原理是什么?
答:webpack为每个模块创造了一个可以导入和导出模块的环境,但本质上没有修改代码的执行逻辑,因此代码执行的顺序和模块加载的顺序完全一致。
3.CommonJS(cjs)和ES6Module(esm)的区别
3.1 CommonJS是什么
CommonJS是包含模块、文件、IO、控制台在内的一系列标准。
CommonJS中规定每个文件是一个模块,会形成一个只属于模块自身的作用域,所有的变量及函数只能自己访问,对外不可见。
3.1.1 导出方式
导出是一个模块向外暴露自身的唯一方式。使用module.exports
方式导出模块中的内容,如:
// calcute.js
module.exports = {
name:'commonJS',
add(a,b){
return a+b}
}
或者为了书写方便,也可用另一种写法:
// calcute.js
exports.name = 'commonJS'
exports.add = function(a,b)
3.1.2 导入方式
在CJS中使用require
的方式进行模块导入,如:
// index.js
const add = require('./calcute').add
const sum = add(2,3)
console.log(sum)
3.2 ES6 Module是什么
ES6 Module也是将每个文件作为一个模块,不同区别在于导入和导出的方式。
3.2.1 导出方式
esm用export
的命令导出模块。export有两种形式:
-
命名导出
-
默认导出
-
1.命名导出
命名有两种写法,如下:
第一种:将变量声明和导出写在一行
// demo.js
export const a = 1
export const b = {
name: '1'
}
第二种:先进行变量声明,然后再用同一个export语句导出
//demo.js
const a = 1
const b = {
name:'1'}
export {
a,b}
- 2.默认导出
命名导出可导出多个模块,但是默认的只能有一个
//demo.js
export default {
a:1,
b:{
name:'1'}
}
3.2.2 导入方式
esm使用import
的方式进行引入,如:
import {
a, b } from './demo1'
console.log(a, b)
3.3 cjs和esm区别
3.3.1 动态与静态
cjs:模块依赖关系的建立发生在代码运行阶段(动态);
esm:模块依赖关系的建立发生在代码编译阶段(静态);
这也是二者最本质的区别。
cjs中require的模块路径可以动态指定,支持传入一个表达式,甚至可以用if
语句判断是否加载某个模块;
但是esm却不行,它的导入和导出语句必须为声明式,而且得位于模块的顶层作用域,不能像cjs那样放在if
中。
3.3.2 值拷贝和动态映射
cjs:导入一个模块时,cjs获取的是一份导出值的拷贝;
esm:是值的动态映射,并且映射为只读的;
可以看一个例子:
// calcute.js
var count = 0
module.exports = {
name:'commonJS',
count: count,
add(a,b){
count +=1
return a+b
}
}
//index.js
const add = require('./calcute').add
const count = require('./calcute').count
console.log(count) //0
add(2,3)
console.log(count) //0
使用cjs的方式,输出count的两次结果都是0,从这个例子可以看出cjs是值拷贝,当导入的模块(calcute.js)发生变化时,使用这个模块的文件(index.js)是不会有任何影响,但这个情况有时并不是我们想要的。
接着看一下esm的引用:
// calcute.js
const count = 0
const add = function(a,b){
count +=1
return a+b
}
export default {
count,add}
// index.js
import {
add, count } from './calcute6'
console.log(count) //0
add(2,3)
console.log(count) //1
使用esm方法的两次输出分别为0,1,从这个例子中可以看出esm导入的变量其实是对原有值的动态映射。index中的count对calcute中的count是实时反映。
另外,从webpack编译出来的结果也可以看出来他们的区别:
再看另一个简单的例子
首先是cjs的写法:
// demo.js
exports.a = 1
exports.b = {
name:'haha'}
// index.js
const {
a,b} = require('./demo')
console.log(a,b)
这就是一个很简单的写法,这时候看webpack打包出来的结果(下面的结果是提取出来的部分,重点关注使用的两个模块):
(function(modules) {
// 执行入口的文件
return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
// 模块打包部分
({
"./src/demo.js": (function(module, exports) {
exports.a = 1
exports.b = {
name:'haha'}
}),
"./src/index.js":
(function(module, exports, __webpack_require__) {
const {
a,b} = __webpack_require__(/*! ./demo */ "./src/demo.js")
console.log(a,b)
})
});
从这个模块打包模块可以看出来,模块打包的内容就是原文件的内容,基本就是将内容进行的赋值,也就是上面说的一份导出值的拷贝。
接着再看esm的写法:
// demo6.js
const a = 1;
const b = {
name:'haha'};
export {
a,b}
// index6.js
import {
a,b} from './demo6'
console.log(a,b)
采用ES6写法的代码和上面的CommonJS的内容近乎一致,接下来看看打包出来的结果:
(function(modules) {
// webpackBootstrap
// Load entry module and return exports
return __webpack_require__(__webpack_require__.s = "./src/index6.js");
})
({
"./src/demo6.js":
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() {
return a; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "b", function() {
return b; });
const a = 1;
const b = {
name:'haha'};
}),
"./src/index6.js":
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _demo6__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./demo6 */ "./src/demo6.js");
console.log(_demo6__WEBPACK_IMPORTED_MODULE_0__["a"],_demo6__WEBPACK_IMPORTED_MODULE_0__["b"])
})
});
这个打包的结果可以明显看出来,在处理模块时候就有不同了,在esm中模块不在是将内容进行的赋值,而是使用了_demo6__WEBPACK_IMPORTED_MODULE_0__
这种对象来获取值,这样就和原模块建立了一种映射接,原先模块的内容变换,_demo6__WEBPACK_IMPORTED_MODULE_0__
就会进行变换,这样引入该模块文件中的内容也就会同样进行改变,实现了动态映射。
3.3.3 循环依赖
循环依赖是指模块A依赖模块B,同时模块B依赖模块A,如下例子:
// a.js
import {
foo} from './b.js'
foo()
// b.js
import {
bar} from 'a.js'
bar()
对于这种情况首先要说肯定不支持这么写法,这会带来很大的麻烦。
另外对于这种情况,cjs是没有办法得到预想中的结果,但是使用esm,利用它的动态映射的特性可以支持循环依赖,但是开发者必须确保导入的值被使用时已经设置好正确的导出值。
4.webpack如何确保被加载过的模块不会再次执行模块代码
在加载模块过程中:
- 如果该模块第一次被加载,webpack首先会执行该模块,然后导出内容;
- 如果该模块曾被加载过,这时该模块的代码不会再次执行,而是直接导出上次执行后得到的结果。
接着看一下打包的结果,就会了解它的缓存原理:
// 最外层立即执行匿名函数
(function(modules) {
// 用于模块缓存。被加载过的模块存储到这个对象里面
var installedModules = {
};
// The require function
function __webpack_require__(moduleId) {
// 检查缓存中是否有这个模块
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// Create a new module (and put it into the cache)
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {
}
};
// Execute the module function
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// Flag the module as loaded
module.l = true;
// Return the exports of the module
return module.exports;
}
}
从上面代码可以看到,在模块中会有一个installedModules
对象用来存放模块的信息,在这个对象中有个属性为loaded
即为l,用于记录该模块是否被加载过。它的默认值为false,当这个模块第一次被加载和执行过后会置为true,后面再次加载时候检查到installedModules
有这个模块信息时,就不会再次执行模块代码了。
5.webpack常见的loader
5.1 loader的定义
预处理器(loader),它赋予了webpack可处理不同资源类型的能力。
为了更好说明loader是如何工作的,看一下loader的源码结构:
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的本质都是一个函数,在该函数中对接受到的内容进行转换,然后返回转换后的结果,用公式表达loader的本质为以下形式:
output = loader(input)
5.2 常用的loader
- babel-loader:用来处理ES6,将它编译为ES5
- vue-loader:用来加载 Vue.js 单文件组件
- file-loader:处理png,jpg,gif这类图片资源时使用,把文件输出到一个文件夹中,并返回pubilcPath,在代码中通过相对 URL 去引用输出的文件
- url-loader:与 file-loader 类似,区别是用户可以设置一个阈值,大于阈值时返回其 publicPath,小于阈值时返回文件 base64 形式编码 (处理图片和字体)
- css-loader:加载 CSS,支持模块化、压缩、文件导入等特性
- style-loader:把 CSS 代码注入到 JavaScript 中,通过 DOM 操作去加载 CSS
- eslint-loader:通过 ESLint 检查 JavaScript 代码
- i18n-loader: 国际化
- ts-loader: 将 TypeScript 转换成 JavaScript
- html-loader: 将HTML文件转化为字符串并进行格式化
- handlebars-loader: 将 Handlebars 模版编译成函数并返回
6.webpack常见的plugin
6.1 Plugin的定义
webpack的plugins是用于接收一个插件数组,我们可以使用webpack内部提供的一些插件,也可以加载外部插件。
6.2 常用plugin
- mini-css-extract-plugin: 分离样式文件,CSS 提取为独立文件,支持按需加载 (替代extract-text-webpack-plugin)
- ignore-plugin:忽略部分文件
- html-webpack-plugin:简化 HTML 文件创建 (依赖于 html-loader)
- web-webpack-plugin:可方便地为单页应用输出 HTML,比 html-webpack-plugin 好用
- webpack-parallel-uglify-plugin: 多进程执行代码压缩,提升构建速度
- webpack-bundle-analyzer: 可视化 Webpack 输出文件的体积 (业务组件、依赖第三方模块)
7.loader和plugin的区别
loader的存在是做翻译官使用的,因为webpack只认JS,所以需要预处理器对其他类型的资源进行转译。loader的本质就是一个函数,在该函数中对接收的内容进行转换。
plugin就是插件,用来扩展webpack的功能,在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。
Loader 在 module.rules 中配置,也就是说他作为模块的解析规则而存在,类型为数组
Plugin 在 plugins 中单独配置,类型为数组,每一项是一个 plugin 的实例,参数都通过构造函数传入
8.source map是什么?生产环境怎么用?
8.1 source map是什么
source map 是将编译、打包、压缩后的代码映射回源代码的过程。
经过webpack打包压缩后的代码不具备良好的可读性,这样代码出现了问题就不好排查,而有了source map,再加上dev-tools,想要调试源码就非常容易了。
map文件只要不打开开发者工具,浏览器是不会加载的。
8.2 生产环境怎么用?
为了他提升source map的安全性,线上环境一般有三种处理方案:
hidden-source-map:不会再bundel文件中添加对于Map文件的引用,可以借助第三方错误监控平台 Sentry 使用map文件
nosources-source-map:只会显示具体行数以及查看源代码的错误栈。安全性比 sourcemap 高
sourcemap:通过 nginx 设置将 .map 文件只对白名单开放(公司内网)
9. Webpack 的热更新原理(必考点)
9.1 什么是HMR
webpack热更新又称为模块热替换(Hot Module Replacement, HMR),这个机制可以让代码在网页不刷新的前提下得到最新的改动,甚至不需要重新发起请求就能看到更新后的效果。
使用HMR是有些前提的,webpack本身的命令行不支持HMR,我们需要确保项目是基于webpack-dev-server或者webpack-dev-middle进行开发的。
9.2 HMR的原理
在本地开发环境下,浏览器是客户端,webpack-dev-server(WDS)相当于我们的服务端。
HMR的核心就是客户端从服务端拉取更新后的文件,更准确说是拉取的不是整个资源文件,而是chunk diff(即chunk需要更新的部分)。
接下来说一下HMR如何知道更改什么部分:
第一步就是浏览器什么时候去拉取这些更新。这需要WDS对本地源文件进行监听。实际上WDS与浏览器之间维护了一个websocket,当本地资源发生变化时WDS会向浏览器推送更新事件,并带上这个构建的hash,让客户端与上一次资源进行对比。通过hash的比对可以防止冗余更新的出现。
第二步知道拉取什么。现在客户端已经知道新的构建结果和当前的有了差别,就会向WDS发起Ajax请求来获取更改内容(文件列表、hash),该结果返回客户端,这样客户端就可再借助这些信息继续向WDS发起jsonp请求获取该chunk的增量更新。
客户端获取到了chunk的更新之后如何处理?哪些状态该保留?哪些又需要更新?这个就不属于webpack的工作了,但它提供了相关 API 以供开发者针对自身场景进行处理。
10.如何优化 Webpack 的构建速度?
优化构建速度的方法有很多,就看要讲多久,简单说几种方法:
-
使用高版本的webpack: 高版本支持的功能更多,进行了更多优化
-
使用多进程构建:HappyPack(不维护了)、thread-loader
-
压缩代码: 通过 mini-css-extract-plugin 提取 Chunk 中的 CSS 代码到单独文件
-
缩小打包作用域:
- 使用include或者exclude指定打包的内容
- 使用noParse 对完全不需要解析的库进行忽略,虽然不会解析,但仍会打包到bundle中
- 使用IgnorePlugin 完全排除一些模块,被排除的模块即使被引用了,也不会被打包进资源文件中
-
使用 html-webpack-externals-plugin,将基础包通过 CDN 引入,不打入 bundle 中
-
使用 SplitChunksPlugin(webpack4+)进行代码分割
-
使用DllPlugin:借鉴动态链接库的思路,对于第三方模块或者一些不常变化的模块,将他们预先编译和打包,然后在项目实际构建中直接取用。
-
利用tree shaking:
1 tree shaking功能可以在打包过程中检测“死代码”,对这部分代码进行标记,并在资源压缩时将他们从最终的bundle中去掉。
2 tree shaking只对ES6 Module生效,所以要多用ES6 Module模块
3 禁用babel-loader 的模块依赖解析,否则 Webpack 接收到的就都是转换过的 CommonJS 形式的模块,无法进行 tree-shaking
4 使用压缩工具去除死代码:如 terser-webpack-plugin
11.什么是代码分割(或者代码分片)
11.1 代码分片意义
代码分片是webpack作为打包工具所特有的一项技术,通过这项技术我们可以把代码按照特定的形式进行拆分,使用户不必一次全部加载,而是按需加载。
11.2 分片方法
webpack提供了插件进行分片操作,CommonChunkPlugin是webpack 4之前的内部插件,SplitChunks是webpack 4之后的插件,比CommonChunkPlugin功能更加强大,还简单易用。
除了使用插件,还可以进行资源的异步加载。当模块数量过多,资源体积过大时,可以把一些暂时用不到的模块延迟加载,这样页面初次渲染的时候用户下载的资源也会尽可能的小,后续的模块等到适当实际再去出发加载。
使用这些方法进行代码分片,可以有效地缩小资源体积,同时更好的利用缓存,提高用户体验。
12.webpack与grunt、gulp的不同
三者都是前端构建工具
grunt 和 gulp 是基于任务和流的。找到一个(或一类)文件,对其做一系列链式操作,更新流上的数据, 整条链式操作构成了一个任务,多个任务就构成了整个web的构建流程.其缺点是集成度不高,要写很多配置后才可以用,无法做到开箱即用。
webpack 是基于入口的。webpack 会自动地递归解析入口所需要加载的所有资源文件,然后用不同的 Loader 来处理不同的文件,用 Plugin 来扩展 webpack 功能
从构建思路来说 gulp和grunt需要开发者将整个前端构建过程拆分成多个Task,并合理控制所有Task的调用关系
webpack需要开发者找到入口,并需要清楚对于不同的资源应该使用什么Loader做何种解析和加工