Chrome Headless: jawaban untuk situs JavaScript rendering sisi server

Addy Osmani
Addy Osmani

Pelajari cara menggunakan API Puppeteer untuk menambahkan kemampuan rendering sisi server (SSR) ke server web Express. Bagian terbaiknya adalah aplikasi Anda memerlukan perubahan kode yang sangat kecil. Headless melakukan semua pekerjaan berat.

Dengan beberapa baris kode, Anda dapat melakukan SSR pada halaman mana pun dan mendapatkan markup akhirnya.

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;
}

Mengapa menggunakan Chrome Headless?

Anda mungkin tertarik dengan Headless Chrome jika:

Beberapa framework seperti Preact disertakan dengan alat yang menangani rendering sisi server. Jika framework Anda memiliki solusi prerendering, gunakan solusi tersebut, bukan menggunakan Puppeteer dan Chrome Headless ke dalam alur kerja Anda.

Meng-crawl web modern

Crawler mesin telusur, platform berbagi sosial, bahkan browser secara historis mengandalkan markup HTML statis secara eksklusif untuk mengindeks web dan menampilkan konten. Web modern telah berkembang menjadi sesuatu yang jauh berbeda. Aplikasi berbasis JavaScript akan terus ada, yang berarti bahwa dalam banyak kasus, konten kami dapat tidak terlihat oleh alat crawling.

Googlebot, crawler Penelusuran kami, memproses JavaScript dengan memastikan hal ini tidak menyulitkan pengguna yang mengunjungi situs. Ada beberapa perbedaan dan batasan yang perlu Anda perhitungkan saat mendesain halaman dan aplikasi untuk menyesuaikan cara crawler mengakses dan merender konten Anda.

Pra-render halaman

Semua crawler memahami HTML. Untuk memastikan crawler dapat mengindeks JavaScript, kita memerlukan alat yang:

  • Mengetahui cara menjalankan semua jenis JavaScript modern dan membuat HTML statis.
  • Selalu diperbarui saat web menambahkan fitur.
  • Berjalan dengan sedikit atau tanpa update kode ke aplikasi Anda.

Kedengarannya bagus, kan? Alat tersebut adalah browser. Headless Chrome tidak peduli library, framework, atau rantai alat apa yang Anda gunakan.

Misalnya, jika aplikasi Anda dibuat dengan Node.js, Puppeteer adalah cara mudah untuk menggunakan Chrome headless.

Mulai dengan halaman dinamis yang membuat HTML-nya dengan JavaScript:

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>

Fungsi SSR

Selanjutnya, ambil fungsi ssr() dari sebelumnya dan tingkatkan sedikit:

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};

Perubahan utamanya:

  • Menambahkan penyimpanan dalam cache. Menyimpan HTML yang dirender ke dalam cache adalah cara terbaik untuk mempercepat waktu respons. Saat halaman diminta ulang, Anda tidak perlu menjalankan Chrome headless sama sekali. Saya akan membahas pengoptimalan lainnya nanti.
  • Menambahkan penanganan error dasar jika waktu tunggu pemuatan halaman habis.
  • Tambahkan panggilan ke page.waitForSelector('#posts'). Hal ini memastikan bahwa postingan ada di DOM sebelum kita membuang halaman yang diserialisasi.
  • Tambahkan sains. Catat waktu yang diperlukan headless untuk merender halaman dan tampilkan waktu rendering beserta HTML.
  • Tempelkan kode dalam modul bernama ssr.mjs.

Contoh server web

Terakhir, berikut server ekspres kecil yang menyatukan semuanya. Pengendali utama melakukan pra-rendering URL http://localhost/index.html (halaman beranda) dan menayangkan hasilnya sebagai responsnya. Pengguna akan langsung melihat postingan saat membuka halaman karena markup statis kini menjadi bagian dari respons.

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

Untuk menjalankan contoh ini, instal dependensi (npm i --save puppeteer express) dan jalankan server menggunakan Node 8.5.0+ dan flag --experimental-modules:

Berikut adalah contoh respons yang dikirim kembali oleh server ini:

<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>

Kasus penggunaan yang sempurna untuk Server-Timing API baru

Server-Timing API menyampaikan metrik performa server (seperti waktu permintaan dan respons atau pencarian database) kembali ke browser. Kode klien dapat menggunakan informasi ini untuk melacak performa keseluruhan aplikasi web.

Kasus penggunaan yang sempurna untuk Server-Timing adalah melaporkan waktu yang diperlukan Chrome headless untuk melakukan pra-rendering halaman. Untuk melakukannya, cukup tambahkan header Server-Timing ke respons server:

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

