转译器加载器


可以使用 load 钩子将 Node.js 无法理解的格式的源代码转换为 JavaScript。 但是,在调用该钩子之前,resolve 钩子需要告诉 Node.js 不要在未知文件类型上抛出错误。

这比在运行 Node.js 之前转译源文件的性能要低;转译加载器应该只用于开发和测试目的。

// coffeescript-loader.mjs
import { readFile } from 'node:fs/promises';
import { dirname, extname, resolve as resolvePath } from 'node:path';
import { cwd } from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import CoffeeScript from 'coffeescript';

const baseURL = pathToFileURL(`${cwd()}/`).href;

// CoffeeScript 文件以 .coffee、.litcoffee、或 .coffee.md 结尾。
const extensionsRegex = /\.coffee$|\.litcoffee$|\.coffee\.md$/;

export async function resolve(specifier, context, nextResolve) {
  if (extensionsRegex.test(specifier)) {
    const { parentURL = baseURL } = context;

    // Node.js 通常在未知文件扩展名上出错,
    // 因此返回以 CoffeeScript 文件扩展名结尾的说明符的 URL。
    return {
      shortCircuit: true,
      url: new URL(specifier, parentURL).href,
    };
  }

  // 让 Node.js 处理所有其他说明符。
  return nextResolve(specifier);
}

export async function load(url, context, nextLoad) {
  if (extensionsRegex.test(url)) {
    // 现在我们修补了解析让 CoffeeScript URL 通过,
    // 我们需要告诉 Node.js 这些 URL 应该被解释为什么格式。因为
    // 因为 CoffeeScript 会转译成 JavaScript,
    // 所以它应该是两种 JavaScript 格式之一:'commonjs' 或 'module'。

    // CoffeeScript 文件可以是 CommonJS 或 ES 模块,
    // 因此我们希望 Node.js 将任何 CoffeeScript 文件视为相同位置的 .js 文件。 
    // 要确定 Node.js 如何解释任意 .js 文件,
    // 则在文件系统中搜索最近的父 package.json 文件 
    // 并读取其 "type" 字段。
    const format = await getPackageType(url);
    // 当钩子返回 'commonjs' 格式时,则 `source` 将被忽略。
    // 为了处理 CommonJS 文件,需要使用 `require.extensions` 注册句柄,
    // 以便使用 CommonJS 加载器处理文件。
    // 避免需要单独的 CommonJS 处理程序 
    // 是 ES 模块加载器计划的未来增强功能。
    if (format === 'commonjs') {
      return {
        format,
        shortCircuit: true,
      };
    }

    const { source: rawSource } = await nextLoad(url, { ...context, format });
    // 此钩子将所有导入的 CoffeeScript 文件的 CoffeeScript 源代码 
    // 转换为的 JavaScript 源代码。
    const transformedSource = coffeeCompile(rawSource.toString(), url);

    return {
      format,
      shortCircuit: true,
      source: transformedSource,
    };
  }

  // 让 Node.js 处理所有其他 URL。
  return nextLoad(url);
}

async function getPackageType(url) {
  // `url` is only a file path during the first iteration when passed the
  // resolved url from the load() hook
  // an actual file path from load() will contain a file extension as it's
  // required by the spec
  // this simple truthy check for whether `url` contains a file extension will
  // work for most projects but does not cover some edge-cases (such as
  // extensionless files or a url ending in a trailing space)
  const isFilePath = !!extname(url);
  // 如果是文件路径,则获取它所在的目录
  const dir = isFilePath ?
    dirname(fileURLToPath(url)) :
    url;
  // 生成同一个目录下的 package.json 的文件路径,
  // 文件可能存在也可能不存在
  const packagePath = resolvePath(dir, 'package.json');
  // 尝试读取可能不存在的 package.json
  const type = await readFile(packagePath, { encoding: 'utf8' })
    .then((filestring) => JSON.parse(filestring).type)
    .catch((err) => {
      if (err?.code !== 'ENOENT') console.error(err);
    });
  // 如果 package.json 存在并包含带有值的 `type` 字段
  if (type) return type;
  // 否则,(如果不在根目录下)继续检查下一个目录
  // 如果在根目录,则停止并返回 false
  return dir.length > 1 && getPackageType(resolvePath(dir, '..'));
}
# main.coffee
import { scream } from './scream.coffee'
console.log scream 'hello, world'

import { version } from 'node:process'
console.log "Brought to you by Node.js version #{version}"
# scream.coffee
export scream = (str) -> str.toUpperCase()

