ヘッドレス Chrome: サーバーサイド レンダリングを行う JS サイト

Puppeteer API を使用して、Express ウェブサーバーにサーバー側レンダリング(SSR)機能を追加する方法を学習します。最も良い点は、アプリのコードをわずかに変更する必要があることです。ヘッドレスが手間のかかる作業をすべて代行します

数行のコードで任意のページの 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 ベースのアプリケーションが今後も存在するため、多くの場合、クロールツールからは Google のコンテンツが認識されません。

Google の検索クローラである Googlebot は、サイトにアクセスするユーザーの利便性を損なわないようにしながら、JavaScript を処理します。クローラーによるアクセスやレンダリングに対応するには、ページやアプリケーションを設計する際に考慮すべき相違点と制限がいくつかあります。

ページの事前レンダリング

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

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

よろしいでしょうか?そのツールがブラウザです。ヘッドレス Chrome では、どのライブラリ、フレームワーク、ツールチェーンを使用しても問題ありません。

たとえば、Node.js でビルドしているアプリケーションの場合、Puppeteer を使用すると 0.headless 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') への呼び出しを追加します。これにより、シリアル化されたページをダンプする前に、POST が DOM に存在することが保証されます。
  • 科学を追加する。ヘッドレスがページをレンダリングするのにかかる時間を記録し、レンダリング時間を HTML とともに返します。
  • ssr.mjs という名前のモジュールにコードを貼り付けます。

ウェブサーバーの例

最後に、すべてをまとめた小規模な Express Server を示します。メインハンドラは 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 Slow エミュレーションにより、クライアント側バージョンよりも FCP8.37 秒高速になります。

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

この結果は有望なものです。サーバーサイドでレンダリングされるページでは、JavaScript に依存せずに投稿を読み込み、表示するため、意味のあるコンテンツをより迅速にユーザーに表示できます。

水分補給の防止

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

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

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

修正しましょう。HTML がすでに設定されていることをページに伝える必要があります。 私が見つけた解決策は、読み込み時に <ul id="posts"> がすでに DOM にあるかどうかをページ JS に確認させることでした。SSR になっていれば、Google はそのページが 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};
}

レンダリングをまたいで 1 つの Chrome インスタンスを再利用する

事前レンダリングのたびに新しいブラウザを起動すると、多くのオーバーヘッドが発生します。代わりに、単一のインスタンスを起動し、それを複数のページのレンダリングに再利用することもできます。

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 ジョブ

App Engine ダッシュボード アプリで、サイトのトップページを定期的に再レンダリングする cron ハンドラをセットアップしています。これにより、訪問者は常に高速で最新のコンテンツを表示でき、新しい事前レンダリングによる「起動コスト」の発生を回避できます。この場合、Chrome のインスタンスを複数生成しても無駄になります。代わりに、共有ブラウザ インスタンスを使用して、複数のページを一度にレンダリングしています。

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!');
});

また、clearCache() エクスポートを ssr.js に追加しました。

...
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 倍のヒット数が発生しています。1 つはヘッドレス Chrome がページをレンダリングし、もう 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 パッケージがいくつ作成されているか確認します。SSRing JS アプリで利用できるパターンtoolsサービスは数え切れないほどあります。

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

ユニバーサル JavaScript のコンセプトは、サーバー上で実行されるコードと同じコードがクライアント(ブラウザ)でも実行されることを意味します。サーバーとクライアントの間でコードを共有すると、心が穏やかになります。

ヘッドレス Chrome では、サーバーとクライアントの間で「アイソモーフィック JS」が実現されます。これは、ライブラリがサーバー(Node)上で動作しない場合に最適な選択肢です。

事前レンダリング ツール

Node コミュニティは、SSR JS アプリを扱える多数のツールを構築してきました。驚くことではありません。個人的には、これらのツールの一部で YMMV を見つけたので、必ず事前に宿題をやっておきましょう。たとえば、一部の SSR ツールは古く、ヘッドレス Chrome(またはヘッドレス ブラウザ)を使用していません。代わりに PhantomJS(旧 Safari)を使用するため、新しい機能を使用している場合、ページが正しくレンダリングされません。

顕著な例外として、事前レンダリングがあります。事前レンダリングは、ヘッドレス Chrome を使用し、ドロップイン Express ミドルウェアが付属する点で興味深いものです。

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

事前レンダリングでは、異なるプラットフォームへの Chrome のダウンロードとインストールに関する詳細が除外されます。多くの場合、正しく行うのはかなり難しいことでしょう。これが、Puppeteer が自動化する理由の一つです。一部のアプリのレンダリングでオンライン サービスの問題も発生しました。

ブラウザでの chromestatus の表示
ブラウザに表示されたサイト
事前レンダリングによってレンダリングされた chromestatus
prerender.io によってレンダリングされた同じサイト