TL;DR
無頭 Chrome 是一個(gè)將動(dòng)態(tài) JS 頁面轉(zhuǎn)成靜態(tài) HTML 頁面的即插即用的解決方案。將其運(yùn)行于 web 服務(wù)器之上,你可以預(yù)渲染任何現(xiàn)代 JS 特性,從而提速內(nèi)容加載,并且是可被搜索引擎索引的。
本篇文章介紹的技術(shù),旨在教大家如何使用 Puppeteer 的 API,給一個(gè) Express 服務(wù)器添加服務(wù)端渲染(SSR)能力。最棒的地方是,應(yīng)用本身幾乎不需要修改任何代碼。無頭 Chrome 做了所有的重活。三兩行代碼,SSR 頁面帶回家。
大餐之前先來點(diǎn)甜點(diǎn):
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;
}
注意: 我會(huì)在文章中使用 ES 模塊(import),這要求 Node 8.5.0+,并在運(yùn)行時(shí)加上 --experimental-modules 標(biāo)志。覺得麻煩的話可以自行使用 require() 語句。關(guān)于 Node 上的 ES 模塊支持可以讀讀這篇文章。
導(dǎo)論
如果我對(duì) SEO 理解沒有偏差的話,你讀到這篇文章可能因?yàn)橄旅鎯蓚€(gè)原因之一。首先,你已經(jīng)搭建了一個(gè) web 應(yīng)用,并且它沒有被搜索引擎索引!你的應(yīng)用可能是 SPA,PWA,使用了 vanilla JS,或者使用了其他更復(fù)雜的框架或類庫。老實(shí)說,你使用何種技術(shù)并不重要。重要的是,你花費(fèi)了大量時(shí)間搭建出優(yōu)秀的 web 頁面,然而用戶卻搜不到它。你讀這篇文章的另一個(gè)理由可能是因?yàn)?,網(wǎng)上一些文章說了服務(wù)端渲染可以提升性能。你希望快速減少 JavaScript 啟動(dòng)時(shí)間,提升首次有效繪制速度。
一些框架,比如 Preact 使用了工具來實(shí)現(xiàn)服務(wù)端渲染。如果你使用的框架具備預(yù)渲染的解決方案,請(qǐng)繼續(xù)使用。沒有任何理由引入另一個(gè)工具(無頭 Chrome / Puppeteer)。
爬取現(xiàn)代網(wǎng)站
搜索引擎爬蟲,社交平臺(tái),甚至瀏覽器自誕生至今就唯一依賴于靜態(tài) HTML 標(biāo)記,來索引 web 頁面和表層內(nèi)容。現(xiàn)代 web 頁面已經(jīng)演變的大為不同?;?JavaScript 的應(yīng)用,在很多時(shí)候,需要保持網(wǎng)站內(nèi)容是對(duì)于爬取工具是可見的。
一些爬蟲,比如 Google 搜索,已經(jīng)變得更智能了!Google 的爬蟲使用 Chrome 41 執(zhí)行 JavaScript,并渲染出最終的頁面。但是這個(gè)方案才剛出來,還不完美。舉個(gè)例子,使用了新特性的頁面,比如 ES6 Class,模塊,箭頭函數(shù)等,將會(huì)在這個(gè)比較老的瀏覽器上報(bào)錯(cuò),使得頁面不能正確渲染。至于其他搜索引擎,鬼知道它們?cè)诟陕铮??ˉ_(ツ)_/ˉ
使用無頭 Chrome 預(yù)渲染頁面
所有的爬蟲程序都能夠理解 HTML。我們要“解決”索引問題的話需要一個(gè)工具,它來執(zhí)行 JS 生成 HTML。我不會(huì)告訴你現(xiàn)在已經(jīng)有這樣一個(gè)工具了!
- 該工具可以運(yùn)行所有類型的現(xiàn)代 JavaScript,并吐出靜態(tài) HTML。
- 出現(xiàn)新特性時(shí),該工具可以保持更新
- 已有應(yīng)用上只需少量代碼就可以運(yùn)行這個(gè)工具
聽起來很不錯(cuò)吧?這個(gè)工具就是瀏覽器!
無頭 Chrome 不在乎你使用什么庫、框架或者工具。它將 JavaScript 作為早餐,在午飯前吐出靜態(tài) HTML。可能會(huì)更快一點(diǎn) :) -Eric
如果你用的 Node,Puppeteer 容易上手。它的 API 提供了預(yù)渲染客戶端應(yīng)用的能力。下面用個(gè)例子演示下。
1. JS 應(yīng)用示例
我們以一個(gè) JavaScript 生成 HTML 的動(dòng)態(tài)頁面為例:
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: 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>
2. 服務(wù)端渲染函數(shù)
接下來,我們會(huì)使用之前提到的 ssr() 函數(shù),并充實(shí)它的內(nèi)容。
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 對(duì)于加速響應(yīng)時(shí)間居功至偉。當(dāng)頁面再次有請(qǐng)求過來,避免了無頭 Chrome 的重復(fù)執(zhí)行。我隨后會(huì)討論其他的優(yōu)化 。
- 添加加載頁面超時(shí)時(shí)的基本錯(cuò)誤處理。
- 添加了
page.waitForSelector('#posts')這行代碼。確保在丟棄這個(gè)序列化頁面之前,posts 節(jié)點(diǎn)存在于 DOM 之中。 - 記錄無頭瀏覽器渲染頁面所用時(shí)間。
- 代碼都被封裝進(jìn)名為
ssr.mjs的模塊中。
3. web 服務(wù)器示例
最后,一個(gè)小的 express 服務(wù)器完成了所有的工作。它預(yù)渲染 URL http://localhost/index.html(主頁),并在響應(yīng)中返回渲染結(jié)果。由于響應(yīng)中包含了靜態(tài) HTML, 當(dāng)用戶訪問頁面,posts 節(jié)點(diǎn)會(huì)立刻呈現(xiàn)。
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'));
要運(yùn)行這個(gè)例子,需安裝依賴 (npm i --save puppeteer express),然后使用 Node 8.5.0+ 并帶有 --experimental-modules 標(biāo)志來運(yùn)行服務(wù)器。
這是一個(gè)該服務(wù)器返回的響應(yīng)示例:
<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 的一個(gè)最佳用例
Server-Timing API 支持將服務(wù)器性能指標(biāo)(比如請(qǐng)求/響應(yīng)時(shí)間,數(shù)據(jù)庫查詢)返回給瀏覽器??蛻舳丝梢允褂眠@些信息來追蹤 web 應(yīng)用的所有性能數(shù)據(jù)。
Server-Timing 的一個(gè)最佳用例是上報(bào)無頭 Chrome 預(yù)渲染頁面的時(shí)間!只需在響應(yīng)上添加 Server-Timing 頭,就可以實(shí)現(xiàn)這一點(diǎn):
res.set('Server-Timing', `Prerender;dur=1000;desc="Headless render time (ms)"`);
客戶端上,Performance Timeline API 和 PerformanceObserver 可以獲取這些指標(biāo):
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)"
}
性能結(jié)果
注意: 這些數(shù)據(jù)體現(xiàn)了我隨后討論的大多數(shù)性能優(yōu)化。
性能數(shù)據(jù)怎么樣?在我的一個(gè)應(yīng)用(代碼)上,無頭 Chrome 渲染頁面大約需要 1s。頁面被緩存后, 3G 低網(wǎng)速模擬下,FCP 要比客戶端渲染版本的快 8.37s。
| ? | 首次繪制 (FP) | 首次內(nèi)容繪制 (FCP) |
|---|---|---|
| 客戶端渲染 | 4s | 11s |
| 服務(wù)端渲染 | 2.3s | ~2.3s |
這些結(jié)果很有用。因?yàn)榉?wù)端渲染頁面不再依賴于 JavaScript 的加載,用戶看到有意義的內(nèi)容比以前快得多。
Preventing re-hydration
還記得我說“我們無需在客戶端應(yīng)用上改任何代碼”嗎?那是騙你們的。
Express 應(yīng)用接收請(qǐng)求,使用 Puppeteer 將頁面加載進(jìn)無頭瀏覽器,然后在響應(yīng)中返回結(jié)果。但這里有一個(gè)問題。
瀏覽器加載頁面時(shí),無頭 Chrome 中相同的 JS 會(huì)在服務(wù)器上再次執(zhí)行。有兩處都在生成 HTML。
一起來修復(fù)這個(gè)問題。我們要告知頁面,它的 HTML 早就名花有主了。我找到的解決方案是,在頁面加載時(shí)判斷 <ul id="posts"> 是否已在 DOM 中,如果在,頁面就已經(jīng)在服務(wù)端渲染過了,這樣就可以避免重新創(chuàng)建 DOM。
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>
優(yōu)化
除了緩存渲染結(jié)果之外,還有一些有趣的優(yōu)化技巧。有的優(yōu)化可以快速見效,而有的可能帶有猜測(cè)性的。
中止不必要的請(qǐng)求
現(xiàn)在,整個(gè)頁面(以及它請(qǐng)求的所有資源)都無腦地加載進(jìn)無頭 Chrome。然而,我們只關(guān)注于兩件事情:
- 渲染 HTML
- 生成 HTML 的 JS
不構(gòu)造 DOM 的網(wǎng)絡(luò)請(qǐng)求是浪費(fèi)的。一些資源,比如圖片、字體、樣式表和媒體內(nèi)容,不參與頁面的 HTML 構(gòu)建。它們負(fù)責(zé)添加樣式,補(bǔ)充頁面的結(jié)構(gòu),但并不顯式地創(chuàng)建頁面。我們應(yīng)該告訴瀏覽器去忽略掉這些資源!這樣可以減少無頭 Chrome 的工作負(fù)擔(dān),從而節(jié)省帶寬,并且潛在地加速了大型頁面的預(yù)渲染時(shí)間。
Protocol 開發(fā)者工具提供了一個(gè)強(qiáng)大的特性,叫做網(wǎng)絡(luò)攔截。它可以用于在瀏覽器發(fā)出之前修改請(qǐng)求。Puppeteer 也支持網(wǎng)絡(luò)攔截,它是通過打開 page.setRequestInterception(true),監(jiān)聽頁面的 request 事件來實(shí)現(xiàn)的。這樣我們可以中止某些資源請(qǐng)求。
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 whitelist = ['document', 'script', 'xhr', 'fetch'];
if (!whitelist.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};
}
注意: 安全起見,我使用了一個(gè)白名單,允許所有其他類型的請(qǐng)求能夠繼續(xù)正常發(fā)出。預(yù)先避免中止掉其他必要的請(qǐng)求。
內(nèi)聯(lián)關(guān)鍵資源
使用構(gòu)建工具(比如 gulp)編譯應(yīng)用,并在構(gòu)建時(shí)將關(guān)鍵 CSS/JS 內(nèi)聯(lián)到頁面內(nèi),是一種很常見的做法。由于瀏覽器初始化頁面加載時(shí)的請(qǐng)求數(shù)更少了,這樣也就加速了首次有效繪制時(shí)間。
別用構(gòu)建工具了,瀏覽器就是你的構(gòu)建工具!我們可以用 Puppeteer 管理頁面 DOM,內(nèi)聯(lián)樣式,JavaScript, 或者其他任何你想在預(yù)渲染之前加到頁面中的東西。
這個(gè)例子演示了如何攔截本地樣式表的響應(yīng),并將這些資源內(nèi)聯(lián)進(jìn) <style> 標(biāo)簽中:
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};
}
這段代碼:
- 使用一個(gè)
page.on('response')處理器來監(jiān)聽網(wǎng)絡(luò)響應(yīng)。 - 儲(chǔ)藏本地樣式表的響應(yīng)。
- 找到 DOM 中所有的
<link rel="stylesheet">,將它們替換成一個(gè)等價(jià)的<style>。具體見page.$$evalAPI 文檔。style.textContent被設(shè)為樣式表的響應(yīng)內(nèi)容。
自動(dòng)壓縮資源
另一個(gè)可以借助網(wǎng)絡(luò)攔截玩的小把戲是修改請(qǐng)求的響應(yīng)內(nèi)容。
舉個(gè)例子,你想要壓縮 CSS,但也希望開發(fā)階段不要被壓縮,這樣開發(fā)時(shí)能方便些。假設(shè)你已經(jīng)用另一個(gè)工具來預(yù)壓縮 styles.css,可以用 Request.respond(),將 styles.css 的內(nèi)容重寫為 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 實(shí)例實(shí)現(xiàn)交叉渲染
每次預(yù)渲染都啟動(dòng)新的瀏覽器會(huì)很浪費(fèi)。相反,你希望只啟動(dòng)一個(gè)實(shí)例,然后在多個(gè)頁面渲染時(shí)重用它。
Puppeteer 可以通過調(diào)用 puppeteer.connect(),連接到一個(gè)已有的 Chrome 實(shí)例,它接收實(shí)例的遠(yuǎn)程調(diào)試 URL 作為參數(shù)。為保證瀏覽器實(shí)例的長(zhǎng)時(shí)間運(yùn)行,我們可以將 ssr() 函數(shù)啟動(dòng) Chrome 這部分代碼移到 Express 服務(wù)器里。
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};
}
例子:實(shí)現(xiàn)周期性預(yù)渲染的定時(shí)任務(wù)
在 App 引擎面板應(yīng)用 里,我創(chuàng)建了一個(gè)定時(shí)處理器,來周期性的重復(fù)渲染排名前幾位的頁面。幫助用戶快速看到最新內(nèi)容,他們根本感知不到一個(gè)新頁面的啟動(dòng)性能消耗。在這個(gè)例子中,生成多個(gè) Chrome 實(shí)例會(huì)很浪費(fèi)。相反,我用了一個(gè)共享的瀏覽器實(shí)例來一次性渲染這些頁面。
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!');
});
我還在 ssr.js export 上加了一個(gè) clearCache() 函數(shù)。
...
function clearCache() {
RENDER_CACHE.clear();
}
export {ssr, clearCache};
其他因素
告訴頁面:“你正在被無頭瀏覽器渲染”
當(dāng)頁面正在服務(wù)器上的無頭 Chrome 中渲染時(shí),客戶端邏輯很有必要知道這一信息。我的應(yīng)用使用了鉤子來“關(guān)閉”部分不參與渲染 post 節(jié)點(diǎn)的頁面。舉例來說,我禁用了懶加載 firebase-auth.js 這部分代碼。根本不需要用戶登錄!
在 URL 上加一個(gè) ?headless 參數(shù),是一個(gè)給頁面加鉤子的簡(jiǎn)單方法:
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};
}
可以在頁面內(nèi)查詢?cè)搮?shù):
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>
Tip:Page.evaluateOnNewDocument() 也可以方便的查詢參數(shù)。它會(huì)在頁面中注入代碼,讓 Puppeteer 在頁面中剩余待執(zhí)行的 JavaScript 之前運(yùn)行這些代碼。
避免 PV 膨脹
你如果正在頁面上使用分析工具,那么要小心了。預(yù)渲染的頁面可能會(huì)造成 PV 出現(xiàn)膨脹。具體來說,打點(diǎn)數(shù)據(jù)將會(huì)提升2倍,一半是在無頭 Chrome 渲染時(shí),另一半出現(xiàn)在用戶瀏覽器渲染時(shí)。
那么怎么修復(fù)這個(gè)問題呢?將所有加載分析腳本的請(qǐng)求攔截掉。
page.on('request', req => {
// Don't load Google Analytics lib requests so pageviews aren't 2x.
const blacklist = ['www.google-analytics.com', '/gtag/js', 'ga.js', 'analytics.js'];
if (blacklist.find(regex => req.url().match(regex))) {
return req.abort();
}
...
req.continue();
});
代碼不加載,頁面訪問就不會(huì)被記錄。真 Skr 個(gè)機(jī)靈鬼 ??。
或者,你也可以繼續(xù)加載分析腳本,來獲悉服務(wù)器上運(yùn)行的預(yù)渲染器數(shù)。
結(jié)論
Puppeteer 通過運(yùn)行無頭 Chrome,不費(fèi)吹灰之力就實(shí)現(xiàn)了服務(wù)端渲染。提升加載性能和沒有改動(dòng)大量代碼就增強(qiáng)了應(yīng)用的可索引性,是這個(gè)方案中我最喜歡的“特性”。
注意: 如果你對(duì)文章中描述的技術(shù)感興趣,可以去看看這個(gè)應(yīng)用,以及它的代碼。
附錄
現(xiàn)有技術(shù)的討論
很難在服務(wù)端上渲染客戶端應(yīng)用。有多難?去看看大家給這個(gè)話題奉獻(xiàn)了多少個(gè) npm 包就知道了。有數(shù)不清的模式,工具,和服務(wù)來輔助服務(wù)端渲染的 JS 應(yīng)用。
同構(gòu) JavaScript
同構(gòu) JavaScript 的概念很簡(jiǎn)單:同樣的代碼既能在服務(wù)端運(yùn)行,也能在客戶端(瀏覽器)運(yùn)行。服務(wù)器和客戶端共享代碼,美滋滋!
實(shí)踐中,我發(fā)現(xiàn)同構(gòu) JS 很難實(shí)現(xiàn)。這是我自己的問題...
我最近開始做一個(gè)項(xiàng)目,嘗試下 lit-html。Lit 是一個(gè)優(yōu)秀的庫,它可以允許你寫使用 JS 模板字符串寫 HTML <template>,然后高效地將這些模板渲染為 DOM。問題是它的核心特性(使用
<template>元素)只能在瀏覽器上工作。這意味著它在 Node 服務(wù)器上不能運(yùn)行。我希望 Node 和前端共享的 SSR 代碼能夠脫離 window 對(duì)象。
最后我意識(shí)到可以使用無頭 Chrome 來服務(wù)端渲染應(yīng)用,Chrome 是經(jīng)用戶的手運(yùn)行或是在服務(wù)器上自動(dòng)運(yùn)行并不重要,它反正是愉快地執(zhí)行了所有 JS。不要多問。
無頭 Chrome 在服務(wù)器和客戶端上啟用 “同構(gòu) JS”。它對(duì)于當(dāng)前庫不支持服務(wù)端(Node)給出了一個(gè)不錯(cuò)的解決方案。
預(yù)渲染工具
Node 社區(qū)已經(jīng)誕生了好幾噸解決服務(wù)端渲染 JS 應(yīng)用的工具。毫無新意!個(gè)人而言,我發(fā)現(xiàn)各人對(duì)于這些工具的體會(huì)可能不同,所以使用這些工具前肯定要做好功課。比如說,一些服務(wù)端渲染工具比較老,并且沒有使用無頭 Chrome(或者任何其他無頭瀏覽器)。相反,它們使用 PhantomJS(又名舊 Safari),這意味著使用新特性時(shí)頁面不會(huì)正確渲染。
一個(gè)值得注意的例外是 Prerender。Prerender 使用了無頭 Chrome 和 Express 中間件。
const prerender = require('prerender');
const server = prerender();
server.use(prerender.removeScriptTags());
server.use(prerender.blockResources());
server.start();
Prerender 省去了跨平臺(tái)下載和安裝 Chrome 的所有細(xì)節(jié)。要正確完成這一過程通常是相當(dāng)棘手的,這也是 Puppeteer 存在的原因之一。我也提了一些渲染我的部分應(yīng)用的 issue。