هذا المشروع عبارة عن دراسة حالة للمسؤولية الاجتماعية للشركات ، فهو يستكشف إمكانات التطبيقات المقدمة من جانب العميل مقارنة بعرض جانب الخادم.
يمكن العثور على مقارنة متعمقة لجميع طرق التقديم في صفحة مقارنة هذا المشروع: https://client-side-rendering.pages.dev/comparison
يشير تقديم من جانب العميل (CSR) إلى إرسال أصول ثابتة إلى متصفح الويب والسماح له بالتعامل مع عملية التقديم بالكامل للتطبيق.
يتضمن تقديم جانب الخادم (SSR) تقديم التطبيق (أو الصفحة) بأكمله على الخادم وتسليم مستند HTML الذي تم تقديمه مسبقًا جاهزًا للعرض.
توليد الموقع الثابت (SSG) هو عملية توليد صفحات HTML مسبقًا كأصول ثابتة ، والتي يتم إرسالها وعرضها بواسطة المتصفح.
على عكس الاعتقاد الشائع ، فإن عملية SSR في الأطر الحديثة مثل React و Angular و Vue و Svelte تؤدي إلى تقديم التطبيق مرتين: مرة واحدة على الخادم ومرة أخرى على المتصفح (يُعرف باسم "الترطيب"). بدون هذا العرض الثاني ، سيكون التطبيق ثابتًا وغير نشط ، ويتصرف بشكل أساسي مثل صفحة ويب "بلا حياة".
ومن المثير للاهتمام ، أن عملية الترطيب لا تبدو أسرع من عرض نموذجي (باستثناء مرحلة الطلاء ، بالطبع).
من المهم أيضًا ملاحظة أن تطبيقات SSG يجب أن تخضع للترطيب أيضًا.
في كل من SSR و SSG ، يتم بناء وثيقة HTML بالكامل ، مما يوفر الفوائد التالية:
من ناحية أخرى ، تقدم تطبيقات CSR المزايا التالية:
في دراسة الحالة هذه ، سنركز على المسؤولية الاجتماعية للشركات ونستكشف طرقًا للتغلب على قيودها الواضحة مع الاستفادة من نقاط قوته إلى الذروة.
سيتم دمج جميع التحسينات في التطبيق المنشور ، والذي يمكن العثور عليه هنا: https://client-side-rendering.pages.dev.
"في الآونة الأخيرة ، اتخذت SSR (عرض جانب الخادم) عالم JavaScript في الواجهة الأمامية عن طريق العاصفة. حقيقة أنه يمكنك الآن تقديم مواقعك وتطبيقاتك على الخادم قبل إرسالها إلى عملائك هي فكرة ثورية تمامًا (وعدم وجود ما كان يفعله الجميع قبل أن تحظى تطبيقات JS Side Side في المقام الأول ...).
ومع ذلك ، فإن نفس الانتقادات التي كانت صالحة للمواقع PHP و ASP و JSP و (ومثل) صالحة لتقديم جانب الخادم اليوم. إنه بطيء ، ويكسر بسهولة إلى حد ما ، ويصعب تنفيذه بشكل صحيح.
الشيء هو ، على الرغم من ما قد يخبرك به الجميع ، ربما لا تحتاج إلى SSR. يمكنك الحصول على جميع مزاياها تقريبًا (بدون عيوب) باستخدام Prerendering ".
~ Prerender Spa Plugin
في السنوات الأخيرة ، اكتسب عرض من جانب الخادم شعبية كبيرة في شكل أطر مثل Next.js و Remix إلى درجة أن المطورين غالبًا ما يكونون افتراضيين لاستخدامهم دون فهم حدودهم تمامًا ، حتى في التطبيقات التي لا تحتاج إلى تحسين محركات البحث (على سبيل المثال ، مع متطلبات تسجيل الدخول).
على الرغم من أن SSR لها مزاياها ، إلا أن هذه الأطر لا تزال تشدد على سرعتها ("الأداء باعتبارها افتراضيًا") ، مما يشير إلى أن تقديم جانب العميل (CSR) بطيء بطبيعته.
بالإضافة إلى ذلك ، هناك اعتقاد خاطئ واسع النطاق بأن مُحسّنات محرّكات البحث المثالية لا يمكن تحقيقها إلا باستخدام SSR ، وأن تطبيقات المسؤولية الاجتماعية للشركات لا يمكن تحسينها لمحرك البحث.
هناك حجة شائعة أخرى لـ SSR وهي أنه مع نمو تطبيقات الويب أكبر ، ستستمر أوقات التحميل الخاصة بها في الزيادة ، مما يؤدي إلى ضعف أداء FCP لتطبيقات CSR.
على الرغم من أنه من الصحيح أن التطبيقات أصبحت أكثر ثراءً للميزات ، إلا أن حجم صفحة واحدة يجب أن ينخفض بالفعل بمرور الوقت.
ويرجع ذلك إلى اتجاه إنشاء إصدارات أصغر وأكثر كفاءة من المكتبات والأطر ، مثل Zustand و Day.js و Headless-Ui و React-Router V6 .
يمكننا أيضًا ملاحظة انخفاض حجم الأطر مع مرور الوقت: الزاوي (74.1 كيلو بايت) ، رد فعل (44.5 كيلو بايت) ، VUE (34 كيلو بايت) ، الصلبة (7.6 كيلو بايت) ، و svelte (1.7 كيلو بايت).
تساهم هذه المكتبات بشكل كبير في الوزن الكلي لنصوص صفحة الويب.
مع تقسيم الكود المناسب ، يمكن أن ينخفض وقت التحميل الأولي للصفحة بمرور الوقت.
ينفذ هذا المشروع تطبيق CSR أساسي مع تحسينات مثل تقسيم الكود والتحويل المسبق. الهدف هو أن تظل وقت التحميل للصفحات الفردية مستقرة مع موازين التطبيق.
الهدف من ذلك هو محاكاة بنية حزمة تطبيق الإنتاج وتقليل أوقات التحميل من خلال الطلبات المتوازية.
من المهم أن نلاحظ أن تحسين الأداء يجب ألا يأتي على حساب تجربة المطور. لذلك ، سيتم تعديل بنية هذا المشروع بشكل طفيف فقط من إعداد React النموذجي ، وتجنب الهيكل الصارم والرأي للأطر مثل Next.js ، أو قيود SSR بشكل عام.
ستركز دراسة الحالة هذه على جانبين رئيسيين: الأداء وكبار المسئولين الاقتصاديين. سوف نستكشف كيفية تحقيق أعلى الدرجات في كلا المجالين.
لاحظ أنه على الرغم من تنفيذ هذا المشروع باستخدام React ، فإن معظم التحسينات هي الإطارات العظيمة وتعتمد بحتة على Bundler ومتصفح الويب.
سوف نفترض إعداد WebPack قياسي (RSPACK) وإضافة التخصيصات المطلوبة مع تقدمنا.
القاعدة الأولى من الإبهام هي تقليل التبعيات إلى الحد الأدنى ، وبين تلك ، اختيار تلك التي لديها أصغر أحجام الملفات.
على سبيل المثال:
يمكننا استخدام Day.js بدلاً من اللحظة ، Zustand بدلاً من Redux Toolkit ، إلخ.
هذا أمر مهم ليس فقط لتطبيقات المسؤولية الاجتماعية للشركات ولكن أيضًا لتطبيقات SSR (و SSG) ، حيث أن الحزم الأكبر تؤدي إلى أوقات تحميل أطول ، تتأخر عندما تصبح الصفحة مرئية أو تفاعلية.
من الناحية المثالية ، يجب تخزين كل ملف تجزئة ، ويجب عدم تخزين index.html .
وهذا يعني أن المتصفح سيقوم في البداية بتخزين Cache 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 file:

على الرغم من أن هذا تحسن كبير ، ما الذي سيحدث إذا قمنا بتحديث تبعية صغيرة جدًا؟
في مثل هذه الحالة ، سيتم إبطال ذاكرة التخزين المؤقت للبائعين بأكملها.
لذلك ، من أجل تحسينه إلى أبعد من ذلك ، سنقسم كل تبعية إلى جزءها الخاص:
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 ( ) ]
}
}البرامج النصية/الحقن-الأجراس-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 المستوردين هنا.
وبهذه الطريقة ، فإن المتصفح قادر على جلب قطعة نصية خاصة بالصفحة بالتوازي مع الأصول الرائعة:

يقدم تقسيم الكود مشكلة أخرى: تكرار بائع Async.
لنفترض أن لدينا قطعتان غير متزامنتين: lorem-ipsum.[hash].js و pokemon.[hash].js . إذا كان كلاهما يتضمن نفس التبعية التي ليست جزءًا من الجزء الرئيسي ، فهذا يعني أن المستخدم سيقوم بتنزيل هذا التبعية مرتين .
لذلك إذا كانت التبعية المذكورة هي moment وتزن 72 كيلو بايت مُنص على ذلك ، فسيكون كل من حجم قطعة Async لا يقل عن 72 كيلو بايت.
نحتاج إلى تقسيم هذه التبعية من هذه القطع غير المتزامنة بحيث يمكن مشاركتها بينهما:
rspack.config.js
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'initial',
cacheGroups: {
vendors: {
test: /[\/]node_modules[\/]/,
+ chunks: 'all',
name: ({ context }) => (context.match(/[\/]node_modules[\/](.*?)([\/]|$)/) || [])[1].replace('@', '')
}
}
}
} الآن كل من lorem-ipsum.[hash].js و pokemon.[hash].js سوف يستخدمون moment.[hash].js
ومع ذلك ، ليس لدينا أي وسيلة لمعرفة أي أجزاء من البائعين غير المتزامنين سيتم تقسيمها قبل أن نبني التطبيق ، لذلك لا نعرف أجزاء البائع غير المتزامنة التي نحتاجها إلى التحميل المسبق (الرجوع إلى قسم "القطع المسبقة المسبقة"):

