WJCHumble / Blog

分享编程和生活(Sharing programming and life)
40 stars 1 forks source link

如何实现一个 esbuild 插件?从入门到上手 #26

Open WJCHumble opened 3 years ago

WJCHumble commented 3 years ago

前言

随着 Vite 2.0 的发布,其底层的设计也不断地被大家所认知。其中,大家十分津津乐道的就是采用 esbuild 来做 Dev 环境下的代码转换(快到飞起 😲)。

与此同时,这也给 esbuild 带来了很多曝光。并且,esbuild 生态也陆续出现了一些新的插件(Plugin),例如 esbuild-plugin-aliasesbuild-plugin-webpack-bridge 等。

那么,回到正题,今天我将和大家一起从 esbuild 插件基础知识出发,手把手教学如何实现一个 esbuild 插件 🚀!

1 认识 esbuild 插件基础

在 esbuild 中,插件被设计为一个函数,该函数需要返回一个对象(Object),对象中包含 namesetup 等 2 个属性:

const myPlugin = options => {
  return {
    name: "my",
    setup(build) {
      // ....
    }
  }
}

其中,name 的值是一个字符串,它表示你的插件名称 。 setup 的值是一个函数,它会被传入一个参数 build(对象)。

build 对象上会暴露整个构建过程中非常重要的 2 个函数:onResolveonLoad,它们都需要传入 Options(选项)和 CallBack(回调)等 2 个参数。

其中,Options 是一个对象,它包含 filter(必须)和 namespace 等 2 个属性:

interface OnResolveOptions {
  filter: RegExp;
  namespace?: string;
}

而 CallBack 是一个函数,即回调函数。插件实现的关键则是在 onResolveonLoad 中定义的回调函数内部做一些特殊的处理。

那么,接下来我们先来认识一下 Options 的 2 个属性:namespacefilter(划重点,它们非常重要 😲)

1.1 namespace

默认情况下,esbuild 是在文件系统上的文件(File Modules)相对应的 namespace 中运行的,即此时 namespace 的值为 file

esbuild 的插件可以创建 Virtual Modules,而 Virtual Modules 则会使用 namespace 来和 File Modules 做区分。

注意,每个 namespace 都是特定于该插件的。

并且,这个时候,我想可能有同学会问:什么是 Virtual Modules 😲?

简单地理解,Virtual Modules 是指在文件系统中不存在的模块,往往需要我们构造出 Virtual Modules 对应的模块内容。

1.2 filter

filter 作为 Options 上必须的属性,它的值是一个正则。它主要用于匹配指定规则的导入(import)路径的模块,避免执行不需要的回调,从而提高整体打包性能。

那么,在认识完 namespacefilter 后。下面我们来分别认识一下 onResolveonLoad 中的回调函数。

1.3 onResolve 的回调函数

onResolve 函数的回调函数会在 esbuild 构建每个模块的导入路径(可匹配的)时执行。

onResolve 函数的回调函数需要返回一个对象,其中会包含 pathnamespaceexternal 等属性。

通常,该回调函数会用于自定义 esbuild 处理 path 的方式,例如:

1.4 onLoad 的回调函数

onLoad 函数的回调函数会在 esbuild 解析模块之前调用,主要是用于处理并返回模块的内容,并告知 esbuild 要如何解析它们。并且,需要注意的是 onLoad 的回调函数不会处理被标记为 external 的模块。

onLoad 函数的回调函数需要返回一个对象,该对象总共有 9 个属性。这里我们来认识一下较为常见 3 个属性:

那么,到这里我们就已经简单认识完有关 esbuild 插件的基础知识了 😎。 所以,下面我们从实际应用场景出发,动手实现一个 esbuild 插件。

2 动手实现一个 esbuild 插件

这里我们来实现一个删除代码中 console 语句的 esbuild 插件。因为,这个过程需要识别和删除 console 对应的 AST 节点。所以,需要使用 babel 提供的 3 个工具包:

那么,首先是创建整个插件的整体结构,如插件名称、setup 函数:

module.exports = options => {
  return {
    name: "auto-delete-console",
    setup(build) {
    }
  }
}

其次,由于我们这个插件主要是对代码内容进行操作。所以,需要使用 onLoad 函数,并且要声明 filter/\.js$/,即只匹配 JavaScript 文件:

module.exports = options => {
  return {
    name: "auto-delete-console",
    setup(build) {
      build.onLoad({ filter: /\.js$/ }, (args) => {
      }
    }
  }
}

而在 onLoad 函数的回调函数中,我们需要做这 4 件事:

1.获取文件内容

onLoad 函数的回调函数会传入一个参数 args,它会包含此时模块的文件路径,即 args.path

所以,这里我们使用 fs.promises.readFile 函数来读取该模块的内容:

build.onLoad({ filter: /\.js$/ }, async (args) => {
  const source = await fs.promises.readFile(args.path, "utf8")
}

2.转化代码生成 AST

因为,之后我们需要找到并删除 console 对应的 AST 节点。所以,需要使用 @babel/parserparse 函数将模块的内容(代码)转为 AST:

build.onLoad({ filter: /\.js$/ }, async (args) => {
  const ast = parser.parse(source)
}

3.遍历 AST 节点,删除 console 对应的 AST 节点

接着,我们需要使用 @babel/traverse 来遍历 AST 来找到 console 的 AST 节点。但是,需要注意的是我们并不能直接就可以找到 console 的 AST 节点。因为,console 属于普通的函数调用,并没有像 await 一样有特殊的 AST 节点类型(AwaitExpression)。

不过,我们可以先使用 CallExpression 来直接访问函数调用的 AST 节点。然后,判断 AST 节点的 callee.object.name 是否等于 console,是则调用 path.remove 函数删除该 AST 节点:

build.onLoad({ filter: /\.js$/ }, async (args) => {
  traverse(ast, {
    CallExpression(path) {
      //...
      const memberExpression = path.node.callee
      if (memberExpression.object && memberExpression.object.name === 'console') {
        path.remove()
      }
    }
  })
}

4.转化 AST 生成代码

最后,我们需要使用 @babel/coretransformFromAst 函数将处理过的 AST 转为代码并返回:

build.onLoad({ filter: /\.js$/ }, async (args) => {
  //...
  const { code } = core.transformFromAst(ast)
  return {
    contents: code,
    loader: "js"
  }
}

那么,到这里我们就完成了一个删除代码中 console 语句的 esbuild 插件,用一句话概括这个过程:“没有比这更简单的了 😃”。

整个插件实现的全部代码如下:

const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const core = require("@babel/core")
const esbuild = require("esbuild")
const fs = require("fs")

module.exports = options => {
  return {
    name: "auto-delete-console",
    setup(build) {
      build.onLoad({ filter: /\.js$/ }, async (args) => {
        const source = await fs.promises.readFile(args.path, "utf8")
        const ast = parser.parse(source)

        traverse(ast, {
          CallExpression(path) {
            const memberExpression = path.node.callee
            if (memberExpression.object && memberExpression.object.name === 'console') {
              path.remove()
            }
          }
        })

        const { code } = core.transformFromAst(ast)
        return {
          contents: code,
          loader: "js"
        }
      }
    }
  }
}

结语

总体上来说,esbuild 的插件设计是十分简约和强大的。这一点,如果写过 webpack 插件的同学对比一下,我想应该深有体会 😶。

并且,通过阅读本文,相信大家都可以随手甩出一个 esbuild 的插件,很可能将来官方提供的插件列表就有你实现的插件 😎。