предисловие
Леса, которые использовались при разработке проекта Vue, я очень мало знаю о черном ящике Webpack, и я всегда смущаюсь, когда сталкиваюсь с проблемами, поэтому я изучил его более полно, когда вскоре был выпущен Webpack 5.0. В этой статье подводятся итоги обучения. Общий план выглядит следующим образом: Эта статья является расширенной главой. Чтобы ознакомиться с основной главой, щелкните портал.Сводка по обучению Webpack5.0 — Основы.
Шпионить по принципу вебпака
Как разработать загрузчик
Загрузчик, по сути, является функцией, его роль заключается в некоторой обработке и выводе содержимого соответствующего исходного файла. Когда правило обрабатывается несколькими загрузчиками, оно будет выполняться в порядке снизу вверх, а содержимое, полученное на следующем шаге, является содержимым, обработанным на предыдущем шаге. Это можно понимать как цепной вызов. Поэтому при разработке загрузчика важнее всего заботиться о его вводе и выводе.
Ниже приведено пошаговое введение в процесс разработки загрузчика с примером.
- Внесите свой загрузчик в конфигурационный файл webpack и используйте его в правиле.
- Напишите собственный загрузчик.
- Сравните различия в файле комплекта (main.js) до и после использования загрузчика, чтобы проверить эффект загрузчика.
В первую очередь понятно, какой функции хочет добиться написанный ниже загрузчик. В данном примере функция удаления js-комментариев реализована просто для знакомства с процессом написания загрузчика.
1. Внесите загрузчик в конфигурационный файл
Введите загрузчик в webpack.config.js, здесь приведено описание resolveLoader, который используется для настройки пути поиска загрузчика.Если resolveLoader не настроен, параметр загрузчика в правилах необходимо заполнить полным путем к файлу загрузчика.
// webpack.config.js
const path = require("path");
module.exports = {
mode: "none", //mode设置为none,不启用任何默认配置,防止Webpack自动处理干扰loader效果。
/* 解析loader的规则 */
resolveLoader: {
// loader查找路径,默认是node_modules,所以我们平常写loader(如babel-loader)时实际都会去node_modules里找
modules: ["node_modules", path.resolve(__dirname, "loaders")], // 增加查找路径。顺序是从前往后
},
module: {
rules: [
{
test: /\.js$/,
// 因为配置了resolveLoader,在loaders文件夹下找到了myLoader
loader: "myLoader",
options:{
oneLine: true, // 是否删除单行注释
multiline: true, // 是否删除多行注释
}
}
]
},
}
2. Напишите собственный загрузчик
// myLoader.js
module.exports = function (source) {
// Webpack5.0开始,不在需要使用工具获取option了
// 获取到webpack.config.js中配置的options
let options = this.getOptions();
let result = source;
// 默认单行和多行注释都删除
const defaultOption = {
oneLine: true,
multiline: true,
}
options = Object.assign({}, defaultOption, options);
if (options.oneLine) {
// 去除单行注释
result = result.replace(/\/\/.*/g, "")
}
if (options.multiline) {
// 去除多行注释
result = result.replace(/\/\*.*?\*\//g, "")
}
// loader必须要有输出,否则Webpack构建报错
return result
}
3. Сравните пакеты, которые упакованы и выведены, чтобы проверить действие загрузчика.
Чтобы сделать сравнение ясным и лаконичным, содержимое исходного кода index.js очень простое.
- исходный код
// index.js
/* 增加多行注释,用于测试 */
const x = 100;
let y = x; // 行内单行测试
// 单行注释测试
console.log(y);
- В выходном файле, когда загрузчик не используется, видно, что комментарии в исходном коде сохранены.
// main.js
/******/ (function() { // webpackBootstrap
var __webpack_exports__ = {};
/* 增加多行注释,用于测试 */
const x = 100;
let y = x; // 行内单行测试
// 单行注释测试
console.log(y);
/******/ })()
;
- В выходном файле при использовании загрузчика видно, что комментарии в исходниках удалены, и загрузчик вступает в силу.
// main.js
/******/ (function() { // webpackBootstrap
var __webpack_exports__ = {};
const x = 100;
let y = x;
console.log(y);
/******/ })()
;
Выше приведен основной процесс написания загрузчика, и есть несколько дополнительных пояснений:
- Проверка параметров параметров: вы можете использовать стороннюю библиотеку schema-utils для проверки параметров, заданных параметрами.
- Синхронный и асинхронный: Загрузчики делятся на синхронный загрузчик и асинхронный загрузчик, о синхронном загрузчике написано выше. В некоторых сценариях может потребоваться использование асинхронных загрузчиков. Следующим образом:
module.exports = function (source) {
// 生成一个异步回调函数。
const callback = this.async();
setTimeout(() => {
// 回调函数的第一个参数是错误信息,第二个参数为输出结果,第三个参数是source-map
callback(null, source);
}, 1000);
};
- При разработке загрузчика постарайтесь возложить на него единственную ответственность. То есть загрузчик выполняет только одну задачу. Это упрощает обслуживание и повторное использование загрузчика в большем количестве сценариев.
Как разработать плагин
Процесс упаковки Webpack похож на конвейер продукта, выполняя шаг за шагом шаг за шагом. Плагины — это дополнительные функции, вставляемые на различных этапах конвейера, и Webpack использует их для расширения своей функциональности.
Прежде чем представить пример, необходимо кратко понять, как плагин точно вставляется на разных этапах упаковки Webpack. оно используетTapableУтилита, компилятор и классы компиляции являются наследниками класса Tapable.
Введение в Tapable
Использование Tapable похоже на модель публикации-подписки.Разные плагины могут подписываться на одно и то же событие.Когда Webpack выполняет событие, оно будет распространяться на каждый зарегистрированный плагин. Tapable предоставляет множество типов хуков, которые можно разделить на синхронные и асинхронные, и методы их регистрации различаются. Синхронные хуки регистрируются через tap, а асинхронные хуки регистрируются через tapAsync или tapPromise. Разница между ними заключается в том, что первый использует функции обратного вызова, а второй использует Promise.
Сам Tapable также подразделяется на множество типов, таких как хуки типа Bail, которые могут завершать вызов таких зарегистрированных событий (если хук Bail зарегистрировал событие с возвратом, другие зарегистрированные события не будут выполняться), которые не будут расширяться. здесь. Давайте посмотрим на использование хуков Tapable на примере чтения файла.
const { SyncHook, AsyncSeriesHook } = require("tapable");
const fs = require("fs");
// 钩子存放容器
const hooks = {
beforeRead: new SyncHook(["param"]), // 同步钩子,数组代表注册时,回调函数的参数。
afterRead: new AsyncSeriesHook(["param"]) // 异步顺序执行钩子
}
// 订阅beforeRead
hooks.beforeRead.tap("name", (param) => {
console.log(param, "beforeRead执行触发回调");
})
// 订阅afterRead
hooks.afterRead.tapAsync("name", (param, callback) => {
console.log(param, "afterRead执行触发回调");
setTimeout(() => {
// 回调执行完毕
callback()
}, 1000);
})
// 读取文件前调用beforeRead,注册事件按照注册顺序同步执行
hooks.beforeRead.call("开始读取")
fs.readFile("package.json", ((err, data) => {
if (err) {
throw new Error(err)
}
// 读取文件后执行afterRead钩子
hooks.afterRead.callAsync(data, () => {
// 所有注册事件执行完后调用,类似Promise.all
console.log("afterRead end~");
})
}))
На двух этапах чтения файла выполняется соответствующий хук, и уведомление рассылается всем зарегистрированным событиям во время выполнения. После выполнения выполните следующие шаги.
Написание пользовательских плагинов
Плагин, по сути, является конструктором, и у его прототипа должен быть метод применения. После того, как Webpack инициализирует объект компилятора, вызывается метод применения экземпляра плагина, передавая объект компилятора. Затем плагин может зарегистрировать хуки, которые он хочет зарегистрировать, на компиляторе, и Webpack вызовет событие регистрации, когда выполнение достигнет соответствующей стадии. Далее показан этот процесс на двух простых примерах плагинов.
Плагин 1: Удалить файлы в выходной папке
Он имитирует плагин CleanWebpackPlugin, но не удаляет папки, потому что Node может удалять только пустые папки, а для полной реализации функции CleanWebpackPlugin требуется рекурсия, здесь мы фокусируемся на процессе написания плагина, поэтому он упрощен до удаления только файлов .
// RmFilePlugin.js
const path = require("path");
const fs = require("fs");
class RmFilePlugin {
constructor(options = {}) {
// 插件的options
this.options = options;
}
// Webpack会自动调用插件的apply方法,并给这个方法传入compiler参数
apply(compiler) {
// 拿到webpack的所有配置
const webpackOptions = compiler.options;
// context为Webpack的执行环境(执行文件夹路径)
const { context } = webpackOptions
// 在compiler对象的beforeRun钩子上注册事件
compiler.hooks.beforeRun.tap("RmFilePlugin", (compiler) => {
// 获取打包输出路径
const outputPath = webpackOptions.output.path || path.resolve(context, "dist");
const fileList = fs.readdirSync(outputPath, { withFileTypes: true });
fileList.forEach(item => {
// 只删除文件,不对文件夹做递归删除,简化逻辑
if (item.isFile()) {
const delPath = path.resolve(outputPath, item.name)
fs.unlinkSync(delPath);
}
})
});
}
};
// 导出 Plugin
module.exports = RmFilePlugin;
Этот пример очень простой, используется только объект компилятора В процессе собственно разработки плагина в большинстве случаев необходимо использовать объект компиляции, так в чем же разница между ним и компилятором?
- Лично компилятор представляет собой весь жизненный цикл Webpack от запуска до завершения работы, хуки на нем основаны на самом запуске Webpack, например, готова ли среда упаковки, начал ли он компилироваться и т. д. Компиляция фокусируется на фазе компиляции, и ее ловушки существуют в различных деталях компиляции, таких как загрузка модуля, оптимизация, фрагментация и т. д.
В следующем примере используется объект компиляции
Плагин 2: удалить комментарии js
Функция этого плагина была реализована в загрузчике выше, и она реализована снова в плагине, чтобы показать, что плагин может делать то, что может делать загрузчик, и плагин может делать это более тщательно.
// DelCommentPlugin.js
const { sources } = require('webpack');
class DelCommentPlugin {
constructor(options) {
this.options = options
}
apply(compiler) {
// compilation 创建之后执行注册事件
compiler.hooks.compilation.tap("DelCommentPlugin", (compilation) => {
// 处理asset
compilation.hooks.processAssets.tap(
{
name: 'DelCommentPlugin', //插件名称
//要对asset做哪种类型的处理,这里的填值代表的是对asset 进行了基础预处理
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_PRE_PROCESS,
},
(assets) => {
for (const name in assets) {
// 只对js资产做处理
if (name.endsWith(".js")) {
if (Object.hasOwnProperty.call(assets, name)) {
const asset = compilation.getAsset(name); // 通过asset名称获取到asset
const contents = asset.source.source(); // 获取到asset的内容
const result = contents.replace(/\/\/.*/g, "").replace(/\/\*.*?\*\//g, "");//删除注释
// 更新asset的内容
compilation.updateAsset(
name,
new sources.RawSource(result)
);
}
}
}
}
);
})
}
}
module.exports = DelCommentPlugin
Как и в случае с загрузчиком, сравните вывод после использования этого плагина.
// main.js
(function() {
var __webpack_exports__ = {};
const x = 100;
let y = x;
console.log(y);
})()
;
Очевидно, что с удалением комментариев проблем нет, и как видите, он удаляет все комментарии в файле main.js, в то время как загрузчик может удалять только комментарии в исходном коде. Плагин может напрямую изменить содержимое конечного выходного пакета.
Написанный вручную простой Webpack
Webpack — это приложение Node, поэтому, по сути, он запускает (большой кусок) кода js в среде Node, который выглядит следующим образом.
// built.js
const myWebpack = require("../lib/myWebpack");
// 引入自定义配置
const config = require("../config/webpack.config.js");
const compiler = myWebpack(config);
// 开始webpack打包
compiler.run();
Передайте конфигурацию конфигурации в функцию myWebpack, затем создайте объект компилятора и выполните его метод запуска. Метод запуска фокусируется на двух вещах: во-первых, найти и записать все зависимости в соответствии с входным файлом, а во-вторых, использовать строку для сборки конечной функции ограничения вывода.Основная функция этой функции — реализовать требование и экспортировать функции в соответствии с зависимостями. Давайте проанализируем код в соответствии с этими двумя шагами:
Проанализируйте таблицу зависимостей в соответствии с входным файлом
// myWebpack.js
const fs = require("fs");
const path = require("path");
const babelParser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const { transformFromAstSync } = require("@babel/core")
// Compiler构造函数
class Compiler {
constructor(options = {}) {
this.options = options; // 获得webpack配置
this.entry = this.options.entry || "./src/index.js" // 获取入口文件,不存在则使用默认值
this.entryDir = path.dirname(this.entry)
this.depsGraph = {}; //依赖关系表,第一步的产出
}
// 启动webpack打包
async run() {
const { entry, entryDir } = this
// 从入口文件开始获取模块信息
this.getModuleInfo(entry, entryDir);
console.log(this.depsGraph);
// 获取到模块信息后生成构建内容,第二步的内容,先注释。
// this.outputBuild()
}
// 根据文件路径获取模块信息
getModuleInfo(modulePath, dirname) {
const { depsGraph } = this
/*
利用fs模块和文件路径可以读取到文件内容,然后根据文件内容(import和export)又可以分析出模块之间的依赖关系。
自己去做这步是没有任何问题的。只是这里为了方便,就利用babelParser库生成一个抽象的模型ast(抽象语法树)。
ast将我们的代码抽象出来,方便我们操作。
*/
const ast = getAst(modulePath);
// 利用ast和traverse库获得该模块的依赖。原理就是分析了代码中的"import"语句。
const deps = getDeps(ast, dirname);
// 利用ast和babel/core将源代通过babel编码输出。如果不用ast也可以直接使用babel/core的transform方法将源代码转码
const code = getParseCode(ast)
// depsGraph保存的模块信息就是code源代码和它的依赖关系
depsGraph[modulePath] = {
deps,
code
}
// 如果该模块存在依赖deps,就通过递归继续找出它下面的依赖,这样循环就找出了入口文件开始的所有依赖。
if (Object.keys(deps).length) {
for (const key in deps) {
if (Object.hasOwnProperty.call(deps, key)) {
// 递归获取模块信息
this.getModuleInfo(deps[key], dirname)
}
}
}
}
}
// getModuleInfo中用到的三个工具函数
// 根据文件路径获取抽象语法树
const getAst = (modulePath) => {
const file = fs.readFileSync(modulePath, "utf-8");
// 2. 将其解析成ast抽象语法树
const ast = babelParser.parse(file, {
sourceType: "module", // 要解析的是 es6 module(默认为commonJs)
});
return ast
};
// 根据抽象语法树ast获取依赖关系
const getDeps = (ast, dirname) => {
// 该模块依赖合集
const dependSet = {
}
// 利用traverse这个库收集依赖,自己收集也可以,不管是抽象语法树还是源代码中都是可以拿到依赖关系的。现成的库比较方便
traverse(ast, {
// 内部遍历ast中的program.body,判断里面语句类型
// 如果type为ImportDeclaration 就会触发当前函数
ImportDeclaration({ node }) {
const relativePath = node.source.value //import文件的相对路径
const absolutePath = path.resolve(dirname, relativePath)
dependSet[relativePath] = absolutePath // 依赖中记录文件的绝对路径
}
})
return dependSet
};
// 根据抽象语法树,获取最终输出代码
const getParseCode = (ast) => {
// 编译代码,将现代浏览器不能识别的语法进行编译处理
// @babel/core可以直接将ast抽象语法树编译成兼容代码
/* 编译完成,可输出 */
const { code } = transformFromAstSync(ast, null, {
presets: ["@babel/preset-env"]
})
return code
}
// 该模块要输出的myWebpack函数
const myWebpack = (config) => {
return new Compiler(config);
};
module.exports = myWebpack;
Если вы запустите приведенный выше файл build.js сейчас, он распечатает таблицу зависимостей, которая выглядит следующим образом.
depsGraph = {
'./src/index.js': {
deps: {
'./add.js': 'E:\\study\\JavaScript\\webpack\\bWebpack\\principle\\myWebpack2\\src\\add.js',
'./sub.js': 'E:\\study\\JavaScript\\webpack\\bWebpack\\principle\\myWebpack2\\src\\sub.js'
},
code: '"use strict";\n' +
'\n' +
'var _add = _interopRequireDefault(require("./add.js"));\n' +
'\n' +
'var _sub = _interopRequireDefault(require("./sub.js"));\n' +
'\n' +
'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n' +
'\n' +
'console.log((0, _add.default)(1, 2));\n' +
'console.log((0, _sub.default)(3, 1));'
},
'E:\\study\\JavaScript\\webpack\\bWebpack\\principle\\myWebpack2\\src\\add.js': {
deps: {},
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports.default = _default;\n' +
'\n' +
'function _default(x, y) {\n' +
' return x + y;\n' +
'}'
},
'E:\\study\\JavaScript\\webpack\\bWebpack\\principle\\myWebpack2\\src\\sub.js': {
deps: {},
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports.default = _default;\n' +
'\n' +
'function _default(x, y) {\n' +
' return x - y;\n' +
'}'
}
}
Второй шаг — закрыть таблицу по зависимостям и вывести окончательный файл бандла.
Соберите функцию вывода
Если выходная функция собрана непосредственно со строками, это может быть немного сложно понять. Итак, сначала реализуйте функцию, которую вы хотите вывести, в js. Эта функция принимает таблицу зависимостей в качестве параметра и реализует функции require и export внутри, поскольку правила CommonJs используются в выводе кода после транскодирования babel.
(function (depsGraph) {
// 为了加载入口文件
function require(module) {
// 定义模块内部的require函数
function localRequire(relativePath) {
// 为了找到要引入模块的绝对路径,通过require加载
return require(depsGraph[module].deps[relativePath])
};
// 定义暴露对象
var exports = {};
/*
模块内部要自定义localRequire,而不是直接用require函数,原因是使用babell转化后的code,require传参时使用的是
相对路径,而我们内部依赖表中,是根据绝对路径找到code,所以要实现一层转化
*/
(function (require, exports, code) {
// code是字符串,用eval执行
eval(code)
})(localRequire, exports, depsGraph[module].code);
// 作为require函数的返回值返回出去
// 后面的require函数能得到暴露的内容
return exports;
}
// 加载入口文件
require("./src/index.js")
})(depsGraph);
Это пакет, который должен быть выведен в конце.Если вы возьмете таблицу зависимостей, полученную на первом шаге, и выполните эту функцию напрямую, вы можете добиться того же эффекта, что и выполнение исходного кода. Последнее, что нужно сделать, это собрать эту функцию из строки в myWebpack.js. Ниже приведен полный код в myWebpack.js.
полный исходный код myWebpack
const fs = require("fs");
const path = require("path");
const babelParser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const { transformFromAstSync } = require("@babel/core")
const myWebpack = (config) => {
return new Compiler(config);
};
// Compiler构造函数
class Compiler {
constructor(options = {}) {
this.options = options; // 获得webpack配置
this.entry = this.options.entry || "./src/index.js" // 获取入口文件,不存在则使用默认值
this.entryDir = path.dirname(this.entry)
this.depsGraph = {}; //依赖关系表,第一步的产出
}
// 启动webpack打包
async run() {
const { entry, entryDir } = this
// 从入口文件开始获取模块信息
this.getModuleInfo(entry, entryDir);
// 获取到模块信息后生成构建内容
this.outputBuild()
}
// 根据文件路径获取模块信息
getModuleInfo(modulePath, dirname) {
const { depsGraph } = this
const ast = getAst(modulePath);
const deps = getDeps(ast, dirname);
const code = getParseCode(ast)
// depsGraph保存的模块信息就是code源代码和它的依赖关系
depsGraph[modulePath] = {
deps,
code
}
// 如果该模块存在依赖deps,就通过递归继续找出它下面的依赖,这样循环就找出了入口文件开始的所有依赖。
if (Object.keys(deps).length) {
for (const key in deps) {
if (Object.hasOwnProperty.call(deps, key)) {
// 递归获取模块信息
this.getModuleInfo(deps[key], dirname)
}
}
}
}
// 最后一步,利用fs输出js文件
outputBuild() {
const build = `(function (depsGraph) {
function require(module) {
function localRequire(relativePath) {
// 为了找到要引入模块的绝对路径,通过require加载
return require(depsGraph[module].deps[relativePath])
};
// 定义暴露对象
var exports = {};
(function (require, exports, code) {
// code是字符串,要eval执行
eval(code)
})(localRequire, exports, depsGraph[module].code);
return exports;
}
require("${this.options.entry}")
})((${JSON.stringify(this.depsGraph)}))`;
let outputPath = path.resolve(this.options.output.path, this.options.output.filename)
fs.writeFileSync(outputPath, build, "utf-8")
}
}
// 根据文件路径获取抽象语法树
const getAst = (modulePath) => {
// 1.读取入口文件内容
/* 第二个参数如果不写,默认返回Buffer数据,如果写了utf-8解码,则返回字符串数据 */
// 注意:从这个入口文件读取可以看出来,node针对的所有相对路径,都是根据运行环境来的,在这里就是package.json目录,
// 即myWebpack目录
const module = fs.readFileSync(modulePath, "utf-8");
// 2. 将其解析成ast抽象语法树
const ast = babelParser.parse(module, {
sourceType: "module", // 要解析的是 es6 module(默认为commonJs)
});
return ast
};
// 根据抽象语法树ast获取依赖关系
const getDeps = (ast, dirname) => {
// 依赖合集
const dependSet = {
}
// 利用traverse这个库收集依赖,自己收集其实也可以,不管是抽象语法树还是import源代码中都是可以拿到依赖关系的。现成的库比较方便
traverse(ast, {
// 内部遍历ast中的program.body,判断里面语句类型
// 如果type:ImportDeclaration 就会触发当前函数
ImportDeclaration({ node }) {
// 模块相对路径"./add.js"
const relativePath = node.source.value
const absolutePath = path.resolve(dirname, relativePath)
dependSet[relativePath] = absolutePath
}
})
return dependSet
};
// 根据抽象语法树,获取最终输出代码
const getParseCode = (ast) => {
// 编译代码,将现代浏览器不能识别的语法进行编译处理
// @babel/core可以直接将ast抽象语法树编译成兼容代码
/* 编译完成,可输出 */
const { code } = transformFromAstSync(ast, null, {
presets: ["@babel/preset-env"]
})
return code
}
module.exports = myWebpack;
Эпилог
Код, необходимый для написания полного веб-пакета, очень велик, и приведенное выше является лишь его простейшей общей архитектурой. Но даже в этом случае я все еще чувствую, что это сложнее, чем делать изложение основ, и могут быть некоторые ошибки. Добро пожаловать, чтобы исправить меня!