Proyek ini adalah studi kasus CSR, ini mengeksplorasi potensi aplikasi yang ditampilkan di sisi klien dibandingkan dengan rendering sisi server.
Perbandingan mendalam dari semua metode rendering dapat ditemukan di halaman perbandingan proyek ini: https://client-side-rendering.pages.dev/comparison
Rendering Sisi Klien (CSR) mengacu pada mengirim aset statis ke browser web dan memungkinkannya untuk menangani seluruh proses rendering aplikasi.
Server-Side Rendering (SSR) melibatkan rendering seluruh aplikasi (atau halaman) di server dan memberikan dokumen HTML yang telah dirender yang siap ditampilkan.
Static Site Generation (SSG) adalah proses halaman HTML pra-menghasilkan sebagai aset statis, yang kemudian dikirim dan ditampilkan oleh browser.
Berlawanan dengan kepercayaan umum, proses SSR dalam kerangka kerja modern seperti React , Angular , Vue , dan Svelte menghasilkan rendering aplikasi dua kali: sekali di server dan lagi di browser (ini dikenal sebagai "hidrasi"). Tanpa render kedua ini, aplikasi ini akan statis dan tidak interaktif, pada dasarnya berperilaku seperti halaman web "tak bernyawa".
Menariknya, proses hidrasi tampaknya tidak lebih cepat daripada render khas (tidak termasuk fase lukisan, tentu saja).
Penting juga untuk dicatat bahwa aplikasi SSG juga harus menjalani hidrasi.
Baik SSR dan SSG, dokumen HTML sepenuhnya dibangun, memberikan manfaat berikut:
Di sisi lain, aplikasi CSR menawarkan keuntungan berikut:
Dalam studi kasus ini, kami akan fokus pada CSR dan mengeksplorasi cara untuk mengatasi keterbatasannya yang jelas sambil memanfaatkan kekuatannya ke puncak.
Semua optimisasi akan dimasukkan ke dalam aplikasi yang digunakan, yang dapat ditemukan di sini: https://client-side-rendering.pages.dev.
"Baru-baru ini, SSR (rendering sisi server) telah mengambil dunia front-end Javascript. Fakta bahwa Anda sekarang dapat membuat situs dan aplikasi Anda di server sebelum mengirimnya ke klien Anda adalah ide yang benar-benar revolusioner (dan sama sekali bukan apa yang dilakukan semua orang sebelum aplikasi sisi klien JS menjadi populer di tempat pertama ...).
Namun, kritik yang sama yang valid untuk situs PHP, ASP, JSP, (dan semacam itu) berlaku untuk rendering sisi server hari ini. Ini lambat, rusak dengan cukup mudah, dan sulit diimplementasikan dengan benar.
Masalahnya, terlepas dari apa yang orang katakan kepada Anda, Anda mungkin tidak perlu SSR. Anda bisa mendapatkan hampir semua keunggulannya (tanpa kerugian) dengan menggunakan prerendering. "
~ Plugin Spa Prerender
Dalam beberapa tahun terakhir, rendering sisi server telah mendapatkan popularitas yang signifikan dalam bentuk kerangka kerja seperti Next.js dan Remix ke titik bahwa pengembang sering default menggunakannya tanpa sepenuhnya memahami keterbatasan mereka, bahkan dalam aplikasi yang tidak memerlukan SEO (misalnya, mereka yang memiliki persyaratan login).
Sementara SSR memiliki keunggulannya, kerangka kerja ini terus menekankan kecepatan mereka ("kinerja sebagai default"), menunjukkan bahwa rendering sisi klien (CSR) secara inheren lambat.
Selain itu, ada kesalahpahaman luas bahwa SEO yang sempurna hanya dapat dicapai dengan SSR, dan bahwa aplikasi CSR tidak dapat dioptimalkan untuk crawler mesin pencari.
Argumen umum lainnya untuk SSR adalah bahwa ketika aplikasi web tumbuh lebih besar, waktu pemuatan mereka akan terus meningkat, yang mengarah ke kinerja FCP yang buruk untuk aplikasi CSR.
Meskipun benar bahwa aplikasi menjadi lebih kaya fitur, ukuran satu halaman harus benar-benar berkurang dari waktu ke waktu.
Hal ini disebabkan oleh tren menciptakan versi perpustakaan dan kerangka kerja yang lebih kecil dan lebih efisien, seperti Zustand , Day.js , Headless-UI , dan React-Router V6 .
Kita juga dapat mengamati pengurangan ukuran kerangka kerja dari waktu ke waktu: sudut (74.1kb), bereaksi (44.5kb), vue (34kb), padat (7.6kb), dan langsing (1.7kb).
Perpustakaan ini berkontribusi secara signifikan terhadap bobot keseluruhan skrip halaman web.
Dengan pemisahan kode yang tepat, waktu pemuatan awal halaman dapat berkurang dari waktu ke waktu.
Proyek ini mengimplementasikan aplikasi CSR dasar dengan optimisasi seperti pemisahan kode dan preloading. Tujuannya adalah agar waktu pemuatan masing -masing halaman tetap stabil saat skala aplikasi.
Tujuannya adalah untuk mensimulasikan struktur paket aplikasi kelas produksi dan meminimalkan waktu pemuatan melalui permintaan yang disejajarkan.
Penting untuk dicatat bahwa meningkatkan kinerja tidak boleh datang dengan biaya pengalaman pengembang. Oleh karena itu, arsitektur proyek ini hanya akan sedikit dimodifikasi dari pengaturan reaksi yang khas, menghindari struktur kerangka kerja yang kaku dan berpendapat seperti Next.js, atau keterbatasan SSR secara umum.
Studi kasus ini akan fokus pada dua aspek utama: kinerja dan SEO. Kami akan mengeksplorasi cara mencapai skor tertinggi di kedua bidang.
Perhatikan bahwa meskipun proyek ini diimplementasikan menggunakan React, sebagian besar optimasi adalah kerangka kerja-agnostik dan murni didasarkan pada bundler dan browser web.
Kami akan mengasumsikan pengaturan webpack standar (RSPACK) dan menambahkan kustomisasi yang diperlukan saat kami maju.
Aturan praktis pertama adalah meminimalkan dependensi dan, di antaranya, memilih yang dengan ukuran file terkecil.
Misalnya:
Kita dapat menggunakan day.js bukan momen , zustand bukan redux toolkit , dll.
Ini penting tidak hanya untuk aplikasi CSR tetapi juga untuk aplikasi SSR (dan SSG), karena bundel yang lebih besar menghasilkan waktu beban yang lebih lama, menunda ketika halaman menjadi terlihat atau interaktif.
Idealnya, setiap file hash harus di -cache, dan index.html tidak boleh di -cache.
Ini berarti bahwa browser pada awalnya akan menyimpan main.[hash].js dan harus memuatnya kembali hanya jika hash (konten) berubah:

