html 문서를 string으로 한번에 그렸더라면, 이번엔 stream으로 그려보자
API응답이 항상 빠르고 메모리가 허락하는 한 stream보다 string이 훨씬 더 빠르다.
단, API 응답이 늦어진다면 string으로 전달할 경우 API 응답을 기다린 후 그릴 수 있어서 최대한 빠르게 SSR를 주려면 stream이 더 나은 방식이다. stream으로 어떻게 구현했는지 알아보자.
정답부터 보고 싶으면 소스 코드를 확인하자
https://github.com/insidedw/react-webpack5-ssr/commit/c2766bcad9bd58ed34842ce2333dd3da839685c7
integrate renderToPipeableStream · insidedw/react-webpack5-ssr@c2766bc
insidedw committed Jul 7, 2024
github.com
https://github.com/insidedw/react-webpack5-ssr/commit/161b6bab7c777ecd3437d8db95a4820b59815b3e
streaming QueryClient · insidedw/react-webpack5-ssr@161b6ba
insidedw committed Jul 7, 2024
github.com
react-query 설치
stream을 사용한다는건 suspense를 사용한다는 것인데, 응답을 늦게 주고 suspesnse가 잘 동작한다는걸 확인하려면 react-query를 설치해보자. 추후 필요한 라이브러리이기도 하다.
npm i @tanstack/react-query
routes.js 생성
QueryClient 객체를 생성하는 파일이다.
서버에서 만들 때와 브라우저에서 만들 때를 구분한다.
서버에서 만들 때는 항상 새로운 객체를 생성하고 브라우저에서는 한번 만들어 놓은거 계속 사용한다.
(브라우저에서 왜 싱글턴을 사용해야하는지 확인이 필요해 보인다.)
import { isServer, QueryClient } from '@tanstack/react-query'
function makeQueryClient() {
return new QueryClient()
}
let browserQueryClient = undefined
export const getQueryClient = () => {
if (isServer) {
// Server: always make a new query client
return makeQueryClient()
} else {
// Browser: make a new query client if we don't already have one
// This is very important, so we don't re-make a new client if React
// suspends during the initial render. This may not be needed if we
// have a suspense boundary BELOW the creation of the query client
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
}
client.js 파일 수정
hydrate를 해야하는데.. 기존 메소드는 deprecated된 function이다. stream을 쓰는 만큼 권장하는 function을 사용하자.
hydrate -> hydrateRoot로 변경
서버에서 prefetch한 값을 client에게 알려줘야 중복 호출을 안한다.
알려주는 방법은 window 객체를 이용해서 전달한다.
그 외 변경사항 설명은 생략한다.
import React from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './components/App'
import { BrowserRouter } from 'react-router-dom'
import { HydrationBoundary, QueryClientProvider } from '@tanstack/react-query'
import { getQueryClient } from './routes'
const queryClient = getQueryClient()
const dehydratedState = window.__REACT_QUERY_STATE__ ?? {}
hydrateRoot(
document.getElementById('root'),
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={dehydratedState}>
<BrowserRouter>
<App />
</BrowserRouter>
</HydrationBoundary>
</QueryClientProvider>,
)
src/components/queries.js 생성
fetch할 함수를 생성한다.
suspense가 잘 동작하는지 확인하기 위해 2초 지연을 주었다.
(추후 위치를 옮겨야 겠다. 왜 여기있지?)
export const customFetch = async () => {
const r = new Promise((resolve, reject) =>
setTimeout(
() =>
resolve({
id: 'new jeans',
src: 'https://pbs.twimg.com/media/F04xYoVaYAAmchT.jpg:large',
}),
2000,
),
)
return await r
}
src/components/Images.js 생성
suspesneQuery를 사용하는 컴포넌트를 생성한다.
import React from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { customFetch } from './queries'
export const Images = () => {
const { data } = useSuspenseQuery({ queryKey: ['image', '1'], queryFn: () => customFetch() })
return (
<div>
<div className={'artist'}>{data.id}</div>
<img src={data.src} alt={data.id} />
</div>
)
}
App.css 수정
Images 컴포넌트에서 사용되는 스타일을 추가하자
h1 {
color: dodgerblue;
}
.artist {
text-transform: uppercase;
font-weight: bold;
font-size: 20px;
}
img {
width: 480px;
}
App.js 수정
Images 컴포넌트와 suspense 컴포넌트를 추가한다.
import React, { Suspense } from 'react'
import './App.css'
import { Link, Route, Routes } from 'react-router-dom'
import { Images } from './Images'
const App = () => {
return (
<Routes>
<Route
path={'/'}
element={
<div>
<h1>Hello, SSR!</h1>
<Link to={'/about'}>About</Link>
<Suspense fallback={<h3>loading...</h3>}>
<Images />
</Suspense>
</div>
}
/>
<Route
path={'/about'}
element={
<div>
<h1>SSR is sever side rendering</h1>
<Link to={'/'}>Home</Link>
</div>
}
/>
</Routes>
)
}
export default App
server.js 수정
변경사항이 가장 많은 파일이다.
html 기본 틀
html 상수에 넣는다.
상수안에 변수는 STREAM과 INITIAL_STATE이다.
추후 STREAM 변수 기준으로 앞뒤를 짜른다.
앞에는 css파일을 주입 시키고 뒤에는 script 파일과 prefetch한 값을 주입시킨다.
queryClient 상수
서버에서도 prefetch를하기 위해 queryClient가 필요하다.
getQueryClient() 함수를 통해 얻는다.
prefetch 할때 주의할 점은 `await`를 추가하면 안된다.
추가하게 되면 string으로 넘겨주는 꼴이 된다. API 응답이 올때까지 기다린다.
elementToReadable 함수
react 컴포넌트를 Readable Stream으로 변환하는 함수이다.
`renderToPipeableStream`은 writable stream으로 변환하는데 response 객체에 바로 쓰이면 그대로 사용할 수 있지만, html 상수 앞뒤 짜른 부분도 stream으로 보내야하기 때문에 writable stream을 PassThrough trasnform stream으로 이용해 Readable stream으로 변환한다. 각 스트림을 순서대로 서빙하기 위해서 generator function을 이용하는데 그때 비동기로 처리하기 위해 Promise로 한번 감싼다.
streamHTML 함수
위에서 언급한 generator function이다. 아까 html 상수 앞뒤 짜른 값이 가공되어 이 함수로 들어간다.
처음에는 head부터 바로 서빙하고 그다음 react node 마지막에 footer가 스트림 된다.
footer가 마지막에 서빙되는데 페이지가 잘 나오는게 신기하다. 추가 조사가 필요하다.
generator function이 궁금하면 이 링크 [JS] 제너레이터 함수 (generator function)를 확인해봐라
Readable.from
streamHTML을 통해서 한개씩 얻은 데이터를 모아둔다.
yield를 통해서 API응답이 종료될 때까지 계속 기다리다 결국 끝까지 받는다.
`ssrStream.pipe(res)`
Readable.from 정보를 가지고 있는 상수가 ssrStream인데, yield로 얻어진 데이터를 res로 넘긴다.
import path from 'path'
import fs from 'fs'
import React from 'react'
import express from 'express'
import { renderToPipeableStream } from 'react-dom/server'
import App from './components/App'
import { StaticRouter } from 'react-router-dom/server'
import { getQueryClient } from './routes'
import { dehydrate, QueryClientProvider } from '@tanstack/react-query'
import { customFetch } from './components/queries'
import { PassThrough, Readable } from 'stream'
const app = express()
app.use(express.static('dist'))
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React SSR</title>
</head>
<body>
<div id="root">{{STREAM}}</div>
</body>
<script>window.__REACT_QUERY_STATE__={{INITIAL_STATE}};</script>
</html>
`
const manifest = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../dist/manifest.json'), 'utf8'))
const scripts = Object.keys(manifest)
.filter((key) => key.endsWith('.js'))
.map((key) => `<script defer src="${manifest[key]}"></script>`)
.join('\n')
const styles = Object.keys(manifest)
.filter((key) => key.endsWith('.css'))
.map((key) => `<link rel="stylesheet" type="text/css" href="${manifest[key]}">`)
.join('\n')
const [head, footer] = html.split('{{STREAM}}')
const refinedHead = head.replace('</head>', `${styles}</head>`)
const refinedFooter = footer.replace('</body>', `${scripts}</body>`)
function elementToReadable(element) {
/**
* pipe 함수가 WritableStream으로 데이터를 전달해주는 함수
* react component만 스트림으로 한다면 바로 pipe(res) 하면 되는데,
* 그 외 html markup도 stream으로 보내야 하므로, transfrom stream을 이용해서 데이터 그대로 읽기 스트림으로 전달한다
* @type {module:stream.internal.PassThrough}
*/
const duplex = new PassThrough()
return new Promise((resolve, reject) => {
/**
* HTML을 제공된 쓰기 가능한 Node.js 스트림으로 출력
*/
const { pipe, abort } = renderToPipeableStream(element, {
onShellReady() {
console.log('[Streaming SSR] onShellReady')
resolve(pipe(duplex))
},
onShellError(error) {
abort()
reject(error)
},
onError: (error) => {
duplex.emit('onError', error)
},
})
})
}
async function* streamHTML(head, body, footer, queryClient) {
yield head
console.log('[Streaming SSR] head rendered')
let i = 0
for await (const chunk of body) {
yield chunk
i++
console.log(`[Streaming SSR] chunk ${i} rendered`)
}
const dehydratedState = dehydrate(queryClient)
const footerWithQueryState = footer.replace('{{INITIAL_STATE}}', `${JSON.stringify(dehydratedState)}`)
yield footerWithQueryState
console.log('[Streaming SSR] footer rendered')
}
app.get('*', async (req, res) => {
res.setHeader('Content-Type', 'text/html')
res.status(200)
const queryClient = getQueryClient()
queryClient.prefetchQuery({ queryKey: ['image', '1'], queryFn: () => customFetch() })
const vnode = (
<QueryClientProvider client={queryClient}>
<StaticRouter location={req.url}>
<App />
</StaticRouter>
</QueryClientProvider>
)
try {
const ssrStream = Readable.from(streamHTML(refinedHead, await elementToReadable(vnode), refinedFooter, queryClient))
ssrStream.on('error', (error) => {
console.log(`ssrStream!`, error.message)
})
ssrStream.on('close', () => {
queryClient.clear()
console.log(`queryClient clear!`)
})
ssrStream.pipe(res)
} catch (e) {
console.log('error', e)
}
})
app.listen(3000, () => {
console.log('Server is listening on port 3000')
})
테스트
stream, suspense가 잘 작동하고 있다는걸 볼 수 있다.
그렇지만 더 확인해봐야 할건 suspense가 렌더링 되고 있을 때 html 응답이 html 일부분밖에 없는데 UI가 깨지지 않고 잘나온다는게 이상하다. 이유를 꼭 찾아보자
suspense시 html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React SSR</title>
<link rel="stylesheet" type="text/css" href="/main.1d973b210f9cb388daa2.css"></head>
<body>
<div id="root"><div><h1>Hello, SSR!</h1>
<a href="/about">About</a><!--$?--><template id="B:0"></template>
<h3>loading...</h3><!--/$--></div>
결과
'IT > react' 카테고리의 다른 글
[react] text-overflow: ellipsis 활성 여부 react code에서 확인하는 방법 (0) | 2024.11.20 |
---|---|
[react] ssr 프로젝트 만들기 (6) - stream으로 html 그리기, 추가 설명 (0) | 2024.07.13 |
[react] ssr 프로젝트 만들기 (4) - react router 추가 (0) | 2024.07.02 |
[react] ssr 프로젝트 만들기 (3) - css, js 번들에 hash 추가 (0) | 2024.07.02 |
[react] ssr 프로젝트 만들기 (2) - css, 번들에 추가하기 (0) | 2024.07.02 |