divar-starter-kit: خشت اول در وبِ دیوار چگونه گذاشته می شود؟

یک مهندس با تجربه مثل یک معمار خوب به کمک یک نگاه همه‌جانبه، هوشمندی و با خلاقیت، ضروریات را برای ایجاد یک ساختار مناسب برای پروژه فراهم می‌کند.

معمار خوب بودن در دیوار یعنی خشت اول به قاعده در جای مناسب قرار گیرد. به عبارتی از نگاه ما:

خشت اول گر نهد دیوار کج، تا ثریا می‌رود معمار کج!

خشت اول در  وبِ دیوار چگونه گذاشته می شود؟
خشت اول در وبِ دیوار چگونه گذاشته می شود؟


ما در وبِ دیوار تصمیم به انتشار و open source کردن boilerplate پروژه‌های front-end خودمان تحت عنوان  divar-starter-kit گرفتیم. divar-starter-kit مجموعه‌ای از ابزارها و قراردادهایی است که معمولاً در دیوار برای توسعه پروژه‌های front-end استفاده می‌کنیم. این مجموعه ابزار با هدف کوتاه شدن زمان شروع پروژه طراحی شده‌ است.

در این مقاله ضمن معرفی  divar-starter-kit، درباره‌ی روند توسعه و چالش‌های فنی که در مسیر پیاده‌سازی SSR در دیوار داشته‌ایم آشنا می‌شوید.

تجربه من به عنوان عضوی از دیوار

من حدود یکسال است که به عنوان توسعه‌دهنده front-end در تیم تراکنش خودرو و chapter وبِ دیوار مشغول به فعالیت هستم. قبل از پیوستن به دیوار، من تجربه کار با یک شرکت کوچک، freelancer بودن و همین‌طور راه‌‌اندازی start-up خودم را داشتم. ورود من به دیوار از راه دوره کارآموزی تابستان بود. در این مدت تجربه‌های فردی و تیمی زیادی در کنار تجربه‌های فنی کسب کردم و حضور در دیوار باعث شده به اهمیت اعتماد متقابل بین اعضا تیم و سازمان پی ببرم و این موضوع نگاه من را به مسائل شخصی نیز تغییر داده است.

در دیوار تقسیم بندی تخصص‌ها بر اساس chapter انجام می‌شود. یک chapter متشکل از تمامی افراد یک تخصص بوده که در تیم‌های مختلف پخش شده‌اند و انتقال دانش و تصمیم‌گیری‌های فنی در آن‌جا رخ می‌دهد. در حال حاضر ۱۰ نفر در چپتر وبِ دیوار مشغول به کار هستند.

پروژه divar-starter-kit

پروژه divar-starter-kit، یک پروژه open source با استفاده از react و razzle است که با هدف سرعت بخشیدن به مراحل شروع و آماده‌سازی پروژه تا پیاده‌سازی، توسعه‌ یافته است. مهم‌ترین هدف این پروژه، شیوه SSR آن است که در ادامه به فرآیند پیاده‌سازی و روش استفاده از آن پرداخته‌ایم. جزئیات بیشتر این kit در مخزن github آن موجود است.

وبِ دیوار از گذشته تا کنون

در دیوار، به عنوان بزرگترین بستر خرید و فروش کالای بی‌واسطه با ۳۵میلیون کاربر، روزانه میزبان ۵۰۰هزار آگهی هستیم. با توجه به اینکه میزان زیادی از کاربران از وبِ دیوار استفاده می‌کنند، ایجاد تجربه مناسب برای کاربران همیشه از اهداف ما در وبِ دیوار بوده است.

چرا Server Side Rendering مهم است؟

