Esbuild使用和插件开发

3,384 阅读4分钟

Esbuild使用和插件开发

ESBUILD.png

一、esbuild介绍

esbuild: An extremely fast JavaScript bundler,一个速度极快的javascript打包神器,可以看官网的各个打包器之间的对比,可以说esbuild是横扫其他打包器,esbuild不同于其他打包器,他是基于go开发的打包神器,他充分利用了go多线程并且能尽可能的利用各个cpu的核,对于打包过程的解析,代码生成都是并行执行的

image.png

二、esbuild的主要特性

  • 没有缓存机制也有极快的打包速度
  • 支持es6和cjs模块
  • 支持es6 modules的tree-shaking
  • 支持ts和jsx
  • sourcemap
  • 压缩工具
  • 自定义的插件开发

三、esbuild使用

在node中使用esbuild你只需要安装正常安装就行了

yarn add esbuild

esbuild对于不同的平台有不同的二进制包下载,如果你仔细看的话会发现两个esbuild的包

image.png
其中下面的那个就是根据你的操作系统获取到的相应的esbuild二进制包了
使用命令来执行esbuild也很简单,可以在package.json下加入以下script就会对src/index.jsx文件进行打包,输出在dist/out.js

"build":./node_modules/.bin/esbuild src/index.jsx --bundle --outfile=dist/out.js

esbuild不光能打包web,也能够进行对node的打包,命令如下:

./node_modules/.bin/esbuild app.js --bundle --platform=node --target=node10.4 --outfile=dist/out.js

esbuild还自带esm的treeshaking 比如在app.js中写一下内容

import {foo} from './m'
foo()

m.js

export function foo(){
    console.log(a)
}
export function bar(){
    console.log('bar')
}

可以看到dist/out.js内容如下,bar已经是被删除了,因为bar没有直接或间接引用,esbuild会认为这是无用代码

// src/node/m.js
function foo() {
  console.log(a);
}

// src/node/index.js
foo();

esbuild不光支持命令的方式来执行,也可以通过function的方式来进行,代码如下

const { build, buildSync, serve } = require("esbuild");

async function runBuild() {
  // 异步方法,返回一个 Promise
  const result = await build({
    // ----  如下是一些常见的配置  --- 
    // 当前项目根目录
    absWorkingDir: process.cwd(),
    // 入口文件列表,为一个数组
    entryPoints: ["./src/index.jsx"],
    // 打包产物目录
    outdir: "dist",
    // 是否需要打包,一般设为 true
    bundle: true,
    // 模块格式,包括`esm`、`commonjs`和`iife`
    format: "esm",
    // 需要排除打包的依赖列表
    external: [],
    // 是否开启自动拆包
    splitting: true,
    // 是否生成 SourceMap 文件
    sourcemap: true,
    // 是否生成打包的元信息文件
    metafile: true,
    // 是否进行代码压缩
    minify: false,
    // 是否开启 watch 模式,在 watch 模式下代码变动则会触发重新打包
    watch: false,
    // 是否将产物写入磁盘
    write: true,
    // Esbuild 内置了一系列的 loader,包括 base64、binary、css、dataurl、file、js(x)、ts(x)、text、json
    // 针对一些特殊的文件,调用不同的 loader 进行加载
    loader: {
      '.png': 'base64',
    }
  });
  console.log(result);
}

runBuild();

只需要引入build方法,build方法是异步的,需要传入一个配置对象信息就行了,各种配置项可以查看上面代码,这些配置项,只需执行runbuild就可以达到和命令相同的效果啦,默认的打包输出文件在dist/index.js

四、esbuild插件开发

esbuild的插件开发是一个对象,该对象中有name即插件名称,还有个setup function,格式如下

let plugin={
    name:'my-plugin',
    setup(build){
        ......
    }
}

setup函数中有个build参数,build参数可以让我们达到esbuild打包过程中的hook,我们可以指定在不同的钩子中执行不同的方法。
开始esbuild插件开发之前需要先认识esbuild插件开发最主要的两个钩子,如果你熟悉其他打包工具比如webpack或者是rollup等,应该对这个概念不陌生。

1.onResolve

示例

 build.onResolve({ filter: /^env$/ }, args => {
      console.log(args)
        return{
      path: args.path,
      namespace: 'env-ns',//作为标识
    }})

onResolve钩子是一个路径解析的钩子,是esbuild去解析路径的时候触发的,也就是你执行import ... from ...的时候会调用onResolve钩子,onResolve钩子接收一个对象,对象的属性包含有filter和namespace,filter故名思义就是过滤,对于不符合要求的模块会直接跳过该钩子,namespace是可选参数,一般onResolve中会返回一个namespace,在其他钩子中使用这个namespace表示这个模块需要使用onResolve这个钩子产生的虚拟模块,虚拟模块的概念我们下面会讲到,onResolve第二个参数是个回调函数,他接收一个参数,这个参数里有啥内容大家可以自己打印下这边就直接贴出来了

