likes
comments
collection
share

给我们的mini-webpack简单加个loader吧

作者站长头像
站长
· 阅读数 15

那么添加 loader 功能的实现思路就很简单了:就是在我们加载文件之后,先别急着解析,看有没有匹配的 loader,如果有就调一下 loader 去解析,否则再去执行我们原来的默认的 js 文件解析逻辑。

为什么需要 loader

我们知道其实 webpack 最基础的功能就是打包,而这个功能其实就是将多个资源文件整合成一个文件(一个捆 bundle 资源)。那么要实现从多个到一个必须要解决两个核心的问题:

  1. 确定依赖关系
  2. 解决合并冲突

对于 js 文件,这两个问题其实还好解决。我们可以将 js 文件解析成 ast,然后通过 ast 分析依赖关系,通过函数包裹的方式解决合并冲突。那对于其他类型的文件呢?

比如 txt,其实这个类型特别好处理:只需要全部读出来,然后加个换行符拼起来即可。再比如 css 文件,那我们可能就需要去分析 @import 了,至于合并冲突:由于 css 文件没有模块化,所以可以直接合并。

总的来说:不同的文件类型意味着文件信息的组织方式不一样,这也意味着我们需要对不同类型的文件进行不同的处理。那么文件类型能不能穷举呢?其实理论上也可以,但是比较困难。而与穷举相对的一种解决方案,那就是暴露接口(由系统外部去实现,再将结果通过接口回传给系统)。那么 webpack 就是通过这种方式来解决不同文件类型的处理问题,也就是 loader。

loader 编写

接下来我们就根据 loader 的特点和功能,一步一步地编写我们的 loader。

修改 Compiler,支持 loader 解析文件

根据前面的思路,我们先调整一下 Compiler 的结构:分析文件依赖之前,先看看有没有匹配的 loader,有的话就交给 loader 去处理。代码如下:

class Compiler {
  // 其他代码省略
  async build(relativePath) {
    const { parser, loader } = this
    // 1、加载(fs读取文件)
    parser.load(relativePath)
    let code = ''
    let deps = new Set()
    // 交给loader处理
    const isMatch = loader.match(parser.absolutePath)
    if (isMatch) {
      // 使用loader处理
      code = await loader.process(parser.file)
    } else {
      // 默认处理
      // 2、解析(将文件解析成ast)
      parser.parser()
      // 3、解析并转化导入导出语句(import、require变成__webpack_require__) => deps、code
      const { deps: _deps, code: _code } = parser.transform()
      deps = _deps
      code = _code
    }
    // 加入缓存
    const module = { deps, code }
    this.modulesCacheMap[relativePath] = code

    return module
  }
}

值得注意的是:由于我们的 loader 有同步的和异步的,所以我们需要将整个打包链路修改成异步链路

loader 配置方式

首先我们先关注的是 loader 的配置方式,我们知道如果是单个 loader 就配置一个对象,如果多个就配置 use 数组。

// 单个 loader
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'loader1',
        options: { a: 1 }
      },
    ],
  },
}
// use数组
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['loader1']
      },
    ],
  },
}
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [{ loader: 'loader1', options: { a: 1 } }]
      },
    ],
  },
}

为了方便用户使用,我们系统会提供多种使用方式给用户使用;为了方便程序使用,我们需要将多种方式转化成一种完整的数据格式。也就是需要对配置数据进行数据格式整理,转化成统一的、方便程序处理的格式。代码如下:

// 参数归一化(将用户方便转化成程序方便)
// 简化配置:方便用户使用
// 复杂配置:方便程序处理
const parserRules = (rule) => {
  if (rule.loader) {
    delete rule.use // 在 webpack 中 loader 和 use 这两个字段同时存在会报错,我们这里做一个优先级处理
    return { ...rule, use: [rule] }
  }
  if (isArray(rule.use)) {
    return {
      ...rule,
      use: rule.use.map((item) => (isString(item) ? { loader: item } : item)),
    }
  }
  return rule
}

