Headless Chrome: jawaban untuk situs JS rendering sisi server

Pelajari cara menggunakan Puppeteer API 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.

Dalam beberapa baris kode, Anda bisa melakukan SSR pada halaman apa 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 Chrome Headless jika:

  • Anda telah membuat aplikasi web yang tidak diindeks oleh mesin telusur.
  • Anda berharap Anda mendapatkan solusi yang cepat untuk mengoptimalkan performa JavaScript dan meningkatkan first bermakna paint.

Beberapa framework seperti Preact dilengkapi dengan alat yang menangani rendering sisi server. Jika framework Anda memiliki solusi pra-rendering, tetap gunakan solusi tersebut daripada menghadirkan Puppeteer dan Headless Chrome ke dalam alur kerja Anda.

Meng-crawl web modern

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

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

Halaman pra-rendering

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

  • Mengetahui cara menjalankan semua jenis JavaScript modern dan menghasilkan HTML statis.
  • Selalu diperbarui seiring penambahan fitur di web.
  • Berjalan dengan sedikit atau tanpa pembaruan kode pada aplikasi Anda.

Terdengar bagus? Alat itu adalah browser. Chrome Headless tidak peduli dengan library, framework, atau toolchain yang Anda gunakan.

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

Mari kita mulai dengan halaman dinamis yang menghasilkan 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, kita akan mengambil fungsi ssr() dari sebelumnya dan sedikit meningkatkannya:

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

  • Cache ditambahkan. Menyimpan kode HTML yang dirender ke dalam cache adalah kemenangan terbesar untuk mempercepat waktu respons. Saat halaman diminta ulang, Anda harus menghindari Chrome headless secara bersamaan. Saya membahas pengoptimalan lainnya nanti.
  • Menambahkan penanganan error dasar jika waktu pemuatan halaman habis.
  • Tambahkan panggilan ke page.waitForSelector('#posts'). Ini memastikan bahwa postingan ada di DOM sebelum kita membuang halaman serial.
  • Tambahkan sains. Catat berapa lama waktu yang diperlukan headless untuk merender halaman dan menampilkan waktu rendering beserta HTML.
  • Tempelkan kode dalam modul bernama ssr.mjs.

Contoh server web

Terakhir, inilah 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

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

Kasus penggunaan yang tepat untuk Waktu Server adalah melaporkan berapa lama waktu yang dibutuhkan 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)"`);

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

Di salah satu aplikasi saya (kode), Chrome headless memerlukan waktu sekitar 1 detik untuk merender halaman di server. Setelah halaman di-cache, emulasi 3G Lambat DevTools menempatkan FCP pada 8,37 detik lebih cepat daripada versi sisi klien.

First Paint (FP)First Contentful Paint (FCP)
Aplikasi sisi klien4 dtk 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 hidrasi ulang

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

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

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

Mari kita perbaiki. Kita perlu memberi tahu halaman tersebut bahwa HTML-nya sudah diterapkan. Solusi yang saya temukan adalah membuat JS halaman memeriksa apakah <ul id="posts"> sudah ada di DOM pada waktu pemuatan. Jika ya, kami tahu bahwa halaman tersebut telah di-SSR dan dapat menghindari penambahan postingan lagi. 👍

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 meng-cache hasil yang dirender, ada banyak pengoptimalan menarik yang dapat kita lakukan pada ssr(). Beberapa di antaranya adalah solusi yang cepat sementara yang lainnya mungkin lebih spekulatif. Manfaat performa yang Anda lihat pada akhirnya dapat bergantung pada jenis halaman yang Anda pra-render dan kompleksitas aplikasi.

Batalkan permintaan yang tidak penting

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

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

Permintaan jaringan yang tidak menyusun DOM sia-sia. Resource seperti gambar, font, stylesheet, dan media tidak ikut digunakan dalam pembuatan HTML halaman. Pengubah tersebut menata gaya dan melengkapi struktur halaman, tetapi tidak membuatnya secara eksplisit. Kita harus memberi tahu browser agar mengabaikan resource ini. Hal ini akan 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. Dengan begitu, kita dapat membatalkan permintaan untuk resource tertentu dan membiarkan yang lain melanjutkan permintaan tersebut.

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

Sumber daya penting inline

Sebaiknya gunakan alat build terpisah (seperti gulp) untuk memproses aplikasi dan menyejajarkan CSS dan JS penting ke halaman pada waktu build. Tindakan ini dapat mempercepat first intent paint karena browser membuat permintaan yang lebih sedikit selama pemuatan halaman awal.

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

