如何在抽象语法树中修改节点

Avatar of Jason Lengstorf
Jason Lengstorf

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费积分!

我最近偶然发现的一个更强大的概念是抽象语法树或 AST 的概念。 如果你曾经学习过炼金术,你可能会记得炼金术士的全部动机是通过科学或神秘的方法发现某种将非金转化为金的方法。

AST 有点像这样。 使用 AST,我们可以将 Markdown 转换为 HTML,将 JSX 转换为 JavaScript,以及更多其他操作。

为什么 AST 有用?

在我职业生涯的早期,我尝试使用查找和替换方法更改文件。 这最终变得相当复杂,因此我尝试使用正则表达式。 我最终放弃了这个想法,因为它太脆弱了; 应用程序一直崩溃,因为有人会以我预料不到的方式输入文本,这会破坏我的正则表达式,导致整个应用程序崩溃。

之所以如此困难,是因为 HTML 很灵活。 这使得使用正则表达式解析变得极其困难。 这种基于字符串的替换很容易出错,因为它可能会错过匹配、匹配过多或执行导致无效标记的奇怪操作,从而使页面看起来很糟糕。

另一方面,AST 将 HTML 转换为更结构化的内容,这使得更轻松地深入文本节点并仅对该文本进行替换,或者在无需处理文本的情况下处理元素。

这使得 AST 变换比纯基于字符串的解决方案更安全、更不易出错。

AST 用于什么?

首先,让我们看一下使用几行 Markdown 的最小文档。 这将另存为名为 home.md 的文件,我们将将其保存在我们网站的内容文件夹中。

# Hello World!

