最近阅读《Webpack 入门、进阶与调优》所给的一些总结,主要是
Webpack
一些功能特征与Webpack
的调优,分为两篇博客。
- 这一篇是Webpack 功能特性与工作原理。
- 下一篇是Webpack 打包优化以及开发环境优化。
主要是做一个记录和自己的一些思考与拓展,方便之后回顾,更多细节内容还是推荐阅读这本书。
简介
Webpack 是什么
关于它的介绍网络上铺天盖地,这里简单说一下,Webpack
是一个开源的前端打包工具。Webpack
提供了前端开发缺乏的模块化开发方式,将各种静态资源视为模块,并从它生成优化过的代码。
为什么需要 Webpack
随着项目业务的增加,Web
应用会变得很庞大,因此需要借助一些工作,否则人工去维护代码成本太高,并且也难以维护。Webpack
可以帮我们处理好不同类型模块之间的依赖关系,并且可以有效的减少资源提及,提高渲染速度。
就我而言,其实主要是之前只是了解
webpack
,没有系统的去学习过,并且目前手上的产品体积很大,系统中已经存在大量的webpack
配置,学习了方便之后可以进行维护。
如何使用 Webpack
安装
首先需要安装webpack
,有全局安装和本地安装,推荐使用本地安装,这样好在不同项目管理不同版本,也防止污染其他人的输出结果。
npm install webpack webpack-cli --save-dev
webpack
是核心模块、webpack-cli
是命令行工具,安装之后可以通过命令npx webpack -v
以及 npx webpack-cli -v
查看版本号验证是否安装成功。这里无法直接使用webpack
命令因为没有安装在全局。
使用
这里简单例子说明一下
// index.js
import content from "./content.js";
document.write(content);
// content.js
export default "Hello Webpack!";
直接使用命令行进行打包
npx webpack --entry=./index.js --output-filename=bundle.js --mode=development
entry
是打包资源的入口output-filename
是输出资源名(默认输出到dist
)。mode
是打包模式(development
、production
、none
)。
可以看出每次都需要输入上面shell
命令,很冗余繁琐,因此可以使用npm scripts
进行打包,在package.json
中添加脚本命令。
"scripts": {
"build": "webpack --entry=./index.js --output-filename=bundle.js --mode=development"
}
可以使用通过npm
命令:npm run build
重新打包。甚至,可以使用更加利于维护的webpack.config.js
,如下。
const path = require("path");
const htmlPlugin = require("html-webpack-plugin");
module.exports = {
entry: "./src/index.js",
output: { filename: "bundle.js" },
mode: "development",
plugins: [
new htmlPlugin({
title: path.basename(__dirname),
}),
],
devServer: {
publicPath: "/dist/",
port: 3000,
},
};
"scripts": {
"build": "webpack"
}
这样在module.exports
中导出对象通过key-value
接受打包时候的参数。
webpack-dev-server
- 令
webpack
进行模块打包,并处理打包结果的资源请求。 - 作为
WebServer
处理静态资源。 - 数据更新后,可以自动刷新(
live-loading
)页面。
"scripts": {
"dev": "webpack-dev-server --open-page \"dist/index.html\"",
"build": "webpack"
}
在上面的webpack.config.js
中已经体现出来,devServer
就是webpack-dev-server
的相关配置。通过npm run dev
可以直接启动服务。
模块
CommonJS
CommonJS
中规定每一个文件是一个模块。直接用script
引用JS
与其最大的区别就是前者顶层作用域是全局作用域,而CommonJS
会形成自己的作用域,所有的变量只有自己能访问。
导出
通过module.exports
可以导出模块的内容,其指定了该模块要对外暴露哪些内容,也就是说导出的是module.exports
这个对象。
module.exports = {
name: "calculator",
add: function (a, b) {
return a + b;
},
};
也可以直接用exports
exports.name = "calculator";
exports.add = function (a, b) {
return a + b;
};
这是因为在内部存在机制将exports
指向module.exports
,可以理解为在CommonJS
模块的首部默认添加了如下代码:
var module = {
exports: {},
// 判断是否被加载
loaded: false,
};
var exports = module.exports;
这样就很好理解上面的两种导出方式了,由于有这种机制,因此不能直接给exports
赋值,否则会失效。
exports = {
name: "calculator",
};
此时exports
所致的对象已发生改变,原来的module.exports
仍然是空对象。并且在module.exports
或exports
之后的代码也是会执行的,其不像return
后面的语句不会执行。
导入
使用require
进行模版的导入。
// calculator.js
console.log("running calculator.js");
module.exports = {
name: "calculator",
add: function (a, b) {
return a + b;
},
};
// index.js
const add = require("./calculator.js").add;
const sum = add(2, 3);
console.log("sum:", sum);
const moduleName =
require("./calculator.js").name;
console.log("end");
// terminal
// running calculator.js
// sum: 5
// end
require
的模块,第一次被加载,之后遇到,如果被加载过,不会再执行,而是直接导出上次执行后的结果。
ES6 Module
ES6 Module
中规定每一个文件是一个模块,都有自己的作用域。其会自动采用严格模式,ES5
需要加上"use strict"
来控制严格模式,ES6 Module
无论是否有"use strict"
,都会采用严格模式。
导出
其导出方式主要分为两种。
- 命名导出。
- 默认导出。
在命名导出中,可以有两种方式。
// 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 _getSum };
可以分开导出,也可以定义完之后,一起导出,并且可以使用as
关键字对变量进行重命名。
在默认导出中,只有一种导出方式。可以理解为输出了一个名为default
的变量,并且其可以导出字符串、class、匿名函数。
export default {
name: "calculator",
add: function (a, b) {
return a + b;
},
};
导入
ES6 Module
主要是你用的是import
语句进行导入模块。
// calculator.js
const name = "calculator";
const add = function (a, b) {
return a + b;
};
export { name, add };
// index.js
import {
name,
add as _getSum,
} from "./calculator.js";
add(2, 3);
需要对应上export
中的内容,并且也可以使用as
关键字对变量进行重命名。
导入多个变量,可以直接整体导入。
import * as calculator from "./calculator.js";
在默认导出时,import
后面直接跟自定义的变量名。以及混合起来都是可以的,但是React
必须在大括号前。
import calculator from "./calculator.js";
import React, { Component } from "react";
CommonJS 与 ES6 Module 区别
第一个区别是“静态”和“动态”,CommonJS 是动态的,ES6 Module 是静态的。动态表示模块依赖关系建立发生在代码运行阶段;静态则是发生在代码编译阶段。
也就是说在CommonJS
中,moduleA
加载moduleB
,会去执行moduleB
中的代码,遇到module.exports
之后会将require
作为返回值进行返回。require
支持传入一个表达式,甚至可以用 if 来判断是否加载这个模块,在执行之前无法判断依赖关系,因此建立是发生在运行阶段。
对ES6 Module
而言其导入、导出都是声明式,不支持导入路径是表达式,因此在编译期间就建立了依赖关系。其根据这些特性可以进行死代码检测和排查,模块变量类型的检查。
**第二个区别是值拷贝和动态映射,CommonJS 是值拷贝,而 ES6 Module 是动态映射。**简单来说就是深拷贝与浅拷贝的关系,CommonJS
引入进来之后就不受原来控制,而ES6 Module
还是映射到原来的模块。
第三个区别是两者对循环依赖的处理方式不同。
// CommonJS
// 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:", bar);
module.exports = "This is bar.js";
// index.js
require("./foo.js");
// terminal
// value of foo: {}
// value of bar: This is bar.js
执行index.js -> foo.js -> bar.js -> 这里由于已经 require(‘./foo.js’),因此不会交回 foo,直接获取默认的空对象)-> foo.js -> index.js。这里后面会提到,require
之后,会将module
对象放入到installedModules
一个数组中,之后再次遇到直接取里面的值。
// ES6 Module
// foo.js
import bar from './bar.js';
console.log('value of bar:', bar);
export default 'This is foo.js';
// bar.js
import foo from './foo.js';
console.log('value of foo:', foo);
export default 'This is bar.js';
// index.js
import foo from './foo.js';
// terminal
// value of foo: undefined
// value of bar: This is bar.js
这里和CommonJS
逻辑一样,不过这里不是空对象,而是undefined
。但是由于ES6 Module
具有动态映射,因此可以利用这个特性来支持循环依赖,可以思考一下如何完成?
打包原理
其实还有诸如AMD
、UMD
等标准,但是目前使用的已经不多。现在来深入理解一下webpack
的打包原理,还是刚刚的实例代码,将其打包之后可以生产如下的代码(bundle.js
),下面给出的代码有些内容被省略了。
/**
* Immediate execution of anonymous functions
* @param {*} modules All dependencies in the project will be stored in `key-value` form.
* @returns
*/
(function (modules) {
// The module cache: executed when each module is loaded for the first time and its exported values are stored in this object.
var installedModules = {};
// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
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
// The corresponding key function is executed, and the result is mounted on `module.export` after execution
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;
}
// Other codes are included here, includes __webpack_require__.(m/c/d/r/n/d/o/p)
// Load entry module and return exports
return __webpack_require__(
(__webpack_require__.s = 0)
);
})({
0: function (
module,
exports,
__webpack_require__
) {
module.exports = __webpack_require__("3qiv");
},
"3qiv": function (
module,
exports,
__webpack_require__
) {
// index.js content
},
jkzz: function (module, exports) {
// calculator.js content
},
});
在生成的bundle.js
中
-
会形成一个立即执行匿名函数。前面提到的
installedModules
会作为模块的缓存。 -
然后实现
require
。判断引入的模块是否存在与缓存中,如果存在直接获取缓存中的exports
,如果不存在会创建一个exports
空对象放入到缓存中,相应的键值函数被执行,执行后的结果被挂载到module.export
上,更新module
的flag
标志标识已经被加载了,最终返回module.exports
。 -
最后执行入口模块的加载。
传入的module
对象全都是以key-value
形式储存了所有被打包的模块。通过传入__webpack_require__.s = 0
,表示入口模块的key
,执行加载模块,当执行到module.exports
记录下模块的导出值,而遇到__webpack_require__
,则会交出执行权,去执行函数体内加载其他模块的逻辑(实际上是一个递归的过程),所有加载完回到入口模块,运行结束。
基础配置
介绍基础配置之前,先看看chunk
、entry
、bundle
的关系,如下图所示。
chunk
表示代码块,在一个工程中可能会有一个或者多个的chunk
,它包含了很多模块的依赖关系。
注意在某些特殊情况下,一个入口可能产生多个
chunk
和多个bundle
。
入口
webpack
通过context
和entry
两者共同决定入口文件的路径。定义出chunk name
,如果一个工程只有一个入口,那么其默认的chunk name
为main
;如果有多个入口,需要每个入口都定义一个chunk name
。
context
只能为字符串。资源入口路径的前缀,需要采用绝对路径。下面的配置信息入口都是<root>/src/scripts/index.js
。主要是为了让entry
写的更加简洁。
module.exports = {
context: path.join(__dirname, "./src"),
entry: "./scripts/index.js",
};
module.exports = {
context: path.join(__dirname, "./src/scripts"),
entry: "./index.js",
};
entry
可以为字符串、数组、对象、函数。
// String
module.exports = {
entry: './src/index.js';
};
// Array
module.exports = {
entry: ['babel-polyfill', './src/index.js'];
};
// Object
module.exports = {
entry: {
index: './src/index.js',
lib: './src/lib.js'
}
};
// Function
module.exports = {
entry: () => new Promise((resolve) => {
setTimeout(() => {
resolve('./src/index.js');
}, 1000);
})
};
- 采用数组时,
webpack
会将最后一个元素作为实际的入口路径。 - 采用对象时,
key
对应chunk name
,value
对呀入口路径。 - 采用函数时,可以添加一些逻辑来获取工程中的入口,支持返回
Promise
进行异步操作。
vendor
webpack
默认配置时,当bundle
超过250kB
时,会发出过大警告,此时代码一旦更新,都需要重新下载资源文件。
vendor
则是代表把工程中使用的库、框架集中打包所产生的bundle
。
module.exports = {
entry: {
app: "./src/app.js",
vendor: [
"react",
"react-dom",
"react-router",
],
},
};
注意,这里没有设置
vendor
的入口路径,但是可以采用optimization.splitChunks
(webpack4
之后),将app
与vendor
这两个chunk
中的公共模块提取出来。app.js
产生的bundle
只包含业务模块,其依赖的第三方模块会抽离成一个新的bundle
。
出口
所有与出口相关配置都集中在output
对象。而output
中有数十个配置项,常用的能覆盖大多数场景(filename
,path
,publicPath
)。
filename
其作用是控制输出资源的文件名,形式为字符串。在许多场景下,需要每个bundle
产生不同的名字,其配置项模版变量有[name]
,[hash]
,[chunkhash]
,[id]
,[query]
。
这些变量主要的作用是区分chunk
和控制客户端缓存,表中的[hash]
,[chunhash]
都与chunk
内容直接相关。
module.exports = {
output: {
filename: "[name]@[chunkhash].js",
},
};
path
指定资源输出的位置,必须为绝对路径。
const path = require("path");
module.exports = {
output: {
filename: "[name]@[chunkhash].js",
path: path.join(__dirname, "dist"),
},
};
这里将打包文件输出到dist
目录下,在webpack4
之前默认会生成到根目录,webpack4
之后会默认生成到dist
目录。
publicPath
path
指定资源的输出位置。publicPath
知道资源的请求位置。
比如当前地址为https://example.com/app/index.html,异步加载的资源名为 0.chunk.js
:
publicPath: ""; // https://example.com/app/0.chunk.js
publicPath: "./js"; // https://example.com/app/js/0.chunk.js
publicPath: "../assets/"; // https://example.com/assets/0.chunk.js
在webpack-dev-server
中也有一个publicPath
,这里和webpack
中的含义不同,这里制定静态资源服务的路径。
loader
webpack
只认识js
代码,在项目中,还有大量的css
、图片、字体等需要进行打包,而这些就需要各种loader
来进行输出成webpack
可以认识的内容。每一个loader
都是一个函数:
$$ output = loader(input) $$
例如使用babel-loader
将ES6+
的代码转换成ES5
时,ES5 = babel-loader(ES6+)
,并且其是可以链式操作的,也就是说上一个的输出可以是下一个的输入。
loader
的配置,则是控制这些内容的处理过程,其配置在module
里面,下面将介绍常见的相关配置。
rules
const path = require("path");
const htmlPlugin = require("html-webpack-plugin");
module.exports = {
entry: "./src/index.js",
output: {
filename: "[name].js",
},
mode: "development",
module: {
rules: [
{
test: /\.css$/,
use: [
"style-loader",
{
loader: "css-loader",
options: {
// css-loader config
},
},
],
},
],
},
plugins: [
new htmlPlugin({
title: path.basename(__dirname),
}),
],
devServer: {
publicPath: "/dist/",
port: 3000,
},
};
这里直接用一个实例来说明了如何配置rules
,module.rules
代表的是模块的处理规则,其是一个对象数组,每一个对象都是处理不同的文件,这里以css
为例,test
代表去正则匹配符合这条规则的文件。use
则是采用哪些loader
进行处理,loader
的处理顺序在数组中是从后往前。这里为了说明不同情况,把css-loader
写成了对象形式,目的是说明可以通过options
传入相关配置项。
exclude 与 include
从这个名称可以看出其作用,就是排除或包含制定目录下的模块,接收的是字符串(文件的绝对路径)或者是正则表达式。
rules: [
{
test: /\.css$/,
use: ["style-loader", "css-loader"],
exclude: /src\/lib/,
include: /src/,
},
];
以上面这个简单例子进行说明,排除/src/lib/
目录下的css
文件,只进行打包其他/src/
目录下的文件。并且由于exclude
相比于include
来说有更高的优先级,include
无法覆盖掉exclude
。
resource 与 issuer
这两个是用来更加精确地确定模块规则的作用范围。前面所说的test
、exclude
、include
本质上属于对resource
也就是被加载着对配置。
rules: [
{
use: ["style-loader", "css-loader"],
resource: {
test: "/.css$/",
exclude: /node_modules/,
},
issuer: {
test: "/.js$/",
exclude: /node_modules/,
},
},
];
表示只允许除了node_modules
目录以外的js
文件引入除了node_modules
目录以外的css
文件,并且采用上方的loader
形式进行打包。
enforce
制定loader
的种类,只接收“pre
”或“post
”。
rules: [
{
test: /\.js/,
enforce: "pre",
use: "eslint-loader",
},
];
表示它将在所有loader
之前执行,保证被检测的代码没有被其他loader
更改过。
小结
最后关于loader
还有几点想说明的,对于不同的资源,在社区中几乎都给出了相关的loader
进行解决,这里便不一一展示,可以在不同loader
的文档中查看实例代码。上面主要是用css-loader
进行演示,其他的还有诸如babel-loader
、file-loader
、ts-loader
、vue-loader
,甚至在不满足需求的情况下,可以自己写一个loader
,因为前面提到了可以通过loader
里面的options
获取配置项。