Contoh ini menunjukkan cara menangkap 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 menciptakan banyak overhead. Sebagai gantinya, sebaiknya Anda meluncurkan satu instance dan menggunakannya kembali untuk merender beberapa halaman.

Puppeteer dapat terhubung kembali ke instance Chrome yang sudah ada dengan memanggil puppeteer.connect() dan meneruskan URL proses debug jarak jauh instance tersebut. Untuk mempertahankan instance browser yang berjalan lama, kita dapat memindahkan kode yang meluncurkan Chrome dari fungsi ssr() dan 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: cron job untuk melakukan pra-rendering secara berkala

Di aplikasi dasbor App Engine, saya menyiapkan pengendali cron untuk merender ulang halaman teratas di situs secara berkala. Hal ini membantu pengunjung selalu melihat konten yang cepat dan baru, serta menghindari dan membantu mereka melihat "biaya startup" pra-rendering baru. Membuat beberapa instance Chrome akan sia-sia untuk kasus ini. Sebagai gantinya, saya menggunakan instance browser bersama untuk merender beberapa halaman sekaligus:

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

Saya juga menambahkan ekspor clearCache() ke ssr.js:

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

export {ssr, clearCache};

Pertimbangan lainnya

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

Saat halaman Anda dirender oleh Chrome headless di server, sebaiknya ketahui logika sisi klien halaman untuk mengetahui hal tersebut. Di aplikasi, saya menggunakan hook ini untuk "menonaktifkan" bagian halaman yang tidak berperan dalam merender markup postingan. Misalnya, saya menonaktifkan kode yang memuat firebase-auth.js dengan lambat. Tidak ada pengguna yang harus login!

Menambahkan parameter ?headless ke URL render adalah cara mudah untuk memberikan hook pada halaman:

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 peningkatan kunjungan halaman Analytics

Hati-hati jika Anda menggunakan Analytics di situs Anda. Halaman pra-rendering dapat mengakibatkan kunjungan halaman yang meningkat. Secara khusus, Anda akan melihat 2x jumlah hit, satu hit saat Chrome headless merender halaman, dan hit lain saat browser pengguna merendernya.

Jadi, apa perbaikannya? 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 Anda untuk mendapatkan insight tentang jumlah pra-rendering yang dilakukan server Anda.

Kesimpulan

Puppeteer memudahkan Anda merender 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 indeks aplikasi Anda tanpa perubahan kode yang signifikan.

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

Lampiran

Pembahasan tentang karya sebelumnya

Rendering sisi server adalah hal yang sulit untuk aplikasi sisi klien. Seberapa sulit? Lihat saja jumlah paket npm yang telah ditulis orang yang dikhususkan untuk topik tersebut. Ada banyak pola, tools, dan layanan yang tersedia untuk membantu aplikasi SSRing JS.

JavaScript Isomorfik / Universal

Konsep JavaScript Universal berarti: kode sama yang berjalan di server juga berjalan di klien (browser). Anda berbagi kode antara server dan klien dan semua orang merasakan momen ketenangan.

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

Alat pra-render

Komunitas Node telah membangun banyak sekali alat untuk menangani aplikasi SSR JS. Tidak ada kejutan di sana! Secara pribadi, saya menemukan bahwa YMMV dengan beberapa alat ini, jadi pasti mengerjakan PR Anda sebelum berkomitmen untuk melakukannya. Misalnya, beberapa alat SSR sudah lama dan tidak menggunakan Chrome headless (atau browser headless apa pun dalam hal ini). Sebaliknya, paket menggunakan PhantomJS (alias Safari lama), yang berarti halaman Anda tidak akan dirender dengan benar jika menggunakan fitur yang lebih baru.

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

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

Perlu diperhatikan bahwa Pra-rendering tidak menyertakan detail terkait mendownload dan menginstal Chrome di berbagai platform. Sering kali, hal tersebut cukup sulit untuk dijawab dengan benar, yang merupakan salah satu alasan mengapa Puppeteer melakukannya untuk Anda. Saya juga mengalami masalah dengan layanan online yang merender beberapa aplikasi saya:

chromestatus dirender di browser
Situs yang dirender di browser
chromestatus dirender oleh pra-rendering
Situs yang sama yang dirender oleh prerender.io