JavaScript需要更多的迭代辅助函数

32 阅读13分钟

JavaScript需要更多用于迭代的辅助函数(如mapfilter等) - 我们应该将它们放在哪里?

迭代是一种将操作与数据容器连接起来的标准:遵循此标准的每个操作都可以应用于实现此标准的每个数据容器。

在这篇博文中:

JavaScript迭代及其怪癖

什么是迭代?

迭代在ECMAScript 6中添加到JavaScript中。迭代有两个方面的协议(接口和使用它们的规则):

  • 数据生成器(如数据结构)可以实现迭代协议,并通过它来公开其输出(或内容)。
  • 数据消费者(如算法)可以通过迭代协议检索其输入。

实现迭代协议的数据生成器称为“可迭代对象”。该术语也用作形容词:“可迭代的数据结构”。

迭代的一个关键好处是,使用迭代的每个数据消费者都可以与每个可迭代的数据生成器一起使用。

JavaScript标准库已经有几个基于迭代的数据生成器和数据消费者 - 例如:

  • 数据生成器:
    • 数组,映射(Maps),集合(Sets),字符串
    • array.keys()的结果(不是数组的可迭代对象)
    • map.entries()的结果(不是数组的可迭代对象)
  • 数据消费者:
    • for-of
    • Array.from()
    • 扩展到数组中([...input]
    • 扩展到函数调用中(func(...input)

然而,遗憾的是,JavaScript尚不支持许多基于迭代的算法。以下是一些有用的示例辅助函数:

  • map:列出在可迭代对象的每个值上调用回调的结果。
  • filter:列出在可迭代对象中回调返回true的所有值。
  • forEach:对可迭代对象的每个值调用回调。

mapfilter的输入和输出都是可迭代的,这意味着我们可以链接这些操作。

核心迭代实体:可迭代对象和迭代器

迭代协议中最重要的两个实体是:

  • 可迭代对象(Iterable):这个实体是容纳数据的容器。它通过成为迭代器的工厂来公开这些数据。
  • 迭代器(Iterator):这个实体通过一个方法返回可迭代对象中包含的每个值,每次一个(类似于数据库中的光标)。

对象obj通过实现一个方法来成为可迭代对象:

  • obj[Symbol.iterator]():这个方法返回迭代器。

一个迭代器(iterator)是一个通过一个方法交付值的对象:

  • iter.next():这个方法返回一个具有两个属性的对象:
    • .value:包含当前值
    • .done:只要还有值就是false,之后是true

这是在实践中使用迭代的样子:

const iterable = ['a', 'b'];
const iterator = iterable[Symbol.iterator]();
iterator.next(); // { value: 'a', done: false }
iterator.next(); // { value: 'b', done: false }
iterator.next(); // { value: undefined, done: true }

同时也是可迭代的迭代器

在实现可迭代对象时,常见的技术是将可迭代对象同时变成迭代器:

function iterArgs(...args) {
  let index = 0;
  const iterable = {
    [Symbol.iterator]() {
      return this; // (A)
    },
    next() {
      if (index >= args.length) {
        return {done: true};
      }
      const value = args[index];
      index++;
      return {value, done: false};
    }
  };
  return iterable;
}

const iterable1 = iterArgs('a', 'b', 'c');
console.log([...iterable1]); // ['a', 'b', 'c']

在A行,我们不返回一个新对象,而是返回this

这种技术有三个好处:

  1. 代码变得更简单。
  2. 我们可以迭代迭代器。
  3. 可以使用生成器函数和方法来同时实现可迭代对象和迭代器。

首先我们来看好处2,然后再看好处3。

我们可以迭代可迭代的迭代器

在以下代码中,我们可以在A行开始的迭代中继续迭代:

const iterable2 = iterArgs('a', 'b', 'c');
const iterator2 = iterable2[Symbol.iterator]();

const firstItem = iterator2.next().value; // (A)
console.log(firstItem); // 'a'

const remainingItems = [...iterator2]; // (B)
console.log(remainingItems); // ['b', 'c']

生成器返回可迭代的迭代器,可以同时实现可迭代对象和迭代器

JavaScript标准库创建的所有迭代器都是可迭代的。生成器返回的对象也同时是迭代器和可迭代对象。

因此,我们可以使用生成器来实现可

迭代的迭代器,如下所示:

function* iterArgs(...args) {
  for (const arg of args) {
    yield arg;
  }
}

const iterable3 = iterArgs('a', 'b', 'c');
console.log([...iterable3]); // ['a', 'b', 'c']

在此代码中,iterArgs函数是一个生成器函数。它既返回一个可迭代对象,又返回一个迭代器。

改进.map()、.filter()和.forEach()

以下是JavaScript标准库的.map()、.filter()和.forEach()方法的完整定义:

Array.prototype.map = function(callback /*, thisArg*/) {
  const result = [];
  let thisArg = arguments[1];
  for (let i = 0; i < this.length; i++) {
    if (!this.hasOwnProperty(i)) continue;
    result[i] = callback.call(thisArg, this[i], i, this);
  }
  return result;
};

Array.prototype.filter = function(callback /*, thisArg*/) {
  const result = [];
  let thisArg = arguments[1];
  for (let i = 0; i < this.length; i++) {
    if (!this.hasOwnProperty(i)) continue;
    if (callback.call(thisArg, this[i], i, this)) result.push(this[i]);
  }
  return result;
};

Array.prototype.forEach = function(callback /*, thisArg*/) {
  let thisArg = arguments[1];
  for (let i = 0; i < this.length; i++) {
    if (!this.hasOwnProperty(i)) continue;
    callback.call(thisArg, this[i], i, this);
  }
};

我们来看其中一个方法,例如Array.prototype.map。其基本思想是:

  1. 创建一个新数组(result)。
  2. 使用回调函数(callback)和可选的this参数(thisArg)对数组的每个元素进行操作。
  3. 将回调函数的结果添加到新数组中。
  4. 返回新数组。

尽管这些方法很有用,但它们都有一些问题。以下是一些可能的怪癖:

  • 如果不显式指定thisArg,回调函数将在全局作用域中运行。
  • 回调函数将在数组的每个元素上调用,包括稀疏数组中的未定义元素。
  • 这些方法不允许中止迭代。
  • 这些方法返回一个新数组,而不是原始数组的视图。
  • 如果数组是大型的,这些方法会创建和返回一个新数组,这可能会导致性能问题。

现在我们来看看如何改进这些方法。

更好的.map()、.filter()和.forEach()

以下是更好的.map().filter().forEach()方法的实现:

Array.prototype.betterMap = function(callback, thisArg) {
  const result = new this.constructor(this.length);
  for (let i = 0; i < this.length; i++) {
    if (!this.hasOwnProperty(i)) continue;
    result[i] = callback.call(thisArg, this[i], i, this);
  }
  return result;
};

Array.prototype.betterFilter = function(callback, thisArg) {
  const result = new this.constructor();
  let resultIndex = 0;
  for (let i = 0; i < this.length; i++) {
    if (!this.hasOwnProperty(i)) continue;
    if (callback.call(thisArg, this[i], i, this)) {
      result[resultIndex] = this[i];
      resultIndex++;
    }
  }
  return result;
};

Array.prototype.betterForEach = function(callback, thisArg) {
  for (let i = 0; i < this.length; i++) {
    if (!this.hasOwnProperty(i)) continue;
    callback.call(thisArg, this[i], i, this);
  }
};

这些更好的方法与原始方法的不同之处在于:

  • 它们允许显式传递this参数(不使用arguments[1])。
  • 它们不在结果数组中创建未定义的条目,而是仅添加通过过滤的条目。
  • 它们返回的是原始数组的子类,而不是新的数组。
  • .betterFilter().betterForEach()中的result数组的大小不固定,根据过滤条件动态增长,因此不需要预先指定大小。

辅助函数的不同实现方式

我们已经看到了两种实现迭代协议的方法:

  • 将可迭代对象同时变成迭代器(本示例中用作工厂)
  • 使用生成器来同时实现可迭代对象和迭代器

但是,到底哪种方式更好呢?

这个问题有不同的回答:

  • 作为可迭代对象的方法:这种方式更简单,但会导致可迭代对象无法同时迭代。这对于某些算法来说是个问题。为了同时迭代可迭代对象,你需要创建多个迭代器,这可能会引发性能问题。
  • 作为函数的方法:这种方式在实现时更复杂,但它允许可迭代对象同时成为迭代器。这意味着你可以在不复制数据的情况下多次迭代。这对于大型数据集来说是有利的。

迭代的其他优势

迭代不仅仅有助于创建更好的.map().filter().forEach()方法。它还具有其他一些优势:

  • 延迟加载:迭代允许你延迟加载数据,只在需要时才生成数据。这对于大型数据集来说很有用,因为它可以减少内存使用。
  • 可组合性:由于所有可迭代对象和迭代器都遵循相同的协议,因此它们可以轻松组合。你可以将多个迭代器链接在一起,以便从多个数据源中获取数据。
  • 多线程支持:迭代可以轻松地在多线程环境中使用,因为它们不共享状态。这使得并行处理数据变得更加容易。

%IteratorPrototype%:标准库中所有迭代器的原型

在JavaScript标准库中,所有的迭代器都共享一个原型,即%IteratorPrototype%。这个原型包含了迭代器的next方法和Symbol.iterator方法。这是它的样子:

%IteratorPrototype% = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]()))

