babel-plugin-macros:babel的另一种配置方式

3,319 阅读6分钟

翻译引用了文章 Babel macros

前言

对于现代的前端项目而言,webpack和babel是两个无法回避的工具。

相比于webpack的热度,以及webpack配置工程师等热门岗位,babel要默默无闻很多,但这并不是说它就不重要。

@babel/polyfill的核心依赖core.js的下载量,远超webpack、react等热门库。

babel配置

babel的作用可以看成是一个编译器,将一些浏览器不能支持的语法进行转换,使得这些语法能够在浏览器中正常的运行。

对于不同的语法,babel提供了对应的plugin来进行语法转换,还可以通过preset来把plugin进行统一处理,简化babel的配置。

babel的配置文件,就是由这些特定的plugin和preset组成的,一旦进行了配置,babel就会自动对语法进行转换。

存在的问题

使用这种方法进行配置和代码转化,所有的转化都是全局性的和隐式的,并且对于一些特定功能的自定义语法和函数无法支持。

举个栗子

optional-chaining提案被采纳之前,我们也会有很多方法来处理类似props?.user?.friends?.[0]?.friend的属性取值

最普通的写法,不易于阅读,但是最有效

const firstFriend =
  props.user && props.user.friends && props.user.friends[0]
    ? props.user.friends[0].friend
    : null;

// or 使用三元运算符
const firstFriend = props
  ? props.user
    ? props.user.friends
      ? props.user.friends
        ? props.user.friends[0]
          ? props.user.friends[0].friend
          : null
        : null
      : null
    : null
  : null;

易于书写,易于阅读,但是会增加运行时的开销

function idx(input, accessor) {
  try {
    return accessor(input);
  } catch (e) {
    return null;
  }
}

const firstFriend = idx(props, _ => _.user.friends[0].friend);

是否有一种方式,能够拥有上面两种方式的优势,既易于书写易于阅读,同时也不会因为try-catch增加运行时的开销。

facebookincubator/idx给出了一种思路,它使用babel plugin来分析哪些文件使用require或者import对idx进行了引用,然后对使用了idx的地方进行替换,下面是一个具体的例子

Install

 npm install idx babel-plugin-idx
 
 or
 
 yarn add idx babel-plugin-idx
 
 // 修改babel配置文件
 {
  plugins: [
    ["babel-plugin-idx"]
  ]
}

Usage

import idx from 'idx';

function getFriends() {
  return idx(props, _ => _.user.friends[0].friends);
}

最终的代码会被转换为

function getFriends() {
  return props.user == null
    ? props.user
    : props.user.friends == null
    ? props.user.friends
    : props.user.friends[0] == null
    ? props.user.friends[0]
    : props.user.friends[0].friends;
}

上面的方案基本上达到了书写简单、阅读简单,同时没有增加运行时开销。但是存在一些问题:

  • 首先得写一个babel plugin
  • 需要一个特殊的标记符来定位需要进行转换的语法
  • 需要更新babel的配置文件
  • 如果idx函数存在问题,对于问题的定位会造成混乱。按照使用的语法,你会去找idx的定义文件,但是idx只是babel plugin的一个特殊标记,真正的代码在babel-plugin-idx中

Babel macros

什么是Babel macros

Babel macros 是babel-plugin-macros中出现的概念,可以理解为一种babel的宏。它是编译时代码转换成运行时代码的一个标准接口。

在babel进行编译的时候,babel-plugin-macros会对所有的模块进行遍历,查找以.macro结尾的引用,并将这些引用传递给.marco文件进行代码转换,用转换完的代码替换原来的引用。

如果宏是默认导出的,那么可以按照自己喜欢的方式对引用进行命名;如果是命名导出,也可以使用as语法重新命名。

使用Babel macros来处理idx

Install

npm install babel-plugin-macros --save-dev

// babel配置
{
  plugins: ['babel-plugin-macros'],
};

macro

// src/utils/idx.macro.js

const { createMacro } = require('babel-plugin-macros');
module.exports = createMacro(({ state, references }) => {
  references.default.forEach(referencePath => {
    idx_transform(referencePath.parentPath, state);
  });
});

use

// src/index.js