使用前面的加载器,运行 node --experimental-loader ./coffeescript-loader.mjs main.coffee 会导致 main.coffee 在其源代码从磁盘加载之后但在 Node.js 执行之前转换为 JavaScript;对于通过任何加载文件的 import 语句引用的任何 .coffee.litcoffee.coffee.md 文件,依此类推。

Sources that are in formats Node.js doesn't understand can be converted into JavaScript using the load hook. Before that hook gets called, however, a resolve hook needs to tell Node.js not to throw an error on unknown file types.

This is less performant than transpiling source files before running Node.js; a transpiler loader should only be used for development and testing purposes.

// coffeescript-loader.mjs
import { readFile } from 'node:fs/promises';
import { dirname, extname, resolve as resolvePath } from 'node:path';
import { cwd } from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import CoffeeScript from 'coffeescript';

const baseURL = pathToFileURL(`${cwd()}/`).href;

// CoffeeScript files end in .coffee, .litcoffee, or .coffee.md.
const extensionsRegex = /\.coffee$|\.litcoffee$|\.coffee\.md$/;

export async function resolve(specifier, context, nextResolve) {
  if (extensionsRegex.test(specifier)) {
    const { parentURL = baseURL } = context;

    // Node.js normally errors on unknown file extensions, so return a URL for
    // specifiers ending in the CoffeeScript file extensions.
    return {
      shortCircuit: true,
      url: new URL(specifier, parentURL).href,
    };
  }

  // Let Node.js handle all other specifiers.
  return nextResolve(specifier);
}

export async function load(url, context, nextLoad) {
  if (extensionsRegex.test(url)) {
    // Now that we patched resolve to let CoffeeScript URLs through, we need to
    // tell Node.js what format such URLs should be interpreted as. Because
    // CoffeeScript transpiles into JavaScript, it should be one of the two
    // JavaScript formats: 'commonjs' or 'module'.

    // CoffeeScript files can be either CommonJS or ES modules, so we want any
    // CoffeeScript file to be treated by Node.js the same as a .js file at the
    // same location. To determine how Node.js would interpret an arbitrary .js
    // file, search up the file system for the nearest parent package.json file
    // and read its "type" field.
    const format = await getPackageType(url);
    // When a hook returns a format of 'commonjs', `source` is be ignored.
    // To handle CommonJS files, a handler needs to be registered with
    // `require.extensions` in order to process the files with the CommonJS
    // loader. Avoiding the need for a separate CommonJS handler is a future
    // enhancement planned for ES module loaders.
    if (format === 'commonjs') {
      return {
        format,
        shortCircuit: true,
      };
    }

    const { source: rawSource } = await nextLoad(url, { ...context, format });
    // This hook converts CoffeeScript source code into JavaScript source code
    // for all imported CoffeeScript files.
    const transformedSource = coffeeCompile(rawSource.toString(), url);

    return {
      format,
      shortCircuit: true,
      source: transformedSource,
    };
  }

  // Let Node.js handle all other URLs.
  return nextLoad(url);
}

async function getPackageType(url) {
  // `url` is only a file path during the first iteration when passed the
  // resolved url from the load() hook
  // an actual file path from load() will contain a file extension as it's
  // required by the spec
  // this simple truthy check for whether `url` contains a file extension will
  // work for most projects but does not cover some edge-cases (such as
  // extensionless files or a url ending in a trailing space)
  const isFilePath = !!extname(url);
  // If it is a file path, get the directory it's in
  const dir = isFilePath ?
    dirname(fileURLToPath(url)) :
    url;
  // Compose a file path to a package.json in the same directory,
  // which may or may not exist
  const packagePath = resolvePath(dir, 'package.json');
  // Try to read the possibly nonexistent package.json
  const type = await readFile(packagePath, { encoding: 'utf8' })
    .then((filestring) => JSON.parse(filestring).type)
    .catch((err) => {
      if (err?.code !== 'ENOENT') console.error(err);
    });
  // Ff package.json existed and contained a `type` field with a value, voila
  if (type) return type;
  // Otherwise, (if not at the root) continue checking the next directory up
  // If at the root, stop and return false
  return dir.length > 1 && getPackageType(resolvePath(dir, '..'));
}
# main.coffee
import { scream } from './scream.coffee'
console.log scream 'hello, world'

import { version } from 'node:process'
console.log "Brought to you by Node.js version #{version}"
# scream.coffee
export scream = (str) -> str.toUpperCase()

With the preceding loader, running node --experimental-loader ./coffeescript-loader.mjs main.coffee causes main.coffee to be turned into JavaScript after its source code is loaded from disk but before Node.js executes it; and so on for any .coffee, .litcoffee or .coffee.md files referenced via import statements of any loaded file.