이 프로젝트는 CSR의 사례 연구이며 서버 측 렌더링에 비해 클라이언트 측 렌더링 앱의 잠재력을 탐색합니다.
모든 렌더링 방법의 심층 비교는이 프로젝트의 비교 페이지에서 찾을 수 있습니다 : https://client-side-rendering.pages.dev/comparison
CSR (Client-Side Rendering) 은 정적 자산을 웹 브라우저로 전송하고 앱의 전체 렌더링 프로세스를 처리 할 수 있도록합니다.
SSR (Server-Side Rendering) 에는 서버의 전체 앱 (또는 페이지)을 렌더링하고 사전 렌더링 된 HTML 문서를 표시 할 준비가되어 있습니다.
정적 사이트 생성 (SSG) 은 HTML 페이지를 정적 자산으로 사전 생성하는 프로세스이며 브라우저에서 전송 및 표시됩니다.
일반적인 믿음과는 달리, React , Angular , Vue 및 Svelte 와 같은 현대적인 프레임 워크의 SSR 프로세스는 앱이 서버에서 한 번, 브라우저에서 다시 한 번 앱 렌더링을 초래합니다 (이것은 "수화"라고합니다). 이 두 번째 렌더가 없으면 앱은 정적이고 무성화되지 않으며 본질적으로 "생명이없는"웹 페이지처럼 작동합니다.
흥미롭게도, 수화 과정은 일반적인 렌더보다 빠르지 않습니다 (물론 그림 단계 제외).
SSG 앱도 수화되어야한다는 점에 유의해야합니다.
SSR과 SSG 모두에서 HTML 문서가 완전히 구성되어 다음과 같은 이점을 제공합니다.
반면에 CSR 앱은 다음과 같은 이점을 제공합니다.
이 사례 연구에서는 CSR에 중점을두고 강점을 피크에 활용하면서 명백한 한계를 극복하는 방법을 모색합니다.
모든 최적화는 배포 된 앱에 통합되며 여기에서 찾을 수 있습니다 : https://client-side-rendering.pages.dev.
"최근에 SSR (Server Side Rendering)은 JavaScript 프론트 엔드 세계를 폭풍으로 가져갔습니다. 이제 고객에게 보내기 전에 사이트와 앱을 서버에서 렌더링 할 수 있다는 사실은 절대적으로 혁신적인 아이디어입니다 (그리고 JS 클라이언트 측 앱이 처음으로 인기를 얻기 전에 모든 사람이하는 일은 전적으로 혁신적인 아이디어입니다.)
그러나 PHP, ASP, JSP (및 그러한) 사이트에 유효한 동일한 비판은 오늘날 서버 측 렌더링에 유효합니다. 느리고 상당히 쉽게 파손되며 제대로 구현하기가 어렵습니다.
모든 사람이 당신에게 말하는 것에도 불구하고, 당신은 아마도 SSR이 필요하지 않을 것입니다. 사전을 사용하여 거의 모든 장점을 얻을 수 있습니다. "
~ 프레 렌더 스파 플러그인
최근에 서버 측 렌더링은 SEO가 필요하지 않은 앱 (예 : 로그인 요구 사항이있는 앱)에서도 개발자가 자신의 제한 사항을 완전히 이해하지 않고도 기본적으로 기본적으로 기본적으로 기본적으로 기본적으로 기본적으로 기본적으로 기본적 으로 프레임 워크 형태로 큰 인기를 얻었습니다.
SSR에는 장점이 있지만 이러한 프레임 워크는 속도 ( "기본값으로 성능")를 계속 강조하고 있으며, 이는 클라이언트 측 렌더링 (CSR)이 본질적으로 느리다는 것을 시사합니다.
또한 SSR을 사용하여 완벽한 SEO를 달성 할 수 있으며 검색 엔진 크롤러에 대한 CSR 앱을 최적화 할 수 없다는 광범위한 오해가 있습니다.
SSR의 또 다른 일반적인 주장은 웹 앱이 커짐에 따라 로딩 시간이 계속 증가하여 CSR 앱의 FCP 성능이 저하된다는 것입니다.
앱이 기능이 더 풍부 해지는 것은 사실이지만, 단일 페이지의 크기는 실제로 시간이 지남에 따라 감소 해야합니다.
이는 Zustand , Day.js , Headless-UI 및 React-Router v6 과 같은 더 작고 효율적인 라이브러리 및 프레임 워크를 생성하는 추세 때문입니다.
또한 시간이 지남에 따라 프레임 워크 크기의 감소를 관찰 할 수 있습니다 : Angular (74.1KB), React (44.5KB), VUE (34KB), Solid (7.6KB) 및 Svelte (1.7KB).
이 라이브러리는 웹 페이지 스크립트의 전체 가중치에 크게 기여합니다.
적절한 코드 분할을 사용하면 페이지의 초기 로딩 시간이 시간이 지남에 따라 감소 할 수 있습니다.
이 프로젝트는 코드 분할 및 사전 로딩과 같은 최적화로 기본 CSR 앱을 구현합니다. 목표는 개별 페이지의로드 시간이 앱 스케일로 안정적으로 유지되는 것입니다.
목표는 프로덕션 등급 앱의 패키지 구조를 시뮬레이션하고 병렬 요청을 통해로드 시간을 최소화하는 것입니다.
성능 향상은 개발자 경험 비용으로 이루어져서는 안된다는 점에 유의해야합니다. 따라서이 프로젝트의 아키텍처는 Next.js와 같은 프레임 워크의 엄격하고 의견이 많은 구조를 피하거나 일반적으로 SSR의 한계를 피하며 일반적인 REACT 설정에서 약간 수정됩니다.
이 사례 연구는 성능과 SEO의 두 가지 주요 측면에 중점을 둘 것입니다. 우리는 두 영역 모두에서 최고 점수를 달성하는 방법을 탐구 할 것입니다.
이 프로젝트는 REACT를 사용하여 구현되지만 대부분의 최적화는 프레임 워크 공연이며 순전히 Bundler 및 웹 브라우저를 기반으로합니다.
우리는 표준 웹 팩 (RSPACK) 설정을 가정하고 진행할 때 필요한 사용자 정의를 추가합니다.
첫 번째 경험은 종속성을 최소화하고 그 중에서도 파일 크기가 가장 작은 규칙을 선택하는 것입니다.
예를 들어:
우리는 순간 대신 day.js , redux 툴킷 대신 zustand를 사용할 수 있습니다.
이는 CSR 앱뿐만 아니라 SSR (및 SSG) 앱의 경우에도 중요합니다. 더 큰 번들로 인해로드 시간이 길어지면 페이지가 보이거나 대화식이 지연되기 때문입니다.
이상적으로는 모든 해시 파일을 캐시해야하며 index.html 절대로 캐시되어서는 안됩니다.
이는 브라우저가 처음에 main.[hash].js

그러나 main.js 에는 전체 번들이 포함되어 있으므로 코드의 가장 작은 변경으로 인해 캐시가 만료되므로 브라우저가 다시 다운로드해야합니다.
이제 우리 번들의 어떤 부분이 대부분의 무게로 구성됩니까? 답은 공급 업체 라고도하는 종속성 입니다.
따라서 공급 업체를 자체 해시 청크로 나눌 수 있다면 코드와 공급 업체 코드를 분리하여 캐시 무효화가 줄어 듭니다.
구성 파일에 다음 최적화를 추가하겠습니다.
rspack.config.js
export default ( ) => {
return {
optimization : {
runtimeChunk : 'single' ,
splitChunks : {
chunks : 'initial' ,
cacheGroups : {
vendors : {
test : / [\/]node_modules[\/] / ,
name : 'vendors'
}
}
}
}
}
} 이렇게하면 vendors.[hash].js 파일 :

이것은 상당한 개선이지만, 매우 작은 의존성을 업데이트하면 어떻게 될까요?
이 경우 전체 공급 업체 청크의 캐시가 무효화됩니다.
따라서 더욱 향상시키기 위해 각 종속성을 해시 청크로 분할 할 것입니다.
rspack.config.js
- name: 'vendors'
+ name: module => {
+ const moduleName = (module.context.match(/[\/]node_modules[\/](.*?)([\/]|$)/) || [])[1]
+
+ return moduleName.replace('@', '')
+ } 이렇게하면 react-dom.[hash].js 는 단일 큰 공급 업체와 [id].[hash].js 파일을 포함합니다.

