Headless Chrome: サーバーサイド レンダリングを行う JavaScript サイトのご紹介

Addy Osmani
Addy Osmani

Puppeteer API を使用して、サーバーサイド レンダリング(SSR)機能を Express ウェブサーバーに追加する方法について学びます。何より、アプリのコードに変更を加える必要があるのはごく一部です。ヘッドレスがすべての処理を行います。

数行のコードで、任意のページを SSR して最終的なマークアップを取得できます。

import puppeteer from 'puppeteer';

async function ssr(url) {
  const browser = await puppeteer.launch({headless: true});
  const page = await browser.newPage();
  await page.goto(url, {waitUntil: 'networkidle0'});
  const html = await page.content(); // serialized HTML of page DOM.
  await browser.close();
  return html;
}

ヘッドレス Chrome を使用する理由

ヘッドレス Chrome は、次のような場合に適しています。

Preact などの一部のフレームワークには、サーバーサイド レンダリングに対応したツールが付属しています。フレームワークにプリレンダリング ソリューションが含まれている場合は、Puppeteer とヘッドレス Chrome をワークフローに導入するのではなく、そのソリューションを使用してください。

最新のウェブをクロールする

これまで、検索エンジンのクローラ、ソーシャル 共有プラットフォーム、ブラウザは、ウェブをインデックスに登録し、コンテンツを表示するために静的 HTML マークアップにのみ依存していました。最新のウェブは、それから大きく進化しました。JavaScript ベースのアプリケーションは今後も使用されるため、多くの場合、コンテンツがクロール ツールに表示されなくなる可能性があります。

Googlebot は、サイトにアクセスするユーザーの利便性を損なわない仕組みでJavaScript を処理します。クローラによるアクセスやレンダリングに対応するには、ページやアプリケーションを設計する際に特別に考慮しなければならない点や制限事項がいくつかあります。

ページをプリレンダリングする

すべてのクローラは HTML を認識します。クローラが JavaScript をインデックスに登録できるようにするには、次のようなツールが必要です。

  • 最新の JavaScript のすべてのタイプを実行し、静的 HTML を生成する方法を理解している。
  • ウェブの機能が追加されるたびに最新の状態に保たれます。
  • アプリケーションのコード更新をほとんどまたはまったく行わずに実行できます。

よろしいでしょうか?そのツールとはブラウザです。Headless Chrome は、使用するライブラリ、フレームワーク、ツールチェーンに関係しません。

たとえば、アプリケーションが Node.js でビルドされている場合、Puppeteer はヘッドレス Chrome を簡単に操作できる方法です。

まず、JavaScript で HTML を生成する動的ページを作成します。

public/index.html

<html>
<body>
  <div id="container">
    <!-- Populated by the JS below. -->
  </div>
</body>
<script>
function renderPosts(posts, container) {
  const html = posts.reduce((html, post) => {
    return `${html}
      <li class="post">
        <h2>${post.title}</h2>
        <div class="summary">${post.summary}</div>
        <p>${post.content}</p>
      </li>`;
  }, '');

  // CAREFUL: this assumes HTML is sanitized.
  container.innerHTML = `<ul id="posts">${html}</ul>`;
}

(async() => {
  const container = document.querySelector('#container');
  const posts = await fetch('/posts').then(resp => resp.json());
  renderPosts(posts, container);
})();
</script>
</html>

SSR 関数

次に、先ほどの ssr() 関数を少し強化します。

ssr.mjs

import puppeteer from 'puppeteer';

// In-memory cache of rendered pages. Note: this will be cleared whenever the
// server process stops. If you need true persistence, use something like
// Google Cloud Storage (https://firebase.google.com/docs/storage/web/start).
const RENDER_CACHE = new Map();

