Webpack

286 阅读11分钟

Webpack 工作流程

关于 webpack 的工作流程,简单来说可以概括为以下几步:

  1. 参数解析
  2. 找到入口文件
  3. 调用 Loader 编译文件
  4. 遍历 AST,收集依赖
  5. 生成 Chunk
  6. 输出文件

其中,真正起编译作用的便是 Loader,本文也就 Loader 进行详细的阐述,其余部分暂且不谈。

Webpack流程概览

Webpack首先会把配置参数和命令行的参数及默认参数合并,并初始化需要使用的插件和配置插件等执行环境所需要的参数;初始化完成后会调用Compiler的run来真正启动webpack编译构建过程,webpack的构建流程包括compile、make、build、seal、emit阶段,执行完这些阶段就完成了构建过程。

  • 初始化参数: 从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。
  • 开始编译: 根据我们的webpack配置注册好对应的插件调用 compile.run 进入编译阶段,在编译的第一阶段是 compilation,他会注册好不同类型的module对应的 factory,不然后面碰到了就不知道如何处理了。
  • 编译模块: 进入 make 阶段,会从 entry 开始进行两步操作:第一步是调用 loaders 对模块的原始代码进行编译,转换成标准的JS代码, 第二步是调用 acorn 对JS代码进行语法分析,然后收集其中的依赖关系。每个模块都会记录自己的依赖关系,从而形成一颗关系树。
  • 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会。
  • 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

image.png

Webpack流程详解

分为三个阶段: 初始化阶段,编译阶段,输出文件(chunk)。

初始化阶段

  • 初始化参数: 从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。这个过程中还会执行配置文件中的插件实例化语句 new Plugin()。
  • 初始化默认参数配置: new WebpackOptionsDefaulter().process(options)
  • 实例化Compiler对象:用上一步得到的参数初始化Compiler实例,Compiler负责文件监听和启动编译。Compiler实例中包含了完整的Webpack配置,全局只有一个Compiler实例。
  • 加载插件: 依次调用插件的apply方法,让插件可以监听后续的所有事件节点。同时给插件传入compiler实例的引用,以方便插件通过compiler调用Webpack提供的API。
  • 处理入口: 读取配置的Entrys,为每个Entry实例化一个对应的EntryPlugin,为后面该Entry的递归解析工作做准备。
new EntryOptionPlugin().apply(compiler)  new SingleEntryPlugin(context, item, name)  compiler.hooks.make.tapAsync

编译阶段

  • run阶段:启动一次新的编译。this.hooks.run.callAsync。
  • compile: 该事件是为了告诉插件一次新的编译将要启动,同时会给插件带上compiler对象。
  • compilation: 当Webpack以开发模式运行时,每当检测到文件变化,一次新的Compilation将被创建。一个Compilation对象包含了当前的模块资源、编译生成资源、变化的文件等。Compilation对象也提供了很多事件回调供插件做扩展。
  • make:一个新的 Compilation 创建完毕主开始编译  完毕主开始编译this.hooks.make.callAsync。
  • addEntry: 即将从 Entry 开始读取文件。
  • _addModuleChain: 根据依赖查找对应的工厂函数,并调用工厂函数的create来生成一个空的MultModule对象,并且把MultModule对象存入compilation的modules中后执行MultModule.build。
  • buildModules: 使用对应的Loader去转换一个模块。开始编译模块,this.buildModule(module)  buildModule(module, optional, origin,dependencies, thisCallback)。
  • build: 开始真正编译模块。
  • doBuild: 开始真正编译入口模块。
  • normal-module-loader: 在用Loader对一个模块转换完后,使用acorn解析转换后的内容,输出对应的抽象语法树(AST),以方便Webpack后面对代码的分析。
  • program: 从配置的入口模块开始,分析其AST,当遇到require等导入其它模块语句时,便将其加入到依赖的模块列表,同时对新找出的依赖模块递归分析,最终搞清所有模块的依赖关系。