기본 구성 (예 : 분할 임계 값 크기)에 대한 자세한 정보는 여기에서 확인할 수 있습니다.
https://webpack.js.org/plugins/split-chunks-plugin/#defaults
우리가 쓴 많은 기능은 몇 페이지 중 일부에만 사용되므로 사용자가 사용중인 페이지를 방문 할 때만로드되기를 원합니다.
예를 들어, 우리는 단지 홈페이지 를로드 한 경우 React-Big-Calendar 패키지가 다운로드, 구문 분석 및 실행 될 때까지 사용자가 기다릴 필요가 없습니다. 우리는 그들이 캘린더 페이지를 방문 할 때만 일어나기를 원할 것입니다.
우리가이를 달성 할 수있는 방법은 경로 기반 코드 분할에 의한 (바람직하게는) 다음과 같습니다.
app.tsx
const Home = lazy ( ( ) => import ( /* webpackChunkName: 'home' */ 'pages/Home' ) )
const LoremIpsum = lazy ( ( ) => import ( /* webpackChunkName: 'lorem-ipsum' */ 'pages/LoremIpsum' ) )
const Pokemon = lazy ( ( ) => import ( /* webpackChunkName: 'pokemon' */ 'pages/Pokemon' ) ) 따라서 사용자가 Pokemon 페이지를 방문하면 메인 청크 스크립트 (프레임 워크와 같은 모든 공유 종속성 포함) 및 pokemon.[hash].js Chunk 만 다운로드합니다.
참고 : 사용자가 즉각적이고 앱과 같은 탐색을 경험할 수 있도록 전체 앱을 다운로드하는 것이 좋습니다. 그러나 모든 자산을 단일 스크립트로 배치하여 페이지의 첫 번째 렌더링을 지연시키는 것은 나쁜 생각입니다.
이러한 자산은 비동기 적으로 다운로드해야하며 사용자보고 페이지가 렌더링을 마치고 완전히 보인 후에 만 다운로드해야합니다.
코드 분할은 하나의 주요 결함이 있습니다. 런타임은 메인 스크립트가 실행될 때까지 어떤 비동기 청크가 필요한지 알지 못하여 크게 지연되어 (CDN에 대한 또 다른 왕복을 만들기 때문에) :

이 문제를 해결할 수있는 방법은 문서에 스크립트를 포함시키는 맞춤형 플러그인을 작성하는 것입니다.
rspack.config.js
import InjectAssetsPlugin from './scripts/inject-assets-plugin.js'
export default ( ) => {
return {
plugins : [ new InjectAssetsPlugin ( ) ]
}
}스크립트/Inject-Assets-plugin.js
import { join } from 'node:path'
import { readFileSync } from 'node:fs'
import HtmlPlugin from 'html-webpack-plugin'
import pagesManifest from '../src/pages.js'
const __dirname = import . meta . dirname
const getPages = rawAssets => {
const pages = Object . entries ( pagesManifest ) . map ( ( [ chunk , { path , title } ] ) => {
const script = rawAssets . find ( name => name . includes ( `/ ${ chunk } .` ) && name . endsWith ( '.js' ) )
return { path , script , title }
} )
return pages
}
class InjectAssetsPlugin {
apply ( compiler ) {
compiler . hooks . compilation . tap ( 'InjectAssetsPlugin' , compilation => {
HtmlPlugin . getCompilationHooks ( compilation ) . beforeEmit . tapAsync ( 'InjectAssetsPlugin' , ( data , callback ) => {
const preloadAssets = readFileSync ( join ( __dirname , '..' , 'scripts' , 'preload-assets.js' ) , 'utf-8' )
const rawAssets = compilation . getAssets ( )
const pages = getPages ( rawAssets )
let { html } = data
html = html . replace (
'</title>' ,
( ) => `</title><script id="preload-data">const pages= ${ stringifiedPages } n ${ preloadAssets } </script>`
)
callback ( null , { ... data , html } )
} )
} )
}
}
export default InjectAssetsPlugin스크립트/preload-assets.js
const isMatch = ( pathname , path ) => {
if ( pathname === path ) return { exact : true , match : true }
if ( ! path . includes ( ':' ) ) return { match : false }
const pathnameParts = pathname . split ( '/' )
const pathParts = path . split ( '/' )
const match = pathnameParts . every ( ( part , ind ) => part === pathParts [ ind ] || pathParts [ ind ] ?. startsWith ( ':' ) )
return {
exact : match && pathnameParts . length === pathParts . length ,
match
}
}
const preloadAssets = ( ) => {
let { pathname } = window . location
if ( pathname !== '/' ) pathname = pathname . replace ( / /$ / , '' )
const matchingPages = pages . map ( page => ( { ... isMatch ( pathname , page . path ) , ... page } ) ) . filter ( ( { match } ) => match )
if ( ! matchingPages . length ) return
const { path , title , script } = matchingPages . find ( ( { exact } ) => exact ) || matchingPages [ 0 ]
document . head . appendChild (
Object . assign ( document . createElement ( 'link' ) , { rel : 'preload' , href : '/' + script , as : 'script' } )
)
if ( title ) document . title = title
}
preloadAssets ( ) 가져온 pages.js 파일은 여기에서 찾을 수 있습니다.
이런 식으로 브라우저는 페이지 별 스크립트 청크를 렌더 크리티컬 자산과 병렬로 가져올 수 있습니다.

코드 분할에는 또 다른 문제가 발생합니다 : 비동기 공급 업체 복제.
lorem-ipsum.[hash].js 및 pokemon.[hash].js . 둘 다 메인 청크의 일부가 아닌 동일한 의존성을 포함하면 사용자는 해당 종속성을 두 번 다운로드합니다.
따라서 의존성이 moment 이고 무게가 72kb 미츠가 있다면, 비동기 청크의 크기는 모두 72kb 이상 입니다.
우리는이 의존성을 이러한 비동기 청크에서 분할하여 그들 사이에서 공유 할 수 있도록해야합니다.
rspack.config.js
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'initial',
cacheGroups: {
vendors: {
test: /[\/]node_modules[\/]/,
+ chunks: 'all',
name: ({ context }) => (context.match(/[\/]node_modules[\/](.*?)([\/]|$)/) || [])[1].replace('@', '')
}
}
}
} moment.[hash].js lorem-ipsum.[hash].js pokemon.[hash].js
그러나 응용 프로그램을 구축하기 전에 어떤 비동기 공급 업체 청크가 분할 될지 알 수있는 방법이 없으므로 사전로드해야 할 비동기 벤더 청크를 알 수 없습니다 ( "Preloading Async Chunks"섹션 참조).

그래서 우리는 청크 이름을 비동기 공급 업체의 이름에 추가 할 것입니다.
rspack.config.js
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'initial',
cacheGroups: {
vendors: {
test: /[\/]node_modules[\/]/,
chunks: 'all',
- name: ({ context }) => (context.match(/[\/]node_modules[\/](.*?)([\/]|$)/) || [])[1].replace('@', '')
+ name: (module, chunks) => {
+ const allChunksNames = chunks.map(({ name }) => name).join('.')
+ const moduleName = (module.context.match(/[\/]node_modules[\/](.*?)([\/]|$)/) || [])[1]
+ return `${moduleName}.${allChunksNames}`.replace('@', '')
}
}
}
}
}스크립트/Inject-Assets-plugin.js
const getPages = rawAssets => {
const pages = Object.entries(pagesManifest).map(([chunk, { path, title }]) => {
- const script = rawAssets.find(name => name.includes(`/${chunk}.`) && name.endsWith('.js'))
+ const scripts = rawAssets.filter(name => new RegExp(`[/.]${chunk}\.(.+)\.js$`).test(name))
- return { path, title, script }
+ return { path, title, scripts }
})
return pages
}스크립트/preload-assets.js
- const { path, title, script } = matchingPages.find(({ exact }) => exact) || matchingPages[0]
+ const { path, title, scripts } = matchingPages.find(({ exact }) => exact) || matchingPages[0]
+ scripts.forEach(script => {
document.head.appendChild(
Object.assign(document.createElement('link'), { rel: 'preload', href: '/' + script, as: 'script' })
)
+ })이제 모든 비동기 공급 업체 청크는 부모의 비동기 청크와 병렬로 가져옵니다.