Namun, karena main.js mencakup seluruh bundel, sedikit perubahan kode akan menyebabkan cachenya berakhir, yang berarti browser harus mengunduhnya lagi.
Sekarang, bagian mana dari bundel kita yang terdiri dari sebagian besar bobotnya? Jawabannya adalah dependensi , juga disebut vendor .
Jadi jika kita dapat membagi vendor untuk potongan hash mereka sendiri, itu akan memungkinkan pemisahan antara kode kita dan kode vendor, yang mengarah pada lebih sedikit pembatalan cache.
Mari tambahkan optimasi berikut ke file konfigurasi kami:
rspack.config.js
export default ( ) => {
return {
optimization : {
runtimeChunk : 'single' ,
splitChunks : {
chunks : 'initial' ,
cacheGroups : {
vendors : {
test : / [\/]node_modules[\/] / ,
name : 'vendors'
}
}
}
}
}
} Ini akan membuat vendors.[hash].js file:

Meskipun ini merupakan peningkatan yang substansial, apa yang akan terjadi jika kami memperbarui ketergantungan yang sangat kecil?
Dalam kasus seperti itu, seluruh cache chunk akan membatalkan.
Jadi, untuk memperbaikinya lebih jauh, kami akan membagi setiap ketergantungan pada potongan hash -nya sendiri:
rspack.config.js
- name: 'vendors'
+ name: module => {
+ const moduleName = (module.context.match(/[\/]node_modules[\/](.*?)([\/]|$)/) || [])[1]
+
+ return moduleName.replace('@', '')
+ } Ini akan membuat file seperti react-dom.[hash].js yang berisi satu vendor besar dan [id].[hash].js file yang berisi semua vendor (kecil) yang tersisa:

Info lebih lanjut tentang konfigurasi default (seperti ukuran ambang terpisah) dapat ditemukan di sini:
https://webpack.js.org/plugins/split-chunks-plugin/#defaults
Banyak fitur yang kami tulis pada akhirnya hanya digunakan di beberapa halaman kami, jadi kami ingin mereka dimuat hanya ketika pengguna mengunjungi halaman tempat mereka digunakan.
Misalnya, kami tidak ingin pengguna harus menunggu sampai paket React-Big-Calendar diunduh, diuraikan dan dieksekusi jika mereka hanya memuat halaman beranda . Kami hanya ingin itu terjadi ketika mereka mengunjungi halaman kalender .
Cara kita dapat mencapai ini adalah (lebih disukai) dengan pemisahan kode berbasis rute:
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' ) ) Jadi ketika pengguna mengunjungi halaman Pokemon , mereka hanya mengunduh skrip chunk utama (yang mencakup semua dependensi bersama seperti kerangka kerja) dan pokemon.[hash].js chunk.
Catatan: Didorong untuk mengunduh seluruh aplikasi sehingga pengguna akan mengalami navigasi instan, seperti aplikasi. Tapi itu adalah ide yang buruk untuk batch semua aset ke dalam satu skrip, menunda render pertama halaman.
Aset-aset ini harus diunduh secara tidak sinkron dan hanya setelah halaman yang diminta pengguna selesai rendering dan sepenuhnya terlihat.
Pemisahan kode memiliki satu cacat besar - runtime tidak tahu bongkahan async mana yang dibutuhkan sampai skrip utama dieksekusi, yang menyebabkan mereka diambil dalam penundaan yang signifikan (karena mereka melakukan perjalanan pulang pergi ke CDN):

Cara kita dapat menyelesaikan masalah ini adalah dengan menulis plugin khusus yang akan menanamkan skrip dalam dokumen yang akan bertanggung jawab atas preloading aset yang relevan:
rspack.config.js
import InjectAssetsPlugin from './scripts/inject-assets-plugin.js'
export default ( ) => {
return {
plugins : [ new InjectAssetsPlugin ( ) ]
}
}skrip/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 InjectAssetsPluginScript/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 ( ) File pages.js yang diimpor dapat ditemukan di sini.
Dengan cara ini, browser dapat mengambil potongan skrip khusus halaman secara paralel dengan aset render-kritis:

Pemisahan kode memperkenalkan masalah lain: duplikasi vendor async.
Katakanlah kita memiliki dua potongan async: lorem-ipsum.[hash].js dan pokemon.[hash].js . Jika mereka berdua memasukkan ketergantungan yang sama yang bukan bagian dari potongan utama, itu berarti pengguna akan mengunduh ketergantungan itu dua kali .
Jadi jika itu ketergantungan itu adalah moment dan beratnya 72kb minzipping, maka kedua ukuran async chunk akan setidaknya 72kb.
Kita perlu membagi ketergantungan ini dari potongan async ini sehingga bisa dibagikan di antara mereka:
rspack.config.js
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'initial',
cacheGroups: {
vendors: {
test: /[\/]node_modules[\/]/,
+ chunks: 'all',
name: ({ context }) => (context.match(/[\/]node_modules[\/](.*?)([\/]|$)/) || [])[1].replace('@', '')
}
}
}
} Sekarang keduanya lorem-ipsum.[hash].js dan pokemon.[hash].js akan menggunakan moment.[hash].js chunk, hemat pengguna banyak lalu lintas jaringan (dan memberikan aset-aset ini yang lebih baik kegigihan cache).
Namun, kami tidak memiliki cara untuk memberi tahu potongan vendor async mana yang akan dibagi sebelum kami membangun aplikasi, jadi kami tidak akan tahu potongan vendor async mana yang perlu kami preload (lihat bagian "Preloading Async Chunks"):

Itu sebabnya kami akan menambahkan nama potongan ke nama vendor async:
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('@', '')
}
}
}
}
}skrip/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
}Script/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' })
)
+ })Sekarang semua potongan vendor async akan diambil secara paralel dengan potongan async induknya:

Salah satu kelemahan CSR yang diduga lebih dari SSR adalah bahwa data halaman (permintaan pengambilan) akan dipecat hanya setelah JS diunduh, diuraikan dan dieksekusi di browser:

Untuk mengatasi ini, kami akan menggunakan preloading sekali lagi, kali ini untuk data itu sendiri, dengan menambal API fetch :
skrip/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 })
})Script/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() Pengingat: File pages.js dapat ditemukan di sini.
Sekarang kita dapat melihat bahwa data sedang diambil segera:

Dengan skrip di atas, kita bahkan dapat memuat data rute dinamis (seperti pokemon/: nama ).
Pengguna harus memiliki pengalaman navigasi yang lancar di aplikasi kami.
Namun, pemisahan setiap halaman menyebabkan keterlambatan navigasi yang nyata, karena setiap halaman harus diunduh (sesuai permintaan) sebelum dapat diberikan di layar.
Kami ingin memilih dan menyimpan semua halaman sebelumnya.
Kami dapat melakukan ini dengan menulis pekerja layanan sederhana:
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/layanan-pekerja-registrasi.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 ( )
}Publik/Layanan-Pekerjaan.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 ) )
} )Sekarang semua halaman akan dipilih dan di -cache bahkan sebelum pengguna mencoba menavigasi ke mereka.
Pendekatan ini juga akan menghasilkan cache kode lengkap.
Saat memeriksa file react-dom.js 43kb kami, kami dapat melihat bahwa waktu yang dibutuhkan untuk permintaan untuk kembali adalah 60ms sementara waktu yang dibutuhkan untuk mengunduh file adalah 3ms:

Ini menunjukkan fakta yang terkenal bahwa RTT memiliki dampak besar pada waktu pemuatan halaman web, kadang-kadang bahkan lebih dari sekadar kecepatan unduhan, dan bahkan ketika aset disajikan dari tepi CDN terdekat seperti dalam kasus kami.
Selain itu dan yang lebih penting, kita dapat melihat bahwa setelah file HTML diunduh, kami memiliki rentang waktu yang besar di mana browser tetap diam dan hanya menunggu skrip tiba:

Ini adalah banyak waktu yang berharga (ditandai dengan warna merah) yang dapat digunakan oleh browser untuk mengunduh, menguraikan dan bahkan menjalankan skrip, mempercepat visibilitas dan interaktivitas halaman.
Ketidakefisienan ini akan terulang kembali setiap waktu aset berubah (cache parsial). Ini bukan sesuatu yang hanya terjadi pada kunjungan pertama.
Jadi bagaimana kita bisa menghilangkan waktu idle ini?
Kami dapat menyambung semua skrip awal (kritis) dalam dokumen, sehingga mereka akan mulai mengunduh, menguraikan dan mengeksekusi sampai aset halaman async tiba:

Kita dapat melihat bahwa browser sekarang mendapatkan skrip awalnya tanpa harus mengirim permintaan lain ke CDN.
Jadi browser pertama -tama akan mengirim permintaan untuk potongan async dan data yang dimuat sebelumnya, dan sementara ini sedang menunggu, itu akan terus mengunduh dan menjalankan skrip utama.
Kita dapat melihat bahwa potongan async mulai mengunduh (ditandai dengan warna biru) tepat setelah file HTML selesai mengunduh, parsing dan mengeksekusi, yang menghemat banyak waktu.
Meskipun perubahan ini membuat perbedaan yang signifikan pada jaringan cepat, itu bahkan lebih penting untuk jaringan yang lebih lambat, di mana penundaan lebih besar dan RTT jauh lebih berdampak.
Namun, solusi ini memiliki 2 masalah besar:
Untuk mengatasi masalah ini, kami tidak dapat lagi tetap berpegang pada file HTML statis, dan karenanya kami akan melega -leaver kekuatan server. Atau, lebih tepatnya, kekuatan pekerja tanpa server Cloudflare.
Pekerja ini harus mencegat setiap permintaan dokumen HTML dan menyesuaikan respons yang sangat cocok.
Seluruh aliran harus digambarkan sebagai berikut:
X-Cached dalam permintaan. Jika header tersebut ada, ia akan mengulangi nilai -nilainya dan hanya menyambung aset yang relevan* yang tidak ada di dalamnya dalam respons. Jika header tersebut tidak ada, itu akan menyambung semua aset yang relevan dalam respons.X-Cached yang menentukan semua aset yang di-cache.* Baik aset awal dan khusus halaman.
Ini memastikan bahwa browser menerima dengan tepat aset yang dibutuhkan (tidak lebih, tidak kurang) untuk menampilkan halaman saat ini dalam satu bundar !
skrip/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 InjectAssetsPluginPUBLIK/_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/ekstrak-inline-skrips.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/layanan-pekerja-registrasi.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 }
)
}Publik/Layanan-Pekerjaan.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 ) )
} )Hasil untuk beban segar (seluruhnya tidak terkena) luar biasa:



Pada beban berikutnya, pekerja CloudFlare merespons dengan dokumen HTML minimal (1.8kB) dan semua aset segera dilayani dari cache.
Optimalisasi ini membawa kita ke yang lain - membelah potongan ke potongan -potongan yang lebih kecil.
Sebagai aturan praktis, membagi bundel menjadi terlalu banyak potongan dapat melukai kinerja. Ini karena halaman tidak akan diterjemahkan sampai semua file diunduh, dan semakin banyak potongan, semakin besar kemungkinan bahwa salah satu dari mereka akan ditunda (karena perangkat keras dan kecepatan jaringan non-linear).
Tetapi dalam kasus kami itu tidak relevan, karena kami melapisi semua potongan yang relevan dan karenanya mereka diambil sekaligus.
rspack.config.js
optimization: {
splitChunks: {
chunks: 'initial',
cacheGroups: {
vendors: {
+ minSize: 10000,
}
}
}
},Pemisahan ekstrem ini akan menyebabkan kegigihan cache yang lebih baik, dan pada gilirannya, untuk waktu beban yang lebih cepat dengan cache parsial.
Ketika aset statis diambil dari CDN, itu termasuk header ETag , yang merupakan hash konten dari sumber daya. Pada permintaan berikutnya, browser memeriksa apakah memiliki ETAG yang disimpan. Jika ya, itu mengirim Etag di header If-None-Match . CDN kemudian membandingkan ETAG yang diterima dengan yang saat ini: jika cocok, ia mengembalikan status 304 Not Modified , menunjukkan browser dapat menggunakan aset yang di -cache; Jika tidak, ia mengembalikan aset baru dengan status 200 .
Dalam aplikasi CSR tradisional, memuat ulang hasil halaman dalam HTML mendapatkan 304 Not Modified , dengan aset lain yang disajikan dari cache. Setiap rute memiliki ETAG yang unik, SO /lorem-ipsum dan /pokemon memiliki entri cache yang berbeda, bahkan jika Etag mereka identik.
Dalam spa CSR, karena hanya ada satu file HTML, Etag yang sama digunakan untuk setiap permintaan halaman. Namun, karena ETAG disimpan per rute, browser tidak akan mengirim header If-None-Match untuk halaman yang tidak dikunjungi, yang mengarah ke status 200 dan redownload HTML, meskipun file yang sama.
Namun, kami dapat dengan mudah membuat implementasi perilaku ini sendiri (ditingkatkan) melalui kolaborasi antara para pekerja:
skrip/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()
})
}
}PUBLIK/_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 })
+ }
.
.
.
}
}Publik/Layanan-Pekerjaan.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
}
} Perhatikan bahwa X-ETag khusus disertakan untuk situasi di mana CDN tidak secara otomatis mengirim ETag .
Sekarang pekerja tanpa server kami akan selalu merespons dengan kode status 304 Not Modified setiap kali tidak ada perubahan, bahkan untuk halaman yang tidak dikunjungi.
Ketika pekerja layanan digunakan, browser menunda mengirim permintaan dokumen HTML awal sampai pekerja layanan dimuat, yang dapat menyebabkan keterlambatan halaman yang sedikit hingga sedang tergantung pada perangkat keras.
Solusi asli untuk masalah ini disebut navigasi preload . Kami akan menerapkan ini untuk memastikan permintaan dokumen dikirim segera, tanpa menunggu pekerja layanan memuat:
src/utils/layanan-pekerja-registrasi.ts
const register = ( ) => {
.
.
.
navigator . serviceWorker ?. addEventListener ( 'message' , async event => {
const { navigationPreloadHeader } = event . data
const registration = await navigator . serviceWorker . ready
registration . navigationPreload . setHeaderValue ( navigationPreloadHeader )
} )
}Publik/Layanan-Pekerjaan.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 ) )
} )Dengan implementasi ini, permintaan dokumen akan segera dikirim, terlepas dari pekerja layanan.
Catatan: Membutuhkan React (v18), Svelte atau Solid.js
Ketika kami membagi halaman dari aplikasi utama, kami memisahkan fase rendernya, yang berarti aplikasi akan dirender sebelum halaman diterjemahkan.
Jadi ketika kita berpindah dari satu halaman async ke yang lain, kita melihat ruang kosong yang tersisa sampai halaman ditampilkan:


