从Webpack本质开始(二):手写loader

这个是探究 Webpack 本质的系列文章,会详细讲解如何手写一些源码如 Webpack, loader, plugin 等等,本文主要讲解的是如何手写 loader

loader 是个啥

webpack 只能处理 js 中的模块,所以若是要处理其他类型的文件的时候,就需要 loader 去做转换(transform)。本质上来说,loader 是用来将一段代码转换成另一段代码的 webpack 加载器。为什么叫做 “loader”?因为当你使用 import 或者其他方式去 “加载” 模块时,这些 loader 就会对模块进行一些预处理的工作。这就是 loader 的作用。

我们经常写 loader 时会这么写

function loader(source) {
// TODO
...
console.log('loader')
return source
}
module.exports = loader

可以看出 loader 本质上是一个函数,并且其参数就是源码

loader 的配置规则

webpack.config.js 的配置中,loader 的配置一般有三种写法

  • use 中写死路径

    module: {
    rules: [
    {
    test: /\.js$/,
    // loader 为项目目录的 /loaders/demo-loader.js
    use: path.resolve(__dirname, 'loaders', 'demo-loader.js')
    }
    ]
    }
  • resolveLoader 中建立别名,然后 use 使用这个别名

    resolveLoader: {
    alias: {
    demoLoader: path.resolve(__dirname, 'loaders', 'demo-loader.js')
    }
    },
    module: {
    rules: [
    {
    test: /\.js$/,
    // 别名
    use: 'demoLoader'
    }
    ]
    }
  • resolveLoader 中配置 modules,从自定义的目录开始寻找

    resolveLoader: {
    // node_modules 目录下找不到,就会从 loaders 目录下寻找 loader
    modules: ['node_modules', path.resolve(__dirname, 'loaders')]
    },
    module: {
    rules: [
    {
    test: /\.js$/,
    use: 'demo-loader'
    }
    ]
    }

loader 的执行顺序

从配置来看,默认的 loader 执行顺序是从下到上、从右往左的。对于 loader 来说,它有如下的分类

  • pre (在前面执行)
  • normal (默认不配置)
  • inline (插入)
  • post (在后面执行)

比如下面的配置

module: {
rules: [
{
test: /\.js$/,
use: { loader: 'demo-loader' },
enforce: 'pre',
},
{
test: /\.js$/,
use: { loader: 'demo-loader2' },
},
{
test: /\.js$/,
use: { loader: 'demo-loader3' },
enforce: 'post',
},
]
}

其执行顺序就变成了 demo-loader -> demo-loader2 -> demo-loader3

inline loader 是怎么回事呢?它实际上就是插入的 loader,如果希望在 require 其他模块时将其导到 post loader 前面去执行,就这样写

// ./src/index.js
const moduleA = require('inline-loader!./moduleA')

这里的 inline-loader 是 inline-loader 的名字,前面加个 ! 表示希望交给 inline-loader 去做处理

那么打印顺序就变成了

loader
loader2
loader3
loader
loader2
inline-loader
loader3

但是由于 require 配置的不同,inline-loader 插入的方式也不一样,比如

// ./src/index.js
const moduleA = require('-!inline-loader!./moduleA')

那么打印顺序变成

loader
loader2
loader3
inline-loader
loader3

后面的 pre 以及 normal 的就没有打印出来了

如果只有一个 ! 表示后面的 normal 不执行

// ./src/index.js
const moduleA = require('!inline-loader!./moduleA')

那么打印顺序变成

loader
loader2
loader3
loader
inline-loader
loader3

如果是两个 ! ,则表示后面的啥都不执行

// ./src/index.js
const moduleA = require('!!inline-loader!./moduleA')

那么打印顺序变成

loader
loader2
loader3
inline-loader

总结下符号的含义

  • -! 禁用前置和正常 loader
  • ! 禁用普通 loader
  • !! 禁用前置后置以及正常 loader

这里说一个为何 inline-loader 前面有打印输出,这是因为 loader 本身还由两个阶段组成: pitchingnormal execution

假如一个 use 配置是这样的

use: ['loader3', 'loader2', 'loader1']

如果 loaderpitch 方法都没有返回值,那么它执行的顺序为

|- loader3 `pitch`
|- loader2 `pitch`
|- loader1 `pitch`
|- requested module is picked up as a dependency
|- loader1 normal execution
|- loader2 normal execution
|- loader3 normal execution

如果某一个 loaderpitch 方法有返回值(比如 loader2),那么它执行的顺序为