وبِ دیوار به صورت Single Page Application با استفاده از React.js توسعه‌یافته‌ است. استفاده از SPAها تجربه کاربر را تا حد قابل توجهی بهبود می‌دهد اما هر بهبودی هزینه‌ دارد. در واقع SPAها شامل یک تعداد فایل JavaScript و CSS هستند که بعد از دانلود و اجرا در مرورگر (Client Side Rendering)، اجزای صفحه در آن mount شده و درخواست‌های AJAX زده می‌شوند. در حالی که در روش‌های قدیمی‌تر، صفحه توسط کدهای سمت server ساخته و با محتوای اولیه ارسال می‌شد. این رفتار SPAها -که بعد از اجرای فایل‌ها توسط مرورگر قابل مشاهده است- می‌تواند در تجربه کاربر و SEO تاثیر منفی بگذارد. چرا که کاربر باید منتظر دانلود و اجرای حجم نسبتاً بالای فایل‌ها و همین‌طور رفت و برگشت درخواست‌های API بماند. از طرفی خزنده‌های موتورهای جستجو نیز ممکن است JavaScript را به درستی اجرا نکنند و به اندازه کافی منتظر نمانند تا درخواست‌های Ajax ارسال و دریافت شوند. بنابراین چیزی را در صفحه برای Index کردن، تشخیص نمی‌دهند (البته گوگل تا حدی این کار را انجام می‌دهد اما امتیاز بهتری برای صفحه‌هایی در نظر می‌گیرد که سریع‌تر load شوند).

برای رفع این مشکل دو راه‌حل کلی وجود دارد:

۱. صفحات از قبل در زمان build ساخته شود(و به اصطلاح Pre-Rendering انجام گیرد). به علت نرخ زیاد تغییر داده‌ی هر صفحه (تعداد زیاد آگهی‌های جدید منتشر شده) این روش برای وبِ دیوار روش مناسبی نیست.

۲. در اولین درخواست، صفحه مورد نظر سمت server ساخته شود، درخواست‌های API زده شده و با محتوای اولیه مناسب render و ارسال شود که به اصطلاح به این کار Server Side Rendering گفته می‌شود. این روش مانند روش قبل بوده اما با این تفاوت که برای هر درخواست render شدن اتفاق می‌افتد(به جای هر دفعه build) که برای وبِ دیوار روش مناسبی است.

پیاده‌سازی سابق SSR

در پروژه وبِ دیوار- که در بین تیم‌های دیوار با نام THE-WALL شناخته می‌شود- در گذشته و در ابتدای پروژه با توجه به نیاز‌های محصولی تصمیم به پیاده‌سازی SSR گرفتیم.

در این روش یک وب‌سرویس Express.js و تعدادی middleware وظیفه مدیریت درخواست‌ها، گرفتن داده‌ها، آماده‌سازی store اولیه، مدیریت مسیرها و chunkها و در نهایت آماده‌سازی HTML با استفاده از renderToString را به عهده دارند. در این مقاله کامپوننت‌ view را کامپوننتی که توسط router، روی یک url خاص نمایش داده می‌شود در نظر می‌گیریم.

در پیاده‌سازی هر view متدهای static ای به نام syncServerSideInitial (برای انجام عملیات sync) و asyncServerSideInitial (برای کارهای async و return کردن promise سمت server) وجود داشت. این متدها پیش از رندر شدن کل برنامه اجرا می‌شدند و می‌توانستند کارهای دیگر مثل ارسال درخواست API و یا dispatch کردن actionهای redux store به همراه داشته باشند.

به ازای هر درخواست سمت server، بعد از پیدا کردن branch صفحه‌هایی که با url درخواست match شده‌اند، متدهای syncServerSideInitial و asyncServerSideInitial درهر view با جستجو در کامپوننت‌هایی که داخل HOCها wrap شده‌اند استخراج شده و تابع‌های sync بلافاصله اجرا می‌شدند.

همچنین به ازای هر درخواست، یک instance جدید از redux store بدون مقدار اولیه ساخته می‌شد. تابع‌های async نیز با گرفتن ورودی‌های مورد نیاز (شامل Instance از redux store و url درخواست شده و...) توسط Promise.all خوانده شده و تغییرات مربوط به خود را روی store اعمال می‌کردند.

مجموعه‌ برنامه توسط اجرای renderRoutes روی routeهای از پیش تعریف شده، داخل Provider مربوط به store (با همان مقداری که در مرحله‌ی قبل پُر شده بود) render و به‌ کمک renderToString تبدیل به string می‌شد.

سپس string مورد نظر در قالب کلی صفحه‌های برنامه قرار می‌گیرد. مقدار کنونی state به‌وسیله‌ی یک تگ script درون یک property خاص روی window اضافه می‌شود و در نهایت این مجموعه به عنوان response به مرورگر ارسال ‌می‌شد.

سمت client و در مرورگر، مشابه server، برنامه با اجرای renderRoutes روی routeها و داخل Provider مربوط به store رندر می‌شد. با این تفاوت که مقدار اولیه‌ی store از همان property خاص روی window ساخته می‌شد که توسط server پُر شده بود. به این ترتیب، داده‌ای که برای render کردن صفحه‌ی مورد نظر سمت server بدست آمده بود، در دسترس client قرار می‌گرفت.

