12k
All articles

JavaScript 中的方法链:优点与缺点

JavaScript方法链:了解其工作原理、何时提升可读性,以及长链何时会影响调试、异步流程和性能。

OpenReplay Team
OpenReplay Team
JavaScript 中的方法链:优点与缺点

方法链(Method chaining)是指在同一个对象上依次调用多个方法,它之所以可行,是因为每个方法都返回了一个仍然具有可调用方法的对象。

如果你曾经盯着一条六步的链式调用,它悄无声息地返回了 undefined,而你却找不到明显的地方设置断点,那么你已经体会到了这种取舍。写点号很廉价,而拆解它们却很昂贵。对于数组和字符串这类内置方法,返回值本身携带了下一个方法;而对于你自己的对象,每个方法都以 return this 结尾。链式调用是一种可读性工具,而非默认选择。它在短流水线上物有所值,但在长链条上就开始让你付出代价。本文将介绍其机制、真正的优点、实际存在的缺点(调试摩擦、无谓的开销、混乱的异步逻辑)、如何正确构建自己的可链式对象,以及一条关于何时该停下的具体规则。

核心要点

  • 方法链之所以有效,是因为每个方法都返回了一个带有更多方法的对象;内置方法返回一个携带下一个方法的新值,而自定义对象则通过在每个方法末尾使用 return this 来实现链式调用。
  • 链式调用本身的性能开销微乎其微;真正的成本在于额外的遍历和内存分配,例如 .filter().map()[0] 会对数组进行两次完整遍历,而 .find() 只需一次并在首个匹配项处停止。
  • 用箭头函数编写的方法会破坏链式调用,因为它没有自己的 this,因此永远不会指向实例,而且 callbindapply 也无法改变这一点。
  • 一条实用规则:一步总是可以的,两步通常没问题,三到四步应该让你警觉,五步及以上就应该拆分为具名步骤。
  • 链式调用优化的是编写速度;而为中间值命名优化的是后续的阅读与调试,这两者并非同一个目标。

什么是方法链,它是如何工作的?

方法链之所以有效,是因为每个方法都返回了一个仍然具有可调用方法的对象。内置的数组和字符串方法会返回一个新值(一个数组、一个字符串),该值自带方法,因此你可以继续往下写:

const topNames = users
  .filter(user => user.active)
  .map(user => user.name)
  .sort();

filter 返回一个数组,所以 map 可用;map 返回一个数组,所以 sort 可用。对于你自己的对象,你可以通过在每个方法中返回实例来复现这一点:

class QueryBuilder {
  constructor() { this.parts = []; }
  where(clause) { this.parts.push(`WHERE ${clause}`); return this; }
  limit(n) { this.parts.push(`LIMIT ${n}`); return this; }
  build() { return this.parts.join(" "); }
}

new QueryBuilder().where("active = 1").limit(5).build();
// "WHERE active = 1 LIMIT 5"

由于 wherelimit 返回 this,下一个方法便会在同一个实例上解析。这与驱动流式(fluent)构建器 API 的原理相同。

优点:可读的流水线与流式 API

链式调用最出色的场景是在步骤构成一个清晰变换的短流水线中。它按从左到右的顺序阅读(先 filter,再 map,然后 sort),并且避免了为你再也不会引用的一次性中间变量命名。对于两步变换,链式调用往往是最直接的意图表达:

const activeNames = users.filter(u => u.active).map(u => u.name);

流式 API 和构建器 API 依靠同样的机制,读起来像句子一样:query.where(...).limit(...).build()expect(value).to.be.an('array')。当整条链描述的是单一连贯的操作时,这种语法确实在为读者创造价值。

缺点:调试、无谓的开销与混乱的异步

链式调用的代价会在链条变长、混合关注点,或者掩盖了实际工作量时显现出来。这些正是不应默认使用链式调用的理由。

调试摩擦。 最难调试的链是那些产生了错误最终值的链,因为在不拆解链条的情况下,没有一个自然的位置来设置断点或打印中间值。你最终不得不把 console.log 塞进回调里,把调试代码和业务逻辑混在一起,或者反正还是要把链拆成多个步骤。在生产环境的前端代码中,这是一种常见的故障模式:你能看到错误的输出,但看不出是哪一步产生的。会话回放(Session replay)在此能派上用场:重放产生错误状态的那次交互,能让你重新获得被折叠的链所隐藏的输入——这正是把链拆分为具名步骤本该暴露出的信息。

无谓的开销。 链式调用会促使你倾向于「处理所有内容」,即使这并非你的本意。.filter().map()[0] 会对数组进行两次完整遍历,并分配一个中间数组,然后丢弃除一个元素之外的全部结果。当你只想要第一个匹配项时,Array.prototype.find() 才是正确的工具。它只会遍历数组直到回调接受某个元素为止,返回该元素,然后不再继续:

const name = users.find(u => u.active)?.name;