输出阶段

  • seal: 封装 compilation.seal seal(callback)。
  • addChunk: 生成资源 addChunk(name)。
  • createChunkAssets: 创建资源 this.createChunkAssets()。
  • getRenderManifest: 获得要渲染的描述文件 getRenderManifest(options)。
  • render: 渲染源码 source = fileManifest.render()。
  • afterCompile: 编译结束   this.hooks.afterCompile。
  • shouldEmit: 所有需要输出的文件已经生成好,询问插件哪些文件需要输出,哪些不需要。this.hooks.shouldEmit。
  • emit: 确定好要输出哪些文件后,执行文件输出,可以在这里获取和修改输出内容。
this.emitAssets(compilation)  this.hooks.emit.callAsync const emitFiles = err this.outputFileSystem.writeFile
  • done: 全部完成     this.hooks.done.callAsync。

webpack 编译流程

手写 webpack

doing

手写 webpack

webpack 详解

详解 webpack webpack 小知识点

webpack 源码阅读

源码阅读

Loader

Loader 的作用很简单,就是处理任意类型的文件,并且将它们转换成一个让 webpack 可以处理的有效模块。

本质上来说,loader 就是一个 node 模块,这很符合 webpack 中「万物皆模块」的思路。

既然是 node 模块,那就一定会导出点什么。

在 webpack 的定义中,loader 导出一个函数,loader 会在转换源模块(resource)的时候调用该函数。在这个函数内部,可以通过传入 this 上下文给 Loader API 来使用它们。

因此我们也可以概括一下 loader 的功能:把源模块转换成通用模块

配置

1. 在 config 里配置

webpack.config.js里配置,定义在 module.rules 里:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      { test: /\.js$/, use: 'babel-loader' },
      {
        test: /\.css$/,
        use: [
          { loader: 'style-loader' },
          { loader: 'css-loader' },
          { loader: 'postcss-loader' },
        ]
      }
    ]
  }
};

每一条 rule 会包含两个属性:test 和 use,比如 { test: /.js$/, use: 'babel-loader' } 意思就是:当 webpack 遇到扩展名为 js 的文件时,先用 babel-loader 处理一下,然后再打包它。

use 的类型:string|array|object|function

  • string: 只有一个 Loader 时,直接声明 Loader,比如 babel-loader
  • array: 声明多个 Loader 时,使用数组形式声明,比如上文声明 .css 的 Loader
  • object: 只有一个 Loader 时,需要有额外的配置项时。
  • functionuse 也支持回调函数的形式。

注意:  当 use 是通过数组形式声明 Loader 时,Loader 的执行顺序是从右到左,从下到上。比如暂且认为上方声明是这样执行的:

postcss-loader -> css-loader -> style-loader

其实就是:

styleLoader(cssLoader(postcssLoader(content)))

2. 内联

可以在 import 等语句里指定 Loader,使用 ! 来将 Loader分开:

import style from 'style-loader!css-loader?modules!./styles.css';

内联时,通过 query 来传递参数,例如 ?key=value

一般来说,推荐使用统一 config 的形式来配置 Loader,内联形式多出现于 Loader 内部,比如 style-loader 会在自身代码里引入 css-loader

require("!!../../node_modules/css-loader/dist/cjs.js!./styles.css");

Loader 类型

1. 同步 Loader

module.exports = function(source) {
  const result = someSyncOperation(source); // 同步逻辑
  return result;
}

一般来说,Loader 都是同步的,通过 return 或者 this.callback 来同步地返回 source转换后的结果。

2. 异步 Loader

有的时候,我们需要在 Loader 里做一些异步的事情,比如说需要发送网络请求。如果同步地等着,网络请求就会阻塞整个构建过程,这个时候我们就需要进行异步 Loader,可以这样做:

module.exports = function(source) {
  // 告诉 webpack 这次转换是异步的
  const callback = this.async();
  // 异步逻辑
  someAsyncOperation(content, function(err, result) {
    if (err) return callback(err);
    // 通过 callback 来返回异步处理的结果
    callback(null, result, map, meta);
  });
};

