又一個基於 Esbuild 的神器!esno

Node.js 並不支持直接執行 TS 文件,如果要執行 TS 文件的話,我們就可以藉助 ts-node 這個庫。相信有些小夥伴在工作中也用過這個庫,關於 ts-node 這個庫的相關內容我就不展開介紹了,因爲本文的主角是由 antfu 大佬開源的 esno 項目,接下來我將帶大家一起來揭開這個項目背後的祕密。

閱讀完本文後,你將瞭解 esno 項目是如何執行 TS 文件。此外,你還會了解如何劫持 Node.js 的 require 函數、如何爲 ES Module 的 import 語句添加鉤子及如何自定義 https 加載器,以支持 import React from "https://esm.sh/react" 導入方式。

esno 是什麼

esno 是基於 esbuild 的 TS/ESNext node 運行時。該庫會針對不同的模塊化標準,採用不同的方案:

使用 esno 的方式很簡單,你可以以全局或局部的方式來安裝它:

全局安裝

$ npm i -g esno

在安裝成功後,你就可以通過以下方式來直接執行 TS 文件:

$ esno index.ts
$ esmo index.ts

局部安裝

$ npm i esno

而對於局部安裝的方式來說,一般情況下,我們會以 npm scripts 的方式來使用它:

{
  "scripts"{
    "start""esno index.ts"
  },
  "dependencies"{
    "esno""0.14.0"
  }
}

esno 是如何工作的

在開始分析 esno 的工作原理之前,我們先來熟悉一下該項目:

├── LICENSE
├── README.md
├── esmo.mjs
├── esno.js
├── package.json
├── pnpm-lock.yaml
├── publish.ts
└── tsconfig.json

觀察以上的項目結構可知,該項目並不會複雜。在項目根目錄下的 package.json 文件中,我們看到了前面介紹的 esnoesmo 命令。

{
  "bin"{
    "esno""esno.js",
    "esmo""esmo.mjs"
  },
}

此外,在 package.jsonscripts 字段中,我們發現了 release 命令。顧名思義,該命令用來發布版本。

{
  "scripts"{
    "release""npx bumpp --tag --commit --push && node esmo.mjs publish.ts"
  },
}

需要注意的是,在 publish.ts 文件中,使用到了 2021 年度 Github 上最耀眼的項目 zx,利用該項目我們可以輕鬆地編寫命令行腳本。寫作本文時,它的 Star 數已經高達 27.5K,強烈推薦感興趣的小夥伴關注一下該項目。

簡單介紹了 esno 項目之後,接下來我們來分析 esno.js 文件:

#!/usr/bin/env node

const spawn = require('cross-spawn')
const spawnSync = spawn.sync

const register = require.resolve('esbuild-register')

const argv = process.argv.slice(2)

process.exit(spawnSync('node'['-r', register, ...argv], 
  { stdio: 'inherit' }).status)

由以上代碼可知,當執行 esno index.ts 命令後,會通過 spawnSync 來啓動 Node.js 程序執行腳本。需要注意的是,在執行時使用了 -r 選項,該選項的作用是預加載模塊:

 -r, --require = ... module to preload (option can be repeated)

這裏預加載的模塊是 esbuild-register,該模塊就是 esno 命令執行 TS 文件的幕後英雄。

esbuild-register 是什麼

esbuild-register 是一個基於 esbuild 來轉換 JSX、TS 和 esnext 特性的工具。你可以通過以下多種方式來安裝它:

$ npm i esbuild esbuild-register -D
# Or Yarn
$ yarn add esbuild esbuild-register --dev
# Or pnpm
$ pnpm add esbuild esbuild-register -D

在成功安裝該模塊之後,就可以在命令行中,直接通過 node 應用程序來執行 ts 文件:

$ node -r esbuild-register file.ts

 -r, --require  =  ... module to preload (option can be repeated)