|- loader3 `pitch`
|- loader2 `pitch`
|- loader3 normal execution

最后需要说下的是 loader 的特点

  • 第一个 loader 必须返回的是 js 脚本
  • 每个 loader 只做其对应的事情,这是为了使 loader 能在更多的场景下进行链式调用
  • 每一个 loader 都是一个模块
  • 每一个 loader 都应该是 无状态 的,确保 loader 在不同模块的转换之间不保存状态

手写 babel-loader

首先需要装三个包

yarn add @babel/core @babel/preset-env loader-utils
  • @babel/core: js 编译器的核心组件
  • @babel/preset-env babel 的插件预设,用来提供支持新语法的环境
  • loader-utils 一个专门用于写 webpack loader 的工具库

然后 webpack.config.js 的内容如下

const path = require('path')

module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
resolveLoader: {
modules: ['node_modules', path.resolve(__dirname, 'loaders')]
},
// 整个 source map 作为一个单独的文件生成。它为 bundle 添加了一个引用注释,以便开发工具知道在哪里可以找到它
devtool: 'source-map',
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'] // 使用 @babel/preset-env 来转化
}
}
}
]
}
}

这里说一下 source-mapsource-map 是一种提供 debug 的源码映射文件,它建立的是一种将压缩文件中的代码对应到源代码文件的方式,有这个source map 文件,出问题了就可以找到对应的代码是哪一段

然后就可以写 babel-loader

const babel = require('@babel/core')
const loaderUtils = require('loader-utils')

function loader(source) {
// this 就是 loaderContext
const options = loaderUtils.getOptions(this)
// 异步返回需要用到 async 这个函数
const cb = this.async()
// babel 转化代码
babel.transform(source, {
...options,
sourceMaps: true, // 使用源码映射 生成 .js.map
filename: this.resourcePath.split('/').pop() // 取出资源文件名
}, (err, result) => {
const { code, map } = result
cb(err, code, map) // 返回 code 以及 map 文件
})
}

module.exports = loader

这里的逻辑就是,拿到 loader 的 Context,将其转化成 options,然后使用 babeltransform API 来进行代码的转换(es6 -> es5),里面除了传入 options 之外,还要传入 sourceMaps 确定生成 soureMap 文件,然后 filename 设置 sourcemap 的文件名。最后是一个异步回调函数,由于是异步的,所以这里就需要 async 这个 API,只要调用它就会自动返回传的参数。

手写 banner-loader

实现这个 loader 的功能主要是这样

  • 读取 js 文件,获取其里面的内容,将其作为注释加入到最终生成的源码文件中
  • 若这个 js 文件不存在,则读取其配置中的 text 选项的内容作为注释加入到最终生成的源码文件中

先安装 schema-utils,这是一个 webpack 专门用于验证 loaders 以及 pluginsoptions 的库

yarn add schema-utils

然后是 webpack.config.js 配置,修改 module.rules[0] 选项

{
test: /\.js$/,
use: {
loader: 'banner-loader',
options: {
text: '这里是注释',
filename: path.resolve(__dirname, 'banner.js')
}
}
}

同时设置 watch 选项为 true

在项目当前目录增加 banner.js 文件,文件里面写上 helloworld

然后在 loaders 目录下增加 banner-loader.js 文件

const fs = require('fs')
const loaderUtils = require('loader-utils')
const validateOptions = require('schema-utils')

function loader(source) {
// 若有缓存则优先用缓存
this.cacheable && this.cacheable()

const cb = this.async()
const options = loaderUtils.getOptions(this)
const schema = {
type: 'object',
properties: {
text: { type: 'string' },
filename: { type: 'string' }
}
}

// 验证配置有没有写正确
validateOptions(schema, options, 'banner-loader')

const { filename, text } = options
if (filename) {
// 添加依赖 在 webpack 使用 watch 配置时使用,只要对应文件有改动,会自动打包更新
this.addDependency(filename)

// 读取文件并写入注释
fs.readFile(filename, 'utf8', (err, data) => {
cb(err, `/**${data}*/${source}`)
})
} else {
cb(null, `/**${text}*/${source}`)
}
}

module.exports = loader

主要的逻辑很简单,就是看有没有配置有没有写对,配置写对了的话就看 filename 存在不存在,filename 不存在就走 textfilename 存在就读取相应文件然后写进去。这里有两个 loader 的 API 需要注意的,一个是 cacheable,另一个是 addDependency,前者是用来缓存文件的,后者是用来配合 webpackwatch 配置来用的

