Skip to content

初识前端编译及 Babel ​

本文主要从个人对前端编译的了解,对前端编译的一些进行思考。会讲解编译的大致流程,以及前端的常用工具 Babel,但不会细节到具体某个环节。

1. 引言 ​

编译在前端开发中的重要性逐渐增加,特别是随着现代前端框架、ES6+, TypeScript 的普及。本文旨在为开发者提供一个比较简单的前端编译的知识输入,主要讲解编译的基本概念以及让大家对编译有所体感。

2. 什么是编译 ​

编译是将源代码从一种编程语言转换为另一种编程语言的过程。

简单来说,它通常是将高级语言转换为低级语言(如汇编或机器代码)。与解释不同,编译是在代码运行之前完成的。

image.png

前端常见的编译工具 ​

前端编译例子:TS 转 JS、JSX 转 JS、Vue Template 转 JS,对应的编译工具有。

  • Babel:主要用于将 ES6+代码转换为向后兼容的 JavaScript 版本。
  • TSC:JavaScript 的超集,添加了类型系统。
  • Vue-Compiler: 支持 Vue 的语法,最终转成 JS、css 代码。
  • 小程序:小程序的设计,天然就决定了他的编译特性,跨端运行。
  • Compiler-NG: 内部对于 Lynx 语法的编译器,将类 react 语法转成一个二进制的 JS 产物。

虽然前端用了这么多编译工具,但这些对于我们来说,可能还是会相对比较陌生。

因为在我们日常开发中,可能我们更多注重接触的是运行时的代码,而一般不会接触到工程层的设计,所以可能对基建工具层不够熟悉。

但其实了解编译的话,会对我们日常开发,也是会有很大帮助的。

3. 编译流程 ​

编译一般包括以下几个步骤:

  • Parse: 通过 parser 将源代码转换成抽象语法树(AST), 其中会涉及到 词法解析,语法解析等操作。
  • Transform: 拿到了源码上对应的 AST, 我们可以去对这个AST, 进行增删改查的操作。其中会涉及到访问者模式的知识。
  • Generate: 转换后的 AST,我们可以转换生成目标代码。

image.png

上方只是简单说了下编译的流程,下方我们对流程进行细化。

https://astexplorer.net/#/gist/c98a65c37b72bec5dfd215ac981a3ad4/140607ab8205b758cd42aae9399a8e86d4519a55

3.1 Parse ​

Parse 阶段是将源码字符串转换成机器能够理解的 AST,这个过程分成此词法分析,语法分析。

词法分析:将字符串分成一个个规定好的 token,分割的工具我们一般成为词法分析器(Tokenizer)。

语法分析: 将一个个 token 进行拼接组装,按照不同的语法结构,来把一堆堆 token 进行组合拼接,生成 AST ,声明语句,赋值表达式都有对应的 AST 节点。

image.png

3.2 Transform ​

Transform 主要是对生成的 AST 进行处理,会进行 AST 的遍历,可以对对应的 AST 节点进行处理。

下方是一个在 log 函数添加新的参数节点的示意图,注意,这里只是表达添加参数节点,并不代表实际节点这么使用。

image.png

3.3 Generate ​

Generate 阶段会根据 AST 生成新的字符串,并生成对应的 SourceMap。

不同的 AST 对应的不同结构的字符串。比如 VariableDeclaration 就可以打印成 const 格式的代码。

image.png

以上是编译的抽象的流程。而前端工程中,其实也是在打包的过程去加入了编译的这样的一个环节。

下方,我们就来讲讲其中用的比较多的打包工具 —— babel

4. Babel: A JavaScript Compiler ​

Babel 实质上是一个 JavaScript 的编译器(转译器),用于将 es next, typescript 等代码做相关的转换,同时暴露了相关的 api 给开发者做特定用途的转换。

image.png

image.png

4.1 Babel 执行流程 ​

image.png

refer: **深入浅出 Babel 上篇:架构和原理 + 实战**

babel 的执行过程从大体来看,和我们上方的流程是一致的。但 babel 在具体实现的细节中,有很多很棒的技术设计。

如核心流程拆包、插件生态、访问者模式。

4.2 **核心流程拆包** ​

