CSR은 html이나 css 전체를 전달하는것이 아닌 번들링된 자바스크립트 파일을 전달해 그 파일을 이용하여 클라이언트 측에서 화면을 렌더링하는 방식이다.
번들링된 파일을 초기에 모두 가져오기 때문에 첫페이지 로딩을 제외한 페이지 이동, 부분 렌더링 속도가 빠르다.
서버 측에서는 첫 로딩을 제외하고 JSON만 전달하면 되는 경우가 많으므로 SSR 대비 서버의 비용이 적다.
첫 페이지 로딩에서 비교적 큰 자바스크립트 파일을 받아야 하므로 FP(First Paint)와 FCP(First Contentful Paint) 등의 초기 렌더링까지의 시간이 오래걸려 애플리케이션이 커질 수록 초기 페이지 로딩에서의 시간이 오래 걸린다.
index.html만 전달하는 경우가 대다수 이므로 메타 데이터를 봇에게 전달하지 못해 SEO 점수에서 SSR 대비 불리하다.
클라이언트 측에서 대부분의 렌더링이 이루어지므로 사용자의 성능이 좋지않은 경우 (오래된 컴퓨터, 혹은 모바일 등) 장점인 페이지 이동, 부분렌더링이 느려질 수 있다.
참고 문서 : https://web.dev/rendering-on-the-web/
// setup server listener as fast as possible
const server = http.createServer(async (req, res) => {
try {
if (handlersPromise) {
await handlersPromise
handlersPromise = undefined
}
sockets.add(res)
res.on('close', () => sockets.delete(res))
await requestHandler(req, res)
} catch (err) {
res.statusCode = 500
res.end('Internal Server Error')
Log.error(`Failed to handle request for ${req.url}`)
console.error(err)
}
})
...
const next = require('../next') as typeof import('../next').default
const addr = server.address()
const app = next({
dir,
hostname,
dev: isDev,
isNodeDebugging,
httpServer: server,
customServer: false,
port: addr && typeof addr === 'object' ? addr.port : port,
})
// line 253
loadComponents({
distDir: this.distDir,
pathname: '/_document',
hasServerComponents: false,
isAppPath: false,
}).catch(() => {})
loadComponents({
distDir: this.distDir,
pathname: '/_app',
hasServerComponents: false,
isAppPath: false,
}).catch(() => {})
// line 267
if (this.isRouterWorker) {
...
const { createWorker, createIpcServer } =
require('./lib/server-ipc') as typeof import('./lib/server-ipc')
next-server.ts 파일의 renderHTMLImpl에서 render.tsx 파일의 renderToHTML로 렌더링을 구현한다.
render.tsx의 눈에 띄는 코드들
async function renderToString(element: React.ReactElement) {
const renderStream = await ReactDOMServer.renderToReadableStream(element)
await renderStream.allReady
return streamToString(renderStream)
}
renderToReadableStream는 리액트 트리를 ReadableStream형태의 html로 파싱해주는 메서드 이다.
ReadableStream 이란?
동영상 스트리밍 처럼 데이터를 쪼갠 뒤 이어 받을 수 있는 Stream API의 인터페이스
=> 서버 렌더링과 클라이언트 렌더링을 나누는 hydrate를 구현할 때 사용되는 기술인듯 하다.
리액트 공식문서에서는 클라이언트 측에서 hydrateRoot를 통해 서버에서 부트스트래핑된 리액트 트리를 이어서 받아 렌더링 할 수 있다고 한다.
export default function App() {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/styles.css"></link>
<title>My app</title>
</head>
<body>
<Router />
</body>
</html>
);
}
...
import { hydrateRoot } from 'react-dom/client';
import App from './App.js';
hydrateRoot(document, <App />);
next에서도 client 측 코드를 정리한 곳에서 같은 방법으로 hydrate 해주고 있었다.
vercel/next.js/packages/next/src/client/app-index.tsx 의 hydrate 함수
const reactRoot = isError
? (ReactDOMClient as any).createRoot(appElement, options)
: (React as any).startTransition(() =>
(ReactDOMClient as any).hydrateRoot(appElement, reactEl, options)
)
if (isError) {
reactRoot.render(reactEl)
}
vercel/next.js/packages/next/src/client/index.tsx 의 renderReactElement 함수
function renderReactElement(
domEl: HTMLElement,
fn: (cb: () => void) => JSX.Element
): void {
// mark start of hydrate/render
if (ST) {
performance.mark('beforeRender')
}
const reactEl = fn(shouldHydrate ? markHydrateComplete : markRenderComplete)
if (!reactRoot) {
// Unlike with createRoot, you don't need a separate root.render() call here
reactRoot = ReactDOM.hydrateRoot(domEl, reactEl, {
onRecoverableError,
})
// TODO: Remove shouldHydrate variable when React 18 is stable as it can depend on `reactRoot` existing
shouldHydrate = false
} else {
const startTransition = (React as any).startTransition
startTransition(() => {
reactRoot.render(reactEl)
})
}
}