【譯】無頭 Chrome:服務(wù)端渲染 JS 頁面的一個解決方案

TL;DR

無頭 Chrome 是一個將動態(tài) JS 頁面轉(zhuǎn)成靜態(tài) HTML 頁面的即插即用的解決方案。將其運行于 web 服務(wù)器之上,你可以預(yù)渲染任何現(xiàn)代 JS 特性,從而提速內(nèi)容加載,并且是可被搜索引擎索引的

本篇文章介紹的技術(shù)楷力,旨在教大家如何使用 Puppeteer 的 API,給一個 Express 服務(wù)器添加服務(wù)端渲染(SSR)能力孵户。最棒的地方是萧朝,應(yīng)用本身幾乎不需要修改任何代碼。無頭 Chrome 做了所有的重活夏哭。三兩行代碼检柬,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;
}

注意: 我會在文章中使用 ES 模塊(import)竖配,這要求 Node 8.5.0+厕吉,并在運行時加上 --experimental-modules 標志。覺得麻煩的話可以自行使用 require() 語句械念。關(guān)于 Node 上的 ES 模塊支持可以讀讀這篇文章头朱。


導(dǎo)論


如果我對 SEO 理解沒有偏差的話,你讀到這篇文章可能因為下面兩個原因之一龄减。首先项钮,你已經(jīng)搭建了一個 web 應(yīng)用,并且它沒有被搜索引擎索引!你的應(yīng)用可能是 SPA烁巫,PWA署隘,使用了 vanilla JS,或者使用了其他更復(fù)雜的框架或類庫亚隙。老實說磁餐,你使用何種技術(shù)并不重要。重要的是阿弃,你花費了大量時間搭建出優(yōu)秀的 web 頁面诊霹,然而用戶卻搜不到它。你讀這篇文章的另一個理由可能是因為渣淳,網(wǎng)上一些文章說了服務(wù)端渲染可以提升性能脾还。你希望快速減少 JavaScript 啟動時間,提升首次有效繪制速度入愧。

一些框架鄙漏,比如 Preact 使用了工具來實現(xiàn)服務(wù)端渲染。如果你使用的框架具備預(yù)渲染的解決方案棺蛛,請繼續(xù)使用正罢。沒有任何理由引入另一個工具(無頭 Chrome / Puppeteer)随夸。

爬取現(xiàn)代網(wǎng)站

搜索引擎爬蟲哩俭,社交平臺竞膳,甚至瀏覽器自誕生至今就唯一依賴于靜態(tài) HTML 標記,來索引 web 頁面和表層內(nèi)容⊥瘢現(xiàn)代 web 頁面已經(jīng)演變的大為不同■伲基于 JavaScript 的應(yīng)用声离,在很多時候,需要保持網(wǎng)站內(nèi)容是對于爬取工具是可見的瘫怜。

一些爬蟲术徊,比如 Google 搜索,已經(jīng)變得更智能了鲸湃!Google 的爬蟲使用 Chrome 41 執(zhí)行 JavaScript赠涮,并渲染出最終的頁面。但是這個方案才剛出來暗挑,還不完美笋除。舉個例子,使用了新特性的頁面炸裆,比如 ES6 Class垃它,模塊,箭頭函數(shù)等,將會在這個比較老的瀏覽器上報錯国拇,使得頁面不能正確渲染洛史。至于其他搜索引擎,鬼知道它們在干嘛=戳摺也殖?ˉ_(ツ)_/ˉ

使用無頭 Chrome 預(yù)渲染頁面


所有的爬蟲程序都能夠理解 HTML。我們要“解決”索引問題的話需要一個工具务热,它來執(zhí)行 JS 生成 HTML忆嗜。我不會告訴你現(xiàn)在已經(jīng)有這樣一個工具了!

  1. 該工具可以運行所有類型的現(xiàn)代 JavaScript陕习,并吐出靜態(tài) HTML霎褐。
  2. 出現(xiàn)新特性時,該工具可以保持更新
  3. 已有應(yīng)用上只需少量代碼就可以運行這個工具

聽起來很不錯吧该镣?這個工具就是瀏覽器冻璃!