loader 执行顺序

根据 loader 的执行顺序,我们可以将 loader 划分成四类:pre、normal、inline、post。且执行顺序的优先级为:post > normal > pre (inline loader 我们先不考虑)。值得注意的是:优先级越高,pitch 越先执行,loader 越后执行

同种类 loader 的执行顺序为:逆序执行无论是顶层配置(多个匹配条件),还是内层配置(同一个匹配条件下的多个 loader)。

prepost 是通过 enforce 字段进行配置,inline 是通过路径配置 loader(不推荐使用),例如:const data = require('raw-loader?raw=true!./data.txt');

所以我们key对 loader 根据配置提前排序,代码如下:

const sortRules = (rules = []) => {
  const postArr = []
  const preArr = []
  const normalArr = []
  rules.forEach((rule) => {
    if (rule.enforce === 'post') {
      postArr.push(rule)
    } else if (rule.enforce === 'pre') {
      preArr.push(rule)
    } else {
      normalArr.push(rule)
    }
  })
  return [...postArr, ...normalArr, ...preArr]
}

熔断机制

根据前面的 loader 执行顺序以及接下来的熔断机制,我们就可以来编写我们的 loader 执行链路了。loader 的执行链路是这样的:先顺序执行所有 loader 的 pitch 方法,再逆序执行 loader。

当 pitch 方法存在返回结果时,中断 pitch 遍历,直接返回到上一个 loader 执行逆序遍历。如下图:

给我们的mini-webpack简单加个loader吧

值得一提的是:pitch 用于控制熔断,所以 pitch 接收参数是需要处理的文件以及应用的 loader(控制信息);而 loader 用于流水线式解析处理文件,所以 loader 接收的是上一个 loader 处理的结果(结果信息)。

根据 loader 执行顺序与熔断机制,编写代码如下:

process(code) {
  const { matchRules } = this
  const rules = matchRules
    .map((rule) => rule.use)
    .flat()
    .map((v) => ({ ...v, loader: require(v.loader) }))

  // 先顺序调用 pitch
  let startIndex = rules.length
  for (let i = 0; i < rules.length; i++) {
    const { loader, options } = rules[i]
    if (!isFunction(loader.pitch)) {
      continue
    }

    const deps = []
    const result = loader.pitch(...deps, options)

    if (result) {
      startIndex = i
      code = result
      break
    }
  }
  // 再逆序执行loader
  for (let i = startIndex - 1; i >= 0; i--) {
    const { loader, options } = rules[i]
    code = loader(code, options)
  }

  return code
}

同步 loader 与 异步 loader

对于同步 loader,有两种方式返回处理结果:returnthis.callback,如下:

module.exports = function (source) {
  return 'loader'
}
module.exports = function (source) {
  this.callback(null, 'loader')
}

我们知道对于 js 语言来说,其实异步结果的返回就两种解决方案:回调 和 promise。所以 webpack 也采用这两种方式来获取异步 loader 的处理结果。

  • 回调方式:
module.exports = function (source) {
  const callback = this.async()
  setTimeout(() => {
    callback(null, 'loader')
  }, 1000)
}
  • promise 方式:
module.exports = function (source) {
  return new Promise((r) => {
    setTimeout(() => {
      r('loader')
    }, 1000)
  })
}

我们为了简单处理,可以全部统一成异步的方式,代码如下:

class Loader {
  // 其他代码省略
  await() {
    return new Promise((resolve, reject) => {
      this.promiseResolve = resolve
      this.promiseReject = reject
    })
  }

  callback(err, ...args) {
    if (err) {
      this.promiseReject(err)
    }
    this.promiseResolve(...args)
  }

  async() {
    return this.callback
  }

