立即执行函数,你或许没想到过的妙用

之前发了一个有关 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 了,也不太容易看明白它的意图。总而言之我们需要根据不同的场景去选取合适的写法。

TAGS:  JavaScript
正在加载,请稍候……