webpack进阶:从0到1实现一个loader——【glsl-stringify-loader】

7,337 阅读8分钟

写作不易,欢迎点赞关注! loader对应的github源码地址以及npm库地址

前言

webpack是一个现代的JavaScript静态模块打包器,webpack打包时只能直接处理JavaScript文件之间的依赖关系。所以任何非JavaScript文件都必须被预先处理转换为JavaScript代码,这样才能参与打包。

而实现这一功能的就是【loader】。

loader

loader的运行是发生在webpack的【构建阶段】。构建阶段从entry(也就是入口文件)开始解析资源与资源之间的依赖,在【compilation】对象中构建module之间的依赖关系。

webpack调用【addEntry】方法从入口文件开始,然后调用【handleModuleCreate】方法根据文件类型构建module,再之后就是运行【loaderRunner】。

loaderRunner是loader的执行库,webpack会调用loaderRunner库的【runLoaders】方法将用户所配置的loader进行读取以及调用loader转换module内容。理论上转译之后输出的是标准的JavaScript文件或者是AST对象,这样webpack才能继续处理下去。

loader的本质

从本质上来说一个loader其实就是node的一个模块,这个模块导出一个函数,也就意味着loader就是一个【函数】。这个导出的函数的主要工作就是【内容转换】!

module.exports = function(source) {
  return source;
};

例子展示的是最简单的loader,其中source为loader的输入

  • 对于第一个loader来说是文件内容。
  • 对于其他loader来说则是上一个loader的处理结果。

接收的参数
loader函数一共接收三个参数:

  • source:资源输入
  • sourceMap:资源的sourceMap结构
  • data:loader链中传递的其他信息 其中最重要的就是【source】,loader要做的工作就是将source转换为另一个格式的输出。比如说:
const sass = require('node-sass');
function sassLoader (source) {
    return sass(source);
}
module.exports = sassLoader;

获取options
在配置loader时,可以给loader传入options配置项:

rules: [
    test: /\.scss/,
    use: [
        'style-loader',
        {
            loader: 'css-loader',
            options: {
                minimize: true,
            }
        },
        'sass-loader'
    ]
]

获取options需要借助【loader-utils】库。

const utils = require('loader-utils');
module.exports = function (source) {
    const options = utils.getOptions(this);
    ...
    return source;
}

返回多个结果
除了使用return返回单一的结果source之外,有些场景下还需要返回其他信息。这时候可以使用this.callback的方式返回更多信息,以供下游的loader或者webpack本身使用。比如babel-loader需要输出ES5代码对应的SourceMap,以方便调试源码:

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

通过this.callback(null, source, sourceMap)语句同时返回转译后的内容与sourceMap。this.callback的详细签名如下:

  • error: 错误信息,正常运行时传递null
  • content: 转译结果
  • sourceMap:可选,源码的SourceMap,方便调试
  • data:可选,任何值,通常用于传递AST,避免重复生成AST,以提升性能
this.callback(
    err: Error | null,
    content: String | Buffer,
    sourceMap?: SourceMap,
    data?: AST
);

同步与异步
不是每一个转译过程都是同步的,有些转译过程是需要通过请求才能得出结果。异步的过程分三步:

  • 调用this.async获取异步回调函数,此时webpack会将该loader标记为异步加载,并挂起当前执行队列,直到callback被触发。
  • 执行转译
  • 调用callback返回处理结果
module.exports = async function (source) {
    const callback = this.async();
    const result = await transform.render(source, params);
    callback(null, result, sourceMap);
}

this.async返回的异步回调函数与this.callback的参数是一致的。

处理二进制数据
在默认的情况下,webpack传给loader的内容是UTF-8的编码格式。但在某些场景下loader不是处理文本文件,而是处理二进制文件,比如file-loader。这时就需要wepback给loader传入二进制格式的数据。

module.exports = function (source) {
   console.log(source instanceof Buffer === true);
   return source;
}
module.exports.raw = true;

通过设置exports.raw = true来告诉webpack,该loader所需要的是二进制的source。

缓存
一般情况下,构建操作是非常耗时的,如果每次构建都还要重新执行重复的转换操作的话,构建就会变得非常缓慢。针对这种情况,webpack会默认缓存所有loader的转换结果,也就是说需要被处理的文件以及其依赖文件没有改变的情况下,是不会重新调用loader去执行转译操作的。必要时可以通过this.cacheable(false)显示声明不用缓存。

module.exports = function (source) {
    this.cacheable(false);
    return source;
}

loader的副作用

除了作为转译内容之外,loader在运行中还能通过一些上下文的API去影响webpack编译过程,从而产生副作用。

