webpack构建流程分析
webpack的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:首先会从配置文件和shell语句中读取与合并参数,并初始化需要使用的插件和配置插件等执行环境所需要的参数;初始化完成后会调用Comiler的run来真正启动webpack编译构建过程,webpack的构建流程包括compile,make,build,seal,emit阶段,执行完这些阶段就完成了构建过程
初始化
entry-options启动
从配置文件和Shell语句中读取与合并参数,得出最终的参数
run实例化
compiler:用上一步得到的参数初始化Compiler对象,加载所有配置的插件,执行对象的run方法开始执行编译,执行对象的run方法开始执行编译
编译构建
entry确定入口
根据配置中的entry找出所有的入口文件
make编译模版
从入口文件出发,调用所有配置的Loader对模块进行编译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
build module完成模块编译
经过上面一步使用Loader翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系
seal 输出资源
根据入口和模块之间的依赖关系,组装成一个个包含多个模块的Chunk,再把每个Chunk转换成一个单独的文件加入到输出列表,这不是可以修改输出内容的最后机会
emit输出完成
在确定好输出内通后,根据配置确定输出的路径和文件名,吧文件内容写到文件系统
实现一个简易的webpack
准备工作
目录结构
首先初始化一个项目,结构如下:
|-- forestpack
|-- dist(打包目录)
| |-- bundle.js
| |-- index.html
|-- lib(核心文件)
| |-- compiler.js(编译相关。Compiler为一个类,并且有run方法去开启编译,还有构建module(buildModule)和输出文件(emitFiles))
| |-- index.js(实例化Compiler类,并将配置参数(对应forstpack.config.js)传入)
| |-- parser.js(解析相关。包含解析AST(getAST)、收集依赖)
| |-- test.js(测试文件,用于测试方法函数打console使用)
|-- src(源代码)
| |-- greeting.js
| |-- index.js
|-- forstpack.config.js(配置文件,类似webpack.config.js)
|-- package.json
完成‘造轮子’前30%的代码
首先完善forstpack.config.js和src
首先forstpack.config.js
const path = require("path");
module.exports = {
entry: path.join(__dirname, "./src/index.js"),
output: {
path: path.join(__dirname, "./dist"),
filename: "bundle.js",
},
};
其次是src,在src目录下定义了两个文件
- greeting.js
// greeting.js
export function greeting(name) {
return "你好" + name;
}
- index.js
import { greeting } from "./greeting.js";
document.write(greeting("森林"));
到这里,我们的准备工作都完成了
逻辑梳理
webpack的整个流程是
- 读取入口文件
- 分析入口文件,递归的去读取模块所依赖的文件内容,生成AST语法树
- 根据AST语法树,生成浏览器能够运行的代码
正式开工
compile.js编写
const path = require("path");
const fs = require("fs");
module.exports = class Compiler {
// 接收通过lib/index.js new Compiler(options).run()传入的参数,对应`forestpack.config.js`的配置
constructor(options) {
const { entry, output } = options;
this.entry = entry;
this.output = output;
this.modules = [];
}
// 开启编译
run() {}
// 构建模块相关
buildModule(filename, isEntry) {
// filename: 文件名称
// isEntry: 是否是入口文件
}
// 输出文件
emitFiles() {}
};
compile.js主要做了几个事情:
- 接收forestpack.config.js配置参数,并初始化entry、output
- 开启编译run方法。处理构建模块、收集依赖、输出文件等。
- buildModule方法。主要用于构建模块(被run方法调用)
- emitFiles方法。输出文件(同样被run方法调用) 到这里,compiler.js的大致结构已经出来了,但是得到模块的源码后, 需要去解析,替换源码和获取模块的依赖项, 也就对应我们下面需要完善的parser.js。
parser.js 编写
const fs = require("fs");
// const babylon = require("babylon");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const { transformFromAst } = require("babel-core");
module.exports = {
// 解析我们的代码生成AST抽象语法树
getAST: (path) => {
const source = fs.readFileSync(path, "utf-8");
return parser.parse(source, {
sourceType: "module", //表示我们要解析的是ES模块
});
},
// 对AST节点进行递归遍历
getDependencies: (ast) => {
const dependencies = [];
traverse(ast, {
ImportDeclaration: ({ node }) => {
dependencies.push(node.source.value);
},
});
return dependencies;
},
// 将获得的ES6的AST转化成ES5
transform: (ast) => {
const { code } = transformFromAst(ast, null, {
presets: ["env"],
});
return code;
},
};
- 首先说一下用到的几个babel包
- @babel/parser:用于将源码生成AST
- @babel/traverse:对AST节点进行递归遍历
- babel-core/@babel/preset-env:将获得的ES6的AST转化成ES5
- 完善 compiler.js
const { getAST, getDependencies, transform } = require("./parser");
const path = require("path");
const fs = require("fs");
module.exports = class Compiler {
constructor(options) {
const { entry, output } = options;
this.entry = entry;
this.output = output;
this.modules = [];
}
// 开启编译
run() {
const entryModule = this.buildModule(this.entry, true);
this.modules.push(entryModule);
this.modules.map((_module) => {
_module.dependencies.map((dependency) => {
this.modules.push(this.buildModule(dependency));
});
});
// console.log(this.modules);
this.emitFiles();
}
// 构建模块相关
buildModule(filename, isEntry) {
let ast;
if (isEntry) {
ast = getAST(filename);
} else {
const absolutePath = path.join(process.cwd(), "./src", filename);
ast = getAST(absolutePath);
}
return {
filename, // 文件名称
dependencies: getDependencies(ast), // 依赖列表
transformCode: transform(ast), // 转化后的代码
};
}
// 输出文件
emitFiles() {
const outputPath = path.join(this.output.path, this.output.filename);
let modules = "";
this.modules.map((_module) => {
modules += `'${_module.filename}' : function(require, module, exports) {${_module.transformCode}},`;
});
const bundle = `
(function(modules) {
function require(fileName) {
const fn = modules[fileName];
const module = { exports:{}};
fn(require, module, module.exports)
return module.exports
}
require('${this.entry}')
})({${modules}})
`;
fs.writeFileSync(outputPath, bundle, "utf-8");
}
};
主要说一下emitFiles
emitFiles() {
const outputPath = path.join(this.output.path, this.output.filename);
let modules = "";
this.modules.map((_module) => {
modules += `'${_module.filename}' : function(require, module, exports) {${_module.transformCode}},`;
});
const bundle = `
(function(modules) {
function require(fileName) {
const fn = modules[fileName];
const module = { exports:{}};
fn(require, module, module.exports)
return module.exports
}
require('${this.entry}')
})({${modules}})
`;
fs.writeFileSync(outputPath, bundle, "utf-8");
}
首先我们需要先了解一下webpack文件机制,下面一段代码时经过webpack打包精简后的代码
// dist/index.xxxx.js
(function(modules) {
// 已经加载过的模块
var installedModules = {};
// 模块加载函数
function __webpack_require__(moduleId) {
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
module.l = true;
return module.exports;
}
__webpack_require__(0);
})([
/* 0 module */
(function(module, exports, __webpack_require__) {
...
}),
/* 1 module */
(function(module, exports, __webpack_require__) {
...
}),
/* n module */
(function(module, exports, __webpack_require__) {
...
})]);
分析:
- webpack将所有模块(可以简单理解为文件)包裹于一个函数,并传入默认参数,将所有模块放入一个数组中,取名为modules,并通过数组的下标来作为moduleId
- 将modules传入一个自执行函数,自执行函数中包含一个installedModules已经加载过的模块和一个模块加载函数,最后加载入口模块并返回
- __webpack_require__模块加载,先判断installedModules是否已经加载,加载过了就直接返回exports数据,没有加载过该模块就通过 modules[moduleId].call(module.exports, module, module.exports, webpack_require) 执行模块并且将 module.exports 给返回。
简单来说:
- 通过webpack打包出来的是一个匿名函数
- modules是一个数组,每一项是一个模块初始化函数
- _webpack_require_用来加载模块,返回module.exports
- 通过WEBPACK_REQUIRE_METHOD(0)启动函数
lib/index.js 入口文件编写
const Compiler = require("./compiler");
const options = require("../forestpack.config");
new Compiler(options).run();
这里逻辑就比较简单了:实例化Compiler类,并将配置参数(对应forstpack.config.js)传入。
运行node lib/index.js就会在dist目录下生成bundle.js文件。
(function (modules) {
function require(fileName) {
const fn = modules[fileName];
const module = { exports: {} };
fn(require, module, module.exports);
return module.exports;
}
require("/Users/fengshuan/Desktop/workspace/forestpack/src/index.js");
})({
"/Users/fengshuan/Desktop/workspace/forestpack/src/index.js": function (
require,
module,
exports
) {
"use strict";
var _greeting = require("./greeting.js");
document.write((0, _greeting.greeting)("森林"));
},
"./greeting.js": function (require, module, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.greeting = greeting;
function greeting(name) {
return "你好" + name;
}
},
});
结果展示
我们在dist目录下创建index.html文件,引入打包生成的bundle.js文件:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script src="./bundle.js"></script>
</body>
</html>
打开浏览器