webpack 构建流程与核心概念

前端日益复杂,很多问题可以借助工具来自动处理,工程化就显得越来越重要。

目前几乎所有的前端构建和开发都是采用 Webpack 。因为 Webpack 有强大的社区生态,每月 Webpack 的下载量超过百万。通过 loader、plugin 支持 Webpack 与主流的前端框架和语言进行集成,比如 Vue、React、TypeScript。

# webpack是什么

webpack Webpack 是一款强大的打包工具。在 Webpack 中一切皆模块。Webpack 官网的 banner 图完美地诠释了这一理念。Webpack 从一个入口文件开始递归地分析模块的依赖关系,根据依赖关系将这些模块打包成一个或多个文件。

本质上,webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle。

简单粗暴的说,webpack做了一件事,那就是整合所有资源模块。没错,是所有!这得益于他完善的loader和插件系统和强大的社区生态。

# webpack能做什么

  • 支持所有的模块化 可以对 ES6 模块、commonjs 模块、AMD 模块等所有标准的模块进行打包。
  • code splitting 可以将代码打成多个 chunk,按需加载,意味着我们的站点无需等待整个 js 资源下载完成之后才能交互,可以大大提升速度。
  • 强大灵活的插件系统 Webpack 提供了很多内置的插件,包括其自身也是架构在插件系统上可以满足所有的打包需求。
  • 编译不支持的文件 借助 loader 预处理非 js 资源,例如.vue, .ts, .less等等,Webpack 可以打包所有的静态资源。
  • 浏览器兼容 在早期前端工程中,手写一堆浏览器兼容代码一直是令人头皮发麻的事情,而在今天这个问题被大大的弱化了,通过webpack的Loader机制,可以自动帮助我们对代码做polyfill。
  • 代码处理, 性能优化 webpack同样可以集成代码压缩,tree shaking移除无用代码,懒加载,缓存等功能
  • 开发体验 webpack开发热更新,开发/生产环境区分

# webpack构建流程

Webpack 的构建流程是一种事件流机制。整个构建流程可以看成是一个流水线,每个环节负责单一的任务,处理完将进入下一个环节。 Webpack 会在每个环节上发布事件,供内置的和自定义的插件有机会干预 Webpack 的构建过程,控制 Webpack 的构建结果。 Webpack 的基本的构建流程如下

  1. 初始化 读取 Webpack 配置文件和 shell 脚本中的参数,将参数合并后初始化 Webpack ,生成 Compiler 对象。
  2. 开始编译 执行 Compiler 的 run 方法开始执行编译。
  3. 编译完成 从入口文件开始,调用配置中的 loader 对模块进行编译,并梳理出模块间的依赖关系,直至所有的模块编译完成。
  4. 资源输出 根据入口与模块间的依赖关系,将上一步编译完成的内容组装成一个个的 chunk (代码块),然后把 chunk 加入到等待输出的资源列表中。
  5. 完成 确定好输出资源后,根据指定的输出路径和文件名配置,将资源写入到磁盘的文件系统中,完成整个构建过程。

整个过程中webpack会通过发布订阅模式,向外抛出一些hooks,而webpack的插件即可通过监听这些关键的事件节点,执行插件任务进而达到干预输出结果的目的。

# 核心概念

# 入口

入口,即webpack打包的起点,根据入口js递归分析依赖项及其各依赖之间的关系,构建依赖图,Webpack 依据该依赖图对模块进行组装,输出到最终的 bundle 文件中。

具体的,入口就是我们配置在webpack.config.js中的entry属性,他是一个文件路径,可以是一个也可以是多个。

// webpack.config.js
module.exports = {
  entry: './src/app.js'
};

// webpack.config.js 多入口场景
module.exports = {
  entry: {
    pageOne: './src/pageOne/app.js',
    pageTwo: './src/pageTwo/app.js'
  }
};

# 出口/输出

配置 output 选项可以指示 Webpack 如何去输出、在哪里输出我们的静态资源文件。

// webpack.config.js
module.exports = {
  output: {
    filename: 'bundle.js',
    path: './dist'
  }
};

此配置将一个单独的 bundle.js 文件输出到 ./dist 目录中。

# loader

# loader的作用

Webpack 本身是不能处理非 JS 资源的,但我们却可以在 Webpack 中引入 css、图片、字体等非 js 文件。

// app.js
import Styles from 'styles.css';

Webpack 中使用 loader 对非 js 文件进行转换。loader 可以在我们 import 或者加载模块时,对文件进行预处理,将非 js 的文件内容,最终转换成 js 代码。

loader 有三种使用方式:

  1. 配置 在 webpack.config.js 文件中指定
  2. 内联 在每个 import 语句中线上指定
  3. CLI 在 shell 命令中指定。

在实际的应用中,绝大数都是采用配置的方式来使用,一方面在配置文件中,可以非常直观地看到某种类型的文件使用了什么 loader,另一方面,在项目复杂的情况下,便于进行维护。