async function ssr(url) {
  if (RENDER_CACHE.has(url)) {
    return {html: RENDER_CACHE.get(url), ttRenderMs: 0};
  }

  const start = Date.now();

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  try {
    // networkidle0 waits for the network to be idle (no requests for 500ms).
    // The page's JS has likely produced markup by this point, but wait longer
    // if your site lazy loads, etc.
    await page.goto(url, {waitUntil: 'networkidle0'});
    await page.waitForSelector('#posts'); // ensure #posts exists in the DOM.
  } catch (err) {
    console.error(err);
    throw new Error('page.goto/waitForSelector timed out.');
  }

  const html = await page.content(); // serialized HTML of page DOM.
  await browser.close();

  const ttRenderMs = Date.now() - start;
  console.info(`Headless rendered page in: ${ttRenderMs}ms`);

  RENDER_CACHE.set(url, html); // cache rendered page.

  return {html, ttRenderMs};
}

export {ssr as default};

主な変更点は次のとおりです。

  • キャッシュを追加しました。レンダリングされた HTML をキャッシュに保存することは、レスポンス時間を短縮するための最も効果的な方法です。ページが再リクエストされた場合、ヘッドレス Chrome を実行する必要がなくなります。その他の最適化については後で説明します。
  • ページの読み込みがタイムアウトした場合の基本的なエラー処理を追加しました。
  • page.waitForSelector('#posts') への呼び出しを追加します。これにより、シリアル化されたページをダンプする前に、投稿が DOM に存在するようになります。
  • 科学を追加します。ヘッドレスがページのレンダリングにかかる時間をログに記録し、HTML とともにレンダリング時間を返します。
  • コードを ssr.mjs という名前のモジュールに貼り付けます。

ウェブサーバーの例

最後に、すべてをまとめた小さな Express サーバーを示します。メイン ハンドラは URL http://localhost/index.html(ホームページ)をプリレンダリングし、結果をレスポンスとして提供します。静的マークアップがレスポンスの一部になったため、ユーザーがページにアクセスするとすぐに投稿が表示されます。

server.mjs

import express from 'express';
import ssr from './ssr.mjs';

const app = express();

app.get('/', async (req, res, next) => {
  const {html, ttRenderMs} = await ssr(`${req.protocol}://${req.get('host')}/index.html`);
  // Add Server-Timing! See https://w3c.github.io/server-timing/.
  res.set('Server-Timing', `Prerender;dur=${ttRenderMs};desc="Headless render time (ms)"`);
  return res.status(200).send(html); // Serve prerendered page as response.
});

app.listen(8080, () => console.log('Server started. Press Ctrl+C to quit'));

この例を実行するには、依存関係(npm i --save puppeteer express)をインストールし、Node 8.5.0 以降と --experimental-modules フラグを使用してサーバーを実行します。

このサーバーが返すレスポンスの例を次に示します。

<html>
<body>
  <div id="container">
    <ul id="posts">
      <li class="post">
        <h2>Title 1</h2>
        <div class="summary">Summary 1</div>
        <p>post content 1</p>
      </li>
      <li class="post">
        <h2>Title 2</h2>
        <div class="summary">Summary 2</div>
        <p>post content 2</p>
      </li>
      ...
    </ul>
  </div>
</body>
<script>
...
</script>
</html>

新しい Server-Timing API のユースケース

Server-Timing API は、サーバー パフォーマンス指標(リクエスト時間、レスポンス時間、データベース ルックアップなど)をブラウザに返します。クライアント コードはこの情報を使用して、ウェブアプリの全体的なパフォーマンスを追跡できます。

Server-Timing のユースケースとしては、ヘッドレス Chrome がページをプリレンダリングするまでの時間を報告することが適しています。そのためには、サーバー レスポンスに Server-Timing ヘッダーを追加します。

res.set('Server-Timing', `Prerender;dur=1000;desc="Headless render time (ms)"`);

クライアントでは、Performance APIPerformanceObserver を使用して、次の指標にアクセスできます。

const entry = performance.getEntriesByType('navigation').find(
    e => e.name === location.href);
