webpack原理浅析

151 阅读15分钟


webpack原理浅析

前言

随着前端技术的飞速发展,前端开发也从静态页面发展到了web应用,简单的静态页面已经不能满足前端开发的需求。这时涌现了大量的工程化工具,例如早期的gulp,grunt等任务流工具,他们类似于使用javascript完成了shell的一些功能。这些工具虽然解决了大量静态文件的批处理问题,但本质上还是对静态文件的操作。

而webpack的出现将前端工程化提升到了一个新的高度,它可谓是将前端的模块化功能发挥到了极致,进而也成为了目前前端市场最火爆的打包工具。在这篇文章里,我们将会为您分析一下webpack的打包原理,帮助您更好的理解这一前端打包神器。

Webpack打包文件分析

webpack是一个现代javascript模块打包器,由于commonJS提出了js的模块化规范,webpack很好的利用了这一规范。奉行一切文件皆模块的原则,会依据入口文件递归的编译并打包所有依赖到的文件,并将这些文件打包成一个或多个bundle。

您也许会想webpack到底把文件打包成了什么?在实际工作中我们并不需要去关心这个问题,因为webpack已经帮我们做好了一切,并且webpack打包后的代码非常难以阅读。不管是复杂的项目或者简简单单的几行代码,打包产出的结果本质是相同的。接下来就让我们一起探究其打包本质,了解webpack是如何打包我们的源文件的。

首先先初始化我们的项目:

mkdir webpack-bundle-analysis
cd webpack-bundle-analysis
npm init 或者 yarn init

接下来安装webpack依赖:

npm install --save-dev webpack webpack-cli
  或者
yarn add --dev webpack webpack-cli

然后在根目录创建我们的源文件:

#index.js
const { add } = require('./add');
add(1, 1);


#add.js
export function add (a, b) {
  return a + b;
}

接下来就到了配置webpack的阶段了,在这里我们只需要最简单的webpack配置即可:

#webpack.config.js
module.exports = {
  mode: 'development',
  entry: {
    index: './index.js',
  },
}

注意:这里一定要将mode设置为development,否则webpack会默认使用生产模式打包代码,使得代码难以阅读。

接下来在我们的package.json文件内填写如下打包指令:

  "scripts": {
    "build": "webpack --config ./webpack.config.js",
  },

最后我们只需要执行 npm run build 或者yarn run build我们就会看见webpack已经帮我们把代码打包到了根目录的/dist/index.js文件内。

现在让我们来看一下webpack打包后的代码:

/******/ (function(modules) { // webpack启动函数
/******/  // webpack会在此处缓存已经加载过的模块,以防止模块的重复加载
/******/  var installedModules = {};
/******/
/******/  // webpack自定义的模块加载函数
/******/  function __webpack_require__(moduleId) {
/******/
/******/    // 如果模块已经被缓存则使用缓存中的模块
/******/    if(installedModules[moduleId]) {
/******/      return installedModules[moduleId].exports;
/******/    }
/******/    // 创建一个模块并将该模块放入缓存中,注意:这里的exports属性为空对                象,webpack会在下面的执行模块方法是将模块导出的对象挂载到module.exports
/******/    var module = installedModules[moduleId] = {
/******/      i: moduleId,
/******/      l: false,
/******/      exports: {}
/******/    };
/******/
/******/    // 执行模块方法,并将模块导出对象挂载在module.exports
/******/    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/    // 标记模块已加载过
/******/    module.l = true;
/******/
/******/    // 返回模块导出对象
/******/    return module.exports;
/******/  }
/******/
/******/
/******/  // expose the modules object (__webpack_modules__)
/******/  __webpack_require__.m = modules;
/******/
/******/  // expose the module cache
/******/  __webpack_require__.c = installedModules;
/******/
/******/  // 这个方法很关键,我们可以在启动函数参数内部的./add.js模块中看到此方法,此方法便起到了挂载导出对象的作用
/******/  __webpack_require__.d = function(exports, name, getter) {
/******/    if(!__webpack_require__.o(exports, name)) {
/******/      Object.defineProperty(exports, name, { enumerable: true, get: getter });
/******/    }
/******/  };


/******/  __webpack_require__.r = function(exports) {
/******/    if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/      Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/    }
/******/    Object.defineProperty(exports, '__esModule', { value: true });
/******/  };
                                .
                                .
                                .
/******/
/******/
/******/  // 加载入口模块并将导出模块返回
/******/  return __webpack_require__(__webpack_require__.s = "./index.js");
/******/ })
/************************************************************************/
/******/ ({




/***/ "./add.js":
/*!****************!*\
  !*** ./add.js ***!
  \****************/
/*! exports provided: add */
/***/ (function(module, __webpack_exports__, __webpack_require__) {




"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"add\", function() { return add; });\nfunction add (a, b) {\n  return a + b;\n}\n\n//# sourceURL=webpack:///./add.js?");




/***/ }),




/***/ "./index.js":
/*!******************!*\
  !*** ./index.js ***!
  \******************/
/*! no static exports found */
/***/ (function(module, exports, __webpack_require__) {




eval("const { add } = __webpack_require__(/*! ./add */ \"./add.js\");\n\nadd(1, 1);\n\n//# sourceURL=webpack:///./index.js?");




/***/ })


从上面代码我们可以看出webpack的打包结果实际上就是一个立即执行函数表达式(IIFE)。

function(...args){})(...args)

参数是一个模块对象,对象的键值是打包模块module_id,即模块相对根目录的相对路径,对象的值则是一个方法。这个方法接收三个参数,第一个参数即该模块对象,第二个参数为模块的导出对象,第三个模块是webpack自定义的模块导入方法__webpack_require__。该导入方法接收一个参数:模块id,在该方法内部首先会判断webpack是否缓存过该模块,如果缓存过便从缓存对象installedModules取出该模块,如果没有缓存便会定义该模块对象并加入缓存,接下来执行模块方法并使用定义好的__webpack_require__.d将执行方法定义在模块的导出对象上。最后标记此模块已加载过,并返回该模块导出对象。

Webpack打包过程分析

前文我们简单分析了webpack打包后的代码,虽然看起来比较晦涩难懂,但当我们提取出代码的关键部分之后,一切困难也就瞬间迎刃而解了。但是,知其然也要知其所以然,我们也要了解webpack的如何编译代码的。接下来,我们将通过webpack的源码来逐步解析webpack的代码编译过程。

在分析webpack的打包流程之前,我们先简单了解一下webpack配置的几个核心概念,webpack的打包会依赖这几个概念:

入口(entry):入口起点指示webpack应该以哪个模块作为打包构建的起点,webpack会从该起点开始不断遍历依赖模块进行打包构建。

loader:loader可以帮助webpack处理非.js(es5)类型的文件(webpack只能解析js文件),loader将不同类型的模块解析成webpack可以处理的模块。

plugin:插件的功能极为强大,其职责范围极广,从打包,优化到最后的输出。它的功能贯穿整个webpack打包流程。

优化(optimization):webpack资源优化,包含分离第三方模块,文件大小控制等等。

分块(Chunk):chunk即代码块,一个代码块由一个或多个模块组成,用于代码合并与分割。

哈希(hash): 代码块哈希命名

出口(output):output 属性告诉 webpack 在哪里输出它所创建的

bundles

,以及如何命名这些文件。

了解了上述基本概念之后,我们应该能对webpack打包流程有了一个粗浅的认识:即webpack从入口模块开始打包,并寻找其依赖,递归的将所有依赖模块打包,每次遇到webpack无法解析的模块,便使用loader将其解析成为webpack可以理解的模块。在打包结束后会对资源进行优化,比如分离第三方模块等等,然后进行代码块的分割创建,哈希命名,最后输出到指定文件目录。而插件则贯穿整个打包流程,打包过程中的每一步都需要插件来提供其能力。