無頭 Chrome 不在乎你使用什么庫、框架或者工具损合。它將 JavaScript 作為早餐省艳,在午飯前吐出靜態(tài) HTML〖奚螅可能會更快一點 :) -Eric

如果你用的 Node跋炕,Puppeteer 容易上手。它的 API 提供了預(yù)渲染客戶端應(yīng)用的能力律适。下面用個例子演示下辐烂。

1. JS 應(yīng)用示例

我們以一個 JavaScript 生成 HTML 的動態(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ù)

接下來,我們會使用之前提到的 ssr() 函數(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};

主要的變化:

  1. 添加了緩存。緩存已渲染的 HTML 對于加速響應(yīng)時間居功至偉厂僧。當(dāng)頁面再次有請求過來扣草,避免了無頭 Chrome 的重復(fù)執(zhí)行。我隨后會討論其他的優(yōu)化 颜屠。
  2. 添加加載頁面超時時的基本錯誤處理辰妙。
  3. 添加了 page.waitForSelector('#posts') 這行代碼。確保在丟棄這個序列化頁面之前甫窟,posts 節(jié)點存在于 DOM 之中密浑。
  4. 記錄無頭瀏覽器渲染頁面所用時間。
  5. 代碼都被封裝進名為 ssr.mjs 的模塊中粗井。

3. web 服務(wù)器示例

最后肴掷,一個小的 express 服務(wù)器完成了所有的工作敬锐。它預(yù)渲染 URL http://localhost/index.html(主頁),并在響應(yīng)中返回渲染結(jié)果呆瞻。由于響應(yīng)中包含了靜態(tài) HTML台夺, 當(dāng)用戶訪問頁面,posts 節(jié)點會立刻呈現(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')); 

要運行這個例子颤介,需安裝依賴 (npm i --save puppeteer express),然后使用 Node 8.5.0+ 并帶有 --experimental-modules 標志來運行服務(wù)器赞赖。

這是一個該服務(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 的一個最佳用例

Server-Timing API 支持將服務(wù)器性能指標(比如請求/響應(yīng)時間滚朵,數(shù)據(jù)庫查詢)返回給瀏覽器∏坝颍客戶端可以使用這些信息來追蹤 web 應(yīng)用的所有性能數(shù)據(jù)辕近。

Server-Timing 的一個最佳用例是上報無頭 Chrome 預(yù)渲染頁面的時間!只需在響應(yīng)上添加 Server-Timing 頭匿垄,就可以實現(xiàn)這一點:

res.set('Server-Timing',  `Prerender;dur=1000;desc="Headless render time (ms)"`);  

客戶端上移宅,Performance Timeline 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)"
}

性能結(jié)果

注意: 這些數(shù)據(jù)體現(xiàn)了我隨后討論的大多數(shù)性能優(yōu)化

性能數(shù)據(jù)怎么樣椿疗?在我的一個應(yīng)用(代碼)上漏峰,無頭 Chrome 渲染頁面大約需要 1s。頁面被緩存后届榄, 3G 低網(wǎng)速模擬下浅乔,FCP 要比客戶端渲染版本的快 8.37s

? 首次繪制 (FP) 首次內(nèi)容繪制 (FCP)
客戶端渲染 4s 11s
服務(wù)端渲染 2.3s ~2.3s

這些結(jié)果很有用铝条。因為服務(wù)端渲染頁面不再依賴于 JavaScript 的加載靖苇,用戶看到有意義的內(nèi)容比以前快得多。


Preventing re-hydration


還記得我說“我們無需在客戶端應(yīng)用上改任何代碼”嗎班缰?那是騙你們的贤壁。

Express 應(yīng)用接收請求,使用 Puppeteer 將頁面加載進無頭瀏覽器鲁捏,然后在響應(yīng)中返回結(jié)果芯砸。但這里有一個問題萧芙。

瀏覽器加載頁面時给梅,無頭 Chrome 中相同的 JS 會在服務(wù)器上再次執(zhí)行。有兩處都在生成 HTML双揪。

一起來修復(fù)這個問題动羽。我們要告知頁面,它的 HTML 早就名花有主了渔期。我找到的解決方案是运吓,在頁面加載時判斷 <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)化可以快速見效瓮床,而有的可能帶有猜測性的。