返回类型不透明。 在像 data.transform().normalize().validate().save() 这样的长链中,你必须在运行时没有任何类型注解的情况下追踪每一步返回什么。当中间某一步返回了意料之外的东西时,整条链的形态就会悄然改变。

混乱的异步。 在同一个 .then() 链中混合异步控制流与数据变换会模糊意图。将获取和解析与变换分开,读起来更清晰:

const res = await fetchUsers();
const users = await res.json();
const activeNames = users.filter(u => u.active).map(u => u.name);

这是可读性上的判断,而非性能上的判断。await.then() 做的是同样的工作。

性能:点号是免费的,遍历不是

链式调用本身的固有性能开销微乎其微:每一步一次方法调用加一次属性访问,相比遍历一个集合的开销可以忽略不计。真正让你付出代价的是做了超出需要的工作。.filter().map()[0] 是两次完整的 O(n) 遍历再加一个中间数组;find() 则是一次遍历且提前终止。这里的启示是:数遍历次数和内存分配,而不是数点号。一条遍历数据一次的五方法链可以比一条遍历数据两次的两方法链更快。只要你只需要第一个符合条件的结果,就应该使用像 findsome 这样具备短路特性的方法。

如何构建自己的可链式 API?

要让一个对象支持链式调用,就在每个应该延续链条的方法中返回 this。有一个必然会破坏它的坑,就是把方法写成箭头函数。箭头函数永远不会获得自己的 this;它借用的是箭头函数被书写时其外层代码所拥有的 this,因此 return this 会交还错误的对象,而且通过 callbindapply 传递该函数也无法改变这一点。

const counter = {
  count: 0,
  // Broken: arrow `this` is the enclosing scope, not `counter`
  incArrow: () => { this.count++; return this; },
  // Correct: method shorthand binds `this` to the instance
  inc() { this.count++; return this; }
};

counter.inc().inc(); // works, count === 2

类和原型都支持同样的模式。如果你更愿意把状态声明为类字段parts = [])而不是在构造函数中赋值,该语法自 ES2022 起已成为标准。原型版本的行为完全一致:

// Prototype form — identical behavior
function Query() { this.parts = []; }
Query.prototype.where = function (c) { this.parts.push(c); return this; };

新代码请使用 class;而原型形式值得了解,以便应对较老的代码库,并理解 class 语法糖背后的实现。

关于链长的经验法则

链式调用优化的是编写速度;而为中间值命名优化的是后续的阅读与调试,这两者并非同一个目标。关于链长的一条实用规则:

链长建议做法原因
1 步放心使用链式调用没什么需要理清的
2 步通常没问题仍是一个清晰的变换
3–4 步暂停;考虑为中间值命名可读性和断点可及性开始变差
5 步及以上拆分为具名步骤返回类型和关注点难以追踪

当你正在积极调试、某一步的返回类型不明确,或者链条把异步控制流与数据变换混在一起时,就把链拆开。并且,只要你只想要一个结果,就优先使用具备短路特性的 findsome,而不是先 filter 再取索引。

当各步骤读起来像一个变换且链条保持简短时,就用链式调用;一旦链条超过三到四步,或者开始做超出你要求的工作,就立刻为中间值命名。下次当一条链越过这条界线时,把它拆开。将来阅读这段代码的你,会花更少时间解读,花更多时间修复问题。

常见问题

方法链比分别调用方法更慢吗?

不会。链式调用的固有开销微乎其微,因为每一步的一次方法调用加一次属性访问,相比遍历一个集合来说可以忽略不计。性能取决于你对数据进行了多少次遍历和内存分配,而不是点号的数量。一条只遍历数据一次的链,可能比遍历数据两次的分离调用更快。

为什么把方法写成箭头函数会破坏链式调用?

箭头函数永远不会获得自己的 'this'。它借用的是其周围代码的 'this',因此 'return this' 会交还错误的对象,下一个方法也就没有有效的目标可供解析。通过 call、bind 或 apply 传递该函数同样无法修复这个问题,因为这些方法无法给箭头函数赋予新的 'this'。请使用方法简写或普通函数,这样 'this' 才会绑定到实例上。

什么时候应该用 find() 而不是 filter().map()[0]?

只要你只想要第一个匹配的元素,就使用 find()。Array.prototype.find() 只会遍历数组直到回调接受某个元素,然后返回该元素并不再继续,因此它只进行一次遍历。相比之下,filter().map()[0] 会对数组进行两次完整遍历,并在丢弃除第一项之外的所有内容之前分配一个中间数组。当你只需要一个布尔值时,'some' 方法同样适用这种短路逻辑。

用 .then() 链式调用 Promise 的性能比使用 await 更差吗?

不会。'.then()' 链和 'await' 底层做的是同样的工作,所以区别在于可读性,而不是性能。链式调用 '.then()' 往往会在一个序列中把异步控制流和数据变换混在一起,从而模糊意图。用 'await' 将获取和解析与变换分开通常读起来更清晰,但两种方式在速度上并无可测量的差异。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.