简单了解完webpack的打包流程之后,接下来该进入本篇文章的重点了,我们会先介绍一下webpack打包所依赖的一些核心对象,然后根据列举源码关键流程,让大家能够通过源码去进一步熟悉该流程。

webpack的打包流程是采用事件流的模式,其内部使用Tapable进行事件定义。

Tapable是一个类似于nodejs的EentEmitter的库,主要控制钩子函数的事件发布和事件订阅。而webpack内部定义着大量功能丰富的插件,我们在前面提到过插件的功能贯穿了整个webpack打包流程,这些插件都会注册在Tapable定义的事件上,在打包过程中会依次有序的触发这些事件以调用插件执行其功能。

webpack内部定义了两个核心对象:compiler和compilation。这两个对象都集成自Tapable类,以便在其内部定义事件调用插件

Compiler:Compiler是webpack编译打包的核心对象,webpack编译每次编译开始时会创建一个全局唯一的compiler对象,compiler对象再创建一个compilation对象来负责模块的打包流程。

Compilation:compilation对象是Compiler编译过程中由compiler对象创建的,其内部也定义了大量的事件钩子以便插件调用。compilation负责打包的整个流程:加载(loaded),封存(sealed),优化(optimization),分块(chunked)和重新创建(restored)。当采用非watch模式启动编译时,只会创建一次compilation对象,当使用watch模式时(通常使用webpack-dev-server调用底层代码进行监听),每次模块内容变动时都会创建一个新的compilation对象。

上面我们了解了webpack的核心对象,现在让我们来分析webpack的源码。通常阅读源码时我们需要从主模块来开始阅读,其主模块为/node_modules/webpack/lib/webpack.js文件。主模块的webpack方法内首先会检查我们的配置文件格式是否有误。当配置文件格式准确无误时,接下来便开始创建compiler对象

    compiler = new Compiler(options.context);

在webpack方法的底部会判断是否由监听模式启动(通常我们会使用webpack-dev-server来启动监听模式),如果为监听模式,便会调用compiler对象的watch方法,反之便会调用run方法。

    if (
      options.watch === true ||
      (Array.isArray(options) && options.some(o => o.watch))
    ) {
      const watchOptions = Array.isArray(options)
        ? options.map(o => o.watchOptions || {})
        : options.watchOptions || {};
      return compiler.watch(watchOptions, callback);
    }
    compiler.run(callback);

我们先不要急着离开这里。文件的下面导出了大量webpack内置的插件,这些插件内部会监听不同的事件。即上面所说的,webpack编译过程中会触发不同的事件,那是便是这些插件大显神通的时候了。

