在JavaScript中如何使用宏详解

作者:拾光

时间:2026-10-24

浏览:224

本文详解JavaScript中宏的使用机制,对比Rust与JS的宏支持差异,分析为何在Create-React-App等工具链中优先使用宏而非直接编写Babel插件。通过实战编写importURL宏,展示如何利用babel-plugin-macros、同步请求及AST转换实现编译期代码拉取与替换,涵盖从环境配置到代码生成的完整流程。

文章目录

在编程语言中,宏通常用于实现领域特定语言(DSL)。借助宏,开发者可以定义自定义的语言格式,例如实现类似 JSX 的语法。随着 WebAssembly(WASM)技术的成熟,使用其他语言编写网页已成为可能。以 Rust 语言为例,其强大的宏功能使得基于 Rust 的 Yew 框架无需依赖类似 Babel 的转换工具,仅凭语言本身的宏机制即可实现类 JSX 语法。以下是一个支持类 JSX 语法的 Yew 组件示例。


impl Component for MyComponent {
    // ...

    fn view(&self) -> Html {
        let onclick = self.link.callback(|_| Msg::Click);
        html! {
            <button onclick=onclick>{ self.props.button_text }</button>
        }
    }
}

JavaScript 宏的局限性

与 Rust 不同,JavaScript 原生不支持宏,因此整个工具链也未针对宏进行优化。虽然可以编写识别自定义语法的宏,但由于配套工具(如常见的 VSCode 和 TypeScript)不支持,会直接报出语法错误。同样,Babel 自身的解析器也不支持扩展语法,除非 Fork 修改 Babel 源码。因此,babel-plugin-macros 不支持自定义语法。不过,借助模板字符串函数,我们可以采取间接方式,至少获得部分自定义语法树的能力。以下是一个在 JavaScript 中直接编写 GraphQL 的示例。


import { gql } from 'graphql.macro';

const query = gql`
  query User {
    user(id: 5) {
      lastName
      ...UserEntry1
    }
  }
`;

//  在编译期会转换成 ↓ ↓ ↓ ↓ ↓ ↓