console.log(entry.serverTiming[0].toJSON());

{
  "name": "Prerender",
  "duration": 3808,
  "description": "Headless render time (ms)"
}

パフォーマンスの結果

次の結果には、後で説明するパフォーマンスの最適化のほとんどが組み込まれています。

サンプルアプリでは、ヘッドレスの Chrome がサーバーでページをレンダリングするのに約 1 秒かかります。ページがキャッシュに保存されると、DevTools の 3G 低速エミュレーションでは、FCP がクライアントサイド バージョンよりも 8.37 秒速くなります。

最初の描画(FP)First Contentful Paint(FCP)
クライアントサイド アプリ4 秒 11 秒
SSR バージョン2.3 秒~ 2.3 秒

これらの結果は前途有望です。サーバーサイドでレンダリングされたページではJavaScript を使用して投稿を読み込んで表示する必要がなくなるため、ユーザーは有意なコンテンツをより迅速に確認できます。

再水分補給を防ぐ

「クライアントサイド アプリのコードに変更を加えていない」と述べたことを覚えていますか?それは嘘だった。

Express アプリはリクエストを受け取り、Puppeteer を使用してヘッドレスにページを読み込み、結果をレスポンスとして提供します。しかし、この設定には問題があります。

ユーザーのブラウザがフロントエンドのページを読み込むと、サーバーでヘッドレス Chrome で実行されるものと同じ JavaScript再度実行されます。マークアップは 2 か所で生成されます。#doublerender!

この問題を解決するには、HTML がすでに配置されていることをページに伝えます。1 つの解決策は、ページの JavaScript で、読み込み時に <ul id="posts"> がすでに DOM にあるかどうかを確認することです。同じ場合は、ページが SSR されたことを確認でき、投稿を再度追加する必要がなくなります。👍

public/index.html

<html>
<body>
  <div id="container">
    <!-- Populated by JS (below) or by prerendering (server). Either way,
         #container gets populated with the posts markup:
      <ul id="posts">...</ul>
    -->
  </div>
</body>
<script>
...
(async() => {
  const container = document.querySelector('#container');

  // Posts markup is already in DOM if we're seeing a SSR'd.
  // Don't re-hydrate the posts here on the client.
  const PRE_RENDERED = container.querySelector('#posts');
  if (!PRE_RENDERED) {
    const posts = await fetch('/posts').then(resp => resp.json());
    renderPosts(posts, container);
  }
})();
</script>
</html>

最適化

レンダリングされた結果のキャッシュ保存以外にも、ssr() に対して行える興味深い最適化はたくさんあります。すぐに効果が出る施策もあれば、より投機的な施策もあります。最終的に得られるパフォーマンス上のメリットは、プリレンダリングするページの種類とアプリの複雑さによって異なります。

不要なリクエストを中止する

現在、ページ全体(およびリクエストするすべてのリソース)がヘッドレス Chrome に無条件に読み込まれます。ただし、ここでは次の 2 つの点にのみ注目します。

  1. レンダリングされたマークアップ。
  2. そのマークアップを生成する JS リクエスト。

DOM を構築しないネットワーク リクエストは浪費です。画像、フォント、スタイルシート、メディアなどのリソースは、ページの HTML の作成には使用されません。ページのスタイルを設定し、構造を補完しますが、明示的に作成することはありません。これらのリソースを無視するようにブラウザに指示する必要があります。これにより、ヘッドレス Chrome のワークロードが軽減され、帯域幅が節約され、大規模なページのプリレンダリング時間が短縮される可能性があります。

DevTools プロトコルは、ネットワーク インターセプトという強力な機能をサポートしています。この機能を使用すると、ブラウザから発行される前にリクエストを変更できます。Puppeteer は、page.setRequestInterception(true) をオンにして、ページの request イベントをリッスンすることで、ネットワーク インターセプトをサポートしています。これにより、特定のリソースのリクエストを中止し、他のリクエストを続行できます。

