精读《用 Babel 创造自定义 JS 语法》

1 引言

在写此次精读以前,我想谈谈前端精读能够为读者带来哪些价值,以及如何评判这些价值。javascript

前端精读已经写到第 123 篇了,你们已经没必要担忧它忽然中止更新,由于我已养成每周写一篇文章的习惯,而读者也养成了每周看一篇的习惯。因此我想说的实际上是一种更有生命力的自媒体运做方式,按期更新。一个按期更新的专栏比一个不不按期更新的专栏更有活力,也更受读者喜好,由于读者能看到文章之间的联系,跟随做者一块儿成长。我的学习也是如此,养成按期学习的习惯,比在培训班突击几个月更有用,学会在生活中规律的学习,甚至好过读几年名牌大学。前端

前端精读想带给读者的不只是一篇篇具体的内容和知识,知识是无穷无尽的,几万篇文章也说不完,但前端精读一直沿用了“引言-概述-精读-总结”这套学习模式,不管是前端任何领域的问题,仍是对人生和世界的思考均可以套用,但愿能为读者提供一套学习思惟框架,让你能学习到如何找到好的文章,以及如何解读它。vue

至今已经选择了许多源码解读的题材,与培训思惟的源码解读不一样,我但愿你不要带着面试的目的学习源码,由于这样会让你只局限在 react、vue 这种热门的框架上。前端精读选取的框架类型之因此普遍,是但愿你能静下心来,吸收不一样框架风格与做者的优点,培养一种优雅编码的气质。java

进入正题,此次选择的文章 《用 Babel 创造自定义 JS 语法》 也是培养编码气质的一类文章,虽然对你实际工做用处不大,但这篇文章能够培养几个程序员求之不得的能力:深刻理解 Babel、深刻理解框架拓展机制。理解一个复杂系统或培养框架思惟不是一朝一夕的,但持续阅读这种文章可让你愈来愈接近掌握它。node

之因此选择 Babel,是由于 Babel 处理的一直是语法树相关的底层逻辑,编译原理是程序世界的基座之一,拥有很大的学习价值。因此咱们的目的并非像文章标题说的 - 创造一个自定义 JS 语法,由于你创造的语法只会让 JS 复杂体系更加混乱,但可让你理解 Babel 解析标准 JS 语法的原理,以及看待新语法提案时,拥有从实现层面思考的能力。react

最后,没必要多说,能重温 Babel 经典的插件机制,你能够发现 Babel 的插件拓展机制和 Antrl4 很像,在设计业务模块拓展方案时也能够做为参考。git

2 概述

咱们要利用 Babel 实现 function @@ 的新语法,用 @@ 装饰的函数会自动柯里化:程序员

// '@@' makes the function `foo` curried
function @@ foo(a, b, c) {
  return a + b + c;
}
console.log(foo(1, 2)(3)); // 6
复制代码

能够看到,function @@ foo 描述的函数 foo 支持 foo(1, 2)(3) 这种柯里化调用。github

实现方式分为两步:面试

  1. Fork babel 源码。
  2. 建立一个 babel 转换器插件。

不要畏惧这些步骤,“若是你读完了这篇文章,你将成为同事眼中的 Babel 大神” - 原文。

首先 Fork babel 源码到本地,执行下面的命令能够初始化并编译 babel:

$ make bootstrap
$ make build
复制代码

babel 使用 Makefile 执行编译命令,而且采用 monorepo 管理,咱们此次要关心的是 package/babel-parser 这个模块。

词法

首先要了解词法知识,更详细的能够阅读原文或精读以前的一篇系列文章:精读《词法分析》

