第七章 - 使用编译器API输出生成的TypeScript和JavaScript代码

94 阅读4分钟

第七章 - 使用编译器API输出生成的TypeScript和JavaScript代码

通过编写AST实现自动生成TypeScript代码的入门指南

这是本书最令人兴奋的章节之一!我们将学习如何自动生成TypeScript代码!

在构建时生成代码有很多应用场景 - 比如生成HTTP客户端、SDK、文档、HTML页面等等。掌握代码生成技术将为你的开发工具箱增添新利器。

本章示例中,我们将基于配置文件生成React代码。不了解React也没关系,它的语法类似HTML但更现代化。关于React的入门知识,可以参考Mozilla的React入门指南

目标

我们的目标是根据JSON配置文件生成React TSX代码。假设配置文件如下:

{
  "name": "IntroPage",
  "title": "我的页面标题",
  "subtitle": "我的页面副标题",
  "path": "/pages/intro",
  "parent": "cloud",
  "content": "你好,世界!",
  "tags": [
    { "id": "tag1", "name": "标签1" },
    { "id": "tag2", "name": "标签2" }
  ]
}

我们要将其转换为如下代码:

import React from "react"
import { PageTitle, PageSubtitle, PageBody, PageTags, PageTag } from "my-ui-lib"

export default function IntroPage() {
  const context = useContext()
  return (
    <>
      <PageTitle>我的页面标题</PageTitle>
      <PageSubtitle>我的页面副标题</PageSubtitle>
      <PageBody>你好,世界!</PageBody>
      <PageTags>
        <PageTag>标签1</PageTag>
        <PageTag>标签2</PageTag>
      </PageTags>
    </>
  )
}

你可能会问:既然React本身就支持模板功能,为什么还要生成代码?这是个好问题。React确实可以直接这样写:

export default function Page() {
  const context = useContext()
  return (
    <>
      <PageTitle>{pageTitle}</PageTitle>
      <PageSubtitle>{subtitle}</PageSubtitle>
      <PageBody>{content}</PageBody>
      <PageTags>
        {tags.map((tag) => (
          <PageTag key={tag.id}>{tag.name}</PageTag>
        ))}
      </PageTags>
    </>
  )
}

为什么不直接使用模板?

两种方式各有优劣。代码生成可以减少运行时计算,因为大部分内容是静态的(比如不需要循环处理标签)。但会增加客户端包体积,因为每个页面都有重复代码而不是共用布局模板。

虽然这个例子中模板可能更合适,但我们仍会演示代码生成,因为:

  1. 有些场景需要在构建时注入生成代码
  2. 掌握这项技术对未来处理复杂情况很有帮助

通过代码生成,我们可以:

  • 结合Webpack等工具自动添加代码
  • 减少需要手写的代码量
  • 支持微前端等架构模式

这项技术不仅适用于前端React,任何代码场景都能受益。

工厂方法

我们将使用TypeScript编译器API的factory命名空间来创建AST节点。例如创建const a = 17

ts.factory.createVariableStatement(
  undefined,
  ts.factory.createVariableDeclarationList(
    [
      ts.factory.createVariableDeclaration(
        ts.factory.createIdentifier("a"),
        undefined,
        undefined,
        ts.factory.createNumericLiteral("17")
      )
    ],
    ts.NodeFlags.Const
  )
)

这段代码会生成对应的AST,之后可以转换为源代码。建议浏览ts.factory了解可用的方法。

开始实践

我们继续使用index.mjs作为开发环境,并配合TS AST Viewer工具。

首先在文件顶部添加打印生成代码的辅助函数:

/**
 * @param {ts.Node[]} nodes
 */
function printCode(nodes) {
  const resultFile = ts.createSourceFile("source.ts", "", ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX)
  const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })

  const sourceFile = ts.factory.createSourceFile(
    nodes,
    ts.factory.createToken(ts.SyntaxKind.EndOfFileToken),
    ts.NodeFlags.None
  )

  const result = printer.printNode(ts.EmitHint.Unspecified, sourceFile, resultFile)
  console.log(result)
}