我们通过一个简单的例子来看一下 loader 的使用:

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

我们需要告诉 Webpack 当遇到 css 文件的时候,使用 css-loader 进行预处理。这里由于 css-loader 是单独的 npm 模块,使用前我们需要先进行安装:

 npm install --save-dev css-loader

# 常见loader

Webpack 可以处理任何非 JS 语言,得益于社区提供的丰富的 loader,这里对一些常用的 loader 进行简要的说明。

  • babel-loader 将 ES2015+ 代码转译为 ES5。
  • ts-loader 将 TypeScript 代码转译为 ES5。
  • css-loader 解析 @import 和 url(),并对引用的依赖进行解析。
  • style-loader 在 HTML 中注入 style 标签将 css 添加到 DOM 中。通常与 css-loader 结合使用。
  • sass-loader 加载 sass/scss 文件并编译成 css。
  • postcss-loader 使用 PostCSS 加载和转译 css 文件。
  • html-loader 将 HTML 导出为字符串。
  • vue-loader 加载和转译 Vue 组件。
  • url-loader 和 file-loader 一样,但如果文件小于配置的限制值,可以返回 data URL。
  • file-loader 将文件提取到输出目录,并返回相对路径。

# 自定义loader

日常开发中所使用到的 loader,都可以在社区找到。当然webpack也支持自定义 (opens new window)

从上面的打包代码我们其实可以知道,Webpack最后打包出来的成果是一份Javascript代码,实际上在Webpack内部默认也只能够处理JS模块代码,在打包过程中, 会默认把所有遇到的文件都当作 JavaScript代码进行解析,因此当项目存在非JS类型文件时,我们需要先对其进行必要的转换,才能继续执行打包任务,这也是Loader机制存在的意义。

通过配置可以看出,针对每个文件类型,loader是支持以数组的形式配置多个的,因此当Webpack在转换该文件类型的时候, 会按顺序链式调用每一个loader,前一个loader返回的内容会作为下一个loader的入参。因此loader的开发需要遵循一些规范, 比如返回值必须是标准的JS代码字符串,以保证下一个loader能够正常工作,同时在开发上需要严格遵循“单一职责”,只关心loader的输出以及对应的输出。

这是一个简单的loader示例:

module.exports = function(source) {
  // source 为 compiler 传递给 Loader 的一个文件的原内容
  // 该函数需要返回处理后的内容,这里简单起见,直接把原内容返回了,相当于该 Loader 没有做任何转换
  return source;
};

一个loader应该有如下准则

# 简单易用

loaders 应该只做单一任务。这不仅使每个 loader 易维护,也可以在更多场景链式调用。

# 链式传递

利用 loader 可以链式调用的优势。写五个简单的 loader 实现五项任务,而不是一个 loader 实现五项任务。功能隔离不仅使 loader 更简单,可能还可以将它们用于你原先没有想到的功能。

# 模块化的输出

保证输出模块化。loader 生成的模块与普通模块遵循相同的设计原则。

# 确保无状态

确保 loader 在不同模块转换之间不保存状态。每次运行都应该独立于其他编译模块以及相同模块之前的编译结果。

# 使用 loader utilities

充分利用 loader-utils 包。它提供了许多有用的工具,但最常用的一种工具是获取传递给 loader 的选项。schema-utils 包配合 loader-utils,用于保证 loader 选项,进行与 JSON Schema 结构一致的校验

# 记录 loader 的依赖

如果一个 loader 使用外部资源(例如,从文件系统读取),必须声明它。这些信息用于使缓存 loaders 无效,以及在观察模式(watch mode)下重编译

# 解析模块依赖关系

根据模块类型,可能会有不同的模式指定依赖关系。例如在 CSS 中,使用 @import 和 url(...) 语句来声明依赖。这些依赖关系应该由模块系统解析

# 提取通用代码

避免在 loader 处理的每个模块中生成通用代码。相反,你应该在 loader 中创建一个运行时文件,并生成 require 语句以引用该共享模块

# 避免绝对路径

不要在模块代码中插入绝对路径,因为当项目根路径变化时,文件绝对路径也会变化。loader-utils 中的 stringifyRequest 方法,可以将绝对路径转化为相对路径

# 使用 peer dependencies

如果一个 loader 使用外部资源(例如,从文件系统读取),必须声明它。这些信息用于使缓存 loaders 无效,以及在观察模式(watch mode)下重编译

下面是一个xml-loader的实例


const xml2js = require('xml2js');
const parser = new xml2js.Parser();