مشکلات پیاده‌سازی سابق SSR

در پیاده‌سازی SSR  پروژه THE-WALL وجود تابع‌های مختلف (sync و async) برای انجام کارهای اولیه‌ی سمت SSR، یک پیچیدگی غیرضروری را به viewها اضافه می‌کرد. همچنین باعث اطلاع داشتن viewها از جزئیات پیاده‌سازی این ویژگی سمت SSR و وابستگی به آن می‌شد.

همین‌طور در آن روش در صورتی که یک view نیاز به قابلیت SSR داشت، برای جلوگیری از تکرار ریکوئست و ایجاد حالت لودینگ غیر ضروری و مجدد render شدن، باید دیتای خود را به کمک store مدیریت می‌کرد تا بتواند در CSR نیز به آن دیتا دسترسی داشته باشد.

این در حالیست که استفاده از store، منطق و دلایل مخصوص به خود را دارد. علاوه بر بی‌ربط بودن استفاده از store به هدف ما، استفاده از آن حجم قابل توجهی از پیچیدگی و همینطور کدهای اضافه‌ برای کار با store به صفحه‌ی مورد نظر تحمیل می‌کند مانند تعریف action-types ،actions ،reducer و رجیستر کردن reducer و connect شدن به store و… .

بازنگری در روش SSR

در سال ۹۸ و با توجه به شروع توسعه پروژه وبِ کارنامه، به سراغ بازنگری و تحقیق در روش پیاده‌سازی SSR رفتیم.

در دیوار روند پیدا کردن راه‌حل‌‌ها این‌گونه است که در ابتدا راه‌حل‌های موجود را با توجه به نیازها بررسی می‌کنیم، سپس نتایج به دست آمده را به همراه جزئیات و دلیل تصمیم گیری تحت فرمت خاصی ثبت و مستند می‌کنیم. ساختار کلی design docها به صورت شرح مسئله و بیان نیازها، توضیح موارد بررسی شده، راه‌حل و نتیجه‌گیری است. این ساختار کمک می‌کند که سایر اعضای chapter در جریان جزئیات قرار بگیرند و همینطور در آینده دلایل تصمیم‌گیری‌های مهم مکتوب باشد و بتوان به آن رجوع کرد.

این تصمیم نیز به همین صورت در chapter وب پیش رفت و بعد از بررسی نیاز‌ها و روش‌های موجود و تصمیم گیری، برای آن design doc تعریف کردیم:

نیازهایی که داشتیم:

  • render شدن صفحه‌های برنامه سمت server، و ارسال صفحه‌ی درخواست شده به همراه همه‌ی محتوای اصلی در response اولیه (همان تعریف کلی SSR).
  • عدم ایجاد محدودیت‌های ساختاری و فنی و امکان استفاده از کتابخانه‌های مختلف (بخصوص react-router)
  • امکان استفاده از Redux، و ارسال داده‌ی store که توسط SSR به‌دست آمده به client (برای جلوگیری از تکرار درخواست‌های API و…).
  • دسترسی به داده‌ی بدست آمده‌ی server توسط client (مرورگر) به ازای هر view بدون الزامی بودن استفاده از Redux. این مورد با هدف جلوگیری از تکرار درخواست‌های API، و در عین حال decoupled نگه داشتن SSR از Redux، و عدم ایجاد اجبار در جزئیات و روش پیاده‌سازی اهمیت داشت.
  • سادگی استفاده از امکانات مذکور در زمان توسعه.

بعد از بررسی کامل روش‌ها، پتلفرم‌ها، کتابخونه‌ها و boilerplateهای موجود در آن زمان و مستند کردن نتایج، در نهایت به چند کاندیدای اصلی رسیدیم:

استفاده از Next.js:

این framework با توجه به توسعه و پشتیبانی خوب، جامعه‌ی بزرگ استفاده‌کنندگان و راه‌اندازی نسبتاً راحت و سریع گزینه مناسبی به نظر می‌رسید اما عدم امکان ایجاد صفحه‌های داخلی تو در تو باعث تکرار componentها می‌شد. همچنین سیستم routing آن به شدت سفت و سخت(opinionated) است که موجب اعمال محدودیت جدی روی ساختار پروژه، روند توسعه، و هزینه فنی مهاجرت از ساختار routing فعلی می‌شد.

