译:我觉得生成器的易用性正在逐渐吸引我

61 阅读7分钟

我喜欢 JavaScript 在过去十年里出现的“语法糖”,像箭头函数、模板字面量、解构赋值等,因为它们解决了很多实际痛点。但生成器函数有点特别,它虽然存在很久了,实用性却没那么流行。不过我至少有一次发现它很有用,还写过相关文章。我决定认真试试生成器,了解之后发现它在特定场景下的人体工程学和思维模型还挺不错的。下面我来详细说说。

迭代器和可迭代协议

要理解生成器,得先了解迭代器和可迭代这两个协议,它们都和产生不确定值序列有关,可迭代协议建立在迭代器协议之上。

迭代器协议

这个协议规定了返回序列的对象的样子和行为。只要一个对象有 next() 方法,这个方法返回一个包含 value(任意值)和 done(布尔值)两个属性的对象,那它就是迭代器。

下面是个简单例子:

const gospelIterator = {
  index: -1,
  next() {
    const gospels = ['Matthew', 'Mark', 'Luke', 'John'];
    this.index++;
    return {
      value: gospels.at(this.index),
      done: this.index + 1 > gospels.length,
    };
  },
};

gospelIterator.next(); // {value: 'Matthew', done: false}
gospelIterator.next(); // {value: 'Mark', done: false}
gospelIterator.next(); // {value: 'Luke', done: false}
gospelIterator.next(); // {value: 'John', done: false}
gospelIterator.next(); // {value: undefined, done: true}

迭代器的序列不一定要结束,无限迭代器也是可以的,比如:

const infiniteIterator = {
  count: 0,
  next() {
    this.count++;
    return { value: this.count, done: false };
  },
};
infiniteGenerator.next(); // 永远计数...

可迭代协议

如果一个对象有 [Symbol.iterator]() 方法,并且这个方法返回一个迭代器对象,那它就是可迭代的。我们用 for...of 循环或者解构数组的时候,就用到了这个协议。像 StringArrayMap 这些常见的类型都内置了这个协议。

我们自己也能实现可迭代协议,定义 for...of 循环的行为。看下面这个例子:

const gospelIteratable = {
  [Symbol.iterator]() {
    return {
      index: -1,
      next() {
        const gospels = ['Matthew', 'Mark', 'Luke', 'John'];
        this.index++;
        return {
          value: gospels.at(this.index),
          done: this.index + 1 > gospels.length,
        };
      },
    };
  },
};

现在 for...of 循环和解构都能正常工作了:

for (const author of gospelIteratable) {
  console.log(author); // Matthew, Mark, Luke, John
}

console.log([...gospelIteratable]); // ['Matthew', 'Mark', 'Luke', 'John']

再看一个不能用简单数组轻松模拟的例子,遍历 1900 年后的每个闰年:

function isLeapYear(year) {
  return year % 100 === 0 ? year % 400 === 0 : year % 4 === 0;
}

const leapYears = {
  [Symbol.iterator]() {
    return {
      startYear: 1900,
      currentYear: new Date().getFullYear(),
      next() {
        this.startYear++;
        while (!isLeapYear(this.startYear)) {
          this.startYear++;
        }
        return {
          value: this.startYear,
          done: this.startYear > this.currentYear,
        };
      },
    };
  },
};

for (const leapYear of leapYears) {
  console.log(leapYear);
}

这里不需要提前构建整个年份序列,所有状态都存储在可迭代对象里,下一个项目是按需计算的。

惰性求值

惰性求值是迭代器的一大优势。我们不需要一开始就获取序列里的每个项目,这样在某些情况下能避免性能问题。

还是看上面的 leapYears 可迭代对象,如果不用可迭代对象,用 for 循环和预构建的数组来处理,代码可能是这样:

const leapYears = [];
const startYear = 1900;
const currentYear = new Date().getFullYear();

for (let year = startYear + 1; year <= currentYear; year++) {
  if (isLeapYear(year)) {
    leapYears.push(year);
  }
}

for (const leapYear of leapYears) {
  console.log(leapYear);
}

这段代码虽然清晰,但要执行两个 for 循环,还得预先处理整个值列表。对于复杂计算或者大数据集,这样会更耗费资源。

比如:

for (const thing of getExpensiveThings(1000)) {
  // Do something important with thing.
}

如果没有自定义的可迭代对象,就得在循环前构建 1000 个项目的完整列表,会延长执行时间。

再比如,我们想根据出生年份找到一个人经历的第一个闰年,用预构建的数组可能会有一部分数据用不上:

function getFirstLeapYear(birthYear) {
  for (const leapYear of leapYears) {
    if (leapYear >= birthYear) return leapYear;
  }
  return null;
} 
// 仅计算到 1992 年的闰年:
getFirstLeapYear(1989) // 1992

通过生成器平滑处理协议

我们可以把上面的可迭代对象写成生成器函数,它会返回一个生成器对象。

function* generateGospels() {
  yield 'Matthew';
  yield 'Mark';
  yield 'Luke';
  yield 'John';
}

这里有两个关键的东西:function*yield 关键字。function* 把它标记为生成器函数,yield 可以看作是生成器被要求提供下一个值时的“暂停点”。

底层还是会调用 next() 方法,每次调用就会移动到下一个 yield 语句。

const generator = generateGospels();
console.log(generator.next()); // {value: 'Matthew', done: false}

for...of 循环也能正常工作:

for (const gospel of generateGospels()) {
  console.log(gospel);
} 
// Matthew 
// Mark 
// Luke 
// John

生成器可以是无限的,比如:

function* multipleGenerator(base) {
  let current = base;
  while (true) {
    yield current;
    current += base;
  }
}

虽然看起来是无限循环,但只要每次迭代之间有 yield,就不会锁住浏览器。