// 模块路径 
console.log(args.path) 
// 父模块路径 
console.log(args.importer) 
// namespace 标识 
console.log(args.namespace) 
// 基准路径 
console.log(args.resolveDir) 
// 导入方式,如 import、require 
console.log(args.kind) 
// 额外绑定的插件数据 
console.log(args.pluginData)

我们也可以看到onResolve也返回了一个对象,对象中包含了path,和namespace,path就是当前模块的路径,namepace命名空间是让其他钩子通过这个命名空间将模块过滤出来,除了返回上面的两个参数外,onResolve返回中的对象还包括了很多种,如下

{   // 错误信息 
    errors: [], 
    // 是否需要 external 
    external: false; 
    // namespace 标识 
    namespace: 'env-ns'; 
    // 模块路径 
    path: args.path, 
    // 额外绑定的插件数据 
    pluginData: null, 
    // 插件名称 
    pluginName: 'xxx', 
    // 设置为 false,如果模块没有被用到,模块代码将会在产物中会删除。否则不会这么做 
    sideEffects: false, 
    // 添加一些路径后缀,如`?xxx` 
    suffix: '?xxx', 
    // 警告信息 
    warnings: [], 
    // 仅仅在 Esbuild 开启 watch 模式下生效 
    // 告诉 Esbuild 需要额外监听哪些文件/目录的变化 
    watchDirs: [], 
    watchFiles: [] 
    }

2.onLoad

上面的代码我们看到了另一个钩子onLoad,这个onLoad会在每个唯一的路径/命名空间且没有被标记为external的时候执行,他会返回当前模块的内容并且告诉esbuild你需要使用哪个loader去加载这块内容,如下就是对以txt结尾文件进行内容获取,并且返回json告诉esbuild需要使用json的loader来进行数据解析,下面的案例就类似于esbuild执行到这块内容的时候调用JSON.parse来进行解析

build.onLoad({ filter: /.txt$/ }, async (args) => {
      let text = await fs.promises.readFile(args.path, 'utf8')
      return {
        contents: JSON.stringify(text.split(/\s+/)),
        loader: 'json',
      }
    })

如何在onLoad中使用上面所说到的命名空间呢,可以看看如下代码 src/build.js