3. Pitching Loader

Pitching Loader 是一个比较重要的概念,之前在 style-loader 里有提到过。

{
  test: /.js$/,
  use: [
    { loader: 'aa-loader' },
    { loader: 'bb-loader' },
    { loader: 'cc-loader' },
  ]
}

Loader 总是从右到左被调用。上面配置的 Loader,就会按照以下顺序执行:

cc-loader -> bb-loader -> aa-loader

每个 Loader 都支持一个 pitch 属性,通过 module.exports.pitch 声明。如果该 Loader 声明了 pitch,则该方法会优先于 Loader 的实际方法先执行,官方也给出了执行顺序:

|- aa-loader `pitch`
  |- bb-loader `pitch`
    |- cc-loader `pitch`
      |- requested module is picked up as a dependency
    |- cc-loader normal execution
  |- bb-loader normal execution
|- aa-loader normal execution

也就是会先从左向右执行一次每个 Loader 的 pitch 方法,再按照从右向左的顺序执行其实际方法。

4. Raw Loader

我们在 url-loader 里和 file-loader 最后都见过这样一句代码:

export const raw = true;

默认情况下,webpack 会把文件进行 UTF-8 编码,然后传给 Loader。通过设置 rawLoader 就可以接受到原始的 Buffer 数据。

Loader 几个重要的 api

所谓 Loader,也只是一个符合 commonjs 规范的 node 模块,它会导出一个可执行函数。loader runner 会调用这个函数,将文件的内容或者上一个 Loader 处理的结果传递进去。同时,webpack 还为 Loader 提供了一个上下文 this,其中有很多有用的 api,我们找几个典型的来看看。

1. this.callback()

在 Loader 中,通常使用 return 来返回一个字符串或者 Buffer。如果需要返回多个结果值时,就需要使用 this.callback,定义如下:

this.callback(
  // 无法转换时返回 Error,其余情况都返回 null
  err: Error | null,
  // 转换结果
  content: string | Buffer,
  // source map,方便调试用的
  sourceMap?: SourceMap,
  // 可以是任何东西。比如 ast
  meta?: any
);

一般来说如果调用该函数的话,应该手动 return,告诉 webpack 返回的结果在 this.callback 中,以避免含糊不清的结果:

module.exports = function(source) {
  this.callback(null, source, sourceMaps);
  return;
};

2. this.async()

同上,异步 Loader

3. this.cacheable()

有些情况下,有些操作需要耗费大量时间,每一次调用 Loader 转换时都会执行这些费时的操作。

在处理这类费时的操作时, webapck 会默认缓存所有 Loader 的处理结果,只有当被处理的文件发生变化时,才会重新调用 Loader 去执行转换操作。

webpack 是默认可缓存的,可以执行 this.cacheable(false) 手动关闭缓存。

4. this.resource

当前处理文件的完整请求路径,包括 query,比如 /src/App.vue?type=templpate

5. this.resourcePath

当前处理文件的路径,不包括 query,比如 /src/App.vue

6. this.resourceQuery

当前处理文件的 query 字符串,比如 ?type=template。我们在 vue-loader 里有见过如何使用它:

const qs = require('querystring');

const { resourceQuery } = this;
const rawQuery = resourceQuery.slice(1); // 删除前面的 ?
const incomingQuery = qs.parse(rawQuery); // 解析字符串成对象
// 取 query
if (incomingQuery.type) {}

7. this.emitFile

让 webpack 在输出目录新建一个文件,我们在 file-loader 里有见过:

if (typeof options.emitFile === 'undefined' || options.emitFile) {
  this.emitFile(outputPath, content);
}

Loader 工作流程简述

我们来回顾一下 Loader 的一些特点:

  • Loader 是一个 node 模块;
  • Loader 可以处理任意类型的文件,转换成 webpack 可以处理的模块;
  • Loader 可以在 webpack.config.js 里配置,也可以在 require 语句里内联;
  • Loader 可以根据配置从右向左链式执行;
  • Loader 接受源文件内容字符串或者 Buffer
  • Loader 分为多种类型:同步、异步和 pitching,他们的执行流程不一样;
  • webpack 为 Loader 提供了一个上下文,有一些 api 可以使用;
  • ...

