likes
comments
collection
share

从零开始的Webpack原理剖析(四)——webpack工作流程

作者站长头像
站长
· 阅读数 16

前言

经过前三篇文章的学习,我们已经积累了webpack的一些必备基础知识,可是依旧不知道,webpack究竟从npm run build开始打包,到结束之间的流程是什么样的,那么这篇文章,我们可以根据几个关键的步骤,手写一个简易版本的webpack,有助于我们对webpack打包全流程的理解。在讲解完整流程之前,我们先做一下基本的准备,包括文件创建,如何编写wepack的loader,如何编写webapck的plugin,千万别打退堂鼓,这都是为了弄明白webpack原理必备的知识,而且我们只需要如何写最简单loaderplugin即可,至于复杂的loaderplugin,之后的文章会单独拿出来讲如何去写。在写本篇文章之前,可以说我也是个小白,对整个工作流程模模糊糊,所以,我会以小白都能看懂的方式,来详细说明webpack的工作流程是什么样的;

当然,如果你是为了应付面试,没空来看完整的文章,那就直接把下文的十个步骤全都背下来,这几乎就是标准答案了,然后祈祷面试官不要深挖就好🙏🏻;如果看完本篇文章,那这道题对你来说,基本上就不会被难倒了。

认真学完这篇文章你能:

  • 了解loaderplugin的书写方法和简单的原理;
  • 复习一些常见的文件读写Node API
  • 手写一个有核心功能的简版webpack,清楚webpack从开始编译到输出文件,做了什么事情;
  • 不惧怕webpack原理相关的面试题;

前置准备

在这个模块,我们将简单讲解如何编写webpackloaderplugin,并且讲解一下为什么要这样写,之后再初始化我们项目中需要的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命令,看下打包结果吧,由于打包结果内容太长,我们就直接截图几个关键位置:

从零开始的Webpack原理剖析(四)——webpack工作流程 从零开始的Webpack原理剖析(四)——webpack工作流程

看到没?截图里边,清清楚楚的标注了我们自定义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命令,查看终端输出如下图,没错,插件生效了!

从零开始的Webpack原理剖析(四)——webpack工作流程

通过上文的学习,我们对自定义loaderplugin,应该有了一个初步的认知,对于我们学习webpack整个的工作流程,有着至关重要的作用,接下来我们一起来按照每一步,手写一个简易版的webpack

webpack工作流程,10步走起

一共是十个步骤,我们先一一列举出来:

  1. 初始化参数:从配置文件和 Shell 语句中读取并合并参数,得出最终的配置对象
  2. 用上一步得到的参数初始化 Compiler 对象
  3. 加载所有配置的插件
  4. 执行compiler对象的 run 方法开始执行编译
  5. 根据配置中的entry找出入口文件
  6. 从入口文件出发,调用所有配置的Loader对模块进行编译
  7. 再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
  8. 根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk
  9. 再把每个 Chunk 转换成一个单独的文件加入到输出列表
  10. 在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

总的来说,上边十个步骤可以分为对应这几大类:初始化参数(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所替代了,说明这块逻辑写的没问题,我们继续进行下一步

从零开始的Webpack原理剖析(四)——webpack工作流程

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文件,发现我们自定义的插件已经能够正常运行了,webpackrun方法也成功被调用,便可以继续进行下边的步骤了。

从零开始的Webpack原理剖析(四)——webpack工作流程

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来监听依赖文件的变化,只要有变化,就开始一次新的编译。

还有人可能又来问了,为啥有CompilerCompilation这两个类呢?多麻烦啊,搞一个不行么,前边也说过了,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的整个工作流程有了一个宏观的理解,对loaderplugin是如何工作的,也有了全新的认知,不止停留在只会配置的阶段,那么再去看webpack原理相关文章的时候,也不会有看天书的感觉了,也能够慢慢的参与进去。

写这篇文章,也花了我3天的时间,包括了不断的调试,站在小白的角度来写注释和提一些问题。当然,因为时间仓促会有很多瑕疵,如果有好的意见,可以随时来沟通交流~