ssr.mjs

async function ssr(url) {
  ...
  const page = await browser.newPage();

  // 1. Intercept network requests.
  await page.setRequestInterception(true);

  page.on('request', req => {
    // 2. Ignore requests for resources that don't produce DOM
    // (images, stylesheets, media).
    const allowlist = ['document', 'script', 'xhr', 'fetch'];
    if (!allowlist.includes(req.resourceType())) {
      return req.abort();
    }

    // 3. Pass through all other requests.
    req.continue();
  });

  await page.goto(url, {waitUntil: 'networkidle0'});
  const html = await page.content(); // serialized HTML of page DOM.
  await browser.close();

  return {html};
}

重要なリソースをインライン化する

通常は、別のビルドツール(gulp など)を使用してアプリを処理し、ビルド時に重要な CSS と JS をページにインライン化します。これにより、ブラウザが最初のページ読み込み中に行うリクエストが少なくなるため、First Meaningful Paint を高速化できます。

別のビルドツールを使用する代わりに、ブラウザをビルドツールとして使用しましょう。Puppeteer を使用すると、ページの DOM、スタイルのインライン化、JavaScript、またはページに追加するその他のものを操作してから、ページをプリレンダリングできます。

この例では、ローカル スタイルシートのレスポンスをインターセプトし、それらのリソースを <style> タグとしてページにインライン化する方法を示します。

ssr.mjs

import urlModule from 'url';
const URL = urlModule.URL;

async function ssr(url) {
  ...
  const stylesheetContents = {};

  // 1. Stash the responses of local stylesheets.
  page.on('response', async resp => {
    const responseUrl = resp.url();
    const sameOrigin = new URL(responseUrl).origin === new URL(url).origin;
    const isStylesheet = resp.request().resourceType() === 'stylesheet';
    if (sameOrigin && isStylesheet) {
      stylesheetContents[responseUrl] = await resp.text();
    }
  });

  // 2. Load page as normal, waiting for network requests to be idle.
  await page.goto(url, {waitUntil: 'networkidle0'});

  // 3. Inline the CSS.
  // Replace stylesheets in the page with their equivalent <style>.
  await page.$$eval('link[rel="stylesheet"]', (links, content) => {
    links.forEach(link => {
      const cssText = content[link.href];
      if (cssText) {
        const style = document.createElement('style');
        style.textContent = cssText;
        link.replaceWith(style);
      }
    });
  }, stylesheetContents);

  // 4. Get updated serialized HTML of page.
  const html = await page.content();
  await browser.close();

  return {html};
}

This code:

  1. Use a page.on('response') handler to listen for network responses.
  2. Stashes the responses of local stylesheets.
  3. Finds all <link rel="stylesheet"> in the DOM and replaces them with an equivalent <style>. See page.$$eval API docs. The style.textContent is set to the stylesheet response.

Auto-minify resources

Another trick you can do with network interception is to modify the responses returned by a request.

As an example, say you want to minify the CSS in your app but also want to keep the convenience having it unminified when developing. Assuming you've setup another tool to pre-minify styles.css, one can use Request.respond() to rewrite the response of styles.css to be the content of styles.min.css.

ssr.mjs

import fs from 'fs';

async function ssr(url) {
  ...

  // 1. Intercept network requests.
  await page.setRequestInterception(true);

  page.on('request', req => {
    // 2. If request is for styles.css, respond with the minified version.
    if (req.url().endsWith('styles.css')) {
      return req.respond({
        status: 200,
        contentType: 'text/css',
        body: fs.readFileSync('./public/styles.min.css', 'utf-8')
      });
    }
    ...

    req.continue();
  });
  ...

  const html = await page.content();
  await browser.close();

  return {html};
}

レンダリング間で単一の Chrome インスタンスを再利用する