Ini terjadi karena pendekatan umum hanya membungkus rute dengan ketegangan:
const App = ( ) => {
return (
< >
< Navigation />
< Suspense >
< Routes > { routes } </ Routes >
</ Suspense >
</ >
)
} React 18 memperkenalkan kami pada hook useTransition , yang memungkinkan kami untuk menunda render sampai beberapa kriteria terpenuhi.
Kami akan menggunakan kait ini untuk menunda navigasi halaman sampai siap:
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 NavigationLinkSekarang halaman async akan terasa seperti tidak pernah terpecah dari aplikasi utama.
Kita dapat memuat data halaman lain saat melayang di atas tautan (desktop) atau ketika tautan masuk viewport (seluler):
Navigationlink.tsx
< NavLink onMouseEnter = { ( ) => fetch ( url , { ... request , preload : true } ) } > { children } </ NavLink >Perhatikan bahwa ini mungkin tidak perlu memuat server API.
Beberapa pengguna membiarkan aplikasi terbuka untuk waktu yang lama, jadi hal lain yang dapat kita lakukan adalah merevalidasi (mengunduh aset baru) aplikasi saat sedang berjalan:
Layanan-pekerja-registrasi.ts
+ 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 }
)
}Kode di atas memulihkan kembali aplikasi setiap jam.
Proses revalidasi sangat murah, karena hanya melibatkan pengembalian kembali pekerja layanan (yang akan mengembalikan kode status yang tidak dimodifikasi 304 jika tidak diubah).
Ketika pekerja layanan tidak berubah, itu berarti aset baru tersedia, dan karenanya mereka akan diunduh dan di -cache secara selektif.
Kami membagi bundel kami menjadi banyak potongan kecil, sangat meningkatkan kemampuan caching aplikasi kami.
Kami membagi setiap halaman sehingga setelah memuat satu, hanya apa yang relevan sedang diunduh segera.
Kami telah berhasil membuat beban awal (tanpa cache) dari aplikasi kami dengan sangat cepat, semua yang diperlukan halaman untuk dimuat secara dinamis disuntikkan ke sana.
Kami bahkan memulai data halaman, menghilangkan data yang terkenal mengambil air terjun yang diketahui dimiliki oleh aplikasi CSR.
Selain itu, kami membuat semua halaman, yang membuatnya tampak seolah -olah mereka tidak pernah terpecah dari kode bundel utama.
Semua ini dicapai tanpa mengorbankan pengalaman pengembang dan tanpa mendikte kerangka kerja JS mana yang harus dipilih.
Keuntungan terbesar dari aplikasi statis adalah dapat disajikan sepenuhnya dari CDN.
CDN memiliki banyak pop (titik kehadiran), juga disebut "jaringan tepi". Pop ini didistribusikan di seluruh dunia dan karenanya dapat melayani file ke setiap wilayah jauh lebih cepat daripada server jarak jauh.
CDN tercepat hingga saat ini adalah Cloudflare, yang memiliki lebih dari 250 pop (dan penghitungan):

https://speed.cloudflare.com
https://blog.cloudflare.com/benchmarking-gede-network-performance
Kami dapat dengan mudah menggunakan aplikasi kami menggunakan halaman cloudflare:
https://pages.cloudflare.com
Untuk menyimpulkan bagian ini, kami akan melakukan tolok ukur aplikasi kami dibandingkan dengan situs dokumentasi Next.js , yang sepenuhnya SSG .
Kami akan membandingkan halaman aksesibilitas minimalis dengan halaman IPSum Lorem kami. Kedua halaman termasuk ~ 246kb JS dalam potongan render-kritis mereka (preloads dan prefetch yang datang setelahnya tidak relevan).
Anda dapat mengklik setiap tautan untuk melakukan tolok ukur langsung.
Aksesibilitas | Next.js
Lorem ipsum | Rendering sisi klien
Saya melakukan benchmark Google PageSpeed Insights (mensimulasikan jaringan 4G yang lambat) sekitar 20 kali untuk setiap halaman dan memilih skor tertinggi.
Ini adalah hasilnya:


Ternyata, kinerja bukan default di Next.js.
Perhatikan bahwa tolok ukur ini hanya menguji beban pertama halaman, tanpa mempertimbangkan bagaimana kinerja aplikasi ketika di -cache sepenuhnya (di mana CSR benar -benar bersinar).
Ini adalah minor yang umum bahwa Google mengalami kesulitan mengindeks aplikasi CSR (JS) dengan benar.
Itu mungkin terjadi pada tahun 2017, tetapi sampai hari ini: Google mengindeks aplikasi CSR sebagian besar dengan sempurna.
Halaman yang diindeks akan memiliki judul, deskripsi, konten, dan semua atribut terkait SEO lainnya, selama kita ingat untuk mengaturnya secara dinamis (baik secara manual seperti ini atau menggunakan paket seperti React-Helmet ).
https://www.google.com/search?q=site:https://client-side-rendering.pages.dev


Kemampuan GoogleBot Render JS dapat dengan mudah ditunjukkan dengan melakukan tes URL langsung aplikasi kami di Google Search Console :