Di klien, Performance API dan PerformanceObserver dapat digunakan untuk mengakses metrik ini:

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)"
}

Hasil performa

Hasil berikut menggabungkan sebagian besar pengoptimalan performa yang akan dibahas nanti.

Pada contoh aplikasi, Chrome headless memerlukan waktu sekitar satu detik untuk merender halaman di server. Setelah halaman di-cache, Emulasi lambat 3G DevTools akan membuat FCP menjadi 8,37 detik lebih cepat daripada versi sisi klien.

First Paint (FP)First Contentful Paint (FCP)
Aplikasi sisi klien4s 11 dtk
Versi SSR2,3 dtk~2,3 dtk

Hasil ini menjanjikan. Pengguna melihat konten yang bermakna jauh lebih cepat karena halaman yang dirender sisi server tidak lagi mengandalkan JavaScript untuk memuat + menampilkan postingan.

Mencegah rehidrasi

Ingat saat saya mengatakan "kita tidak membuat perubahan kode apa pun pada aplikasi sisi klien"? Itu bohong.

Aplikasi Express kami mengambil permintaan, menggunakan Puppeteer untuk memuat halaman ke dalam headless, dan menayangkan hasilnya sebagai respons. Namun, penyiapan ini memiliki masalah.

JavaScript yang sama yang dieksekusi di Chrome headless di server berjalan lagi saat browser pengguna memuat halaman di frontend. Kita memiliki dua tempat yang menghasilkan markup. #doublerender!

Untuk memperbaikinya, beri tahu halaman bahwa HTML-nya sudah ada. Salah satu solusinya adalah dengan meminta JavaScript halaman memeriksa apakah <ul id="posts"> sudah ada di DOM pada waktu pemuatan. Jika demikian, Anda tahu bahwa halaman tersebut telah di-SSR dan dapat menghindari penambahan ulang postingan. 👍

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>

Pengoptimalan

Selain menyimpan hasil yang dirender dalam cache, ada banyak pengoptimalan menarik yang dapat kita lakukan pada ssr(). Beberapa di antaranya adalah solusi cepat, sementara yang lain mungkin lebih spekulatif. Manfaat performa yang Anda lihat pada akhirnya mungkin bergantung pada jenis halaman yang Anda pra-render dan kompleksitas aplikasi.

Membatalkan permintaan yang tidak penting

Saat ini, seluruh halaman (dan semua resource yang dimintanya) dimuat tanpa syarat ke Chrome headless. Namun, kita hanya tertarik dengan dua hal:

  1. Markup yang dirender.
  2. Permintaan JS yang menghasilkan markup tersebut.

Permintaan jaringan yang tidak membuat DOM akan sia-sia. Resource seperti gambar, font, stylesheet, dan media tidak berpartisipasi dalam mem-build HTML halaman. Class ini menata gaya dan melengkapi struktur halaman, tetapi tidak membuatnya secara eksplisit. Kita harus memberi tahu browser untuk mengabaikan resource ini. Hal ini mengurangi beban kerja untuk Chrome headless, menghemat bandwidth, dan berpotensi mempercepat waktu pra-rendering untuk halaman yang lebih besar.

Protokol DevTools mendukung fitur canggih yang disebut Intersepsi jaringan yang dapat digunakan untuk mengubah permintaan sebelum dikeluarkan oleh browser. Puppeteer mendukung intersepsi jaringan dengan mengaktifkan page.setRequestInterception(true) dan memproses peristiwa request halaman. Hal ini memungkinkan kita membatalkan permintaan untuk resource tertentu dan membiarkan yang lain terus berjalan.

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};
}

Resource penting inline

Biasanya, alat build terpisah (seperti gulp) digunakan untuk memproses aplikasi dan menyisipkan CSS dan JS penting ke dalam halaman pada waktu build. Hal ini dapat mempercepat gambar pertama yang bermakna karena browser membuat lebih sedikit permintaan selama pemuatan halaman awal.

Sebagai ganti alat build terpisah, gunakan browser sebagai alat build Anda. Kita dapat menggunakan Puppeteer untuk memanipulasi DOM halaman, gaya inline, JavaScript, atau apa pun yang ingin Anda tempelkan di halaman sebelum melakukan pra-rendering.

Contoh ini menunjukkan cara mencegat respons untuk stylesheet lokal dan menyisipkan resource tersebut ke dalam halaman sebagai tag <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};
}

Menggunakan kembali satu instance Chrome di seluruh render

