一步步地教你用babel实现一个js可选链的语法转译

3,090 阅读10分钟

在写需求的时候,发现队友在项目里大量使用 可选链(Optional chaining) 这个特性,几乎只要遇到对象属性取值就用一下,导致我看代码到处都是 ?

const x = null
const y = x?.a

可能有的小伙伴没听说过这个概念,我大概说下,js可选链大概就是上面的意思,?.共同构成可选链的符号,正常情况下,如果去掉可选链符号,那么上述代码肯定会报错 但是如果使用了可选链,代码就不会报错了,并且 y会被赋值为 undefined,关于可选链更多的信息网上已经有很多了,我就不多说了,推荐一篇写得比较好的 精读《Optional chaining》

js可选链的特性,虽然已经在 stage 4 阶段了,但目前哪怕是最新版谷歌浏览器都还不默认支持,所以肯定是需要 babel转译,而一般情况下,babel转译后的代码都会比原始代码体积大一些,那么作为一个曾经有着资深移动端开发经验(雾)的我,下意识地就考虑到那么多的可选链都要转译,会使最终项目的体积增大多少呢?

于是,立马专门去 babel 在线编译了一下,编译出来的内容如下:

"use strict";

var x = null;
var y = x === null || x === void 0 ? void 0 : x.a;

字符数相比于源代码增长了大概三倍,确实会对最终项目的体积产生影响,不过考虑到为了保证项目代码正常运行,必要的判空逻辑是需要的,只不过如果能避免滥用当然更好了

这个时候我又突然想到,这个转译是 babel完成后,如果这个过程让我来自行实现,我该怎么做呢?

对比了一下 babel转译前后的代码,发现其实是有规律的,只要遇到 ?.符号,就把它转成一个三元表达式

如果被取值的对象全等于 null 或者 全等于 void 0,那么三元表达式直接返回 void 0(也就是 undefined),否则就返回从这个被取值对象上取到的值

虽然看起来 ?.符号只会判断被取值对象是 null 或是 void 0 这两种情况,但实际上,因为存在隐式转换,所以这就足以应对任何类型的取值了,不明白的多想一下就明白了( ̄▽ ̄)"

既然知道了规律,那么紧接着而来的问题就很清晰了,如何完成这个转换过程呢?

如何识别源码中的 ?. 符号,并且将之转换成正确的三元表达式?

单纯对源码字符进行替换理论上当然是可行的,但很明显还有更好的办法,那就是先将源码字符转换成 AST,然后操作这个 AST,最后再把处理好的 AST转换会源码字符,这才是基操

那么,如何把源码字符转换成 AST

理论上你当然可以自己写个转换插件,但没必要,因为 babel已经为我们提供了这种插件

@babel/parser 用于将源码字符转换成 AST@babel/generator 用于将 AST 转换成源码字符

第一步,将源码字符转换成 AST

@babel/parser!

const { parse } = require('@babel/parser')

const code = `
const x = null
const y = x?.a
`
const ast = parse(source)

得到的 ast就是 code代表的源代码转换后的 ast对象,通过断点调试可以看到 ast是一个结构比较规律的对象:

只要遍历这个 ast对象,就能获取到这个 ast结构上我们想要的节点,你可以根据 ast的结构自行编写代码遍历它,但 bable已经提供了这种遍历的插件:@babel/traverse

因为我们这里只关心 js可选链的转换,而 js可选链在 babel中的节点类型 typeOptionalMemberExpression,所以有如下代码,对 ast进行遍历:

const { parse } = require('@babel/parser')
const traverse = require('@babel/traverse').default

const code = `
const x = null
const y = x?.a
`
const ast = parse(source)
traverse(ast, {
  OptionalMemberExpression(path) {
    console.log(path)
  }
})

path 存储了遍历到的节点所在 ast结构的信息,也就是 x?.a 转换成 ast的一些结构信息

那么接下来只需要把这个结构改成三元表达式的结构就行了

但是三元表达式的结构是什么样的?

你可以先手写个转换后的三元表达式代码,然后再使用 @babel/parser 将其转换成 ast,观察其与可选链 ast结构之间的差别,然后将可选链的 ast 结构修改成三元表达式的

不过也不必这么麻烦,因为已经有了一个 ast 在线编译查看的网站:astexplorer,强烈推荐刚学习 ast 的同学好好利用这个网站