module.exports =  function(source) {
  this.cacheable && this.cacheable();
    // 如果 loader 配置了 options 对象,那么this.query将指向 options
    const options = this.query;
    
    // 可以用作解析其他模块路径的上下文
    console.log(this.context);
    /*
     * this.callback 参数:
     * error:Error | null,当 loader 出错时向外抛出一个 error
     * content:String | Buffer,经过 loader 编译后需要导出的内容
     * sourceMap:为方便调试生成的编译后内容的 source map
     * ast:本次编译生成的 AST 静态语法树,之后执行的 loader 可以直接使用这个 AST,进而省去重复生成 AST 的过程
     */
  const self = this;
  parser.parseString(source, function (err, result) {
    self.callback(err, !err && "module.exports = " + JSON.stringify(result));
  });
};

# 插件

Loader负责文件转换,那么Plugin便是负责功能扩展。Loader和Plugin作为Webpack的两个重要组成部分,承担着两部分不同的职责。 webpack基于发布订阅模式,在运行的生命周期中会广播出许多事件,插件通过监听这些事件,就可以在特定的阶段执行自己的插件任务,从而实现自己想要的功能。

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack -plugin'); 
const webpack = require('webpack'); 

const config = {
  plugins: [
    new webpack.optimize.UglifyJsPlugin(),
    new HtmlWebpackPlugin({template: './src/index.html'})
  ]
};

module.exports = config;

示例中的两个插件,一个是内置的 UglifyJsPlugin 插件,该插件对 js 进行压缩,减小文件的体积。 一个是外部插件 HtmlWebpackPlugin,用来自动生成入口文件,并将最新的资源注入到 HTML 中。

# 常用插件

  • HtmlWebpackPlugin 自动生成入口文件,并将最新的资源注入到 HTML 中。
  • CommonsChunkPlugin 用以创建独立文件,常用来提取多个模块中的公共模块。
  • DefinePlugin 用以定义在编译时使用的全局常量。
  • DllPlugin 拆分 bundle 减少不必要的构建。
  • ExtractTextWebpackPlugin 将文本从 bundle 中提取到单独的文件中。常见的场景是从 bundle 中将 CSS 提取到独立的 css 文件中。
  • HotModuleReplacementPlugin 在运行过程中替换、添加或删除模块,而无需重新加载整个页面。
  • UglifyjsWebpackPlugin 对 js 进行压缩,减小文件的体积。
  • CopyWebpackPlugin 将单个文件或整个目录复制到构建目录。一个常用的场景是将项目中的静态图片不经构建直接复制到构建后的目录。

# 自定义插件

Plugin的开发 (opens new window)和开发Loader一样,需要遵循一些开发上的规范和原则:

  • 插件必须是一个函数或者是一个包含 apply 方法的对象,这样才能访问compiler实例;
  • 传给每个插件的 compiler 和 compilation 对象都是同一个引用,若在一个插件中修改了它们身上的属性,会影响后面的插件;
  • 异步的事件需要在插件处理完任务时调用回调函数通知 Webpack 进入下一个流程,不然会卡住;
class MyPlugin {
  apply (compiler) {
    // 找到合适的事件钩子,实现自己的插件功能
    compiler.hooks.emit.tap('MyPlugin', compilation => {
        // compilation: 当前打包构建流程的上下文
        console.log(compilation);
        
        // do something...
    })
  }
}

// 函数形式
// 一个 JavaScript 命名函数。
function MyExampleWebpackPlugin() {

};

// 在插件函数的 prototype 上定义一个 `apply` 方法。
MyExampleWebpackPlugin.prototype.apply = function(compiler) {
  // 指定一个挂载到 webpack 自身的事件钩子。
  compiler.plugin('webpacksEventHook', function(compilation /* 处理 webpack 内部实例的特定数据。*/, callback) {
    console.log("This is an example plugin!!!");

    // 功能完成后调用 webpack 提供的回调。
    callback();
  });
};
# compiler 和 compilation 对象

在插件开发中最重要的两个资源就是 compilercompilation 对象。理解它们的角色是扩展 webpack 引擎重要的第一步。

compiler 对象代表了完整的 webpack 环境配置。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。 当在 webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用它来访问 webpack 的主环境。

compilation 对象代表了一次资源版本构建。当运行 webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。 一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。

下面是一个完整的示例

function FileListPlugin(options) {}

FileListPlugin.prototype.apply = function(compiler) {
  compiler.plugin('emit', function(compilation, callback) {
    // 在生成文件中,创建一个头部字符串:
    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;
      }
    };

    callback();
  });
};

module.exports = FileListPlugin;

这个示例的作用是生成一个叫做 filelist.md 的新文件;文件内容是所有构建生成的文件的列表。

# 总结

结合一些优秀的文章和webpack本身的源码,大概整理了几个相对重要的概念和流程,其中的实现细节和设计思路还需要结合源码去阅读和慢慢理解。 Webpack作为一款优秀的打包工具,它改变了传统前端的开发模式,是现代化前端开发的重要工具。

最近更新
01
echarts扇形模拟镜头焦距与可视化角度示意图
03-10
02
vite插件钩子
03-02
03
vite的依赖预构建
02-13
更多文章>