Chrome แบบ Headless: คำตอบสำหรับไซต์ JS การแสดงผลฝั่งเซิร์ฟเวอร์

ดูวิธีใช้ Puppeteer API เพื่อเพิ่มความสามารถในการแสดงผลฝั่งเซิร์ฟเวอร์ (SSR) ไปยังเว็บเซิร์ฟเวอร์ Express และส่วนที่ดีที่สุดคือแอป ต้องมีการเปลี่ยนแปลงโค้ดเพียงเล็กน้อย แบบ Headless ทำงานยก หนักสุด

ในโค้ด 2-3 บรรทัด คุณสามารถ 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 แบบ Headless

คุณอาจสนใจ Chrome แบบ Headless ในกรณีต่อไปนี้

  • คุณได้สร้างเว็บแอปที่เครื่องมือค้นหาไม่ได้จัดทำดัชนี
  • คุณคาดหวังว่าจะได้รับผลลัพธ์อย่างรวดเร็วในการเพิ่มประสิทธิภาพประสิทธิภาพ JavaScript และปรับปรุง First Media Paint

บางเฟรมเวิร์ก เช่น เตรียมการให้บริการด้วยเครื่องมือที่จัดการการแสดงผลฝั่งเซิร์ฟเวอร์ หากเฟรมเวิร์กของคุณมีโซลูชันการแสดงผลล่วงหน้า ให้ใช้โซลูชันดังกล่าวแทนการนำ Puppeteer และ Headless Chrome ไปใช้ในเวิร์กโฟลว์

การรวบรวมข้อมูลเว็บสมัยใหม่

ที่ผ่านมา โปรแกรมรวบรวมข้อมูลของเครื่องมือค้นหา แพลตฟอร์มการแชร์ผ่านโซเชียล และเบราว์เซอร์ต่างๆ เคยพึ่งพามาร์กอัป HTML แบบคงที่เพียงอย่างเดียวในการจัดทำดัชนีเว็บและแสดงเนื้อหา เว็บสมัยใหม่ได้พัฒนาไปอย่างมาก แอปพลิเคชันที่ใช้ JavaScript จะยังคงอยู่ ซึ่งหมายความว่าในหลายๆ กรณี เครื่องมือรวบรวมข้อมูลอาจมองไม่เห็นเนื้อหาของเรา

Googlebot ซึ่งเป็น Crawler ของ Search จะประมวลผล JavaScript ในขณะเดียวกันก็จะไม่ทำให้ประสบการณ์การใช้งานของผู้ใช้ที่เข้าชมเว็บไซต์แย่ลง มีความแตกต่างและข้อจำกัดบางอย่างที่คุณต้องคำนึงถึงเมื่อออกแบบหน้าเว็บและแอปพลิเคชันให้รองรับวิธีที่โปรแกรมรวบรวมข้อมูลเข้าถึงและแสดงเนื้อหา

หน้าที่แสดงผลล่วงหน้า

โปรแกรมรวบรวมข้อมูลทั้งหมดเข้าใจ HTML เพื่อให้แน่ใจว่าโปรแกรมรวบรวมข้อมูลจัดทำดัชนี JavaScript ได้ เราจำเป็นต้องใช้เครื่องมือที่

  • รู้วิธีเรียกใช้ JavaScript สมัยใหม่ทุกประเภทและสร้าง HTML แบบคงที่
  • ไม่พลาดทุกข่าวสารขณะที่เว็บเพิ่มฟีเจอร์ต่างๆ
  • ทำงานได้โดยมีการอัปเดตโค้ดเพียงเล็กน้อยหรือไม่ต้องติดตั้งเลย

น่าสนใจใช่ไหม เครื่องมือนั้นก็คือเบราว์เซอร์ Chrome แบบ Headless จะไม่สนใจไลบรารี เฟรมเวิร์ก หรือเครือเครื่องมือที่คุณใช้

ตัวอย่างเช่น ถ้าแอปพลิเคชันของคุณสร้างด้วย Node.js Puppeteer ก็เป็นวิธีที่ง่ายในการทำงานกับ Chrome 0.headless

เรามาเริ่มต้นด้วยหน้าเว็บแบบไดนามิกที่สร้าง HTML ด้วย JavaScript:

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

ตัวอย่างเว็บเซิร์ฟเวอร์

สุดท้าย นี่คือเซิร์ฟเวอร์แบบเร่งด่วนขนาดเล็กที่รวบรวมทุกอย่างไว้ด้วยกัน เครื่องจัดการหลักจะแสดงผลล่วงหน้าสำหรับ URL 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) และเรียกใช้เซิร์ฟเวอร์โดยใช้โหนด 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 ใหม่

