헤드리스 Chrome: 서버 측 렌더링 JS 사이트에 대한 답변

Puppeteer API를 사용하여 서버 측 렌더링 (SSR) 기능을 Express 웹 서버에 추가하는 방법을 알아보세요. 가장 좋은 점은 앱의 코드를 아주 조금 변경해야 한다는 점입니다. 헤드리스가 모든 힘든 일을 맡아줍니다.

코드 몇 줄이면 원하는 페이지를 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을 사용해 보세요.

Preact와 같은 일부 프레임워크는 서버 측 렌더링을 처리하는 도구를 제공합니다. 프레임워크에 사전 렌더링 솔루션이 있는 경우 Puppeteer 및 Headless Chrome을 워크플로에 가져오는 대신 사전 렌더링 솔루션을 유지합니다.

최신 웹 크롤링

검색엔진 크롤러, 소셜 공유 플랫폼, 브라우저도 지금까지 웹 및 노출 영역 콘텐츠의 색인을 생성하기 위해 정적 HTML 마크업에만 의존해 왔습니다. 현대 웹은 매우 다른 것으로 진화했습니다. 자바스크립트 기반 애플리케이션은 계속 남아 있습니다. 즉, 대부분의 경우 크롤링 도구에서 Google의 콘텐츠를 보지 못할 수 있습니다.

Google의 검색 크롤러인 Googlebot은 자바스크립트를 처리하면서 사이트를 방문하는 사용자의 환경을 저해하지 않도록 합니다. 크롤러가 콘텐츠에 액세스하고 렌더링하는 방법을 수용하려면 페이지와 애플리케이션을 설계할 때 몇 가지 차이점과 제한사항을 고려해야 합니다.

페이지 사전 렌더링

모든 크롤러는 HTML을 이해합니다. 크롤러가 자바스크립트의 색인을 생성할 수 있도록 하려면 다음과 같은 도구가 필요합니다.

  • 모든 유형의 최신 자바스크립트를 실행하고 정적 HTML을 생성하는 방법을 알고 있어야 합니다.
  • 웹에 기능이 추가될 때마다 최신 정보를 받을 수 있습니다.
  • 애플리케이션의 코드 업데이트를 거의 또는 전혀 없이 실행할 수 있습니다.

괜찮으신가요? 바로 브라우저입니다. 헤드리스 Chrome은 개발자가 사용하는 라이브러리나 프레임워크 또는 도구 체인에 신경 쓰지 않습니다.

예를 들어 애플리케이션이 Node.js로 빌드된 경우 Puppeteer를 사용하면 0.headless Chrome으로 쉽게 작업할 수 있습니다.

자바스크립트로 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 서버가 있습니다. 기본 핸들러는 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의 완벽한 사용 사례

Server-Timing API는 서버 성능 측정항목 (예: 요청 및 응답 시간 또는 데이터베이스 조회)을 브라우저에 다시 전달합니다. 클라이언트 코드는 이 정보를 사용하여 웹 앱의 전반적인 성능을 추적할 수 있습니다.

Server-Timing의 완벽한 사용 사례는 헤드리스 Chrome이 페이지를 사전 렌더링하는 데 걸리는 시간을 보고하는 것입니다. 이렇게 하려면 서버 응답에 Server-Timing 헤더를 추가하기만 하면 됩니다.

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

클라이언트에서 Performance 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)"
}

성능 결과

다음 결과에는 뒷부분에서 설명하는 성능 최적화가 대부분 통합되어 있습니다.

중 하나(코드)에서 헤드리스 Chrome이 서버에서 페이지를 렌더링하는 데 약 1초가 걸립니다. 페이지가 캐시되면 DevTools의 3G 느린 에뮬레이션이 클라이언트 측 버전보다 8.37초 더 빠르게 FCP를 배치합니다.

첫 페인트 (FP)First Contentful Paint (FCP)
클라이언트 측 앱4초 11초
SSR 버전2.3초약 2.3초

이러한 결과는 고무적입니다. 서버 측에서 렌더링된 페이지가 더 이상 JavaScript를 사용하여 로드하지 않고 게시물을 표시하므로 사용자가 의미 있는 콘텐츠를 훨씬 빠르게 볼 수 있습니다.

수분 보충 방지

'클라이언트 측 앱의 코드를 변경하지 않았다'고 했던 것을 기억하시나요? 그건 거짓말입니다.

Express 앱은 요청을 받고 Puppeteer를 사용하여 페이지를 헤드리스로 로드하고 결과를 응답으로 제공합니다. 하지만 이 설정에는 문제가 있습니다.

사용자의 브라우저가 프런트엔드의 페이지를 로드할 때 서버의 헤드리스 Chrome에서 실행되는 동일한 JS다시 실행됩니다. 두 곳에서 마크업을 생성합니다. #doublerender.