プリレンダリングごとに新しいブラウザを起動すると、オーバーヘッドが大きくなります。代わりに、1 つのインスタンスを起動して、複数のページのレンダリングに再利用することをおすすめします。

Puppeteer は、puppeteer.connect() を呼び出してインスタンスのリモート デバッグ URL を渡すことで、Chrome の既存のインスタンスに再接続できます。長時間実行されるブラウザ インスタンスを維持するには、Chrome を起動するコードを ssr() 関数から Express サーバーに移動します。

server.mjs

import express from 'express';
import puppeteer from 'puppeteer';
import ssr from './ssr.mjs';

let browserWSEndpoint = null;
const app = express();

app.get('/', async (req, res, next) => {
  if (!browserWSEndpoint) {
    const browser = await puppeteer.launch();
    browserWSEndpoint = await browser.wsEndpoint();
  }

  const url = `${req.protocol}://${req.get('host')}/index.html`;
  const {html} = await ssr(url, browserWSEndpoint);

  return res.status(200).send(html);
});

ssr.mjs

import puppeteer from 'puppeteer';

/**
 * @param {string} url URL to prerender.
 * @param {string} browserWSEndpoint Optional remote debugging URL. If
 *     provided, Puppeteer's reconnects to the browser instance. Otherwise,
 *     a new browser instance is launched.
 */
async function ssr(url, browserWSEndpoint) {
  ...
  console.info('Connecting to existing Chrome instance.');
  const browser = await puppeteer.connect({browserWSEndpoint});

  const page = await browser.newPage();
  ...
  await page.close(); // Close the page we opened here (not the browser).

  return {html};
}

例: 定期的にプリレンダリングする cron ジョブ

複数のページを一度にレンダリングするには、共有ブラウザ インスタンスを使用します。

import puppeteer from 'puppeteer';
import * as prerender from './ssr.mjs';
import urlModule from 'url';
const URL = urlModule.URL;

app.get('/cron/update_cache', async (req, res) => {
  if (!req.get('X-Appengine-Cron')) {
    return res.status(403).send('Sorry, cron handler can only be run as admin.');
  }

  const browser = await puppeteer.launch();
  const homepage = new URL(`${req.protocol}://${req.get('host')}`);

  // Re-render main page and a few pages back.
  prerender.clearCache();
  await prerender.ssr(homepage.href, await browser.wsEndpoint());
  await prerender.ssr(`${homepage}?year=2018`);
  await prerender.ssr(`${homepage}?year=2017`);
  await prerender.ssr(`${homepage}?year=2016`);
  await browser.close();

  res.status(200).send('Render cache updated!');
});

また、ssr.jsclearCache() エクスポートを追加します。

...
function clearCache() {
  RENDER_CACHE.clear();
}

export {ssr, clearCache};

その他の考慮事項

ページのシグナルを作成します。「ヘッドレスでレンダリングされています」

ページがサーバー上のヘッドレス Chrome によってレンダリングされている場合は、ページのクライアントサイド ロジックがそのことを認識すると便利です。私のアプリでは、このフックを使用して、投稿のマークアップのレンダリングに関係しないページの部分を「オフ」にしました。たとえば、firebase-auth.js を遅延読み込みするコードを無効にしました。ログインするユーザーがいません。

レンダリング URL に ?headless パラメータを追加すると、ページにフックを簡単に追加できます。

ssr.mjs

import urlModule from 'url';
const URL = urlModule.URL;

async function ssr(url) {
  ...
  // Add ?headless to the URL so the page has a signal
  // it's being loaded by headless Chrome.
  const renderUrl = new URL(url);
  renderUrl.searchParams.set('headless', '');
  await page.goto(renderUrl, {waitUntil: 'networkidle0'});
  ...

  return {html};
}

このページで、そのパラメータを検索できます。

public/index.html

<html>
<body>
  <div id="container">
    <!-- Populated by the JS below. -->
  </div>
</body>
<script>
...

