Chrome ללא GUI: תשובה לאתרי JavaScript לעיבוד בצד השרת

Addy Osmani
Addy Osmani

איך משתמשים בממשקי ה-API של Puppeteer כדי להוסיף יכולות של עיבוד בצד השרת (SSR) לשרת האינטרנט של Express? החלק הכי טוב הוא שצריך לבצע שינויים קטנים מאוד בקוד של האפליקציה. ה-Headless עושה את כל העבודה הקשה.

בעזרת כמה שורות קוד אפשר לבצע SSR של כל דף ולקבל את ה-Markup הסופי שלו.

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 אם:

מסגרות מסוימות, כמו Preact, כוללות כלים לעיבוד בצד השרת. אם ל-framework יש פתרון לעיבוד מראש, כדאי להשתמש בו במקום להוסיף את Puppeteer ו-Headless Chrome לתהליך העבודה.

סריקה של האינטרנט המודרני

בעבר, רובוטים של מנועי חיפוש, פלטפורמות שיתוף ברשתות חברתיות ואפילו דפדפנים הסתמכו אך ורק על תיוג HTML סטטי כדי להוסיף לאינדקס את האתרים ולהציג את התוכן. האינטרנט המודרני התפתח למשהו שונה לגמרי. אפליקציות מבוססות-JavaScript הן כאן כדי להישאר, ולכן במקרים רבים התוכן שלנו יכול להיות בלתי נראה לכלים לסריקה.

Googlebot, הרובוט שלנו לסריקה בחיפוש, מעבד JavaScript תוך כדי שמירה על חוויית המשתמש של מי שמבקר באתר. יש כמה הבדלים ומגבלות שצריך להביא בחשבון כשאתם מתכננים את הדפים והאפליקציות, כדי להתאים את האופן שבו הסורקים ניגשים לתוכן שלכם ומריצים אותו.

רינדור מראש של דפים

כל הסורקים מבינים HTML. כדי לוודא שסורקים יכולים להוסיף JavaScript לאינדקס, אנחנו צריכים כלי ש:

  • הוא יודע להריץ כל סוגי JavaScript המודרניים וליצור HTML סטטי.
  • האתר מתעדכן כשמתווספות תכונות לאינטרנט.
  • מריצים את האפליקציה עם מעט עדכוני קוד או ללא עדכוני קוד כלל.

נשמע טוב, נכון? הכלי הזה הוא הדפדפן! ל-Headless Chrome לא אכפת באיזו ספרייה, מסגרת או שרשרת כלים אתם משתמשים.

לדוגמה, אם האפליקציה שלכם מבוססת על Node.js, Puppeteer היא דרך קלה לעבוד עם Chrome ללא ממשק משתמש.

מתחילים עם דף דינמי שיוצר את ה-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 לפני שאנחנו פורקים את הדף בסריאליזציה.
  • מוסיפים מדע. מתעדים את משך הזמן שלוקח ל-headless להציג את הדף ומחזירים את זמן העיבוד יחד עם ה-HTML.
  • מדביקים את הקוד במודול בשם ssr.mjs.

שרת אינטרנט לדוגמה

לבסוף, זהו שרת Express הקטן שמרכז את כל הרכיבים. הטיפול הראשי מבצע עיבוד מראש של כתובת ה-URL http://localhost/index.html (דף הבית) ומציג את התוצאה בתור התגובה שלו. המשתמשים רואים את הפוסטים מיד כשהם נכנסים לדף, כי ה-Markup הסטטי הוא עכשיו חלק מהתגובה.

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 החדש

ה-API של Server-Timing מעביר בחזרה לדפדפן מדדי ביצועים של השרת (כמו זמני בקשה ותגובה או חיפושים במסדי נתונים). קוד לקוח יכול להשתמש במידע הזה כדי לעקוב אחרי הביצועים הכוללים של אפליקציית אינטרנט.

תרחיש לדוגמה שבו כדאי להשתמש ב-Server-Timing הוא דיווח על משך הזמן שלוקח ל-Chrome ללא גוף (headless) לבצע עיבוד מראש של דף. כדי לעשות זאת, פשוט מוסיפים את הכותרת 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 ללא ראש נדרשת בערך שנייה כדי להציג את הדף בשרת. אחרי שהדף נשמר במטמון, הדמיה איטית של 3G בכלי הפיתוח מקצרת את FCP ב-8.37 שניות בהשוואה לגרסה בצד הלקוח.

הצגת תמונה ראשונית במסך (FP)First Contentful Paint (FCP)
אפליקציה בצד הלקוח4s 11 שניות
גרסת SSR2.3 שניותבערך 2.3 שניות

התוצאות האלה מבטיחות. המשתמשים רואים תוכן משמעותי מהר יותר כי הדף שעבר עיבוד בצד השרת לא מסתמך יותר על JavaScript כדי לטעון ולראות פוסטים.