SSR에 대한 CSR의 추정 된 단점 중 하나는 페이지의 데이터 (페치 요청)가 브라우저에서 JS가 다운로드, 구문 분석 및 실행 된 후에 만 해고 될 것이라는 것입니다.

이를 극복하기 위해 fetch API를 패치하여 이번에는 데이터 자체를 위해 예압을 다시 사용합니다.
스크립트/Inject-Assets-plugin.js
const getPages = rawAssets => {
- const pages = Object.entries(pagesManifest).map(([chunk, { path, title }]) => {
+ const pages = Object.entries(pagesManifest).map(([chunk, { path, title, data, preconnect }]) => {
const scripts = rawAssets.filter(name => new RegExp(`[/.]${chunk}\.(.+)\.js$`).test(name))
- return { path, title, script }
+ return { path, title, scripts, data, preconnect }
})
return pages
}
HtmlPlugin.getCompilationHooks(compilation).beforeEmit.tapAsync('InjectAssetsPlugin', (data, callback) => {
const preloadAssets = readFileSync(join(__dirname, '..', 'scripts', 'preload-assets.js'), 'utf-8')
const rawAssets = compilation.getAssets()
const pages = getPages(rawAssets)
+ const stringifiedPages = JSON.stringify(pages, (_, value) => {
+ return typeof value === 'function' ? `func:${value.toString()}` : value
+ })
let { html } = data
html = html.replace(
'</title>',
- () => `</title><script id="preload-data">const pages=${JSON.stringify(pages)}n${preloadAssets}</script>`
+ () => `</title><script id="preload-data">const pages=${stringifiedPages}n${preloadAssets}</script>`
)
callback(null, { ...data, html })
})스크립트/preload-assets.js
const preloadResponses = {}
const originalFetch = window.fetch
window.fetch = async (input, options) => {
const requestID = `${input.toString()}${options?.body?.toString() || ''}`
const preloadResponse = preloadResponses[requestID]
if (preloadResponse) {
if (!options?.preload) delete preloadResponses[requestID]
return preloadResponse
}
const response = originalFetch(input, options)
if (options?.preload) preloadResponses[requestID] = response
return response
}
.
.
.
const getDynamicProperties = (pathname, path) => {
const pathParts = path.split('/')
const pathnameParts = pathname.split('/')
const dynamicProperties = {}
for (let i = 0; i < pathParts.length; i++) {
if (pathParts[i].startsWith(':')) dynamicProperties[pathParts[i].slice(1)] = pathnameParts[i]
}
return dynamicProperties
}
const preloadAssets = () => {
- const { path, title, scripts } = matchingPages.find(({ exact }) => exact) || matchingPages[0]
+ const { path, title, scripts, data, preconnect } = matchingPages.find(({ exact }) => exact) || matchingPages[0]
.
.
.
data?.forEach(({ url, ...request }) => {
if (url.startsWith('func:')) url = eval(url.replace('func:', ''))
const constructedURL = typeof url === 'string' ? url : url(getDynamicProperties(pathname, path))
fetch(constructedURL, { ...request, preload: true })
})
preconnect?.forEach(url => {
document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'preconnect', href: url }))
})
}
preloadAssets() 알림 : pages.js 파일은 여기에서 찾을 수 있습니다.
이제 데이터가 바로 가져오고 있음을 알 수 있습니다.

위의 스크립트를 사용하면 Dynamic Routes 데이터 (예 : Pokemon/: Name )를 예압 할 수도 있습니다.
사용자는 앱에서 원활한 내비게이션 경험이 있어야합니다.
그러나 모든 페이지를 분할하면 화면에서 렌더링되기 전에 모든 페이지를 다운로드해야하므로 탐색이 눈에 띄게 지연됩니다.
우리는 모든 페이지를 미리 미리 사전하고 캐시하고 싶습니다.
간단한 서비스 직원을 작성 하여이 작업을 수행 할 수 있습니다.
rspack.config.js
import { InjectManifestPlugin } from 'inject-manifest-plugin'
import InjectAssetsPlugin from './scripts/inject-assets-plugin.js'
export default ( ) => {
return {
plugins : [
new InjectManifest ( {
include : [ / fonts/ / , / scripts/.+.js$ / ] ,
swSrc : join ( __dirname , 'public' , 'service-worker.js' ) ,
compileSrc : false ,
maximumFileSizeToCacheInBytes : 10000000
} ) ,
new InjectAssetsPlugin ( )
]
}
}src/utils/service-worker-registration.ts
const register = ( ) => {
window . addEventListener ( 'load' , async ( ) => {
try {
await navigator . serviceWorker . register ( '/service-worker.js' )
console . log ( 'Service worker registered!' )
} catch ( err ) {
console . error ( err )
}
} )
}
const unregister = async ( ) => {
try {
const registration = await navigator . serviceWorker . ready
await registration . unregister ( )
console . log ( 'Service worker unregistered!' )
} catch ( err ) {
console . error ( err )
}
}
if ( 'serviceWorker' in navigator ) {
const shouldRegister = process . env . NODE_ENV !== 'development'
if ( shouldRegister ) register ( )
else unregister ( )
}공개/서비스-일꾼 JS
const CACHE_NAME = 'my-csr-app'
const allAssets = self . __WB_MANIFEST . map ( ( { url } ) => url )
const getCache = ( ) => caches . open ( CACHE_NAME )
const getCachedAssets = async cache => {
const keys = await cache . keys ( )
return keys . map ( ( { url } ) => `/ ${ url . replace ( self . registration . scope , '' ) } ` )
}
const precacheAssets = async ( ) => {
const cache = await getCache ( )
const cachedAssets = await getCachedAssets ( cache )
const assetsToPrecache = allAssets . filter ( asset => ! cachedAssets . includes ( asset ) && ! ignoreAssets . includes ( asset ) )
await cache . addAll ( assetsToPrecache )
await removeUnusedAssets ( )
}
const removeUnusedAssets = async ( ) => {
const cache = await getCache ( )
const cachedAssets = await getCachedAssets ( cache )
cachedAssets . forEach ( asset => {
if ( ! allAssets . includes ( asset ) ) cache . delete ( asset )
} )
}
const fetchAsset = async request => {
const cache = await getCache ( )
const cachedResponse = await cache . match ( request )
return cachedResponse || fetch ( request )
}
self . addEventListener ( 'install' , event => {
event . waitUntil ( precacheAssets ( ) )
self . skipWaiting ( )
} )
self . addEventListener ( 'fetch' , event => {
const { request } = event
if ( [ 'font' , 'script' ] . includes ( request . destination ) ) event . respondWith ( fetchAsset ( request ) )
} )이제 사용자가 탐색하기 전에 모든 페이지가 프리 페치 및 캐시됩니다.
이 접근법은 또한 전체 코드 캐시를 생성합니다.
43kb react-dom.js 파일을 검사 할 때, 우리는 반환 요청에 걸리는 시간이 60ms이고 파일을 다운로드하는 데 걸리는 시간은 3ms입니다.

이는 RTT가 웹 페이지로드 시간, 때로는 다운로드 속도보다 훨씬 더 많은 영향을 미치며, 우리의 경우와 같이 근처의 CDN 엣지에서 자산이 제공되는 경우에도 잘 알려진 사실을 보여줍니다.
또한 더 중요한 것은 HTML 파일을 다운로드 한 후 브라우저가 유휴 상태를 유지하고 스크립트가 도착하기를 기다리는 큰 타임 스팬이 있음을 알 수 있습니다.