لهذا السبب سنقوم بإلحاق أسماء القطع باسم بائع 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('@', '')
}
}
}
}
}البرامج النصية/الحقن-الأجراس-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 هي أن بيانات الصفحة (طلبات الجلب) لن يتم إطلاقها إلا بعد تنزيل JS وتحليلها وتنفيذها في المتصفح:

للتغلب على هذا ، سوف نستخدم التحميل المسبق مرة أخرى ، هذه المرة للبيانات نفسها ، عن طريق تصحيح API fetch :
البرامج النصية/الحقن-الأجراس-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 هنا.
الآن يمكننا أن نرى أن البيانات يتم جلبها على الفور:

باستخدام البرنامج النصي أعلاه ، يمكننا حتى تحميل بيانات الطرق الديناميكية (مثل 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-are-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 ( )
}العام/الخدمة-بير
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 ) )
} )الآن سيتم تجريد جميع الصفحات وتخزينها مؤقتًا حتى قبل أن يحاول المستخدم التنقل إليها.
سيؤدي هذا النهج أيضًا إلى إنشاء ذاكرة التخزين المؤقت الكاملة.
عند فحص ملف react-dom.js الذي يبلغ طوله 43 كيلو بايت ، يمكننا أن نرى أن الوقت الذي استغرقه طلب العودة كان 60 مللي ثانية في حين أن الوقت الذي استغرقته لتنزيل الملف كان 3 مللي ثانية:

يوضح هذا الحقيقة المعروفة بأن RTT لها تأثير كبير على صفحات الويب ، وأحيانًا أكثر من سرعة التنزيل ، وحتى عندما يتم تقديم الأصول من حافة CDN القريبة كما في حالتنا.
بالإضافة إلى ذلك والأهم من ذلك ، يمكننا أن نرى أنه بعد تنزيل ملف HTML ، لدينا فترات زمنية كبيرة حيث يبقى المتصفح في وضع الخمول وينتظر وصول البرامج النصية:

هذا هو الكثير من الوقت الثمين (الذي تم وضعه في اللون الأحمر) الذي يمكن للمتصفح استخدامه لتنزيل البرامج النصية وتحليلها وحتى تنفيذها ، مما يسرع من رؤية الصفحة وتفاعلها.
هذا عدم الكفاءة سيؤدي إلى إعادة تشغيل كل مرة تتغير فيها الأصول (ذاكرة التخزين المؤقت الجزئية). هذا ليس شيئًا لا يحدث إلا في الزيارة الأولى.
فكيف يمكننا القضاء على هذا الوقت الخمول؟
يمكننا ضمير جميع البرامج النصية الأولية (الحرجة) في المستند ، بحيث تبدأ في تنزيل وتحليل وتنفيذ حتى تصل أصول صفحة Async:

يمكننا أن نرى أن المتصفح يحصل الآن على البرامج النصية الأولية دون الحاجة إلى إرسال طلب آخر إلى CDN.
لذلك سيقوم المتصفح أولاً بإرسال طلبات لأجزاء Async والبيانات المحملة مسبقًا ، وعلى الرغم من أنها معلقة ، إلا أنها ستستمر في تنزيل وتنفيذ البرامج النصية الرئيسية.
يمكننا أن نرى أن أجزاء Async تبدأ في التنزيل (المميز باللون الأزرق) مباشرة بعد أن تنتهي ملف HTML من التنزيل والتحليل والتنفيذ ، مما يوفر الكثير من الوقت.
على الرغم من أن هذا التغيير يحدث فرقًا كبيرًا في الشبكات السريعة ، إلا أنه أكثر أهمية بالنسبة للشبكات الأبطأ ، حيث يكون التأخير أكبر و RTT أكثر تأثيرًا.
ومع ذلك ، فإن هذا الحل له قضيتان رئيسيتان:
للتغلب على هذه المشكلات ، لم يعد بإمكاننا الالتزام بملف HTML ثابت ، وبالتالي سنقوم بقايا طاقة الخادم. أو ، بتعبير أدق ، قوة عامل الخادم CloudFlare.
يجب على هذا العامل اعتراض كل طلب مستند HTML وتكييف استجابة تناسبها تمامًا.
يجب وصف التدفق بأكمله على النحو التالي:
X-Cached في الطلب. في حالة وجود مثل هذا الرأس ، فسوف يتكرر على قيمه ويضمن فقط الأصول* ذات الصلة التي تغيب عنها في الاستجابة. إذا لم يكن هذا الرأس موجودًا ، فسيؤدي ذلك إلى تحديد جميع الأصول* ذات الصلة في الاستجابة.X-Cached يحدد جميع أصوله المخزنة مؤقتًا.* الأصول الأولية والصفحة الخاصة.
هذا يضمن أن المتصفح يتلقى الأصول التي يحتاجها بالضبط (لا بعد ، لا أقل) لعرض الصفحة الحالية في مستديرة واحدة !
البرامج النصية/الحقن-الأجراس-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 InjectAssetsPluginعام/_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-are-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 }
)
}العام/الخدمة-بير
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 مع مستند HTML (1.8 كيلو بايت) الحد الأدنى (1.8 كيلو بايت) ويتم تقديم جميع الأصول على الفور من ذاكرة التخزين المؤقت.
يقودنا هذا التحسين إلى قطعة أخرى - تقسيم قطع إلى قطع أصغر.
كقاعدة عامة ، يمكن أن يؤدي تقسيم الحزمة إلى الكثير من الأجزاء إلى الأداء. وذلك لأن الصفحة لن يتم تقديمها حتى يتم تنزيل جميع ملفاتها ، وكلما زاد عدد الأجزاء ، زادت احتمال تأخير أحدها (لأن سرعة الأجهزة وسرعة الشبكة غير خطية).
ولكن في حالتنا ، لا علاقة لها ، نظرًا لأننا ندخل جميع القطع ذات الصلة وبالتالي يتم جلبها مرة واحدة.
rspack.config.js
optimization: {
splitChunks: {
chunks: 'initial',
cacheGroups: {
vendors: {
+ minSize: 10000,
}
}
}
},سيؤدي هذا الانقسام الشديد إلى استمرار ذاكرة التخزين المؤقت أفضل ، وبالتالي ، إلى أوقات تحميل أسرع مع ذاكرة التخزين المؤقت الجزئية.
عندما يتم جلب الأصل الثابت من CDN ، فإنه يتضمن رأس ETag ، وهو تجزئة محتوى للمورد. بناءً على الطلبات اللاحقة ، يتحقق المتصفح إذا كان يحتوي على ETAG مخزنة. إذا كان الأمر كذلك ، فإنه يرسل ETAG في رأس If-None-Match . يقوم CDN بعد ذلك بمقارنة ETAG المستقبلة مع الموجة الحالية: إذا كانت متطابقة ، فإنها تُرجع حالة 304 Not Modified ، مما يشير إلى أن المتصفح يمكنه استخدام الأصل المخزنة مؤقتًا ؛ إذا لم يكن الأمر كذلك ، فإنه يعيد الأصل الجديد بحالة 200 .
في تطبيق CSR التقليدي ، يؤدي إعادة تحميل الصفحة إلى الحصول على HTML للحصول على 304 Not Modified ، مع الأصول الأخرى التي يتم تقديمها من ذاكرة التخزين المؤقت. يحتوي كل مسار على ETAG فريدة من نوعها ، SO /lorem-ipsum و /pokemon لها إدخالات ذاكرة التخزين المؤقت المختلفة ، حتى لو كانت etags متطابقة.
في SPA CSR ، نظرًا لوجود ملف HTML واحد فقط ، يتم استخدام نفس ETAG لكل طلب صفحة. ومع ذلك ، نظرًا لأن ETAG يتم تخزينه لكل مسار ، فلن يرسل المتصفح رأسًا If-None-Match للصفحات غير المرغوب فيها ، مما يؤدي إلى 200 حالة وتنزيل من HTML ، على الرغم من أنه نفس الملف.
ومع ذلك ، يمكننا بسهولة إنشاء تنفيذ هذا السلوك الخاص (المحسّن) من خلال التعاون بين العمال:
البرامج النصية/الحقن-الأجراس-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()
})
}
}عام/_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 })
+ }
.
.
.
}
}العام/الخدمة-بير
.
.
.
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
}
} لاحظ أنه يتم تضمين X-ETag المخصص في المواقف التي لا يقوم فيها CDN بإرسال ETag تلقائيًا.
الآن سوف يستجيب عامل الخادم الخاص بنا دائمًا برمز الحالة 304 Not Modified كلما لم تكن هناك تغييرات ، حتى بالنسبة للصفحات غير المرغوب فيها.
عند استخدام عامل الخدمة ، يقوم المتصفح بتأخير إرسال طلب مستند HTML الأولي حتى يتم تحميل عامل الخدمة ، مما قد يتسبب في تأخير صفحة خفيفة إلى معتدلة اعتمادًا على الأجهزة.
يسمى الحل الأصلي لهذه المشكلة التحميل المسبق للملاحة . سنقوم بتنفيذ هذا لضمان إرسال طلب المستند على الفور ، دون انتظار تحميل عامل الخدمة:
src/utils/service-are-registration.ts
const register = ( ) => {
.
.
.
navigator . serviceWorker ?. addEventListener ( 'message' , async event => {
const { navigationPreloadHeader } = event . data
const registration = await navigator . serviceWorker . ready
registration . navigationPreload . setHeaderValue ( navigationPreloadHeader )
} )
}العام/الخدمة-بير
.
.
.
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 ) )
} )مع هذا التنفيذ ، سيتم إرسال طلب المستند على الفور ، بغض النظر عن عامل الخدمة.
ملاحظة: يتطلب React (v18) أو svelte أو solid.js
عندما نقوم بتقسيم صفحة من التطبيق الرئيسي ، نفصل مرحلة العرض الخاصة به ، مما يعني أن التطبيق سيتم عرضه قبل تقديم الصفحة.
لذلك عندما ننتقل من صفحة غير متزامنة إلى أخرى ، نرى مساحة فارغة تظل حتى يتم تقديم الصفحة:


يحدث هذا بسبب النهج الشائع المتمثل في لف فقط الطرق مع التشويق:
const App = ( ) => {
return (
< >
< Navigation />
< Suspense >
< Routes > { routes } </ Routes >
</ Suspense >
</ >
)
} قدمنا React 18 إلى خطاف useTransition ، والذي يسمح لنا بتأخير تقديم حتى يتم استيفاء بعض المعايير.
سوف نستخدم هذا الخطاف لتأخير التنقل في الصفحة حتى يصبح جاهزًا:
USETRANSITIONNAVIGENT.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 useTransitionNavigatesevigationlink.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 بأنها لم تنفصل أبدًا عن التطبيق الرئيسي.
يمكننا التحميل مسبقًا لبيانات الصفحات الأخرى عند التحول عبر الروابط (سطح المكتب) أو عندما تدخل الروابط في عرض العرض (الهاتف المحمول):
sevigationlink.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 على العديد من الملوثات العضوية الثابتة (نقاط التواجد) ، وتسمى أيضًا "شبكات الحافة". يتم توزيع هذه الملوثات العضوية الثابتة في جميع أنحاء العالم ، وبالتالي فهي قادرة على تقديم الملفات إلى كل منطقة أسرع بكثير من الخادم البعيد.
أسرع CDN حتى الآن هو CloudFlare ، الذي يحتوي على أكثر من 250 منبثقة (والعد):

