Puppeteerの非同期処理を理解する

[Increments × cyma (Ateam Inc.) Advent Calendar 2020](https://qiita.com/advent-calendar/2020/increments-cyma) の20日目は、株式会社エイチーム EC事業本部のリードエンジニア @ihsiek が担当します。

## はじめに

普段はアーキテクチャ設計やインフラ運用をメインで担当していますが、今年は副業も含め TypeScript + Node.js でコードを書く機会が増えました。
特にここ2カ月ほどは [Puppeteer](https://github.com/puppeteer/puppeteer) を使って、外部システムとの連携機能の実装に取り組んでいます。

Puppeteer は`async/await`を使った非同期処理が前提となっていますが、使い始めた当初は`await`への理解が浅く、不必要にネストが深くなったり、挙動が不安定になることがよくありました。
最近になって、ようやくこのあたりの挙動を理解できたので整理しがてら説明してみます。

## 対象者

* Puppeteerで`await`の挙動がわからず躓いている人

## `await`の挙動について

まずはメソッドを呼び出すたびに使う`await`の説明から。
`await`は名前の通り呼び出したメソッドから`Promise`が返されるのを待ちます。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/await

なので、以下のように書くと基本的には逐次処理になります。
**基本的に**とした理由は、次項で説明します。

```javascript:screenshot-full.js
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.emulate(puppeteer.devices['iPhone 6']);
  await page.goto('https://www.nytimes.com/');
  await page.screenshot({ path: 'full.png', fullPage: true });
  await browser.close();
```

### `await`の例外

実は[公式のAPIドキュメント](https://github.com/puppeteer/puppeteer/blob/v5.5.0/docs/api.md#pageclickselector-options)に以下の記載がありますが、`click()`については配慮が必要です。

> Bear in mind that if click() triggers a navigation event and there's a separate page.waitForNavigation() promise to be resolved, you may end up with a race condition that yields unexpected results. The correct pattern for click and wait for navigation is the following:

以下のように書いた場合、`click()`が完了したタイミングで`Promise`を返してしまうため、タイミングによっては`waitForNavigation()`を捕捉できず、実行途中で立ち往生する可能性があるというものです。

```javascript:悪い例
await page.click(selector, clickOptions);
const response = page.waitForNavigation(waitOptions);
```

以下のように`click()`と`waitForNavigation()`の呼び出しを並列化することで、予期しない挙動を防ぐことができます。

```javascript:良い例
const [response] = await Promise.all([
  page.waitForNavigation(waitOptions),
  page.click(selector, clickOptions),
]);
```

## うまくいかなかった方法

実装を進める中でいろんな実装を参考にしましたが、今思うといくつか間違った実装があったので紹介してみます。

### `await`を省略するために`Promise.all()`を使用する

文脈としては、`await`が多くて見苦しいのでどうにか見やすくしたいという質問への回答だったと思います。
`Promise.all()`は、引数に与えたメソッドがすべて`Promise`を解決した場合に`Promise`を返すので、`page.select()`や`page.click()`など、順不同で処理してもいいケースで利用するのは良いアイデアだと思います。

しかしながら、検索結果を表示した後にスクリーンショットを撮りたいというように、厳密に順番を守る必要がある場合には意図した結果が得られない可能性があります。

以下は極端な悪例ですが、ニューヨークタイムスのページを表示した後、スクリーンショットを撮る事例です。
「ページへ遷移する」、「スクリーンショットを撮る」、「ブラウザを閉じる」というイベントを並列に実行しているため、「ブラウザを閉じる」→「スクリーンショットを撮る」→「ページへ遷移する」の順で実行される可能性もあります。

```javascript:悪い例
  await Promise.all([
    page.goto('https://www.nytimes.com/'),
    page.screenshot({ path: 'full.png', fullPage: true }),
    browser.close(),
  ]);
```

また、1文字ずつのキー入力をエミュレートしている`page.type()`も並列実行すると、フォーカスが当たっているフォームに入力し続けるため、予期しない挙動になります。

```javascript
await page.type(selector1, value1);
await page.type(selector2, value2);
```

上の実装を

```javascript
await Promise.all([
  page.type(selector1, value1),
  page.type(selector2, value2),
]);
```

とすると挙動が不安定になるので、一度試してみてください。

### `await A.then(async () => await B)`で`Promise`を待つ

これはAの後にBを実行したいという文脈で出てきた書き方です。
前述のとおり、`await`の仕様を考えると不要であることがわかります。

私が実装した時にも、たしかに`await A.then(async () => await B)`とすることで挙動が安定した経験はありますが、これはおそらく`Promise`が返るタイミングを正しく把握できていなかったことが原因です。
ブロックを区切ったことで`Promise`が返るタイミングが間違っていても、なんとなく動いてしまっていただけだと思われます。

### テキストボックスをクリアする

検索条件に指定されている初期値をテキストボックスからクリアするのが目的でしたが、外部サービス側のJavaScriptで入力イベントを拾って裏で処理しているケースがあり意外と苦戦しました。

`''`を値として代入する方法では、表面上の値はクリアされるものの、フォームの値がクリアされず断念。
フォーカスインした後にキーボード操作で全選択して削除するパターンに落ち着きました。
トリプルクリックで全選択する方法もありますが、外部サービスの実装との相性が悪く断念しています。

```javascript:うまく動作した例
  await page.focus(selector);
  await page.keyboard.press('Home');
  await page.keyboard.down('Shift');
  await page.keyboard.press('End');
  await page.keyboard.up('Shift');
  await page.keyboard.press('Backspace');
```

## おわりに

結論、[puppeteer/examples](https://github.com/puppeteer/examples)に公式が用意した多くのサンプルがあるので、これを真似してみるのが慣れる近道ではあると思います。

アーキテクチャ設計などをやっていると実装を人に任せることが多く、アーキテクチャのウィークポイントや懸念点を知れないこともよくあるので、自分で書く機会を得られたのは非常にありがたかったです。
ようやく人並みにPuppeteerを使えるようになってきたので、次は動作の安定性を高める実装に取り組んでみようと思います。

---
[Increments × cyma (Ateam Inc.) Advent Calendar 2020](https://qiita.com/advent-calendar/2020/increments-cyma) の21日目は、Increments株式会社の @kiitan さんがお送りします。