webpack loader

April 23, 2018 by Sylvenas

Webpack原理

本质上来说webpack是运行在node.js之上的一个JavaScript程序,通过配置文件指令程序应当从哪里入手、遇到各种文件应当怎么处理、遇到导入的文件,怎么根据路径加载,以及在处理各种文件的过程中,做各种优化和处理;

一切文件:JavaScript、CSS、SCSS、图片、模板,在 Webpack 眼中都是一个个模块,这样的好处是能清晰的描述出各个模块之间的依赖关系,以方便 Webpack对模块进行组合和打包。 经过Webpack的处理,最终会输出浏览器能使用的静态资源。

webpack的核心概念

  • entry: 一个可执行模块或库的入口文件
  • chunk:多个文件组成的一个代码块,例如把一个可执行模块和它所有依赖的模块组合和成一个chunk,这体现了webpack的打包机制
  • loader:文件转换器,例如把ES6转换为ES5,SCSS转换为CSS
  • plugin:插件,用于扩展webpack的功能,在webpack构建生命周期的节点上加入扩展hook为webpack加入功能。

webpack构建流程

从启动webpack构建到输出结果经历了一系列过程,它们是:

  • 解析webpack配置参数,合并从shell传入和webpack.config.js文件里配置的参数,生产最后的配置结果
  • 注册所有配置的插件,好让插件监听webpack构建生命周期的事件节点,以做出对应的反应
  • 从配置的entry入口文件开始解析文件构建AST语法树,找出每个文件所依赖的文件,递归下去。
  • 在解析文件递归的过程中根据文件类型loader配置找出合适的loader用来对文件进行转换。
  • 递归完后得到每个文件的最终结果,根据entry配置生成代码块chunk
  • 输出所有chunk到文件系统。

需要注意的是,在构建生命周期中有一系列插件在合适的时机做了合适的事情,eg:UglifyJsPlugin会在loader转换递归完后对结果再使用UglifyJs压缩覆盖之前的结果。

webpack-loader

Loader 就像是一个流水工厂,能把源文件经过转化后输出新的结果,并且一个文件还可以链式的经过多个工厂加工

以处理 SCSS 文件为例:

  • SCSS 源代码会先交给sass-loader把SCSS转换成CSS;
  • sass-loader输出的CSS交给css-loader处理,找出CSS中依赖的资源、压缩CSS等;
  • css-loader输出的 CSS 交给style-loader处理,转换成通过脚本加载的 JavaScript 代码;

可以看出以上的处理过程需要有顺序的链式执行,先sass-loadercss-loaderstyle-loader

loader有点类似RxJs的管道机制,同时透露着函数式编程的理念

loader的职责

由上面的例子可以看出:

  • 一个 Loader 的职责是单一的,只需要完成一种转换。
  • 如果一个源文件需要经历多步转换才能正常使用,就通过多个 Loader 去转换。
  • 在调用多个 Loader 去转换一个文件时,每个 Loader 会链式的顺序执行,
  • 第一个 Loader 将会拿到需处理的原内容,上一个 Loader 处理后的结果会传给下一个接着处理,最后的 Loader 将处理后的最终结果返回给 Webpack。

所以,在你开发一个 Loader 时,请保持其职责的单一性,你只需关心输入和输出。

Writing A Loader

从上面的介绍可以看出loader就是一个Node.js的模块,这个模块需要导出一个函数,而这个函数的作用就是对接收的内容进行处理,并把处理的结果返回给下一个模块。 那么一个最简单的loader的内容就是:

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

使用一个具体的例子来说: 如果我们想把某个txt文件中的{{name}}替换为某个具体的人名,可以这样写,代码很简单

const loaderUtils = require("loader-utils");
const schema = require("./name-options.json");
const validateOptions = require("schema-utils");