استفاده از After.js:

استفاده از این framework به نیازهای فنی و محصولی ما نزدیک بود اما عدم nesting routing مناسب و البته maintain نشدن از دلایل عدم انتخاب این گزینه بود.

نتیجه نهایی - پیاده‌سازی SSR توسط خودمان:

این بار نیز با توجه به نیاز‌های محصولی و این که در نظر داشتیم بستر پروژه‌های front-end را یکسان نگه‌ داریم و به کم شدن هزینه‌های فنی و جابه‌جایی آسان‌ در بین پروژه‌ها کمک کنیم، تصمیم گرفتیم که SSR را خودمان پیش ببریم و با ارتقای codebase قبلی دقیقاً چیزی را بسازیم که به آن نیاز داریم. می‌دانستیم استفاده از تجربه قبلی از توسعه SSR هزینه فنی و زمانی را کاهش می‌دهد. البته در این میان از Razzle نیز کمک گرفتیم.

پیاده‌سازی روش جدید SSR

بطور کلی برای استفاده از تجربه‌های موجود و جلوگیری از دوباره‌کاری، نقطه‌ی شروع را بر مبنای روش موجود در پروژه‌ THE-WALL گذاشتیم. با این تفاوت که علاوه بر اضافه شدن قابلیت‌هایی مثل Hot Module Replacement سمت server در زمان توسعه، در اینجا به دلیل استفاده از Razzle حجم قابل توجهی از کد و پیچیدگی‌هایی که مربوط به configهای webpack و ابزارهای development و production بود، کاهش یافت. در حالی که قابلیت‌های روش قبلی (بطور خاص ارسال داده‌ی redux store از server به client) همچنان پشتیبانی می‌شدند.

در این روش که به روش استفاده شده در Next.js شبیه است و در divar-starter-kit استفاده شده، برای SSR کردن یک view لازم است که یک متد static به نام serverSideInitial تعریف کنیم که ورودی‌های لازم را دریافت کرده و بتواند عملیات‌ sync و یا async را انجام دهد. اگر در این view، به داده‌ای از تابع serverSideInitial نیاز بود، باید دیتای مورد نیاز را در این تابع return کنیم. این داده حتی می‌تواند یک Promise باشد.

  مثالی از نحوه استفاده از تابع serverSideInitial برای SSR کردن یک view
مثالی از نحوه استفاده از تابع serverSideInitial برای SSR کردن یک view


در انتها برای دریافت داده‌ی برگردانده شده از تابع serverSideInitial لازم است view مورد نظر را داخل یک HOC به نام withSSRData قرار دهیم. با این کار view می‌تواند داده‌ی مذکور را داخل یک prop به نام initialSSRData دریافت کند که مقدار آن همان مقدار برگردانده شده توسط serverSideInitial یا مقدار resolve شده از Promise داخل آن است.

نحوه دریافت مقادیر از server  با HOC withSSRData
نحوه دریافت مقادیر از server با HOC withSSRData


جزئیات پیاده‌سازی divar-starter-kit

از یک react-context برای ذخیره‌ و از داده‌ی دریافتی از تابع‌های serverSideInitial استفاده کردیم که کاربرد این context، سمت server و client به عنوان Provider و همچنین withSSRData به‌عنوان Consumer است.

در SSR، به ازای هر درخواست، یک instance جدید از این context ساخته می‌شود. به‌دلیل نیاز به دسترسی به context توسط withSSRData ، بعد از هربار ساخته شدنِ context در server، مقدار آن ذخیره می‌شود تا با import کردن تابع getContext در دسترس باشد.

در client نیز در ابتدای بالا آمدن برنامه، یک instance از context ساخته می‌شود.

ساختار داده‌ی داخل این context به این شکل است:

  • یک پراپرتی data که شامل کل داده‌ی مربوط به serverSideInitial هاست (با ساختاری که در ادامه توضیح می‌دهیم).
  • یک تابع به نام clearDataByKey که برای پاک کردن داده‌ی مخصوص یک view (بعد از استفاده) به‌کار می‌رود.

