Webpack原理系列(二)loader原理

1,061 阅读11分钟

前言

loader是webpack打包过程中非常重要的一环,通过了解loader的执行过程,不仅可以学习到很多设计思想,还可以在以后遇到webpack配置问题,处理起来得心应手

如何写一个loader

loader本质是一个函数,接收文件内容,返回处理过后的源码,下面是一个简单的loader示例

module.exports = function(source) {
    const code = transform(source) // 在这里你可以对文件内容进行转换或处理
    return code
}

以上实现了一个简单的loader, 看起来是不是很简单。下面稍微升级一点难度。实现一个简单的style-loader

function loader(source) {
    let script = `let style = document.createElement("style");
    style.innerHTML = ${JSON.stringify(source)}; document.head.appendChild(style); `;
    return script;
}
module.exports = loader;

当我们配置上style-loader后,遇到import 'a.css'时会将其原本的内容替换成一段JS脚本,并将样式代码插入到head标签中

loader的种类

虽说要实现一个loader很简单,但是需要注意的是,在webpack中loader可以分以下几种类型:

  • pre loader
  • normal loader
  • inline loader
  • post loader

以上loader的执行是从上到下执行的。也就是 pre-loader => normal loader => inline loader => post loader,我们先来看一个例子。

代码包含两个文件index.jstest.js, 在导入test.js时使用了inline-loader, 我们先不关心各种Loader是怎么写的。

// index.js
import test from 'inline-loader2!inline-loader1!./test'
export default function func() {
  return test
}

// test.js
export default 1

下面的代码配置了另外三种loader

const path = require('path')

function loaderPath(loaders) {
  return loaders.map(loader => path.resolve(__dirname, 'loaders', loader + '.js'))
}
module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
  },
  module: {
    rules: [
      // pre loader
      {
        test: /\.js$/,
        enforce: 'pre',
        use: loaderPath(['pre-loader1', 'pre-loader2'])
      },
      // normal loader
      {
        test: /\.js$/,
        use: loaderPath(['normal-loader1', 'normal-loader2'])
      },
      // post loader
      {
        test: /\.js$/,
        enforce: 'post',
        use: loaderPath(['post-loader1', 'post-loader2'])
      }
    ]
  }
}

上面配置中需要注意的点

  1. 通过enforce属性,设置loader的执行顺序
  2. 通过!分割inline-loader

看下运行结果

// index.js 执行的loader
pre-loader2
pre-loader1
normal-loader2
normal-loader1
post-loader2
post-loader1

// test.js 执行的loader
pre-loader2
pre-loader1
normal-loader2
normal-loader1
inline-loader2
inline-loader1
post-loader2
post-loader1

inline loader的写法

通过上面的示例,我们大体了解了loader的执行顺序,大家先留个印象。但是大家可能比较疑惑,inline-loader的写法怎么这么奇怪。有时候我们项目在编译的时候经常会看到类似的log, 比如vue编译的时候, 有这么一长串:

-!../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!../node_modules/vue-loader/lib/index.js??vue-loader-options!./app.vue?vue&type=template&id=5ef48958&scoped=true&

上面的内容其实可以分为四部分

-!../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options

!../node_modules/vue-loader/lib/index.js??vue-loader-options

!./app.vue

?vue&type=template&id=5ef48958&scoped=true&

inline-loader其实是通过!将loader进行分割,例如

import test from 'inline-loader2!inline-loader1!./test'
// 包含 inline-loader2 和 inline-loader1

那么-!这个前缀又是什么呢,其实前缀有多种写法: Webpack中文文档

符号变量含义
-!noPreAutoLoaders不要前置和普通 loader
!noAutoLoaders不要普通 loader
!!noPrePostAutoLoaders其他loader都不要,只要内联 loader

比如我们在前面加了!!前缀,那么normal, pre, post loader都不会执行, 所以内联loader是比较灵活的,在日常项目中并不推荐使用

loader是如何执行的