module.exports = function (source) {
    // 获取options配置项
    const options = loaderUtils.getOptions(this) || {};
    // 校验配置项是否符合规则
    validateOptions(schema, options, "name Loader");
    // 替换字符串
    const result = source.replace(/{{name}}/, options.name)
    // 拼接结果返回给webpack
    return `module.exports = '${result}'`;
};

最后的结果要手动拼接module.exports的原因是:loader最后会创建为一个模块,而当我们require一个模块的时候,我们需要实际上加载的时候最后镜多loader处理之后的那个模块,而**return module.exports = '${result}'**就是为了创建这个供其他应用程序require的模块,只不过和我们平时写的模块的唯一区别就是需要我们手动拼接创建

现在name-loader已经开发完成,接下来只要按照官方文档的说明的使用方法,加载一下这个loader就可以了:

module.exports = {
  //...
  module: {
    rules: [
      {
        test: [/\.tpl$/],
        use: [
            {
            loader: path.resolve('./picture-loader/name-loader.js'),
            options: {
                name: 'James'
                }
            },
            ]
        },
    ]
  }
};

接下来我们测试一下在实际项目中怎么使用.tpl格式的文件:

Hello,I am {{name}}.
import React, { Component } from 'react';
import tpl from './test-name.tpl';

class App extends Component {
  render() {
    return (
      <div className="App">
        <h1>{tpl}</h1>
      </div>
    );
  }
}

export default App;

现在在项目中已经具体使用我们的loader了

loader的常用API

获取loader的options

借助webpack提供的loader-utils工具包可以通过getOptions方法直接获取到用户设置的options

const loaderUtils = require('loader-utils');
const options = loaderUtils.getOptions(this);

处理二进制数据

webpack默认的传递给loader的内容是UTF-8格式编码的字符串,但是有些场景下loader是处理二进制的文件的,例如图片,那么我们就需要告诉webpack,传递给loader二进制的数据给我们的loader,实现方式为module.exporst.raw = true

module.exports = function(source){
  source instanceof Buffer === true
  return source
}

module.exporst.raw = true

异步与同步

上面的介绍和例子中,我们的loader都是同步处理数据的,但是JavaScript中异步才是绝大多数情况,例如:某个loader需要读写硬盘才能得到结果,如果采用同步的方式,则会阻塞整个构建,导致构建非常缓慢,webpack提供了异步处理结果的方式:

module.exports = function(source) {
    // 告诉 Webpack 本次转换是异步的,Loader 会在 callback 中回调结果
    var loaderCallback = this.async();
    someAsyncOperation(source, function(err, result, sourceMaps, ast) {
        // 通过 callback 返回异步执行后的结果
        loaderCallback(err, result, sourceMaps, ast);
    });
};

由于node.js的单线程,建议尽量都把loader写成异步的方式,避免长时间的同步的计算

缓存

某些情况下,某些转换操作需要大奖的极端非常耗时,如果每次构建都要重复执行转换,这样效率非常低下,所以可以尽量使用缓存:

module.exports = function (source) {
    this.cacheable && this.cacheable();
    // ...
}

emitFile

在dev环境下往memory中写入一个文件,在prod环境下,向磁盘写入文件,这个功能在file-loader和图片处理中非常的常见

this.emitFile(name: string, content: Buffer|string, sourceMap: {...})

请求路径

如果我们想要获取资源在请求的时候的原路径,可以使用this.resource属性获取,并且包含资源的query 请求:

import girl from './girl.jpg?size=360'

在loader中获取请求的路径:

module.exports = function (source) {
    const resource = this.resource
}

相对而言options是一个基础的统一的配置项,query可以定制请求的资源的配置项,eg:responsive image中请求不同大小的图片

Summary

一个loader从本质上来说就做了以下三件事:

  • 将源文件的内容转换为JavaScript值,可能是字符串,eg:name-loader,把模块的内容转换为字符串
  • 将引用的具体的文件,转换为对文件的请求,eg:url-loader把对图片的请求转换为对图片路径
  • 将构建的结果,手动拼接为JavaScript的模块,并导出提供给应用程序的其他部分使用