webpack核心流程解析与简单实现
时至今日,webpack仍然是最火和最稳定的前端打包构建工具。但是在平常的业务开发中我们很少接触到其内部原理,大多数仅仅停留在常用的配置层面,对webpack整个工作流程没有一个清晰的认知,所以本文实现一个简易的webpack,旨在了解webpack核心流程与思想。
Tapable
webpack内部使用了tapable.
- tapable 是一个类似于 Node.js 中的 EventEmitter 的库,但更专注于自定义事件的触发和处理
- webpack 通过 tapable 将实现与流程解耦,所有具体实现通过插件的形式存在
大致像这样
class SyncHook {
constructor() {
this.taps = [];
}
tap(name, fn) {
this.taps.push(fn);
}
call() {
this.taps.forEach((tap) => tap());
}
}
let hook = new SyncHook();
hook.tap("some name", () => {
console.log("some name");
});
class Plugin {
apply() {
hook.tap("Plugin", () => {
console.log("Plugin ");
});
}
}
new Plugin().apply();
hook.call();
webpack编译流程梳理
- 初始化参数:从配置文件和 Shell 语句中读取并合并参数,得出最终的配置对象
- 用上一步得到的参数初始化 Compiler 对象
- 加载所有配置的插件
- 执行对象的 run 方法开始执行编译
- 根据配置中的
entry
找出入口文件 - 从入口文件出发,调用所有配置的
Loader
对模块进行编译 - 再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
- 根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk
- 再把每个 Chunk 转换成一个单独的文件加入到输出列表
- 在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
在以上过程中,Webpack 会在特定的时间点广播出特定的事件,webpack插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果
具体流程实现
创建目录
src/entry1.js
const title = require('./title');
console.log('entry1', title);
src/entry2.js
const title = require('./title');
console.log('entry2', title);
src/title.js
module.exports = 'title';
debugger.js(最后执行这个文件来调用自己实现的webpack)
const webpack = require('./webpack');
const webpackConfig = require('./webpack.config');
//这是编译器对象代表整个编译过程
const compiler = webpack(webpackConfig);
//4.执行对象的 run 方法开始执行编译
compiler.run((err, stats) => {
console.log(err);
//stats是一个对象,记录了整个编译 过程 和产出的内容
console.log(
stats.toJson({
assets: true, //输出打包出来的文件或者说资源 main.js
chunks: true, //生成的代码块
modules: true, //打包的模块
})
);
});
webpack.config.js
const path = require('path');
const RunPlugin = require('./plugins/run-plugin');
const DonePlugin = require('./plugins/done-plugin');
module.exports = {
mode: 'production',
devtool: false,
entry: {
entry1: './src/entry1.js',
entry2: './src/entry2.js'
},
output: {
path: path.resolve('dist'),
filename: '[name].js'
},
resolve: {
extensions: ['.js', '.json']
},
module: {
rules: [
{
test: /.js$/,
use: [
path.resolve(__dirname, 'loaders/logger1-loader.js'),
path.resolve(__dirname, 'loaders/logger2-loader.js')
]
}
]
},
plugins: [
new RunPlugin(),//希望在编译开始的时候运行run插件
new DonePlugin()//在编译 结束的时候运行done插件
]
}
webpack.js
const Compiler = require('./Compiler');
function webpack(options) {
//1.初始化参数:从配置文件和 Shell 语句中读取并合并参数,得出最终的配置对象
const argv = process.argv.slice(2);
const shellOptions = argv.reduce((memo, options) => {
const [key, value] = options.split('=');
memo[key.slice(2)] = value;
return memo;
}, {});
const finalOptions = { ...options, ...shellOptions };
//2.用上一步得到的参数初始化 Compiler 对象
const compiler = new Compiler(finalOptions);
//3. 加载所有配置的插件
const { plugins = [] } = finalOptions;
for (const plugin of plugins) {
plugin.apply(compiler);
}
return compiler;
}
module.exports = webpack;
Compiler.js
const { SyncHook } = require('tapable');
const path = require('path');
const fs = require('fs');
const Compilation = require('./Complication');
const fileDependencySet = new Set();
class Compiler {
constructor(options) {
this.options = options;
this.hooks = {
run: new SyncHook(), //会在开始编译的时候触发
emit: new SyncHook(), // 输出 asset 到 output 目录之前执行 (写入文件之前)
done: new SyncHook(), //会在结束编译的时候触发
};
}
// 4.执行Compiler对象的run方法开始执行编译
run(callback) {
this.hooks.run.call();
// 5.根据配置中的entry找出入口文件
const onCompiled = (err, stats, fileDependencies) => {
const { assets } = stats;
// 在输出文件前调用emit钩子
this.hooks.emit.call();
for (const filename in assets) {
//10.在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
// 先判断目录是否存在
if (!fs.existsSync(this.options.output.path)) {
fs.mkdirSync(this.options.output.path);
}
let filePath = path.join(this.options.output.path, filename);
fs.writeFileSync(filePath, assets[filename], 'utf8');
}
callback(err, {
toJson: () => stats,
});
// 遍历依赖的文件,对这些文件进行监听,当这些文件发生变化后会重新开始一次新的编译
[...fileDependencies].forEach(fileDependency => {
if (!fileDependencySet.has(fileDependency)) {
fs.watch(fileDependency, () => this.compile(onCompiled));
fileDependencySet.add(fileDependency);
}
});
// 结束之后触发钩子
this.hooks.done.call();
};
// 调用this.compile方法开始真正的编译,编译成功后会执行onCompiled回调
this.compile(onCompiled);
}
// 每次调用compile方法,都会创建一个新的Compilation
compile(callback) {
const compilation = new Compilation(this.options);
//调用compilation的build方法开始编译
compilation.build(callback);
}
}
module.exports = Compiler;
Complication.js
const path = require('path');
const fs = require('fs');
const parser = require('@babel/parser');
const types = require('babel-types');
const traverse = require('@babel/traverse').default;
const generator = require('@babel/generator').default;
function normalizePath(path) {
//统一成linux的路径分隔符
return path.replace(/\\/g, '/');
}
const baseDir = normalizePath(process.cwd());
class Compilation {
constructor(options) {
this.options = options;
this.fileDependencies = new Set();
this.modules = []; //存放着本次编译生产所有的模块 所有的入口产出的模块
this.chunks = []; //代码块的数组
this.assets = {}; //产出的资源
}
//这个才是编译最核心的方法
build(callback) {
//5.根据配置中的entry找出入口文件
let entry = {};
if (typeof this.options.entry === 'string') {
entry.main = this.options.entry; //如果字符串,其实入口的名字叫main
} else {
entry = this.options.entry; //否则 就是一个对象
}
// 6.从入口文件出发,调用所有配置的Loader对模块进行编译
for (let entryName in entry) {
//找到入口文件的绝对路径
const entryFilePath = path.posix.join(baseDir, entry[entryName]);
this.fileDependencies.add(entryFilePath);
const entryModule = this.buildModule(entryName, entryFilePath);
// 8.根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk
const chunk = {
name: entryName, //代码块的名称就是入口的名字
entryModule, //入口模块
modules: this.modules.filter(module => module.names.includes(entryName)),
};
this.chunks.push(chunk);
}
//9.再把每个 chunk 转换成一个单独的文件加入到输出列表
this.chunks.forEach(chunk => {
const filename = this.options.output.filename.replace('[name]', chunk.name);
this.assets[filename] = getSource(chunk);
});
// 调用传入的回调函数
callback(
null,
{
chunks: this.chunks,
module: this.modules,
assets: this.assets,
},
this.fileDependencies
);
}
/**
* 编译模块
* @param {*} name 模块所属于代码块或者说入口的名称
* @param {*} modulePath 模块的绝对路径
*/
buildModule = (name, modulePath) => {
//读取模块的源代码
const sourceCode = fs.readFileSync(modulePath, 'utf8');
//读取配置的loader
const { rules } = this.options.module;
const loaders = [];
rules.forEach(rule => {
const { test } = rule;
if (modulePath.match(test)) {
loaders.push(...rule.use);
}
});
//使用配置的loader 对源码进行转换,得到最后的结果
const transformedSourceCode = loaders.reduceRight((sourceCode, loader) => {
return require(loader)(sourceCode);
}, sourceCode);
//当前模块的模块ID
const moduleId = './' + path.posix.relative(baseDir, modulePath);
//入口模块和它依赖的模块组成一个代码块,entry1.js title.js entry1的代码块chunk
//每个代码块会生成一个bundle,也就是一个文件entry1.js
//因为一个模块可能会属于多个入口,多个代码块,而模块不想重复编译的,所以一个模块的names对应于它的代码块名称的数组
const module = { id: moduleId, dependencies: [], names: [name] }; //names=['entry1']
const ast = parser.parse(transformedSourceCode, { sourceType: 'module' });
//7.再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
traverse(ast, {
CallExpression: ({ node }) => {
//说明这是要依赖或者说加载别的模块了
if (node.callee.name === 'require') {
//获取依赖模块的相对路径 wepback打包后不管什么模块,模块ID都是相对于根目录的相对路径
const depModuleName = node.arguments[0].value;
//先找到当前模块所在目录
const dirname = path.posix.dirname(modulePath);
//得到依赖的模块的绝对路径
const depModulePath = this.tryExtension(path.posix.join(dirname, depModuleName));
this.fileDependencies.add(depModulePath);
//模块ID不管是本地的还是第三方的,都会转成相对项目根目录的相对路径,而且是添加过后缀的
const depModuleId = './' + path.posix.relative(baseDir, depModulePath);
//修改ast语法树上的require节点
node.arguments = [types.stringLiteral(depModuleId)]; // ./title => ./src/title.js
//给当前的模块添加模块依赖
module.dependencies.push({ depModuleId, depModulePath });
}
},
});
//根据改造后语法树重新生成源代码
const { code } = generator(ast);
// module._source属性指向此模块改造后的源码
module._source = code;
//找到这个模块依赖的模块数组,循环编译这些依赖
module.dependencies.forEach(({ depModuleId, depModulePath }) => {
//先在已经编译好的模块数组中找一找有没有这个模块
const existModule = this.modules.find(module => module.id === depModuleId);
if (existModule) {
//如果已经编译过了,在名称数组添加当前的代码块的名字
existModule.names.push(name);
} else {
let depModule = this.buildModule(name, depModulePath);
this.modules.push(depModule);
}
});
return module;
};
tryExtension = modulePath => {
//如果文件存在,说明require模块的时候已经添加了后缀了,直接返回
if (fs.existsSync(modulePath)) {
return modulePath;
}
let extensions = ['.js'];
if (this.options.resolve && this.options.resolve.extensions) {
extensions = this.options.resolve.extensions;
}
for (let i = 0; i < extensions.length; i++) {
let filePath = modulePath + extensions[i];
if (fs.existsSync(filePath)) {
return filePath;
}
}
throw new Error(`${modulePath}未找到`);
};
}
function getSource(chunk) {
return `
(() => {
var modules = ({
${chunk.modules.map(
module => `
"${module.id}":(module,exports,require)=>{
${module._source}
}
`)}
});
var cache = {};
function require(moduleId) {
var cachedModule = cache[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
var module = cache[moduleId] = {
exports: {}
};
modules[moduleId](module, module.exports, require);
return module.exports;
}
var exports = {};
${chunk.entryModule._source}
})()
`;
}
module.exports = Compilation;
plugins/run-plugin.js
webpack
插件都是一个类(类本质上都是funciton的语法糖),每个插件都必须存在一个apply
方法
class RunPlugin {
apply(compiler) {
compiler.hooks.run.tap('RunPlugin', () => {
console.log('RunPlugin');
});
}
}
module.exports = RunPlugin;
plugins/done-plugin.js
class DonePlugin {
apply(compiler) {
compiler.hooks.done.tap('DonePlugin', () => {
console.log('DonePlugin');
});
}
}
module.exports = DonePlugin;
loaders/logger1-loader.js
loader
本质上就是一个函数,接受我们的源代码作为入参同时返回处理后的结果
function loader(source) {
return source + '//logger1';
}
module.exports = loader;
loaders/logger2-loader.js
function loader(source) {
return source + '//logger2';
}
module.exports = loader;
测试打包结果
最后执行 debugger.js
node debugger.js
可以看到生成了dist目录,且下面有entry1.js和entry2.js两个文件
entry1.js
(() => {
var modules = {
'./src/title.js': (module, exports, require) => {
module.exports = 'title'; //logger2//logger1
},
};
var cache = {};
function require(moduleId) {
var cachedModule = cache[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
var module = (cache[moduleId] = {
exports: {},
});
modules[moduleId](module, module.exports, require);
return module.exports;
}
var exports = {};
const title = require('./src/title.js');
console.log('entry1', title); //logger2//logger1
})();
执行entry1.js
node entry1.js
可以看到打包后的结果正确,至此webpack的大致流程就完成了!当然了,这里只是最简单的实现,webpack远比想象中的复杂得多,不过大致运行原理和思想都是相通的。
转载自:https://juejin.cn/post/7113471451478360071