在这个网站上,可以即时编译 ast,并且可视化地查看 ast结构,可以说是非常方便了,虽然相比于 @babel/parser 转换后的 ast对象来说,astexplorer 转换后的 ast缺失了一些东西,但我们需要的主体内容都还是存在的,并不影响使用

从这个网站上可以看到,对于以下三元表达式

x === null || x === void 0 ? void 0 : x.a

ast结构为:

?.typeOptionalMemberExpression,转换成三元表达式对应了三个 ExpressionLogicalExpressionUnaryExpressionMemberExpression,这三个 Expression分别对应了三元表达式的三个 expression

LogicalExpression及其子 Expression加起来对应三元表达式的第一个 expressionx === null || x === void 0UnaryExpression及其子 Expression加起来对应三元表达式的第二个 expressionvoid 0MemberExpression对应三元表达式的第三个 expressionx.a

那么这里就遇到如何构建 Expression的问题了,babel提供了 @babel/types 来解决这个问题

根据 astexplorer 的可视化结果,我们知道三元表达式的 typeConditionalExpression,所以顶层 ast就是一个 ConditionalExpression的节点:

const transCondition = node => {
  return t.conditionalExpression(
  )
}

根据 @babel/types 提供的 conditionalexpression 的方法文档,得知此方法所接收的三个参数

三个参数都是 Expression类型,正好对应上面我们说的三元表达式的三个 Expression,于是代码可以写成:

const transCondition = node => {
  return t.conditionalExpression(
    t.logicalExpression(),
    t.unaryExpression(),
    t.memberExpression()
  )
}

继续查询 t.logicalExpression()t.unaryExpression()t.memberExpression()这三个方法的文档,最终可以得到完整的方法:

const transCondition = node => {
  return t.conditionalExpression(
    t.logicalExpression(
      '||',
      t.binaryExpression('===', node.object, t.nullLiteral()),
      t.binaryExpression('===', node.object, t.unaryExpression('void', t.numericLiteral(0)))
    ),
    t.unaryExpression('void', t.numericLiteral(0)),
    t.memberExpression(node.object, node.property, node.computed, node.optional)
  )
}

经过 transCondition方法处理后的 ast 结构就是一个三元表达式的结构,接下来需要用这个三元表达式的 ast替换掉 可选链的 ast

babel也已经提供了很多用于操作 ast的方法,其中就包括替换的方法 replaceWith

替换好了 ast之后,再通过 @babel/generator 将这个 ast 结构转换成源码字符,就是最终我们需要的转译结果了

完整代码如下:

const { parse } = require('@babel/parser')
const traverse = require('@babel/traverse').default
const generator = require('@babel/generator').default
const t = require('@babel/types')

const code = `
const x = null
const y = x?.a
`

const transCondition = node => {
  return t.conditionalExpression(
    t.logicalExpression(
      '||',
      t.binaryExpression('===', node.object, t.nullLiteral()),
      t.binaryExpression('===', node.object, t.unaryExpression('void', t.numericLiteral(0)))
    ),
    t.unaryExpression('void', t.numericLiteral(0)),
    t.memberExpression(node.object, node.property, node.computed, node.optional)
  )
}

const ast = parse(source)
traverse(ast, {
  OptionalMemberExpression(path) {
    path.replaceWith(transCondition(path.node, path))
  }
})
console.log(generator(ast).code)

输入的 generator(ast).code 如下:

const x = null;
const y = x === null || x === void 0 ? void 0 : x.a;

除了?.之外,其他不相干的东西都不关心,所以没有转换 const

完成了?

我一开也是这么认为,后来当我把 const y = x?.a 换成 const y = x?.a?.b?.c的之后,就发现问题了,const y = x?.a?.b?.c转换后的代码是:

const x = null;
const y = ((x === null || x === void 0 ? void 0 : x.a) === null || (x === null || x === void 0 ? void 0 : x.a) === void 0 ? void 0 : (x === null || x === void 0 ? void 0 : x.a).b) === null || ((x === null || x === void 0 ? void 0 : x.a) === null || (x === null || x === void 0 ? void 0 : x.a) === void 0 ? void 0 : (x === null || x === void 0 ? void 0 : x.a).b) === void 0 ? void 0 : ((x === null || x === void 0 ? void 0 : x.a) === null || (x === null || x === void 0 ? void 0 : x.a) === void 0 ? void 0 : (x === null || x === void 0 ? void 0 : x.a).b).c;