手写 file-loader 以及 url-loader

首先新建一个图片 demo.jpg 放入 assets 目录下

然后在 src/index.js 中写入

// 测试 file-loader 以及 url-loader
import picture from './assets/demo.jpg'
const img = document.createElement('img')
img.src = picture
document.body.appendChild(img)

file-loader 的作用就是

  • 根据图片生成 md5 发射到 dist 目录下
  • 返回当前的图片路径

首先是 webpack.config.js

{
test: /\.(png|jpg)$/,
use: {
loader: 'file-loader'
}
}

在项目 loaders 下新建 file-loader.js 文件

const loaderUtils = require('loader-utils')
function loader(source) {
// 根据 loaderContext 生成文件, 这个文件是一串 hash + .jpg 作为后缀名的
const filename = loaderUtils.interpolateName(this, '[hash].[ext]', {
content: source
})
// 发射文件
this.emitFile(filename, source)
// 最后 file-loader 需要返回一个路径,这样 index.js 在 import 图片的时候
// img 的 src 才是正确的路径
return `module.exports = "${filename}"`
}
// 让 source 变成二进制的 buffer
loader.raw = true
module.exports = loader

这里需要注意的一点是 需要将 source 变成二进制的 buffer,这样便于传参以及后续的处理。最后返回的是一个 module.exports,因为最终需要的是一个路径,这样相当于改变了模块的源码,别人在引入图片的时候就直接引入了路径了

当然更高级一点的写法是 url-loader,这个 loader 的作用就是

  • 将传入的文件做一个 limit 的判断,如果这个文件大于 limit,则返回的是文件路径
  • 若小于 limit,则返回的是一串 base64

写这个之前需要安装 mime

yarn add mime

mime 是一个判断文件类型的工具包,生成 base64 的时候还需要注明图片类型

const loaderUtils = require('loader-utils')
const mime = require('mime')
function loader(source) {
const { limit } = loaderUtils.getOptions(this)
if (limit && source.length < limit) {
// 图片没有超过设定的限制大小就返回 base64
return `module.exports = "data:${mime.getType(this.resourcePath)};base64,${source.toString('base64')}"`
} else {
// 图片超过了就调用 file-loader 返回图片本身路径
return require('./file-loader').call(this, source)
}
}
// 让 source 变成二进制的 buffer
loader.raw = true
module.exports = loader

这里我们看到使用二进制的好处是便于判断图片的大小

手写 less-loader、css-loader 以及 style-loader

由于上篇文章已经讲过 less-loader 以及 style-loader 的写法(那个时候将 style-loader 当成 css-loader 使用了,不过不影响讲解),这里就列出 less-loader 以及 style-loader 的源码

首先还是要安装 less

yarn add less

然后是 webpack.config.js

{
test: /\.less$/,
use: ['style-loader', 'css-loader', 'less-loader']
}

注意这里 use 数组的顺序不能变

src/index.less

@color: red;

body {
background: @color;
}

这里的分号必须是要的!!!

src/index.js 里面引入

import './index.less'

然后是 less-loader.js

const less = require('less')
function loader(source) {
let css = ''
less.render(source, (err, c) => {
console.log(c)
css = c.css
})
return css
}

module.exports = loader

然后是 style-loader

function loader(source) {
const str = `
let style = document.createElement('style')
style.innerHTML = ${JSON.stringify(source)}
document.head.appendChild(style)
`
return str
}

module.exports = loader

前面讲过,使用 JSON.stringfy 的原因是可以将源码转换成字符串,而且把里面的回车都转成 \r\n

现在将 index.less 改成如下

@color: red;

body {
background: @color;
background: url(./assets/demo.jpg);
}

很显然,打包进 dist/bundle.js 中的文件路径直接在这里使用是引用不到的,因为 dist 目录下并没有 assets/demo.jpg。这时候就必须要写 css-loader 来解析这个 less 文件,首先就是要考虑到 url(./assets/demo.jpg),将他变成 url(require(./assets/demo.jpg)) 就可以了,因为有 require 的情况下 webpack 就会自动将其打包到 bundle.js 中了

那么怎么做呢?可以将这个 index.less 分为三个部分

【@color: red;

body {
background: @color;
background:】【 url(./assets/demo.jpg);】
【}】

如上,用 “【】”包起来的就是一部分了,接下来看看 css-loader.js 的代码