let myplugin={
    name:'my-plugin',
    setup(build){
        build.onResolve({filter:/^env$/},args=>({
            path:args.path,
            namespace:'env-ns'
        }))
        build.onLoad({filter:/*/,namespace:'env-ns'},args=>({
            contents:JSON.stringfy(process.env),
            loader:'json'
        }))
    }
}
require('esbuild').build({
  entryPoints: ['src/plugin.js'],
  bundle: true,
  outfile: 'out.js',
  // 应用插件
  plugins: [envPlugin],
}).catch(() => process.exit(1))

src/plugin.js

import {PATH} from 'env'
console.log(PATH)

我们来分析下上面的代码: 首先我们知道没有env这个模块,所以我们需要构造一个env模块,这里就需要我们的onResolve钩子,他过滤出这个路径,并返回了一个namespace,这个时候其实我们的env模块就有一个虚拟模块的概念,他在我们操作系统的文件中并不存在,只是临时存在内存中的,我们在通过onLoad的时候指定namespace其实就是去加载这个虚拟模块,我们可以指定虚拟模块的返回内容,这边我们返回JSON.stringfy(process.env),process.env里面具有PATH属性,esbuild会使用json加载器来进行解析,最后就可以在我们的env模块中使用PATH字段了
也就是说env这个模块是一个在esbuild打包过程中临时存在的,中间他们变成 import {PATH} from 'process.env' process.env中就有我们所需要的PATH模块
我们执行上面src/build.js代码可以看到我们的打包类似下面的内容

(() => {
  // env-ns:env
  var PATH = "/Users/xiaoming/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/xiaoming/bin:/Users/xiaoming/.cargo/bin:/opt/homebrew/bin:/opt/homebrew/bin";

  // src/plugin.js
  console.log(PATH);
})();

关于虚拟模块的概念其实也只是代表了我自己的理解,可能理解的也还不是很透彻,大家可以在esbuild的官网出看看官方是怎么解释这个概念的,这边就贴一张图吧

image.png

当然除了上述钩子外还有onStart,onEnd两个钩子,这两个钩子理解起来都比较简单,这边就不说废话了

五、cdn拉取依赖插件开发

这是一个官网demo,这边讲解下,我们需要打包的是以下内容,可以看到react和reactDom都是通过cdn的方式来进行引入的,我们实际打包的话就需要拉取cdn的内容在进行打包,我们就可以通过插件来帮助我们拉取三方依赖

//src/cnd.js
import { render } from "https://cdn.skypack.dev/react-dom";
import React from 'https://cdn.skypack.dev/react'

let Greet = () => <h1>Hello, juejin!</h1>;

render(<Greet />, document.getElementById("root"));

1、首先我们肯定是路径解析,我们需要找出https或者是http开头的模块就要用到onResolve啦 2、在通过请求的方式将内容请求回来,在onLoad的方式中将内容返回让esbuild知道这个模块的内容

build.onResolve({filter:/^https?:\/\//},args=>({
    path:args.path,
    namespace:'http-url'
}))

build.onLoad({filter:/.*/,namespace:'http-url'},(args)=>{
    const contents=http.get('.....')
    return {contents}
})

拉取内容的代码就不做过多阐述,大家应该都能看懂

let contents = await new Promise((resolve, reject) => {
        function fetch(url) {
          console.log(`Downloading: ${url}`);
          let lib = url.startsWith("https") ? https : http;
          let req = lib
            .get(url, (res) => {
              if ([301, 302, 307].includes(res.statusCode)) {
                // 重定向
                fetch(new URL(res.headers.location, url).toString());
                req.abort();
              } else if (res.statusCode === 200) {
                // 响应成功
                let chunks = [];
                res.on("data", (chunk) => chunks.push(chunk));
                res.on("end", () => resolve(Buffer.concat(chunks)));
              } else {
                reject(
                  new Error(`GET ${url} failed: status ${res.statusCode}`)
                );
              }
            })
            .on("error", reject);
        }
        // 根据返回的path字段来抓取文件
        fetch(args.path);
      });

完整的例子就如下面的代码

module.exports=()=>({
    name:'esbuild:http',
    setup(build){
        let https=require('https')
        let http=require('http')
        //拦截以https开头的请求依赖
         build.onResolve({ filter: /^https?:\/\// }, (args) => ({
                path: args.path,
                namespace: "http-url",
        }));
     
        //所有文件都使用http-url命名空间
      build.onLoad({ filter: /.*/, namespace: "http-url" }, async (args) => {
      let contents = await new Promise((resolve, reject) => {
        function fetch(url) {
          console.log(`Downloading: ${url}`);
          let lib = url.startsWith("https") ? https : http;
          let req = lib
            .get(url, (res) => {
              if ([301, 302, 307].includes(res.statusCode)) {
                // 重定向
                fetch(new URL(res.headers.location, url).toString());
                req.abort();
              } else if (res.statusCode === 200) {
                // 响应成功
                let chunks = [];
                res.on("data", (chunk) => chunks.push(chunk));
                res.on("end", () => resolve(Buffer.concat(chunks)));
              } else {
                reject(
                  new Error(`GET ${url} failed: status ${res.statusCode}`)
                );
              }
            })
            .on("error", reject);
        }
        // 根据返回的path字段来抓取文件
        fetch(args.path);
      });
      return { contents };
    });
    },
})

build.js

const { build } = require("esbuild");
const httpImport = require("./http-plugin");
async function runBuild() {
  build({
    absWorkingDir: process.cwd(),
    entryPoints: ["./src/cdn.jsx"],
    outdir: "dist",
    bundle: true,
    format: "esm",
    splitting: true,
    sourcemap: true,
    metafile: true,
    plugins: [httpImport()],
  }).then(() => {
    console.log("🚀 Build Finished!");
  });
}

runBuild();

执行build.js,我们会发现报错了,错误如下

image.png 其实查看错误信息就可以知道说有部分模块的内容无法resolve,如果我们仔细发现就会知道这些间接依赖写的是相对路径,并不是绝对路径,你也可以直接点进cdn链接里也能看到他并不是写的绝对路径,所以拉取不到,因此我们这边还要加一步就是将相对路径转变成绝对路径,怎么加呢,重写路径就行了

  build.onResolve({ filter: /.*/, namespace: "http-url" }, (args) => ({
  // 重写路径
    path: new URL(args.path, args.importer).toString(),
    namespace: "http-url",
    }));

所以plugin的完整代码就如下了

module.exports=()=>({
    name:'esbuild:http',
    setup(build){
        let https=require('https')
        let http=require('http')
        //拦截以https开头的请求依赖
         build.onResolve({ filter: /^https?:\/\// }, (args) => ({
                path: args.path,
                namespace: "http-url",
        }));
        build.onResolve({ filter: /.*/, namespace: "http-url" }, (args) => ({
  // 重写路径
    path: new URL(args.path, args.importer).toString(),
    namespace: "http-url",
    }));
        //所有文件都使用http-url命名空间
      build.onLoad({ filter: /.*/, namespace: "http-url" }, async (args) => {
      let contents = await new Promise((resolve, reject) => {
        function fetch(url) {
          console.log(`Downloading: ${url}`);
          let lib = url.startsWith("https") ? https : http;
          let req = lib
            .get(url, (res) => {
              if ([301, 302, 307].includes(res.statusCode)) {
                // 重定向
                fetch(new URL(res.headers.location, url).toString());
                req.abort();
              } else if (res.statusCode === 200) {
                // 响应成功
                let chunks = [];
                res.on("data", (chunk) => chunks.push(chunk));
                res.on("end", () => resolve(Buffer.concat(chunks)));
              } else {
                reject(
                  new Error(`GET ${url} failed: status ${res.statusCode}`)
                );
              }
            })
            .on("error", reject);
        }
        // 根据返回的path字段来抓取文件
        fetch(args.path);
      });
      return { contents };
    });
    },
})

参考文章:
esbuild官网
三元哥的小册yyds