中止不必要的請求

現(xiàn)在产镐,整個頁面(以及它請求的所有資源)都無腦地加載進無頭 Chrome隘庄。然而,我們只關(guān)注于兩件事情:

  1. 渲染 HTML
  2. 生成 HTML 的 JS

不構(gòu)造 DOM 的網(wǎng)絡(luò)請求是浪費的癣亚。一些資源丑掺,比如圖片、字體述雾、樣式表和媒體內(nèi)容街州,不參與頁面的 HTML 構(gòu)建。它們負責(zé)添加樣式绰咽,補充頁面的結(jié)構(gòu)菇肃,但并不顯式地創(chuàng)建頁面。我們應(yīng)該告訴瀏覽器去忽略掉這些資源取募!這樣可以減少無頭 Chrome 的工作負擔(dān)琐谤,從而節(jié)省帶寬,并且潛在地加速了大型頁面的預(yù)渲染時間玩敏。

Protocol 開發(fā)者工具提供了一個強大的特性斗忌,叫做網(wǎng)絡(luò)攔截。它可以用于在瀏覽器發(fā)出之前修改請求旺聚。Puppeteer 也支持網(wǎng)絡(luò)攔截织阳,它是通過打開 page.setRequestInterception(true),監(jiān)聽頁面的 request 事件來實現(xiàn)的砰粹。這樣我們可以中止某些資源請求唧躲。

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

注意: 安全起見,我使用了一個白名單碱璃,允許所有其他類型的請求能夠繼續(xù)正常發(fā)出弄痹。預(yù)先避免中止掉其他必要的請求。

內(nèi)聯(lián)關(guān)鍵資源

使用構(gòu)建工具(比如 gulp)編譯應(yīng)用嵌器,并在構(gòu)建時將關(guān)鍵 CSS/JS 內(nèi)聯(lián)到頁面內(nèi)肛真,是一種很常見的做法。由于瀏覽器初始化頁面加載時的請求數(shù)更少了爽航,這樣也就加速了首次有效繪制時間蚓让。

別用構(gòu)建工具了乾忱,瀏覽器就是你的構(gòu)建工具!我們可以用 Puppeteer 管理頁面 DOM历极,內(nèi)聯(lián)樣式窄瘟,JavaScript, 或者其他任何你想在預(yù)渲染之前加到頁面中的東西趟卸。

這個例子演示了如何攔截本地樣式表的響應(yīng)寞肖,并將這些資源內(nèi)聯(lián)進 <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};
}

這段代碼:

  1. 使用一個 page.on('response') 處理器來監(jiān)聽網(wǎng)絡(luò)響應(yīng)。
  2. 儲藏本地樣式表的響應(yīng)衰腌。
  3. 找到 DOM 中所有的 <link rel="stylesheet">新蟆,將它們替換成一個等價的 <style>。具體見 page.$$eval API 文檔右蕊。style.textContent 被設(shè)為樣式表的響應(yīng)內(nèi)容琼稻。

自動壓縮資源

另一個可以借助網(wǎng)絡(luò)攔截玩的小把戲是修改請求的響應(yīng)內(nèi)容

舉個例子饶囚,你想要壓縮 CSS帕翻,但也希望開發(fā)階段不要被壓縮,這樣開發(fā)時能方便些萝风。假設(shè)你已經(jīng)用另一個工具來預(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 實例實現(xiàn)交叉渲染

每次預(yù)渲染都啟動新的瀏覽器會很浪費睬塌。相反,你希望只啟動一個實例歇万,然后在多個頁面渲染時重用它揩晴。

Puppeteer 可以通過調(diào)用 puppeteer.connect(),連接到一個已有的 Chrome 實例贪磺,它接收實例的遠程調(diào)試 URL 作為參數(shù)硫兰。為保證瀏覽器實例的長時間運行,我們可以將 ssr() 函數(shù)啟動 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};
}

例子:實現(xiàn)周期性預(yù)渲染的定時任務(wù)

App 引擎面板應(yīng)用 里劫映,我創(chuàng)建了一個定時處理器,來周期性的重復(fù)渲染排名前幾位的頁面刹前。幫助用戶快速看到最新內(nèi)容泳赋,他們根本感知不到一個新頁面的啟動性能消耗。在這個例子中腮郊,生成多個 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!');
});

