瞭解如何使用 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 感興趣,如果:
- 您建構的網頁應用程式未被搜尋引擎索引。
- 您希望能快速提升JavaScript 效能,並改善第一個有意義的顯示時間。
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 API 和 PerformanceObserver 存取下列指標:
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 中。不過,我們只想瞭解兩件事:
- 已轉譯的標記。
- 產生該標記的 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:
- Use a
page.on('response')
handler to listen for network responses. - Stashes the responses of local stylesheets.
- Finds all
<link rel="stylesheet">
in the DOM and replaces them with an equivalent<style>
. Seepage.$$eval
API docs. Thestyle.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 會為您處理。