https://speed.cloudflare.com
https://blog.cloudflare.com/benchmarking-edge-network-performance
يمكننا بسهولة نشر تطبيقنا باستخدام صفحات CloudFlare:
https://pages.cloudflare.com
للاختتام هذا القسم ، سنقوم بإجراء معيار لتطبيقنا مقارنة بموقع توثيق Next.js ، وهو SSG بالكامل .
سنقارن صفحة إمكانية الوصول إلى الحد الأدنى مع صفحة Lorem Ipsum الخاصة بنا. تتضمن كلتا الصفحتين ~ 246 كيلو بايت من JS في قطعهما الرصاصية (التحميل المسبق والأحواض المسبقة التي تأتي بعدها غير ذات صلة).
يمكنك النقر فوق كل رابط لأداء معيار مباشر.
إمكانية الوصول | Next.js
لوريم Ipsum | تقديم جانب العميل
لقد قمت بإجراء معيار PageSpeed Insights من Google (محاكاة شبكة 4G البطيئة) حوالي 20 مرة لكل صفحة واخترت أعلى الدرجات.
هذه هي النتائج:


كما اتضح ، فإن الأداء ليس افتراضيًا في Next.js.
لاحظ أن هذا المعيار يختبر فقط الحمل الأول للصفحة ، حتى دون النظر في كيفية أداء التطبيق عندما يتم تخزينه مؤقتًا بالكامل (حيث تضيء CSR بالفعل).
إنه تصور شائع أن Google تواجه مشكلة في فهرسة تطبيقات CSR (JS) بشكل صحيح.
قد يكون هذا هو الحال في عام 2017 ، ولكن اعتبارًا من اليوم: فهارس Google CSR تطبيقات في الغالب لا تشوبه شائبة.
سيكون للصفحات المفهرسة عنوانًا ووصفًا ومحتوى وجميع السمات الأخرى المتعلقة بكبار المسئولين الاقتصاديين ، طالما أننا نتذكر وضعها ديناميكيًا (إما مثل هذا يدويًا أو باستخدام حزمة مثل React-Helmet ).
https://www.google.com/search؟q=site:https://client-side-rendering.pages.dev


قدرة Googlebot يمكن عرض JS بسهولة من خلال إجراء اختبار عنوان URL المباشر لتطبيقنا في وحدة التحكم في Google :

يستخدم GoogleBot أحدث إصدار من Chromium to Crawl Apps ، وبالتالي فإن الشيء الوحيد الذي يجب أن نفعله هو التأكد من تحميل تطبيقنا بسرعة وأنه سريع في جلب البيانات.
حتى عندما تستغرق البيانات وقتًا طويلاً في الجلب ، فإن GoogleBot ، في معظم الحالات ، تنتظرها قبل أخذ لقطة من الصفحة:
https://support.google.com/webmasters/thread/202552760/for-how-donong-does-googlebot-wait-for-the-last-http-request
https://support.google.com/webmasters/thread/165370285؟hl=en&msgid=165510733
يمكن العثور على شرح مفصل لعملية الزحف JS من Googlebot هنا:
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 كامل (مع البيانات) لكل صفحة.
لدينا خياران عندما يتعلق الأمر بالتقدم:
إن prerendering بدون خادم هو النهج الموصى به لأنه يمكن أن يكون رخيصًا للغاية ، خاصة على GCP .
ثم نقوم بإعادة توجيه زحفات الويب (التي تم تحديدها بواسطة سلسلة رأس User-Agent ) إلى Prerenderer ، باستخدام عامل CloudFlare (على سبيل المثال):
عام/_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 (Web Crawlers): https://docs.prerender.io/docs/how-to-add-additional-bots#cloudflare. تذكر أن تستبعد googlebot من القائمة.
يتم تشجيع Prerendering ، الذي يطلق عليه أيضًا Dynamic Rendering ، من قبل Microsoft ويستخدمه بشدة العديد من المواقع الشهيرة بما في ذلك Twitter.
النتائج كما هو متوقع:
https://www.bing.com/search؟q=Site٪3AHTTPS٪3A٪2F٪2FClient-side-rendering.pages.dev

