無頭 Chrome:處理伺服器端轉譯 JS 網站的問題

瞭解如何使用 Puppeteer API,在 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 和 Headless Chrome。

檢索現代網路

搜尋引擎檢索器、社交分享平台,甚至瀏覽器以往都只能使用靜態 HTML 標記來為網頁索引及顯示內容。現代網路已演變成截然不同的新趨勢我們會保留以 JavaScript 為基礎的應用程式,也就是說,在許多情況下,檢索工具都無法發現我們的內容。

我們的搜尋檢索器 (也就是我們的搜尋檢索器) 會處理 JavaScript,同時確保 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')。這樣就能確保貼文存在於 DOM 前,再傾印序列化頁面。
  • 新增科學。記錄無頭轉譯頁面所需的時間,並連同 HTML 一併傳回轉譯時間。
  • 將程式碼放入名為 ssr.mjs 的模組中。

網路伺服器範例

最後,這個小型快速伺服器會整合所有內容。主要處理常式會預先算繪網址 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 秒鐘在伺服器上轉譯網頁。對網頁進行快取後,開發人員工具的 3G 慢速模擬功能可讓 FCP 的速度比用戶端版本快 8.37 秒

畫面首次顯示所需時間 (FP)First Contentful Paint (FCP)
用戶端應用程式4 秒 11 秒
SSR 版本2.3 秒最多 2.3 秒

這些結果令人滿意。由於伺服器端轉譯的網頁不再依賴 JavaScript 載入 + 顯示貼文,因此使用者能更快看到有意義的內容。

防止重水

記得時說「我們完全不用變更用戶端應用程式的程式碼」嗎?沒聽錯。

Express 應用程式會接收要求,並使用 Puppeteer 將頁面載入無頭模式,並將結果做為回應。但是這項設定有點問題。

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

讓我們一起解決這個問題!我們必須告訴網頁 HTML 已經寫好了。 我找到的解決方案是在載入時,讓頁面 JS 檢查 <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 的工作負載、節省頻寬,並有可能加快大型網頁的預先算繪時間。

開發人員工具通訊協定支援名為網路攔截的強大功能,可用於在瀏覽器發出要求前修改要求。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 工作

我在 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 的程式碼。沒有使用者能登入!

只要在轉譯網址中加入 ?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();
});

如果程式碼從未載入,系統就一律不會記錄網頁命中。轟爆了吧。

或者,您也可以繼續載入 Analytics (分析) 程式庫,深入瞭解您的伺服器正在執行多少預先算繪作業。

結語

Puppeteer 會在您的網路伺服器上以夥伴模式執行無頭 Chrome,讓您輕鬆執行伺服器端轉譯頁面。我最喜歡這個方法的「功能」是,不用大幅變更程式碼,就能改善應用程式的載入效能可索引性

如果您想瞭解採用本文所述技術的運作中應用程式,請參閱 devwebfeed 應用程式

附錄

探討既有藝術

伺服器端轉譯用戶端應用程式並不容易。難易度為何?您只需查看使用者為該主題寫入的 npm 套件數量。您可以利用無數的模式tools服務來協助 SSRing JS 應用程式。

變形 / 通用 JavaScript

通用 JavaScript 的概念如下:在伺服器上執行的程式碼,也會在用戶端 (瀏覽器) 上執行。您在伺服器和用戶端之間共用程式碼 彼此之間會覺得空閒

無頭 Chrome 可在伺服器和用戶端之間啟用「不規則的 JS」。如果程式庫無法在伺服器 (節點) 中運作,這會是不錯的選擇。

預先算繪工具

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

值得注意的是,預先算繪不會遺漏在其他平台上下載及安裝 Chrome 的詳細資料。有時候,這很難正確執行,這是Puppeteer 對你的用途的原因之一。此外,在線上服務轉譯部分應用程式時也有問題:

在瀏覽器中顯示的 Chrome 狀態
在瀏覽器中顯示的網站
預先算繪的 Chromestatus
由 prerender.io 轉譯的相同網站