我们根据以上暂时知道的特点,可以对 Loader 的工作流程有个猜测,假设有一个 js-loader,它的工作流程简单来说是这样的:

  1. webpack.config.js 里配置了一个 js 的 Loader
  2. 遇到 js 文件时,触发了 js-loader;
  3. js-loader 接受了一个表示该 js 文件内容的 source;
  4. js-loader 使用 webapck 提供的一系列 api 对 source 进行转换,得到一个 result;
  5. 将 result 返回或者传递给下一个 Loader,直到处理完毕。

loader运行流程

vue-loader

vue-loader 执行过程

style-loader

style-loader 的功能就一个,在 DOM 里插入一个 <style> 标签,并且将 CSS 写入这个标签内。

简单来说就是这样:

const style = document.createElement('style'); // 新建一个 style 标签
style.type = 'text/css';
style.appendChild(document.createTextNode(content)) // CSS 写入 style 标签document.head.appendChild(style); // style 标签插入 head 中

style-loader 执行过程

如何编写一个 Loader

单一任务和链式调用

1. 单一职责

一个 loader 只做一件事,这样不仅可以让 loader 的维护变得简单,还能让 loader 以不同的串联方式组合出符合场景需求的搭配。

2. 链式组合

这一点是第一点的延伸。好好利用 loader 的链式组合的特型,可以收获意想不到的效果。具体来说,写一个能一次干 5 件事情的 loader ,不如细分成 5 个只能干一件事情的 loader,也许其中几个能用在其他你暂时还没想到的场景。下面我们来举个例子。

假设现在我们要实现通过 loader 的配置和 query 参数来渲染模版的功能。我们在 “apply-loader” 里面实现这个功能,它负责编译源模版,最终输出一个导出 HTML 字符串的模块。根据链式组合的规则,我们可以结合另外两个开源 loader:

  • jade-loader 把模版源文件转化为导出一个函数的模块。
  • apply-loader 把 loader options 传给上面的函数并执行,返回 HTML 文本。
  • html-loader 接收 HTMl 文本文件,转化为可被引用的 JS 模块。

事实上串联组合中的 loader 并不一定要返回 JS 代码。只要下游的 loader 能有效处理上游 loader 的输出,那么上游的 loader 可以返回任意类型的模块。

3.模块化

保证 loader 是模块化的。loader 生成模块需要遵循和普通模块一样的设计原则。

4.无状态

在多次模块的转化之间,我们不应该在 loader 中保留状态。每个 loader 运行时应该确保与其他编译好的模块保持独立,同样也应该与前几个 loader 对相同模块的编译结果保持独立。

5.使用 Loader 实用工具

请好好利用 loader-utils 包,它提供了很多有用的工具,最常用的一个就是获取传入 loader 的 options。除了 loader-utils 之外包还有 schema-utils 包,我们可以用 schema-utils 提供的工具,获取用于校验 options 的 JSON Schema 常量,从而校验 loader options。下面给出的例子简要地结合了上面提到的两个工具包:

import { getOptions } from 'loader-utils';
import { validateOptions } from 'schema-utils';

const schema = {
  type: object,
  properties: {
    test: {
      type: string
    }
  }
}

export default function(source) {
    const options = getOptions(this);

    validateOptions(schema, options, 'Example Loader');

    // 在这里写转换 source 的逻辑 ...
    return `export default ${ JSON.stringify(source) }`;
};

手写 loader

总结

一个 loader 在我们项目中 work 需要经历以下步骤:

  • 创建 loader 的目录及模块文件
  • 在 webpack 中配置 rule 及 loader 的解析路径,并且要注意 loader 的顺序,这样在 require 指定类型文件时,我们能让处理流经过指定 loader
  • 遵循原则设计和开发 loader

webpack loader & plugin