수정해 봅시다. 페이지에 HTML이 이미 삽입되어 있음을 알려야 합니다. 제가 찾은 해결책은 페이지 JS가 로드 시 <ul id="posts">가 이미 DOM에 있는지 확인하도록 하는 것이었습니다. 그렇다면 Google은 페이지가 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에 무조건 로드됩니다. 하지만 Google에서는 다음 두 가지에만 관심을 가지고 있습니다.

  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는 puppeteer.connect()를 호출하고 인스턴스의 원격 디버깅 URL에 전달하여 기존 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};
}

예: 주기적으로 사전 렌더링하는 크론 작업

App Engine 대시보드 앱에서 사이트의 상위 페이지를 주기적으로 다시 렌더링하도록 크론 핸들러를 설정합니다. 이렇게 하면 방문자가 항상 빠르고 최신 콘텐츠를 볼 수 있으며, 새로운 사전 렌더링의 '시작 비용'이 표시되지 않도록 할 수 있습니다. 이 경우 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에 의해 렌더링되는 경우 페이지의 클라이언트 측 로직이 이를 아는 것이 도움이 될 수 있습니다. 제 앱에서는 이 후크를 사용하여 페이지에서 게시물 마크업 렌더링에 관여하지 않는 부분을 '해제'했습니다. 예를 들어 firebase-auth.js를 지연 로드하는 코드를 사용 중지했습니다. 로그인할 사용자가 없습니다.

렌더링 URL에 ?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>

애널리틱스 페이지 조회수를 부풀리지 마세요

사이트에서 애널리틱스를 사용하는 경우 주의해야 합니다. 페이지를 사전 렌더링하면 페이지 조회수가 부풀려질 수 있습니다. 구체적으로는 조회수가 2배가 됩니다. 헤드리스 Chrome에서 페이지를 렌더링할 때 조회가 발생하고 사용자의 브라우저가 페이지를 렌더링할 때 조회가 발생합니다.

해결 방법은 무엇일까요? 네트워크 가로채기를 사용하여 분석 라이브러리를 로드하려는 요청을 취소합니다.

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

코드가 로드되지 않으면 페이지 조회수가 기록되지 않습니다. 붐 위의 CODE

또는 애널리틱스 라이브러리를 계속 로드하여 서버가 수행 중인 사전 렌더링 수를 파악하세요.

결론

Puppeteer를 사용하면 웹 서버에서 헤드리스 Chrome을 컴패니언으로 실행하여 손쉽게 서버 측에서 페이지를 렌더링할 수 있습니다. 이 접근 방식에서 가장 마음에 드는 '기능'은 큰 코드 변경 없이도 앱의 로드 성능색인 생성 가능성을 개선하는 것입니다.

여기에 설명된 기법을 사용하는 작동하는 앱을 보려면 devwebfeed 앱을 확인하세요.

부록

선행 기술 논의

클라이언트 측 앱 서버 측 렌더링은 어렵습니다. 얼마나 어려운가요? 사람들이 해당 주제 전용으로 작성한 npm 패키지가 몇 개인지 살펴보세요. SSRing JS 앱에 도움이 되는 수많은 패턴, tools, 서비스가 있습니다.

동형 / 범용 자바스크립트

범용 자바스크립트의 개념은 서버에서 실행되는 것과 동일한 코드가 클라이언트 (브라우저)에서도 실행된다는 의미입니다. 서버와 클라이언트 간에 코드를 공유하면 모두가 평온감을 느낍니다.

헤드리스 Chrome은 서버와 클라이언트 간에 '동일형 JS'를 사용 설정합니다. 라이브러리가 서버 (노드)에서 작동하지 않는 경우에 유용합니다.

사전 렌더링 도구

Node 커뮤니티는 SSR JS 앱을 처리하기 위한 수많은 도구를 빌드했습니다. 놀랄 것 없죠! 개인적으로 이러한 도구 중 일부에서는 YMMV가 사용된다는 것을 알았습니다. 따라서 도구를 사용하기 전에 먼저 숙제를 해야 합니다. 예를 들어 일부 SSR 도구는 오래되어 헤드리스 Chrome (또는 이와 관련하여 헤드리스 브라우저)을 사용하지 않습니다. 대신 PhantomJS (이전 Safari라고도 함)를 사용하므로 최신 기능을 사용하면 페이지가 제대로 렌더링되지 않습니다.

주목할 만한 예외 중 하나는 Prerender입니다. 사전 렌더링은 헤드리스 Chrome을 사용하고 삽입형 Express용 미들웨어와 함께 제공된다는 점에서 흥미롭습니다.

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

사전 렌더링은 여러 플랫폼에서 Chrome을 다운로드하고 설치하는 데 대한 세부정보를 제외한다는 점에 유의해야 합니다. 종종 맞히기가 까다롭기 때문에 Puppeteer를 이용해 주세요. 일부 앱을 렌더링하는 온라인 서비스에도 문제가 있습니다.

브라우저에서 렌더링되는 chromestatus
브라우저에서 렌더링된 사이트
사전 렌더링으로 렌더링된 chromestatus
prerender.io로 렌더링된 동일한 사이트