一、webpack 打包编译的主要流程
compiler 的流程:
- 将 webpack.config.js 作为参数传入 Compiler 类 (entry-options)
- 创建 Compiler 实例
- 调用 Compiler.run 开始编译 (make)
- 创建 Compilation( compiler 内创建 compilation 对象,并将 this 传入,compilation 就包含了对 compiler 的引用)
- 基于配置开始创建 Chunk (读取文件,转成 AST )
- 使用 Parser 从 Chunk 开始解析依赖 (找到依赖关系)
- 使用 Module 和 Dependency 管理代码模块相互依赖关系 (build-module)
- 使用 Template 基于 Compilation 的数据生成结果代码
- 可以简单分为这三个阶段
二、准备工作
我们先建一个项目,目录如下:
selfWebpack
- src
- data.js
- index.js
- random.js
// index.js
import data from './data.js'
import random from './random.js'
console.log('🐻我是数据文件--->', data)
console.log('🦁我是随机数--->', random)
console.log('🐺我是index.js')
// data.js
const result = '我是文件里面的数据'
export default result
// random.js
const random = Math.random()
export default random
然后我们先用 webpack 进行一次打包,分析一下 我们需要做什么工作
// 基本安装
npm init -y
npm install webpack@4.44.2 webpack-cli@4.2.0 --save-dev
// package.json
// 修改
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack --mode development"
},
整理一下打包后的代码
(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;
}
// Load entry module and return exports
return __webpack_require__(__webpack_require__.s = "./src/index.js");
})({
"./src/data.js": function(module, __webpack_exports__, __webpack_require__) {
"use strict";
const result = '我是文件里面的数据'
__webpack_exports__["default"] = (result);
},
"./src/index.js": function(module, __webpack_exports__, __webpack_require__) {
"use strict";
var _random_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__("./src/random.js");
console.log('🐻我是数据文件--->', _data_js__WEBPACK_IMPORTED_MODULE_0__["default"])
console.log('🦁我是随机数--->', _random_js__WEBPACK_IMPORTED_MODULE_1__["default"])
console.log('🐺我是index.js')
},
"./src/random.js": function(module, __webpack_exports__, __webpack_require__) {
"use strict";
const random = Math.random()
__webpack_exports__["default"] = (random);
}
});
最外层是一个立即执行函数,入参是所有的 modules(模块) list。传入的 modules 参数是一个对象。
- 对象的格式是,文件名: 方法。
- key 是 index.js 文件的相对路径,value 是一个匿名函数,函数体里面就是咱们写在 index.js 里的代码。(这就是 webpack 加载模块的方式)
我们要是实现的两个功能
import
变成__webpack_require__
- 读取模块中的所有依赖,生成一个 Template
三、开始搭建自己的 selfpack
- 实现 打包编译的代码,放在 src 同级的 selfpack 目录,再增加一个配置文件(selfpack.config.js),如下:
selfWbpack
+ src
// 新增
- selfpack
- compilation.js
- compiler.js
- index.js
- Parser.js
- selfpack.config.js
// selfpack.config.js
const { join } = require('path')
module.exports = {
entry: join(__dirname, './src/index.js'),
output: {
path: join(__dirname, './dist'),
filename: 'main.js'
}
}
四、实现转换 AST
- 为什么要转成 ast ? 因为有 import ,我们要把它替换成 webpack_require 。
- 怎么做? 遍历 AST ,把其中 import 语句引入的文件路径收集起来。
- 第一步,实现通过参数找到入口文件并获取文件内容
- 第二步,转成 AST
- 第三步,解析主模块文件依赖
- 第四步,将 AST 转换回 JS 代码
- 第五步,分析模块之间的依赖关系,将 import 替换成 webpack_require
4.1 获取入口文件
// selfpack/index.js
const Compiler = require('./Compiler')
const options = require('../selfpack.config.js')
const compiler = new Compiler(options)
compiler.run()
// selfpack/compilation.js
const fs = require('fs')
class Compilation {
constructor(compiler) {
const { options } = compiler
this.options = options
}compiler
static ast(path){
const content = fs.readFileSync(path, 'utf-8') // 读取文件
console.log('获取文件', content)
}
buildModule(absolutePath, isEntry) {
this.ast(absolutePath)
}
}
module.exports = Compilation
npm install tapable
// selfpack/compiler.js
const { SyncHook } = require('tapable')
const Compilation = require('./Compilation')
class Compiler {
constructor(options) {
this.options = options
this.hooks = {
run: new SyncHook()
}
}
run() {
this.compile()
}
compile() {
const compilation = new Compilation(this)
//通过entry找入口文件
const entryModule = compilation.buildModule(this.options.entry, true)
}
}
module.exports = Compiler
// selfpack/Parser.js
const fs = require('fs')
class Parser{
static ast(path) {
const content = fs.readFileSync(path, 'utf-8') // 读取文件
console.log('读取文件', content)
}
}
module.exports = Parser
将 selfpack.config.js 作为参数传入 Compiler 类,执行 run 方法。 通过 new 一个 Compilation 实例,调用 buildModule()
- buildModule( absolutePath, isEntry )
- absolutePath: 入口文件的绝对路径
- isEntry: 是否是主模块
获取入口文件的结果:
第一步成功实现,下面实现第二步转成AST
4.2 转化成AST
这一步需要用到 @babel/parser , 将代码转化为 AST 语法树。
npm install @babel/parser
sourceType 代表我们要解析的是ES模块
- 调用 Parser.ast()
- 通过 readFileSync 读取文件内容,传给 parser.parse() 得到 AST。
// selfpack/Parser.js
const fs = require('fs')
const parser = require('@babel/parser')
class Parser{
static ast(path) {
const content = fs.readFileSync(path, 'utf-8') // 读取文件
console.log('读取文件', content)
const _ast = parser.parse(content, {
sourceType: 'module' //表示我们要解析的是ES模块
})
console.log(_ast)
console.log('我是body内容', _ast.program.body)
return _ast
}
}
module.exports = Parser
到这一步我们很顺利! 这是整个文件的信息,而我们需要的文件内容在它的属性 program 里的 body 里。 看一下 body 的内容
这是 src/index.js
的一个 import 的 Node 属性,它的类型是 ImportDeclaration。
4.3 解析主模块文件依赖
接下来,解析主模块。
遍历AST要用到 @babel/traverse
npm install @babel/traverse
traverse() 的用法:第一个参数就是 AST ,第二个参数就是配置对象
// selfpack/Parser.js
const traverse = require('@babel-traverse').default
const fs = require('fs')
const parser = require('@babel/parser')
const path = require('path')
class Parser{
static ast(path){
const content = fs.readFileSync(path, 'utf-8') // 读取文件
const _ast = parser.parse(content, {
sourceType: 'module' //表示我们要解析的是ES模块
})
console.log(_ast)
console.log('我是body内容', _ast.program.body)
return _ast
}
static getDependecy(ast, file) {
const dependecies = {}
traverse(ast, {
ImportDeclaration: ({node}) => {
const oldValue = node.source.value
const dirname = path.dirname(file)
const relativepath = "./" + path.join(dirname, oldValue)
dependecies[oldValue] = relativepath
node.source.value = relativepath // 将 ./data.js 转化成 ./src/data.js
}
})
return dependecies
}
}
module.exports = Parser
- 调用 Parser.getDependecy 方法,获取主模块的依赖路径,修改源码。
- getDependecy(): 静态方法,是对 type 为 ImportDeclaration 的节点的处理。
- node.source.value: 就是 import 的值。
- 因为我们打包后的代码,入参部分的 key 变成了
./src/data.js
,所以这里也需要做出相应的改变import data from './data.js'
==>require('./data.js')
==>require('./src/data.js')
relativepath: 这里获取的是依赖的文件路径
dependecies: 是收集的依赖对象,key 为 node.source.value ,value 为转换后的路径。
import data from './data.js'
import random from './random.js'
node.source.value: 指的是 from 后面的 './data.js' 、'./random.js'
path.relative(from, to): 方法根据当前工作目录返回 ( from ) 到 ( to ) 的 ( 相对路径 )
process.cwd(): 返回 Node.js 进程的当前工作目录(path.resolve())
// selfpack/compilation.js
const Parser = require('./Parser')
const path = require('path')
class Compilation {
constructor(compiler) {
const { options } = compiler
this.options = options
this.entryId
// 增加
this.root = process.cwd() // 执行命令的当前目录
}
buildModule(absolutePath, isEntry) {
let ast = ''
ast = Parser.ast(absolutePath)
const relativePath = './' + path.relative(this.root, absolutePath)
if(isEntry){
this.entryId = relativePath
}
const dependecies = Parser.getDependecy(ast, relativePath)
console.log("依赖项", dependecies)
}
}
module.exports = Compilation
遍历之后 在 ast 里面找到节点类型, 通过 index.js 的 ast 获取到 index.js 文件的依赖(也就是data.js、random.js)
主模块的依赖路径已经全部找到啦! 走到这一步,离成功就不远了。
4.4 转换代码
接下来是转换代码,就是将修改后的 AST 转换成 JS 代码。
用到了 @babel/core 的 transformFromAst 和 @babel/preset-env。
安装一下 npm install @babel/core @babel/preset-env
- transformFromAst: 就是将我们传入的 AST 转化成我们在第三个参数(
@babel/preset-env
)里配置的模块类型,会返回转换后的代码
@babel/preset-env 是将我们使用的 JS 新特性转换成兼容的代码。
此时 Parser.js 长这样
// selfpack/Parser.js 完整
const traverse = require('@babel-traverse').default
const fs = require('fs')
const parser = require('@babel/parser')
const path = require('path')
// 增加
const { transformFromAst } = require('@babel/core')
class Parser{
static ast(path){
const content = fs.readFileSync(path, 'utf-8') // 读取文件
const _ast = parser.parse(content, {
sourceType: 'module' //表示我们要解析的是ES模块
})
console.log(_ast)
console.log('我是body内容', _ast.program.body)
return _ast
}
static getDependecy(ast, file) {
const dependecies = {}
traverse(ast, {
ImportDeclaration: ({node}) => {
const oldValue = node.source.value
const dirname = path.dirname(file)
const relativepath = "./" + path.join(dirname, oldValue)
dependecies[oldValue] = relativepath
node.source.value = relativepath // 将 ./data.js 转化成 ./src/data.js
}
})
return dependecies
}
// 增加
static transform(ast) {
const { code } = transformFromAst(ast, null, {
presets: ['@babel/preset-env']
})
return code
}
}
module.exports = Parser
// selfpack/compilation.js
...
buildModule(absolutePath, isEntry) {
let ast = ''
ast = Parser.ast(absolutePath)
const relativePath = './' + path.relative(this.root, absolutePath)
if(isEntry){
this.entryId = relativePath // 保存主入口的文件路径
}
const dependecies = Parser.getDependecy(ast, relativePath)
// 增加
const transformCode = Parser.transform(ast)
console.log("转换后的代码 ", transformCode)
return {
relativePath,
dependecies,
transformCode
}
}
}
...
先来看下结果:
可以看到 const 成功转换成了 var,但是 require("./data.js")
引用的路径还没有和 modules 的 key 保持一致。
4.5 递归收集依赖
我们怎么去确定一个模块应该包含什么信息呢?
首先要确定这个文件的唯一性,所以我们需要要的文件路径,因为这个是唯一的。
然后再来分析文件的内容:
- 是否引入了其他文件
- 自己的主体内容
所以我们需要的模块信息如下:
- 该模块的路径
- 该模块的依赖
- 该模块转换后的代码 这里我们获取转换后的代码,并在 buildModule 返回一个对象,返回值结构如下:
// 获取的模块信息
{
relativePath: './src/xxx',
dependecies: {
'./data.js': './src/data.js',
'./random.js': './src/random.js'
},
transformCode: {
...
}
}
但是 buildModule 只能收集一个模块的依赖,而我们最终的目的是收集所有依赖,所以我们要做一个递归处理。 修改一下 compiler.js
// selfpack/compiler.js
...
compile() {
const compilation = new Compilation(this)
//通过entry找入口文件
const entryModule = compilation.buildModule(this.options.entry, true)
// 增加
this.modules.push(entryModule)
this.modules.map((_module) => {
const deps = _module.dependecies
for (const key in deps){
if (deps.hasOwnProperty(key)){
this.modules.push(compilation.buildModule(deps[key], false))
}
}
})
console.log('最终的 modules', this.modules)
}
...
先来看一下 compile 中递归的方法:
- 将主入口文件传入
buildModule
,得到主入口的文件模块 - 最外层遍历的主入口文件的模块
- 然后获取主模块的依赖所有模块
- 把依赖的模块 push 到 this.modules 里
来看一下最终的 modules
成功得到了包含所有模块的:路径、依赖、转换后的代码。
五、生成 webpack 模版文件
编译的最后一步就是 生成模板文件,并放到 output 目录。
我们直接借用文章开头那段打包出来的 dist/main.js 文件的内容,然后做些修改。
来看修改后的的 compilation.js
// selfpack/compilation.js 完整
const path = require('path')
const Parser = require('./Parser')
const fs = require('fs')
class Compilation {
constructor(compiler) {
// 修改
const { options, modules } = compiler
this.options = options
this.root = process.cwd() // 执行命令的当前目录
this.entryId
// 增加
this.modules = modules
}
buildModule(absolutePath, isEntry) {
let ast = ''
ast = Parser.ast(absolutePath)
const relativePath = './' + path.relative(this.root, absolutePath)
if(isEntry){
this.entryId = relativePath
}
const dependecies = Parser.getDependecy(ast, relativePath)
const transformCode = Parser.transform(ast)
// console.log("依赖项", dependecies)
// console.log("转换后的代码 ", transformCode)
return {
relativePath,
dependecies,
transformCode
}
}
// 增加
emitFiles(){
let _modules = ''
const outputPath = path.join(
this.options.output.path,
this.options.output.filename
)
this.modules.map((_module) => {
// 记得加引号
_modules += `'${_module.relativePath}': function(module, exports, require){
${_module.transformCode}
},`
})
const template = `
(function(modules) {
var installedModules = {};
function __webpack_require__(moduleId) {
// Check if module is in cache
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
var module = installedModules[moduleId] = {
exports: {}
};
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
return module.exports;
}
// 执行的入口函数
return __webpack_require__('${this.entryId}');
})({
${_modules}
})
`
const dist = path.dirname(outputPath)
fs.mkdirSync(dist)
fs.writeFileSync(outputPath, template, 'utf-8')
}
}
module.exports = Compilation
打包之后的文件内容,大体上长这样,还有点小瑕疵。
看下 emitFiles 函数的作用
- 获取 selfpack.config.js 中的 output 对象的 path,filename
- 遍历所有的 modules 并放在模板的入参位置
- 新建一个文件,将编译后的代码写入
完整的 compiler
// selfpack/compiler.js 完整
const { SyncHook } = require('tapable')
const Compilation = require('./Compilation')
class Compiler {
constructor(options) {
this.modules = []
this.options = options
this.hooks = {
run: new SyncHook()
}
}
run() {
this.compile()
}
compile() {
const compilation = new Compilation(this)
const entryModule = compilation.buildModule(this.options.entry, true)
this.modules.push(entryModule)
this.modules.map((_module) => {
const deps = _module.dependecies
for (const key in deps){
if (deps.hasOwnProperty(key)){
this.modules.push(compilation.buildModule(deps[key], false))
}
}
})
// 增加
compilation.emitFiles()
}
}
module.exports = Compiler
编译后的代码如下:
// dist/main.js
(function (modules) {
var installedModules = {};
function __webpack_require__(moduleId) {
// Check if module is in cache
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
var module = installedModules[moduleId] = {
exports: {}
};
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
return module.exports;
}
// 执行的入口函数
return __webpack_require__('./src/index.js');
})({
'./src/index.js': function (module, exports, require) {
"use strict";
var _data = _interopRequireDefault(require("./src/data.js"));
var _random = _interopRequireDefault(require("./src/random.js"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
console.log('🐻我是数据文件--->', _data["default"]);
console.log('🦁我是随机数--->', _random["default"]);
console.log('🐺我是index.js');
}, './src/data.js': function (module, exports, require) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var result = '我是文件里面的数据';
var _default = result;
exports["default"] = _default;
}, './src/random.js': function (module, exports, require) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var random = Math.random();
var _default = random;
exports["default"] = _default;
},
})
走到这里一个简单 webpack 的编译流程代码就算写完啦。
把代码复制到浏览器测试一下
六、实现 webpack 的 Plugins 功能
怎么开发一个自定义的plugins?
webpack中内部实现了自己的一套生命周期,而 plugins 就是用 apply 来调用webpack里面提供的生命周期。
而 webpack 的生命周期主要就是 tapable 来实现的。
这里只用到了 SyncHook
,更多可参考这篇 Tapable 详解。
我们修改一下官网的 ConsoleLogOnBuildWebpackPlugin.js 例子。
在 src同级目录新建一个 plugins
+ src
- plugins
- ConsoleLogOnBuildWebpackPlugin.js
编写一个简单的 plugins
// ConsoleLogOnBuildWebpackPlugin.js
const pluginName = 'ConsoleLogOnBuildWebpackPlugin';
class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
compiler.hooks.run.tap(pluginName, compilation => {
console.log('The webpack build process is starting!!!');
});
// 在文件打包结束后执行
compiler.hooks.done.tap(pluginName,(compilation)=> {
console.log("整个webpack打包结束")
})
// 在webpack输出文件的时候执行
compiler.hooks.emit.tap(pluginName,(compilation)=> {
console.log("文件开始发射")
})
}
}
module.exports = ConsoleLogOnBuildWebpackPlugin;
然后再配置文件引入这个 plugins
// selfpack.config.js
const { join } = require('path')
const ConsoleLogOnBuildWebpackPlugin = require('./plugins/ConsoleLogOnBuildWebpackPlugin')
module.exports = {
entry: join(__dirname, './src/index.js'),
output: {
path: join(__dirname, './dist'),
filename: 'main.js'
},
plugins: [new ConsoleLogOnBuildWebpackPlugin()],
}
要让我们的 selfwebpack 支持 plugins ,还要做些改动。
// selfpack/index.js
const Compiler = require('./Compiler')
const options = require('../selfpack.config.js')
const compiler = new Compiler(options)
const plugins = options.plugins
for (let plugin of plugins) {
plugin.apply(compiler)
}
compiler.run()
// selfpack/compiler.js
const { SyncHook } = require('tapable')
const Compilation = require('./Compilation')
class Compiler {
constructor(options) {
this.modules = []
this.options = options
this.hooks = {
run: new SyncHook(),
// 增加
emit: new SyncHook(),
done: new SyncHook()
}
}
run() {
this.compile()
}
compile() {
const compilation = new Compilation(this)
// 增加
this.hooks.run.call()
//通过entry找入口文件
const entryModule = compilation.buildModule(this.options.entry, true)
this.modules.push(entryModule)
this.modules.map((_module) => {
const deps = _module.dependecies
for (const key in deps){
if (deps.hasOwnProperty(key)){
this.modules.push(compilation.buildModule(deps[key], false))
}
}
})
// console.log('最终的 modules', this.modules)
compilation.emitFiles()
// 增加
this.hooks.emit.call()
this.hooks.done.call()
}
}
module.exports = Compiler
在 compiler 函数一初始化的时候就定义自己的 webpack 的生命周期,并且在 run 期间进行相应的调用,这样我们就实现了自己的生命周期。
打印结果如下:
本文只实现了简单的编译原理,更多实现请看 webapck-github
参考文章:手写webpack核心原理