لاحظ أنه عند استخدام CSS-in-JS ، يمكننا تعطيل التحسين السريع أثناء Prerendering إذا كنا نريد حذف أنماطنا إلى DOM.
عندما نشارك رابط تطبيق CSR في وسائل التواصل الاجتماعي ، يمكننا أن نرى أنه بغض النظر عن الصفحة التي نرتبط بها ، ستبقى المعاينة كما هي.
يحدث هذا لأن معظم تطبيقات CSR تحتوي على ملف HTML واحد فقط ، ولا تقدم زحفات وسائل التواصل الاجتماعي JS.
هذا هو المكان الذي يأتي فيه Prerendering إلى مساعدتنا مرة أخرى ، وسوف يولد معاينة المشاركة المناسبة لكل صفحة:
Whatsapp:

فيسبوك :

من أجل جعل جميع صفحات التطبيقات الخاصة بنا يمكن اكتشافها على محركات البحث ، يوصى بإنشاء ملف sitemap.xml الذي يحدد جميع طرق موقعنا.
نظرًا لأن لدينا بالفعل ملف Pages.js مركزي ، يمكننا بسهولة إنشاء خريطة sitement أثناء وقت البناء:
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 )هذا سوف ينبعث من خريطة sitemap التالية:
<? 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 >يمكننا إرسال خريطة Sitemap الخاصة بنا يدويًا إلى Google Search Console وأدوات مشرف المواقع Bing .
كما ذكر أعلاه ، يمكن العثور على مقارنة متعمقة لجميع طرق التقديم هنا: https://client-side-rendering.pages.dev/comparison
لقد رأينا مزايا الملفات الثابتة: فهي قابلة للتخزين مؤقت ويمكن تقديمها من CDN قريب دون الحاجة إلى خادم.
قد يقودنا هذا إلى الاعتقاد بأن SSG تجمع بين فوائد كل من CSR و SSR: فهو يجعل تطبيقنا يتم تحميله بسرعة كبيرة ( FCP ) وبشكل مستقل عن أوقات استجابة خادم API لدينا.
ومع ذلك ، في الواقع ، لدى SSG قيودًا كبيرة:
نظرًا لأن JS غير نشط خلال اللحظات الأولية ، فإن كل ما يعتمد على JS ليتم تقديمه ببساطة لن يكون مرئيًا أو سيتم عرضه بشكل غير صحيح (مثل المكونات التي تعتمد على window.matchMedia وظيفة matchmedia لتقديمها).
يمكن رؤية مثال كلاسيكي لهذه المشكلة على الموقع التالي:
https://death-to-ie11.com
لاحظ كيف لم يكن الموقت مرئيًا على الفور؟ ذلك لأنه تم إنشاؤه بواسطة JS ، والذي يستغرق وقتًا للتنزيل والتنفيذ.
نرى أيضًا مشكلة مماثلة عند تحديث صفحة "أدلة" Vercel مع بعض المرشحات المطبقة:
https://vercel.com/guides؟topics=analytics
يحدث هذا بسبب وجود مجموعات مرشح محتملة 65536 (2^16) ، وتخزين كل مجموعة كملف HTML منفصل يتطلب الكثير من تخزين الخادم.
لذلك ، يقومون بإنشاء ملف guides.html واحد. html يحتوي على جميع البيانات ، ولكن هذا الملف الثابت لا يعرف أي مرشحات يتم تطبيقها حتى يتم تحميل JS ، مما تسبب في تحول تخطيط.
من المهم أن نلاحظ أنه حتى مع التجديد الثابت المتزايد ، لا يزال يتعين على المستخدمين انتظار استجابة الخادم عند زيارة الصفحات التي لم يتم تخزينها بعد (كما هو الحال في SSR).
مثال آخر على هذه المشكلة هو js الرسوم المتحركة - قد تظهر ثابتة في البداية وبدء فقط للبدء بمجرد تحميل JS.
هناك العديد من الحالات التي تؤذي فيها هذه الوظيفة المتأخرة تجربة المستخدم ، مثل عندما تُظهر مواقع الويب فقط شريط التنقل بعد تحميل JS (نظرًا لأنها تعتمد على التخزين المحلي للتحقق مما إذا كان إدخال معلومات المستخدم موجودًا).
هناك مشكلة مهمة أخرى ، خاصة بالنسبة لمواقع التجارة الإلكترونية ، وهي أن صفحات SSG قد تعرض بيانات قديمة (مثل سعر المنتج أو توفره).
هذا هو بالضبط سبب عدم استخدام موقع التجارة الإلكترونية الرئيسية SSG.
إنها حقيقة أنه في ظل اتصال الإنترنت السريع ، يؤدي كل من CSR و SSR بشكل رائع (طالما تم تحسينهما) ، وكلما ارتفعت سرعة الاتصال - كلما اقتربوا من حيث أوقات التحميل.
ومع ذلك ، عند التعامل مع الاتصالات البطيئة (مثل شبكات الهاتف المحمول) ، يبدو أن SSR لديها ميزة على المسؤولية الاجتماعية للشركات فيما يتعلق بأوقات التحميل.
نظرًا لأن تطبيقات SSR يتم تقديمها على الخادم ، يتلقى المتصفح ملف HTML المصمم بالكامل ، وبالتالي يمكنه عرض الصفحة للمستخدم دون انتظار تنزيل JS. عندما يتم تنزيل JS في نهاية المطاف وتحليلها ، يكون الإطار قادرًا على "ترطيب" DOM مع الوظيفة (دون الحاجة إلى إعادة بنائها).
على الرغم من أنها تبدو ميزة كبيرة ، فإن هذا السلوك يقدم تأثيرًا جانبيًا غير مرغوب فيه ، وخاصة على اتصالات أبطأ:
حتى يتم تحميل JS ، يمكن للمستخدمين النقر فوق أي مكان يرغبون فيه ، لكن التطبيق لن يتفاعل مع أي من الأحداث المستندة إلى JS.
إنها تجربة مستخدم سيئة عندما لا تستجيب الأزرار لتفاعلات المستخدم ، لكنها تصبح مشكلة أكبر بكثير عندما لا يتم منع الأحداث الافتراضية.
هذه مقارنة بين موقع Next.js على الويب وتطبيق تقديم جانب العميل لدينا على اتصال سريع 3G:


ماذا حدث هنا؟
نظرًا لأن JS لم يتم تحميله بعد ، فإن موقع Next.js لم يتمكن من منع السلوك الافتراضي لعناصر علامة المرساة ( <a> ) للانتقال إلى صفحة أخرى ، مما يؤدي إلى كل نقرة عليها مما يؤدي إلى إعادة تحميل صفحة كاملة.
وكلما كانت الاتصال أبطأ - كلما أصبحت هذه المشكلة أكثر حدة.
بمعنى آخر ، حيث كان ينبغي أن يكون لدى SSR ميزة أداء على المسؤولية الاجتماعية للشركات ، نرى سلوكًا "خطيرًا للغاية" قد يؤدي إلى تدهور تجربة المستخدم بشكل كبير.
من المستحيل أن تحدث هذه المشكلة في تطبيقات المسؤولية الاجتماعية للشركات ، منذ اللحظة التي يتم فيها عرضها - تم بالفعل تحميل JS بالكامل.
لقد رأينا أن أداء تقديم من جانب العميل على قدم المساواة وأحيانًا أفضل من SSR من حيث أوقات التحميل الأولية (وتجاوزه بكثير في أوقات التنقل).
لقد رأينا أيضًا أن GoogleBot يمكنه فهرسة تطبيقات من جانب العميل بشكل مثالي ، ويمكننا بسهولة إعداد خادم Prerender لخدمة جميع الروبوتات والزحفات الأخرى.
والأهم من ذلك ، لقد حققنا كل هذا فقط عن طريق إضافة بعض الملفات واستخدام خدمة Prerender ، لذلك يجب أن يكون كل تطبيق 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.