美文网首页
Babel - 应用

Babel - 应用

作者: 前端C罗 | 来源:发表于2022-04-11 10:10 被阅读0次

    Babel是一系列模块的结合,本文会介绍其中主要模块的使用方法。

    注意:本文并不能代替API文档,详细的文档可以查阅这里

    babel-parser


    babel-parseracorn fork出来的项目,跟acorn一样执行快速并且易用性高。使用插件模式的架构,对当前非标准的特性进行扩展支持。

    • 安装包
    $ npm install --save @babel/parser
    

    先从一段简单的代码解析开始

    import parser from "@babel/parser";
    
    const code = `function square(n) {
      return n * n;
    }`;
    
    console.log(parser.parse(code));
    

    相应的输出为

    Node {
      type: 'File',
      start: 0,
      end: 40,
      loc: SourceLocation {
        start: Position { line: 1, column: 0, index: 0 },
        end: Position { line: 3, column: 1, index: 40 },
        filename: undefined,
        identifierName: undefined
      },
      errors: [],
      program: Node {
        type: 'Program',
        start: 0,
        end: 40,
        loc: SourceLocation {
          start: [Position],
          end: [Position],
          filename: undefined,
          identifierName: undefined
        },
        sourceType: 'script',
        interpreter: null,
        body: [ [Node] ],
        directives: []
      },
      comments: []
    }
    

    只传入code的话表示使用默认的配置进行代码parse。也可以指定配置,比如下面的代码:

    parser.parse(code, {
      sourceType: "module", // default: "script"
      plugins: ["jsx"] // default: []
    });
    

    相应的输出为

    Node {
      type: 'File',
      start: 0,
      end: 40,
      loc: SourceLocation {
        start: Position { line: 1, column: 0, index: 0 },
        end: Position { line: 3, column: 1, index: 40 },
        filename: undefined,
        identifierName: undefined
      },
      errors: [],
      program: Node {
        type: 'Program',
        start: 0,
        end: 40,
        loc: SourceLocation {
          start: [Position],
          end: [Position],
          filename: undefined,
          identifierName: undefined
        },
        sourceType: 'module',
        interpreter: null,
        body: [ [Node] ],
        directives: []
      },
      comments: []
    }
    

    可以看到输出的AST中,program节点的sourceType变为module
    sourceType的值可以是module也可以是script,这个值代表着以哪种模式对代码进行parsemodule模式将使用严格模式并允许模块定义,但script不会。

    注意: sourceType默认值为script,这种模式下如果代码中有importexportparse时会报错。这种情况下,将sourceType设置为module即可避免报错。

    babel-traversal


    开发者可以使用该模块管理AST的状态,包括 替换/删除新增节点。
    安装包

    $ npm install --save @babel/traverse
    

    使用babel-traverse更新部分节点

    import parser from "@babel/parser";
    import traverse from "@babel/traverse";
    
    const code = `function square(n) {
      return n * n;
    }`;
    
    const ast = parser.parse(code);
    
    traverse(ast, {
      enter(path) {
        if (
          path.node.type === "Identifier" &&
          path.node.name === "n"
        ) {
          path.node.name = "x";
        }
      }
    });
    

    babel-types


    babel-typeslodash风格的操作AST节点的工具库。提供AST节点的创建校验转换。使用精心设计的工具方法可以帮助更加单纯地把精力放在AST操作逻辑上,而不是节点操作的细节。
    安装包

    $ npm install --save @babel/types
    

    简单的使用示例

    import traverse from "@babel/traverse";
    import * as t from "@babel/types";
    
    traverse(ast, {
      enter(path) {
        if (t.isIdentifier(path.node, { name: "n" })) {
          path.node.name = "x";
        }
      }
    });
    

    Definitions

    babel-types为每一类节点都提供了相关的定义,每个定义包含如下信息:

    • 属性属于哪里???
    • 合法的值域
    • 创建该类节点的方法
    • 访问节点的方法
    • 节点的别名

    一个简单的示例如下

    defineType("BinaryExpression", {
      builder: ["operator", "left", "right"],
      fields: {
        operator: {
          validate: assertValueType("string")
        },
        left: {
          validate: assertNodeType("Expression")
        },
        right: {
          validate: assertNodeType("Expression")
        }
      },
      visitor: ["left", "right"],
      aliases: ["Binary", "Expression"]
    });
    

    Builders

    上面BinaryExpression的定义中,有一个字段是builder

    builder: ["operator", "left", "right"]
    

    每个节点都有一个builder方法,比如创建二元计算表达式

    t.binaryExpression("*", t.identifier("a"), t.identifier("b"));
    

    上面的代码将会创建一个AST节点

    {
      type: "BinaryExpression",
      operator: "*",
      left: {
        type: "Identifier",
        name: "a"
      },
      right: {
        type: "Identifier",
        name: "b"
      }
    }
    

    相应的代码为

    a * b
    

    Builder还会验证它创建的节点,如果使用不当,会引发描述性错误。这也引出了下面会提到的方法。

    Validators

    BinaryExpression的定义中还有关于fields的相关信息,其中就有这些field的验证。

    fields: {
      operator: {
        validate: assertValueType("string")
      },
      left: {
        validate: assertNodeType("Expression")
      },
      right: {
        validate: assertNodeType("Expression")
      }
    }
    

    验证方法通常有两类

      1. isX。判断是否为X
    t.isBinaryExpression(maybeBinaryExpressionNode);
    // !!!
    // 传递第二个参数,对节点的 属性/属性值,进行进一步判断
    t.isBinaryExpression(maybeBinaryExpressionNode, { operator: "*" });
    
      1. 断言。
    t.assertBinaryExpression(maybeBinaryExpressionNode);
    t.assertBinaryExpression(maybeBinaryExpressionNode, { operator: "*" });
    // Error: Expected type "BinaryExpression" with option { "operator": "*" }
    

    第1种方法,返回bool值,表示是否为判定的X,并不会中断程序执行。第2种方法,抛出错误,将会中断后续执行。

    Converters

    制作中

    babel-generator


    babel-generator包用于生成代码。接收AST输出带有sourcemap信息的代码。
    安装

    $ npm install --save @babel/generator
    

    简单使用示例

    import parser from "@babel/parser";
    import generate from "@babel/generator";
    
    const code = `function square(n) {
      return n * n;
    }`;
    
    const ast = parser.parse(code);
    
    generate(ast, {}, code);
    // {
    //   code: "...",
    //   map: "..."
    // }
    

    第二个参数是生成代码时的配置,默认是空对象,也可以根据说明文档传入指定的配置

    generate(ast, {
      retainLines: false,
      compact: "auto",
      concise: false,
      quotes: "double",
      // ...
    }, code);
    

    babel-template


    babel-template 是另外一个小而美的包(前一个是babel-types😄)。引入这个包可以让开发者使用模板字符串的方式替代大量的AST操作方案来生成AST节点。在计算机领域这个模式被称为quasiquotes

    $ npm install --save @babel/template
    

    国际惯例,简单的示例

    import template from "@babel/template";
    import generate from "@babel/generator";
    import * as t from "@babel/types";
    
    const buildRequire = template(`
      var IMPORT_NAME = require(SOURCE);
    `);
    
    const ast = buildRequire({
      IMPORT_NAME: t.identifier("myModule"),
      SOURCE: t.stringLiteral("my-module")
    });
    
    console.log(generate(ast).code);
    // var myModule = require("my-module");
    

    开发Babel插件


    前面介绍了Babel相关的基础和包的使用,下面通过开发一个Babel的插件将这些知识串联起来。

    插件本质是一个方法

    export default function(babel) {
      // plugin contents
    }
    

    方法返回一个包含visitor属性的对象

    export default function({ types: t }) {
      return {
        visitor: {
          // visitor contents
        }
      };
    };
    

    visitor上使用相应的访问器(特定的节点类型)操作节点,每个访问器有2个参数pathstate

    export default function({ types: t }) {
      return {
        visitor: {
          Identifier(path, state) {},
          ASTNodeTypeHere(path, state) {}
        }
      };
    };
    

    介绍完插件的基本知识点之后,我们开发一个将代码中==替换为===的插件。
    插件的逻辑非常简单

    • 使用访问器hook节点
    • 如果是BinaryExpression节点,则检查operator字段,是==则替换为===,否则不处理。
    export default function({ types: t }) {
      return {
        visitor: {
          BinaryExpression: (path) => {
            if (path.node.operator === '==') {
                path.node.operator = '==='
            }
          }
        }
      };
    };
    

    如你所见,使用babel操作代码就是这么简单!

    常用操作


    Visiting


    获取子节点的path

    访问节点的属性通常是先获取节点实例,然后通过点运算符获取相应属性的值,path.node.property

    // the BinaryExpression AST node has properties: `left`, `right`, `operator`
    BinaryExpression(path) {
      path.node.left;
      path.node.right;
      path.node.operator;
    }
    

    如果获取相应属性的path,则需要使用get方法,参数为属性名的字符串值

    BinaryExpression(path) {
      path.get('left'); 
    }
    Program(path) {
      path.get('body.0');
    }
    

    上面的代码中对body的子元素访问相对特殊,不能直接get('body'),因为body下是BlockStatement的数组。使用.连接访问路径。比如对于下面的代码

    export default function f() {
      return bar;
    }
    

    获取其return相应的path,相应的代码如下所示

    ExportDefaultDeclaration(path) {
      path.get("declaration.body.body.0");
    }
    
    判断节点类型(babel-types)

    判断二元表达式的左节点是否为名称是n的变量

    BinaryExpression(path) {
      if (t.isIdentifier(path.node.left, { name: "n" })) {
        // ...
      }
    }
    
    判断path的类型
    BinaryExpression(path) {
      if (path.get('left').isIdentifier({ name: "n" })) {
        // ...
      }
    }
    
    检查变量是否被引用
    Identifier(path) {
      if (path.isReferencedIdentifier()) {
        // ...
      }
    }
    // or
    Identifier(path) {
      if (t.isReferenced(path.node, path.parent)) {
        // ...
      }
    }
    
    查找祖先路径

    有的场景下需要自当前节点向上遍历,以找到符合条件的祖先节点path
    findParent回调函数执行返回值为true时,返回对应的NodePath,遍历结束。

    path.findParent((path) => path.isObjectExpression());
    

    如果要包含当前节点,则使用find方法

    path.find((path) => path.isObjectExpression());
    

    获取最近的祖先函数节点path或者programpath也有相应的快捷方法

    path.getFunctionParent();
    
    查找兄弟路径

    如果一个path是在Functionprogram节点的body中(数组型),那么这个path就会有兄弟路径

    • path.inList检查path是否在list
    • path.getSibling(index)获取相邻指定步长的兄弟path
    • path.key当前path在列表中的位置
    • path.container节点的容器
    • path.listKey列表容器的名称
    var a = 1; // pathA, path.key = 0
    var b = 2; // pathB, path.key = 1
    var c = 3; // pathC, path.key = 2
    
    export default function({ types: t }) {
      return {
        visitor: {
          VariableDeclaration(path) {
            // if the current path is pathA
            path.inList // true
            path.listKey // "body"
            path.key // 0
            path.getSibling(0) // pathA
            path.getSibling(path.key + 1) // pathB
            path.container // [pathA, pathB, pathC]
            path.getPrevSibling() // path(undefined) *
            path.getNextSibling() // pathB
            path.getAllPrevSiblings() // []
            path.getAllNextSiblings() // [pathB, pathC]
          }
        }
      };
    }
    

    path(undefined)是一个NodePathpath.node === undefined

    中止遍历
    • 特定的条件下不执行,使用return
    BinaryExpression(path) {
      if (path.node.operator !== '**') return;
    }
    
    • 使用pathapipath.skip()忽略对当前path的子节点的遍历,path.stop()停止所有未执行的遍历。
    outerPath.traverse({
      Function(innerPath) {
        innerPath.skip(); // if checking the children is irrelevant
      },
      ReferencedIdentifier(innerPath, state) {
        state.iife = true;
        innerPath.stop(); // if you want to save some state and then stop traversal, or deopt
      }
    });
    

    操作AST


    替换节点
    BinaryExpression(path) {
      path.replaceWith(
        t.binaryExpression("**", path.node.left, t.numberLiteral(2))
      );
    }
    

    将同一个变量的相乘替换为该变量的2次方

      function square(n) {
    -   return n * n;
    +   return n ** 2;
      }
    
    替换单个节点为多个节点
    ReturnStatement(path) {
      path.replaceWithMultiple([
        t.expressionStatement(t.stringLiteral("Is this the real life?")),
        t.expressionStatement(t.stringLiteral("Is this just fantasy?")),
        t.expressionStatement(t.stringLiteral("(Enjoy singing the rest of the song in your head)")),
      ]);
    }
    
      function square(n) {
    -   return n * n;
    +   "Is this the real life?";
    +   "Is this just fantasy?";
    +   "(Enjoy singing the rest of the song in your head)";
      }
    
    使用源码串替换节点
    FunctionDeclaration(path) {
      path.replaceWithSourceString(`function add(a, b) {
        return a + b;
      }`);
    }
    

    原来的函数代码被直接替换为新的代码

    - function square(n) {
    -   return n * n;
    + function add(a, b) {
    +   return a + b;
      }
    

    除非处理的是动态代码,否则这种方案是不推荐的,更好的做法是在外部先将待替换的源码转成节点,然后完成替换。

    插入兄弟节点
    FunctionDeclaration(path) {
      path.insertBefore(t.expressionStatement(t.stringLiteral("Because I'm easy come, easy go.")));
      path.insertAfter(t.expressionStatement(t.stringLiteral("A little high, little low.")));
    }
    
    + "Because I'm easy come, easy go.";
      function square(n) {
        return n * n;
      }
    + "A little high, little low.";
    
    往容器中插入节点
    ClassMethod(path) {
      path.get('body').unshiftContainer('body', t.expressionStatement(t.stringLiteral('before')));
      path.get('body').pushContainer('body', t.expressionStatement(t.stringLiteral('after')));
    }
    
     class A {
      constructor() {
    +   "before"
        var a = 'middle';
    +   "after"
      }
     }
    
    删除节点
    FunctionDeclaration(path) {
      // 把当前的函数申明节点删除
      path.remove();
    }
    
    - function square(n) {
    -   return n * n;
    - }
    
    替换父节点
    BinaryExpression(path) {
      path.parentPath.replaceWith(
        t.expressionStatement(t.stringLiteral("Anyway the wind blows, doesn't really matter to me, to me."))
      );
    }
    
      function square(n) {
    -   return n * n;
    +   "Anyway the wind blows, doesn't really matter to me, to me.";
      }
    
    删除父节点
    BinaryExpression(path) {
      path.parentPath.remove();
    }
    
      function square(n) {
    -   return n * n;
      }
    

    Scope


    判断变量是否绑定
    FunctionDeclaration(path) {
      if (path.scope.hasBinding("n")) {
        // ...
      }
    }
    

    path.scope.hasBinding方法将会自底向顶地遍历scope树,判断是否绑定变量n
    如果只想在当前scope上查找,则使用path.scope. hasOwnBinding

    FunctionDeclaration(path) {
      if (path.scope.hasOwnBinding("n")) {
        // ...
      }
    }
    
    生成UID

    path.scope.generateUidIdentifier会生成一个不与给定作用域下其他变量冲突的标识符

    FunctionDeclaration(path) {
      path.scope.generateUidIdentifier("uid");
      // Node { type: "Identifier", name: "_uid" }
      path.scope.generateUidIdentifier("uid");
      // Node { type: "Identifier", name: "_uid2" }
    }
    
    将变量定义提升到当前作用域的父级作用域
    FunctionDeclaration(path) {
      const id = path.scope.generateUidIdentifierBasedOnNode(path.node.id);
      path.remove();
      path.scope.parent.push({ id, init: path.node });
    }
    

    产生的变化如下

    - function square(n) {
    + var _square = function square(n) {
        return n * n;
    - }
    + };
    
    修改binding及引用
    FunctionDeclaration(path) {
      path.scope.rename("n", "x");
    }
    
    - function square(n) {
    -   return n * n;
    + function square(x) {
    +   return x * x;
      }
    

    也可以使用生成的UIDbinding进行变量命名

    FunctionDeclaration(path) {
      // 第二个参数不传,则会自动生成uid
      path.scope.rename("n");
    }
    
    - function square(n) {
    -   return n * n;
    + function square(_n) {
    +   return _n * _n;
      }
    

    最佳实践

    使用工具函数

    使用工具函数能够大大减少节点操作的复杂度,减少相应的操作错误概率。如

    function buildAssignment(left, right) {
      return t.assignmentExpression("=", left, right);
    }
    
    按需遍历,尽早退出

    遍历AST节点开销较大,而且很容易在遍历时对不必要的节点进行访问。Babel中可以将同样操作的访问器合并到一起进行处理。

    比如下面的代码

    path.traverse({
      Identifier(path) {
        // ...
      }
    });
    
    path.traverse({
      BinaryExpression(path) {
        // ...
      }
    });
    

    从逻辑上看并没有任何问题,在需要的地方遍历AST,但这种写法会造成对同样的树进行多次遍历,这显然是一种浪费。可以对其中的访问器进行合并,一次遍历就能完成相关的逻辑处理,如下:

    path.traverse({
      Identifier(path) {
        // ...
      },
      BinaryExpression(path) {
        // ...
      }
    });
    
    明确规则代替遍历

    在一些场景下,节点的查找规则是确定的,这种情况使用明确的查找规则代替遍历会是更优的做法。
    比如查找函数参数

    const nestedVisitor = {
      Identifier(path) {
        // ...
      }
    };
    
    const MyVisitor = {
      FunctionDeclaration(path) {
        path.get('params').traverse(nestedVisitor);
      }
    };
    

    可以换成

    const MyVisitor = {
      FunctionDeclaration(path) {
        // 直接通过点运算符获取params列表
        path.node.params.forEach(function() {
          // ...
        });
      }
    };
    
    优化嵌套访问器

    在访问器中有嵌套逻辑时,编写嵌套逻辑的代码是有其意义的

    // 外层 访问器
    const MyVisitor = {
      FunctionDeclaration(path) {
        path.traverse({
          // 内层访问器
          Identifier(path) {
            // ...
          }
        });
      }
    };
    

    它的问题在于,每次命中FunctionDeclaration时都会新创建一个访问器对象,这个开销是巨大的。更好的做法是定义一个访问器对象,每次将该对象传入。

    const nestedVisitor = {
      Identifier(path) {
        // ...
      }
    };
    
    const MyVisitor = {
      FunctionDeclaration(path) {
        path.traverse(nestedVisitor);
      }
    };
    

    如果被嵌套的访问器需要使用内部的状态,可以将state传入travese,通过this获取相应的状态,如下

    const nestedVisitor = {
      Identifier(path) {
        // 通过this使用相应的状态值
        if (path.node.name === this.exampleState) {
          // ...
        }
      }
    };
    
    const MyVisitor = {
      FunctionDeclaration(path) {
        var exampleState = path.node.params[0].name;
        // 第二个参数是传入的状态
        path.traverse(nestedVisitor, { exampleState });
      }
    };
    
    小心嵌套的结构

    在做AST转换时,通常会被忽视掉的是拿到的是一个嵌套结构。比如对一个类的构造函数进行处理时,类代码如下

    class Foo {
      constructor() {
        // ...
      }
    }
    

    访问其构造函数

    const constructorVisitor = {
      ClassMethod(path) {
        if (path.node.name === 'constructor') {
          // ...
        }
      }
    }
    
    const MyVisitor = {
      ClassDeclaration(path) {
        if (path.node.id.name === 'Foo') {
          path.traverse(constructorVisitor);
        }
      }
    }
    

    上面的构造函数的访问器明显忽略了一个事实,类是可以继承的,它的构造函数可能是一个链条

    class Foo {
      constructor() {
        class Bar {
          constructor() {
            // ...
          }
        }
      }
    }
    

    假定它是一个单独的构造函数,往往会出错。

    单元测试

    对Babel插件的测试有几种主要的方案:快照测试/AST测试和执行测试。

    快照测试

    jest提供了比较方便的快照测试能力。针对具体的测试用例,事先准备好相应的快照文件,然后将测试用例的结果跟快照文件的内容进行比对,如果相同则测试通过,如果不同则测试失败,并抛出不同的内容。

    测试用例

    // src/__tests__/index-test.js
    const babel = require('babel-core');
    const plugin = require('../');
    
    var example = `
    var foo = 1;
    if (foo) console.log(foo);
    `;
    
    it('works', () => {
      const {code} = babel.transform(example, {plugins: [plugin]});
      expect(code).toMatchSnapshot();
    });
    

    快照文件

    exports[`test works 1`] = `
    "
    var bar = 1;
    if (bar) console.log(bar);"
    `;
    

    如果把上面快照文件中的bar改成baz,重新运行jest命令,会抛出如下错误提示

    Received value does not match stored snapshot 1.
    
        - Snapshot
        + Received
    
        @@ -1,3 +1,3 @@
         "
        -var bar = 1;
        -if (bar) console.log(bar);"
        +var baz = 1;
        +if (baz) console.log(baz);"
    

    对于内容较多的快照文件,可以使用jest -u更新快照内容,避免手动创建。

    AST测试

    除了上述的快照测试,还可以对AST进行审查。下面是一个简单的示例

    it('contains baz', () => {
      const {ast} = babel.transform(example, {plugins: [plugin]});
      const program = ast.program;
      const declaration = program.body[0].declarations[0];
      assert.equal(declaration.id.name, 'baz');
      // or babelTraverse(program, {visitor: ...})
    });
    

    通过校验AST的结构(上面的示例是特定节点),来断言插件功能是否正确。

    执行测试

    AST转换为代码,执行转换后的代码,借此判断插件是否正确。示例代码如下

    it('foo is an alias to baz', () => {
      var input = `
        var foo = 1;
        // test that foo was renamed to baz
        var res = baz;
      `;
      var {code} = babel.transform(input, {plugins: [plugin]});
      var f = new Function(`
        ${code};
        return res;
      `);
      var res = f();
      assert(res === 1, 'res is 1');
    });
    
    babel-plugin-tester

    使用babel-plugin-tester会简化babel插件的测试,具体使用可以参考相关的文档,下面是一个简单的示例

    import pluginTester from 'babel-plugin-tester';
    import identifierReversePlugin from '../identifier-reverse-plugin';
    
    pluginTester({
      plugin: identifierReversePlugin,
      fixtures: path.join(__dirname, '__fixtures__'),
      tests: {
        'does not change code with no identifiers': '"hello";',
        'changes this code': {
          code: 'var hello = "hi";',
          output: 'var olleh = "hi";',
        },
        'using fixtures files': {
          fixture: 'changed.js',
          outputFixture: 'changed-output.js',
        },
        'using jest snapshots': {
          code: `
            function sayHi(person) {
              return 'Hello ' + person + '!'
            }
          `,
          snapshot: true,
        },
      },
    });
    

    至此,Babel handbook基本翻译结束,里面可能会有一些错漏,感兴趣的读者可以点击这里查看原文。

    相关文章

      网友评论

          本文标题:Babel - 应用

          本文链接:https://www.haomeiwen.com/subject/shgcsrtx.html