吐血整理webpack经典面试+ 手写题

439 阅读6分钟

webpack 到底是如何工作的,原理是什么?了解了这些原理,我们应该如何扩展,以解决工作中的实际问题?

吐血整理了一波 webpack 经典面试题 + 手写基本原理.

一、webpack运行流程

webpack的运行过程可以简单概述如下:

初始化配置参数 -> 绑定事件钩子回调 -> 确定Entry逐一遍历 -> 使用loader编译文件 -> 输出文件.

具体流程:

  1. 首先,webpack会读取你在命令行传入的配置以及项目里的 webpack.config.js 文件,初始化本次构建的配置参数,并且执行配置文件中的插件实例化语句,生成Compiler传入plugin的apply方法,为webpack事件流挂上自定义钩子.

  2. 接下来到了entryOption阶段,webpack开始读取配置的Entries,递归遍历所有的入口文件

  3. Webpack接下来就开始了compilation过程。会依次进入其中每一个入口文件(entry),先使用用户配置好的loader对文件内容进行编译(buildModule),我们可以从传入事件回调的compilation上拿到module的resource(资源路径)、loaders(经过的loaders)等信息;之后,再将编译好的文件内容使用acorn解析生成AST静态语法树(normalModuleLoader),分析文件的依赖关系逐个拉取依赖模块并重复上述过程,最后将所有模块中的require语法替换成__webpack_require__来模拟模块化操作。

  4. emit阶段,所有文件的编译及转化都已经完成,包含了最终输出的资源,我们可以在传入事件回调的compilation.assets 上拿到所需数据,其中包括即将输出的资源、代码块Chunk等等信息。

二、webpack打包原理

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')

const getModuleInfo = (file) => {
  const body = fs.readFileSync(file, 'utf-8')
  // console.log(body)
  // 1 将我们提供的代码解析成完整的ECMAScript代码的AST
  const ast = parser.parse(body,{
    sourceType: 'module'
  })
  // console.log(ast.program.body)
  // 2 遍历AST,将用到的依赖收集起来
  const deps = {}
  traverse(ast, {
    ImportDeclaration({node}) {
      const dirname = path.dirname(file)
      // value指的就是import后面的 './add.js'
      const abspath = './' + path.join(dirname, node.source.value)
      deps[node.source.value] = abspath
    }
  })
  console.log(deps)
  // 3 把获得的ES6的AST转化成ES5
  const {code} = babel.transformFromAst(ast, null, {
    presets: ['@babel/preset-env']
  })
  // console.log(code)
  const moduleInfo = {file, deps, code}
  return moduleInfo;
}

// 4,获取依赖, 类似一个层序遍历
const parseModules = (file) => {
  const entry = getModuleInfo(file)
  const temp = [entry]
  const depsGraph = {} // map
  for(let i = 0; i < temp.length; i++) {
    const deps = temp[i].deps
    if(deps) {
      for(const key in deps) {
        if(deps.hasOwnProperty(key)) {
          temp.push(getModuleInfo(deps[key]))
        }
      }
    }
  }
  temp.forEach(moduleInfo => {
    depsGraph[moduleInfo.file] = {
      deps: moduleInfo.deps,
      code: moduleInfo.code
    }
  })
  // console.log(depsGraph)
  return depsGraph;
}

// 5, 把index.js的内容和它的依赖模块整合起来。然后把代码写到一个新建的bundle.js文件
// 浏览器不识别执行require和exports, 需要定义
const bundle = (file) => {
  const depsGraph = JSON.stringify(parseModules(file))
  // eval(code)。也就是执行主模块的code这段代码
  return `(function(graph){
    function require(file) {
      function absRequire(relPath){
        return require(graph[file].deps[relPath])
      }
      var exports = {};
      (function(require, exports, code){
        eval(code)
      })(absRequire, exports, graph[file].code)
      return exports;
    }
    require('${file}')
  })(${depsGraph})`
}

const content = bundle('./src/index.js')

console.log(content)

fs.mkdirSync('./dist')
fs.writeFileSync('./dist/bundle.js', content)

三、webpack 将代码编译成了什么?

webpack 打包结果就是一个 IIFE,一般称它为 webpackBootstrap,这个 IIFE 接收一个对象 modules 作为参数,modules 对象的 key 是依赖路径,value 是经过简单处理后的脚本(它不完全等同于我们编写的业务脚本,而是被 webpack 进行包裹后的内容)。

