理解Babel是如何工作的

Babel 是用于编写下一代 JavaScript 的 transpiler (转译器)。Babel 是以将 ES5+ 代码转译成可在任何浏览器中运行的向后兼容的 JavaScript 版本而闻名。因此,学习如何使用 Babel 以编程方式修改 JavaScript 代码是有必要的。

转译器概念和流程

转译器也称为源到源编译器。它们是读取以一种编程语言编写的源代码并以另一种语言生成等效代码的工具。转译器使 TypeScript 和 CoffeeScript 等语言得以存在,这是 JavaScript 的语法糖。转译器基本上需要三个主要步骤才能完成看似神奇的功能:

1. 词法分析(token化) —  源代码到 token

词法分析器(我们称其为分词器)使用已定义的规则将字符串形式的代码转换为令牌数组。Tokenizer 会逐个字符地扫描代码,当遇到符号或空格时,它会判断一个单词是否完成,最后给它一个类型和值。但是,生成的标记并不能解释事物如何组合在一起。它仅代表输入的组成部分。

Babel 不会将其标记器作为包的一部分导出(它的功能与 分组@babel/parser,但是ESPRIMA提供了一个简单易用的标记器,可用于可视化标记化的结果。生成的信息有助于语法分析。

import esprima from 'esprima';
const code = 'const asnwer = 42';
const result = esprima.tokenize(code);

console.log(result);
/* 
[
  { type: 'Keyword', value: 'const' },
  { type: 'Identifier', value: 'asnwer' },
  { type: 'Punctuator', value: '=' },
  { type: 'Numeric', value: '42' }
] */

2. 语法分析(解析) —  token 到 AST抽象语法树

生成的令牌将被转换为抽象语法树 (AST)。AST 是用编程语言编写的源代码的抽象句法结构的树状表示。树的每个节点表示源代码中出现的一个构造。AST 在表示源代码的抽象结构(不表示出现在实际语法中的每个细节)方面很有用。AST 可用于将代码编译为不同的语言、转译代码(babel 所做的)、执行代码的静态分析、生成源代码等等。

var a = 42;
var b = 5;
var c = a + b;

 

3. 代码生成 — AST 到 源代码

修改后的 AST 将用于生成要使用的最终代码。在 Babel 的上下文中,这是通过提供的@babel/generator实现。

利用这三步,我们可以通过使用 Babel 修改 AST 来自信地操作代码。

修改并生成代码

我们希望将变量关键字 更改为 x,转换流程如下:

1. @babel/parser 提供了一种将源代码转换为 AST 的 parse 方法。

2. @babel/traverse 为我们提供了一种使用 visitor 访问我们感兴趣的 node 的方法。我们正在定义一个对象字面量,它实现了一个visitor 属性,该属性由一个方法对象组成,该对象的名称与它应该处理的节点匹配(类型基于@babel/types)。 在这个例子中,我们正在寻找 Identifier 节点,并且该函数将访问 Identifier 一次(n 和 y)。 但是,在使用多个 visitor 和 plugins 的实际代码中,一个 node 可能会被多次访问(尽管很少见,但这种情况会发生)。 =所以应该做一些检查来跳过转换。

3. @babel/generator 将修改后的 AST 转换回代码。

import generate from '@babel/generator';
import parser from '@babel/parser';
import generate from '@babel/traverse';

const inputCode = `const n = 12
const y = 100`;

const ast = parser.parse(inputCode);

traverse(ast, {
  Identifier(path) {
    if (path.isIdentifier({ name: 'n' })) {
      path.node.name = 'x';
    }
  },
});

const output = generate(ast).code;

console.log(ouput);
/* 
const x = 12;
const y = 100;
 */

总之,babel 可以处理了所有三个步骤,如此,我们完全可以专注于要求转换 AST。

使用 Babel 遍历 AST

要遍历 AST,我们只需定义一个对象字面量,其中定义了用于接受树中特定节点类型的方法(类型基于@babel/types)。

const MyVisitor = {
  Identifier() {
    console.log("Called!");
  }
}

上面的代码是一个简单的 visitor,在遍历过程中,将为访问的每个 Identifier 调用 Identifier() 方法。 我们也可以使用别名作为访问者节点(例如 Function 是 FunctionDeclaration、FunctionExpression、ArrowFunctionExpression、ObjectMethod 和 ClassMethod 的别名)。

在遍历 AST 来转换源代码时,应该注意遍历顺序。 可以运行以下代码并将输出与 AST Explorer 进行比较以可视化流程:

import babel from '@babel/core';
import traverse from '@babel/traverse';

const code = `function printHelloWorld(){
  console.log('Hello World')
  try {
    console.log('trying')
  }catch(error) {
    console.log(error)
  }
}`;

const ast = babel.parse(code);

let depth = 0;
traverse(ast, {
  enter(path) {
    console.log(`enter ${path.type}(${path.key})`);
    depth++;
  },
  exit(path) {
    depth--;
    console.log(`  exit ${path.type}(${path.key})`);
  },
});

我们在遍历过程中得到的路径是节点之间链接的对象表示。 想象一下下面的代码:

function foo(){
}

/**
 具有以下压缩的 AST:
 {
        "type": "FunctionDeclaration",
        "id": {
          "type": "Identifier",
          "name": "foo"
 }
*/

我们知道上面的代码有 FunctionDeclaration 和 Identifier 节点。 如果我们选择将 Identifier 表示为路径,我们将得到类似下面的代码片段:

{
    "parent": {
        type: "FunctionDeclaration",
        id: {...}
    },
    "node": {
        type: 'Identifier',
        name: 'foo'
    }
}

Babel 确实提供了额外的元数据和方法来简化 AST 转换。 总之,路径是树中节点位置的带有附加信息的反应性表示。 每当您调用修改树的方法时,Babel 都会管理所有内容并更新信息。 Babel 使得使用 AST 变得非常简单。

State

我们需要非常小心 AST 转换过程中的状态。 让我们看下面的代码:


function add(foo, bar) {
  return foo + bar;
}

const foo = 100;

假设我们想在函数内部重命名foo为baz,我们可以像这样进行 hacky 转换:

let paramName;
const Visitor = {
  FunctionDeclaration(path) {
    const firstParams = path.get('params.0');
    paramName = firstParams.node.name;
    firstParams.name = 'baz';
  },

  Identifier(path) {
    if (path.node.name === paramName) {
      path.node.name = 'baz';
    }
  },
});