const query = {
  "kind": "Document",
  "definitions": [{
    ...

为什么要用宏而非 Babel 插件

Babel 插件的能力确实远超宏,且在某些场景下必须使用插件。宏相比 Babel 插件的优势在于其“开箱即用”的理念。使用 React 的开发者可能熟悉 Create-React-App(CRA),它封装了各种底层细节,让开发者专注于代码编写。然而,CRA 的封装过于严格,一旦需要自定义 Babel 插件,通常需执行 yarn react-scripts eject 暴露所有底层细节。相比之下,使用宏只需在项目的 Babel 配置中添加 babel-plugin-macros 插件,即可完美支持任何自定义 Babel 宏,无需下载多个独立插件。CRA 已内置 babel-plugin-macros,因此可在 CRA 项目中直接使用任意 Babel 宏。

如何写一个宏?

介绍

宏的编写方式与 Babel 插件非常相似,因此事先了解如何编写 Babel 插件非常有帮助。Babel 官方手册专门介绍了从零编写 Babel 插件的方法。在掌握 Babel 插件编写后,我们通过一个宏的使用示例,介绍 Babel 如何识别文件中的宏:是某种特殊语法,还是常用的 $ 符号?


import preval from 'preval.macro'

const one = preval`module.exports = 1 + 2 - 1 - 1`

这是一个非常常见的宏,其作用是在编译期间执行字符串中的 JavaScript 代码,并将执行结果替换到相应位置。上述代码在编译期会被展开为:


import preval from 'preval.macro'

const one = 1

从使用方式来看,唯一与识别宏相关的是 *.macro 字符,这确实也是 Babel 识别宏的方式。实际上,不仅对于 *.macro 形式,Babel 认为库名匹配正则 /[./]macro(\.c?js)?$/ 的库即为 Babel 宏。以下是一些匹配表达式的示例:


'my.macro'
'my.macro.js'
'my.macro.cjs'
'my/macro'
'my/macro.js'
'my/macro.cjs'

编写

接下来,我们将简单编写一个 importURL 宏,其作用是通过 URL 引入库,并在编译期间将这些库的代码预先拉取、处理后引入文件。虽然部分 Webpack 插件已支持从 URL 引入库,但这仍是一个很好的学习如何编写宏的例子,同时也展示了如何在 Node.js 中发起同步请求。

准备

首先创建一个名为 importURL 的文件夹,执行 npm init -y 快速创建项目。使用宏的用户需要安装 babel-plugin-macros,编写宏的用户同样需要安装此插件。在编写之前,还需提前安装一些辅助库:

  • 在 package.json 中将 name 改为 import-url.macro,以符合 Babel 识别宏的格式
  • 我们需要使用 Babel 提供的辅助方法来创建宏。执行 yarn add babel-plugin-macros
  • yarn add fs-extra,一个更易于使用的替代 Node.js fs 模块的库
  • yarn add find-root,编写宏的过程中,我们需要根据处理文件的路径找到其所在的工作目录,从而写入缓存,这是一个已封装好的库

示例

我们的目标是将如下代码转换为目标格式:


import importURL from 'importurl.macros';

const React = importURL('https://unpkg.com/react@17.0.1/umd/react.development.js');

// 编译成

import importURL from 'importurl.macros';

const React = require('../cache/pkg1.js');

我们将解析代码中 importURL 函数的第一个参数,将其视为远程库的地址,然后在编译期间通过 GET 请求同步拉取代码内容。接着将代码写入项目顶层文件夹下的 .cache 目录,并将相应的 importURL 语句替换为 require(...) 语句。路径...则是使用 importURL 的文件相对于 .cache 中文件的相对路径,以便 webpack 在最终打包时能找到对应代码。

开始

我们先看看最终的代码长什么样。


import { execSync } from 'child_process';
import findRoot from 'find-root';
import path from 'path';
import fse from 'fs-extra';

import { createMacro } from 'babel-plugin-macros';

const syncGet = (url) => {
  const data = execSync(`curl -L ${url}`).toString();
  if (data === '') {
    throw new Error('empty data');
  }
  return data;
}

let count = 0;
export const genUniqueName = () => `pkg${++count}.js`;

module.exports = createMacro((ctx) => {
  const {
    references, // 文件中所有对宏的引用
    babel: {
      types: t,
    }
  } = ctx;
  // babel 会把当前处理的文件路径设置到 ctx.state.filename
  const workspacePath = findRoot(ctx.state.filename);
  // 计算出缓存文件夹
  const cacheDirPath = path.join(workspacePath, '.cache');
  //
  const calls = references.default.map(path => path.findParent(path => path.node.type === 'CallExpression' ));
  calls.forEach(nodePath => {
    // 确定 astNode 的类型
    if (nodePath.node.type === 'CallExpression') {
      // 确定函数的第一个参数是纯字符串
      if (nodePath.node.arguments[0]?.type === 'StringLiteral') {
        // 获取一个参数,当做远程库的地址
        const url = nodePath.node.arguments[0].value;
        // 根据 url 拉取代码
        const codes = syncGet(url);
        // 生成一个唯一包名,防止冲突
        const pkgName = genUniqueName();
        // 确定最终要写入的文件路径
        const cahceFilename = path.join(cacheDirPath, pkgName);
        // 通过 fse 库,将内容写入, outputFileSync 会自动创建不存在的文件夹
        fse.outputFileSync(cahceFilename, codes);
        // 计算出相对路径
        const relativeFilename = path.relative(ctx.state.filename, cahceFilename);
        // 最终计算替换 importURL 语句
        nodePath.replaceWith(t.stringLiteral(`require('${relativeFilename}')`))
      }
    }
  });
});

创建一个宏

我们通过 createMacro 函数来创建宏,createMacro 接受我们编写的函数作为参数来生成宏。实际上,我们并不关心 createMacro 的返回值是什么,因为我们的代码最终都会被自己替换掉,不会在运行期间执行。我们编写的函数的第一个参数是 Babel 传递给我们的一些状态,我们可以大致查看其类型。


function createMacro(handler: MacroHandler, options?: Options): any;
interface MacroParams {
      references: { default: Babel.NodePath[] } & References;
      state: Babel.PluginPass;
      babel: typeof Babel;
      config?: { [key: string]: any };
  }
export interface PluginPass {
    file: BabelFile;
    key: string;
    opts: PluginOptions;
    cwd: string;
    filename: string;
    [key: string]: unknown;
}

可视化 AST

我们可以通过 astexplorer 观察将要处理代码的语法树。对于如下代码:


import importURL from 'importurl.macros';

const React = importURL('https://unpkg.com/react@17.0.1/umd/react.development.js');

会生成如下语法树:

JavaScript 宏的局限性

红色标红的语法树节点,就是 Babel 会通过 ctx.references 传递给我们的。因此,我们需要通过 .findParent() 方法向上找到父节点 CallExpression,才能获取 arguments 属性下的参数,从而拿到远程库的 URL 地址。

同步请求

这里的一个难点在于,Babel 不支持异步转换,所有转换操作都是同步的,因此在发起请求时也必须是同步请求。我原本以为这很简单,Node.js 会提供类似 sync: true 的选项,但实际上并没有。Node.js 确实不支持任何同步请求,除非采用下面这种较怪异的方式:


const syncGet = (url) => {
  const data = execSync(`curl -L ${url}`).toString();
  if (data === '') {
    throw new Error('empty data');
  }
  return data;
}

收尾

在拿到代码后,我们将代码写入到开始计算出的文件路径中。这里使用 fs-extra 的目的在于,fs-extra 在写入时如果遇到不存在的文件夹,不会像 fs 模块那样直接抛出错误,而是自动创建相应的文件夹。写入完成后,我们通过 Babel 提供的辅助方法 stringLiteral 创建字符串节点,随后替换掉我们的 importURL(...),自此整个转换流程结束。

最后

这个宏存在一些缺陷,有兴趣的同学可以继续完善:

没有识别同一 URL 的库进行复用,不过我想这些已经满足如何编写一个宏的目的了。

genUniqueName 在跨文件时会计算出重复包名,正确的算法应该是根据 URL 计算哈希值来作为唯一包名

到此这篇关于在JavaScript中如何使用宏的文章就介绍到这了,更多相关JavaScript使用宏内容请搜索本站以前的文章或继续浏览下面的相关文章,希望大家以后多多支持本站!

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章