API Server-Timing คือการสื่อสารเมตริกประสิทธิภาพของเซิร์ฟเวอร์ (เช่น เวลาส่งคำขอและเวลาในการตอบกลับ หรือการค้นหาฐานข้อมูล) กลับไปยังเบราว์เซอร์ รหัสไคลเอ็นต์สามารถใช้ข้อมูลนี้เพื่อติดตาม ประสิทธิภาพโดยรวมของเว็บแอป

กรณีการใช้งานที่ดีที่สุดสำหรับ Server-Timing คือการรายงานระยะเวลาที่ 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 แบบไม่มีส่วนหัวจะใช้เวลาประมาณ 1 วินาทีในการแสดงผลหน้าเว็บบนเซิร์ฟเวอร์ เมื่อแคชหน้าเว็บแล้ว การจำลอง 3G Slow ในเครื่องมือสำหรับนักพัฒนาเว็บจะทำให้ FCP อยู่ที่ 8.37 วินาทีกว่าเวอร์ชันฝั่งไคลเอ็นต์

การแสดงผลครั้งแรก (FP)First Contentful Paint (FCP)
แอปฝั่งไคลเอ็นต์4 วิ 11 วิ
เวอร์ชัน SSR2.3 วิประมาณ 2.3 วินาที

ผลลัพธ์เหล่านี้มีแนวโน้มที่ดี ผู้ใช้จะเห็นเนื้อหาที่มีความหมายได้เร็วขึ้นมากเนื่องจากหน้าที่แสดงผลฝั่งเซิร์ฟเวอร์ไม่ต้องอาศัย JavaScript ในการโหลด + แสดงโพสต์อีกต่อไป

ป้องกันการดื่มน้ำในร่างกาย

จำได้ไหมว่าเมื่อผมพูดว่า "เราไม่ได้ทำการเปลี่ยนแปลงโค้ดใดๆ ในแอปฝั่งไคลเอ็นต์" เมื่อกี้คุณโกหก

แอป Express ของเราจะรับคำขอ ใช้ Puppeteer เพื่อโหลดหน้าเว็บแบบ Headless และแสดงผลเป็นการตอบกลับ แต่การตั้งค่านี้มีปัญหา

JS เดียวกันที่เรียกใช้ใน Chrome แบบไม่มีส่วนหัวบนเซิร์ฟเวอร์จะทำงานอีกครั้งเมื่อเบราว์เซอร์ของผู้ใช้โหลดหน้าเว็บในฟรอนท์เอนด์ เรามีสถานที่ 2 แห่งที่สร้างมาร์กอัป #doubleRender

มาแก้ไขกัน เราต้องบอกหน้าเว็บว่า HTML พร้อมใช้งานแล้ว วิธีแก้ไขที่เจอคือให้ตรวจสอบว่า <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 แบบไม่มีส่วนหัว อย่างไรก็ตาม เราสนใจเพียง 2 อย่างเท่านั้น คือ

  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 สามารถเชื่อมต่อกับอินสแตนซ์ที่มีอยู่ของ Chrome อีกครั้งได้โดยการเรียกใช้ puppeteer.connect() และส่ง URL การแก้ไขข้อบกพร่องระยะไกลของอินสแตนซ์ไปยังอินสแตนซ์ดังกล่าว เพื่อให้อินสแตนซ์ของเบราว์เซอร์ทำงานได้นาน เราสามารถย้ายโค้ดที่เปิด 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 แบบไม่มีส่วนหัวบนเซิร์ฟเวอร์แสดงผลหน้าเว็บ ข้อมูลนี้อาจเป็นประโยชน์สำหรับตรรกะฝั่งไคลเอ็นต์ของหน้าเว็บ ในแอป ผมใช้ฮุกนี้เพื่อ "ปิด" ส่วนต่างๆ ของหน้าที่ไม่ได้มีส่วนในการแสดงผลมาร์กอัปของโพสต์ เช่น ฉันปิดใช้โค้ดที่โหลดแบบ Lazy Loading firebase-auth.js ไม่มีผู้ใช้ที่ลงชื่อเข้าใช้

การเพิ่มพารามิเตอร์ ?headless ลงใน URL ที่แสดงผลเป็นวิธีง่ายๆ ในการเพิ่มฮุกหน้าเว็บ ดังนี้

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 ในเว็บไซต์ หน้าที่แสดงผลล่วงหน้าอาจ ส่งผลให้การดูหน้าเว็บสูงเกินจริง กล่าวอย่างเจาะจงคือ คุณจะเห็นจํานวน Hit เป็น 2 เท่า โดย 1 ครั้งเมื่อ Chrome แบบไม่มีส่วนหัวแสดงผลหน้าเว็บ และอีก Hit เมื่อเบราว์เซอร์ของผู้ใช้แสดงผล