如果你想查看这个原型的属性和方法,可以使用Object.getOwnPropertyNames()

Object.getOwnPropertyNames(%IteratorPrototype%);

应该将可迭代对象的辅助函数放在哪里?

我们已经讨论了如何使用迭代,以及迭代的核心概念,现在让我们看看应该将可迭代对象的辅助函数放在哪里。在讨论不同方法之前,我们来看看这些辅助函数是如何工作的。

方法的方法

这是将辅助函数添加到可迭代对象的最直接方法。您只需在对象上定义一个方法,该方法以回调函数作为参数,然后在内部迭代对象的值并调用回调。

class MyIterable {
  constructor(values) {
    this.values = values;
  }

  map(callback) {
    const mapped = [];
    for (const value of this.values) {
      mapped.push(callback(value));
    }
    return new MyIterable(mapped);
  }
}

这种方法的好处是可读性高,因为它与可迭代对象直接关联。然而,它有一个局限性,即它会创建一个新的可迭代对象。这意味着你不能在原始对象上链接多个操作,而必须在每个操作后创建一个新的可迭代对象。

包装可迭代对象

另一种方法是创建一个新的可迭代对象,该对象包装了原始可迭代对象,并提供了额外的辅助函数。这可以通过在新对象上实现迭代协议来实现。