上述转换应该有效。 我们正在更改 FunctionDeclaration 中的参数和 Identifier 中的名称。 我们得到的转换后的源代码实际上是这样的:

function add(baz, bar) {
  return baz + bar;
}

const baz = 100; 

但为什么? 这是因为我们正在修改每个与 foo 同名的标识符。 这是由于 AST 的 state,如果使用大型代码库,很可能会发生这种情况。
我们如何避免上述情况? 我们可以在 FunctionDeclaration 中进行递归遍历,以消除对全局状态的污染。

const Visitor = {
  FunctionDeclaration(path) {
    const firstParams = path.get('params.0');
    const paramName = firstParams.node.name;
    /** 在匹配的路径内遍历,这避免了操作外部作用域 */
    path.traverse({
      Identifier(path) {
        if (path.node.name === paramName) {
          path.replaceWith(t.identifier('baz'));
        }
      },
    });
  },
};
/* 
function add(baz, bar) {
  return baz + bar;
}
const foo = 100;
*/

这个解决方案实际上是一个非常幼稚,实际案例中的实际转换逻辑应该有更多的检查,以确保转换只发生在您感兴趣的节点上。

Scope

请注意,JavaScript 实现了词法作用域。 我们可以把它想象成一个树结构,其中每个嵌套节点都有其 scope。 更深 scope 内的代码可以使用来自更高 scope 的引用并创建同名引用而无需修改它。