其实webpack为了实现loader的功能,单独开发了一个loader执行器,也就是loader-runnner。下面看个简单的例子

import { runLoaders } from "loader-runner";

runLoaders({
    resource: "/abs/path/to/file.txt?query", // 需要处理的文件路径
    loaders: ["/abs/path/to/loader.js"], // loader文件路径
    context: { minimize: true }, // loader上下文,可通过this获取
    processResource: (loaderContext, resourcePath, callback) => { ... },
    readResource: fs.readFile.bind(fs)
}, function(err, result) {
   // 处理后的文件内容
})

在执行runLoaders过后,会获取到文件最终的内容。上面的例子在执行后,会经过如下的流程 (注意:enforce的pre、post配置是webpack自身制定的规则,runLoaders只负责执行

  1. 按照post -> inline -> normal -> pre顺序, 从左到右执行相同类型的loader.pitch
  2. 按照pre -> normal -> inline -> post顺序, 从右到左执行相同类型的loader

未命名文件 (12).png

pitch和normal执行顺序完全相反,pitch先执行

pitch loader

看了上面loader执行的过程,大家可能又比较疑惑pitch loader是什么。其实在开发 Loader 时,我们可以在导出的函数上添加一个 pitch 函数,就像下面这样:

function loader(source) {
  console.log('normal-loader1')
  return source
}

/**
 * 
 * @param {*} remainingRequest 剩余需要执行的pitch loader
 * @param {*} precedingRequest 已经执行过得pitch loader
 * @param {*} data 
 */
loader.pitch = function(remainingRequest, precedingRequest, data) {
  console.log(remainingRequest)
  console.log(precedingRequest)
  console.log(data)
}

module.exports = loader

当文件经过该loader处理时,pitch会先执行,并打印出下面内容

D:\code\pre-loader1.js!D:\code\pre-loader2.js!D:\code\webpack-demo\src\test.js  // 剩余需要执行的pitch loader

D:\code\post-loader1.js!D:\code\post-loader2.js!D:\code\normal-loader1.js // 已经执行过得pitch loader

{} // 空对象

再测试下一开始的例子,将会打印下面的内容

// pitch 优先执行了,并且是从post开始
post-loader1 pitch
post-loader2 pitch
inline-loader1 pitch
inline-loader2 pitch
normal-loader1 pitch
normal-loader2 pitch
pre-loader1 pitch
pre-loader2 pitch

pre-loader2
pre-loader1
normal-loader2
normal-loader1
inline-loader2
inline-loader1
post-loader2
post-loader1

pitch loader的熔断机制

当pitch返回一个非空的值时,将会跳过后面pitch loadernormal loader的执行

function loader(source) {
  console.log('normal-loader1')
  return source
}
loader.pitch = function(remainingRequest, precedingRequest, data) {
  console.log('normal-loader1 pitch');
  return 'let a = 0' // 这里返回了非空值
}
module.exports = loader

我们在normal-loader1的pitch函数中返回了非空值测试下:

post-loader1 pitch
post-loader2 pitch
inline-loader1 pitch
inline-loader2 pitch
normal-loader1 pitch
inline-loader2
inline-loader1
post-loader2
post-loader1

可以看到loader只执行到了normal-loader1 pitch, normal-loader1自身的loader也不会执行。 并且normal-loader1 pitch的返回值,将作为inline-loader2source参数(大家注意下面红色箭头

未命名文件 (13).png

loader上下文

我们再回到前面的例子

import { runLoaders } from "loader-runner";

runLoaders({
    resource: "/abs/path/to/file.txt?query", // 需要处理的文件路径
    loaders: ["/abs/path/to/loader.js"], // loader文件路径
    context: { minimize: true }, // loader上下文,可通过this获取
    processResource: (loaderContext, resourcePath, callback) => { ... },
    readResource: fs.readFile.bind(fs)
}, function(err, result) {
   // 处理后的文件内容
})

大家会发现有一个context属性,那它是干嘛的呢。下面举个简单的例子

function loader(source) {
  const callback = this.async()
  setTimeout(() => {
    callback(null, source) // 等同于this.callback
  }, 2000)
}

上面的代码,我们通过this调用了async方法,获取一个callback, 这种方式可以让我们在Loader中实现异步操作

什么是loader上下文呢,简单来讲就是this, loader的this上有许多变量和函数,能方便我们获取当前需要处理的文件,或者异步处理文件内容。原理也很简单, 就是通过apply来实现

loader.apply(loaderContext, args)

loader-runner自带的上下文属性

其实loader上下文的属性可以分为loader-runner内置的上下文属性 和 webpack内置的上下文属性,什么意思呢?抛开webpack这个构建工具,如我们只是单纯使用loader-runner它将包含下面这些上下文属性

function loader(source) {
  this.resource // 需要处理的资源路径
  this.request // 完整的请求
  this.loaders // loader对象数组
  this.readResource // 读取资源的方法,默认fs.readFile
  this.loaderIndex // 当前正在执行的loader索引
  this.callback // 回调方法
  this.async // 异步方法,返回一个回调函数
  this.remainingRequest // 剩余请求
  this.currentRequest // 当前请求
  this,previousRequest // 已经处理过得请求
  this.data // 当前loader的公共数据
  return source
}
module.exports = loader

webpack的loader上下文属性

前面我们知道在执行runLoaders方法时,可以传一个自己的context,最终会和内置的上下文属性合并。我们直接来看下webpack的源码。

// webpack\lib\NormalModule.js
doBuild(options, compilation, resolver, fs, callback) {
  // 创建loader上下文
  const loaderContext = this.createLoaderContext(
    resolver,
    options,
    compilation,
    fs
  );
  // 执行Loader
  runLoaders(
    {
      resource: this.resource,
      loaders: this.loaders,
      context: loaderContext,
      readResource: fs.readFile.bind(fs)
    },
    (err, result) => {
      return callback();
    }
  );
}

loaderContext源码如下

// webpack\lib\NormalModule.js
createLoaderContext(resolver, options, compilation, fs) {
    // ..
    const loaderContext = {
        version: 2,
        emitWarning: warning => {
        },
        emitError: error => {
        },
        getLogger: name => {
        },
        // TODO remove in webpack 5
        exec: (code, filename) => {
        },
        resolve(context, request, callback) {
        },
        getResolve(options) {
        },
        emitFile: (name, content, sourceMap, assetInfo) => {
        },
        rootContext: options.context,
        webpack: true,
        sourceMap: !!this.useSourceMap,
        mode: options.mode || "production",
        _module: this,
        _compilation: compilation,
        _compiler: compilation.compiler,
        fs: fs
    };
    compilation.hooks.normalModuleLoader.call(loaderContext, this);
    return loaderContext;
}

从上面的代码可以知道normalModuleLoader hook可以方便的获取到loaderContext, 并且扩展loader功能

compiler.hooks.compilation.tap("LoaderPlugin", compilation => {
    compilation.hooks.normalModuleLoader.tap(
        "LoaderPlugin",
        (loaderContext, module) => {
            // 扩展loaderContext
        }
    );
});

另外,关于webpack中loaderContext的属性用法,大家感兴趣可以看下

Webpack中文文档

实现loader-runner

前面介绍了loader-runner用法,不如趁热打铁实现一波~, 实现起来也是非常简单的, 先来看下整体流程图

loader-runner流程图.png

在实现之前我们先来回顾下runLoaders用法

import { runLoaders } from "loader-runner";

runLoaders({
    resource: "/abs/path/to/file.txt?query", // 需要处理的文件路径
    loaders: ["/abs/path/to/loader.js"], // loader文件路径
    context: { minimize: true }, // loader上下文,可通过this获取
    processResource: (loaderContext, resourcePath, callback) => { ... },
    readResource: fs.readFile.bind(fs)
}, function(err, result) {
   // 处理后的文件内容
})

1. 初始化loaderContext

先来实现初始化逻辑

function createLoaderObject(loader) {
  // 获取loader函数
  let normal = require(loader)

  // 获取pitch函数
  let pitch = normal.pitch

  // 如果为true loader接收的是Buffer,否则是字符串
  let raw = normal.raw

  return {
    path: loader,
    normal,
    pitch,
    raw,
    data: {}, // 每个loader可以携带一个自定义的数据对象
    pitchExecuted: false, // pitch是否执行
    normalExecuted: false // normal是否执行
  }
}

function runLoaders(options, finalCallback) {
  const {
    resource, // 资源路径
    loaders = [], // loader配置
    context = {}, // 上下文对象
    readResource = fs.readFile
  } = options

  const loaderObjects = loaders.map(createLoaderObject)
  const loaderContext = context
  loaderContext.resource = resource
  loaderContext.loaders = loaderObjects
  loaderContext.readResource = readResource
  loaderContext.loaderIndex = 0 // 当前正在执行的Loader索引
  // 调用它会执行下一个loader
  loaderContext.callback = null
  // 默认Loader是同步的
  loaderContext.async = null
   
  // 定义request getter
  Object.defineProperty(loaderContext, 'request', {
    get() {
      // loader1!loader2!loader3!./a.js
      return loaderContext.loaders
        .map(loader => loader.path)
        .concat(loaderContext.resource)
        .join('!')
    }
  })
  // 定义remainingRequest getter
  Object.defineProperty(loaderContext, 'remainingRequest', {
    get() {
      return loaderContext.loaders
        .slice(loaderContext.loaderIndex + 1)
        .map(loader => loader.path)
        .concat(loaderContext.resource)
        .join('!')
    }
  })
  // 定义currentRequest getter
  Object.defineProperty(loaderContext, 'currentRequest', {
    get() {
      return loaderContext.loaders
        .slice(loaderContext.loaderIndex)
        .map(loader => loader.path)
        .concat(loaderContext.resource)
        .join('!')
    }
  })
  // 定义previousRequest getter
  Object.defineProperty(loaderContext, 'previousRequest', {
    get() {
      return loaderContext.loaders
        .slice(0, loaderContext.loaderIndex)
        .map(loader => loader.path)
        .concat(loaderContext.resource)
        .join('!')
    }
  })
  // 定义data getter
  Object.defineProperty(loaderContext, 'data', {
    get() {
      return loaderContext.loaders[loaderContext.loaderIndex]
    }
  })

  let processOptions = {
    resourceBuffer: null, // 本次要读取的资源文件Buffer
    readResource
  }

  // 迭代执行pitch
  iteratePitchingLoader(processOptions, loaderContext, (err, result) => {
    // 最终的回调
    finalCallback && finalCallback(err, {
      result,
      resourceBuffer: processOptions.resourceBuffer
    })
  })
}

exports.runLoaders = runLoaders

上面的代码中,主要做了这么几件事

  • 为每个loader创建loader对象
  • 基于传入的context,再初始化一些内置上下文
  • 定义一些requestgetter,因为这样才能根据loaderIndex实时获取到当前正在执行loader的request信息
  • 迭代pitch

下面我们详细看下iteratePitchingLoader的实现

2. iteratePitchingLoader

function iteratePitchingLoader(processOptions, loaderContext, pitchingCallback) {
  // 从左向右执行,越界了,就可以读取文件了
  if (loaderContext.loaderIndex >= loaderContext.loaders.length) {
    return processResource(processOptions, loaderContext, pitchingCallback)
  }
  // 获取当前要执行的loader
  let currentLoader = loaderContext.loaders[loaderContext.loaderIndex]

  // 没有pitch的情况会执行
  if (currentLoader.pitchExecuted) {
    loaderContext.loaderIndex++
    return iteratePitchingLoader(processOptions, loaderContext, pitchingCallback)
  }
  let fn = currentLoader.pitch
  currentLoader.pitchExecuted = true
  // 没有pitch的情况会执行
  if (!fn) {
    return iteratePitchingLoader(processOptions, loaderContext, pitchingCallback)
  }

  runSyncOrAsync(fn, loaderContext, [
    loaderContext.remainingRequest,
    loaderContext.previousRequest,
    loaderContext.data
  ], (err, ...args) => {
    // pitch返回值不为空 跳过后续loader, 掉头执行前一个Loader的normal
    if (args.length && args.some(e => e)) {
      loaderContext.loaderIndex--
      iterateNormalLoaders(processOptions, loaderContext, args, pitchingCallback)
    } else {
      return iteratePitchingLoader(processOptions, loaderContext, pitchingCallback)
    }
  })
}

上面的代码主要做了这几件事

  • 从左向右执行,判断是否越界了,超过就代表就可以读取文件了(调用processResource方法),同时也代表pitch没有返回值
  • 没有pitch的情况, 继续向后迭代,并使loaderIndex++
  • 存在pitch, 就调用runSyncOrAsync

3. runSyncOrAsync

function runSyncOrAsync(fn, loaderContext, args, runCallback) {
  let isSync = true

  loaderContext.callback = (...args) => {
    runCallback(...args)
  }
  loaderContext.async = function() {
    isSync = false
    return loaderContext.callback
  }
  const result = fn.apply(loaderContext, args)
  if (isSync) {
    runCallback(null, result)
  }
}

runSyncOrAsync实现比较简单,只是在loaderContext上挂载了一些回调方法。其实最后执行的都是loaderContext.callback。 在执行完上面的内容后,会通过runCallback拿到返回结果,并判断结果是否为空,如果为空就继续迭代。否则就开始迭代normal loader

4. iterateNormalLoaders

看完上面iteratePitchingLoader的实现后,其实大家也能猜到这个方法的实现了,其实就是反过来迭代了。

function convertArgs(args, raw) {
  if (raw && !Buffer.isBuffer(args[0])) {
    args[0] = Buffer.from(args[0])
  } else if (!raw && Buffer.isBuffer(args[0])) {
    args[0] = args[0].toString()
  }
}

function iterateNormalLoaders(processOptions, loaderContext, args, pitchingCallback) {
  // 如果超出左边边界,就调用结束回调
  if (loaderContext.loaderIndex < 0) {
    return pitchingCallback(null, ...args)
  }
  // 获取当前loader
  let currentLoader = loaderContext.loaders[loaderContext.loaderIndex]
  if (currentLoader.normalExecuted) {
    loaderContext.loaderIndex--
    return iterateNormalLoaders(processOptions, loaderContext, args, pitchingCallback)
  }
  let normalFn = currentLoader.normal
  currentLoader.normalExecuted = true
  convertArgs(args, currentLoader.raw)
  // 执行normal loader
  runSyncOrAsync(normalFn, loaderContext, args, (err, ...returnArgs) => {
    return iterateNormalLoaders(processOptions, loaderContext, returnArgs, pitchingCallback)
  })
}


function processResource(processOptions, loaderContext, pitchingCallback) {
 // 调用readResource 读取文件内容,读取完成后,拿到文件内容向左迭代
  processOptions.readResource(loaderContext.resource, (err, resourceBuffer) => {
    processOptions.resourceBuffer = resourceBuffer
    loaderContext.loaderIndex--
    // 迭代执行normal loader
    iterateNormalLoaders(processOptions, loaderContext, [resourceBuffer], pitchingCallback)
  })
}

以上就是loader-runner的执行过程,是不是非常简单~,源码已放入github

加餐:vue-loader源码

前面我们学习了loader的执行过程,并且了解了pitcher基本的使用,甚至自己实现了一个loader-runner,那么有了上面这些基础,阅读vue-loader将非常轻松。

我们先看下vue-loader是如何使用的:

// webpack.config.js
const VuePlugin = require('vue/dist/plugin.js')
module.export = {
  //...
  module: {
    rules: [
      {
        test: /\.vue$/,
        use: ['vue-loader']
      },
    ]
  },
  plugins: [
    new VuePlugin(),
  ]
  //...
}

源码概览

整个源码可以大致分为两个部分

  • VuePlugin初始化:为webpack的rules中插入了一个pitch loader
  • vue-loader处理
    • 阶段一:将SFC(单文件组件)拆分成三部分style, template, script,并生成不同的请求。

      import { render, staticRenderFns } from "./App.vue?vue&type=template&id=13429420&scoped=true&"
      import script from "./App.vue?vue&type=script&lang=js&"
      export * from "./App.vue?vue&type=script&lang=js&"
      import style0 from "./App.vue?vue&type=style&index=0&id=13429420&scoped=true&lang=scss&"
      // 省略其他代码...
      
    • 阶段二: 命中pitch loaderresourceQuery规则,包含vue参数,会进入pitch loader。然后会为每个block添加对应的loader,让它们分别用不同的loader处理。

    • 阶段三

下面我们直接分析vue-loader做了哪些事

阶段一

下面举个例子

<template>
  <div id="app">
    <img alt="Vue logo" src="./assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js App"/>
  </div>
</template>

<script>
import HelloWorld from './components/HelloWorld.vue'

export default {
  name: 'App',
  components: {
    HelloWorld
  }
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

在这个阶段,vue-loader会使用@vue/component-compiler-utils这个包,将代码解析成descriptor

// node_modules\vue-loader\lib\index.js

const descriptor = parse({
    source,
    compiler: options.compiler || loadTemplateCompiler(loaderContext),
    filename,
    sourceRoot,
    needMap: sourceMap
})

最后生成一系列代码,如下:

import { render, staticRenderFns } from "./App.vue?vue&type=template&id=514e6843&"
import script from "./App.vue?vue&type=script&lang=js&"
export * from "./App.vue?vue&type=script&lang=js&"
import style0 from "./App.vue?vue&type=style&index=0&lang=css&"
import normalizer from "!../node_modules/vue-loader/lib/runtime/componentNormalizer.js"
var component = normalizer(
    script,
    render,
    staticRenderFns,
    false,
    null,
    null,
    null
)
export default component.exports

总结一下做了什么

  • 将组件文件,分成三部分, 并且添加vue&type=xxx,用type来区分。

阶段二

上面生成代码后,webpack会递归依赖,就会进入到pitch loader的逻辑。在这个阶段会对请求路径添加一些inline-loader

1、template


import { render, staticRenderFns } from "./App.vue?vue&type=template&id=514e6843&"

// 被处理成 export,也就是说,进入上面的文件后,又被导出了

export * from "../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=template&id=514e6843&"

通过上面的学习,相信大家已经看出来了,会经过这些loader的处理

  • templateLoader.js
  • vue-loader

2、script

import script from "./App.vue?vue&type=script&lang=js&"

// 被处理成 export,也就是说,进入上面的文件后,又被导出了

import mod from "../node_modules/babel-loader/lib/index.js!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=script&lang=js&"
export default mod;
export * from "../node_modules/babel-loader/lib/index.js!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=script&lang=js&"

会经过如下loader的处理

  • babel-loader
  • vue-loader

3、style

import style0 from "./App.vue?vue&type=style&index=0&lang=css&"

// 被处理成 export,也就是说,进入上面的文件后,又被导出了

export * from "-!../node_modules/mini-css-extract-plugin/dist/loader.js??ref--7-oneOf-1-0!../node_modules/css-loader/dist/cjs.js??ref--7-oneOf-1-1!../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../node_modules/postcss-loader/src/index.js??ref--7-oneOf-1-2!../node_modules/cache-loader/dist/cjs.js??ref--1-0!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=style&index=0&lang=css&"

会经过如下loader的处理

  • mini-css-extract-plugin
  • css-loader
  • stylePostLoader
  • postcss-loader
  • vue-loader

阶段三

回调最终的代码

// vue-loader
if (incomingQuery.type) {
    return selectBlock(
      descriptor,
      loaderContext,
      incomingQuery,
      !!options.appendExtension
    )
}