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模块加载机制
在弄明白
webpack
的模块加载机制之前,我们先看一下一个简单的工程经过webpack
打包之后会变成什么样?创建一个
example
目录,并且在该目录下创建三个文件a.js
、b.js
和index.js
,为了便于研究具体的打包的结果,所有的模块都采用了commonjs
模块进行加载,采用es6
模块原理也是大同小异。a.js:
b.js:
index.js:
安装webpack和webpack-cli:
⚠️注意:这里我们选择
webpack4.x
版本进行演示。新增一个webpack配置文件
webpack
打包模式mode
设置为development
模式,这样可以使打包后的js代码便于观察。webpack.config.js:
在项目的
package.json
中增加一条webpack
编译命令,并指定webpack
打包的配置文件。package.json:
现在执行
$ yarn run build
就会在dist
目录下的bundle.js
文件中生成打包后的结果。删除掉注释和暂时用不到的代码,整个
bundle.js
可以进行如下简化:这个结构就很清晰了,所有的逻辑在一个立即执行函数里面,
webpack
里面叫做webpackBootstrap
,结构如下:立即执行函数的实参是一个对象,对象的
key
是文件的路径(这里的 key 其实就是一个全局唯一的标识,production模式下并不一定是文件路径),value
是文件的具体内容。接下来看立即执行函数的函数体。整个函数体内部形成了一个闭包,定义了一个闭包变量
installedModules
,用来缓存所有已经加载过的模块。定义一个
__webpack_require__
函数用来辅助加载模块,函数接收一个模块id作为入参,接下来看一下__webpack_require__
做了哪些事情。installedModules
对象中是否已经存在缓存,如果存在缓存的话就直接返回已经缓存的模块。installedModules
对象中,key
为模块的id,value
是一个对象,包含i
、l
和exports
三个值,分别用来记录模块的id、标记模块是否已经加载过的标志位和存储模块执行后的返回结果。modules[moduleId].call()
执行模块。第一个参数module.exports
指定了执行模块的上下文,并且传入默认参数,执行的结果会挂到module.exports
上。函数体最后返回
__webpack_require__("./src/index.js")
的执行结果,其中./src/index.js
指定了整个模块加载的入口文件。实现一个简易版的 webpack
明白了上面的模块加载机制之后,下面我们就来自己实现一个简易版的
webpack
。初步的想法是和
webpack
一样,提供一个mini-webpack
命令行,通过--confg
参数能够获取指定的webpack
配置文件并进行打包。初始化工程
创建一个工程
mini-webpack
,并且初始化工程。新增
src
目录,在src
目录下新建mini-webpack
文件,定义一个Compiler
类,提供一个构造函数和一个run
方法。为了能够使用
typescript
编写我们的代码,可以使用tsc
对代码进行编译。根目录下创建
tsconfig.json
配置文件。在
package.json
中增加一条编译命令,执行$ npm run build
就可以对我们的 ts 代码进行编译,并输出到dist
目录下了。接下来就可以提供
mini-webpack
命令行了。根目录下新建
bin
目录,在bin
目录下创建mini-webpack
文件,引用dist
目录下的mini-webpack
文件,实例化mini-webpack
,并执行run
方法。在
package.json
文件中增加bin
字段,指向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
中增加一条编译指令。这样,在
example
目录下执行$ npm run build
就开始进行编译。核心打包模块的实现
首先我们需要解析命令行传过来的参数,获取
webpack
的配置文件。定义一个
parseArgs
方法,并在构造函数里调用,通过 minimist 解析出命令行参数,获取打包的入口、输出目录以及其他一些配置项。上一节
webpack模块加载机制
中已经介绍过,打包后的bundle.js
的结构大概是这样:所以,接下来就要想办法生成这么一个字符串,输出到
output
指定的目录。这个字符串中有两部分是动态生成的,一个就是立即执行函数的入参,是一个资源清单,另一个是webpack
打包的入口。为了方便生成格式化的字符串,这里我选择使用 Handlebars 来生成模板。定义一个
generateCode
方法,用来接收资源清单和打包入口,生成输出字符串。安装
handlebars
:入参
sourceList
是一个数组,结构如下:generateCode
方法:bundle.hbs
模板文件:有了前面的铺垫,接下来只需要获取到资源清单列表,整个
webpack
编译打包流程就可以跑通了。webpack
打包通常是由一个入口作为切入点,作为构建其内部依赖图的开始,读取入口文件后,会找出入口文件依赖了哪些文件(通俗的理解就是找到该文件import
或者require
了哪些文件),找到这些依赖文件之后将其记录下来,接着再找依赖的依赖又依赖了哪些文件,一直到最后所有的依赖都已经被找完,生成完整的依赖图。上面的过程中就会涉及到一个新的概念,如何分析文件,解析
require
或者import
语法?答案就是 babel。
这里主要用到了babel的三个包:
安装上述依赖包:
定义一个
build
方法,该方法会先根据传入的模块路径读取取到源文件,然后调用this.parseModule
方法,传入当前模块的源文件和父目录,获取通过@babel/generator
重新生成的源码sourceCode
,和该文件中的依赖项列表moduleList
,然后将收集到的数据 push 到sourceList
列表中,接着根据依赖项列表moduleList
递归进行上述过程。build 方法:
parseModule
方法主要涉及到babel
对源文件的一些处理。首先根据传入的源文件通过@babel/parser
将源文件转换为ast语法树,然后通过@babel/traverse
,遍历ast语法树,找到require
语句(import语句类似,这里暂时只考虑 require 一种),将require
方法名替换为__webpack_require__
方法名,函数参数的路径转换为以./src
开头的相对路径。parseModule 方法:
最后在
run
方法里调用build
和generateCode
方法,重新编译mini-webpack
后,在 example 目录下执行$ npm run build
就完成了整个转换过程,会在dist
目录下生成bundle.js
文件。总结
通过上述章节的介绍,可以看到
webpack
的打包原理并不是很复杂,明白了打包原理之后再去实现一个webpack
打包工具就水到渠成了。当然,这里只是实现了一个最小化的webpack
打包工具,真正的webpack
打包还会涉及到loader
、插件系统等一系列复杂的工作,尤其是webpack
的插件系统对于理解前端工程化还是大有裨益的,像业内比较有名的开源框架比如umi
、taro
等框架中都有借鉴,感兴趣的同学可以阅读下相关源码。参考链接
示例源码