![cardigan corgi](<https://images.dog.ceo/breeds/corgi-cardigan/n02113186_1030.jpg>) An adorable corgi!

Some more text goes here.

假设我们了解 Markdown,我们可以推断,当此 Markdown 被解析时,它最终将成为一个显示“Hello World!”的 h1 标签,然后是两段文本:第一段包含柯基犬的图像和一些旨在描述它的文本,第二段显示“此处显示更多文本”。

但是它是如何从 Markdown 转换为 HTML 的呢?

这就是 AST 发挥作用的地方!

由于它支持多种语言,我们将使用 unist 语法树规范,更具体地说,是项目 unified

安装依赖项

首先,我们需要安装解析 Markdown 为 AST 并将其转换为 HTML 所需的依赖项。 为此,我们需要确保已将文件夹初始化为包。 在您的终端中运行以下命令

# make sure you’re in your root folder (where `content` is)
# initialize this folder as an npm package
npm init

# install the dependencies
npm install unified remark-parse remark-html

如果我们假设我们的 Markdown 存储在 home.md 中,我们可以使用以下代码获取 AST

const fs = require('fs');
const unified = require('unified');
const markdown = require('remark-parse');
const html = require('remark-html');

const contents = unified()
  .use(markdown)
  .use(html)
  .processSync(fs.readFileSync(`${process.cwd()}/content/home.md`))
  .toString();

console.log(contents);

此代码利用 Node 内置的 fs 模块,该模块允许我们访问和操作文件系统。 有关其工作原理的更多信息,请 查看官方文档

如果我们将其另存为 src/index.js 并使用 Node 从命令行 执行此脚本,我们将在终端中看到以下内容

$ node src/index.js 
<h1>Hello World!</h1>
<p><img src="<https://images.dog.ceo/breeds/corgi-cardigan/n02113186_1030.jpg>" alt="cardigan corgi"> An adorable corgi!</p>
<p>Some more text goes here.</p>

我们告诉 unified 使用 remark-parse 将 Markdown 文件转换为 AST,然后使用 remark-html 将 Markdown AST 转换为 HTML——或者更准确地说,它将其转换为称为 VFile 的内容。 使用 toString() 方法将该 AST 转换为我们可以在浏览器中显示的实际 HTML 字符串!

感谢开源社区的辛勤工作,remark 为我们完成了将 Markdown 转换为 HTML 的所有艰苦工作。(查看差异

接下来,让我们看看这实际上是如何工作的。

AST 看起来像什么?

要查看实际的 AST,让我们编写一个小型插件来记录它

const fs = require('fs');
const unified = require('unified');
const markdown = require('remark-parse');
const html = require('remark-html');

const contents = unified()
	.use(markdown)
  .use(() => tree => console.log(JSON.stringify(tree, null, 2)))
	.use(html)
	.processSync(fs.readFileSync(`${process.cwd()}/content/home.md`))
	.toString();

现在运行脚本的输出将是

{
  "type": "root",
  "children": [
    {
      "type": "heading",
      "depth": 1,
      "children": [
        {
          "type": "text",
          "value": "Hello World!",
          "position": {}
        }
      ],
      "position": {}
    },
    {
      "type": "paragraph",
      "children": [
        {
          "type": "image",
          "title": null,
          "url": "<https://images.dog.ceo/breeds/corgi-cardigan/n02113186_1030.jpg>",
          "alt": "cardigan corgi",
          "position": {}
        },
        {
          "type": "text",
          "value": " An adorable corgi!",
          "position": {}
        }
      ],
      "position": {}
    },
    {
      "type": "paragraph",
      "children": [
        {
          "type": "text",
          "value": "Some more text goes here.",
          "position": {}
        }
      ],
      "position": {}
    }
  ],
  "position": {}
}

请注意,位置值已截断以节省空间。 它们包含有关节点在文档中的位置的信息。 出于本教程的目的,我们不会使用此信息。(查看差异

这看起来有点让人不知所措,但是如果我们放大,我们可以看到 Markdown 的每个部分都成为具有文本节点的某种类型的节点。

例如,标题变为

{
  "type": "heading",
  "depth": 1,
  "children": [
    {
      "type": "text",
      "value": "Hello World!",
      "position": {}
    }
  ],
  "position": {}
}

这意味着什么

  • 类型告诉我们正在处理哪种类型的节点。
  • 每个节点类型都有描述节点的其他属性。 标题上的 depth 属性告诉我们它是哪一级标题——深度为 1 表示它是 <h1> 标签,2 表示 <h2>,依此类推。
  • children 数组告诉我们此节点内部的内容。 在标题和段落中,只有文本,但我们也可以在这里看到内联元素,例如 <strong>

这就是 AST 的强大功能:我们现在已将 Markdown 文档描述为计算机可以理解的对象。 如果我们想将其打印回 Markdown,Markdown 编译器会知道深度为 1 的“标题”节点以 # 开头,并且值为“Hello”的子文本节点表示最终行应为 # Hello

AST 变换如何工作

AST 变换通常使用访问者模式完成。 了解其工作原理的来龙去脉对于提高生产力并不重要,但如果你好奇,Soham Kamani 的 JavaScript Design Patterns for Humans 有一个 很好的示例来帮助解释其工作原理。 需要知道的重要一点是,关于 AST 工作的大多数资源都会谈论“访问节点”,这大致相当于“查找 AST 的一部分以便我们可以对其进行操作”。 在实践中,其工作方式是我们编写一个将应用于符合我们条件的 AST 节点的函数。

关于其工作原理的一些重要说明

  • AST 可能很大,因此出于性能原因,我们将直接更改节点。 这与我通常处理事物的方式相反——作为一个通用的规则,我不喜欢更改全局状态——但在这种情况下是有道理的。
  • 访问者递归工作。 这意味着,如果我们处理一个节点并创建相同类型的节点,访问者也会在新建的节点上运行,除非我们明确告诉访问者不要这样做。
  • 在本教程中,我们不会深入探讨,但这两个想法将帮助我们理解我们在开始修改代码时发生了什么。

如何修改 AST 的 HTML 输出?

但是,如果我们想更改 Markdown 的输出怎么办? 假设我们的目标是将图像标签包装在 figure 元素中并提供标题,如下所示

<figure>
  <img
    src="<https://images.dog.ceo/breeds/corgi-cardigan/n02113186_1030.jpg>"
    alt="cardigan corgi"
  />
  <figcaption>An adorable corgi!</figcaption>
</figure>

为了实现这一点,我们需要转换 HTML AST——而不是 Markdown AST——因为 Markdown 无法创建 figure 或 figcaption 元素。 幸运的是,由于 unified 与多个解析器具有互操作性,因此我们无需编写大量自定义代码即可做到这一点。

将 Markdown AST 转换为 HTML AST

要将 Markdown AST 转换为 HTML AST,请添加 remark-rehype 并切换到 rehype-stringify 以将 AST 转换回 HTML。

npm install remark-rehype rehype-stringify

src/index.js 中进行以下更改以切换到 rehype

const fs = require('fs');
const unified = require('unified');
const markdown = require('remark-parse');
const remark2rehype = require('remark-rehype');
const html = require('rehype-stringify');

const contents = unified()
	.use(markdown)
  .use(remark2rehype)
	.use(() => tree => console.log(JSON.stringify(tree, null, 2)))
	.use(html)
	.processSync(fs.readFileSync('corgi.md'))
	.toString();

console.log(contents);

请注意,HTML 变量已从 remark-html 更改为 rehype-stringify——两者都将 AST 转换为可以转换为 HTML 的格式

如果我们运行脚本,我们可以看到图像元素现在在 AST 中看起来像这样

{
  "type": "element",
  "tagName": "img",
  "properties": {
    "src": "https://images.dog.ceo/breeds/corgi-cardigan/n02113186_1030.jpg",
    "alt": "cardigan corgi"
  },
  "children": [],
  "position": {}
}

这是图像的 HTML 表示的 AST,因此我们可以开始将其更改为使用 figure 元素。(查看差异

为 Unified 编写插件

为了将我们的 img 元素包装在 figure 元素中,我们需要编写一个插件。在 Unified 中,插件是通过 use() 方法添加的,该方法接受插件作为第一个参数,任何选项作为第二个参数。

.use(plugin, options)

插件代码是一个函数(在 Unified 行话中称为“连接器”),它接收选项。这些选项用于创建一个新函数(称为“转换器”),该函数接收 AST 并执行工作来转换它。有关插件的更多详细信息,请查看 Unified 文档中的插件概述

它返回的函数将接收整个 AST 作为其参数,并且它不返回任何内容。(请记住,AST 在全局范围内被修改。)在与 index.js 相同的文件夹中创建一个名为 img-to-figure.js 的新文件,然后将以下内容放入其中

module.exports = options => tree => {
  console.log(tree);
};

要使用它,我们需要将其添加到 src/index.js

const fs = require('fs');
const unified = require('unified');
const markdown = require('remark-parse');
const remark2rehype = require('remark-rehype');
const html = require('rehype-stringify');
const imgToFigure = require('./img-to-figure');

const contents = unified()
  .use(markdown)
  .use(remark2rehype)
  .use(imgToFigure)
  .processSync(fs.readFileSync('corgi.md'))
  .toString();

console.log(contents);

如果我们运行脚本,我们将在控制台中看到整个树被输出

{
  type: 'root',
  children: [
    {
      type: 'element',
      tagName: 'p',
      properties: {},
      children: [Array],
      position: [Object]
    },
    { type: 'text', value: '\\n' },
    {
      type: 'element',
      tagName: 'p',
      properties: {},
      children: [Array],
      position: [Object]
    }
  ],
  position: {
    start: { line: 1, column: 1, offset: 0 },
    end: { line: 4, column: 1, offset: 129 }
  }
}

(查看差异)

向插件添加访问者

接下来,我们需要添加一个访问者。这将让我们真正接触到代码。Unified 利用了许多实用程序包,所有这些包都以 unist-util-* 为前缀,这使我们能够在不编写自定义代码的情况下对 AST 执行常见操作。

我们可以使用 unist-util-visit 来修改节点。这给了我们一个访问帮助器,它接受三个参数

  • 我们正在处理的整个 AST
  • 一个谓词函数,用于识别我们想要访问的节点
  • 一个函数,用于对我们想要进行的 AST 进行任何更改

要安装,请在命令行中运行以下命令

npm install unist-util-visit

让我们通过添加以下代码在插件中实现一个访问者

const visit = require('unist-util-visit');

  module.exports = options => tree => {
    visit(
      tree,
      // only visit p tags that contain an img element
      node =>
        node.tagName === 'p' && node.children.some(n => n.tagName === 'img'),
      node => {
        console.log(node);
      }
    );
};

当我们运行它时,我们可以看到只输出了一个段落节点

{
  type: 'element',
  tagName: 'p',
  properties: {},
  children: [
    {
      type: 'element',
      tagName: 'img',
      properties: [Object],
      children: [],
      position: [Object]
    },
    { type: 'text', value: ' An adorable corgi!', position: [Object] }
  ],
  position: {
    start: { line: 3, column: 1, offset: 16 },
    end: { line: 3, column: 102, offset: 117 }
  }
}

完美!我们只获取了包含我们想要修改的图像的段落节点。现在我们可以开始转换 AST 了!

(查看差异)

将图像包装在 figure 元素中

现在我们有了图像属性,我们可以开始更改 AST 了。请记住,因为 AST 可能非常大,所以我们原地修改它们以避免创建大量副本并可能减慢脚本速度。

我们首先将节点的 tagName 更改为 figure 而不是 paragraph。其余详细信息现在可以保持不变。

src/img-to-figure.js 中进行以下更改

const visit = require('unist-util-visit');

module.exports = options => tree => {
  visit(
    tree,
    // only visit p tags that contain an img element
    node =>
    node.tagName === 'p' && node.children.some(n => n.tagName === 'img'),
    node => {
      node.tagName = 'figure';
    }
  );
};

如果我们再次运行脚本并查看输出,我们可以看到我们越来越接近了!

<h1>Hello World!</h1>
<figure><img src="<https://images.dog.ceo/breeds/corgi-cardigan/n02113186_1030.jpg>" alt="cardigan corgi">An adorable corgi!</figure>
<p>Some more text goes here.</p>

(查看差异)

使用图像旁边的文本作为标题

为了避免需要编写自定义语法,我们将使用任何与图像一起内联传递的文本作为图像标题。

我们可以假设 Markdown 中的图像通常没有内联文本,但值得注意的是,这绝对可能导致为编写 Markdown 的人显示意外的标题。我们将在本教程中承担这个风险。如果您计划将其投入生产,请务必权衡利弊并选择最适合您情况的方案。

要使用文本,我们将查找父节点内的文本节点。如果我们找到一个,我们想获取其值作为我们的标题。如果没有找到标题,我们不想转换此节点,因此我们可以提前返回。

src/img-to-figure.js 进行以下更改以获取标题

const visit = require('unist-util-visit');

module.exports = options => tree => {
  visit(
    tree,
    // only visit p tags that contain an img element
    node =>
    node.tagName === 'p' && node.children.some(n => n.tagName === 'img'),
    node => {
      // find the text node
      const textNode = node.children.find(n => n.type === 'text');
 
      // if there’s no caption, we don’t need to transform the node
      if (!textNode) return;
 
      const caption = textNode.value.trim();
 
      console.log({ caption });
      node.tagName = 'figure';
    }
  );
};

运行脚本,我们可以看到输出的标题

{ caption: 'An adorable corgi!' }

(查看差异)

向 figure 元素添加 figcaption 元素

现在我们有了标题文本,我们可以添加一个 figcaption 来显示它。我们可以通过创建一个新节点并删除旧的文本节点来做到这一点,但是由于我们是在原地修改,所以更改文本节点为元素稍微简单一些。

但是,元素没有文本,因此我们需要添加一个新的文本节点作为 figcaption 元素的子元素来显示标题文本。

src/img-to-figure.js 进行以下更改以将标题添加到标记中

const visit = require('unist-util-visit');

module.exports = options => tree => {
  visit(
    tree,
    // only visit p tags that contain an img element
    node =>
      node.tagName === 'p' && node.children.some(n => n.tagName === 'img'),
    node => {
      // find the text node
      const textNode = node.children.find(n => n.type === 'text');

      // if there’s no caption, we don’t need to transform the node
      if (!textNode) return;

      const caption = textNode.value.trim();
      // change the text node to a figcaption element containing a text node
      textNode.type = 'element';
      textNode.tagName = 'figcaption';
      textNode.children = [
        {
          type: 'text',
          value: caption
        }
      ];

      node.tagName = 'figure';
    }
  );
};

如果我们再次使用 node src/index.js 运行脚本,我们会看到转换后的图像被包装在一个 figure 元素中,并用 figcaption 进行描述!

<h1>Hello World!</h1>
<figure><img src="<https://images.dog.ceo/breeds/corgi-cardigan/n02113186_1030.jpg>" alt="cardigan corgi"><figcaption>An adorable corgi!</figcaption></figure>

<p>Some more text goes here.</p>

(查看差异)

将转换后的内容保存到新文件

现在我们已经进行了一系列转换,我们希望将这些调整保存到实际文件中,以便我们可以共享它们。

由于 Markdown 不包含完整的 HTML 文档,因此我们将添加另一个名为 rehype-document 的 rehype 插件以添加完整的文档结构和标题标签。

通过运行以下命令安装

npm install rehype-document

接下来,对 src/index.js 进行以下更改

const fs = require('fs');
const unified = require('unified');
const markdown = require('remark-parse');
const remark2rehype = require('remark-rehype');
const doc = require('rehype-document');
const html = require('rehype-stringify');

const imgToFigure = require('./img-to-figure');

const contents = unified()
	.use(markdown)
	.use(remark2rehype)
	.use(imgToFigure)
    .use(doc, { title: 'A Transformed Document!' })
	.use(html)
	.processSync(fs.readFileSync(`${process.cwd()}/content/home.md`))
	.toString();

 const outputDir = `${process.cwd()}/public`;

  if (!fs.existsSync(outputDir)) {
    fs.mkdirSync(outputDir);
  }
 
  fs.writeFileSync(`${outputDir}/home.html`, contents);

再次运行脚本,我们将能够在根目录中看到一个名为 public 的新文件夹,并在其中看到 home.html。在其中,保存了我们转换后的文档!

<!doctype html><html lang="en">
<head>
<meta charset="utf-8">
<title>A Transformed Document!</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
	<h1>Hello World!</h1>
	<figure><img src="<https://images.dog.ceo/breeds/corgi-cardigan/n02113186_1030.jpg>" alt="cardigan corgi"><figcaption>An adorable corgi!</figcaption></figure>
	<p>Some more text goes here.</p>
</body>
</html>

(查看差异)

如果我们在浏览器中打开 public/home.html,我们可以看到我们转换后的 Markdown 呈现为带有标题的 figure。

太棒了!看看那只可爱的柯基犬!我们知道它很可爱,因为标题告诉了我们。

接下来做什么

使用 AST 转换文件非常强大——借助它,我们能够以安全的方式创建我们能想到的几乎任何东西。无需使用正则表达式或字符串解析!

从这里,您可以深入研究 remark 和 rehype 的插件生态系统,以了解更多可能的功能并获得更多关于使用 AST 转换可以做什么的想法,从构建您自己的 Markdown 静态网站生成器;到通过就地修改代码来自动执行性能改进;以及您能想到的任何其他功能!

AST 转换是一种编码超能力。通过 查看此演示的源代码 开始入门——我迫不及待地想看看您用它构建了什么!在 Twitter 上 与我分享您的项目。