要解析语法,首先要进行词法分析。任何语法输入都是一个字符串,好比 function @@ foo(a, b, c),词法分析就是要将这个长度为 24 的字符拆分为一个个有语义的单词片断:function @@ foo ( a ..

因为 @@ 是咱们创造的语法,因此咱们第一个任务就是让 babel 词法分析能够识别它。

下面是 package/babel-parser 的文件结构:

- src/
  - tokenizer/
  - parser/
  - plugins/
    - jsx/
    - typescript/
    - flow/
    - ...
- test/
复制代码

能够看到,分为词法分析 tokenizer,语法分析 parser,以及支持一些特殊语法的插件,以及测试用例 test

推荐使用 Test-driven development (TDD) - 测试驱动开发的方式,就是先写测试用例,再根据测试用例开发。这种开发方式在后端或者 babel 这种底层框架很常见,由于 TDD 方式开发的逻辑能保证测试用例 100% 覆盖,同时先看测试用例也是个很好的切面编程思惟。

// packages/babel-parser/test/curry-function.js

import { parse } from '../lib';

function getParser(code) {
  return () => parse(code, { sourceType: 'module' });
}

describe('curry function syntax', function() {
  it('should parse', function() {
    expect(getParser(`function @@ foo() {}`)()).toMatchSnapshot();
  });
});
复制代码

能够利用 jest 直接测试这段代码:

BABEL_ENV=test node_modules/.bin/jest -u packages/babel-parser/test/c
复制代码

结果会出现以下报错:

SyntaxError: Unexpected token (1:9)

at Parser.raise (packages/babel-parser/src/parser/location.js:39:63)
at Parser.raise [as unexpected] (packages/babel-parser/src/parser/util.js:133:16)
at Parser.unexpected [as parseIdentifierName] (packages/babel-parser/src/parser/expression.js:2090:18)
at Parser.parseIdentifierName [as parseIdentifier] (packages/babel-parser/src/parser/expression.js:2052:23)
at Parser.parseIdentifier (packages/babel-pars
复制代码

第 9 个字符就是 @,说明程序如今还不支持函数前面的 @ 解析。咱们还能够在错误堆栈中找到报错位置,并把当前 Token 与下一个 Token 打印出来:

// packages/babel-parser/src/parser/expression.js

parseIdentifierName(pos: number, liberal?: boolean): string {
  if (this.match(tt.name)) {
    // ...
  } else {
    console.log(this.state.type); // current token
    console.log(this.lookahead().type); // next token
    throw this.unexpected();
  }
}
复制代码

this.state.type 表明当前 Token,this.lookahead().type 表示下一个 Token。lookahead 是词法分析的专有词,表示向后查看。打印以后,咱们会发现输出了两个 @ Token:

TokenType {
  label: '@',
  // ...
}
复制代码

下一步,咱们须要让 babel 词法分析识别 @@ 这个 Token。首先须要注册这个 Token:

// packages/babel-parser/src/tokenizer/types.js

export const types: { [name: string]: TokenType } = {
  // ...
  at: new TokenType('@'),
  atat: new TokenType('@@'),
};
复制代码

注册了以后,咱们要在遍历 Token 时增长判断 “若是当前字符是 @ 且下一个字符也是 @,则总体构成了 @@ Token 而且光标向后移动两格”:

// packages/babel-parser/src/tokenizer/index.js

getTokenFromCode(code: number): void {
  switch (code) {
    // ...
    case charCodes.atSign:
      // if the next character is a `@`
      if (this.input.charCodeAt(this.state.pos + 1) === charCodes.atSign) {
        // create `tt.atat` instead
        this.finishOp(tt.atat, 2);
      } else {
        this.finishOp(tt.at, 1);
      }
      return;
    // ...
  }
}
复制代码

再次运行测试文件,输出变成了:

// current token
TokenType {
  label: '@@',
  // ...
}

// next token
TokenType {
  label: 'name',
  // ...
}
复制代码

到这一步,已经能正确解析 @@ Token 了。

语法

词法已经能够将 @@ 解析为 atat Token,下一步咱们就要利用这个 Token,让生成的 AST 结构中包含柯里化函数的信息,并利用 babel 插件在解析时实现柯里化功能。

首先咱们能够在 Babel AST explorer 看到 AST 解析的结构,咱们拿 generator 函数测试,由于这个函数结构与柯里化函数相似:

能够看到,babel 经过 generator async 属性来标识函数是否为 generator 或者 async 函数。同理,增长一个 curry 属性就能够实现第一步了:

要实现如上效果,只需在词法分析 parser/statement 文件的 parseFunction 处新增 atat 解析便可:

// packages/babel-parser/src/parser/statement.js

export default class StatementParser extends ExpressionParser {
  // ...
  parseFunction<T: N.NormalFunction>(
    node: T,
    statement?: number = FUNC_NO_FLAGS,
    isAsync?: boolean = false
  ): T {
    // ...
    node.generator = this.eat(tt.star);
    node.curry = this.eat(tt.atat);
  }
}
复制代码

eat 是吃掉的意思,实际上能够理解为吞掉这个 Token,这样作有两个效果:1. 为函数添加了 curry 属性 2. 吞掉了 @@ 标识,保证全部 Token 都被识别是 AST 解析正确的必要条件。

关于递归降低语法分析的更多知识,能够参考 精读《手写 SQL 编译器 - 语法分析》,或者阅读原文。

咱们再次执行测试函数,发现测试经过了,一切都在预料中。

babel 插件

如今咱们获得了标记了 curry 的 AST,那么最后须要一个 babel 解析插件,实现柯里化。

首先咱们经过修改 babel 源码的方式实现的效果,是能够转化为自定义 babel parser 插件的:

// babel-plugin-transformation-curry-function.js

import customParser from './custom-parser';

export default function ourBabelPlugin() {
  return {
    parserOverride(code, opts) {
      return customParser.parse(code, opts);
    },
  };
}
复制代码

这样就能够实现修改 babel 源码同样的效果,这也是作框架经常使用的插件机制。

其次咱们要理解如何实现柯里化。柯里化能够经过柯里函数包装后实现:

function currying(fn) {
  const numParamsRequired = fn.length;
  function curryFactory(params) {
    return function (...args) {
      const newParams = params.concat(args);
      if (newParams.length >= numParamsRequired) {
        return fn(...newParams);
      }
      return curryFactory(newParams);
    }
  }
  return curryFactory([]);
}

// from
function @@ foo(a, b, c) {
  return a + b + c;
}

// to
const foo = currying(function foo(a, b, c) {
  return a + b + c;
})
复制代码

柯里化函数经过构造参数数量相关的递归,当参数传入不足时返回一个新函数,并持久化以前传入的参数,最后当参数齐全后一次性调用函数。

咱们须要作的是,将 @@ foo 解析为 currying() 函数包裹后的新函数。

下面就是咱们熟悉的 babel 插件部分了:

// babel-plugin-transformation-curry-function.js

export default function ourBabelPlugin() {
  return {
    // ...
    visitor: {
      FunctionDeclaration(path) {
        if (path.get('curry').node) {
          // const foo = curry(function () { ... });
          path.node.curry = false;
          path.replaceWith(
            t.variableDeclaration('const', [
              t.variableDeclarator(
                t.identifier(path.get('id.name').node),
                t.callExpression(t.identifier('currying'), [
                  t.toExpression(path.node),
                ])
              ),
            ])
          );
        }
      },
    },
  };
}
复制代码

FunctionDeclaration 就是 AST 的 visit 钩子,这个钩子在执行到函数时被触发,咱们经过 path.get('curry') 拿到 柯里化函数,并利用 replaceWith 将这个函数构造为一个被 currying 函数包裹的新函数。

剩下最后一个问题:currying 函数源码放在哪里。

第一种方式,建立相似 babel-plugin-transformation-curry-function 这样的插件,在 babel 解析时将 currying 函数注册到全局,这是全局思惟的方案。

第二种是模块化解决方案,建立一个自定义的 @babel/helpers,注册一个 currying 标识:

// packages/babel-helpers/src/helpers.js
helpers.currying = helper("7.6.0")` export default function currying(fn) { const numParamsRequired = fn.length; function curryFactory(params) { return function (...args) { const newParams = params.concat(args); if (newParams.length >= numParamsRequired) { return fn(...newParams); } return curryFactory(newParams); } } return curryFactory([]); } `;
复制代码

在 visit 函数使用 addHelper 方式拿到 currying

path.replaceWith(
  t.variableDeclaration('const', [
    t.variableDeclarator(
      t.identifier(path.get('id.name').node),
      t.callExpression(this.addHelper("currying"), [
        t.toExpression(path.node),
      ])
    ),
  ])
);
复制代码

这样在 babel 转换后,就会自动 import helper,并引用 helper 中导出的 currying

最后原文末尾留下了一些延伸阅读内容,感兴趣的同窗能够 点击到原文

3 精读

读完这篇文章,相信你不只对 babel 插件有了更深入的认识,并且还掌握了如何为 js 添加新语法这种黑魔法。

我来帮你从 babel 这篇文章总结一些编程模型和知识点,借助 babel 创造自定义语法的实例,加深对它们的理解。

TDD

Test-driven development 即测试驱动的开发模式。

从文章的例子能够看出,创造一个新语法,能够先在测试用例先写上这个语法,经过执行测试命令经过报错堆栈一步步解决问题。这种方式开发可让测试覆盖率更高,目的更专一,更容易保障代码质量。

联想编程

联想编程不属于任何编程模型,但从简介的思路来看,做者把 “为 babel 建立一个新 js 语法” 看做一种探案式探索过程,经过错误堆栈和代码阅读,一步一步经过合理联想实现最终目的。

在 AST 那一节,还借助了 Babel AST explorer 工具查看 AST 结构,经过联想到 generator 函数找到相似的 AST 结构,并找到拓展 AST 的突破口。

随着解决问题的不一样,联想方式也不一样,若是可以触类旁通,对不一样场景都能合理的联想,才算是具有了技术专家的软素质。

词法、语法分析

词法、语法分析属于编译原理的知识,理解词法拆分、递归降低,能够帮助你技术走的更深。

不管是 Babel 插件的使用、仍是 Babel 增长自定义 JS 语法,都要具有基本编译原理知识。编译原理知识还能帮助你开发在线编辑器,作智能语法提示等等。

插件机制

以下是 babel 自定义 parser 的插件拓展方式:

export default function ourBabelPlugin() {
  return {
    parserOverride(code, opts) {
      return customParser.parse(code, opts);
    },
  };
}
复制代码

这只是插件拓展的一种,有申明式,也有命令式;有用 JS 书写的,也有用 JSON 书写的。babel 选择了经过对象方式拓展,是比较适合对 AST 结构统一处理的。

作框架首先要肯定接口规范,好比 parser,先按照接口规范实现一套官方解析,对接时按照接口进行对接,就能够天然而然被用户自定义插件替代了。

能够参考的文章: 精读《插件化思惟》

柯里化

柯里化是面试常常考察的一个知识点,咱们能学到的有两点:理解递归、理解如何将函数变成柯里化。

这里再拓展一下,咱们还能够想到 JS 尾递归优化。如何快速写一个支持尾递归的函数?

const fn = tailCallOptimize(() => {
  if ( /* xxx */ ) {
    fn()
  }
})
复制代码

经过封装 tailCallOptimize 函数,能够很方便的构造一个支持尾递归的函数,这个函数能够这么写:

export function tailCallOptimize<T>(f: T): T {
  let value: any;
  let active = false;
  const accumulated: any[] = [];
  return function accumulator(this: any) {
    accumulated.push(arguments);
    if (!active) {
      active = true;
      while (accumulated.length) {
        value = (f as any).apply(this, accumulated.shift());
      }
      active = false;
      return value;
    }
  };
}
复制代码

感兴趣的读者能够在评论里解释一下这个函数的原理。

AST visit

遍历 AST 树常采用的方案是作一个遍历器 visitor,因此在遍历过程当中进行拓展常采用 babel 这种方式:

return {
  // ...
  visitor: {
    FunctionDeclaration(path) {
      if (path.get('curry').node) {
        // const foo = curry(function () { ... });
        path.node.curry = false;
        path.replaceWith(
          t.variableDeclaration('const', [
            t.variableDeclarator(
              t.identifier(path.get('id.name').node),
              t.callExpression(t.identifier('currying'), [
                t.toExpression(path.node),
              ])
            ),
          ])
        );
      }
    },
  },
};
复制代码

visitor 下每个 key 名都是遍历过程当中的拓展点,好比上面的例子,咱们能够对函数定义位置进行拓展和改写。

内置函数注册

babel 提供了两种内置函数注册方式,一种相似 polyfill,在全局注册 window 级的变量,另外一种是模块化的方式。

除此以外,能够学习的是 babel 经过 this.addHelper("currying") 这种插件拓展方式,在编译后会自动从 helper 引入对应的模块,前提是 @babel/helper 须要注册 currying 这个 helper。

babel 将编译过程隐藏了起来,经过一些高度封装的函数调用,以较为语义化方式书写插件,这样写出来的代码也容易理解。

4 总结

《用 Babel 创造自定义 JS 语法》这篇文章虽说的是 babel 相关知识,但能够从中提取到许多通用知识,这就是如今还去理解 babel 的缘由。

从某个功能点为切面,走一遍框架的完整流程是一种高效的进阶学习方式,若是你也有看到相似这样的文章,欢迎推荐出来。

讨论地址是:精读《用 Babel 创造自定义 JS 语法》 · Issue #210 · dt-fe/weekly

若是你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证

相关文章
相关标签/搜索