-r 用於指定預加載的文件,即在執行 file.ts 文件前,提前加載 esbuild-register 模塊

它將會使用 tsconfig.json 中的 jsxFactory, jsxFragmentFactorytarget 配置項來執行轉換操作。

esbuild-register 不僅可以在命令行中使用,而且還可以通過 API 的方式進行使用:

const { register } = require('esbuild-register/dist/node')

const { unregister } = register({
  // ...options
})

// Unregister the require hook if you don't need it anymore
unregister()

瞭解完 esbuild-register 的基本使用之後,接下來我們來分析它內部是如何工作的。

esbuild-register 是如何工作的

esbuild-register 內部利用了 pirates 這個庫來劫持 Node.js 的 require 函數,從而讓你可以在命令行中,直接執行 ts 文件。下面我們來看一下 esbuild-register 模塊中定義的 register 函數:

// esbuild-register/src/node.ts
import { transformSync, TransformOptions } from 'esbuild'
import { addHook } from 'pirates'

export function register(esbuildOptions: RegisterOptions = {}) {
  const {
    extensions = DEFAULT_EXTENSIONS,
    hookIgnoreNodeModules = true,
    hookMatcher,
    ...overrides
  } = esbuildOptions

  // 利用 transformSync 
  const compile: COMPILE = function compile(code, filename, format) {
    const dir = dirname(filename)
    const options = getOptions(dir)
    format = format ?? inferPackageFormat(dir, filename)

    const {
      code: js,
      warnings,
      map: jsSourceMap,
    } = transformSync(code, {
      sourcefile: filename,
      sourcemap: 'both',
      loader: getLoader(filename),
      target: options.target,
      jsxFactory: options.jsxFactory,
      jsxFragment: options.jsxFragment,
      format,
      ...overrides,
    })
 // 省略部分代碼
  }

  const revert = addHook(compile, {
    exts: extensions,
    ignoreNodeModules: hookIgnoreNodeModules,
    matcher: hookMatcher,
  })

  return {
    unregister() {
      revert()
    },
  }
}

觀察以上的代碼可知,在 register 函數內部是利用 esbuild 模塊提供的 transformSync API 來實現 ts -> js 代碼的轉換。其實最關鍵的環節,還是通過調用 pirates 這個庫提供的 addHook 函數來註冊編譯 ts 文件的鉤子。那麼 addHook 函數內部到底做了哪些處理呢?下面我們來看一下它的實現:

// pirates-4.0.5/src/index.js
export function addHook(hook, opts = {}) {
  let reverted = false;
  const loaders = []; // 存放新的loader
  const oldLoaders = []; // 存放舊的loader
  let exts;

  const originalJSLoader = Module._extensions['.js']; // 原始的JS Loader 
  // 省略部分代碼
  exts.forEach((ext) ={
    // 獲取已註冊的loader,若未找到,則默認使用JS Loader
    const oldLoader = Module._extensions[ext] || originalJSLoader;
    oldLoaders[ext] = Module._extensions[ext];

    loaders[ext] = Module._extensions[ext] = function newLoader(
   mod, filename) {
      let compile;
      if (!reverted) {
        if (shouldCompile(filename, exts, matcher, ignoreNodeModules)) {
          compile = mod._compile;
          mod._compile = function _compile(code) {
            // 這裏需要恢復成原來的_compile函數,否則會出現死循環
            mod._compile = compile;
            // 在編譯前先執行用戶自定義的hook函數
            const newCode = hook(code, filename);
            if (typeof newCode !== 'string') {
              throw new Error(HOOK_RETURNED_NOTHING_ERROR_MESSAGE);
            }

            return mod._compile(newCode, filename);
          };
        }
      }

       oldLoader(mod, filename);
     };
  });
}

其實 addHook 函數的實現並不會複雜,該函數內部就是通過替換 mod._compile 方法來實現鉤子的功能。即在調用原始的 mod._compile 方法進行編譯前,會先調用 hook(code, filename) 函數來執行用戶自定義的 hook 函數,從而對代碼進行預處理。