Meluncurkan browser baru untuk setiap pra-rendering akan menghasilkan banyak overhead. Sebagai gantinya, Anda dapat meluncurkan satu instance dan menggunakannya kembali untuk merender beberapa halaman.

Puppeteer dapat terhubung kembali ke instance Chrome yang ada dengan memanggil puppeteer.connect() dan meneruskan URL proses debug jarak jauh instance. Untuk mempertahankan instance browser yang berjalan lama, kita dapat memindahkan kode yang meluncurkan Chrome dari fungsi ssr() ke server 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};
}

Contoh: tugas cron untuk melakukan pra-rendering secara berkala

Untuk merender sejumlah halaman sekaligus, Anda dapat menggunakan instance browser bersama.

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

Selain itu, tambahkan ekspor clearCache() ke ssr.js:

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

export {ssr, clearCache};

Pertimbangan lainnya

Buat sinyal untuk halaman: "Anda sedang dirender dalam headless"

Saat halaman Anda dirender oleh Chrome headless di server, logika sisi klien halaman mungkin akan berguna untuk mengetahuinya. Di aplikasi saya, saya menggunakan hook ini untuk "menonaktifkan" bagian halaman yang tidak berperan dalam merender markup postingan. Misalnya, saya menonaktifkan kode yang memuat lambat firebase-auth.js. Tidak ada pengguna yang akan login.

Menambahkan parameter ?headless ke URL render adalah cara sederhana untuk memberi halaman hook:

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};
}

Dan di halaman, kita dapat mencari parameter tersebut:

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>

Menghindari penggelembungan kunjungan halaman Analytics

Berhati-hatilah jika Anda menggunakan Analytics di situs Anda. Pra-rendering halaman dapat menyebabkan penggelembungan kunjungan halaman. Secara khusus, Anda akan melihat 2 kali jumlah hit, satu hit saat Chrome headless merender halaman dan hit lainnya saat browser pengguna merendernya.

Jadi, bagaimana cara memperbaikinya? Gunakan intersepsi jaringan untuk membatalkan permintaan apa pun yang mencoba memuat library Analytics.

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();
});

Hit halaman tidak pernah dicatat jika kode tidak pernah dimuat. Boom 💥.

Atau, terus muat library Analytics untuk mendapatkan insight tentang jumlah pra-rendering yang dilakukan server Anda.

Kesimpulan

Puppeteer memudahkan rendering halaman sisi server dengan menjalankan Chrome headless, sebagai pendamping, di server web Anda. "Fitur" favorit saya dari pendekatan ini adalah Anda meningkatkan performa pemuatan dan kemampuan pengindeksan aplikasi tanpa perubahan kode yang signifikan.

Jika Anda ingin melihat aplikasi yang berfungsi dan menggunakan teknik yang dijelaskan di sini, lihat aplikasi devwebfeed.

Lampiran

Diskusi karya pendahulu

Rendering sisi server aplikasi sisi klien itu sulit. Seberapa sulit? Cukup lihat jumlah paket npm yang telah ditulis orang-orang yang didedikasikan untuk topik tersebut. Ada banyak pola, alat, dan layanan yang tersedia untuk membantu SSR aplikasi JS.

JavaScript Isomorfik / Universal

Konsep Universal JavaScript berarti: kode yang sama yang berjalan di server juga berjalan di klien (browser). Anda berbagi kode antara server dan klien, dan semua orang merasa tenang.

Chrome Headless memungkinkan "JS isomorfik" antara server dan klien. Ini adalah opsi yang bagus jika library Anda tidak berfungsi di server (Node).

Alat pra-rendering

Komunitas Node telah membuat banyak alat untuk menangani aplikasi JS SSR. Tidak ada kejutan di sana. Secara pribadi, saya mendapati bahwa YMMV dengan beberapa alat ini, jadi pastikan Anda melakukan pekerjaan rumah sebelum berkomitmen pada salah satunya. Misalnya, beberapa alat SSR sudah lama dan tidak menggunakan Chrome headless (atau browser headless apa pun). Sebagai gantinya, mereka menggunakan PhantomJS (alias Safari lama), yang berarti halaman Anda tidak akan dirender dengan benar jika menggunakan fitur yang lebih baru.

Salah satu pengecualian yang penting adalah Pra-rendering. Prerender menarik karena menggunakan Chrome headless dan dilengkapi dengan middleware untuk Express:

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

Perlu diperhatikan bahwa Prerender tidak menyertakan detail download dan penginstalan Chrome di berbagai platform. Sering kali, hal ini cukup rumit untuk dilakukan dengan benar, yang merupakan salah satu alasan mengapa Puppeteer melakukannya untuk Anda.