const multiplier = multipleGenerator(22);
multiplier.next(); // {value: 22, done: false}
multiplier.next(); // {value: 44, done: false}
multiplier.next(); // {value: 66, done: false}
//... no infinite loop!

不过要注意,生成器是同步执行的,可能会阻塞主线程,有方法可以避免这种情况,还有 AsyncGenerator 对象可以帮忙。

我开始欣赏它们的地方

生成器虽然没有什么突破性的能力,很多问题用其他常见方法也能解决,但我用得越多,就越欣赏它,主要有下面几个原因。

它们可以帮助减少紧密耦合

生成器和其他迭代器在封装自身和管理状态方面做得很好,能缓解组件之间的紧密耦合。

比如,点击按钮依次显示某个价格在过去五年中的移动平均值,一开始的代码可能是这样:

let windowStart = 0;

function calculateMovingAverage(values, windowSize) {
  const section = values.slice(windowStart, windowStart + windowSize);
  if (section.length < windowSize) return null;
  return section.reduce((sum, val) => sum + val, 0) / windowSize;
}

loadButton.addEventListener('click', function () {
  const avg = calculateMovingAverage(prices, 5);
  average.innerHTML = `Average: $${avg}`;
  windowStart++;
});

这段代码需要在高作用域中保留 windowStart 变量,事件监听器还要负责更新状态,而且在其他地方复用计算移动平均值的逻辑会很困难。

用生成器可以解决这些问题:

function* calculateMovingAverage(values, windowSize) {
  let windowStart = 0;
  while (windowStart <= values.length - 1) {
    const section = values.slice(windowStart, windowStart + windowSize);
    yield section.reduce((sum, val) => sum + val, 0) / windowSize;
    windowStart++;
  }
}

const generator = calculateMovingAverage(prices, 5);
loadButton.addEventListener('click', function () {
  const { value } = generator.next();
  average.innerHTML = `Average: $${value}`;
});

这样做有很多好处:

  • windowStart 变量只在需要的地方出现。
  • 状态和逻辑是自包含的,可以并行使用多个不同的生成器。
  • 每个部分的目的更明确,生成器负责数学和状态,点击处理程序只更新 DOM。

我们还可以进一步改进,让生成器只提供值,点击处理程序只处理值,双方不需要知道对方的细节:

for (const value of calculateMovingAverage(prices, 5)) {
  await new Promise((r) => {
    loadButton.addEventListener(
      'click',
      function () {
        average.innerHTML = `Average: $${value}`;
        r();
      },
      { once: true }
    );
  });
}

它们可以帮助避免一些“烦人”的小事

以前我们做一些事情的时候,经常会用到递归、回调等方法,虽然这些方法本身没错,但用起来有点麻烦。

比如,一个仪表板每秒显示最新的应用程序状态,有几种实现方法。

setInterval()

// ...一遍又一遍!
function monitorVitals(cb) {
  setInterval(async () => {
    const vitals = await requestVitals();
    cb(vitals);
  }, 1000);
}

monitorVitals((vitals) => {
  console.log('Update the UI...', vitals);
});

这种方法需要传递回调,可能会引发“回调地狱”,而且间隔时间不考虑请求数据的时间,可能会导致乱序问题。

setTimeout()

async function monitorVitals(cb) {
  const vitals = await requestVitals();
  cb(vitals);
  await new Promise((r) => {
    setTimeout(() => monitorVitals(cb), 1000);
  });
}

monitorVitals((vitals) => {
  console.log('Update the UI...', vitals);
});

这种方法还是需要传递回调。

while(){}

async function monitorVitals(cb) {
  while (true) {
    await new Promise((r) => setTimeout(r, 1000));
    const vitals = await requestVitals();
    cb(vitals);
  }
}

monitorVitals((vitals) => {
  console.log('Update the UI...', vitals);
});

这种方法同样需要传递回调。

异步生成器的引入

async function* generateVitals() {
  while (true) {
    const result = await requestVitals();
    await new Promise((r) => setTimeout(r, 1000));
    yield result;
  }
}

for await (const vitals of generateVitals()) {
  console.log('更新 UI...', vitals);
}

用异步生成器就没有那些问题了,没有时间风险,没有递归,也没有回调,不同的关注点能很好地分开。

再比如获取分页资源的所有项目,原来的代码可能是这样:

async function fetchAllItems() {
  let currentPage = 1;
  let hasMore = true;
  let items = [];
  while (hasMore) {
    const data = await requestFromApi(currentPage);
    hasMore = data.hasMore;
    currentPage++;
    items = items.concat(data.items);
  }
  return items;
}

这种方法有很多辅助变量,还得拼接列表,而且要等 API 全部获取完才能做其他操作。

用异步生成器可以改进:

async function* fetchAllItems() {
  let currentPage = 1;
  while (true) {
    const data = await requestFromApi(currentPage);
    if (!data.hasMore) return;
    currentPage++;
    yield data.items;
  }
}

for await (const items of fetchAllItems()) {
  // 做一些事情。
}

这样辅助变量更少,处理开始前的时间更短,关注点也能很好地分开。

它们使得实时生成项目集合变得非常方便

因为生成器是可迭代的,所以可以像数组一样被解构。如果需要生成各种批次的东西,用生成器很简单。

function* getElements(tagName = 'div') {
  while (true) yield document.createElement(tagName);
}

现在可以直接解构:

const [el1, el2, el3] = getElements('div');

我们拭目以待

我现在对生成器很欣赏,但不知道这种热情能持续多久。不过就算热情消退了,我也很高兴有了更多的实践经验。知道怎么用这个工具很有价值,更重要的是,它让我重新思考处理问题的方式,这个回报率还是挺不错的。

英文原文