而對於 esbuild-register 庫中的 register 函數來說,當 hook 函數執行時,就會調用該函數內部定義的 compile 函數來編譯 ts 代碼,然後再調用mod._compile 方法編譯生成的 js 代碼。

關於 esbuild-register 和 pirates 這兩個庫的內容就先介紹到這裏,如果你想詳細瞭解 pirates 這個庫是如何工作的,可以閱讀 如何爲 Node.js 的 require 函數添加鉤子? 這篇文章。

現在我們已經分析完 esno.js 文件,接下來我們來分析 esmo.mjs 文件。

esmo 是如何工作的

esmo 命令對應的是 esmo.mjs 文件:

#!/usr/bin/env node

import spawn from 'cross-spawn'
import { resolve } from 'import-meta-resolve'
const spawnSync = spawn.sync

const argv = process.argv.slice(2)
resolve('esbuild-node-loader', import.meta.url).then((path) ={
  process.exit(spawnSync('node'['--loader', path, ...argv], 
 { stdio: 'inherit' }).status)
})

由以上代碼可知,當使用 node 應用程序執行 ES Module 文件時,會通過 --loader 選項來指定自定義的 ES Module 加載器。

 --loader, --experimental-loader = ... use the specified module as a custom loader

需要注意的是,通過 --loader 選項指定的自定義加載器只適用於 ES Module 的 import 調用,並不適用於 CommonJS 的 require 調用。

那麼自定義加載器有什麼作用呢?在當前最新的 Node.js v17.4.0 版本中,還不支持以 https:// 開頭的說明符。我們可以在自定義加載器中,利用 Node.js 提供的鉤子機制,讓 Node.js 可以使用 import 導入以 https:// 協議開頭的 ES 模塊。

在分析如何自定義 https 資源加載器前,我們需要先介紹一下 import 說明符的概念。

import 說明符

import 語句的說明符是 from 關鍵字之後的字符串,例如 import { sep } from 'path' 中的 'path'。說明符也用於 export from 語句,並作爲 import() 表達式的參數。

有三種類型的說明符:

裸說明符解析由 Node.js 模塊解析算法處理,所有其他說明符解析始終僅使用標準的相對 URL 解析語義進行解析。

和 CommonJS 一樣,包內的模塊文件可以通過在包名上添加路徑來訪問,除非包的 package.json 包含一個 "exports" 字段,在這種情況下,包中的文件只能通過 "exports" 中定義的路徑訪問。

介紹完 import 說明符之後,接下來我們來看一下如何自定義 https 加載器。

自定義 https 加載器

resolve 鉤子

resolve 鉤子用於根據模塊的說明符和 parentURL 生成導入目標的絕對路徑,調用該鉤子後會返回一個包含 format(可選) 和 url 屬性的對象。

// https-loader.mjs
import { get } from 'https';

export function resolve(specifier, context, defaultResolve) {
  const { parentURL = null } = context;

  if (specifier.startsWith('https://')) {
    return {
      url: specifier
    };
  } else if (parentURL && parentURL.startsWith('https://')) {
    return {
      url: new URL(specifier, parentURL).href
    };
  }

  // 讓 Node.js 處理其它的說明符
  return defaultResolve(specifier, context, defaultResolve);
}

在以上代碼中,會先判斷 specifier 字符串是否以 'https://' 開頭,如果條件滿足的話,該字符串的值直接作爲 url 屬性的值,直接返回 { url: specifier } 對象。否則,會判斷 parentURL 是否以 'https://' 開頭,如果條件滿足的話,則會調用 URL 構造函數,創建 URL 對象。

parentURL 是從 context 對象上獲取的,那它什麼時候會有值呢?假設在 ES 模塊 A 中,以相對路徑的形式導入 ES 模塊 B。在導入 ES 模塊 B 時,也會調用 resolve 鉤子,此時 context 對象上的 parentURL 就會有值。

