从零开始的Webpack原理剖析(四)——webpack工作流程
前言
经过前三篇文章的学习,我们已经积累了webpack
的一些必备基础知识,可是依旧不知道,webpack
究竟从npm run build
开始打包,到结束之间的流程是什么样的,那么这篇文章,我们可以根据几个关键的步骤,手写一个简易版本的webpack
,有助于我们对webpack
打包全流程的理解。在讲解完整流程之前,我们先做一下基本的准备,包括文件创建,如何编写wepack的loader
,如何编写webapck的plugin
,千万别打退堂鼓,这都是为了弄明白webpack
原理必备的知识,而且我们只需要如何写最简单loader
和plugin
即可,至于复杂的loader
和plugin
,之后的文章会单独拿出来讲如何去写。在写本篇文章之前,可以说我也是个小白,对整个工作流程模模糊糊,所以,我会以小白都能看懂的方式,来详细说明webpack
的工作流程是什么样的;
当然,如果你是为了应付面试,没空来看完整的文章,那就直接把下文的十个步骤全都背下来,这几乎就是标准答案了,然后祈祷面试官不要深挖就好🙏🏻;如果看完本篇文章,那这道题对你来说,基本上就不会被难倒了。
认真学完这篇文章你能:
- 了解
loader
和plugin
的书写方法和简单的原理; - 复习一些常见的文件读写
Node API
; - 手写一个有核心功能的简版
webpack
,清楚webpack
从开始编译到输出文件,做了什么事情; - 不惧怕
webpack
原理相关的面试题;
前置准备
在这个模块,我们将简单讲解如何编写webpack
的loader
和plugin
,并且讲解一下为什么要这样写,之后再初始化我们项目中需要的webpack.config.js
基本配置与基本文件结构。
初始化目录文件:
// step1: 创建文件夹和基本目录如下:
flow // 根目录
-- src // 用于存放入口文件
-- entry1.js // 入口文件1
-- entry2.js // 入口文件2
-- title.js // 入口文件中import依赖的文件
-- plugins // 用于存放我们自定义的插件
-- run-plugin.js // 自定义插件
-- loaders // 用于存放我们自定义的loader
-- loader1.js // 自定义loader
-- webpack.config.js // webpack的配置文件
-- package.json
// step2: 安装相关npm依赖包
npm install webpack webpack-cli -D
// step3: 初始化文件内容
// entry1.js
const title = require('./title')
console.log('入口1:', title)
// entry2.js
const title = require('./title')
console.log('入口2:', title)
// title.js
module.exports = 'title'
// package.json
.....
"script": {
"build": "webpack"
}
.....
// webpack.config.js
const path = require('path')
const RunPlugin = require('./plugins/run-plugin') // 等后边写完自定义插件,再添加这一行
module.exports = {
mode: 'development',
entry: {
entry1: './src/entry1.js',
entry2: './src/entry2.js'
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js',
clean: true
},
devtool: false,
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx']
},
module: {
rules: [
{
test: /\.js$/,
use: [
path.resolve(__dirname, 'loaders/loader1.js')
]
}
]
},
plugins: [
new RunPlugin() // 等后边写完自定义插件,在添加这行代码
]
}
编写最简单的自定义loader
文件建好之后,我们首先来写一个loader
,在webpack.config.js
中已经配好了我们的loader
路径和名称,设置好了要匹配的文件是.js
结尾的,那我们便开始写自定义的loader
吧!因为只是起到一个扫盲的作用,所以我们写一个并没有啥卵用的最简单loader
,作用就是给匹配到的.js
文件最后加个注释,以便证明我们自定义的loader
确实起作用了,之后在面试的时候,当问起来有没有写过自定义的loader
,自定义的plugin
啊?就不会被这些所谓高大上的问题吓到了。
// loader1.js
function loader1 (input) {
return input + '//loader1处理后添加的注释'
}
module.exports = loader1
什么?就这三行代码?在逗我么?你没看错,为了能让小白都理解,我们只用最简单的例子,来理解其功能,那么input
参数是啥呢?没错,就是.js
文件中输出的内容,我们就是在输出内容后边,加了个注释,证明自定义的loader
确实起作用了,话不多说,我们执行npm run build
命令,看下打包结果吧,由于打包结果内容太长,我们就直接截图几个关键位置:
看到没?截图里边,清清楚楚的标注了我们自定义loader
处理完.js
文件后,添加的注释,loader
奏效啦~好了,我们只需要了解至此,更复杂的loader
实现,之后会在专门的文章中来写。
编写最简单的自定义plugin
看了上边loader
的编写,是不是发现,诶,是不是没有想象中的那么难呢?接下来,我们依旧用相同的风格,来写一个最简单的plugin
在写plugin
之前,我们首先要了解下什么是tapable
。是不是曾经听说过这个词,但只是听说过?我们简单介绍下这个库。它其实就类似于Node
中的EventEmitter
库(啊?这,我不会Node啊),再说白点,就是个能实现发布订阅的库,为啥用它呢?webpack
通过tapable
把具体的实现,与编译流程进行了解耦,具体的实现,都是以插件的形式存在的。什么意思呢,说通俗点,webpack
提供了100多个钩子,在不同阶段的时候,会触发不同的钩子,我们写的插件,其实就是在开始编译前进行了订阅,然后在webpack
各种钩子触发的时候,进行了调用,仅此而已,所谓Talk is cheap, show me your code
,我们直接上代码,用最简洁的代码来介绍其中的原理:
/* 我们以一个钩子为例,比如有个钩子叫RunHook,作用就是在webpack打包开始的时候,会被触发,那么在内部,
核心的逻辑是这样的 */
class RunHook {
// es7写法,相当于语法糖,不用写constructor了
taps = []
// 订阅方法
tap(name, fn) {
// name就是起一个标识的作用,可以先不关注
this.taps.push(fn)
}
// 发布方法
call() {
this.taps.forEach(fn => fn())
}
}
// 我们通过new生成一个runHook钩子,并且订阅上runHook内部执行逻辑
const runHook = new RunHook()
runHook.tap('内部的run钩子', () => {
console.log('内部run钩子逻辑开始执行啦')
})
/* 当webpack打包开始的时候,就要触发这个runHook钩子,怎么触发呢?没错就是内部通过call方法,将taps存
放的所有之前订阅的方法,进行执行 */
runHook.call()
所以通过上边的代码,我们发现,如果
webpack
能把runHook
这个钩子暴露给我们,我们就可以先在runHook实例上订阅多个方法,在执行某一特定钩子的时候,就会一起被触发执行,这就是我们自定义插件的原理,明白了这个道理,我们就知道,为啥插件的格式要这样写了。
// run-plugin.js
// 我们定义一个RunPlugin类,里边的apply方法是按规定必须有的
class RunPlugin {
apply (compiler) {
/* compiler是贯穿webpack打包整个流程中的对象,里边提供了相当多的hook,我们这里以run这个hook为
例,希望在webpack打包开始的时候,执行下边的代码,看下边这个tap方法是不是很熟悉,没错就是我们上文提
到的订阅方法(tapable中不止提供了tap这一个订阅方法,其他的方法作用我们之后的文章会细说 )*/
compiler.hooks.run.tap('Run program:', () => {
console.log('程序开始运行啦')
})
}
}
module.exports = RunPlugin
我们在webpack.config.js
文件中引入这个插件,并且初始化,然后执行npm run build
命令,查看终端输出如下图,没错,插件生效了!
通过上文的学习,我们对自定义loader
和plugin
,应该有了一个初步的认知,对于我们学习webpack
整个的工作流程,有着至关重要的作用,接下来我们一起来按照每一步,手写一个简易版的webpack
。
webpack工作流程,10步走起
一共是十个步骤,我们先一一列举出来:
- 初始化参数:从配置文件和 Shell 语句中读取并合并参数,得出最终的配置对象
- 用上一步得到的参数初始化 Compiler 对象
- 加载所有配置的插件
- 执行
compiler
对象的 run 方法开始执行编译 - 根据配置中的
entry
找出入口文件 - 从入口文件出发,调用所有配置的
Loader
对模块进行编译 - 再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
- 根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk
- 再把每个 Chunk 转换成一个单独的文件加入到输出列表
- 在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
总的来说,上边十个步骤可以分为对应这几大类:初始化参数(1,2) -> 开始编译(3,4,5) -> 编译模块(6,7) -> 完成编译(8) -> 输出资源(9) -> 写入文件(10)
可能你现在看这个流程还是一脸懵的状态,不急,当我们写完整个代码,再回来看,就能理解每个步骤究竟做了什么事情。
再次强调一遍,这里说的编译完成不代表打包完成,也不代表最后的打包文件已经生成了!
1.初始化参数
webpack(webpackConfig)
// 我们在webpack.config.js同级目录下创建webpack.js文件,用于手写webpack源码
function webpack (options) {
// 1. 初始化参数:从配置文件和 Shell 语句中读取并合并参数,得出最终的配置对象
const argv = process.argv.slice(2)
// 将shell参数比如--mode=development转化为 {mode: 'development'}这种对象
const shellOptions = argv.reduce((shellOptions, options) => {
const [key, value] = options.split('=')
shellOptions[key.slice(2)] = value
return shellOptions
}, {})
const finalOptions = {...options, ...shellOptions}
console.log(finalOptions)
}
module.exports = webpack
// 我们在webpack.config.js同级目录下创建debugger.js文件,用于调试我们的webpack源码
const webpack = require('./webpack') // 引入我们自己的webpack.js文件
const webpackConfig = require('./webpack.config') // 传入webpack.config.js配置文件
我们接下来,在项目目录执行node debugger.js --mode=production
命令进行简单的调试,发现配置文件中的mode
,成功被命令行参数中的--mode=production
所替代了,说明这块逻辑写的没问题,我们继续进行下一步
2.初始化Compiler
对象
// 在webpack.js同级创建Compiler.js文件
class Compiler {
// todo...
}
module.exports = Compiler
// webpack.js
const Compiler = require('./Compiler')
function webpack (options) {
// 1. 初始化参数:从配置文件和 Shell 语句中读取并合并参数,得出最终的配置对象
const argv = process.argv.slice(2)
// 将shell参数比如--mode=development转化为 {mode: 'development'}这种对象
const shellOptions = argv.reduce((shellOptions, options) => {
const [key, value] = options.split('=')
shellOptions[key.slice(2)] = value
return shellOptions
}, {})
const finalOptions = {...options, ...shellOptions}
// 2.用上一步得到的参数初始化 Compiler 对象
const compiler = new Compiler(finalOptions)
return compiler
}
module.exports = webpack
3.加载所有配置的插件
const Compiler = require('./Compiler')
function webpack(options) {
// 1. 初始化参数:从配置文件和 Shell 语句中读取并合并参数,得出最终的配置对象
const argv = process.argv.slice(2)
// 将shell参数比如--mode=development转化为 {mode: 'development'}这种对象
const shellOptions = argv.reduce((shellOptions, options) => {
const [key, value] = options.split('=')
shellOptions[key.slice(2)] = value
return shellOptions
}, {})
const finalOptions = { ...options, ...shellOptions }
// 2. 初始化Compiler对象
const compiler = new Compiler(finalOptions)
// 3. 加载所有配置的插件,拿到配置项中的plugins(内部都是new出来的插件实例对象)
const { plugins } = finalOptions
for (let plugin of plugins) {
// 每个插件实例都一定有一个apply方法(之前我们在编写插件类的时候,就有一定要有apply方法,webpack规定如此)
// 注意,这里只是加载了,相当于只是注册了,之后再合适的时间点才会被触发执行
plugin.apply(compiler)
}
return compiler
}
module.exports = webpack
4.执行compiler
对象的run
方法开始执行编译
接下来才是重头戏,我们此时来再写一下Compiler
类的具体逻辑,看看核心的逻辑到底长啥样:
// debugger.js
const webpack = require('./webpack')
const webpackConfig = require('./webpack.config')
const compiler = webpack(webpackConfig)
compiler.run(() => {
console.log('compiler run方法执行啦!')
})
// tapable.js 我们在webpack.js同级目录,创建tapable.js,简单写一个SyncHook类
class SyncHook {
taps = []
tap(name, fn) {
this.taps.push(fn)
}
call() {
this.taps.forEach(fn => fn())
}
}
module.exports = SyncHook
// Compiler.js
// Compiler负责整个的编译过程,存放着所有的编译信息
const SyncHook = require('./tapable')
// Compiler负责整个编译过程,存放着所有的编译信息
class Compiler {
constructor(options) {
this.options = options
this.hooks = {
run: new SyncHook() // run钩子,在开始编译前会被调用,还有非常多钩子,这里不多列举
}
}
run(callback) {
this.hooks.run.call() // 在编译前开始触发run钩子
callback()
}
}
module.exports = Compiler
至此,我们运行node debugger.js
文件,发现我们自定义的插件已经能够正常运行了,webpack
的run
方法也成功被调用,便可以继续进行下边的步骤了。
5.根据配置中的entry找出入口文件
那么我们再次介绍下,Compiler
模块到底是什么呢?和接下来要提到的Compilation
又有什么关系呢?在webpack
官方文档上可以看到:
Compiler
模块是 webpack 的主要引擎,它通过 CLI 或者 Node API 传递的所有选项创建出一个compilation
实例。 它扩展(extends)自Tapable
类,用来注册和调用插件。 大多数面向用户的插件会首先在Compiler
上注册。
我们不需要记住很专业的定义,只需要简单把Compiler
模块理解为一个总指挥,在准备阶段的时候,大部分的插件都可以在其中进行定义,每次开始编译的时候,由它发布号令,创建一个compilation
实例,而这个compilation
实例就是个打工人,它才是真正干活的,compilation
实例能够访问所有的模块和它们的依赖,然后进行编译。
// compiler.js
const Compilation = require('./Compilation') // 可以先手动创建一个空白的文件,后文详细讲解
const SyncHook = require('./tapable')
const fs = require('fs')
// Compiler负责整个编译过程,存放着所有的编译信息
class Compiler {
constructor(options) {
this.options = options
this.hooks = {
run: new SyncHook() // run钩子,在开始编译前会被调用,还有非常多钩子,这里不多列举
}
}
run(callback) {
this.hooks.run.call() // 在编译前开始触发run钩子
/* 注意看onCompiled被当成参数,一直传递到了Compilation.build方法中,它其实是个回调(事实上里边
存在callback函数),在编译完后才会进行执行 */
const onCompiled = (err, stats, fileDependencies) => {
// 在编译的过程中,会收集所有的依赖模块
fileDependencies.forEach(fileDependency => {
// 监听依赖文件的变化,如果依赖的文件有变化,那么就开始一次新的编译
fs.watch(fileDependency, () => this.compile(onCompiled))
})
// 传入的回调函数,stats对象其实包括了modules,chunks,asset三个属性值里边的各种信息,后边会用到
// toJson是callback中提供的一个方法,可以返回stats的信息
callback(err, {
toJson: () => stats
})
}
// 调用compile进行编译
this.compile(onCompiled)
}
// 开启新的编译
compile (onCompiled) {
// 每次编辑,都会创建一个新的compilation实例,并且调用其build方法进行编译
let compilation = new Compilation(this.options)
// 5.根据配置中的entry找出入口文件
compilation.build(onCompiled)
}
}
module.exports = Compiler
可能你对fs.watch
这里比较困惑,什么叫监听依赖文件变化呢?我们来说个栗子🌰,你就明白了,刚开始学习webpack
的时候,你一定用过watch
这个参数,无论是在命令行里边敲的--watch
,或者就是在webpack.config.js
文件里进行对watch
参数的配置,那这个watch
还记得有啥作用么?哦吼,聪明的你这会可能已经知道为啥要用fs.watch
这个方法了,也知道什么叫依赖文件变化了,没错,这里的依赖文件就是指我们在写的项目代码呀。什么?还不理解,再说通俗点,每次打包是不是都要执行npm run build
命令呢,那如果加上了--watch
参数,是不是当你代码里有改动的时候,就不用再重新执行npm run build
命令,webpack
就会自动打包了呢,所以说么,靠的就是这个fs.watch
来监听依赖文件的变化,只要有变化,就开始一次新的编译。
还有人可能又来问了,为啥有Compiler
和Compilation
这两个类呢?多麻烦啊,搞一个不行么,前边也说过了,Compiler
相当于一个总管事,上边还挂载着所有的自定义插件,具体干活还得靠Compilation
,如果我们用Compiler
进行编译,那么每次执行编译的时候,都得挂载一次插件,完全是没必要的;就像一个新的前端项目,你的前端leader把项目创建好了,组件该封装的都封装好了,那么在之后每次有新的需求丢过来的时候,只需要你去写业务,干活就ok了,不需要每个新需求,都要你的leader重新搭建一遍基础架构,这不纯属闲的么...
6 ~ 9步 compilation
开始发功
前边其实都是一些打包的准备工作,接下来才是真正的核心部分,也最难得一部分,我们来详细的进行讲解说明,因为这几个步骤都是写在compiler.js
,所以一定要根据我标注的序号和代码执行的顺序(为了阅读习惯除了函数调用,都是按顺序从上到下),慢慢去阅读:
首先npm install @babel/core @babel/types -D
安装相应的依赖包(这个我们就不手写了,用人家Babel
提供的现成的库吧,毕竟ast
这块相关的东西...)
// 我们在Compiler.js同级目录创建Compilation.js文件
// Compilation.js
const path = require('path')
const fs = require('fs')
const core = require('@babel/core')
let types = require("@babel/types")
// 获取根目录,也就是当前目录,因为windows的分隔符是\,所以要用正则替换成unix系统下的分隔符/
let baseDir = process.cwd().replace(/\\/g, '/')
class Compilation {
constructor(options) {
this.options = options
this.modules = [] // 存放本次编译涉及到的模块
this.chunks = [] // 存放本次编译所组装的代码块
this.files = [] // 存放本次打包出来的文件内容
this.assets = {} // key是文件名,value是文件内容
this.fileDependencies = [] // 本次打包所依赖的模块
}
build(onCompiled) {
// 5.根据配置中的entry找出入口文件
// 声明入口对象
let entry = {}
if (typeof this.options.entry === 'string') {
// 入口文件可能是个字符串,其实是有个默认名字叫main
entry.main = this.options.entry
} else {
// 入口文件如果是个对象,就直接取这个对象
entry = this.options.entry
}
// 因为我们在entry中写的是相对路径,所以需要找到入口文件的绝对路径
for (let entryName in entry) {
if (entry.hasOwnProperty(entryName)) {
// 使用path.posix是为了统一使用 / 作为分隔符
let entryFilePath = path.posix.join(baseDir, entry[entryName])
// 把依赖的文件路径,push进依赖模块数组中
this.fileDependencies.push(entryFilePath)
// 执行buildModule方法,传入模块名和路径,进行到了第6步
let entryModule = this.buildModule(entryName, entryFilePath)
// 所有的模块经过loader处理过后,就到了第8步
// 8.根据入口和模块之间的依赖关系,组装成多个包含多个模块的chunk
let chunk = {
name: entryName, //入口文件名称
entryModule, // 经过处理的入口模块即其依赖的模块
modules: this.modules.filter(item => item.names.includes(entryName))
}
this.chunks.push(chunk)
}
}
// 9.再把每个chunk转换成一个单独的文件加入到输出列表,即放入到asset对象中
this.chunks.forEach(chunk => {
// 将webpack.config.js中filename配置的名称做正则替换
let filename = this.options.output.filename.replace('[name]', chunk.name)
// asset输出列表中,key为最终的filename,value为getSource处理过后对应的代码
this.assets[filename] = getSource(chunk)
})
onCompiled(null, {
modules: this.modules,
chunks: this.chunks,
files: this.files,
assets: this.assets
}, this.fileDependencies)
}
buildModule(name, modulePath) {
// 6.从入口文件出发,调用我们配置中所有的loader对模块进行编译
// 根据路径,读取文件源码,注意,此时独取出来的结果,都是字符串
let sourceCode = fs.readFileSync(modulePath, 'utf-8')
// 获取我们配置的loader
let { rules } = this.options.module
let loaders = []
rules.forEach(rule => {
let { test } = rule
// 自定义规则里的正则,能够匹配上我们的文件路径,就把loader的路径push到loaders数组里
/* 注意,这里的逻辑简化了许多其他情况,比如配置可以不写use,直接写loader: xxx,所以webpack.config.js配置文件里一定要按照我这个写法
来写,弄明白一个逻辑,其他兼容的逻辑后期再补上也是可以的 */
if (test.test(modulePath) {
loaders.push(...rule.use) // 此时的loaders数组里内容为['loader1的绝对路径']
}
})
/* 按从右至左的顺序,执行每一个loader,并传入sourceCode来让loader处理,这也就说明了为啥loader是一个函数,同样我们这里简化了逻辑,统一使用commonJS
规范进行导入导出,这样,得到的sourceCode就是当前文件被所有loader处理后的结果了 */
sourceCode = loaders.reduceRight((sourceCode, loader) => {
return require(loader)(sourceCode)
}, sourceCode)
/* 创建module,来记录入口文件和入口文件所依赖的模块的信息,这个module又是啥呢?没错,和我们webpack系列文章,第一篇文章中提到的打包结果中
的modules息息相关,我们一步一步看是如何生成的 */
/* var modules = {
"./src/title.js": module => {
module.exports = 'title';
}
} */
// 模块的id都是以相对路径进行命名的
let moduleId = './' + path.posix.relative(baseDir, modulePath)
// dependencies代表当前模块属于哪些模块的依赖项,比如我们当前案例,title.js就属于entry1.js和entry2.js的依赖项,name是模块所属代码块的名字
let module = { id: moduleId, dependencies: [], names: [name] }
// 到此步骤,我们只是把当前入口文件的代码用loader处理过了,但当前入口文件所依赖的模块还没有被处理,所以我们接下来要进行第7步了
// 7.第 7步第一环节,先找出入口文件依赖的模块,怎么去找呢?没错就是我们上篇文章提到的ast
/* 我们还是用上篇文章提到的@babel/core和@babel/types包进行ast的逻辑的处理,当然也可以用@babel/parser等包进行处理,这里为了和前一篇文章对接上,
所以依旧使用@babel/core这个包 */
const resolveDependencyPlugin = {
visitor: {
// 我们要访问type是CallExpression的路径
CallExpression: ({ node }) => {
if (node.callee.name === "require") {
//获取依赖模块的相对路径,比如当前文件是entry1.js那么获取到的就是entry.js文件中require传入的参数,即获取到'./title'
let depModuleName = node.arguments[0].value
//获取当前模块的所在的目录,即entry1.js所在的目录的绝对路径,即'/xxx/xxx/xxx/src'
let dirname = path.posix.dirname(modulePath)
// 进行拼接,获得'title.js'的绝对路径,即将entry1.js的目录和title.js相对路径进行拼接,得到了'/xxx/xxx/xxx/src/title'
let depModulePath = path.posix.join(dirname, depModuleName)
// 找到了模块依赖文件的路径后,我们还需要拼接扩展名,用tryExtentions一个一个的拼接后缀名去尝试,判断存不存在
let extensions = this.options.resolve.extensions
// 这时得到的才是带有后缀名的完整文件路径
depModulePath = tryExtensions(depModulePath, extensions)
// 将依赖路径push到总的依赖数组中
this.fileDependencies.push(depModulePath)
//生成当前依赖模块的模块id(即'title.js'模块)
let depModuleId = './' + path.posix.relative(baseDir, depModulePath)
// 修改ast,把entry1.js中的require('./title')换成模块id,即require('./src/title.js'),这点可以从我们第一篇文章的打包结果里看到
node.arguments[0].value = types.stringLiteral(depModuleId).value // ./title => ./src/title.js
// 把入口文件依赖的模块id和模块录几个,都放到入口文件以来的数组中
module.dependencies.push({ depModuleId, depModulePath })
}
}
}
}
const result = core.transform(sourceCode, {
plugins: [resolveDependencyPlugin]
})
module._source = result.code; //module._source存放着改造后的源码
// 7. 第 7步骤第二环节,找到当前入口文件即entry1.js依赖的路径之后,递归调用this.buildModule直到所有当入口文件依赖的模块都经过了的处理
module.dependencies.forEach(({ depModuleId, depModulePath }) => {
/* 判断当前入口文件被依赖的模块是不是已经被编译过了,如果是,就把namepush到names数组中即可,不需要再次进行递归编译
比如entry1.js依赖title.js,title.js第一次被编译,entry2.js也依赖title.js,这时候就不需要再去递归编译了 */
let existModule = this.modules.find(item => item.id === depModuleId)
if (existModule) {
existModule.names.push(name)
} else {
// 递归用loader处理依赖模块
let depModule = this.buildModule(name, depModulePath)
this.modules.push(depModule)
}
})
return module
}
}
function tryExtensions(modulePath, extensions) {
// 根据模块路径和webpack.config.js中配置的扩展名,进行拼接,然后查找文件在不在
if (fs.existsSync(modulePath)) {
return modulePath;
}
for (let i = 0; i < extensions.length; i++) {
let filePath = modulePath + extensions[i];
if (fs.existsSync(filePath)) {
return filePath;
}
}
throw new Error(`找不到${modulePath}这个路径下的文件!`);
}
function getSource(chunk) {
/* 下边这些内容,是不是很眼熟呢?没错,就是我们第一篇文章中分析的webpack打包结果里边的代码,遍历chunk.modules,添加到modules变量中,
入口文件的代码也进行变量替换,其它的模板代码是固定的 */
return `
(() => {
var modules = {
${chunk.modules.map(
(module) => `
"${module.id}": (module) => {
${module._source}
},
`
)}
};
var cache = {}
function require(moduleId) {
var cachedModule = cache[moduleId]
if (cachedModule !== undefined) {
return cachedModule.exports
}
var module = (cache[moduleId] = {
exports: {}
})
modules[moduleId](module, module.exports, require)
return module.exports
}
var exports = {}
${chunk.entryModule._source}
})()
`
}
module.exports = Compilation
10.在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
最后这个步骤是写在Compiler.js
文件中的onCompiled
回调函数中的,下边是Compiler.js
的完整代码
const Compilation = require('./Compilation')
const SyncHook = require('./tapable')
const fs = require('fs')
const path = require('path')
// Compiler负责整个编译过程,存放着所有的编译信息
class Compiler {
constructor(options) {
this.options = options
this.hooks = {
run: new SyncHook() // run钩子,在开始编译前会被调用,还有非常多钩子,这里不多列举
}
}
run(callback) {
this.hooks.run.call() // 在编译前开始触发run钩子
//注意看onCompiled被当成参数,一直传递到了Compilation.build方法中,它其实是个回调(事实上里边存在callback函数),在编译完后才会进行执行
const onCompiled = (err, stats, fileDependencies) => {
//10在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
for (let filename in stats.assets) {
let filePath = path.posix.join(this.options.output.path, filename)
fs.writeFileSync(filePath, stats.assets[filename], 'utf8')
}
// 传入的回调函数,stats对象其实包括了modules,chunks,asset三个属性值里边的各种信息
// toJson是callback中提供的一个方法,可以返回stats的信息
callback(err, {
toJson: () => stats
})
// 在编译的过程中,会收集所有的依赖模块
fileDependencies.forEach(fileDependency => {
// 监听依赖文件的变化,如果依赖的文件有变化,那么就开始一次新的编译
fs.watch(fileDependency, () => this.compile(onCompiled))
})
}
// 调用compile进行编译
this.compile(onCompiled)
}
// 开启新的编译
compile (onCompiled) {
// 每次编辑,都会创建一个新的compilation实例,并且调用其build方法进行编译
let compilation = new Compilation(this.options)
// 5.根据配置中的entry找出入口文件
compilation.build(onCompiled)
}
}
module.exports = Compiler
好啦,到此为止,我们亲自手写了一个简单的webpack
,再次强调一下,webpack.config.js
一定要按照文章中的来配置,因为只是手写核心的工作流程,所以如果配置成了其他的格式,就会报错。
我们执行node debugger.js
,命令,会惊喜的发现,在我们文件根目录,生成了dist
文件,里边便是最终打包后的代码了!而且在打包过程中,我们自定义的插件,和自定义的loader
也同样能够正常的运行。那么有兴趣的小伙伴,可以修改相关的webpack.config.js
的配置,然后再根据修改的配置,去兼容完善我们手写的webpack
源码里边相对应的逻辑,这样更能够加深对webpack
的理解。
结语
当我第一次尝试手写webpack的时候,也是望而生畏,感觉是一道不可逾越的鸿沟,但抛开具体细节不谈,完整的写了一个简版的webpack
之后,会发现,对跟webpack
的整个工作流程有了一个宏观的理解,对loader
和plugin
是如何工作的,也有了全新的认知,不止停留在只会配置的阶段,那么再去看webpack
原理相关文章的时候,也不会有看天书的感觉了,也能够慢慢的参与进去。
写这篇文章,也花了我3天的时间,包括了不断的调试,站在小白的角度来写注释和提一些问题。当然,因为时间仓促会有很多瑕疵,如果有好的意见,可以随时来沟通交流~
转载自:https://juejin.cn/post/7154192670326259719