이것은 브라우저가 스크립트를 다운로드, 구문 분석 및 실행하는 데 사용할 수있는 소중한 시간 (빨간색으로 표시)입니다.
이 비 효율성은 자산이 변경 될 때마다 다시 발생합니다 (부분 캐시). 이것은 첫 방문에서만 발생하는 것이 아닙니다.
그렇다면이 유휴 시간을 어떻게 제거 할 수 있습니까?
문서의 모든 초기 (중요한) 스크립트를 인화하여 비동기 페이지 자산이 도착할 때까지 다운로드, 구문 분석 및 실행을 시작할 수 있습니다.

브라우저는 이제 CDN에 다른 요청을 보내지 않고 초기 스크립트를 얻는다는 것을 알 수 있습니다.
따라서 브라우저는 먼저 비동기 청크 및 사전로드 된 데이터에 대한 요청을 보내며 보류 중이지만 기본 스크립트를 계속 다운로드하여 실행합니다.
HTML 파일이 다운로드, 구문 분석 및 실행이 완료된 직후 Async 청크가 다운로드 (파란색으로 표시)를 시작하여 많은 시간을 절약 할 수 있습니다.
이 변화는 빠른 네트워크에서 큰 차이를 만들고 있지만, 지연이 더 크고 RTT가 훨씬 더 큰 영향을 미치는 느린 네트워크의 경우 더욱 중요합니다.
그러나이 솔루션에는 두 가지 주요 문제가 있습니다.
이러한 문제를 극복하기 위해 더 이상 정적 HTML 파일을 고수 할 수 없으므로 서버의 전력을 늘려야합니다. 또는 더 정확하게는 CloudFlare 서버리스 작업자의 힘입니다.
이 작업자는 모든 HTML 문서 요청을 가로 채고 완벽하게 맞는 응답을 조정해야합니다.
전체 흐름은 다음과 같이 설명해야합니다.
X-Cached 헤더가 있는지 확인합니다. 그러한 헤더가 존재하는 경우, 그 값을 반복하고 응답에없는 관련* 자산 만 인라인으로 만듭니다. 이러한 헤더가 존재하지 않으면 응답의 모든 관련* 자산을 인화합니다.X-Cached 헤더와 함께 보냅니다.* 초기 및 페이지 별 자산.
이를 통해 브라우저는 단일 왕복에 현재 페이지를 표시하기 위해 필요한 자산 (더 이상 필요하지 않음)을 정확하게 수신 할 수 있습니다!
스크립트/Inject-Assets-plugin.js
class InjectAssetsPlugin {
apply ( compiler ) {
const production = compiler . options . mode === 'production'
compiler . hooks . compilation . tap ( 'InjectAssetsPlugin' , compilation => {
.
.
.
} )
if ( ! production ) return
compiler . hooks . afterEmit . tapAsync ( 'InjectAssetsPlugin' , ( compilation , callback ) => {
let html = readFileSync ( join ( __dirname , '..' , 'build' , 'index.html' ) , 'utf-8' )
let worker = readFileSync ( join ( __dirname , '..' , 'build' , '_worker.js' ) , 'utf-8' )
const rawAssets = compilation . getAssets ( )
const pages = getPages ( rawAssets )
const assets = rawAssets
. filter ( ( { name } ) => / ^scripts/.+.js$ / . test ( name ) )
. map ( ( { name , source } ) => ( {
url : `/ ${ name } ` ,
source : source . source ( ) ,
parentPaths : pages . filter ( ( { scripts } ) => scripts . includes ( name ) ) . map ( ( { path } ) => path )
} ) )
const initialModuleScriptsString = html . match ( / <scripts+type="module"[^>]*>([sS]*?)(?=</head>) / ) [ 0 ]
const initialModuleScripts = initialModuleScriptsString . split ( '</script>' )
const initialScripts = assets
. filter ( ( { url } ) => initialModuleScriptsString . includes ( url ) )
. map ( asset => ( { ... asset , order : initialModuleScripts . findIndex ( script => script . includes ( asset . url ) ) } ) )
. sort ( ( a , b ) => a . order - b . order )
const asyncScripts = assets . filter ( asset => ! initialScripts . includes ( asset ) )
html = html
. replace ( / ,"scripts":s*[(.*?)] / g , ( ) => '' )
. replace ( / scripts.forEach[sS]*?data?.s*forEach / , ( ) => 'data?.forEach' )
. replace ( / preloadAssets / g , ( ) => 'preloadData' )
worker = worker
. replace ( 'INJECT_INITIAL_MODULE_SCRIPTS_STRING_HERE' , ( ) => JSON . stringify ( initialModuleScriptsString ) )
. replace ( 'INJECT_INITIAL_SCRIPTS_HERE' , ( ) => JSON . stringify ( initialScripts ) )
. replace ( 'INJECT_ASYNC_SCRIPTS_HERE' , ( ) => JSON . stringify ( asyncScripts ) )
. replace ( 'INJECT_HTML_HERE' , ( ) => JSON . stringify ( html ) )
writeFileSync ( join ( __dirname , '..' , 'build' , '_worker.js' ) , worker )
callback ( )
} )
}
}
export default InjectAssetsPluginpublic/_worker.js
const initialModuleScriptsString = INJECT_INITIAL_MODULE_SCRIPTS_STRING_HERE
const initialScripts = INJECT_INITIAL_SCRIPTS_HERE
const asyncScripts = INJECT_ASYNC_SCRIPTS_HERE
const html = INJECT_HTML_HERE
const documentHeaders = { 'Cache-Control' : 'public, max-age=0' , 'Content-Type' : 'text/html; charset=utf-8' }
const isMatch = ( pathname , path ) => {
if ( pathname === path ) return { exact : true , match : true }
if ( ! path . includes ( ':' ) ) return { match : false }
const pathnameParts = pathname . split ( '/' )
const pathParts = path . split ( '/' )
const match = pathnameParts . every ( ( part , ind ) => part === pathParts [ ind ] || pathParts [ ind ] ?. startsWith ( ':' ) )
return {
exact : match && pathnameParts . length === pathParts . length ,
match
}
}
export default {
fetch ( request , env ) {
const pathname = new URL ( request . url ) . pathname . toLowerCase ( )
const userAgent = ( request . headers . get ( 'User-Agent' ) || '' ) . toLowerCase ( )
const bypassWorker = [ 'prerender' , 'googlebot' ] . includes ( userAgent ) || pathname . includes ( '.' )
if ( bypassWorker ) return env . ASSETS . fetch ( request )
const cachedScripts = request . headers . get ( 'X-Cached' ) ?. split ( ', ' ) . filter ( Boolean ) || [ ]
const uncachedScripts = [ ... initialScripts , ... asyncScripts ] . filter ( ( { url } ) => ! cachedScripts . includes ( url ) )
if ( ! uncachedScripts . length ) {
return new Response ( html , { headers : documentHeaders } )
}
let body = html . replace ( initialModuleScriptsString , ( ) => '' )
const injectedInitialScriptsString = initialScripts
. map ( ( { url , source } ) =>
cachedScripts . includes ( url ) ? `<script src=" ${ url } "></script>` : `<script id=" ${ url } "> ${ source } </script>`
)
. join ( 'n' )
body = body . replace ( '</body>' , ( ) => `<!-- INJECT_ASYNC_SCRIPTS_HERE --> ${ injectedInitialScriptsString } n</body>` )
const matchingPageScripts = asyncScripts
. map ( asset => {
const parentsPaths = asset . parentPaths . map ( path => ( { path , ... isMatch ( pathname , path ) } ) )
const parentPathsExactMatch = parentsPaths . some ( ( { exact } ) => exact )
const parentPathsMatch = parentsPaths . some ( ( { match } ) => match )
return { ... asset , exact : parentPathsExactMatch , match : parentPathsMatch }
} )
. filter ( ( { match } ) => match )
const exactMatchingPageScripts = matchingPageScripts . filter ( ( { exact } ) => exact )
const pageScripts = exactMatchingPageScripts . length ? exactMatchingPageScripts : matchingPageScripts
const uncachedPageScripts = pageScripts . filter ( ( { url } ) => ! cachedScripts . includes ( url ) )
const injectedAsyncScriptsString = uncachedPageScripts . reduce (
( str , { url , source } ) => ` ${ str } n<script id=" ${ url } "> ${ source } </script>` ,
''
)
body = body . replace ( '<!-- INJECT_ASYNC_SCRIPTS_HERE -->' , ( ) => injectedAsyncScriptsString )
return new Response ( body , { headers : documentHeaders } )
}
}SRC/UTILS/Extract-Inline-Scripts.ts
const extractInlineScripts = ( ) => {
const inlineScripts = [ ... document . body . querySelectorAll ( 'script[id]:not([src])' ) ] . map ( ( { id , textContent } ) => ( {
url : id ,
source : textContent
} ) )
return inlineScripts
}
export default extractInlineScriptssrc/utils/service-worker-registration.ts
import extractInlineScripts from './extract-inline-scripts'
const register = ( ) => {
window . addEventListener (
'load' ,
async ( ) => {
try {
const registration = await navigator . serviceWorker . register ( '/service-worker.js' )
console . log ( 'Service worker registered!' )
registration . addEventListener ( 'updatefound' , ( ) => {
registration . installing ?. postMessage ( { inlineAssets : extractInlineScripts ( ) } )
} )
} catch ( err ) {
console . error ( err )
}
} ,
{ once : true }
)
}공개/서비스-일꾼 JS
const CACHE_NAME = 'my-csr-app'
const allAssets = self . __WB_MANIFEST . map ( ( { url } ) => url )
const createPromiseResolve = ( ) => {
let resolve
const promise = new Promise ( res => ( resolve = res ) )
return [ promise , resolve ]
}
const [ precacheAssetsPromise , precacheAssetsResolve ] = createPromiseResolve ( )
const getCache = ( ) => caches . open ( CACHE_NAME )
const getCachedAssets = async cache => {
const keys = await cache . keys ( )
return keys . map ( ( { url } ) => `/ ${ url . replace ( self . registration . scope , '' ) } ` )
}
const cacheInlineAssets = async assets => {
const cache = await getCache ( )
assets . forEach ( ( { url , source } ) => {
const response = new Response ( source , {
headers : {
'Cache-Control' : 'public, max-age=31536000, immutable' ,
'Content-Type' : 'application/javascript'
}
} )
cache . put ( url , response )
console . log ( `Cached %c ${ url } ` , 'color: yellow; font-style: italic;' )
} )
}
const precacheAssets = async ( { ignoreAssets } ) => {
const cache = await getCache ( )
const cachedAssets = await getCachedAssets ( cache )
const assetsToPrecache = allAssets . filter ( asset => ! cachedAssets . includes ( asset ) && ! ignoreAssets . includes ( asset ) )
await cache . addAll ( assetsToPrecache )
await removeUnusedAssets ( )
await fetchDocument ( '/' )
}
const removeUnusedAssets = async ( ) => {
const cache = await getCache ( )
const cachedAssets = await getCachedAssets ( cache )
cachedAssets . forEach ( asset => {
if ( ! allAssets . includes ( asset ) ) cache . delete ( asset )
} )
}
const fetchDocument = async url => {
const cache = await getCache ( )
const cachedAssets = await getCachedAssets ( cache )
const cachedDocument = await cache . match ( '/' )
try {
const response = await fetch ( url , {
headers : { 'X-Cached' : cachedAssets . join ( ', ' ) }
} )
return response
} catch ( err ) {
return cachedDocument
}
}
const fetchAsset = async request => {
const cache = await getCache ( )
const cachedResponse = await cache . match ( request )
return cachedResponse || fetch ( request )
}
self . addEventListener ( 'install' , event => {
event . waitUntil ( precacheAssetsPromise )
self . skipWaiting ( )
} )
self . addEventListener ( 'message' , async event => {
const { inlineAssets } = event . data
await cacheInlineAssets ( inlineAssets )
await precacheAssets ( { ignoreAssets : inlineAssets . map ( ( { url } ) => url ) } )
precacheAssetsResolve ( )
} )
self . addEventListener ( 'fetch' , event => {
const { request } = event
if ( request . destination === 'document' ) return event . respondWith ( fetchDocument ( request . url ) )
if ( [ 'font' , 'script' ] . includes ( request . destination ) ) event . respondWith ( fetchAsset ( request ) )
} )신선한 (완전히 성분되지 않은) 하중의 결과는 예외적입니다.