  async process(code) {
    const { matchRules } = this
    const rules = matchRules
      .map((rule) => rule.use)
      .flat()
      .map((v) => ({ ...v, loader: require(v.loader) }))

    // 先顺序调用 pitch
    let startIndex = rules.length
    for (let i = 0; i < rules.length; i++) {
      const { loader, options } = rules[i]
      if (!isFunction(loader.pitch)) {
        continue
      }

      const deps = []
      this.query = options
      const result = await Promise.race([
        this.await(),
        loader.pitch.call(this, ...deps),
      ])

      if (result) {
        startIndex = i
        code = result
        break
      }
    }
    // 再逆序执行loader
    for (let i = startIndex - 1; i >= 0; i--) {
      const { loader, options } = rules[i]
      this.query = options
      code = await Promise.race([this.await(), loader.call(this, code)])
    }

    return code
  }
}

raw 配置

我们知道,如果 loader 配置了 raw 属性为 true 的话,webpack 会将 buffer 数据交给我们的 loader。所以我们需要在调用loader之前判断一下这个字段,代码如下:

for (let i = startIndex - 1; i >= 0; i--) {
  const { loader, options } = rules[i]
  if (loader.raw) {
    res = Buffer.from(res)
  } else {
    res = res.toString('utf8')
  }
  res = await Promise.race([this.await(), loader.call(this, res, options)])
}

return res

附录

const { isString, isArray, isObject, isFunction } = require('./utils')

// 参数归一化(将用户方便转化成程序方便)
// 简化配置:方便用户使用
// 复杂配置:方便程序处理
const parserRules = (rule) => {
  if (rule.loader) {
    delete rule.use // 在 webpack 中 loader 和 use 这两个字段同时存在会报错,我们这里做一个优先级处理
    return { ...rule, use: [rule] }
  }
  if (isArray(rule.use)) {
    return {
      ...rule,
      use: rule.use.map((item) => (isString(item) ? { loader: item } : item)),
    }
  }
  return rule
}

const sortRules = (rules = []) => {
  const postArr = []
  const preArr = []
  const normalArr = []
  rules.forEach((rule) => {
    if (rule.enforce === 'post') {
      postArr.push(rule)
    } else if (rule.enforce === 'pre') {
      preArr.push(rule)
    } else {
      normalArr.push(rule)
    }
  })
  return [...postArr, ...normalArr, ...preArr]
}

class Loader {
  constructor(rules = []) {
    this.rules = sortRules(rules.map(parserRules))
    this.matchRules = []
    this.promiseResolve = null
    this.promiseReject = null
  }

  match(filePath) {
    this.matchRules = this.rules.filter((rule) => rule.test.test(filePath))
    return this.matchRules.length > 0
  }

  await() {
    return new Promise((resolve, reject) => {
      this.promiseResolve = resolve
      this.promiseReject = reject
    })
  }

  callback(err, ...args) {
    if (err) {
      this.promiseReject(err)
    }
    this.promiseResolve(...args)
  }

  async() {
    return this.callback
  }

  async process(buffer) {
    const { matchRules } = this
    const rules = matchRules
      .map((rule) => rule.use)
      .flat()
      .map((v) => ({ ...v, loader: require(v.loader) }))
    let res = buffer

    // 先顺序调用 pitch
    let startIndex = rules.length
    for (let i = 0; i < rules.length; i++) {
      const { loader, options } = rules[i]
      if (!isFunction(loader.pitch)) {
        continue
      }

      const deps = []
      const result = await Promise.race([
        this.await(),
        loader.pitch.call(this, ...deps, options),
      ])

      if (result) {
        startIndex = i
        res = result
        break
      }
    }
    // 再逆序执行loader
    for (let i = startIndex - 1; i >= 0; i--) {
      const { loader, options } = rules[i]
      if (loader.raw) {
        res = Buffer.from(res)
      } else {
        res = res.toString('utf8')
      }
      res = await Promise.race([this.await(), loader.call(this, res, options)])
    }

    return res
  }
}

module.exports = Loader