無頭 Chrome:對於伺服器端轉譯 JavaScript 網站的答案

Addy Osmani
Addy Osmani

瞭解如何使用 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;
}

為什麼要使用 Headless Chrome?

您可能會對 Headless Chrome 感興趣,如果:

Preact 等部分架構會提供工具,用於處理伺服器端轉譯作業。如果您的架構有預先轉譯解決方案,請繼續使用該方案,不要將 Puppeteer 和 Headless Chrome 納入工作流程。

檢索現代網際網路

搜尋引擎檢索器、社群分享平台,甚至是瀏覽器,過去都只會依賴靜態 HTML 標記來為網頁和內容建立索引。現代網路已發展出截然不同的面貌。以 JavaScript 為基礎的應用程式將持續存在,這表示在許多情況下,我們的內容可能會對檢索工具隱藏。

Googlebot 是我們的搜尋檢索器,可處理 JavaScript,同時確保檢索作業不會破壞網站的使用體驗。您在設計網頁和應用程式時需要考量一些差異和限制,讓檢索器能順利存取並轉譯您的內容。

預先轉譯網頁

所有檢索器都能解讀 HTML。為確保檢索器能夠為 JavaScript 建立索引,我們需要以下工具:

  • 瞭解如何執行 所有類型的新式 JavaScript,並產生靜態 HTML。
  • 隨著網頁新增功能,您也能隨時掌握最新資訊。
  • 應用程式幾乎不需要更新程式碼即可執行。

聽起來不錯吧?這項工具就是瀏覽器!無頭 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 伺服器。主要處理程序會預先轉譯網址 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 會將伺服器效能指標 (例如要求和回應時間或資料庫查詢) 傳回至瀏覽器。用戶端程式碼可以使用這項資訊,追蹤網頁應用程式的整體效能。

伺服器時間的最佳用途是回報無頭 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 大約需要一秒的時間,才能在伺服器上轉譯網頁。網頁快取後,DevTools 3G 慢速模擬會讓 FCP 比用戶端版本快上 8.37 秒

首次繪製 (FP)First Contentful Paint (FCP)
用戶端應用程式4 秒 11 秒
SSR 版本2.3 秒約 2.3 秒

這些結果令人振奮。由於伺服器端轉譯的網頁不再需要 JavaScript 來載入及顯示貼文,使用者就能更快看到有意義的內容。

避免重新補水

還記得我說過「我們沒有對用戶端應用程式進行任何程式碼變更」嗎?那是謊言。

Express 應用程式會接收要求,使用 Puppeteer 將網頁載入無頭模式,並將結果做為回應提供。但這種設定方式有問題。

當使用者的瀏覽器在前端載入網頁時,在伺服器上執行的無頭 Chrome 中執行的 JavaScript再次執行。我們有兩個地方會產生標記。#doublerender

如要修正這個問題,請告訴網頁 HTML 已就位。解決方法之一是讓網頁 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 中。不過,我們只想瞭解兩件事:

  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 內嵌至網頁。這可加快首次有意義的繪製作業,因為瀏覽器在初始網頁載入期間提出的請求較少。

使用瀏覽器做為建構工具,而非使用單獨的建構工具!我們可以使用 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 例項

為每個預先算繪作業啟動新的瀏覽器會產生大量額外負擔。您可能會改為啟動單一例項,並重複使用該例項來轉譯多個頁面。

Puppeteer 可以呼叫 puppeteer.connect(),並傳遞執行個體的遠端偵錯網址,藉此重新連線至現有的 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!');
});

此外,請將 clearCache() 匯出項目新增至 ssr.js

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

export {ssr, clearCache};

其他注意事項

為網頁建立信號:「您正在以無頭模式顯示」

當伺服器上的無頭 Chrome 轉譯網頁時,網頁的用戶端邏輯可能會知道這項資訊。在我的應用程式中,我使用這個掛鉤來「關閉」頁面中與轉譯文章標記無關的部分。舉例來說,我停用了會延遲載入 firebase-auth.js 的程式碼。沒有使用者登入!

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

避免 Analytics 網頁瀏覽量偏高

如果您在網站上使用 Analytics,請務必小心。預先算繪頁面可能會導致瀏覽量偏高。具體來說,您會看到 2 次命中數量:在無頭 Chrome 轉譯網頁時會產生一次命中,在使用者瀏覽器轉譯網頁時會產生另一次命中。

那麼,修正方法是什麼?使用網路攔截功能,中止任何嘗試載入 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();
});

如果程式碼從未載入,系統就不會記錄網頁瀏覽次數。Boom 💥?。

或者,您也可以繼續載入 Analytics 程式庫,深入瞭解伺服器執行了多少預先顯示作業。

結論

透過 Puppeteer,您可以在網路伺服器上執行無頭 Chrome 做為輔助程式,輕鬆執行伺服器端轉譯網頁。我最喜歡這個方法的「功能」是,您可以改善載入效能,並提升應用程式的索引功能,且不必大幅變更程式碼

如果您想查看使用這裡所述技巧的實際應用程式,請查看devwebfeed 應用程式

附錄

討論先前技術

伺服器端轉譯用戶端應用程式並不容易。難度如何?只要看看有多少人針對這個主題撰寫了多少 npm 套件,有無數的模式工具服務可協助處理 SSRing JS 應用程式。

同構 / 通用 JavaScript

通用 JavaScript 的概念是指:在伺服器上執行的程式碼,也能在用戶端 (瀏覽器) 上執行。您可以在伺服器和用戶端之間共用程式碼,讓每個人都能享受片刻的禪意時光。

無頭 Chrome 可在伺服器和用戶端之間啟用「同構 JS」。如果程式庫無法在伺服器 (Node) 上運作,這是非常實用的選項。

預先算繪工具

Node 社群已建構大量工具,用於處理 SSR JS 應用程式。不會有任何意外!就我個人而言,我發現部分工具的效果因人而異,因此請務必先做好功課,再決定採用哪種工具。舉例來說,部分 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 會為您處理