다음로드에서 CloudFlare 작업자는 최소 (1.8KB) HTML 문서로 응답하고 모든 자산은 즉시 캐시에서 제공됩니다.
이 최적화는 우리를 다른 하나로 이끌어줍니다. 덩어리를 더 작은 조각으로 나눕니다.
경험상 번들을 너무 많은 청크로 분할하면 성능이 상처를 줄 수 있습니다. 이는 모든 파일이 다운로드 될 때까지 페이지가 렌더링되지 않으며, 파일이 더 많을수록 하드웨어와 네트워크 속도가 비선형이므로 그 중 하나가 지연 될 가능성이 높아집니다.
그러나 우리의 경우 우리는 모든 관련 덩어리를 인화하고 한 번에 모두 가져 오기 때문에 관련이 없습니다.
rspack.config.js
optimization: {
splitChunks: {
chunks: 'initial',
cacheGroups: {
vendors: {
+ minSize: 10000,
}
}
}
},이 극단적 인 분할은 더 나은 캐시 지속성으로 이어지고 부분적으로는 부분 캐시를 사용하여 더 빠른로드 시간으로 이어집니다.
CDN에서 정적 자산을 가져 오면 자원의 컨텐츠 해시 인 ETag 헤더가 포함됩니다. 후속 요청에서 브라우저는 저장된 ETAG가 있는지 확인합니다. 그렇다면 ETAG를 If-None-Match 헤더로 보냅니다. 그런 다음 CDN은 수신 된 ETAG를 현재의 ETAG와 비교합니다. 일치하는 경우 304 Not Modified 상태를 반환하여 브라우저가 캐시 된 자산을 사용할 수 있음을 나타냅니다. 그렇지 않은 경우 새 자산을 200 상태로 반환합니다.
기존의 CSR 앱에서 페이지를 다시로드하면 HTML이 캐시에서 다른 자산을 제공하면서 304 Not Modified . 각 경로에는 고유 한 ETAG가 있으므로 /lorem-ipsum 및 /pokemon ETAG가 동일하더라도 다른 캐시 항목이 있습니다.
CSR SPA에서는 HTML 파일이 하나만 있기 때문에 모든 페이지 요청에 동일한 ETAG가 사용됩니다. 그러나 ETAG는 경로 당 저장되므로 브라우저는 방문하지 않은 페이지에 대한 If-None-Match 헤더를 보내지 않으므로 파일이 동일한 파일이더라도 200 상태와 HTML의 레드로드로 이어집니다.
그러나 우리는 근로자 간의 협력을 통해이 행동의 (개선 된) 구현을 쉽게 만들 수 있습니다.
스크립트/Inject-Assets-plugin.js
+ import { createHash } from 'node:crypto'
class InjectAssetsPlugin {
apply(compiler) {
.
.
.
compiler.hooks.afterEmit.tapAsync('InjectAssetsPlugin', (compilation, callback) => {
let html = readFileSync(join(__dirname, '..', 'build', 'index.html'), 'utf-8')
let worker = readFileSync(join(__dirname, '..', 'build', '_worker.js'), 'utf-8')
.
.
.
+ const documentEtag = createHash('sha256').update(html).digest('hex').slice(0, 16)
.
.
.
worker = worker
.replace('INJECT_INITIAL_MODULE_SCRIPTS_STRING_HERE', () => JSON.stringify(initialModuleScriptsString))
.replace('INJECT_INITIAL_SCRIPTS_HERE', () => JSON.stringify(initialScripts))
.replace('INJECT_ASYNC_SCRIPTS_HERE', () => JSON.stringify(asyncScripts))
.replace('INJECT_HTML_HERE', () => JSON.stringify(html))
+ .replace('INJECT_DOCUMENT_ETAG_HERE', () => JSON.stringify(documentEtag))
writeFileSync(join(__dirname, '..', 'build', '_worker.js'), worker)
callback()
})
}
}public/_worker.js
+ const documentEtag = INJECT_DOCUMENT_ETAG_HERE
.
.
.
export default {
fetch(request, env) {
+ if (request.headers.get('If-None-Match') === documentEtag) {
+ return new Response(null, { status: 304, headers: documentHeaders })
+ }
.
.
.
}
}공개/서비스-일꾼 JS
.
.
.
const getRequestHeaders = responseHeaders => ({
'If-None-Match': responseHeaders?.get('ETag') || responseHeaders?.get('X-ETag'),
'X-Cached': JSON.stringify(allAssets)
})
.
.
.
const precacheAssets = async ({ ignoreAssets }) => {
.
.
.
+ await fetchDocument('/')
}
const fetchDocument = async url => {
const cache = await getCache()
const cachedDocument = await cache.match('/')
const requestHeaders = getRequestHeaders(cachedDocument?.headers)
try {
const response = await fetch(url, { headers: requestHeaders })
if (response.status === 304) return cachedDocument
cache.put('/', response.clone())
return response
} catch (err) {
return cachedDocument
}
} CDN이 ETag 자동으로 보내지 않는 상황에 대해 사용자 정의 X-ETag 포함되어 있습니다.
이제 우리의 서버리스 작업자는 변경되지 않은 페이지에도 변경이 없을 때마다 304 Not Modified 상태 코드로 응답합니다.
서비스 작업자가 사용되면 브라우저는 서비스 작업자가로드 될 때까지 초기 HTML 문서 요청을 보내는 것을 지연시켜 하드웨어에 따라 약간 또는 중간 정도의 페이지 지연이 발생할 수 있습니다.
이 문제에 대한 기본 솔루션을 Navigation Preload 라고합니다. 서비스 작업자가로드되기를 기다리지 않고 문서 요청이 즉시 전송되도록이를 구현합니다.
src/utils/service-worker-registration.ts
const register = ( ) => {
.
.
.
navigator . serviceWorker ?. addEventListener ( 'message' , async event => {
const { navigationPreloadHeader } = event . data
const registration = await navigator . serviceWorker . ready
registration . navigationPreload . setHeaderValue ( navigationPreloadHeader )
} )
}공개/서비스-일꾼 JS
.
.
.
const fetchDocument = async ( { url , preloadResponse } ) => {
const cache = await getCache ( )
const cachedDocument = await cache . match ( '/' )
const requestHeaders = getRequestHeaders ( cachedDocument ?. headers )
try {
const response = await ( preloadResponse && cachedDocument
? preloadResponse
: fetch ( url , { headers : requestHeaders } ) )
if ( response . status === 304 ) return cachedDocument
cache . put ( '/' , response . clone ( ) )
self . clients . matchAll ( { includeUncontrolled : true } ) . then ( ( [ client ] ) => {
client ?. postMessage ( { navigationPreloadHeader : JSON . stringify ( getRequestHeaders ( response . headers ) ) } )
} )
return response
} catch ( err ) {
return cachedDocument
}
}
.
.
.
self . addEventListener ( 'activate' , event => event . waitUntil ( self . registration . navigationPreload ?. enable ( ) ) )
.
.
.
self . addEventListener ( 'fetch' , event => {
const { request , preloadResponse } = event
if ( request . destination === 'document' ) return event . respondWith ( fetchDocument ( { url : request . url , preloadResponse } ) )
if ( [ 'font' , 'script' ] . includes ( request . destination ) ) event . respondWith ( fetchAsset ( request ) )
} )이 구현을 통해 문서 요청은 서비스 작업자와 독립적으로 즉시 전송됩니다.
참고 : 반응 (v18), svelte 또는 solid.js가 필요합니다
기본 앱에서 페이지를 분할하면 렌더 단계를 분리하여 페이지가 렌더링되기 전에 앱이 렌더링됩니다.
따라서 한 비동기 페이지에서 다른 비동기 페이지로 이동하면 페이지가 렌더링 될 때까지 남아있는 빈 공간이 표시됩니다.