/** global scope */
let global = "This is global scope"
function foo(){
    /** inner scope 1 */
    let scope1 = "This is inner scope 1"
    function bar(){
        /** inner scope 2 */
        let scope2 = "This is inner scope 2"
        scope1 = "Updating the reference in inner scope 1"
        function baz(){
            /** inner scope 3 */
            let scope2 = 'This is inner scope 3 and does not affect the one in inner scope 2'
        }
    }
}

在转换复杂的 AST 时,我们应该注意 scope 以避免破坏现有代码。 让我们看下一个示例并确定转换的问题:

/** global scope */
const x = 1;
const y = 2; 
const foo = 1000;
function add(foo, bar) { 
  /** function scope 1 */
  /** 此 x 指的是 global scope 的 x */
  console.log(x, y);
  return () => { 
    /** function scope 2 */
    const x = 100;
    /** 此 x 指的是 function scope 2 中的 x, 而 foo & bar 指的是函数范围 1 中foo & bar */
    return x + foo + bar;
  }
}

假设我们想将 add 函数的第一个参数从 foo 转换为 x。 我们可以轻松地重用前面示例中的逻辑来进行转换。 但是,我们最终会得到以下结果:

/** global scope */
const x = 1;
const y = 2; 
const foo = 1000;
function add(x, bar) { 
  /** function scope 1 */
  /** 此 x 不再指的是 global scope 的 x,而优先是 function scope 1 的 x */
  console.log(x, y);
  return () => { 
    /** function scope 2 */
    const x = 100;
    /** 此 x 指的是 function scope 2 中的 x, 而 foo & bar 指的是函数范围 1 中foo & bar */
    return x + x + bar;
  }
}

起初,似乎转换是正确的。 但是, add 函数从全局范围引用 x 和 y。 基于当前的转换,变量 x 不再指向全局范围内的变量。 在大型代码库中,我们甚至不会注意到这个错误,因为几乎不可能跟踪闭包和 scope。尽管如此,让我们尝试解决上面的示例:

/** 用于获取唯一的标识符 */
const getUid = () => {
  let uid = 0;
  return () => `_${uid++}`;
};

const Visitor = {
  FunctionDeclaration(path) {
    const firstParams = path.get('params.0');
    /** 如果没有参数直接 return */
    if (firstParams == null) {
      return;
    }

    const paramName = firstParams.node.name;
    const currentBinding = path.scope.getBinding(paramName);
    const gid = getUid();
    let finalName;

    /** 循环获取未使用的唯一变量名 */
    while (true) {
      finalName = gid();

      /** 这将遍历并检查父 scope 是否有变量,如果有跳过 */
      if (path.scope.parentHasBinding(finalName)) {
        continue;
      }

      /** 这检查当前 scope 是否有变量, 如果有跳过 */
      if (path.scope.hasOwnBinding(finalName)) {
        continue;
      }

      /** 检查第一个参数是否被其他地方引用, 如果是,我们将检查名称是否冲突,如果冲突,我们将打破循环并重新开始 */
      if (currentBinding.references > 0) {
        let findIt = false;
        for (const refNode of currentBinding.referencePaths) {
          if (
            refNode.scope !== path.scope &&
            refNode.scope.hasBinding(finalName)
          ) {
            findIt = true;
            break;
          }
        }
        if (findIt) {
          continue;
        }
      }
      break;
    }

    /** 创建一个新的标识符并替换 */
    const i = t.identifier(finalName);
    /** 替换所有原始引用 */
    currentBinding.referencePaths.forEach((p) => p.replaceWith(i));
    firstParams.replaceWith(i);
  },
};

/**
 * 处理结果:
const x = 1;
const y = 2;
const foo = 1000;

function add(_0, bar) {
  console.log(x, y);
  return () => {
    const x = 100;
    return x + _0 + bar;
  };
}
*/

尽管上面的代码并不完美,但它显示了在转换过程中处理 scope 是多么复杂和困难。 我们必须跟踪所有 binding 和 reference,同时确定是否存在冲突的 Identifier 名称。 我们很幸运,因为 Babel 提供了许多我们可以在 path.scope 中使用的有用方法(可以参考这里)。借助 babel,处理简化为:

const Visitor = {
  FunctionDeclaration(path) {
    const firstParams = path.get('params.0');
    /** 如果没有参数就 return */
    if (firstParams == null) {
      return;
    }
    const i = path.scope.generateUid('_');
    path.scope.rename(firstParams.node.name, i)
  }
};
/** 
* 转化结果:
const x = 1;
const y = 2;
const foo = 1000;

function add(_, bar) {
  console.log(x, y);
  return () => {
    const x = 100;
    return x + _ + bar;
  };
}
*/

@babel/preset-env

在 babel@7 推出之际,babel 官方把 babel preset stage 以及 es2015 es2016 等等都废弃了,取而代之的是 @babel/preset-env。preset-env 主要做的是转换 JavaScript 最新的 Syntax(指的是 const let ... 等), 而作为可选项 preset-env 也可以转换 JavaScript 最新的 API (指的是比如 数组最新的方法 filter 、includes,Promise 等等)。preset-env 的三个关键参数:

  • targets:决定了项目需要适配到的环境,比如可以申明适配到的浏览器版本,这样 babel 会根据浏览器的支持情况自动引入所需要的 polyfill。
  • usebuiltins:决定了 preset-env 如何处理 polyfills。 usage,不需要在入口文件处 import 对应的 polyfills 相关库。 babel 会根据用户代码的使用情况,并根据 targets 自行注入相关 polyfills。 entry会将最低环境不支持的所有 polyfill 都引入到入口文件(即使你在你的业务代码中并未使用)。默认值是false,即不会自动引入任何polyfill。
  • corejs:用 corejs-3,开启 proposals: true,proposals 为真那样我们就可以使用 proposals 阶段的 API 了。

使用 preset-env 注入的 polyfill 是会污染全局的,但是如果是自己的应用其实是在可控的。

targets 下设置我们业务项目所需要支持的最低环境配置,useBuiltIns 设置为 entry,将最低环境不支持的所有 polyfill 都引入到入口文件(即使你在你的业务代码中并未使用)。这是一种兼顾最终打包体积和稳妥的方式,因为我们很难保证引用的三方包有处理好 polyfill 这些问题。当然如果充分保证三方依赖 polyfill 处理得当,那么也可以把 useBuiltIns 设置为 usage:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "chrome": "58" // 按自己需要填写
        },
        "useBuiltIns": "entry",
        "corejs": {
          "version": 3,
          "proposals": true
        }
      }
    ]
  ],
  "plugins": []
}

@babel/plugin-transform-runtime

@babel/plugin-transform-runtime 插件实现的 polyfill 是不会影响全局的,所以更适合 Library 作者使用。@babel/plugin-transform-runtime 开启 corejs 并且 @babel/preset-env 也开启 useBuiltIns会使得被使用到的高级 API polyfill 将会采用 runtime 的不污染全局方案(注意:@babel/preset-env targets 设置将会失效),而不被使用到的将会采用污染全局的。

综上,如果是业务项目开发者@babel/plugin-transform-runtime ,建议关闭 corejs,polyfill 的引入由 @babel/preset-env 完成,即开启 useBuiltIns(如需其他配置,自行根据诉求配置):

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "chrome": 58
        },
        "useBuiltIns": "entry",
        "corejs": {
          "version": 3,
          "proposals": true
        }
      }
    ]
  ],
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "corejs": false
      }
    ]
  ]
}
import 'core-js/stable';
import 'regenerator-runtime/runtime';
// 入口文件代码

如果是 Library 开发者@babel/plugin-transform-runtime ,建议开启 corejs,polyfill 由 @babel/plugin-transform-runtime 引入。 @babel/preset-env 关闭 useBuiltIns:

{
  "presets": [
    [
      "@babel/preset-env",
    ]
  ],
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "corejs": {
          "version": 3,
          "proposals": true
        }
      }
    ]
  ]
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

薛定谔的猫96

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值