GoogleBot menggunakan versi terbaru dari kromium untuk merangkak, jadi satu -satunya hal yang harus kami lakukan adalah memastikan aplikasi kami memuat dengan cepat dan cepat mengambil data.
Bahkan ketika data membutuhkan waktu lama untuk diambil, GoogleBot, dalam kebanyakan kasus, akan menunggu sebelum mengambil snapshot dari halaman:
https://support.google.com/webmasters/thread/202552760/for-how-long-does-googleebot-wait-for-the-last-http-request
https://support.google.com/webmasters/thread/165370285?hl=en&msgid=165510733
Penjelasan terperinci tentang proses merangkak JS GoogleBot dapat ditemukan di sini:
https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics
Jika GoogleBot gagal membuat beberapa halaman, sebagian besar disebabkan oleh keengganan Google untuk menghabiskan sumber daya yang diperlukan untuk merangkak situs web, yang berarti memiliki anggaran merangkak yang rendah.
Ini dapat dikonfirmasi dengan memeriksa halaman merangkak (dengan mengklik tampilan halaman merangkak di konsol pencarian) dan memastikan semua permintaan yang gagal memiliki peringatan kesalahan lainnya (yang berarti permintaan itu sengaja dibatalkan oleh GoogleBot):

Ini seharusnya hanya terjadi pada situs web yang dianggap Google tidak memiliki konten yang menarik atau memiliki lalu lintas yang sangat rendah (seperti aplikasi demo kami).
Informasi lebih lanjut dapat ditemukan di sini: https://support.google.com/webmasters/thread/4425254?hl=en&msgid=4426601
Mesin pencari lain seperti Bing tidak dapat membuat JS, jadi agar mereka merangkak dengan benar aplikasi kita, kita perlu melayani mereka versi prerender dari halaman kita.
Prerendering adalah tindakan merangkak aplikasi web dalam produksi (menggunakan kromium tanpa kepala) dan menghasilkan file HTML lengkap (dengan data) untuk setiap halaman.
Kami memiliki dua opsi ketika datang ke prerendering:
Prerendering tanpa server adalah pendekatan yang disarankan karena bisa sangat murah, terutama di GCP .
Kemudian kami mengarahkan Web Crawlers (diidentifikasi oleh string header User-Agent mereka) ke prerenderer kami, menggunakan pekerja CloudFlare (misalnya):
PUBLIK/_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 )
}
} Berikut adalah daftar terkini dari semua Bot Agnets (Web Crawlers): https://docs.prerender.io/docs/how-to-add-additional-bots#cloudflare. Ingatlah untuk mengecualikan googlebot dari daftar.
Prerendering , juga disebut Dynamic Rendering , didorong oleh Microsoft dan banyak digunakan oleh banyak situs web populer termasuk Twitter.
Hasilnya seperti yang diharapkan:
https://www.bing.com/search?q=site%3ahttps%3a%2f%2fclient-side-rendering.pages.dev

Perhatikan bahwa saat menggunakan CSS-in-Js, kita dapat menonaktifkan optimasi cepat selama prerendering jika kita ingin gaya kita dihilangkan ke DOM.
Ketika kami berbagi tautan aplikasi CSR di media sosial, kami dapat melihat bahwa apa pun halaman yang kami tautkan, pratinjau akan tetap sama.
Ini terjadi karena sebagian besar aplikasi CSR hanya memiliki satu file HTML tanpa konten, dan perayap media sosial tidak membuat JS.
Di sinilah prerendering datang ke bantuan kami sekali lagi, itu akan menghasilkan pratinjau saham yang tepat untuk setiap halaman:
Whatsapp:

Facebook :