מניעת הידרציה מחדש

זוכרים שאמרתי "לא ביצענו שינויים בקוד באפליקציה בצד הלקוח"? זה היה שקר.

אפליקציית Express שלנו מקבלת בקשה, משתמשת ב-Puppeteer כדי לטעון את הדף ל-headless ומציגה את התוצאה בתור תשובה. אבל יש בעיה בהגדרה הזו.

אותו JavaScript שמופעל ב-Chrome ללא ראש בשרת פועל שוב כשדפדפן המשתמש טוען את הדף בחזית. יש לנו שני מקומות לייצור רכיבי קוד. #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 ללא ממשק משתמש. עם זאת, אנחנו מתעניינים רק בשני דברים:

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

משאבים קריטיים בקוד

מקובל להשתמש בכלי build נפרדים (כמו gulp) כדי לעבד אפליקציה ולהטמיע בקוד של הדף נכסי CSS ו-JS קריטיים בזמן ה-build. כך אפשר לזרז את הצגת התוכן העיקרי (FMP) כי הדפדפן שולח פחות בקשות במהלך טעינת הדף הראשונית.

במקום כלי build נפרד, משתמשים בדפדפן ככלי build. אפשר להשתמש ב-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 ב-renders שונים

הפעלת דפדפן חדש לכל עיבוד מראש יוצרת הרבה זמן אחזור. במקום זאת, מומלץ להפעיל מכונה אחת ולהשתמש בה שוב לצורך עיבוד של כמה דפים.

כדי להתחבר מחדש למכונה קיימת של 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 לביצוע רינדור מראש באופן תקופתי

כדי להציג מספר דפים בו-זמנית, אפשר להשתמש במכונה משותפת של דפדפן.

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 ללא ראש בשרת, יכול להיות שיהיה שימושי לדעת זאת לצורך הלוגיקה של הדף בצד הלקוח. באפליקציה שלי, השתמשתי ב-hook הזה כדי "להשבית" חלקים מהדף שלא משתתפים ברינדור של ה-Markup של הפוסטים. לדוגמה, השבתתי קוד שמפעיל טעינת אשכול (lazy-load) של 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 באתר, חשוב להיזהר. עיבוד מראש של דפים עלול לגרום לעלייה במספר הצפיות בדפים. באופן ספציפי, המערכת תציג פי 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 ללא ראש (headless) בתור תוכנית נלווית בשרת האינטרנט. 'התכונה' האהובה עלי בגישה הזו היא שמשפרים את ביצועי הטעינה ואת היכולת להוסיף את האפליקציה לאינדקס בלי שינויים משמעותיים בקוד.

רוצים לראות אפליקציה שפועלת ומשתמשת בשיטות המתוארות כאן? כדאי לבדוק את אפליקציית devwebfeed.

נספח

דיון בידע קודם

קשה לבצע עיבוד בצד השרת של אפליקציות בצד הלקוח. עד כמה קשה? פשוט בודקים כמה חבילות npm נכתבו בנושא. יש אינספור תבניות, כלים ושירותים שיעזרו לכם להשתמש ב-SSR באפליקציות JS.

JavaScript איזומורפי / אוניברסלי

הרעיון של Universal JavaScript הוא שאותו קוד שפועל בשרת פועל גם בלקוח (הדפדפן). אתם משתפים קוד בין השרת ללקוח, וכולם נהנים מרגעי שלווה.

‏Headless Chrome מאפשר 'JS איזומורפי' בין שרת ללקוח. זו אפשרות נהדרת אם הספרייה לא פועלת בשרת (Node).

כלים לעיבוד מראש

קהילת Node יצרה המון כלים לטיפול באפליקציות JS מסוג SSR. אין הפתעות! באופן אישי, גיליתי שYMMV עם חלק מהכלים האלה, לכן מומלץ לבצע את המחקר הנדרש לפני שמתחייבים לשימוש בכלי מסוים. לדוגמה, חלק מכלי ה-SSR הם ישנים יותר ולא משתמשים ב-Headless Chrome (או בכל דפדפן אחר ללא ממשק משתמש). במקום זאת, הם משתמשים ב-PhantomJS (שנקרא גם Safari הישן), כלומר הדפים לא ייראו כמו שצריך אם הם משתמשים בתכונות חדשות יותר.

אחד מהחריגים הבולטים הוא עיבוד מראש. העניין ב-Prerender הוא שהוא משתמש ב-Chrome ללא ממשק משתמש (headless) ומגיע עם Middleware ל-Express:

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

חשוב לציין שהתכונה 'טרום-עיבוד' לא כוללת את הפרטים של הורדה והתקנה של Chrome בפלטפורמות שונות. לרוב, קשה מאוד לעשות זאת בצורה נכונה, וזו אחת הסיבות לכך שPuppeteer עושה זאת בשבילכם.