#compiler.js
exportPlugins(exports, {
  AutomaticPrefetchPlugin: () => require("./AutomaticPrefetchPlugin"),
  BannerPlugin: () => require("./BannerPlugin"),
    ...

接下来让我们走进webpack的编译过程。

调用compiler实例的run方法时,会依次触发beforeRun和run两个钩子

beforeRun

compiler.run() 执行之前,添加一个钩子

run

正是启动一次新的编译

#compiler.js method:run()  
this.hooks.beforeRun.callAsync(this, err => {
      if (err) return finalCallback(err);
      this.hooks.run.callAsync(this, err => {
        if (err) return finalCallback(err);
        this.readRecords(err => {
          if (err) return finalCallback(err);
          this.compile(onCompiled);
        });
      });
    });

在compiler.run方法内部会触发compiler的compile方法,compiler.compile方法内部会创建一个compilation对象

#compiler.js method:compile()
const compilation = this.newCompilation(params);

创建完compilation对象过程中,会触发thisCompilation和compilation钩子。

thisCompilation

触发 compilation 事件之前执行。

compilation

表示一个compilation对象创建完成

 #compiler.js method: newCompilation()  
 this.hooks.thisCompilation.call(compilation, params);
 this.hooks.compilation.call(compilation, params);

在compilation钩子执行过程中,会调用不同的loader来完成对文件的编译工作。同时也会触发一些文件转换的钩子事件。

buildModule

在模块构建开始之前触发

normalModuleLoader

普通模块loader钩子,会一个接一个的加载模块图中的所有模块,并使用acorn将文件转换成AST以供webpack进行分析

seal

所有的模块都通过loader转换完成时,开始生成chunk

创建完compilation对象,回到compiler的compile方法内部,触发make钩子。

make

开启模块编译过程,从entry入口开始不断递归依赖

#compiler.js method:compile()
this.hooks.make.callAsync(compilation, err => {

编译过程结束,接下来调用compilation对象的finish方法,在finish方法内部会触发compilation对象的finishModule钩子,代表所有模块全部完成构建。

finishModule

所有模块全部完成构建

后面会调用compilation的seal方法,seal方法内部完成的功能比较多,会依次完成模块的优化,哈希化等工作。这里涉及了很多打包过程的细节,由于我们今天只探讨webpack的打包流程,便不对这些细节一一列举。

当所有的模块打包工作做好之后,便会触发compiler对象的afterCompile钩子,代表此次编译已经完成。

#compiler.js method: compile()
this.hooks.afterCompile.callAsync(compilation, err => {

上述过程全部结束之后会根据开发者的配置文件出口配置将输出文件打包到相应的输出目录。

Webpack是一个及其庞大的项目,源码相对来说也非常复杂,这里只列出关键流程,帮助大家能够对webpack打包过程有一个比较清晰的认识。

编写loader

了解完了webpack的打包工作流程后,大家大概也能明白loader在webpack打包过程中起到了编译源文件的作用,即将非js文件转换为webpack可以理解的模块。如果把loader比作为一个系统,它的输入就是我们项目中的源文件,输出就是编译后文件。babel-loader可以将es6语法的js文件转换为es5语法的文件,less-loader可以将less文件转换为css文件。

有时,loader做文件的转换时也不是一步完成的,需要多个loader的多步转化才能将文件转换为webpack理解的格式。比如当我们转换css预处理文件时,我们需要同时配置less-loader/sass-loader,css-loader,style-loader,这是一个顺序的转换,一个loader的输出是另一个loader的输入。

从上面我们可以看出我们在开发loader的时候需要保持功能的单一性,剩余的工作可以交给其他的loader进行处理。

接受输入,返回输出,保持职责单一,是不是感觉和函数式编程有些类似,其实loader本质上就是一个函数,下面就是一个最简单的loader的结构:

module.exports = function(input){
    // do something transform...
    return output
}

以上只是一个最简单的loader,我们可以看到它接受源文件或者是其它loader返回的内容,返回值为转换好的内容。

但是,只是接受源文件还是远远不够的,我们添加loader时通常还会加入一些其他的配置,

例如:test,options等等。我们的loader如何获取这些配置参数呢?这里我们可以使用loader-utils这个库。

const loaderUtils = require("loader-utils")
module.exports = function(input) {
   // 获取 options
   const options = loaderUtils.getOptions(this)
   // do something transform...
   return output
}

上面的代码只是返回了源文件被转换后的内容,有时我们还需要更多的信息。如果我们想要捕获loader处理过程中的错误,或者是sourceMap等信息,这是我们应该怎么做呢?loader的上下文提供了this.callback方法,我们可以使用它来进行更多内容的返回。this.callback可以接收四个参数:

error:Error | null,loader解析过程中出错抛出的异常

content:String | Buffer,源文件解析后的内容

sourceMap:为方便调试生成的编译后内容的source map

ast:本次编译生成的AST静态语法树

这样一来我们就可以获取更多的信息:

module.exports = function(input) {
   // 获取 options
   const options = loaderUtils.getOptions(this)
   // do something transform...
   this.callback(null, content)
   // 这里不要返回任何内容,这样webpack才能知道我们的内容是通过this.callback返回的
   return
}

上面我们的代码都是以同步的形式处理的,当然loader也存在异步的情况。这是我们只需要将loader的导出方法改为async functon即可:

module.exports = async function(input) {
   const output = await fetchSomething();
   this.callback(null, output);
   return
}

还有另一种异步处理方式,即使用webpack提供的this.async方法

module.exports = async function(input) {
   const callback = this.async();
   fetchSomething().then(res => {
     callback(res);
   })
}

上面看了这么多编写loader的方式,接下来让我们动手编写一个自己的loader。

实际工作中我们有时会遇到这么一个场景,就是跨域访问,我们在开发环境需要自己手动配置代理服务器。我们可以将所有api路径放在一个/src/api.js(当然文件想要放在哪里都可以)的文件,最终在每一个api前面加上/api来使用代理,例如:/some-path转换为/api/some-path。

我们编写一个叫api-resolver-loader的loader,我们先进行一下基础配置:

// webpack.config.js
module.exports = {
  ...
  module: {
    rules: [{
        test: /^\/src\/api\.js$/,
        loader: 'api-resolver-loader'
     }]
  }
  ...
}

该loader的实现非常简单:

module.exports = function(input) {
  return input.replace(/\/(.*)/g, "/api/$1")
}

编写plugin

除了webpack loader以外,webpack的plugin也是一个很重要的概念,前文我们已经提到过,plugin的功能提供贯穿了整个webpack的打包流程。

webpack的事件流机制在打包过程中会广播很多事件,注册的plugin捕获到相应事件之后便会执行其功能,以便改变webpack输出结果。

loader只是对文件模块的转换操作。而plugin提供了对webpack的功能扩展。

究竟我们该如何编写一个webpack的plugin呢?

因为webpack的plugin是监听了一个注册在webpack内部的事件,所以我们在编写之前要弄明白我们的plugin要做什么事情,什么时候做,以及监听什么事件。这样的话我们的思路就会很清晰。首先我们要了解webpack的打包过程中触发了哪些事件,我们可以去webpack的官网上面去发现。

比如:我们想在webpack编译之前执行一个插件,我们可以将插件注册在webpack的beforeCompile钩子上:

compiler.hooks.beforeCompile.tap(...)

如果我们看了webpack plugin的源码我们就会发现,plugin其实就是一个class,其内部有一个apply方法,在apply方法内部用来注册钩子

class MyPlugin {
   constructor(options) {
       this.options = options
   }
   apply(compiler) {
       compiler.hooks.someHook.tap('MyPlugin', () => {
           // do something...
       })
   }
}

从上面的代码我们可以看出其实一个webpack的插件的骨架非常简单,只有上面几行代码,

插件实现细节只需要在compiler.hooks.someHook.tap()的第二个参数中实现即可。

现在让我们来手动实现一个webpack的插件,让我们实现一个类似clean-webpack-plugin功能的插件,即在webpack打包之前清空输出目录下的所有文件。

const fs = require('fs');
const path = require('path')


class MyPlugin {
  constructor(options) {
    this.outputPath = options.outputPath
  }
  apply(compiler) {
    compiler.hooks.beforeRun.tap('MyPlugin', () => {
      const rootPath = process.cwd();
      const outputPath = path.resolve(rootPath, this.outputPath);
      const files = fs.readDirSync(outputPath);
      for (const file of files) {
        fs.unLinkSync(file);
      }
    })
  }
}

结语

现在webpack已经成为前端工程师的一项必备技能,我们不仅要明白怎么配置,我们还要理解它的原理,这样我们才能在配置出现问题时快速准确的定位问题。希望大家能在看完这篇文章后有所收获。