上下文的API可以通过this获取,this对象由NormalModule.createLoaderContext函数在调用loader前创建的:

  • context:模块所在的目录,可以用作解析其他模块路径的上下文。假如当前loader处理的文件是 /src/main.js,则 this.context 就等于 /src
  • resource:资源的完整请求路径,包括 querystring,例如 /src/main.js?name=1
  • resourcePath:资源的路径,例如 /src/main.js
  • resourceQuery:资源的querystring。
  • target:编译的目标,等于Webpack配置中的Target。
  • loadModule:但loader在处理一个资源时,如果依赖其它文件的处理结果才能得出当前文件的结果时, 就可以通过this.loadModule(request: string, callback: function(err, source, sourceMap, module)) 去获得 request对应文件的处理结果。
  • resolve:像require语句的功能一样获得指定文件的完整路径,使用方法为 resolve(context: string, request: string, callback: function(err, result: string))
  • addDependency:给当前处理文件添加其依赖的文件,以便再其依赖的文件发生变化时,会重新调用loader处理该文件。使用方法为addDependency(file: string)
  • addContextDependency:和addDependency类似,但addContextDependency是把整个目录加入到当前正在处理文件的依赖中。使用方法为 addContextDependency(directory: string)
  • clearDependencies:清除当前正在处理文件的所有依赖,使用方法为 clearDependencies()
  • emitFile:输出一个文件,使用方法为 emitFile(name: string, content: Buffer|string, sourceMap: {...})。 其中【addDependency】、【emitFile】、【emitError】、【emitWarning】都会对后续编译流程产生副作用。比如在less-loader中:
try {
    result = await (options.implementation || less).render(data, lessOptions);
    } catch (error) {
    // ...
    }

    const { css, imports } = result;

    imports.forEach((item) => {
    // ...
    this.addDependency(path.normalize(item));
});

首先调用less编译文件内容,之后遍历所有import语句,也就是result.imports数组。调用this.addDependency函数将import进来的其他资源都注册为依赖,这样才能使得这些资源文件发生变化时触发重新编译。

normal与pitch

loader是允许在函数上挂载一个名为【pitch】的方法,为了区分,所以称loader主函数为【normal】。

多个loader之间是按照配置的顺序从后到前依次执行,比如说经典的处理scss的配置:

rules: [
    {
        test: /\.scss$/i,
        use: [
            'style-loader',
            'css-loader',
            'sass-loader'
        ]
    }
]

按照配置的顺序,wepback解析scss文件内容时,先调用【sass-loader】;将sass-loader返回的结果再传入【css-loader】接着处理;最后再将css-loader处理结果传入【style-loader】;最终以style-loader的处理结果为准: image.png 三个loader分别完成内容换译的一部分,形成了从右到左的调用链。

pitch
与normal不同的是,pitch调用比loader更早。 image.png 所以上面的例子中完整调用链是: image.png 而且在pitch阶段还能中断后续的链路的调用: image.png 相信这就是pitch出现的缘由:能够中断loader的执行。

pitch可以接收三个参数:

  • remainingRequest:此loader之后的loader列表以及资源请求路径
  • previousRequest:此oader之前的loader列表
  • data:任意信息,与normal主函数的data参数意义相同 所以在执行css-loader的pitch时,以上这3个参数大概是:
remainingRequest = less-loader!./xxx.less
previousRequest = style-loader
data = {}

loader的开发知识大概介绍到这,下面进入实战

glsl-stringify-loader

写过webGL的同学们都清楚,glsl对JavaScript是说只是一份字符串,完全没有给予编程语言的待遇!

所以在此写一个loader,能够处理glsl文件。主要有以下功能:

  • 基本功能:处理glsl文件格式。
  • 简陋的模块系统:能在glsl文件中通过#require关键字引用别的glsl文件。

举例:
shader.glsl文件中通过#require引入shader-export.glsl文件:

precision mediump float;
#require "./shader-export.glsl"
void main () {
  gl_FragColor = vec4(1, 0, 0, 1);
}

shader-export.glsl文件是一个简单的函数:

float myFunction(vec3 normal) {
  vec3 hello = vec3(1, 0, 0)
  return dot(hello, normal);
}

经过【glsl-stringify-loader】处理之后会变成

precision mediump float;
float myFunction(vec3 normal) {
  vec3 hello = vec3(1, 0, 0)
  return dot(hello, normal);
}
void main () {
  gl_FragColor = vec4(1, 0, 0, 1);
}

测试驱动开发

简单的写一个单元测试,来保证loader能够按照我们预期的方式正确运行,预期即上面的glsl例子。

readWebpack.js
首先用Node.js APImemory-fs去执行webpack,可以避免向磁盘产生输出文件,并允许访问获取转换模块的统计数据stats。

module.exports = function readWebpack (configs, entry) {
    return new Promise((resolve, reject) => (
        ...
    )}
}

该函数接收两个参数,并返回一个Promise:

  • 第一个参数:configs,即webpack配置对象。
