如果对前端八股文感兴趣,可以留意公重号:码农补给站,总有你要的干货。
前言
Webpack
是一个强大的打包工具,拥有灵活、丰富的插件机制,网上关于如何使用Webpack
及Webpack
原理分析的技术文档层出不穷。最近自己也是发现面试官问到Webpack
特别喜欢问构建流程,那么本文主要探讨,Webpack
的一次构建流程中,主要干了哪些事儿,带领您手写一个打包工具。
真是卷...
本文主要讲的是基本的构建和输出打包,不包含treeshaking、热更新等其他功能的内容。
基本架构
构建流程
准备阶段
从配置文件中读取到配置参数,传入配置参数实例化一个Compiler
编译器,执行编译器的run
方法开始编译。
const path = require('path');
const Compiler = require('../lib/Compiler.js');
let config = require(path.resolve('webpack.config.js')); // 从webpack.config.js中获取配置
let compiler = new Compiler(config); // 实例化一个Compiler编译器
compiler.run(); // 执行编译器的run方法
开始编译
class Compiler {
constructor(config) {
this.config = config; // 配置文件
this.entryId; // 入口文件名字
this.modules = {}; // 依赖模块的集合
this.entry = config.entry; // 入口路径
this.root = process.cwd();
}
run() {
this.buildModule(path.resolve(this.root, this.entry), true);
}
}
Compiler
初始化阶段就存储了配置文件config
、入口路径entry
、根路径root
,定义了依赖模块的集合modules
和入口文件名entryId
。其中后续我们解析到的所有模块内容都会存储在modules
。
run
方法从配置中获取入口文件,从入口文件开始buildModule
buildModule(modulePath, isEntry) { // modulePath 模块路径 isEntry是否是入口文件
// 拿到模块内容
let source = this.getSource(modulePath);
let moduleName = './' + path.relative(this.root, modulePath); // src/index.js
if (isEntry) {
// 如果是入口文件获取入口文件名
this.entryId = moduleName;
}
// 开始解析文件依赖
const { sourceCode, dependencies } = this.parse(source, path.dirname(moduleName));
this.modules[moduleName] = sourceCode;
dependencies.forEach(dep => { // 递归加载模块
this.buildModule(path.join(this.root, dep), false);
})
}
parse(source, parentPath) { // 解析源码返回依赖列表 parentPath ./src
// 解析源码获取ast语法树
let ast = babylon.parse(source);
let dependencies = [];
// 解析ast语法树获取关联的依赖
traverse(ast, {
CallExpression(p) {
let node = p.node;
if (node.callee.name === 'require') {
node.callee.name = '__webpack_require__';
let moduleName = node.arguments[0].value; // 取到模块的引用名字
moduleName = moduleName + (path.extname(moduleName) ? '' : '.js');
moduleName = './' + path.join(parentPath, moduleName);
dependencies.push(moduleName);
node.arguments = [t.stringLiteral(moduleName)];
}
}
})
let sourceCode = generator(ast).code;
return {
sourceCode, // 源码
dependencies // 关联的依赖
};
}
过程如下: buildModule
中接收两个参数modulePath
模块路径、isEntry
是否是入口文件。拿到模块文件中内容,并获取入口文件名称。 parse
中也是接收两个参数source
文件内容,以及父路径parentPath
。将文件内容通过babylon
插件解析成AST
语法树,然后通过@babel/traverse
解析语法树获取其关联的依赖文件。递归解析依赖文件将所有模块都存入modules
中。
打包输出
run() {
this.buildModule(path.resolve(this.root, this.entry), true);
// 发射一个文件
this.emitFile();
}
emitFile() { // 发射一个文件
// 从配置文件中获取打包输入路径和文件名
let main = path.join(this.config.output.path, this.config.output.filename);
// 获取模板
let templateStr = this.getSource(path.join(__dirname, 'main.ejs'));
let code = ejs.render(templateStr, { entryId: this.entryId, modules: this.modules });
this.assets = {};
this.assets[main] = code;
fs.writeFileSync(main, this.assets[main]);
}
再此之间前我们的文件都已经解析好了存在modules
中。从入口文件获取打包输出的文件路径和文件名,然后获取一个打包输出的文件模板,文件模板是要一个.ejs
文件。
// main.ejs
(() => {
var __webpack_modules__ = ({
<%for(let key in modules){%>
"<%-key%>":
((module, exports, __webpack_require__) => {
eval(`<%-modules[key]%>`);
}),
<%}%>
});
var __webpack_module_cache__ = {};
function __webpack_require__(moduleId) {
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
var module = __webpack_module_cache__[moduleId] = {
exports: {}
};
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
return module.exports;
}
var __webpack_exports__ = __webpack_require__("<%-entryId%>");
})()
;
文件模板中我们可以看到,其实里面是一个自我执行函数,从入口<%-entryId%>
开始依次从modules
中获取文件代码内容,并执行。
最终生成assets
,将每个assets
打包到指定位置。
loader
loader
本质上是一个函数,参数content
是一段字符串,存储着文件的内容,最后将loader
函数导出就可以提供给webpack
使用。
我们来实现一个less-loader
和style-loader
:
// less-loader
const less = require('less'); // npm install less -D
function loader(content) {
let css = '';
less.render(content, (err, c) => {
css= c.css;
})
return css;
}
module.exports = loader;
// style-loader
function loader(content) {
let style = `
let style = document.createElement('style');
style.innerHTML = ${JSON.stringify(content)}
document.head.appendChild(style)
`;
return style;
}
module.exports = loader;
所以我们编译阶段获取文件内容的时候就需要匹配文件名来判断是否需要使用该loader
getSource(modulePath) {
// 获取我们配置的rules
let rules = this.config.module.rules || [];
// 获取到指定路径的文件内容
let content = fs.readFileSync(modulePath, 'utf-8');
// 循环匹配,拿到每个规则来处理
for (let i = 0; i < rules.length; i++) {
let rule = rules[i];
let { test, use = [] } = rule;
let len = use.length - 1;
// test正则匹配文件路径
if (test.test(modulePath)) {
function normalLoader() {
let loader = require(use[len--]);
if (loader) {
content = loader(content);
}
if (len >= 0) {
normalLoader();
}
}
normalLoader();
}
}
return content;
}
总结
- 初始化参数。获取用户在
webpack.config.js
文件配置的参数 - 开始编译。初始化
Compiler
对象,执行run
方法开始编译。 - 从入口文件出发,获取文件内容,如果配置了
loader
就匹配对应的loader
来改变文件内容,开始解析文件构建AST
语法树,找到依赖项,递归下去,并且将每个模块存储下来。 - 完成编译并输出。递归结束,得到每个文件结果,包含转换后的模块以及他们之前的依赖关系,根据
entry
以及output
等配置生成代码块chunk
。 - 输出文件。
原文链接:https://juejin.cn/post/7298927442488197157