我還在 ssr.js export 上加了一個 clearCache() 函數(shù)衅鹿。

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

export {ssr, clearCache};


其他因素


告訴頁面:“你正在被無頭瀏覽器渲染”

當(dāng)頁面正在服務(wù)器上的無頭 Chrome 中渲染時,客戶端邏輯很有必要知道這一信息过咬。我的應(yīng)用使用了鉤子來“關(guān)閉”部分不參與渲染 post 節(jié)點的頁面大渤。舉例來說,我禁用了懶加載 firebase-auth.js 這部分代碼掸绞。根本不需要用戶登錄泵三!

在 URL 上加一個 ?headless 參數(shù),是一個給頁面加鉤子的簡單方法:

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)查詢該參數(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ù)衔掸。它會在頁面中注入代碼烫幕,讓 Puppeteer 在頁面中剩余待執(zhí)行的 JavaScript 之前運行這些代碼。

避免 PV 膨脹

你如果正在頁面上使用分析工具敞映,那么要小心了较曼。預(yù)渲染的頁面可能會造成 PV 出現(xiàn)膨脹。具體來說振愿,打點數(shù)據(jù)將會提升2倍捷犹,一半是在無頭 Chrome 渲染時,另一半出現(xiàn)在用戶瀏覽器渲染時冕末。

那么怎么修復(fù)這個問題呢萍歉?將所有加載分析腳本的請求攔截掉。

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

代碼不加載档桃,頁面訪問就不會被記錄枪孩。真 Skr 個機靈鬼 ??。

或者藻肄,你也可以繼續(xù)加載分析腳本销凑,來獲悉服務(wù)器上運行的預(yù)渲染器數(shù)。


結(jié)論


Puppeteer 通過運行無頭 Chrome仅炊,不費吹灰之力就實現(xiàn)了服務(wù)端渲染斗幼。提升加載性能沒有改動大量代碼就增強了應(yīng)用的可索引性,是這個方案中我最喜歡的“特性”抚垄。

注意: 如果你對文章中描述的技術(shù)感興趣蜕窿,可以去看看這個應(yīng)用,以及它的代碼呆馁。


附錄


現(xiàn)有技術(shù)的討論

很難在服務(wù)端上渲染客戶端應(yīng)用桐经。有多難?去看看大家給這個話題奉獻了多少個 npm 包就知道了浙滤。有數(shù)不清的模式阴挣,工具,和服務(wù)來輔助服務(wù)端渲染的 JS 應(yīng)用纺腊。

同構(gòu) JavaScript

同構(gòu) JavaScript 的概念很簡單:同樣的代碼既能在服務(wù)端運行畔咧,也能在客戶端(瀏覽器)運行茎芭。服務(wù)器和客戶端共享代碼,美滋滋誓沸!

實踐中梅桩,我發(fā)現(xiàn)同構(gòu) JS 很難實現(xiàn)。這是我自己的問題...

我最近開始做一個項目拜隧,嘗試下 lit-html宿百。Lit 是一個優(yōu)秀的庫,它可以允許你寫使用 JS 模板字符串寫 HTML <template>洪添,然后高效地將這些模板渲染為 DOM垦页。問題是它的核心特性(使用 <template> 元素)只能在瀏覽器上工作。這意味著它在 Node 服務(wù)器上不能運行干奢。我希望 Node 和前端共享的 SSR 代碼能夠脫離 window 對象外臂。

最后我意識到可以使用無頭 Chrome 來服務(wù)端渲染應(yīng)用,Chrome 是經(jīng)用戶的手運行或是在服務(wù)器上自動運行并不重要律胀,它反正是愉快地執(zhí)行了所有 JS宋光。不要多問。

無頭 Chrome 在服務(wù)器和客戶端上啟用 “同構(gòu) JS”炭菌。它對于當(dāng)前庫不支持服務(wù)端(Node)給出了一個不錯的解決方案罪佳。

預(yù)渲染工具