const path = require('path')

module.exports = {
    target: 'node',
    mode: 'production',
    module: {
        rules: [{
            test: /\.glsl$/,
            exclude: [/node_modules/],
            use: [
                path.resolve(__dirname, '../index.js')
            ]
        }]
    }
}

use中使用的index.js即为要开发【glsl-stringify-loader】文件。

  • 第二个参数:entry,即入口文件,入口文件非常简单,引用shader.glsl文件即可。
var shader = require('./shader.glsl')
module.exports = shader

1、配置entry和output
可以看到configs中缺少了两个最重要的配置:【entry】与【output】,这两个配置将在readWebpack函数里面定义。

// readWepback.js
const config = require(path.resolve(CONFIGROOT, configs))
config.entry = path.resolve(CONFIGROOT, entry)
if (!config.output) config.output = {}
Object.assign(config.output, {
    path: '/',
    filename: 'bundle.js',
    libraryTarget: 'umd'
})
  • 【CONFIGROOT】是configs文件的绝对路径。
  • entry参数经过路径处理后作为config.entry属性。
  • 使用【CONFIGROOT】读取entry参数文件,也就是说读取的configs、entry文件都放在同一个目录中。
  • 最后增加config.output属性。

2、创建wepback实例

const compiler = wepback(config)
const memfs = new MemoryFileSystem()
compiler.outputFileSystem = memfs
compiler.run((err, stats) => {
    if (err) return reject(err)
    if (stats.compilation.errors && stats.compilation.errors.length > 0) {
        return reject(stats.compilation.errors[0])
    }
    const bundleContent = String(memfs.readFileSync('/bundle.js'))
    const output = requireFromString(bundleContent).trim()
    resolve(output)
})
  • 首先用上面配置好的config对象,来创建webpack实例。
  • 然后以【MemoryFileSystem】方式将wepback设定为输出构建结果到内存中,避免写入磁盘,提高编译速度。
  • 接着执行run方法。
  • 在执行回调中,读取构建结果并转为字符串形式。
  • 之后使用【requireFromString】模块读取字符串形式的构建结果值,并以promise方式返回。

接下来看单元测试:
index.test.js

const webpack = require('./readWebpack')
describe('glsl-stringify-loader', () => {
    test('glsl file with local & external dependencies', async () => {
        const stats = await webpack('config.js', 'entry.js');
        expect(stats.indexOf('gl_FragColor =')).toBeTruthy();
        expect(stats.indexOf('vec3 hello =')).toBeTruthy();
    })
})
  • gl_FragColor =语句在shader.glsl文件中。
  • vec3 hello =语句在shader-export.glsl文件中。
  • shader-export.glsl通过#require关键字引进到shader.glsl中。
  • 预期是这两个语句在最终编译出来的文件里面都存在!

loader开发

首先必不可少的fspath模块。

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

接着用于匹配#require关键字的正则语句

const reg = /#require "([.\/\w_-]+)"/gi;

normal
loader是一个函数

module.exports = function(source) {
    this.cacheable();
    const cb = this.async();
    parse(this, source, this.context, function(err, bld) {
        if (err) {
            return cb(err);
        }

        cb(null, 'module.exports = ' + JSON.stringify(bld));
    });
};
  • 因为loader处理文件一般比较耗时,所以都用缓存this.cacheable()
  • 用的fs模块读写文件,所以是用到异步const cb = this.async()
  • parse函数是主函数,主要功能就是读取glsl并递归解析#require关键字引用的glsl

parse
在parse中首先找到当前文件的#require关键字:

const requires = [];
let match = reg.exec(source);

while (match != null) {
    requires.push({
        path: match[1],
        target: match[0],
        content: ''
    });
    match = reg.exec(source);
}

如果当前文件没有#require则直接使用cb(null, source)返回。这里的知识点就是只需在source前面加上module.exports = 即可,这也是【raw-loader】的基本原理。

如果有就pop出来处理:

  • const req = requires.pop()
  • 首先用this.resolve获取指定文件的完整路径
this.resolve(this.context, './' + req.path, function callback(resolve){
    ...
})
  • 在callback里面使用this.addDependency添加该文件依赖。
  • 然后使用fs.readFile根据路径读取文件,递归调用parse
function callback(resolve){
    this.addDependency(resolved);
    fs.readFile(resolved, 'utf-8', function cb(res) {
        parse()
    }
}
  • 剩下的requires会在parse递归完后继续处理。
parse(function cb () {
    // 处理剩余的requires
})

基本的代码就是这样!

运行测试
运行jest命令后,看最后结果 image.png 完美!

总结

更多关于webpack的文章请关注我的专栏

创作不易,烦请动动手指点一点赞。

楼主github, 如果喜欢请点一下star,对作者也是一种鼓励