function loader(source) {
// 匹配 url(xxx) 这个东西, 用 g 表示全局查找,因为 less 文件里不只是只有一个 url
const reg = /url\((.+?)\)/g
// 匹配的指针位置
let pos = 0
// 当前匹配的结果
let current
// 这个数组本质上就是保存一个代码段的
const arr = ['let list = []']
// 在 source 中循环匹配 reg
while(current = reg.exec(source)) {
// 匹配到了 ['url(./assets/demo.jpg)', './assets/demo.jpg']
const [matchUrl, g] = current
// ↓ lastPos 位置
// url(./assets/demo.jpg)
const lastPos = reg.lastIndex - matchUrl.length
// 截取第一部分
arr.push(`list.push(${JSON.stringify(source.slice(pos, lastPos))})`)

// 指针移动
// pos 位置 ↓
// url(./assets/demo.jpg)
pos = reg.lastIndex

// 截取第二部分
// 把 g 替换成 require 的写法
// 即 './assets/demo.jpg' => require('./assets/demo.jpg')
arr.push(`list.push('url(' + require('${g}') + ')')`)
}

// 截取第三部分
arr.push(`list.push(${JSON.stringify(source.slice(pos))})`)

// 将整个 less 文件作为一个模块返回,而这个模块是一整个字符串
// 当 style-loader require 引入时,里面的代码就可以直接执行了
arr.push(`module.exports = list.join('')`)

// 为了好看,每行代码之间增加一个回车
return arr.join('\r\n')
}

module.exports = loader

这里主要的思路就是将如上的 less 中的代码看成三个部分,然后以匹配 url 为界点将代码分成三个部分,前两个部分都在循环匹配 url,然后将 url 里的路径改为 require 引入。匹配修改完成之后再截取剩下的第三部分。这里用了数组 arr 的方式将 less 代码转变成了另一段代码,而这段代码也是用数组的形式将 less 代码 拼凑起来,最后形成了一个 模块。到最后,style-loader 在使用 require 引入时就以字符串的形式引入了这些代码。

接下来改造 style-loader.js,增加 pitch 函数

const loaderUtils = require('loader-utils')

// 普通 loader
function loader(source) {
const str = `
let style = document.createElement('style')
style.innerHTML = ${JSON.stringify(source)}
document.head.appendChild(style)
`
return str
}

// pitch 执行的顺序是 style-loader css-loader less-loader
// 这里 remainingRequest 剩余请求表示的就是 css-loader!less-loader!./index.less
// 也就是剩下的还没操作的 css-loader less-loader
loader.pitch = (remainingRequest) => {
// 使用 stringifyRequest 的作用是
// 将请求转换为可以在 require() 或 import 中使用的字符串,同时避免使用绝对路径
const req = loaderUtils.stringifyRequest(this, '!!' + remainingRequest
const str = `
let style = document.createElement('style')
style.innerHTML = require(${req})
document.head.appendChild(style)
`
return str
}

module.exports = loader

注意,这里使用 stringifyRequest 的意义在于,由于 webpack 在将模块路径转换为模块 id 之前会计算哈希值,因此在这里必须避免使用绝对路径,以确保不同编译之间的哈希值保持一致。

这样写的好处就是,style-loaderpitch loader 会首先执行,但是上面的普通 loader 就不会执行了。这里参数 remainingRequest 相当于剩下的请求,打印出来其实就相当于局部路径 css-loader!less-loader!./index.less。还记得前面说的这些感叹号的意思么? require('css-loader!less-loader!./index.less') 表示内联 loader,优先级是 从右往左。但是这里就有个问题,index.less 会先从 style-loaderpitch 开始,因为有 require 的存在,会经过 less-loadercss-loader,然后又会经过 style-loaderpitch,这样一来就会死循环了。所以需要在 require 时添加 !!,也就是 require('!!css-loader!less-loader!./index.less'),这里 !! 号就表示执行完 less-loadercss-loader 就不会调用 style-loader 去执行了,也就解决了之前的死循环的问题。

总结

本文写了比较多的 loader,现在对 loader 一个最基本的认识就是 loader 就是一个 函数,一旦有模块被 import 或者 require 时它就会去拦截这些模块的源码,对其进行改造,然后输出到另一个模块中,循环往复,最终迭代到入口文件中,形成最终的代码
所以它的本质在于对代码的 拼凑转换,其中用的比较多的库是 loaderUtils,这个工具库在写 loader 时必用,需要注意。

另,👉项目测试仓库戳这里