手摸手实现一个webpack
在平时的工作和学习过程中,webpack 是一个重要的知识点,本文通过分析 webpack 的打包原理,最终带大家实现一个简易版的 webpack。
webpack模块加载机制
在弄明白 webpack 的模块加载机制之前,我们先看一下一个简单的工程经过 webpack 打包之后会变成什么样?
创建一个 example 目录,并且在该目录下创建三个文件 a.js、b.js 和 index.js,为了便于研究具体的打包的结果,所有的模块都采用了 commonjs 模块进行加载,采用 es6 模块原理也是大同小异。
a.js:
代码语言:javascript复制module.exports = 'I am module a';
b.js:
代码语言:javascript复制module.exports = 'I am module b';
index.js:
代码语言:javascript复制const a = require('./a');
const b = require('./b');
function main() {
console.log('I am entry module');
console.log(a);
console.log(b);
}
main();
module.exports = main;
安装webpack和webpack-cli:
⚠️注意:这里我们选择 webpack4.x 版本进行演示。
$ yarn add webpack@4.28.4 webpack-cli@3.3.0 -D
新增一个webpack配置文件
webpack 打包模式 mode 设置为 development 模式,这样可以使打包后的js代码便于观察。
webpack.config.js:
代码语言:javascript复制const path = require('path');
module.exports = {
entry: './src/index.js',
mode: 'development',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
}
};
在项目的 package.json 中增加一条 webpack 编译命令,并指定 webpack 打包的配置文件。
package.json:
代码语言:javascript复制{
"scripts": {
"build": "webpack --config webpack.config.js"
}
}
现在执行 $ yarn run build 就会在 dist 目录下的 bundle.js 文件中生成打包后的结果。
删除掉注释和暂时用不到的代码,整个 bundle.js 可以进行如下简化:
(function(modules) {
// The module cache
var installedModules = {};
// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// Create a new module (and put it into the cache)
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
// Execute the module function
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// Flag the module as loaded
module.l = true;
// Return the exports of the module
return module.exports;
}
return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
({
"./src/a.js": (function(module, exports) {
eval("module.exports = 'I am module a';nn//# sourceURL=webpack:///./src/a.js?");
}),
"./src/b.js": (function(module, exports) {
eval("module.exports = 'I am module b';nn//# sourceURL=webpack:///./src/b.js?");
}),
"./src/index.js": (function(module, exports, __webpack_require__) {
eval("const a = __webpack_require__(/*! ./a */ "./src/a.js");nconst b = __webpack_require__(/*! ./b */ "./src/b.js");nnfunction main() {n console.log('I am entry module');n console.log(a);n console.log(b);n}nnmain();nnmodule.exports = main;nnn//# sourceURL=webpack:///./src/index.js?");
})
});
这个结构就很清晰了,所有的逻辑在一个立即执行函数里面,webpack 里面叫做 webpackBootstrap,结构如下:
(function(modules){
// body
})({
"a.js": (function(){}),
"b.js": (function(){}),
// ...
});
立即执行函数的实参是一个对象,对象的 key 是文件的路径(这里的 key 其实就是一个全局唯一的标识,production模式下并不一定是文件路径),value 是文件的具体内容。
接下来看立即执行函数的函数体。整个函数体内部形成了一个闭包,定义了一个闭包变量 installedModules,用来缓存所有已经加载过的模块。
var installedModules = {};
定义一个 __webpack_require__ 函数用来辅助加载模块,函数接收一个模块id作为入参,接下来看一下 __webpack_require__ 做了哪些事情。
- 检查
installedModules对象中是否已经存在缓存,如果存在缓存的话就直接返回已经缓存的模块。
// Check if module is in cache
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
- 如果缓存不存在,则定义一个对象挂载到
installedModules对象中,key为模块的id,value是一个对象,包含i、l和exports三个值,分别用来记录模块的id、标记模块是否已经加载过的标志位和存储模块执行后的返回结果。
// Create a new module (and put it into the cache)
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
modules[moduleId].call()执行模块。第一个参数module.exports指定了执行模块的上下文,并且传入默认参数,执行的结果会挂到module.exports上。
// Execute the module function
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
- 标记模块为已经加载过
// Flag the module as loaded
module.l = true;
函数体最后返回 __webpack_require__("./src/index.js") 的执行结果,其中 ./src/index.js 指定了整个模块加载的入口文件。
实现一个简易版的 webpack
明白了上面的模块加载机制之后,下面我们就来自己实现一个简易版的 webpack。
初步的想法是和 webpack 一样,提供一个 mini-webpack 命令行,通过 --confg 参数能够获取指定的 webpack 配置文件并进行打包。
初始化工程
创建一个工程 mini-webpack,并且初始化工程。
$ mkdir mini-webpack
$ cd mini-webpack
$ yarn init -y
新增 src 目录,在 src 目录下新建 mini-webpack 文件,定义一个 Compiler 类,提供一个构造函数和一个 run 方法。
export default class Compiler {
constructor() {}
run() {
console.log('webpack running...')
}
}
为了能够使用 typescript 编写我们的代码,可以使用 tsc 对代码进行编译。
$ yarn add typescript @types/node -D
根目录下创建 tsconfig.json 配置文件。
{
"compilerOptions": {
"target": "ES2015",
"noImplicitAny": false,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"removeComments": false,
"baseUrl": ".",
"outDir": "dist",
"rootDir": "./src",
"module": "commonjs",
"sourceMap": true,
"skipLibCheck": true
},
"include": [
"./src"
]
}
在 package.json 中增加一条编译命令,执行 $ npm run build 就可以对我们的 ts 代码进行编译,并输出到 dist 目录下了。
{
"scripts": {
"build": "tsc"
},
}
接下来就可以提供 mini-webpack 命令行了。
根目录下新建 bin 目录,在 bin 目录下创建 mini-webpack 文件,引用 dist 目录下的 mini-webpack 文件,实例化 mini-webpack,并执行 run 方法。
#! /usr/bin/env node
const MiniWebpack = require('../dist/mini-webpack').default
new MiniWebpack().run();
在 package.json 文件中增加 bin 字段,指向 bin/mini-webpack。
{
"bin": {
"mini-webpack": "bin/mini-webpack"
},
}
在 mini-webpack 目录下执行 $ ./bin/mini-webpack 就可以调用 mini-webpack 的 run 方法并打印出日志了。
如果想使用 mini-webpack 命令行对 example 工程进行编译操作,可以在 mini-webpack 目录下执行 npm link,然后在 example 目录下执行 npm link mini-webpack,在 example 目录的 package.json 中增加一条编译指令。
{
"scripts": {
"build": "mini-webpack --config webpack.config.js"
},
}
这样,在 example 目录下执行 $ npm run build 就开始进行编译。
核心打包模块的实现
首先我们需要解析命令行传过来的参数,获取 webpack 的配置文件。
定义一个 parseArgs 方法,并在构造函数里调用,通过 minimist 解析出命令行参数,获取打包的入口、输出目录以及其他一些配置项。
import * as minimist from 'minimist';
import * as path from 'path';
export default class Compiler {
private config;
private cwd;
private entry;
private outputDir;
private outputFilename;
constructor() {
this.cwd = process.cwd();
this.config = this.parseArgs();
this.entry = this.config.entry;
this.outputDir = this.config.output.path;
this.outputFilename = this.config.output.filename || 'bundle.js';
}
parseArgs() {
const args = minimist(process.argv.slice(2))
const { config = 'webpack.config.js' } = args;
const configPath = path.resolve(this.cwd, config);
return require(configPath);
}
run() {
console.log('webpack running...')
}
}
上一节 webpack模块加载机制 中已经介绍过,打包后的 bundle.js 的结构大概是这样:
(function(modules){
var installedModules = {};
function __webpack_require__(moduleId) {
// ...
}
return __webpack_require__("./src/index.js");
})({
"a.js": (function(){}),
"b.js": (function(){}),
// ...
});
所以,接下来就要想办法生成这么一个字符串,输出到 output 指定的目录。这个字符串中有两部分是动态生成的,一个就是立即执行函数的入参,是一个资源清单,另一个是 webpack 打包的入口。为了方便生成格式化的字符串,这里我选择使用 Handlebars 来生成模板。
定义一个 generateCode 方法,用来接收资源清单和打包入口,生成输出字符串。
安装 handlebars:
$ yarn add handlebars
入参 sourceList 是一个数组,结构如下:
[
{
path: "./src/a.js",
code: "module.exports = 'I am module a';",
},
{
path: "./src/b.js",
code: "module.exports = 'I am module b';",
},
{
path: "./src/index.js",
code: "const a = __webpack_require__(/*! ./a */ "./src/a.js");nconst b = __webpack_require__(/*! ./b */ "./src/b.js");nnfunction main() {n console.log('I am entry module');n console.log(a);n console.log(b);n}nnmain();nnmodule.exports = main;",
},
]
generateCode 方法:
import * as path from 'path';
import * as fs from 'fs';
import * as Handlebars from 'handlebars';
export default class Compiler {
// ...
generateCode(sourceList, entryPath) {
const tplPath = path.join(__dirname, '../templates', 'bundle.hbs');
const tpl = fs.readFileSync(tplPath, 'utf-8');
const template = Handlebars.compile(tpl);
const data = {
entryPath,
sourceList,
};
const bundleContent = template(data);
fs.writeFileSync(path.join(this.outputDir, this.outputFilename), bundleContent, { encoding: 'utf8' });
}
// ...
}
bundle.hbs 模板文件:
(function(modules) {
// The module cache
var installedModules = {};
// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// Create a new module (and put it into the cache)
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
// Execute the module function
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// Flag the module as loaded
module.l = true;
// Return the exports of the module
return module.exports;
}
return __webpack_require__(__webpack_require__.s = "{{{ entryPath }}}");
})
({
{{#each sourceList}}
"{{{path}}}": (function(module, exports, __webpack_require__) {
eval(`{{{code}}}`);
}),
{{/each}}
});
有了前面的铺垫,接下来只需要获取到资源清单列表,整个 webpack 编译打包流程就可以跑通了。
webpack 打包通常是由一个入口作为切入点,作为构建其内部依赖图的开始,读取入口文件后,会找出入口文件依赖了哪些文件(通俗的理解就是找到该文件 import 或者 require 了哪些文件),找到这些依赖文件之后将其记录下来,接着再找依赖的依赖又依赖了哪些文件,一直到最后所有的依赖都已经被找完,生成完整的依赖图。
上面的过程中就会涉及到一个新的概念,如何分析文件,解析 require 或者 import 语法?
答案就是 babel。
这里主要用到了babel的三个包:
- @babel/parser:将代码解析成ast语法树
- @babel/traverse:可以用来遍历更新@babel/parser生成的ast语法树
- @babel/generator:根据ast生成代码
- @babel/types: 提供一些工具类方法
安装上述依赖包:
代码语言:javascript复制$ yarn add @babel/parser @babel/traverse @babel/generator @babel/types
定义一个 build 方法,该方法会先根据传入的模块路径读取取到源文件,然后调用 this.parseModule 方法,传入当前模块的源文件和父目录,获取通过 @babel/generator 重新生成的源码 sourceCode,和该文件中的依赖项列表 moduleList,然后将收集到的数据 push 到 sourceList 列表中,接着根据依赖项列表 moduleList 递归进行上述过程。
build 方法:
代码语言:javascript复制build(modulePath) {
const code = fs.readFileSync(modulePath, 'utf-8');
const { sourceCode, moduleList } = this.parseModule(code, path.dirname(modulePath));
this.sourceList.push({
path: `./${path.relative(this.cwd, modulePath)}`,
code: sourceCode,
});
if(moduleList.length !== 0) {
moduleList.forEach(m => this.build(path.resolve(this.cwd, m)));
}
}
parseModule 方法主要涉及到 babel 对源文件的一些处理。首先根据传入的源文件通过 @babel/parser 将源文件转换为ast语法树,然后通过 @babel/traverse,遍历ast语法树,找到 require 语句(import语句类似,这里暂时只考虑 require 一种),将 require 方法名替换为 __webpack_require__ 方法名,函数参数的路径转换为以 ./src 开头的相对路径。
parseModule 方法:
代码语言:javascript复制parseModule(code, parentPath) {
const relativePath = path.relative(this.cwd, parentPath);
const ast = parser.parse(code);
let moduleList: Array<string> = [];
traverse(ast, {
CallExpression({ node }) {
if (node.callee.name === 'require') {
node.callee.name = '__webpack_require__';
let moduleName = node.arguments[0].value;
moduleName = path.extname(moduleName) ? moduleName : moduleName '.js';
moduleName = `./${path.join(relativePath, moduleName)}`;
node.arguments = [types.stringLiteral(moduleName)];
moduleList.push(moduleName);
}
}
});
const sourceCode = generator(ast).code;
return {
sourceCode,
moduleList,
}
}
最后在 run 方法里调用 build 和 generateCode 方法,重新编译 mini-webpack 后,在 example 目录下执行 $ npm run build 就完成了整个转换过程,会在 dist 目录下生成 bundle.js 文件。
run() {
this.build(path.join(this.cwd, this.entryPath));
this.generateCode(this.sourceList);
}
写在最后
通过上述章节的介绍,可以看到 webpack 的打包原理并不是很复杂,明白了打包原理之后再去实现一个 webpack 打包工具就水到渠成了。当然,这里只是实现了一个最小化的 webpack 打包工具,真正的 webpack 打包还会涉及到 loader、插件系统等一系列复杂的工作,尤其是 webpack 的插件系统对于理解前端工程化还是大有裨益的,像业内比较有名的开源框架比如 umi、taro 等框架中都有借鉴,感兴趣的同学可以阅读下相关源码。
参考链接
示例源码
- webpack打包原理 ? 看完这篇你就懂了 !
- Webpack 是怎样运行的?
- 深入理解 webpack 文件打包机制