Untuk membuat semua halaman aplikasi kami dapat ditemukan ke mesin pencari, disarankan untuk membuat file sitemap.xml yang menentukan semua rute situs web kami.
Karena kami sudah memiliki file page.js yang terpusat, kami dapat dengan mudah menghasilkan sitemap selama waktu pembangunan:
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 )Ini akan memancarkan sitemap berikut:
<? 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 >Kami dapat secara manual mengirimkan sitemap kami ke Google Search Console dan Bing Webmaster Tools .
Seperti disebutkan di atas, perbandingan mendalam dari semua metode rendering dapat ditemukan di sini: https://client-side-rendering.pages.dev/comparison
Kami telah melihat keunggulan file statis: mereka dapat di -cache dan dapat dilayani dari CDN terdekat tanpa memerlukan server.
Ini mungkin membuat kita percaya bahwa SSG menggabungkan manfaat CSR dan SSR: itu membuat aplikasi kita memuat secara visual sangat cepat ( FCP ) dan terlepas dari waktu respons server API kami.
Namun, pada kenyataannya, SSG memiliki batasan besar:
Karena JS tidak aktif selama saat -saat awal, segala sesuatu yang bergantung pada JS yang akan disajikan tidak akan terlihat atau akan ditampilkan secara tidak benar (seperti komponen yang bergantung pada window.matchMedia Fungsi MatchMedia untuk dirender).
Contoh klasik dari masalah ini dapat dilihat di situs web berikut:
https://death-to-ie11.com
Perhatikan bagaimana timer tidak terlihat segera? Itu karena dihasilkan oleh JS, yang membutuhkan waktu untuk diunduh dan dieksekusi.
Kami juga melihat masalah serupa saat menyegarkan halaman 'panduan' Vercel dengan beberapa filter diterapkan:
https://vercel.com/guides?topics=analytics
Ini terjadi karena ada 65536 (2^16) kemungkinan kombinasi filter, dan menyimpan setiap kombinasi sebagai file HTML terpisah akan membutuhkan banyak penyimpanan server.
Jadi, mereka menghasilkan satu guides.html file yang berisi semua data, tetapi file statis ini tidak tahu filter mana yang diterapkan sampai JS dimuat, menyebabkan pergeseran tata letak.
Penting untuk dicatat bahwa bahkan dengan regenerasi statis tambahan , pengguna masih harus menunggu respons server ketika mengunjungi halaman yang belum di -cache (seperti di SSR).
Contoh lain dari masalah ini adalah animasi JS - mereka mungkin terlihat statis pada awalnya dan hanya mulai menjiwai setelah JS dimuat.
Ada banyak contoh di mana fungsionalitas yang tertunda ini membahayakan pengalaman pengguna, seperti ketika situs web hanya menunjukkan bilah navigasi setelah JS dimuat (karena mereka bergantung pada penyimpanan lokal untuk memeriksa apakah ada entri info pengguna).
Masalah kritis lainnya, terutama untuk situs web e-commerce, adalah bahwa halaman SSG dapat menampilkan data yang sudah ketinggalan zaman (seperti harga atau ketersediaan produk).
Inilah sebabnya mengapa tidak ada situs web e-commerce utama yang menggunakan SSG.
Adalah fakta bahwa di bawah koneksi internet yang cepat, baik CSR dan SSR berkinerja hebat (selama keduanya dioptimalkan), dan semakin tinggi kecepatan koneksi - semakin dekat mereka dalam hal waktu pemuatan.
Namun, ketika berhadapan dengan koneksi lambat (seperti jaringan seluler), tampaknya SSR memiliki keunggulan selama CSR mengenai waktu pemuatan.
Karena aplikasi SSR diterjemahkan di server, browser menerima file HTML yang sepenuhnya dibangun, sehingga dapat menampilkan halaman kepada pengguna tanpa menunggu JS untuk diunduh. Ketika JS akhirnya diunduh dan diuraikan, kerangka kerja dapat "melembabkan" DOM dengan fungsionalitas (tanpa harus merekonstruksi).
Meskipun sepertinya keuntungan besar, perilaku ini memperkenalkan efek samping yang tidak diinginkan, terutama pada koneksi yang lebih lambat:
Sampai JS dimuat, pengguna dapat mengklik di mana pun mereka inginkan, tetapi aplikasi tidak akan bereaksi terhadap acara berbasis JS mereka.
Ini adalah pengalaman pengguna yang buruk ketika tombol tidak menanggapi interaksi pengguna, tetapi itu menjadi masalah yang jauh lebih besar ketika peristiwa default tidak dicegah.
Ini adalah perbandingan antara situs web Next.js dan aplikasi rendering sisi klien kami pada koneksi 3G cepat:


Apa yang terjadi di sini?
Karena JS belum <a> , situs web berikutnya.
Dan semakin lambat hubungannya - semakin parah masalah ini.
Dengan kata lain, di mana SSR seharusnya memiliki keunggulan kinerja di atas CSR, kami melihat perilaku yang sangat "berbahaya" yang mungkin secara signifikan menurunkan pengalaman pengguna.
Tidak mungkin terjadi masalah ini di aplikasi CSR, karena saat yang mereka render - JS telah sepenuhnya dimuat.
Kami melihat bahwa kinerja rendering sisi klien setara dan kadang-kadang bahkan lebih baik daripada SSR dalam hal waktu pemuatan awal (dan jauh melampaui waktu navigasi).
Kami juga telah melihat bahwa GoogleBot dapat dengan sempurna mengindeks aplikasi yang diberikan di sisi klien, dan bahwa kami dapat dengan mudah mengatur server prerender untuk melayani semua bot dan crawler lainnya.
Dan yang paling penting, kami telah mencapai semua ini hanya dengan menambahkan beberapa file dan menggunakan layanan prerender, sehingga setiap aplikasi CSR yang ada harus dapat dengan cepat dan mudah menerapkan perubahan ini dan mendapat manfaat dari mereka.
Fakta -fakta ini mengarah pada kesimpulan bahwa tidak ada alasan kuat untuk menggunakan 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.