之前发了一个有关 JavaScript 中立即执行函数(IIFE)的想法,但还想多写一点东西。立即执行函数是 JavaScript 中很特殊的一种用法。由于以前 JavaScript 原生没有成熟的模块化机制,立即执行函数曾被广泛地用于编写独立的模块和库,以防止全局变量的名称冲突。但随着 ES6 标准对于模块化和块级作用域支持的完善,这种用法也逐渐退出了历史舞台,顶多出现在被 Polyfill 转译的代码里。
在网上的讨论中,提到立即执行函数的作用,基本上都是说用于隔离作用域,防止全局命名空间冲突等。但实践中我们往往忽视了它的另一个作用,即将任意 statement(语句) 转化为 expression(表达式) 的能力。
statement 与 expression
在学习编程时,我们就学到过 statement 和 expression 这两个概念,但是当时我完全没有在意这些偏理论性的名词,因为觉得直接看代码就很直观了,不讨论这些名词也不妨碍我写代码。一直到代码经验比较多了,且接触了各种不同语言的语法和一些奇技淫巧,才逐渐意识到区分这两者的重要性。
为了写这篇文章,我特意 Google 了一下 statement 和 expression 的准确定义,但发现网上也是众说纷纭,有把它们作为两个对等的概念的,也有说是包含关系的。不过抛开诸多定义上的细节不同,大家对两者的差别还是有共识的:
expression 是一段执行以后会返回一个值的代码,而 statement 只是执行一段逻辑,并不产生一个返回值。
在大多数语言中,expression 主要包括数字、字符串等字面量以及连接它们的运算符,而 statement 会包含一些赋值与控制流语句。例如:
// expressions:
> 1 + 2
> "expression"
> Math.sqrt(2)
> x * x
// statements:
> let x = 3;
> if () {}
> for () {}
> while () {}
将 statement 转化为 expression
让我们关注一个常见的使用场景:
switch (id) {
case 1:
this.name = (...some logic)
break
case 2:
this.name = (...some other logic)
break
default:
this.name = (...default logic)
break
}
这段代码根据 id
的值不同,执行不同的逻辑,并将计算后的值赋给 this.name
变量。switch
语句是典型的 statement ,它根据值的不同进入不同的分支。但很多时候每个分支的逻辑是类似的,例如上面的例子,我们计算的结果最终都是为了赋值,那这个赋值的操作就被重复了多次,引起了代码冗余,很不优雅。
有些语言中支持一种名为 switch expression 的语法,顾名思义,这种语法支持将 switch
语句变成一个 expression,执行完后返回一个值。用这种语法改写后的代码形如:(伪代码)
this.name =
switch (id) {
case 1: (...some logic)
case 2: (...some other logic)
default: (...default logic)
}
可以看到确实简洁了很多。
JavaScript 目前并不支持这样的做法,但通过立即执行函数,我们可以变通地实现这一功能。
this.name = (() => {
switch (id) {
case 1: return (...some logic)
case 2: return (...some other logic)
default: return (...default logic)
}
})()
得益于它是一个函数,不仅是 switch
语句,条件判断、循环等语句,甚至是更加复杂的逻辑,都可以使用这种方式转化为一个 expression 。
这样做有意义吗?
从功能的角度,确实原本的 switch
语句功能也完全够用了,要说代码冗余也不是很严重,这么做并没有特别明显的好处。但是从代码的优雅与可读性的角度,改写成 expression 的形式确实更好一些。
一个佐证是 Java 在 JDK 12 中也增加了对 switch expression 的支持。作为一门被广泛使用的工业级语言,Java 并不一直追求最时髦的语言特性,甚至常常被人诟病语法繁琐臃肿。连 Java 中都如此高优先级地对这一语法做了支持,足以说明它在程序员中的呼声是很大的。
// Java 12 的 switch expression
var today = switch (day) {
case SAT, SUN: break "Weekend day";
case MON, TUS, WED, THU, FRI: break "Working day";
default: throw new IllegalArgumentException("Invalid day: " + day.name());
};
在 JavaScript 中,除了与其他语言相同的应用场景之外,还有一个场合可能会用到立即执行函数封装后的 expression,那就是 React 的 JSX 语法。我们经常需要根据条件渲染 JSX 元素,但在 JSX 中混入逻辑代码时,代码的内容必须为 expression,这时我们就可以用上这个技巧。
<div>
{(() => {
switch (name) {
case "A": return <CompA />;
case "B": return <CompB />;
case "C": return <CompC />;
default: return <CompA />
}
})()}
</div>
从更宏观的角度来说,这实际上是需不需要将任何语句都设计为 expression 的问题。将代码分为 statement 和 expression 并不是理所当然的设计。例如,在函数式编程中,就没有 statement,只有 expression,通过 expression 的串联使代码更为简洁流畅。
有些面向对象的语言也借鉴了这一思路,例如 Ruby 中几乎一切都是 expression,连类定义和方法定义也不例外。即使在传统的 OO 设计中,也涌现出了 Fluent interface 之类的方式,通过将 setter 改写为返回上下文的 expression 来实现优雅的流式接口调用。
我们会在下文中简单介绍几种其他将 statement 转换为 expression 的方式。
为什么是匿名函数?
回到 JavaScript,也许有人会说,这没什么特别的,完全可以把逻辑抽取到一个独立的函数中来实现相同的效果。
function getNameById(id) {
switch (id) {
case 1: return (...some logic)
case 2: return (...some other logic)
default: return (...default logic)
}
}
this.name = getNameById(id)
这样做当然可以。一般来说,如果是多次出现的可复用的逻辑,都是建议单独抽取成函数,以减少代码重复。但函数也并不是拆分得越细越好的。如果这段逻辑与上下文关联比较紧密,拆分的成本会比较高,而且在阅读代码时需要上下文来回切换,理解多个参数之间的对应关系,反而降低了代码的可读性。
函数的命名也是一个经常困扰开发者的问题。
总的来说,虽然使用匿名函数实现的原理和拆分函数是一样的,但是在这里我们不要过于纠结它的本质有多么高大上,而是充分利用 JavaScript 提供的简洁语法,将其作为一种语法糖来使用。(可以类比同样 JavaScript 中的 !!
语法,虽然从原理上来说并没有什么特别之处,但也经常出现在 x 个不为人知的 JavaScript 小技巧中。)
迁移到其他语言
其他语言中没有 JavaScript 的立即执行函数语法,那么它们能借鉴这一思路实现类似的功能吗?也并不一定不行。这里我们给出一个低版本的 Java 语言实现:
final int id = 1;
String name = (new Callable<String>() {
@Override
public String call() {
switch (id) {
case 1:
return (...some logic);
case 2:
return (...some other logic);
default:
return (...default logic);
}
}
}).call();
我们使用 Java 中的匿名内部类来实现将 statement 转化为 expression 。Callable
老实说在 Java 中并不是很常用,可以理解为拥有了返回一个值的能力的 Runnable
。
可以对比一下它与 JavaScript 版本的区别。在 Java 版本中,匿名内部类的语法相对比较繁冗,虽然可以利用 Java 8 的 lambda 进一步简化,但终究省不了一些方法名和关键字的出现,而这会对阅读代码产生一定的视觉干扰;而 JavaScript 的立即执行函数配合箭头函数,全是由符号组成的,我们在阅读代码时可以很轻松地将其省略,聚焦到核心的逻辑上。
另外 Java 中匿名内部类引用外部变量时,由于 Java 执行机制的限制,我们必须要把使用到的外部变量声明为 final
即不可变的。这也势必导致使用这种方法修改代码的成本变高,更加麻烦。
再次,Java 的匿名内部类在编译之后都会成为一个单独的类,在运行时也有类加载、内存等的成本,总的来说还是比较重的,因此一般也不会去大量使用。
其他语言中有没有类似的做法,我就不太清楚了。也许比不上 JavaScript 中那么简洁,不过本身也不失为一种有趣的用法,有兴趣的同学可以在项目中尝试一下。
Bonus:再谈将 statement 转化为 expression
虽然本文主要讨论了立即执行函数的用途,但倘若抛开这一用法,“将 statement 转化为 expression”这一目的本身,也不失为一个让代码更加优雅的重构手段。而在 JavaScript 中,为了实现这一点,使用立即执行函数并不是唯一的方法。
考虑一个常见的功能,将一个配置数组转化为 key-value 的对象:
const config = array.reduce((obj, item) => {
obj[item.prop] = item.value
return obj
}, {})
我们很容易想到,使用 reduce
函数可以简洁地实现这一功能,但 reduce
函数的内部却要手动 return obj
,显得十分碍眼。其本质原因就是此处赋值表达式返回的是 obj[item.prop]
,而不是 obj
本身。
那么有进一步简化这段代码的方法吗?一种方法是借助现成的函数,例如 Object.assign
或解构赋值:
const config = array.reduce(
(obj, item) => Object.assign(obj, { [item.prop]: item.value }),
{}
)
另一种方法是借助一个更冷门的语法——逗号表达式。我们可以把一串表达式用逗号连接起来,它们会从左到右执行,并把最后一个表达式的返回值作为整体的返回值。在这个例子中,我们只需要:
const config = array.reduce(
(obj, item) => (obj[item.prop] = item.value, obj),
{}
)
不过这个语法有点 tricky 了,也不太容易看明白它的意图。总而言之我们需要根据不同的场景去选取合适的写法。