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
なので、以下のように書くと基本的には逐次処理になります。
基本的にとした理由は、次項で説明します。
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 さんがお送りします。