在写需求的时候,发现队友在项目里大量使用 可选链(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
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
中的节点类型 type 是 OptionalMemberExpression,所以有如下代码,对 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
结构为:

?.
的 type
是 OptionalMemberExpression
,转换成三元表达式对应了三个 Expression
:LogicalExpression
、UnaryExpression
、MemberExpression
,这三个 Expression
分别对应了三元表达式的三个 expression
LogicalExpression
及其子 Expression
加起来对应三元表达式的第一个 expression
:x === null || x === void 0
;UnaryExpression
及其子 Expression
加起来对应三元表达式的第二个 expression
: void 0
;MemberExpression
对应三元表达式的第三个 expression
:x.a
那么这里就遇到如何构建 Expression
的问题了,babel
提供了 @babel/types 来解决这个问题
根据 astexplorer 的可视化结果,我们知道三元表达式的 type
是 ConditionalExpression
,所以顶层 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;
插件代码写好了,如何使用呢?
最简单的就是把这份代码当做是 webpack
的 loader
,在其他的所有 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 chaining
, babel
官网早就提供了转译插件 @babel/plugin-proposal-optional-chaining,已经有的东西就没必要自己再去写一遍了
有轮堪用直须用,莫待无轮自己造 - 鲁迅