(async() => {
  const params = new URL(location.href).searchParams;

  const RENDERING_IN_HEADLESS = params.has('headless');
  if (RENDERING_IN_HEADLESS) {
    // Being rendered by headless Chrome on the server.
    // e.g. shut off features, don't lazy load non-essential resources, etc.
  }

  const container = document.querySelector('#container');
  const posts = await fetch('/posts').then(resp => resp.json());
  renderPosts(posts, container);
})();
</script>
</html>

アナリティクスのページビューの増加を防ぐ

サイトでアナリティクスを使用している場合は注意が必要です。ページをプリレンダリングすると、ページビュー数が増加する可能性があります。具体的には、ヒット数が 2 倍になるため、ヘッドレス Chrome がページをレンダリングしたときに 1 回、ユーザーのブラウザがレンダリングしたときに 1 回、ヒットが発生します。

では、この問題を解決するにはどうすればよいでしょうか。ネットワーク インターセプトを使用して、アナリティクス ライブラリの読み込みを試みるリクエストをすべて中止します。

page.on('request', req => {
  // Don't load Google Analytics lib requests so pageviews aren't 2x.
  const blockist = ['www.google-analytics.com', '/gtag/js', 'ga.js', 'analytics.js'];
  if (blocklist.find(regex => req.url().match(regex))) {
    return req.abort();
  }
  ...
  req.continue();
});

コードが読み込まれない場合、ページヒットは記録されません。ブーム 💥?。

または、アナリティクス ライブラリの読み込みを継続して、サーバーが実行しているプリレンダリングの数に関する分析情報を取得することもできます。

まとめ

Puppeteer を使用すると、ウェブサーバーでヘッドレス Chrome をコンパニオンとして実行することで、ページをサーバーサイドで簡単にレンダリングできます。このアプローチの一番の「特徴」は、コードを大幅に変更することなく、アプリの読み込みパフォーマンスインデックス登録可能性を改善できることです。

ここで説明する手法を使用した動作するアプリを確認するには、devwebfeed アプリをご覧ください。

付録

先行技術に関する議論

サーバーサイドでレンダリングするクライアントサイド アプリは難しいものです。どの程度ですか?このトピック専用の npm パッケージがどれだけ作成されているかを見れば、その人気がわかります。JS アプリの SSR に役立つパターンツールサービスは数多くあります。

イソモルフィック / ユニバーサル JavaScript

ユニバーサル JavaScript のコンセプトは、サーバー上で実行されるコードがクライアント(ブラウザ)でも実行されることを意味します。サーバー側とクライアント側でコードを共有し、誰もが心を落ち着かせることができます。

ヘッドレス Chrome を使用すると、サーバー側とクライアント側で「イソモルフィック JS」を実現できます。ライブラリがサーバー(Node)で動作しない場合は、この方法が最適です。

プリレンダリング ツール

Node コミュニティでは、SSR JS アプリに対応する多くのツールが構築されています。驚くことはありません。個人的には、これらのツールの一部はYMMV であることがわかりました。そのため、ツールを決める前に必ず調査してください。たとえば、一部の SSR ツールは古く、ヘッドレス Chrome(またはヘッドレス ブラウザ)を使用していません。代わりに、PhantomJS(古い Safari)を使用します。つまり、新しい機能を使用している場合、ページが正しくレンダリングされません。

注目すべき例外の一つがプリレンダリングです。Prerender は、ヘッドレス Chrome を使用し、Express 用のドロップイン ミドルウェアが付属しているという点で興味深いものです。

const prerender = require('prerender');
const server = prerender();
server.use(prerender.removeScriptTags());
server.use(prerender.blockResources());
server.start();

なお、Prerender では、さまざまなプラットフォームでの Chrome のダウンロードとインストールの詳細は省略されています。多くの場合、正しく設定するのはかなり難しいものです。これが、Puppeteer が自動で設定する理由の一つです。