이것은 경로 만 서스펜스로 포장하는 일반적인 접근 방식으로 인해 발생합니다.
const App = ( ) => {
return (
< >
< Navigation />
< Suspense >
< Routes > { routes } </ Routes >
</ Suspense >
</ >
)
} React 18은 우리를 useTransition 후크에 소개하여 일부 기준이 충족 될 때까지 렌더를 지연시킬 수 있습니다.
이 후크를 사용하여 페이지의 내비게이션이 준비 될 때까지 지연시킵니다.
Usetransitionnavigate.ts
import { useTransition } from 'react'
import { useNavigate } from 'react-router-dom'
const useTransitionNavigate = ( ) => {
const [ , startTransition ] = useTransition ( )
const navigate = useNavigate ( )
return ( to , options ) => startTransition ( ( ) => navigate ( to , options ) )
}
export default useTransitionNavigateNavigationLink.tsx
const NavigationLink = ( { to , onClick , children } ) => {
const navigate = useTransitionNavigate ( )
const onLinkClick = event => {
event . preventDefault ( )
navigate ( to )
onClick ?. ( )
}
return (
< NavLink to = { to } onClick = { onLinkClick } >
{ children }
</ NavLink >
)
}
export default NavigationLink이제 Async 페이지는 기본 앱에서 결코 나뉘 지 않은 것처럼 느껴집니다.
링크 (데스크탑) 위로 호버링 할 때 또는 링크가 뷰포트 (모바일)를 입력 할 때 다른 페이지 데이터를 사전로드 할 수 있습니다.
NavigationLink.tsx
< NavLink onMouseEnter = { ( ) => fetch ( url , { ... request , preload : true } ) } > { children } </ NavLink >이것은 불필요하게 API 서버를로드 할 수 있습니다.
일부 사용자는 장기간 앱을 열어 두므로 우리가 할 수있는 또 다른 일은 앱이 실행 중에 앱을 다시 비교 (새 자산을 다운로드)하는 것입니다.
서비스 직원 등록
+ const REVALIDATION_INTERVAL_HOURS = 1
const register = () => {
window.addEventListener(
'load',
async () => {
try {
const registration = await navigator.serviceWorker.register('/service-worker.js')
console.log('Service worker registered!')
registration.addEventListener('updatefound', () => {
registration.installing?.postMessage({ inlineAssets: extractInlineScripts() })
})
+ setInterval(() => registration.update(), REVALIDATION_INTERVAL_HOURS * 3600 * 1000)
} catch (err) {
console.error(err)
}
},
{ once: true }
)
}위의 코드는 매 시간마다 앱을 재평가합니다.
재평가 프로세스는 매우 저렴합니다. 서비스 작업자를 재개하는 것만 포함되므로 (변경되지 않은 경우 304 수정되지 않은 상태 코드를 반환합니다).
서비스 작업자가 변경 되면 새로운 자산을 사용할 수 있으므로 선택적으로 다운로드하고 캐시됩니다.
우리는 번들을 많은 작은 덩어리로 나누어 앱의 캐싱 능력을 크게 향상시킵니다.
우리는 모든 페이지를 분할하여 하나를로드하면 관련이있는 것만 바로 다운로드됩니다.
우리는 앱의 초기 (캐시리스)로드를 매우 빠르게 만들었습니다. 페이지를로드 해야하는 모든 것이 동적으로 주입됩니다.
우리는 페이지의 데이터를 사전로드하여 CSR 앱이 가지고있는 유명한 데이터를 가져 오는 폭포를 제거합니다.
또한, 우리는 모든 페이지를 사전에 사전에 옮기므로, 마치 기본 번들 코드에서 결코 분할되지 않은 것처럼 보입니다.
이 모든 것들은 개발자 경험을 타협하지 않고 선택할 JS 프레임 워크를 지시하지 않고 달성되었습니다.
정적 앱의 가장 큰 장점은 CDN에서 전적으로 제공 될 수 있다는 것입니다.
CDN에는 "Edge Networks"라고도 불리는 많은 팝 (존재 지점)이 있습니다. 이 팝은 전 세계에 배포되므로 원격 서버보다 훨씬 빠른 모든 지역에 파일을 제공 할 수 있습니다.
현재까지 가장 빠른 CDN은 CloudFlare이며 250 개 이상의 팝 (및 계산)이 있습니다.

