LoginSignup
7
1

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-12-19

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

はじめに

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

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

対象者

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

awaitの挙動について

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

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

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ドキュメントに以下の記載がありますが、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()を捕捉できず、実行途中で立ち往生する可能性があるというものです。

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

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

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

うまくいかなかった方法

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

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

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

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

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

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

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

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

上の実装を

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で入力イベントを拾って裏で処理しているケースがあり意外と苦戦しました。

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

うまく動作した例
  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に公式が用意した多くのサンプルがあるので、これを真似してみるのが慣れる近道ではあると思います。

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


Increments × cyma (Ateam Inc.) Advent Calendar 2020 の21日目は、Increments株式会社の @kiitan さんがお送りします。

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1