
فهرست
مقدمه
صفحه ۱دانلود و نمایش دمو
نصب و اجرا
ویرایش و توسعه
جزییات نصب و اجرا ـ ۱
صفحه ۲عملیات Build
Start پروژه
جزییات نصب و اجرا ـ ۲
صفحه ۳استفاده از Node Stream
آمادهسازی Router
ارتباط با API
صفحه ۴تابع FetchData و پارامترهای آن
تابع API و پارامترهای آن
ارتباط کامپوننتهای React با دادههای API
صفحه ۵پیادهسازی Loading
صفحه ۶پیادهسازی تگهای سرصفحه
صفحه ۷پیادهسازی نقشهی سایت XML
صفحه ۸
در صفحههای قبلی، نحوهی دریافت، نصب و اجرا و توسعهی پروژه و همچنین بخش نخست از جزییات نصب و اجرای پروژه، توضیح داده شد. در این صفحه به سراغ بخش دوم از جزییات نصب و اجرا میرویم.
جزییات نصب و اجرا ـ ۲
استفاده از Node Stream
در این پروژه برای ترکیب کردن اجزای مختلف مورد نیاز عملیاتِ رندرینگ و تهیهی Source نهایی، از Node Stream استفاده شده است.
استریمها در Node.js به سختی در درک شدن، شهرت دارند. به قول دومینیک تار (برنامهنویس شاخصی که در این لینک از آن نقل قول شده): «استریمها بهترین و بدفهمیدهشدهترین ایدهی Node.js هستند!» و دن آبراموف، خالق Redux و یکی از اعضای اصلی تیم React.js، در توییتی گفته که از Node Stream می ترسد!
Stream یک Abstract Interface برای کار با جریانِ داده در Node.js است. Streams یا جریانها میتوانند از نوع قابل خواندن، قابل نوشتن و یا هر دو باشند.
Streamها در Node.js به شکل ماژول node:stream
که یک API برای پیادهسازی Stream Interface
، ارائه میدهد، فراخوانده میشوند.
Node Stream امکان «ترکیب پذیری» دادهها را میدهد؛ به این شکل که میتواند جریانهایی را که حاوی اجزای مختلف دادهها هستند را دریافت، ترکیب و منتشر کند. Node Stream قطعات طولانی، پیچیده و مختلف کد را، جزء به جزء میخواند و با pipe کردن یا همرسانی دادهها، میتواند آنها را منتشر کند.
جریانها میتوانند حتی بعد از خواندن نخستین تکه یا بخش از دادهها، منتشر شوند و پس از آن، به تکمیل انتشار ادامهی بخشها بپردازند. مثل روند نمایش ویدیوهای یوتیوب که بخش به بخش دانلود میشوند و به محض دانلود نخستین بخش، نمایش داده میشوند.
استریمها چهار نوع هستند که در این پروژه از نوع Transform که شامل جریانهایی است که هم توانایی خواندن و هم نوشتن دارند، استفاده شده است. Transform میتواند دادهها را در هنگام خواندن یا نوشتن، تغییر دهد.
در این پروژه، Transform کامپوننتهای رندر شدهی React را میخواند و بهشکل buffer به همراه Encoding آن، تکه تکه به response.write
نودجیاس تحویل میدهد. در متد Transform، یک callback وجود دارد تا این متد، عملیات write
را تا زمان انتشار آخرین تکه، همچنان اجرا کند. در نهایت بعد از انتشار آخرین تکه، متد از callback خارج میشود.
در ابتدا، برای اینکه بتوانیم کامپوننتهای React را در Node Stream دریافت کنیم، renderToPipeableStream
را در تابع render (فایل entry-server.jsx
) به عنوان خروجی فراخوانی میکنیم.
// src/entry-server.jsx
import { renderToPipeableStream } from 'react-dom/server'
import { StaticRouter } from "react-router-dom/server";
import App from './App'
export function render(path, dataFromServer, options) {
return renderToPipeableStream(
<StaticRouter>
<App />
</StaticRouter>,
options
)
}
.
.
تابع render، تابع renderToPipeableStream
را بر میگرداند که این تابع، دو پارامتر دارد، پارامتر نخست، کامپوننت <App />
را به عنوان آرگومان میگیرد. و پارامتر دوم یک آبجکت را به عنوان آرگومان دریافت میکند که حاوی گزینههایی مثل متدهای onAllReady
ـ onShellReady
ـ onShellError
و onError
خواهد بود. renderToPipeableStream
یک آبجکت که حاوی دو متد است را برمیگرداند: متد pipe
و متد abort
.
گزینههای renderToPipeableStream
onError
خطاهای سرور را برمیگرداند و به طور پیشفرض console.error را فراخوانی میکند.
onShellError
فقط وقتی در رندرینگ اولیه، خطایی وجود داشته باشد، قبل از onShellReady
و onAllReady
و قبل از انتشار هرگونه stream، فراخوانی خواهد شد. میتوانیم در آن کد status صفحه را تعیین کنیم و در صفحه محتوایی را به عنوان خروجی خطا، قرار داده و نمایش دهیم.
onShellReady
در زمان عملیات رندرینگ فعال میشود و میتوانیم به واسطهی آن:
۱. کد status صفحه را تعیین کنیم ۲. در صفحه محتوایی را قرار دهیم. ۳. متد pipe که Node Stream را فعال میکند و آرگومانی حاوی کلاس Transform را دریافت میکند، تا خروجی HTML برگرداند را، فراخوانی کنیم.
pipe در واقع دادههای مورد نظر را برای خوانده شدن (reading)، به عملیات انتشار (writing) متصل یا لولهکشی! میکند.
بر خلاف onAllReady
در صورتی که Node Stream را در onShellReady
فعال کنیم، به محض اینکه تنها بخشی از محتوا در دسترس قرار بگیرد، نمایش داده میشود و بارگیری دادهها برای اعمال درخروجی، به صورت تدریجی خواهد بود.
// server.mjs
import fs from 'node:fs/promises';
import { Transform } from 'node:stream';
.
.
const template = await fs.readFile(`./dist/client/index.html`, 'utf-8');
.
.
const render = (await import(`./dist/server/entry-server.js`)).render;
const { pipe, abort } = render(path, dataFromServer, {
.
.
onShellReady() {
res.status(didError ? 500 : 200);
res.set({ 'Content-Type': 'text/html' });
const transformStream = new Transform({
transform(chunk, encoding, callback) {
res.write(chunk, encoding);
callback();
}
});
const [htmlStart, htmlEnd] = template.split(`<!--app-html-->`);
res.write(htmlStart);
transformStream.on('finish', () => {
res.end(htmlEnd);
});
pipe(transformStream);
}
.
.
});
.
.
onShellReady در کد بالا:
۱. ابتدا status و Content-Type صفحه را تنظیم کردیم. (خط ۱۴ و ۱۵)
۲. یک شئ از کلاس Transform (از انواع Node Stream) را نمونهسازی کردیم و متد transform (از متدهای داخلی Node Stream وابسته به رابطهای داخلی ReadableOptions و WritableOptions) را به عنوان پارامتر اول constructor کلاسِ Transform، مقداردهی کردیم و به ثابت transformStream نسبت دادیم. ثابت transformStream حالا یک شئ از کلاسِ Transform از Node Stream است. (خط ۱۷ تا ۲۲)
۳. کدهای HTML فایل index.html را به دو بخش قبل و بعد از خط کامنت<!--app-html-->
تقسیم کردیم و هر بخش را به یکی از دو ثابتhtmlstart
وhtmlend
نسبت دادیم. (خط۲۴)
۴. بخش نخست یاhtmlStart
را که «از ابتدای HTML ما شروع میشود و شامل محتویات Head تا خود تگ<div id="root">
، است» را بیواسطه در Source صفحه قرار دادیم. (خط ۲۶)
۵. در خط ۳۲ کد بالا، شئ transformStream را به عنوان آرگومان به متد pipe ارسال کردیم و متد pipe را فراخوانی کردیم، حالا بعد از فراخوانی متد pipe، متد transform:
ـــ در پارامتر chunk: حاوی کامپوننتهای رندر شدهی React و تکه تکه شده با تایپBuffer
ـــ در پارامتر encoding: حاوی Encoding آنها
ـــ در پارامتر callback حاوی: متدcallback()
خواهد بود.
متد transform که حالا مقدار chunk و encoding و callback را در پارامترهای خود دریافت کرده است، با اجرا کردن متد res.write، تکهی نخست از کامپوننتهای رندرشدهی React را که تکه تکه شده بودند، در سورس HTML ما قرار میدهد. (خط ۱۶)
حالا برای انتشار ادامهی تکهها، متدcallback()
تا زمان انتشار آخرین تکه، اجرا خواهد شد و متدres.write
را تا زمان انتشار آخرین تکه اجرا خواهد کرد.(خط ۱۷)
یادآوری: متد pipe یکی از دو خروجی renderToPipeableStream (از Server API های React) است. این متد، دادههای مورد نظر ــ که همان کامپوننتهای رندرشدهی React است ــ را با هدف انتشار به متد پردازشکننده و منتشرکنندهی جریان (جریان Transform)، متصل یا در معنی واژه به واژه، لولهکشی میکند.۶. اما هنوز محتویاتِ بخش دوم یا
htmlEnd
را درون Source صفحه قرار ندادیم!
transformStream در هنگام پایان کارش (On Finish) یعنی بعد از قرار دادن کامپوننتهای React در Source صفحه، محتویاتhtmlEnd
را به HTML ما اضافه میکند. این محتویات فقط شامل موارد زیر خواهد بود.
</div>
<script>window.__data__={---api---data---}</script>
</body>
</html>
onAllReady
(برای رباتهای خزندهی موتورهای جستوجو)
onAllReady
برخلاف onShellReady
در زمان پایانِ رندر فعال میشود که میتوانیم از آن برای تولید استاتیک صفحه و ارسال خروجی مناسب برای رباتهای خزندهی موتورهای جستوجو استفاده کنیم. در صورتی که Node Stream را در onAllReady
به کار ببریم، برخلاف onShellReady
، بارگیری دادهها تدریجی نخواهد بود و نتیجهی Stream یک HTML پس از پایان بارگیری تمام اجزا و دادهها، خواهد بود.
در صورت بهکارگیری onAllReady و استفاده از <Suspense> و lazy در React، فقط نتیجهی درون کامپوننت lazy load شده، به خروجی ارسال خواهد شد. بنابراین برای اینکه خزندههای جستوجو، محتوای کامپوننت lazy load
شدهی درون <Suspense>
را دریافت کنند و به جای آن به اشتباه Loading را دریافت نکنند، از onAllReady
استفاده میکنیم.
اگر از onShellReady
برای خزندههای جستوجو استفاده کنیم، Loading به خزنده ارسال خواهد شد و محتوای درون کامپوننت، دریافت نمیشود. (منظور از Loading همان محتوای پراپز fallback
کامپوننت Suspense است)
اما برای نمایش به کاربر باید از همان onShellReady
استفاده شود تا کاربر <Suspense>
را با لودینگِ قبل از نمایشِ آن، ببیند.
بنابراین احتیاج به استفاده از شرطی داریم که بسته به هویت کاربر، عملیات رندرینگ، فقط در یکی از دو گزینهی onAllReady
یا onShellReady
اجرا شود. اگر ربات بود در onAllReady
و اگر کاربر معمولی بود در onShellReady
.
پس ابتدا هویت کاربر را خارج از تابع رندر، دریافت و مشخص میکنیم و سپس درون تابع رندر و درون دو گزینهی ذکر شده، شرط را قرار میدهیم. حالا درون آن شرطها است که میبایست کدهای مربوطه را قرار داد.
// server.mjs
.
.
const isCrawler = botDetector(req.get("user-agent"));
const { pipe, abort } = render(path, dataFromServer, {
.
.
onShellReady() {
if (!isCrawler) {
...
pipe(...)
}
}
onAllReady() {
if (isCrawler) {
...
pipe(...)
}
},
.
.
}
.
.
در کد بالا ابتدا یک ثابت به نام
isCrawler
تعریف کردیم و تابع botDetector را که تشخیص دهندهی هویت کاربر است را به آن نسبت دادیم. تابعbotDetector
در مسیرcore/botDetector.mjs
قرار دارد و از پکیج isbot ، استفاده میکند. این تابعUser-Agent
دریافتشده را به عنوان آرگومان میگیرد و اگرUser-Agent
ربات بود، true و اگر نبود false را برمیگرداند.(boolean)
سپس در تابع رندر و درون گزینههای onAllReady و onShellReady، از مقدار ثابتisCrawler
به عنوان یک شرط استفاده میکنیم و عملیات رندر را، درون هر یک از شرطها جایگذاری میکنیم.
نکته: onAllReady پس از بارگیری تمام دادهها، خروجی را ارسال میکند و onShellReady جریانی از دادهها با امکان بارگیریِ تدریجی را به کاربر میفرستد.
پس میتوان برای onAllReady که خزنده به وسیلهی آن، خروجی نهایی HTML را دریافت می کند، و قرار نیست انسان آن را ببینید، محتوای ارسالی را سبکتر کرد.
اما در این پروژه، برای هر دو گزینه، کدی همسان در نظر گرفته شده است. برای کوتاه کردن کد و به این دلیل که کدهای همسان را در دوجای مختلف قرار نداده باشیم؛ یک تابع به نام Transformer
تهیه شده که در مسیر core/Transformer.mjs
قرار دارد. این تابع حاوی بخشی از همان کدهای باکس بالاتر (↥) است و آرگومانهای موردنیاز را از متغیرهای تعریف شده، میگیرد و خروجی آن، transformStream
از انواع Node Stream است که باید درون متد pipe قرار بگیرد.
// server.mjs
.
.
onShellReady() {
if (!isCrawler) {
const transformStream = Transformer(res, didError, template, apiDataInScript);
pipe(transformStream);
}
},
onAllReady() {
if (isCrawler) {
const transformStream = Transformer(res, didError, template, apiDataInScript);
pipe(transformStream);
}
},
.
.
خروجی renderToPipeableStream
متد pipe
درون تابع render و در گرینههای onShellReady و onAllReady فراخوانی میشود و وظیفه دارد جریان دادهها را با Node Stream همرسانی کند تا بهوسیلهی Transform، نتیجهی نهایی را به شکل HTML در Source صفحهها، بگنجاند.
به عبارتی دیگر، متد
pipe
، دادههای مورد نظر ــ که همان کامپوننتهای رندرشدهی React است ــ را با هدف انتشار به متد پردازشکننده و منتشرکنندهی جریان (جریان Transform)، متصل یا در معنی واژه به واژه، لولهکشی میکند.
متد abort
خارج از تابع render فراخوانی خواهد شد و وظیفه دارد تا هر زمان که فراخوانی شد عملیات رندرِ سرور را متوقف کند و ادامهی برنامه را روی کلاینت رندر کند. این متد را درون یک setTimeout و خارج از تابع render فراخوانی میکنیم تا اگر تابع render ما، در طول زمانی که مشخص کردهایم، عملیات رندر را به پایان نرساند، عملیات رندرِ سرور را متوقف کنیم و برنامه را روی کلاینت رندر کنیم.
// server.mjs
.
.
const ABORT_DELAY = 10000;
.
const { pipe, abort } = render(path, dataFromServer, {...});
setTimeout(() => {
abort();
}, ABORT_DELAY);
.
.
آمادهسازی Router
در راه و روشی که این پروژه در اختیار شما قرار میدهد، ورودی بخشهای Client و Server در React از هم جدا شدهاند:
● فایل src/entry-server.jsx
: وظیفه دارد برنامه را برای عملیات رندر در سمت سرور، آماده کند.
● فایل src/entry-client.jsx
: وظیفه دارد محتوایی را که سمت سرور Render شدهاست را برای نمایش در سمت کلاینت، هیدراته کند و در عنصر DOM قرار دهد.
هیچیک از این دو فایل بههنگام اجرای نسخهی پروداکشن پروژه فراخوانی نخواهند شد؛ بلکه همانطور که در بخش عملیات build توضیح داده شد، توسط اجرای دستور build، قبل از اجرای پروژه، به فایل(های) دیگری تبدیل خواهند شد و آن فایل(ها) برای انجام عملیات رندرینگ و یا اجرا، فراخوانی خواهند شد.
در این پروژه برای پیادهسازی SPA و مدیریت سیستم مسیریابی پویا، از کتابخانه React Router استفاده شده است.
برای آمادهسازی این کتابخانه در فایل entry-server.jsx از کامپوننت StaticRouter که در سمت سرور و برای راهاندازی سناریوهای SSR و SSG کاربرد دارد، و در فایل entry-client.jsx از کامپوننت BrowserRouter که در سمت کلاینت و برای راهاندازی سناریوی CSR کاربرد دارد، استفاده شده است.
برای اینکه پیمایش به درستی انجام پذیرد، نیاز است تا props هایی از این کامپوننتها مقداردهی شوند.
۱. basename
برای هر دو کامپوننت که دایرکتوری base
پروژه را دریافت میکند، که در این پروژه shop
تعریف شده است.
۲. location
برای فقط کامپوننت StaticRouter که «دایرکتوری base
به علاوهی دایرکتوری فرعی URL درخواست شده» را دریافت میکند.
برای اینکه دایرکتوریِ base
پروژه (تعریف شده در فایل env.) را در basename
وارد کنیم، ابتدا باید با کمک گرفتن از افزونهی EnvironmentPlugin در فایل vite.config.js، ثابت(های) تعریف شدهی موردنیاز را در کامپوننتهای React، دسترسپذیر کنیم. (خط۹ کد زیر)
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import EnvironmentPlugin from 'vite-plugin-environment'
export default defineConfig({
plugins: [
react(),
EnvironmentPlugin(['WEBSITE_DIRECTORY_NAME']),
],
.
.
.
حالا ثابت WEBSITE_DIRECTORY_NAME
(تعریف شده در فایل env.) در کامپوننتهای React ما در دسترس است و با کد زیر میتوان آن را دریافت کرد و مثلا در ثابت appDir قرار داد.
const appDir = process.env.WEBSITE_DIRECTORY_NAME;
حالا میتوانیم basename
را در StaticRouter و BrowserRouter مقداردهی کنیم:
// entry-server.jsx
.
.
<StaticRouter basename={appDir}>
.
.
// entry-client.jsx
.
.
<BrowserRouter basename={appDir}>
.
.
اما برای مقداردهی location
که یک props از کامپوننت StaticRouter در فایل entry-server.jsx است باید همان ثابت appDir
را به علاوهی مقدار path
کنیم.
مثلا برای
http://localhost:5173/shop/
مقدار path وجود ندارد و خالی است؛ اما برایhttp://localhost:5173/shop/products/
مقدار path مساوی باproducts
است یا برایhttp://localhost:5173/shop/category/electronics/
مقدار path مساوی باcategory/electronics
است.
همانطور که گفته شدlocation
مساوی باappDir + path
است که در مثالهای بالا:/shop/
برای URL نخست،/shop/products/
برای دومی و/shop/category/electronics/
برای URL سومی است.
برای دریافت مقدار درست path
، باید از طریق فایل server.mjs اقدام کنیم و مقدار path
را با توجه به URL درخواست شدهی کاربر (خط ۶۳)، به پارامتر path
از تابع render اختصاص دهیم(خط ۹۱). حالا همان پارامتر path
را به appDir
اضافه(+) میکنیم و در location
(که یک props از کامپوننت StaticRouter است) قرار میدهیم تا آدرس کاملی برای اجرای روتر در سمت سرور وجود داشته باشد.(خط ۹ کد زیر)
// entry-server.mjs
import { renderToPipeableStream } from 'react-dom/server'
import { StaticRouter } from "react-router-dom/server";
import App from './App'
const appDir = process.env.WEBSITE_DIRECTORY_NAME;
export function render(path, dataFromServer, options) {
return renderToPipeableStream(
<StaticRouter basename={appDir} location={appDir + path}}>
.
.
صفحهی قبل: جزییات نصب و اجرا ـ ۱
صفحهی بعد: نحوهی ارتباط با API
اطلاعات برنامه
نام: express-react-ssr
پلتفرم: Node.js
زبان: JavaScript
لایسنس: MIT
تاریخ انتشار اولیه: ۹ مرداد ۱۴۰۳
تاریخ آخرین بهروزرسانی: ۱۱ شهریور ۱۴۰۳
مخزن: GitHub