import idx from './utils/idx.macro';

function getFriends() {
  return idx(props, _ => _.user.friends[0].friends);
}

社区中已经有idx对应的marco,idx.macro

babel cache 引发的问题

在实际的项目中,我们会对babel-loader开启cache功能,所有使用babel-loader的文件进行缓存,以加快webpack编译的速度。

对于使用babel marco的文件,cache也是生效的。如果marco不是一个纯函数,编译的结果可能会和预期的结果有出入。

比如一个处理json文件的宏,目标的json文件已经修改了,但是使用宏的文件没有做任何代码的改动,那么还是会使用cache中的代码,即上一次解析未改动的json文件产生的结果。

解决方法可以直接关闭babel-loader的缓存功能,但是这个可能会影响项目的编译速度;第二种是在使用macro的地方增加一个注释,来告诉babel-loader这个地方需要重新编译。

如何编写一个marco

babel-plugin-marcos提供了一个实现宏的规范,但它还是基于babel来实现的。所以要开发一个marco,要对babel有一定的了解。

首先要熟悉babel,了解如何编写一个babel插件 babel插件手册

babel-plugin-marcos提供了官方的marco开发手册,hand book for macros authors

这里实现一个try-catch的marco

try-catch.macro

在项目开发中经常会遇到一些错误没有抛出,导致无法定位问题。希望通过一种简单的方法来对函数进行try catch包装。

通过babel-plugin-marcos来进行按需的转换,而不是通过babel的插件进行全局的替换。

装饰器语法对于单纯的函数没法处理,所以这里就选用了flow的语法。使用flow的语法来对需要转换的方法进行标记,在语法方法可能不太符合flow的标准。

因为使用了flow的语法,所以要安装对应的preset,npm install --save-dev @babel/preset-flow。这里对于flow的使用可能不符合规范,只是为了简化操作。

// marco.js
const { createMacro } = require('babel-plugin-macros');

function macro({ babel, references, state }) {
  const { types, template } = babel;
  
  // babel的模板函数,生成最终的被try catch包裹的函数体
  const wrapFunctionWithTryCatch = template(`{
    try {
      BODY
    } catch(e) {
      console.error(e);
    }
  }`);

  references.TryCatch.forEach(reference => {
    const parentFunction = reference.getFunctionParent();

    if (!parentFunction) {
      return;
    }

    const body = parentFunction.node.body.body;

    if (!body || !body.length) {
      return;
    }
    
    // 对于已经被try catch包裹的函数不做处理
    if (body.length === 1 && types.isTryStatement(body[0])) {
      return;
    }

    // 使用模板函数替换老的函数体
    parentFunction.get('body').replaceWith(wrapFunctionWithTryCatch({
      BODY: body,
    }));

  });
}

module.exports = createMacro(macro)

marco需要是一个js的模块,通过babel-plugin-macros导出的createMacro方法对转换函数进行包裹并导出。

macro函数接受一个对象,其中三个常用的属性是babel、state和references。

  • babel就是babel-plugin-macros模块,包含了babel的所有功能
  • state是常规的babel插件中visitor的第二个参数
  • references是模块中对marco的所有引用

最终效果

import { TryCatch } from 'try-catch.macro';

var a = (b): TryCatch => {
  return b;
}

class Test {

  print = (): TryCatch => {
    const data = this.getData();

    return data;
  }
}

    ↓ ↓ ↓ ↓ ↓ ↓

var a = (b) => {
  try {
    return b;
  } catch(e) {
    console.error(e);
  }
}

class Test {

  print = () => {
    try {
      const data = this.getData();

      return data;
    } catch(e) {
      console.log(e);
    }
  }
}

github仓库是 try-catch.macro

总结

babel-plugin-marcos给我们提供了另一种代码转换的思路,通过显式的引用实现代码的按需转换。

通过规范化的接口定义,保证了marco的开发规范。

通过编译时的代码转换,在某些场景下能够减少代码运行时的开销。

社区也有很多高质量的宏开源出来,基本上所有的css in js的方案都提供了对应的marco,减少了依赖的安装,代码更加直观。

链接

Babel macros

babel插件手册

hand book for macros authors

awesome-babel-macros