class IterableWrapper {
  constructor(iterable) {
    this.iterable = iterable;
  }

  [Symbol.iterator]() {
    const iterator = this.iterable[Symbol.iterator]();
    return {
      next() {
        const result = iterator.next();
        return { value: result.value, done: result.done };
      }
    };
  }

  map(callback) {
    const mapped = [];
    for (const value of this.iterable) {
      mapped.push(callback(value));
    }
    return new IterableWrapper(mapped);
  }
}

这种方法允许你在原始可迭代对象上链接多个操作,因为每个操作都返回包装器的新实例。但是,它可能会导致性能开销,因为每个操作都需要创建一个新的包装器对象。

为迭代器引入一个超类

另一种方法是创建一个迭代器的超类,该超类定义了所有辅助函数。然后,你可以在可迭代对象上创建迭代器的实例,并使用这些辅助函数。

class IteratorWithHelpers {
  constructor(iterable) {
    this.iterable = iterable;
    this.iterator = iterable[Symbol.iterator]();
  }

  next() {
    return this.iterator.next();
  }

  map(callback) {
    const mapped = [];
    for (const value of this.iterable) {
      mapped.push(callback(value));
    }
    return new IteratorWithHelpers(mapped);
  }
}

这种方法的好处是,你可以在原始可迭代对象上链接多个操作,而不必为每个操作创建新的对象。但是,它需要你为每个可迭代对象创建一个迭代器的实例。

函数的方法

最后,你还可以将辅助函数作为独立的函数提供,而不是绑定到可迭代对象或迭代器。这种方法灵活性最高,但可能会导致可读性下降。

function map(iterable, callback) {
  const mapped = [];
  for (const value of iterable) {
    mapped.push(callback(value));
  }
  return mapped;
}

这种方法最灵活,因为它不会引入任何新的对象或类,但它也可能会导致代码变得难以理解,特别是在链接多个操作时。

迭代和迭代辅助函数的其他好处

迭代和迭代辅助函数不仅仅在语法上使代码更整洁,还具有其他好处:

  • 更具可读性:使用for-of循环或迭代器方法通常比使用传统的for循环更容易理解和维护。

  • 模块化:迭代辅助函数允许你将复杂的操作拆分为较小的可重用部分,从而提高了代码的模块化性。

  • 可组合性:你可以轻松地组合不同的迭代辅助函数以执行复杂的操作

,而不必编写大量嵌套的代码。

  • 可测试性:由于辅助函数通常具有清晰的输入和输出,因此它们更容易进行单元测试,从而提高了代码的可测试性。

结论

迭代在现代JavaScript中起着重要作用,它使得处理数据变得更加简单和可读。迭代辅助函数(如mapfilterforEach)可以进一步简化代码,提高可读性,并促进代码的模块化和可组合性。

在将这些辅助函数添加到项目中时,你可以选择将它们作为可迭代对象的方法、包装可迭代对象、为迭代器引入一个超类,或者将它们作为独立的函数提供,具体取决于项目的需求和偏好。

如果更喜欢方法而不是函数

在某些情况下,你可能更喜欢使用方法链式调用,而不是函数式编程风格。如果你喜欢这种方法,你可以考虑使用类似于Lodash或Underscore.js这样的库,它们提供了大量的链式方法来处理集合。

基于模块的标准库?

尽管迭代和迭代辅助函数对于JavaScript非常有用,但它们尚未成为JavaScript的标准库的一部分。这意味着它们在不同的环境和项目中可能会有所不同。在一些项目中,你可能需要自己实现这些功能,或者使用第三方库来填补这一空白。

有关迭代的进一步阅读

希望这篇文章帮助你更好地理解JavaScript中的迭代和迭代辅助函数,以及如何将它们应用到你的项目中。如果你有任何问题或评论,请随时提出。