怎么这么长?

如果我再继续往下取属性,岂不是还要更更更长?难道根本就不止增加三倍代码那么多?

赶紧去 babel 在线编译了一下,发现果然是我想得简单了:

"use strict";

var _x$a, _x$a$b;

var x = null;
var y = x === null || x === void 0 ? void 0 : (_x$a = x.a) === null || _x$a === void 0 ? void 0 : (_x$a$b = _x$a.b) === null || _x$a$b === void 0 ? void 0 : _x$a$b.c;

当取值的深度不止一层时,babel会自行添加额外的变量,用来存储上一级取值表达式的结果,然后继续对这个变量而不是上一级取值的表达式进行取值,这个做法不仅显著缩短了代码量,同时也避免了很多重复计算

我们可以按照这个思路继续改造代码

这个时候需要考虑将一个连续的取值操作当成整体来看待,而不是上面进行单独截取的思路,也就是,对于 x?.a?.b?.c 这个可选链取值表达式来说,不应该将其拆分成 x?.a(x?.a)?.b(x?.a?.b)?.c来单独处理,因为这会丢失上下文,无法准确定义额外的变量

那么就需要我们在进入一个可选链的顶级结构的时候,自行递归处理这个顶级结构下所有子级的可选链结构

const transCondition = (node, path) => {
  if (node.type !== 'OptionalMemberExpression') {
    return node
  }
  const expression1 = transCondition(node.object, path)
  const alternate = t.memberExpression(expression1, node.property, node.computed, node.optional)
  const res = t.conditionalExpression(
    t.logicalExpression(
      '||',
      t.binaryExpression('===', expression1, t.nullLiteral()),
      t.binaryExpression('===', expression1, t.unaryExpression('void', t.numericLiteral(0)))
    ),
    t.unaryExpression('void', t.numericLiteral(0)),
    alternate
  )
  return res
}

因为是自行递归遍历顶级可选链 ast结构下的子级可选链,所以 transCondition方法中需要实现这个递归遍历处理的逻辑,只要子级的类型不再是 OptionalMemberExpression,则跳出递归

只是修改 transCondition这个方法,其他逻辑不变,运行代码,发现编译后的结果和之前的不变,也是一长串的三元表达式冗余嵌套

下面开始设置额外的辅助变量

对于

var _x$a, _x$a$b;

babel 如何用 ast来描述这段赋值代码呢?

查看文档得知,@babel/types 提供了 variableDeclaration用于定义变量

例如,对于上述代码来说,定义的代码如下:

t.variableDeclaration('var', [t.variableDeclarator(t.identifier('_x$a')), t.variableDeclarator(t.identifier('_x$a$b'))])

代码定义好了,那么还需要把定义好的代码插入到源代码中

@babel/traverse 提供了一个 insertBefore的方法,用于在当前 ast路径前插入额外的 Expression

按照 babel的做法,额外变量的变量名跟 path也就是当前这一条 js语句相关,变量名是由当前可选链取值属性之前的对象属性拼凑得到,这个没什么特殊含义,只是为了方便代码可读以及避免变量冲突的一个规则,我们这里就按着这个规则来

变量定义好了,那么如何给变量赋值呢?

这里变量的值,实际上就应该是当前可选链属性之前取到的对象值,例如对于 x?.a?.b来说,假设当前解析到了 b,那么其前面定义值的变量就是 _x$a

这里需要注意 babel的解析顺序问题,对于:

x?.a?.b?.c

babel的解析结构是从后往前嵌套的,例如 x?.(a?.b?.c),即 (x?.a?.b)?.c,先把 x?.a?.b当做一个整体,对这个整体取属性 c的值,然后再将 x?.a当做一个整体,取 b的值

本文只考虑 OptionalMemberExpression类型的处理,对于 OptionalCallExpression类型的,也就是类似于 a?.b()a?.(),以及其他一些特殊场景就不考虑了,因为原理都是类似的,感兴趣的可以自己尝试实现下

最终代码如下:

// optional-chaining-loader.js
const { parse } = require('@babel/parser')
const traverse = require('@babel/traverse').default
const generator = require('@babel/generator').default
const t = require('@babel/types')