سمت server روی branch صفحه‌هایی که با url درخواست match شده‌اند، iterate انجام می‌شود تا آرایه‌ای از موارد زیر در قالب یک Object استخراج شود. لازم به ذکر است که ترتیب آرایه متناسب با ساختار branch حفظ می‌شود:

  • متد serverSideInitial هر view
  • تعیین اینکه view مورد نظر به داده‌ی return شده‌ی آن تابع نیاز دارد یا خیر. تشخیص این نکته، به کمک بررسی وضعیت wrapشدگی view داخل withSSRData انجام می‌گیرد.
  • Path مربوط به view. که در ادامه به عنوان کلید یکتای ذخیره‌ی داده‌ی آن view استفاده خواهد شد.

همه‌ی توابع بدست آمده با ورودی‌های لازم و در قالب یک Promise.all اجرا می‌شوند. با توجه به رفتار Promise.all، به صورت خودکار از هر دو حالتِ مقدار return-value از تابع (چه Promise باشد یا نباشد)، پشتیبانی می‌شود. نتایج Promise.all به ازای هر مورد داخل یک Object (به نام preloadedInitialData) قرار می‌گیرد که کلید‌های آن، pathهای هریک از viewها بوده، به شرط اینکه view مورد نظر به این داده‌ها نیاز داشته باشد.

پس از بدست آمدن داده، برنامه داخل Provider مربوط به Context مذکور (با همان مقدار preloadedInitialData) و Provider مربوط به store گذاشته شده و به‌وسیله‌ی renderToString تبدیل به string می‌شود.

در نهایت string مورد نظر در قالب کلیِ صفحه‌های برنامه قرار گرفته و به عنوان response به client ارسال می‌شوند. هم‌چنین مقدار کنونی preloadedInitialData و state در یک تگ script درون propertyهای خاص روی window اضافه می‌شود.

سمت CSR (مشابه server)، برنامه داخل Providerهای context مربوط به preloadedInitialData و store گذاشته می‌شود. و مقدار اولیه‌ی هر دو مورد از همان propertyهای خاص روی window دریافت می‌شود که توسط server پُر شده است.

شیوه استفاده از context در رندر اولیه صفحه. فایل: src/server/handlers/ssr-handler.js
شیوه استفاده از context در رندر اولیه صفحه. فایل: src/server/handlers/ssr-handler.js


کامپوننت withSSRData در واقع Consumer ــِـ context ــِـ initialSSRData بوده که با استفاده از تابع getContext در هر دو سمت SSR و CSR به instance مناسب از این context دسترسی پیدا می‌کند.

کامپوننت HOC withSSRData با وصل شدن به withRouter ، کلید path مربوط به صفحه‌ی حاضر را دریافت می‌کند و با استفاده از کلید path، داده‌ی مخصوص view کنونی را از property ــِـ data در context برداشته و داخل یک prop به نام initialSSRData به component ــِـ wrapشده ارسال می‌کند.

این کامپوننت با پیاده‌سازی componentWillUnmount در زمان حذف شدن view کنونی، داده‌ی مربوط به آن‌ را با کمک کلید path و تابع clearDataByKey در context پاک می‌کند و مشکلی از بابت دریافت داده‌ی اشتباه در instanceهای دیگرِ این component به وجود نمی‌آورد. این روش این امکان را به ما می‌دهد که در صورت نیاز، در آینده می‌توان با تغییری مختصر cache کردن را اختیاری کرد و یا بهبود بخشید. همچنین، با قرار دادن یک عضو static (به نام HAS_PRELOADED_DATA و با مقدار true) روی component که می‌سازد به تشخیص نیاز صفحه‌ی مورد نظر به initialSSRData کمک می‌کند.

در پایان...

ما در دیوار هم‌اکنون ۷ ماه است که از divar-starter-kit به صورت مستقیم در پروژه‌هایمان مانند کارنامه و سکو استفاده می‌کنیم.

بازنگری در روش SSR کردن‌ تأثیر مستقیمی در سهولت و سرعت پیاده‌سازی محصولات ما گذاشته‌است. توسعه divar-starter-kit علاوه بر رفع مشکلات و نیازهای ما، کمک کرده تا حجم source code، زنجیره‌ی ابزارهای (tool-chain) پروژه و زمان راه‌اندازی پروژه را کمتر کنیم و codebase تمیزتری داشته باشیم.

در دیوار هنوز راهی طولانی در پیش داریم و برای هموار کردن مسیرمان نیاز به همراهانی پرانگیزه و مشتاق داریم. اگر به فعالیت در زمینه‌ی front-end علاقه‌مند هستید، برای همراهی در این مسیر به ما بپیوندید.