https://speed.cloudflare.com
https://blog.cloudflare.com/benchmarking-edge-network-performance
CloudFlare 페이지를 사용하여 앱을 쉽게 배포 할 수 있습니다.
https://pages.cloudflare.com
이 섹션을 마무리하기 위해, 우리는 전적으로 SSG 인 Next.js 의 문서 사이트와 비교하여 앱의 벤치 마크를 수행합니다.
최소한의 접근성 페이지를 Lorem Ipsum 페이지와 비교할 것입니다. 두 페이지 모두 렌더 크리티컬 덩어리에 ~ 246kb의 JS가 포함됩니다 (예압 및 후에 오는 프리 페치).
각 링크를 클릭하여 라이브 벤치 마크를 수행 할 수 있습니다.
접근성 | 다음 .js
Lorem Ipsum | 클라이언트 측 렌더링
각 페이지에 대해 Google의 Pagespeed Insights Benchmark (느린 4G 네트워크 시뮬레이션)를 약 20 번 수행하고 가장 높은 점수를 얻었습니다.
결과는 다음과 같습니다.


결과적으로, 성능은 Next.js의 기본값이 아닙니다.
이 벤치 마크는 앱이 완전히 캐시 될 때 (CSR이 실제로 빛나는 곳) 수행 방식을 고려하지 않고도 페이지의 첫 번째로드 만 테스트합니다.
Google이 CSR (JS) 앱을 제대로 인덱싱하는 데 어려움을 겪고 있다는 것은 일반적인 MinConception입니다.
2017 년에는 그럴 수도 있지만 오늘날 : Google은 CSR 앱을 대부분 완벽하게 색인합니다.
인덱스 된 페이지에는 제목, 설명, 컨텐츠 및 기타 모든 SEO 관련 속성이 있습니다. 동적으로 설정하는 것을 기억하는 한 (수동으로이를 좋아하거나 React-Helmet 과 같은 패키지를 사용).
https://www.google.com/search?q=site:https://client-side-rendering.pages.dev


GoogleBot의 능력 Google 검색 콘솔 에서 앱의 라이브 URL 테스트를 수행하여 렌더 JS를 쉽게 입증 할 수 있습니다.

GoogleBot은 최신 버전의 Chromium to Crawl 앱을 사용하므로 우리가해야 할 유일한 작업은 앱을 빠르게로드하고 데이터를 빠르게 가져 오는 것입니다.
데이터를 가져 오는 데 시간이 오래 걸리더라도 GoogleBot은 대부분 페이지의 스냅 샷을 찍기 전에 기다릴 것입니다.
https://support.google.com/webmasters/thread/202552760/for-how-long-does-googlebot-for-the-last-http-request
https://support.google.com/webmasters/thread/165370285?hl=en&msgid=165510733
GoogleBot의 JS 크롤링 프로세스에 대한 자세한 설명은 다음과 같습니다.
https://developers.google.com/search/docs/crawling-indexing/javaScript/javaScript-seo-basics
GoogleBot이 일부 페이지를 렌더링하지 못하면 대부분 Google이 웹 사이트를 크롤링하기 위해 필요한 리소스를 소비하지 않기 때문입니다. 즉, 크롤링 예산이 낮습니다.
이는 크롤링 페이지를 검사하여 (검색 콘솔에서 크롤링 된 페이지를 클릭하여)를 검사하고 모든 실패한 요청에 다른 오류 경보가 있는지 확인하여 확인할 수 있습니다 (이는 해당 요청이 GoogleBot에 의해 의도적으로 중단되었음을 의미합니다).

이는 Google이 흥미로운 콘텐츠가 없다고 생각하거나 트래픽이 매우 낮은 웹 사이트 (예 : 데모 앱)에서만 발생해야합니다.
자세한 내용은 https://support.google.com/webmasters/thread/4425254?hl=en&msgid=4426601을 참조하십시오
Bing과 같은 다른 검색 엔진은 JS를 렌더링 할 수 없으므로 앱을 제대로 기어 올리려면 사전 버전의 페이지를 제공해야합니다.
Prerendering은 프로덕션에서 웹 앱을 크롤링하는 행위 (헤드리스 크롬 사용)와 각 페이지에 대해 전체 HTML 파일 (데이터 포함)을 생성하는 행위입니다.
사전 렌더링과 관련하여 두 가지 옵션이 있습니다.
Serverless Prerendering은 특히 GCP 에서 매우 저렴할 수 있기 때문에 권장되는 접근법입니다 .
그런 다음 CloudFlare Worker (예 :)를 사용하여 웹 크롤러 ( User-Agent 헤더 문자열로 식별)를 리디렉션합니다.
public/_worker.js
const BOT_AGENTS = [ 'bingbot' , 'yandex' , 'twitterbot' , 'whatsapp' , ... ]
const fetchPrerendered = async ( { url , headers } , userAgent ) => {
const headersToSend = new Headers ( headers )
/* Custom Prerenderer */
const prerenderUrl = new URL ( ` ${ YOUR_PRERENDERER_URL } ?url= ${ url } ` )
/*************/
/* OR */
/* Prerender.io */
const prerenderUrl = `https://service.prerender.io/ ${ url } `
headersToSend . set ( 'X-Prerender-Token' , YOUR_PRERENDER_IO_TOKEN )
/****************/
const prerenderRequest = new Request ( prerenderUrl , {
headers : headersToSend ,
redirect : 'manual'
} )
const { body , ... rest } = await fetch ( prerenderRequest )
return new Response ( body , rest )
}
export default {
fetch ( request , env ) {
const pathname = new URL ( request . url ) . pathname . toLowerCase ( )
const userAgent = ( request . headers . get ( 'User-Agent' ) || '' ) . toLowerCase ( )
// a crawler that requests the document
if ( BOT_AGENTS . some ( agent => userAgent . includes ( agent ) ) && ! pathname . includes ( '.' ) ) {
return fetchPrerendered ( request , userAgent )
}
return env . ASSETS . fetch ( request )
}
} 다음은 모든 BOT Agnets (웹 크롤러)의 최신 목록입니다. 목록에서 googlebot 제외해야합니다.
Dynamic Rendering 이라고도 불리는 사전 렌더링은 Microsoft 에 의해 장려되며 Twitter를 포함한 많은 인기 웹 사이트에서 크게 사용됩니다.
결과는 예상대로 다음과 같습니다.
https://www.bing.com/search?q=site%3AHTTPS%3A%2F%2FCLIENT-Side-Rendering.pages.dev

CSS-in-JS를 사용할 때는 스타일을 DOM에 생략하려면 사전에 빠른 최적화를 비활성화 할 수 있습니다.
소셜 미디어에서 CSR 앱 링크를 공유 할 때 어떤 페이지에 링크하든 미리보기가 동일하게 유지되는 것을 알 수 있습니다.
대부분의 CSR 앱에는 컨텐츠가없는 HTML 파일이 하나만 있고 소셜 미디어 크롤러는 JS를 렌더링하지 않기 때문에 발생합니다.
사전 렌더링이 다시 한 번 우리의 도움을받는 곳은 각 페이지에 대한 적절한 공유 미리보기를 생성합니다.
whatsapp :

Facebook :