注意创建源文件时指定了TSX脚本类型。为方便起见,我们直接将配置存入变量:

const config = {
  pages: [
    {
      name: "IntroPage",
      title: "我的页面标题",
      subtitle: "我的页面副标题",
      path: "/pages/intro",
      parent: "cloud",
      content: "你好,世界!",
      tags: [
        { id: "tag1", name: "标签1" },
        { id: "tag2", name: "标签2" }
      ]
    }
  ]
}

构建页面

将生成逻辑分为两部分:

  • 创建import语句
  • 创建export语句

对应两个函数:

function createImportStatements() {}
function createExportStatement(page) {}

连接这两个部分:

const page = [...createImportStatements(), createExportStatement(config.pages[0])]
printCode(page)

注意:使用扩展运算符展开import语句的返回数组

生成import语句

使用TS AST Viewer工具分析以下代码:

import React from "react"
import { PageTitle, PageSubtitle, PageBody, PageTags, PageTag } from "my-ui-lib"

工具会生成对应的factory代码。我们可以优化为:

function createImportStatements() {
  const componentImports = ["PageTitle", "PageSubtitle", "PageBody", "PageTags", "PageTag"]

  return [
    ts.factory.createImportDeclaration(
      undefined,
      ts.factory.createImportClause(false, ts.factory.createIdentifier("React"), undefined),
      ts.factory.createStringLiteral("react"),
      undefined
    ),
    ts.factory.createImportDeclaration(
      undefined,
      ts.factory.createImportClause(
        false,
        undefined,
        ts.factory.createNamedImports(
          componentImports.map((id) => 
            ts.factory.createImportSpecifier(false, undefined, factory.createIdentifier(id)))
        )
      ),
      ts.factory.createStringLiteral("my-ui-lib"),
      undefined
    )
  ]
}

这样更易于维护,运行后会输出正确的import语句。

生成export语句

将示例函数粘贴到TS AST Viewer,会得到大量factory代码。我们需要做以下修改:

  1. 使用配置中的页面标题
  2. 使用配置中的副标题和内容
  3. 设置导出函数名为配置中的名称
  4. 移除不必要的JSX文本节点
  5. 动态生成标签组件

关键修改点:

// 动态生成标签
const tagElements = page.tags.map((tag) =>
  ts.factory.createJsxElement(
    ts.factory.createJsxOpeningElement(
      ts.factory.createIdentifier("PageTag"), 
      undefined,
      ts.factory.createJsxAttributes([])
    ),
    [ts.factory.createJsxText(tag.name, false)],
    ts.factory.createJsxClosingElement(ts.factory.createIdentifier("PageTag"))
  )
)

// 在返回语句中使用
ts.factory.createJsxElement(
  ts.factory.createJsxOpeningElement(
    ts.factory.createIdentifier("PageTags"),
    undefined,
    ts.factory.createJsxAttributes([])
  ),
  tagElements,
  ts.factory.createJsxClosingElement(ts.factory.createIdentifier("PageTags"))
)

完整脚本运行后会输出预期的React组件代码。

代码优化

发现重复模式可以提取公共函数:

function createComponent(identifier, text) {
  return ts.factory.createJsxElement(
    ts.factory.createJsxOpeningElement(
      ts.factory.createIdentifier(identifier),
      undefined,
      ts.factory.createJsxAttributes([])
    ),
    [ts.factory.createJsxText(text, false)],
    ts.factory.createJsxClosingElement(ts.factory.createIdentifier(identifier))
  )
}

然后可以这样使用:

createComponent("PageTitle", page.title)

这类似于JSX的底层实现原理,非常有趣!

总结

本章我们:

  1. 学习了使用TypeScript编译器API生成代码
  2. 完成了React TSX代码生成示例
  3. 理解了代码生成的应用场景

下一章我们将学习如何读取和重写TypeScript AST来实现代码转换!