打包结果中,定义了一个重要的模块加载函数 webpack_require。

我们首先使用 webpack_require 加载函数去加载入口模块 ./src/index.js。 加载函数 webpack_require 使用了闭包变量 installedModules,它的作用是将已加载过的模块结果保存在内存中。

四、按需加载打包结果,webpack 又会产出什么样的代码呢?

重新构建后会输出两个文件,分别是执行入口文件 main.js 和异步加载文件 0.js;

main.js:

多了一个 webpack_require.e:它初始化了一个 promise 数组,使用 Promise.all() 进行异步插入 script 脚本;

多了一个 webpackJsonp: 它会挂在到全局对象 window 上,进行模块安装。

五、compiler 和 compilation

compiler 和 compilation 这两个对象是 webpack 核心原理中最重要的概念。它们是理解 webpack 工作原理、loader 和插件工作的基础。

compiler 对象:它的实例包含了完整的 webpack 配置,全局只有一个 compiler 实例,因此它就像 webpack 的骨架或神经中枢。当插件被实例化的时候,会收到一个 compiler 对象,通过这个对象可以访问 webpack 的内部环境。

compilation 对象:当 webpack 以开发模式运行时,每当检测到文件变化,一个新的 compilation 对象将被创建。这个对象包含了当前的模块资源、编译生成资源、变化的文件等信息。也就是说,所有构建过程中产生的构建数据都存储在该对象上,它也掌控着构建过程中的每一个环节。该对象也提供了很多事件回调供插件做扩展。

六、plugin

plugin 的实现可以是一个带有 apply方法 的 类class,使用时传入相关配置来创建一个实例,然后放到配置的 plugins 字段中,而 plugin 实例中最重要的方法是 apply,该方法在 webpack compiler 安装插件时会被调用一次,apply 接收 webpack compiler 对象实例的引用,你可以在 compiler 对象实例上注册各种事件钩子函数,来影响 webpack 的所有构建流程,以便完成更多其他的构建任务。

事件钩子可以理解为当 webpack 运行中执行到某个钩子的状态时,便会触发你注册的事件,即发布订阅模式。

Compiler对象包含了所有的webpack可配置的内容。开发插件时,我们可以从 compiler 对象中拿到所有和 webpack 主环境相关的内容。

compilation 对象包含了当前的模块资源、编译生成资源、文件的变化等。当webpack在开发模式下运行时,每当检测到一个文件发生改变的时候,那么一次新的 Compilation将会被创建。从而生成一组新的编译资源。

Compiler对象 与 Compilation 对象 的区别是:Compiler代表了是整个webpack从启动到关闭的生命周期。Compilation 对象只代表了一次新的编译。

class FileListPlugin {
  constructor(options) {
    // 读取 plugin 实例化时传入的配置
  }

  apply(compiler) {
    // 在 compiler 的 emit hook 中注册一个方法,当 webpack 执行到该阶段时会调用这个方法
    // 在我们的emit钩子事件发生时,表示的含义是:源文件的转换和组装已经完成了,在这里事件钩子里面我们可以读取到最终将输出的资源、代码块、模块及对应的依赖文件。并且我们还可以输出资源文件的内容。
    compiler.hooks.emit.tap('FileListPlugin', (compilation) => {
      // 给生成的 markdown 文件创建一个简单标题
      var filelist = 'In this build:\n\n'

      // 遍历所有编译后的资源,每一个文件添加一行说明
      for (var filename in compilation.assets) {
        filelist += ('- '+ filename +'\n')
      }

      // 将列表作为一个新的文件资源插入到 webpack 构建结果中
      compilation.assets['filelist.md'] = {
        source: function() {
          return filelist
        },
        size: function() {
          return filelist.length
        },
      }
    })
  }
}

module.exports = FileListPlugin

使用

// 假设我们上述那个例子的代码是 ./plugins/FileListPlugin 这个文件
const FileListPlugin = require('./plugins/FileListPlugin.js')

module.exports = {
  // ... 其他配置
  plugins: [
    new FileListPlugin(), // 实例化这个插件,有的时候需要传入对应的配置
  ],
}

暂时总结这么多,后续会继续更新,可以点个关注或者收藏本文。