load 鉤子

load 鉤子用於定義應該如何解釋、檢索和解析 URL 的方法,調用該方法後,會返回包含 formatsource 屬性的對象。其中 format 屬性值只能是 'builtin''commonjs''json''module''wasm' 中的一種。而 source 屬性值的類型可以爲 stringArrayBufferTypedArray

import { get } from 'https';

export function load(url, context, defaultLoad) {
  if (url.startsWith('https://')) {
    return new Promise((resolve, reject) ={
      get(url, (res) ={
        let data = '';
        res.on('data'(chunk) =data += chunk);
        res.on('end'() => resolve({
          format: 'module',
          source: data,
        }));
      }).on('error'(err) => reject(err));
    });
  }

  // 讓 Node.js 加載其它類型的文件
  return defaultLoad(url, context, defaultLoad);
}

在以上代碼中,會通過 https 模塊中的 get 函數來加載 https:// 協議的 ES 模塊。如果不是以 'https://' 開頭,則會使用默認的加載器來加載其它類型的文件。

創建完 https-loader 之後,我們來測試一下該加載器。首先創建一個 main.mjs 文件並輸入以下內容:

// main.mjs
import React from "https://esm.sh/react@17.0.2"

console.dir(React);

然後在命令行輸入以下命令:

$ node --experimental-loader ./https-loader.mjs ./main.mjs

當以上命令成功運行之後,控制檯會輸出以下內容:

{
  Fragment: Symbol(react.fragment),
  StrictMode: Symbol(react.strict_mode),
  Profiler: Symbol(react.profiler),
  Suspense: Symbol(react.suspense),
  ...
}

瞭解完以上的內容後,我們回過頭來看一下 esmo.mjs 文件中所使用的 esbuild-node-loader 模塊。下面我們來簡單分析一下 load 鉤子:

// loader.mjs(esbuild-node-loader v0.6.4)
export function load(url, context, defaultLoad) {
  if (extensionsRegex.test(new URL(url).pathname)) {
    const { format } = context;

    let filename = url;
    if (!isWindows) filename = fileURLToPath(url);

    const rawSource = fs.readFileSync(new URL(url){ encoding: "utf8" });
    const { js } = esbuildTransformSync(rawSource, filename, url, format);

    return {
      format: "module",
      source: js,
    };
  }

  // Let Node.js handle all other format / sources.
  return defaultLoad(url, context, defaultLoad);
}

通過觀察以上代碼,我們可知 load 鉤子的核心處理流程,可以分爲兩個步驟:

而在 esbuildTransformSync 函數中,使用了 esbuild 模塊提供的 transformSync 函數來實現代碼的轉換。該函數的相關代碼如下所示:

// loader.mjs(esbuild-node-loader v0.6.4)
function esbuildTransformSync(rawSource, filename, url, format) {
  const {
    code: js,
    warnings,
    map: jsSourceMap,
  } = transformSync(rawSource.toString(){
    sourcefile: filename,
    sourcemap: "both",
    loader: new URL(url).pathname.match(extensionsRegex)[1],
    target: `node${process.versions.node}`, 
    format: format === "module" ? "esm" : "cjs",
  });

  // 省略部分代碼
  return { js, jsSourceMap };
}

關於 transformSync 函數的使用方式,我就不展開介紹了。感興趣的小夥伴可以自行閱讀一下 esbuild 官網上的相關文檔。

好的,esno 這個項目就介紹到這裏。如果你對 Node.js 平臺下的 requireimport hook 機制感興趣的話,可以詳細閱讀一下 pirates、esbuild-register 和 esbuild-node-loader 這幾個項目的源碼。若有遇到問題的話,可以跟阿寶哥交流喲。

參考資源

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/aVisSW3mk7AJxBfJWhUX0w