babel 讲流程拆的很细, 并且每个包都符合单一职责的设计。

  • 核心:
    • @babel/core: 整体流程的内核,加载配置和插件,调用 parser, traverser, generator 的流程调用。
  • 核心能力支撑:
    • **@babel/parser:**负责 源代码进行解析,解析成 AST。它已经内置支持很多语法. 例如 JSX、Typescript、Flow、以及最新的 ECMAScript 规范。
    • @babel/traverse : 实现了访问者模式,对 AST 进行遍历,可以对节点进行 patch。
    • @babel/generator : 将 AST 转换为源代码,支持 SourceMap。

4.3 插件生态 ​

  • 插件:
    • 语法插件(@babel/plugin-syntax-*):上面说了 @babel/parser 已经支持了很多 JavaScript 语法特性,Parser 也不支持扩展. 因此plugin-syntax-*实际上只是用于开启或者配置 Parser 的某个功能特性。
    • 转换插件: 用于对 AST 进行转换, 实现转换为 ES5 代码、压缩、功能增强等目的.
    • 预定义集合(@babel/presets-*): 插件集合或者分组,主要方便用户对插件进行管理和使用。比如preset-env含括所有的标准的最新特性; 再比如preset-react含括所有 react 相关的插件.
  • 插件开发辅助:
    • @babel/template: 某些场景直接操作 AST 太麻烦,就比如我们直接操作 DOM 一样,所以 Babel 实现了这么一个简单的模板引擎,可以将字符串代码转换为 AST。比如在生成一些辅助代码(helper)时会用到这个库。
    • @babel/types: AST 节点构造器和断言. 插件开发时使用很频繁。
    • @babel/helper: 辅助代码,单纯的语法转换可能无法让代码运行起来,比如低版本浏览器无法识别 class 关键字,这时候需要添加辅助代码,对 class 进行模拟。
  • 工具:
    • @babel/cli: CLI 工具
    • @babel/node: Node.js CLI, 通过它直接运行需要 Babel 处理的 JavaScript 文件

4.4 访问者模式 ​

// 看看写不写

5.简单的写一个 Babel 插件 ​

整体会使用 vite + react 来实现。

5.1 需求 ​

埋点,可以理解为针对用户的特定行为,进行一个记录,处理和发送相关事件以及对应的数据的技术。

我们这里做一个简单的案例,我们只需要在对应的 DOM 节点打上标识,然后当点击对应的 DOM 节点的时候,能够进行埋点数据的上报。

5.2 思路 ​

于是,我们的思路比较明确,主要是以下两点

  • 修改代码的时机:在对应的打包工具中,看编译过程中的生命周期钩子,在对应生命周期中,修改代码。(如 vite 中的  transform  钩子)
  • 具体如何修改代码:我们可以借助 babel 来进行一层代码的转译。分为  引入 SDK  以及  添加编译事件。

5.3 实现 ​

修改代码的时机

这里其实涉及两点。

  • 打包工具提供的钩子:vite 提供了 transform 钩子,我们可以直接用,但其他打包工具的具体看提供的钩子。
  • 编译插件调用顺序:项目中一般也存在其他的编译插件,我们要注意执行顺序,不要产生冲突(如我们的插件应该是 在 tsx/jsx 进行编译,而不是在他转成 js 的时候进行编译)。

我们使用 vite 的 transform 钩子。

ts
import * as babel from "@babel/core";
import autoTrackerBabelPlugin from "./babel-plugin";

interface AutoTrackerPluginOptions {
  libPath: string;
}

const fileRegex = /\\.(tsx)$/;

export default function autoTracker(pluginOptions: AutoTrackerPluginOptions) {
  return {
    name: "autoTracker",
    enforce: "pre",

    async transform(code: string, id: string) {
      if (!fileRegex.test(id)) {
        return;
      }

      const result = await babel.transformAsync(code, {
        babelrc: false,
        configFile: false,
        ast: true,
        code: true,
        parserOpts: {
          plugins: ["jsx", "typescript"],
        },
        plugins: [[autoTrackerBabelPlugin, pluginOptions]],
      });
      return {
        code: result.code,
        map: null,
      };
    },
  };
}

babel 代码编译

babel 这里主要注意两点

  • SDK 引入:我们看对应的文件,之前有没有引入 SDK,没有的话,我们手动引入一下。

代码如下:

ts
function checkHasLogIdentification(attributes) {
  return (attributes || []).some(item => item.name.name === 'data-log-params');
}

function findAttributeNode(attributes, key) {
  return (attributes || []).find(item => item.name && item.name.name === key);
}

export default function (babel, options) {
  const { types: t, template } = babel;
  const { libName, libPath } = options;
  return {
    name: "autoTrackerPlugin",
    visitor: {
      Program(path, state) {
        let needImportSDK = false;
        let loggerId;

        // 这里从 Program 节点进行遍历, 主要两个功能        // 1. 判断该组件下的 是否需要引入 SDK。        // 2. 判断 SDK 是否导入,导入记录下 loggerId。        path.traverse({
          JSXOpeningElement(elePath) {
            const { attributes } = elePath.node;

            const hasLogIdentification = checkHasLogIdentification(
              attributes,
            );

            if (hasLogIdentification) {
              needImportSDK = true;
              elePath.stop();
            }
          },
          ImportDeclaration(importPath) {
            const { node } = importPath;
            if (node.source.value.includes(libPath)) {
              const specifier = node.specifiers[0];
              if (!t.isImportDefaultSpecifier(specifier)) {
                importPath.stop();
                needImportSDK = true;
                return;
              }
              loggerId = specifier.local.name; // 取出导入的变量名赋值给loggerId              importPath.stop();
              state.loggerNodeName = loggerId;
            }
          },
        });

        if (!needImportSDK) {
          return;
        }

        if (!loggerId) {
          // 如果loggerId没有值,说明源代码中还没有导入此模块,          loggerId = path.scope.generateUid(libName);
          path.node.body.unshift(
            t.importDeclaration(
              [t.importDefaultSpecifier(t.identifier(loggerId))],
              t.stringLiteral(libPath),
            ),
          );
          state.loggerNodeName = loggerId;
        }
      },
    }
  };
}

代码编译:

ts
function checkHasLogIdentification(attributes) {
  return (attributes || []).some(
    (item) => item.name.name === "data-log-params"
  );
}

function findAttributeNode(attributes, key) {
  return (attributes || []).find((item) => item.name && item.name.name === key);
}

export default function (babel, options) {
  const { types: t, template } = babel;
  const { libName, libPath } = options;
  return {
    name: "autoTrackerPlugin",
    visitor: {
      JSXOpeningElement(path, state) {
        if (!state.loggerNodeName) {
          return;
        }
        const { attributes } = path.node;

        const hasLogIdentification = checkHasLogIdentification(attributes);
        if (!hasLogIdentification) {
          return;
        }

        const onClickNode = findAttributeNode(attributes, "onClick");

        if (!onClickNode) {
          path.pushContainer(
            "attributes",
            t.jsxAttribute(
              t.jsxIdentifier("onClick"),
              t.jsxExpressionContainer(
                template.expression(`${state.loggerNodeName}.reportClick`)()
              )
            )
          );
        } else {
          const { value } = onClickNode;
          const { expression: onClickFNNode } = value;

          const newTapFNNode = t.callExpression(
            t.callExpression(
              t.memberExpression(
                t.memberExpression(
                  t.identifier(state.loggerNodeName),
                  t.identifier("generateReportClickFn")
                ),
                t.identifier("bind")
              ),
              [t.thisExpression()]
            ),
            [onClickFNNode]
          );
          onClickNode.value.expression = newTapFNNode;
        }
      },
    },
  };
}

5.4 效果 ​

image.png

6. 结尾 ​

上方简单的介绍了个人视角下的前端编译流程以及对 babel 的理解,也许部分内容存在部分错误,也请批评指正。

编译在前端开发中扮演着越来越重要的角色,特别是在现代 Web 应用的构建中。掌握基础的编译原理和工具使用是每个前端开发者的必备技能。

具体表现在

  • 不同的框架的不同语法,Vue, React 等。
  • 自定义语法支持,比如公司内部的 Lynx, 同时 Lynx 有多套编译器。
  • 越来越多的打包工具的诞生,从编译角度讲,rust、 go 编译的性能会比 JS 编译的性能高。

未来,随着 WebAssembly、工具链新技术的出现,前端编译可能会有更多的应用场景和发展趋势。

参考链接 ​

Released under the MIT License.