모든 앱 페이지를 검색 엔진을 발견 할 수 있도록하려면 모든 웹 사이트 경로를 지정하는 sitemap.xml 파일을 작성하는 것이 좋습니다.
이미 중앙 집중식 pages.js 파일이 있으므로 빌드 시간 동안 사이트 맵을 쉽게 생성 할 수 있습니다.
create-sitemap.js
import { Readable } from 'stream'
import { writeFile } from 'fs/promises'
import { SitemapStream , streamToPromise } from 'sitemap'
import pages from '../src/pages.js'
const stream = new SitemapStream ( { hostname : 'https://client-side-rendering.pages.dev' } )
const links = pages . map ( ( { path } ) => ( { url : path , changefreq : 'weekly' } ) )
streamToPromise ( Readable . from ( links ) . pipe ( stream ) )
. then ( data => data . toString ( ) )
. then ( res => writeFile ( 'public/sitemap.xml' , res ) )
. catch ( console . log )이것은 다음 사이트 맵을 방출합니다.
<? xml version = " 1.0 " encoding = " UTF-8 " ?>
< urlset xmlns = " http://www.sitemaps.org/schemas/sitemap/0.9 " xmlns : image = " http://www.google.com/schemas/sitemap-image/1.1 " xmlns : news = " http://www.google.com/schemas/sitemap-news/0.9 " xmlns : video = " http://www.google.com/schemas/sitemap-video/1.1 " xmlns : xhtml = " http://www.w3.org/1999/xhtml " >
< url >
< loc >https://client-side-rendering.pages.dev/</ loc >
< changefreq >weekly</ changefreq >
</ url >
< url >
< loc >https://client-side-rendering.pages.dev/lorem-ipsum</ loc >
< changefreq >weekly</ changefreq >
</ url >
< url >
< loc >https://client-side-rendering.pages.dev/pokemon</ loc >
< changefreq >weekly</ changefreq >
</ url >
</ urlset >Google Search Console 및 Bing Webmaster 도구 에 사이트 맵을 수동으로 제출할 수 있습니다.
위에서 언급했듯이 모든 렌더링 방법의 심층 비교는 여기에서 찾을 수 있습니다 : https://client-side-rendering.pages.dev/comparison
우리는 정적 파일의 장점을 보았습니다. 그들은 캐시 가능하며 서버를 필요로하지 않고 근처의 CDN에서 제공 될 수 있습니다.
이로 인해 SSG가 CSR과 SSR의 이점을 결합한다고 믿게 될 수 있습니다. 이는 API 서버의 응답 시간과 독립적으로 앱을 시각적으로 빠르게로드하고 ( FCP ) 매우 빠르게로드합니다.
그러나 실제로 SSG는 주요 제한 사항이 있습니다.
JS는 초기 순간 동안 활성화되지 않기 때문에 제시 할 JS에 의존하는 모든 것이 단순히 표시되지 않거나 잘못 표시되지 않습니다 ( window.matchMedia 함수에 따라 렌더링 할 수있는 구성 요소).
이 문제의 전형적인 예는 다음 웹 사이트에서 볼 수 있습니다.
https://death-to-ie11.com
타이머가 어떻게 즉시 보이지 않습니까? JS에 의해 생성되기 때문에 다운로드 및 실행하는 데 시간이 걸리기 때문입니다.
또한 일부 필터가 적용된 Vercel의 '가이드'페이지를 새로 고칠 때도 비슷한 문제가 있습니다.
https://vercel.com/guides?topics=analytics
이는 65536 (2^16) 가능한 필터 조합이 있으며, 각 조합을 별도의 HTML 파일로 저장하려면 많은 서버 스토리지가 필요합니다.
따라서 모든 데이터를 포함하는 단일 guides.html 파일을 생성하지만이 정적 파일은 JS가로드 될 때까지 어떤 필터가 적용되는지 알지 못하여 레이아웃 이동이 발생합니다.
점진적인 정적 재생을 사용하더라도 아직 캐시되지 않은 페이지 (SSR에서와 같이)를 방문 할 때는 서버 응답을 기다려야한다는 점에 유의해야합니다.
이 문제의 또 다른 예는 JS 애니메이션입니다. 처음에는 정적으로 나타나고 JS가로드되면 애니메이션 만 시작할 수 있습니다.
이 지연된 기능이 JS가로드 된 후에 웹 사이트가 내비게이션 막대 만 표시 할 때와 같이이 지연된 기능에 해를 끼치는 경우가 많이 있습니다 (사용자 정보 입력이 있는지 확인하기 위해 로컬 스토리지에 의존하기 때문에).
특히 전자 상거래 웹 사이트의 또 다른 중요한 문제는 SSG 페이지가 제품의 가격 또는 가용성과 같은 오래된 데이터를 표시 할 수 있다는 것입니다.
이것이 바로 주요 전자 상거래 웹 사이트가 SSG를 사용하지 않는 이유입니다.
빠른 인터넷 연결에서 CSR과 SSR이 모두 최적화되고 연결 속도가 높을수록 로딩 시간에 가까워 질 수 있습니다.
그러나 느린 연결 (예 : 모바일 네트워크)을 처리 할 때 SSR은 로딩 시간과 관련하여 CSR보다 우위에있는 것으로 보입니다.
SSR 앱은 서버에서 렌더링되므로 브라우저는 완전히 구성된 HTML 파일을 수신하므로 JS를 다운로드 할 때까지 기다리지 않고 사용자에게 페이지를 표시 할 수 있습니다. JS가 결국 다운로드 및 구문 분석되면 프레임 워크는 기능으로 DOM을 "수화"할 수 있습니다 (재구성 할 필요없이).
큰 장점 인 것처럼 보이지만이 동작은 특히 느린 연결에서 바람직하지 않은 부작용을 소개합니다.
JS가로드 될 때까지 사용자는 원하는 곳을 클릭 할 수 있지만 앱은 JS 기반 이벤트에 반응하지 않습니다.
버튼이 사용자 상호 작용에 응답하지 않을 때는 잘못된 사용자 경험이지만 기본 이벤트가 방지되지 않을 때 훨씬 더 큰 문제가됩니다.
이것은 빠른 3G 연결에서 Next.js의 웹 사이트와 클라이언트 측 렌더링 앱을 비교 한 것입니다.


여기서 무슨 일이 있었나요?
JS가 아직로드되지 않았으므로 Next.js의 웹 사이트는 앵커 태그 요소 ( <a> )의 기본 동작이 다른 페이지로 이동하는 것을 방해 할 수 없으므로 모든 클릭을 클릭하면 전체 페이지 새로 고침이 트리거됩니다.
연결이 느리면이 문제가 더 심해집니다.
다시 말해, SSR이 CSR보다 성능 우위를 점했던 곳에서는 사용자 경험을 크게 저하시킬 수있는 매우 "위험한"동작을 볼 수 있습니다.
CSR 앱 에서이 문제가 발생하는 것은 불가능합니다. 렌더링 순간 -JS는 이미 완전히로드되었습니다.
우리는 클라이언트 측 렌더링 성능이 초기 로딩 시간 측면에서 SSR보다 훨씬 우수하다는 것을 알았습니다 (그리고 탐색 시간에는 훨씬 능가).
또한 GoogleBot은 클라이언트 측 렌더링 앱을 완벽하게 색인 할 수 있으며 다른 모든 봇 및 크롤러를 제공하기 위해 프레 렌더 서버를 쉽게 설정할 수 있음을 알았습니다.
그리고 가장 중요한 것은 몇 개의 파일을 추가하고 프레 렌더 서비스를 사용 하여이 모든 것을 달성 했으므로 모든 기존 CSR 앱은 이러한 변경 사항을 빠르고 쉽게 구현하고 이의 혜택을 누릴 수 있어야합니다.
이러한 사실은 SSR을 사용해야 할 강력한 이유가 없다는 결론으로 이어집니다. Doing so would only add unnecessary complexity and limitations to our app, degrading both the developer and user experience, while also incurring higher server costs.
As time passes, connection speeds are getting faster and end-user devices are becoming more powerful. As a result, the performance differences between various website rendering methods are guaranteed to diminish further (except for SSR, which still depends on API server response times).
A new SSR method called Streaming SSR (in React, this is through "Server Components") and newer frameworks like Qwik are capable of streaming responses to the browser without waiting for the API server's response. However, there are also newer and more efficient CSR frameworks like Svelte and Solid.js, which have much smaller bundle sizes and are significantly faster than React (greatly improving FCP on slow networks).
Nevertheless, it's important to note that nothing will ever outperform the instant page transitions that client-side rendering provides, nor the simple and flexible development flow it offers.