// 实际使用时,这行代码应该是从文件中读取的实际代码,这里只是做个演示
const code = `
a?.b?.[c]?.[++d]?.[e++].r
`
const optinal2member = (node, extraList = []) => {
  if (!node.object) {
    return node
  }
  let list = []
  while (node && !node.optional) {
    list.push(node)
    node = node.object
  }
  if (node) {
    list.push(node)
    node = node.object
  }
  return joinMemberExpression(extraList.concat(list, node ? joinVariable(node) : []))
}
const separator = '$'
const singlePrefix = '_'
const updateExpressionType = 'UpdateExpression'
const joinVariable = node => {
  let variabelStr = ''
  let localNode = node
  while (localNode.object) {
    const name = localNode.property.type === updateExpressionType ? localNode.property.argument.name : localNode.property.name
    variabelStr = variabelStr ? (name + separator + variabelStr) : name
    localNode = localNode.object
  }
  variabelStr = singlePrefix + localNode.name + (variabelStr || (separator + variabelStr))
  return variabelStr
}
const joinMemberExpression = list => {
  const top = list.pop()
  let parentObject = typeof top === 'string' ? t.identifier(top) : top
  while (list.length) {
    const object = list.pop()
    parentObject = t.memberExpression(parentObject, object.property, object.computed)
  }
  return parentObject
}
const transCondition = ({ node, path, expression = null, variabelList = [], memberExpression = [] }) => {
  if (!node) {
    return expression
  }
  if (!node.optional) {
    return transCondition({
      node: node.object,
      path,
      expression,
      variabelList,
      memberExpression: memberExpression.concat(node)
    })
  }
  const extraVariable = t.identifier(joinVariable(node.object))
  variabelList.unshift(t.variableDeclarator(extraVariable))
  const res = t.conditionalExpression(
    t.logicalExpression(
      '||',
      t.binaryExpression('===', t.assignmentExpression('=', extraVariable, optinal2member(node.object)), t.nullLiteral()),
      t.binaryExpression('===', extraVariable, t.unaryExpression('void', t.numericLiteral(0)))
    ),
    t.unaryExpression('void', t.numericLiteral(0)),
    expression || optinal2member(node, memberExpression)
  )
  
  if (node.object.object) {
    return transCondition({ node: node.object, path, expression: res, variabelList })
  }
  path.insertBefore(t.variableDeclaration('var', variabelList))
  return res
}

function transOptinal(source) {
  const ast = parse(source, {
    plugins: [
      'optionalChaining',
    ]
  })
  traverse(ast, {
    OptionalMemberExpression(path) {
      path.replaceWith(transCondition({ node: path.node, path }))
    }
  })
  return generator(ast).code
}

const parseCode = transOptinal(code)
console.log(parseCode)

对于 a?.b?.[c]?.[++d]?.[e++].r 这行代码来说,运行上述代码,输出结果如下:

var _a$, _ab, _ab$c, _ab$c$d;

(_a$ = a) === null || _a$ === void 0 ? void 0 : (_ab = _a$.b) === null || _ab === void 0 ? void 0 : (_ab$c = _ab[c]) === null || _ab$c === void 0 ? void 0 : (_ab$c$d = _ab$c[++d]) === null || _ab$c$d === void 0 ? void 0 : _ab$c$d[e++].r;

插件代码写好了,如何使用呢?

最简单的就是把这份代码当做是 webpackloader,在其他的所有 js处理 loader之前加载

// optional-chaining-loader.js
function transOptinal(source) {
  const ast = parse(source, {
    plugins: [
      'optionalChaining',
    ]
  })
  traverse(ast, {
    OptionalMemberExpression(path) {
      path.replaceWith(transCondition({ node: path.node, path }))
    }
  })
  return generator(ast).code
}
module.exports = transOptinal
// webpack.config.js
module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: path.resolve(__dirname, 'optional-chaining-loader.js')
          }
        ]
      }
    ]
  }
}

了解了如何利用 babel转译代码之后,你就可以尽情地对你的代码做任何处理了,例如,在打包的时候清理掉代码里的所有 console.log,你甚至可以自己创造一个语法,然后自己写插件来转译(虽然一般情况下没人会这么干╮( ̄▽ ̄)╭)

当然,本文只是为了介绍 babel的这个用法,并不建议你真的自己去写转译插件,对于本文描述的 Optional chainingbabel官网早就提供了转译插件 @babel/plugin-proposal-optional-chaining,已经有的东西就没必要自己再去写一遍了

有轮堪用直须用,莫待无轮自己造 - 鲁迅