แล้ววิธีแก้ไขคืออะไร ใช้การสกัดกั้นเครือข่ายเพื่อล้มเลิกคำขอที่พยายามโหลดไลบรารี 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();
});

จะไม่มีการบันทึก Page Hit หากโค้ดไม่โหลด บูม 🎥

หรือให้โหลดไลบรารี Analytics ต่อไปเพื่อดูข้อมูลเชิงลึกเกี่ยวกับจำนวนการแสดงผลล่วงหน้าของเซิร์ฟเวอร์

บทสรุป

Puppeteer ช่วยให้การแสดงผลหน้าฝั่งเซิร์ฟเวอร์ทำได้ง่ายๆ โดยเรียกใช้ Chrome แบบไม่มีส่วนหัวในเว็บเซิร์ฟเวอร์ของคุณ "ฟีเจอร์" ที่ฉันชื่นชอบคือคุณปรับปรุงประสิทธิภาพการโหลดและความสามารถในการจัดทำดัชนีของแอปโดยไม่ต้องเขียนโค้ด

ถ้าอยากจะดูแอปที่ใช้งานได้และใช้เทคนิคที่อธิบายไว้ที่นี่ โปรดดูแอป devwebfeed

ภาคผนวก

การสนทนาเกี่ยวกับศิลปะก่อนหน้า

แอปการแสดงผลฝั่งเซิร์ฟเวอร์ทำได้ยาก ยากแค่ไหน ลองดูว่ามีคนเขียนแพ็กเกจ npm ซึ่งเขียนมาเพื่อหัวข้อนั้นๆ โดยเฉพาะกี่รายการ เรามี รูปแบบ tools และบริการมากมายนับไม่ถ้วนที่พร้อมให้ความช่วยเหลือกับแอป SSRing JS

Isomorphic / Universal JavaScript

แนวคิดของ Universal JavaScript หมายถึงโค้ดเดียวกับที่ทำงานบนเซิร์ฟเวอร์จะทำงานบนไคลเอ็นต์ (เบราว์เซอร์) ด้วย คุณแชร์โค้ดระหว่างเซิร์ฟเวอร์กับไคลเอ็นต์ และทุกคนก็รู้สึกสงบ

Chrome แบบ Headless จะเปิดใช้ "isomorphic JS" ระหว่างเซิร์ฟเวอร์และไคลเอ็นต์ ซึ่งเป็นตัวเลือกที่ดีหากไลบรารีของคุณไม่ทำงานบนเซิร์ฟเวอร์ (โหนด)

เครื่องมือแสดงผลล่วงหน้า

ชุมชนโหนดได้สร้างเครื่องมือมากมายสำหรับการจัดการแอป SSR JS ไม่แปลกใจเลย โดยส่วนตัวแล้ว เราพบว่า YMMV ด้วยเครื่องมือบางอย่าง ดังนั้นคุณควรทำการบ้านก่อนที่จะตัดสินใจ เช่น เครื่องมือ SSR บางอย่างเป็นเวอร์ชันเก่าและไม่ได้ใช้ Chrome แบบไม่มีส่วนหัว (หรือเบราว์เซอร์ที่ไม่มีส่วนหัวเพื่อการดำเนินการดังกล่าว) แต่จะใช้ PhantomJS (หรือ Safari ตัวเดิม) แทน ซึ่งหมายความว่าหน้าของคุณจะแสดงผลอย่างไม่ถูกต้องหากใช้ฟีเจอร์ใหม่อยู่

ข้อยกเว้นที่สําคัญอย่างหนึ่งคือแสดงผลล่วงหน้า การแสดงผลล่วงหน้ามีความน่าสนใจตรงที่ใช้ Chrome แบบไม่มีส่วนหัวและมิดเดิลแวร์ดรอปอินสำหรับ Express ดังนี้

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

โปรดทราบว่า Preserve ไม่ได้ให้รายละเอียดเกี่ยวกับการดาวน์โหลดและติดตั้ง Chrome ในแพลตฟอร์มต่างๆ ในหลายๆ ครั้ง เรื่องนี้ค่อนข้างยาก จึงจะทำให้ถูกต้อง ซึ่งเป็นเหตุผลหนึ่งที่ Puppeteer ทำสิ่งนั้นให้คุณ ฉันพบปัญหาเกี่ยวกับบริการออนไลน์ที่แสดงผลบางแอปของฉันด้วย เช่น

chromestatus ที่แสดงผลในเบราว์เซอร์
เว็บไซต์ที่แสดงผลในเบราว์เซอร์
chromestatus ที่แสดงผลโดยการแสดงผลล่วงหน้า
เว็บไซต์เดียวกันที่แสดงผลโดย prerender.io