Node 社區(qū)已經(jīng)誕生了好幾噸解決服務(wù)端渲染 JS 應(yīng)用的工具。毫無新意黑低!個人而言赘艳,我發(fā)現(xiàn)各人對于這些工具的體會可能不同,所以使用這些工具前肯定要做好功課克握。比如說蕾管,一些服務(wù)端渲染工具比較老,并且沒有使用無頭 Chrome(或者任何其他無頭瀏覽器)菩暗。相反掰曾,它們使用 PhantomJS(又名舊 Safari),這意味著使用新特性時頁面不會正確渲染停团。

一個值得注意的例外是 Prerender旷坦。Prerender 使用了無頭 Chrome 和 Express 中間件

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

Prerender 省去了跨平臺下載和安裝 Chrome 的所有細節(jié)佑稠。要正確完成這一過程通常是相當(dāng)棘手的秒梅,這也是 Puppeteer 存在的原因之一。我也提了一些渲染我的部分應(yīng)用的 issue舌胶。

瀏覽器中渲染的 Chrome 狀態(tài)
prerender 渲染的 Chrome 狀態(tài)
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末捆蜀,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌辆它,老刑警劉巖誊薄,帶你破解...
    沈念sama閱讀 222,104評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異娩井,居然都是意外死亡,警方通過查閱死者的電腦和手機似袁,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,816評論 3 399
  • 文/潘曉璐 我一進店門洞辣,熙熙樓的掌柜王于貴愁眉苦臉地迎上來扬霜,“玉大人著瓶,你說我怎么就攤上這事材原∽庸危” “怎么了?”我有些...
    開封第一講書人閱讀 168,697評論 0 360
  • 文/不壞的土叔 我叫張陵狭姨,是天一觀的道長。 經(jīng)常有香客問我,道長栋豫,這世上最難降的妖魔是什么嫩絮? 我笑而不...
    開封第一講書人閱讀 59,836評論 1 298
  • 正文 為了忘掉前任杠步,我火速辦了婚禮试躏,結(jié)果婚禮上助析,老公的妹妹穿的比我還像新娘雪隧。我一直安慰自己庄拇,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 68,851評論 6 397
  • 文/花漫 我一把揭開白布袱巨。 她就那樣靜靜地躺著笛厦,像睡著了一般纳鼎。 火紅的嫁衣襯著肌膚如雪贱鄙。 梳的紋絲不亂的頭發(fā)上逗宁,一...
    開封第一講書人閱讀 52,441評論 1 310
  • 那天宫补,我揣著相機與錄音粉怕,去河邊找鬼健民。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的凤优。 我是一名探鬼主播悦陋,決...
    沈念sama閱讀 40,992評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼筑辨!你這毒婦竟也來了俺驶?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,899評論 0 276
  • 序言:老撾萬榮一對情侶失蹤棍辕,失蹤者是張志新(化名)和其女友劉穎暮现,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體楚昭,經(jīng)...
    沈念sama閱讀 46,457評論 1 318
  • 正文 獨居荒郊野嶺守林人離奇死亡栖袋,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,529評論 3 341
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了抚太。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片塘幅。...
    茶點故事閱讀 40,664評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖尿贫,靈堂內(nèi)的尸體忽然破棺而出电媳,到底是詐尸還是另有隱情,我是刑警寧澤庆亡,帶...
    沈念sama閱讀 36,346評論 5 350
  • 正文 年R本政府宣布匾乓,位于F島的核電站,受9級特大地震影響又谋,放射性物質(zhì)發(fā)生泄漏拼缝。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 42,025評論 3 334
  • 文/蒙蒙 一彰亥、第九天 我趴在偏房一處隱蔽的房頂上張望咧七。 院中可真熱鬧,春花似錦剩愧、人聲如沸猪叙。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,511評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽穴翩。三九已至,卻和暖如春锦积,著一層夾襖步出監(jiān)牢的瞬間芒帕,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,611評論 1 272
  • 我被黑心中介騙來泰國打工丰介, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留背蟆,地道東北人鉴分。 一個月前我還...
    沈念sama閱讀 49,081評論 3 377
  • 正文 我出身青樓,卻偏偏與公主長得像带膀,于是被迫代替她去往敵國和親志珍。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,675評論 2 359

推薦閱讀更多精彩內(nèi)容