webpack|学习总结:知其原理,手写一个 mini-webpack
一、WHAT:webpack 是什么
webpack 是一个静态模块打包工具
,将我们项目中的每一个模块组合成一个或多个 bundles(包)。
与之相关的一些核心概念
(在 webpack.config.js 配置文件中进行配置):
- 入口(entry):打包的入口,告诉 webpack
从哪儿开始
- 输出(output):告诉 webpack
在哪里输出
它所创建的 bundle,以及如何命名
这些文件。主要输出文件的默认值是 ./dist/main.js,其他生成文件默认放置在 ./dist 文件夹中 - loader:webpack 只能理解 JavaScript 和 JSON 文件,这是 webpack 开箱可用的自带能力。但我们的项目中不止有这两种类型的文件,还有其他类型,要怎么处理呢?loader 就是 webpack 能够
处理其他类型的文件
的武器,可以将其他类型文件处理并转换为有效的模块,比如将 SASS 转换为 CSS,或者将 ES6 转换为 ES5。具体来说,loader 是一个函数
,负责将输入的源文本转换为特定的文本输出。它们既可以是异步的,也可以是同步的。具体配置时,loader 有2个属性:test 属性【识别哪些文件会被转换】和 use 属性【定义在进行转换时使用哪个 loader】。
const path = require('path');
module.exports = {
output: {
filename: 'my-first-webpack.bundle.js',
},
// loader 的配置规则要定义在 module.rules 中
module: {
rules: [{ test: /\.txt$/, use: 'raw-loader' }],
// 碰到「在 require()/import 语句中被解析为 '.txt' 的路径」时,在打包之前,先 use(使用) raw-loader 转换一下
},
};
- plugin:插件是用来
改变构建过程的行为
的,比如自动将静态资源上传到云、去掉输出中重复的文件、注入环境变量等。具体来说,插件是可以挂接到 webpack 更底层 API 的类的实例,想要使用一个插件,只需要 require 它,然后把它添加到 plugins 数组中。多数插件可以通过选项 (option) 自定义,也可以在一个配置文件中因为不同目的而多次使用同一个插件,这时需要通过使用 new 操作符来创建一个插件实例。
如果需要转换 Vue/React代码、SASS 或其他转译语言,就用loader。如果需要调整 JavaScript,或用某种方式处理文件,就用plugin。
const HtmlWebpackPlugin = require('html-webpack-plugin'); // 通过 npm 安装
const webpack = require('webpack'); // 用于访问内置插件
module.exports = {
module: {
rules: [{ test: /\.txt$/, use: 'raw-loader' }],
},
plugins: [new HtmlWebpackPlugin({ template: './src/index.html' })],
// html-webpack-plugin 可以生成一个 HTML 文件,并自动将生成的所有 bundle 注入到此文件中
};
二、WHY:为什么要用 webpack
工欲善其事,必先利其器。
当我们编写大型复杂的项目时,我们通过模块化将业务逻辑解耦, 那是否可以有一种方式,不仅可以让我们编写模块,而且还支持任何模块格式(至少在 ESM 之前),并且可以同时处理各种资源?
这就是我们使用 webpack 的原因,它可以打包 JavaScript 应用程序(支持 ESM 和 CommonJS),可以扩展为支持许多不同的静态资源。
三、HOW:webpack 是怎么工作的
知道了 WHAT & WHY 之后,我们最好奇的应该是 HOW? webpack 到底是如何工作的?不妨从一个简单的例子出发,先看看 webpack 将我们写的代码编译成了什么?因为不论是复杂的项目还是简单的一行代码,经过 webpack 编译打包之后的产出本质是一样的
,所以我们可以从最简单的情况开始,研究其打包产出的秘密。
1、一个简单的例子
src 目录下创建 index.js
:
const sayHello = require('./hello.js')
console.log(sayHello('慧嚯'))
hello.js
:
module.exports = function (name) {
return 'hello ' + name
}
在 index.html
中引入打包之后的文件::
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script type="text/javascript" src="../dist/bundle.js" charset="utf-8"></script>
</body>
</html>
执行打包命令之后,打开 dist/bundle.js
(指定的输出文件名):
/******/ (() => { // webpackBootstrap
/******/ var __webpack_modules__ = ({
/***/ "./src/hello.js":
/*!**********************!*\
!*** ./src/hello.js ***!
\**********************/
/***/ ((module) => {
eval("module.exports = function (name) {\n return 'hello ' + name\n}\n\n//# sourceURL=webpack://webpack/./src/hello.js?");
/***/ }),
/***/ "./src/index.js":
/*!**********************!*\
!*** ./src/index.js ***!
\**********************/
/***/ ((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => {
eval("const sayHello = __webpack_require__(/*! ./hello */ \"./src/hello.js\")\nconsole.log(sayHello('慧嚯'))\n\n//# sourceURL=webpack://webpack/./src/index.js?");
/***/ })
/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/************************************************************************/
/******/
/******/ // startup
/******/ // Load entry module and return exports
/******/ // This entry module can't be inlined because the eval devtool is used.
/******/ var __webpack_exports__ = __webpack_require__("./src/index.js");
/******/
/******/ })()
;
注释很多,我们把核心部分提取出来:
// 最外层是一个立即执行函数 IIFE
(() => { // webpackBootstrap
// 定义了一个 __webpack_modules__ 对象,对象以文件名为属性,属性值是对应的文件内容
var __webpack_modules__ = ({
"./src/hello.js": ((module) => {
eval("module.exports = function (name) {\n return 'hello ' + name\n}\n\n//# sourceURL=webpack://webpack/./src/hello.js?");
}),
"./src/index.js":
eval("const sayHello = __webpack_require__(/*! ./hello */ \"./src/hello.js\")\nconsole.log(sayHello('慧嚯'))\n\n//# sourceURL=webpack://webpack/./src/index.js?");
})
// The module cache
// 缓存模块
var __webpack_module_cache__ = {};
// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
// Create a new module (and put it into the cache)
var module = __webpack_module_cache__[moduleId] = {
// no module.id needed
// no module.loaded needed
exports: {}
};
// Execute the module function
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
// Return the exports of the module
return module.exports;
}
var __webpack_exports__ = __webpack_require__("./src/index.js");
})();
我们可以得出结论:
- webpack 打包结果就是一个
IIFE
,一般称它为 webpackBootstrap; - 打包结果中,定义了一个模块加载函数 webpack_require;
- 首先使用 webpack_require 加载函数去加载入口模块 ./src/index.js。
- 加载函数 webpack_require 使用了闭包变量 webpack_module_cache,将已加载过的模块结果缓存。
2、工作原理
简单总结为下图【来源于网络】:
- 首先,webpack 会读取项目中由开发者定义的
webpack.config.js
配置文件,或者从 shell 语句中获得必要的参数,完成配置读取。 - 接着,实例化所需 webpack 插件,在 webpack 事件流上挂载插件钩子,这样在合适的构建过程中,插件具备了改动产出结果的能力。
- 同时,根据配置所定义的入口文件,
以入口文件
(可以不止有一个)为起始,进行依赖收集
:对所有依赖的文件进行编译,这个编译过程依赖 loader,不同类型文件根据开发者定义的不同 loader 进行解析。编译好的内容解析生成 AST 静态语法树,分析文件依赖关系,用 webpack 自己的加载器进行模块化实现。 - 上述过程进行完毕后,产出结果,根据开发者的配置,将结果打包到相应目录。
一些核心概念
:
- AST:我们的老朋友了,抽象语法树,是 JS 对象,帮助我们进行代码分析。
- compiler :compiler 对象的实例包含了
完整的 webpack 配置
,全局只有一个 compiler 实例。当插件被实例化的时候,会收到一个 compiler 对象,通过这个对象可以访问 webpack 的内部环境
。 - compilation 对象:当 webpack 以开发模式运行时,每当检测到文件变化,一个新的 compilation 对象将被创建。这个对象包含了当前的模块资源、编译生成资源、变化的文件等信息。也就是说,
所有构建过程中产生的构建数据都存储在该对象上
,它掌控着构建过程中的每一个环节,该对象也提供了很多事件回调供插件做扩展。
四、不如自己实现一个 mini-webpack
首先我们安装几个要用到的包:
- @babel/parser:用于将输入代码解析成抽象语法树(AST)
- @babel/traverse:用于对输入的抽象语法树(AST)进行遍历
- @babel/core:babel 的核心模块,进行代码的转换
- @babel/preset-env:可根据配置的目标浏览器或者运行环境来自动将 ES6 + 的代码转换为 ES5
npm install @babel/parser @babel/traverse @babel/core @babel/preset-env -D
1、核心代码
首先我们需要一个配置文件:
// mini-webpack.config.js
const path = require('path')
module.exports = {
entry: './src/index.js',
mode: 'development',
output: {
path: path.resolve(__dirname, './dist'),
filename: 'bundle.js'
}
}
然后新建一个 bundle.js
,用来实现打包核心代码:
const options = require("./mini-webpack.config");
const fs = require("fs");
const path = require('path');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const babel = require("@babel/core");
// 创建一个 MiniWebpack 类
class MiniWebpack {
constructor(options) {
// 配置项
this.options = options;
}
// 解析入口文件
parse(filename) {
// 利用 Node 的核心模块 fs 读取文件
const fileBuffer = fs.readFileSync(filename, "utf-8");
// 基于 @babel/parser 解析文件得到 AST
const ast = parser.parse(fileBuffer, { sourceType: "module" });
const deps = {}; // 用来收集依赖
// 遍历 AST
traverse(ast, {
ImportDeclaration({ node }) {
const dirname = path.dirname(filename);
const absPath = "./" + path.join(dirname, node.source.value);
deps[node.source.value] = absPath;
},
});
// 代码转换
const { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"],
});
const moduleInfo = { filename, deps, code };
return moduleInfo;
}
// 收集模块依赖图
analyse(file) {
// 定义依赖图
const depsGraph = {};
// 首先获取入口的信息
const entry = this.parse(file);
const temp = [entry];
for (let i = 0; i < temp.length; i++) {
const item = temp[i];
const deps = item.deps;
if (deps) {
// 遍历模块的依赖,递归获取模块信息
for (const key in deps) {
if (deps.hasOwnProperty(key)) {
temp.push(this.parse(deps[key]));
}
}
}
}
temp.forEach((moduleInfo) => {
depsGraph[moduleInfo.filename] = {
deps: moduleInfo.deps,
code: moduleInfo.code,
};
});
return depsGraph;
}
// 生成最终执行的代码
generate(graph, entry) {
// 是一个立即执行函数
return `(function(graph){
function require(file) {
var exports = {};
function absRequire(relPath){
return require(graph[file].deps[relPath])
}
(function(require, exports, code){
eval(code)
})(absRequire, exports, graph[file].code)
return exports
}
require('${entry}')
})(${graph})`;
}
// 指定打包后的文件的目录
outputFile(output, code) {
const {path: dirPath, filename} = output;
const outputPath = path.join(dirPath, filename);
if(!fs.existsSync(dirPath)){
fs.mkdirSync(dirPath)
}
fs.writeFileSync(outputPath, code, 'utf-8')
}
// 将所有打包逻辑串起来
bundle(){
const {entry, output} = this.options
const graph = this.analyse(entry)
const graphStr = JSON.stringify(graph)
const code = this.generate(graphStr, entry)
this.outputFile(output, code)
}
}
// 实例化一个 MiniWebpack
const miniWebpack = new MiniWebpack(options)
// 运行打包逻辑
miniWebpack.bundle()
2、来个例子
整个目录如图所示:
在 src 目录下新建几个文件:
index.html
:
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script type="text/javascript" src="../dist/bundle.js" charset="utf-8"></script>
</body>
</html>
index.js
:
import minus from './minus.js'
import add from './add.js'
console.log('3-1 = >>>>>>', minus(3,1))
console.log('3+1 = >>>>>>', add(3,1))
minus.js
:
// minus.js
export default (a, b) => { return a - b }
add.js
:
// add.js
export default (a, b) => { return a + b }
通过 node bundle.js
命令运行打包逻辑,在 scripts
里面写为:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "node bundle.js"
},
执行 npm run build
:
OK!成功打包,生成了打包文件:dist/bundle.js
,在 index.html
中引入了这个文件,现在我们在浏览器打开 index.html
:
成功!🎉
五、实现一个简单的 loader
一个 loader 秉承单一职责
,完成最小单元的文件转换。一个源文件可能需要经历多步转换才能正常使用,比如 Sass 文件先通过 sass-loader 输出 CSS,之后将内容交给 css-loader 处理,甚至 css-loader 输出的内容还需要交给 style-loader 处理,转换成通过脚本加载的 JavaScript 代码。如下使用方式:
module.exports = {
// ...
module: {
rules: [
{
test: /\.less$/,
use: [
{
loader: "style-loader", // 通过 JS 字符串,创建 style node
},
{
loader: "css-loader", // 编译 css 使其符合 CommonJS 规范
},
{
loader: "less-loader", // 编译 less 为 css
},
],
},
],
},
};
当我们调用多个 loader 串联去转换一个文件时,每个 loader 会链式地顺序执行
。webpack 中,在同一文件存在多个匹配 loader 的情况下,遵循以下原则:
- loader 的执行顺序是和配置顺序
相反
的,即配置的最后一个 loader 最先执行,第一个 loader 最后执行。 - 第一个执行的 loader 接收源文件内容作为参数,其他 loader 接收前一个执行的 loader 的返回值作为参数。最后执行的 loader 会返回最终结果。
配置 loader 时,可以增加一些配置,比如:
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: "babel-loader",
options: {
plugins: ["dynamic-import-webpack"],
},
},
];
}
这里传入的 options 怎么获取呢?通过 loader-utils 模块:
const loaderUtils = require("loader-utils");
module.exports = function (source) {
// 获取开发者配置的 options
const options = loaderUtils.getOptions(this);
// ...
return content;
};
loader 本质上是个函数
,我们只关心它的输入输出即可。
比如我们想实现一个 replace-loader
,这个 loader 可以自定义替换我们配置的要进行替换的字符串,使用和配置方式为:
const path = require("path");
module.exports = {
// ...
module: {
rules: [
{
test: /\.js$/,
use: {
loader: "replaceLoader",
options: {
str: "let",
replaceStr: "const",
// 将 let 替换为 const
},
},
},
],
},
};
在 replaceLoader.js
中:
const loaderUtils = require("loader-utils");
module.exports = function (source) {
// 获取 options
const options = loaderUtils.getOptions(this);
return source.replace(options.str, options.replaceStr);
};
回到前面的例子:
let sayHello = require('./hello.js')
console.log(sayHello('慧嚯'))
打包之后:
loader 生效了~
六、实现一个简单的 plugin
webpack有事件流机制
,在 webpack 构建的生命周期
中,会触发许多事件。这时候,开发中注册的各种插件,便可以根据需要监听与自身相关的事件。捕获事件后,在合适的时机通过 webpack 提供的 API 去改变编译输出结果。
所以 loader 和 plugin 的区别很明显了:
- loader 其实就是一个
转换器
,执行单纯的文件转换操作。 - plugin 是一个
扩展器
,它丰富了 webpack 本身,在 loader 过程结束后,webpack 打包的整个过程中,weback plugin 并不直接操作文件,而是基于事件机制工作,监听 webpack 打包过程中的某些事件,见缝插针,修改打包结果。
webpack 插件是一个具有 apply 方法的 JavaScript 对象。apply 方法会被 webpack compiler 调用,并且在整个编译生命周期都可以访问 compiler 对象。
由于插件可以携带参数/选项,你必须在 webpack 配置中,向 plugins 属性传入一个 new 实例
。
所以我们实现的 plugin 应当是一个 class 或是 构造函数
。
我们来简单实现一个 HtmlWebpackPlugin
插件,打包之后生成一个 html 文件,并在这个文件中引入打包生成的 js 文件。
// my-html-webpack-plugin.js
const pluginName = "MyHtmlWebpackPlugin";
class MyHtmlWebpackPlugin {
apply(compiler) {
const filename = compiler.options.output.filename;
compiler.hooks.emit.tapAsync(pluginName, (compilation, callback) => {
const content = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Webpack</title>
<script defer src="./${filename}"></script>
</head>
<body>
</body>
</html>
`;
// 将这个文件作为一个新的文件资源,插入到 webpack 构建中:
compilation.assets["index.html"] = {
source: function () {
return content;
},
size: function () {
return content.length;
},
};
callback();
});
}
}
module.exports = MyHtmlWebpackPlugin;
在 webpack.config.js
中配置:
const path = require("path");
const MyHtmlWebpackPlugin = require('./my-html-webpack-plugin');
module.exports = {
mode: "development",
entry: {
main: "./src/index.js",
},
output: {
path: path.resolve(__dirname, "dist"),
filename: "bundle.js",
},
// ...
plugins: [new MyHtmlWebpackPlugin()],
};
执行打包命令:
成功!
转载自:https://juejin.cn/post/7041483613514235934