دیزاین سیستم جدید دیجی‌کالا

ما دیزاین سیستم جدید خود را به‌شکل custom elementهای بدون فریمورک و با صفر وابستگی در runtime ساختیم. باندل کوچک‌تر می‌خواستیم، بدون وابستگی به هیچ کتابخانه‌ای و بدون هیچ‌کدام از مشکل‌هایی که از محاسبه‌ی استایل‌ها در runtime می‌آید. این‌جا می‌گوییم چه چیزی را اندازه گرفتیم، چه چالش‌هایی در این مسیر داشتیم، و در برابرش چه کارهایی کردیم.

هیچ‌کدام از کامپوننت‌های دیزاین سیستم ما نمی‌دانست چه رنگی دارد.

رنگ، فاصله‌گذاری، radius و تایپوگرافی همگی از یک hook به نام useToken() می‌آمدند. این hook یک فایل JSON با حجم 320KB را در مرورگر می‌خواند و مقدار مناسب را هم‌زمان برای چهار بُعد پیدا می‌کرد: استایل برند (3 مورد)، تم (20 مورد)، حالت روشن یا تاریک و دستگاه. نتیجه فقط در runtime مشخص می‌شد؛ بنابراین stylesheet هم باید همان موقع ساخته می‌شد. این همان کاری بود که @emotion/css در بیش از 70 فایل دیزاین سیستم انجام می‌داد؛ از طریق useToken در حدود 140 فایل، و در حدود 100 فایل از اپ فروشگاه ما.

یعنی استایل کامپوننت یک فایل نبود؛ چیزی بود که در اثر رندر شدن ساخته می‌شد.

در این پست از هزینه‌های این وضعیت می‌گوییم، از چیزهایی که پیش از تغییر اندازه گرفتیم و از قاعده‌ای که جایگزین کردیم. دیزاین سیستم جدید با صفر وابستگی runtime منتشر می‌شود: بدون React، بدون Lit و بدون کتابخانه‌ی state machine. فقط مجموعه‌ای از custom elementهاست، همراه با CSS ساده. حجم runtime مشترک پس از gzip برابر با 427 بایت است و با اضافه شدن المنت‌های جدید بیشتر نمی‌شود.

هیچ محاسبه‌ای حذف نشده است؛ همان کار به‌جای دستگاه هر کاربر، یک‌بار در فرایند ریلیز انجام می‌شود.

چرا این مسیر را رفتیم

یک دلیل نداشتیم. همه‌ی این‌ها را با هم می‌خواستیم:

  • باندل کوچک‌تر برای همان فیچر

  • بدون وابستگی به هیچ کتابخانه‌ی UI، تا دیزاین سیستم از فریمورک مستقل بماند و مانع تصمیم بعدی درباره‌ی فریمورک نشود

  • performance بهتر: بدون محاسبه‌ی استایل روی دستگاه کاربر و بدون hydration برای بخش‌هایی که به آن نیاز ندارند

  • رفع مشکلات runtime: استایل‌هایی که همراه JavaScript می‌رسند، layoutی که بعد از اولین paint جابه‌جا می‌شود و کامپوننتی که بدون React نمی‌توان به آن استایل داد

  • صفر وابستگی در پکیج منتشرشده

یکی از این دلایل راحت‌تر از بقیه نادیده گرفته می‌شود: اینکه کجا می‌توان از دیزاین سیستم استفاده کرد. صفحه برای نمایش یک رنگ باید React اجرا می‌کرد. در نتیجه، این صفحه‌ها نمی‌توانستند ظاهری هماهنگ با هویت بصری برند داشته باشند:

  • صفحه‌ی ورود، که اپ React نیست

  • صفحه‌هایی که با استکی غیر از JavaScript در سمت سرور ساخته می‌شوند؛ نه React دارند و نه npm

  • صفحه‌های مارکتینگ روی CMS

  • هر محیط میزبانی جدیدی که شاید بعداً بخواهیم امتحان کنیم

هیچ‌کدام React اجرا نمی‌کنند و بیشترشان حتی npm هم ندارند. دیزاین سیستمی که نتواند به یک فرم ورود استایل بدهد، دیزاین سیستم نیست؛ فقط برای یک اپ خاص ساخته شده است.

این وضعیت در runtime هزینه‌ی زیادی برایمان داشت. استایل‌ها همراه JavaScript می‌رسیدند، پس HTML سرور ابتدا بدون استایل نمایش داده می‌شد و بعد، با رسیدن استایل‌ها، اجزای صفحه جابه‌جا می‌شدند. هیچ pipelineای برای استخراج CSS هنگام بیلد نداشتیم که جلوی این مشکل را بگیرد.

یک فیچر یکسان را دو بار ساختیم

پیش از قطعی کردن رویکرد، یک vertical slice ساختیم: یک فیچر واقعی با دو پیاده‌سازی، روی یک صفحه و با داده‌های یکسان. فیچر، انتخابگر شهر بود؛ یک مودال با فهرستی قابل جست‌وجو. نسخه‌ی اول یک custom element بدون وابستگی در light DOM بود که از <dialog> بومی مرورگر و یک state machine کوچک استفاده می‌کرد. نسخه‌ی دوم، همتای React آن بود که با کامپوننت‌های استک فعلی ساختیم.

معیارهای پذیرش را پیش از شروع نوشتیم. مشخص کردیم چه نتیجه‌ای باعث می‌شود رویکرد جدید را رد کنیم و بعد دیگر معیار را تغییر ندادیم. این تصمیم مهم بود، چون همان نتایج اولیه در یکی از معیارها مردود شدند.

به سطر اول نگاه کنید. اگر فقط بایت‌های افزوده را حساب کنیم، custom element با ضریب 1.94× برنده شده است. اما ما «هم‌مرتبه بودن» را نتیجه‌ای مردود تعریف کرده بودیم. پس در همان معیاری که همه انتظار دارند مهم باشد، رویکرد جدید به حد نصاب خودمان نرسید.

سطر آخر هم مهم است. نسخه‌ی React مودال را 24ms سریع‌تر باز می‌کند، چون state machine آن از قبل، هنگام بارگذاری صفحه، hydrate شده بود.

سطری که انتظارش را نداشتیم

در مرحله‌ای قبلی از پروژه، همین نسخه‌ی React روی همین محیط تست موبایل، CLS برابر با صفر داشت. layout صفحه از آن زمان تغییری نکرده بود. فقط یک المنت تعاملی به بالای صفحه اضافه کردیم که استایلش در runtime محاسبه می‌شد: همان کنترل باز کردن انتخابگر شهر. CLS به 0.9991 رسید؛ در هر سه اجرا.

همان صفحه با دو پیاده‌سازی. سمت راست، بخش استایل‌دار از ابتدا اندازه‌ی نهایی خود را دارد؛ بنابراین چیزی زیر آن جابه‌جا نمی‌شود.

بدون custom element هم می‌شود این مشکل را با استخراج CSS هنگام بیلد حل کرد. ما آن pipeline را نداشتیم و جابه‌جایی صفحه فقط یکی از نشانه‌های هزینه‌ای بزرگ‌تر بود. هزینه‌ی محاسبه‌ی استایل در runtime با حجم کدی که منتشر می‌کنید رشد نمی‌کند؛ با تعداد المنت‌های استایل‌دار بالای صفحه رشد می‌کند. هر فیچری که تیم محصول به بالای صفحه اضافه می‌کند، وضعیت را بدتر می‌کند و کنترل سخت‌گیرانه‌ی حجم باندل هم کمکی نمی‌کند. custom element این باگ را ندارد: استایل‌هایش در یک فایل استاتیک هستند و باکس آن از اولین paint اندازه‌ی درست را دارد.

تصمیم: بدون هیچ وابستگی‌ای

این آزمایش مسیر را نشان داد، اما تصمیم را به‌جای ما نگرفت. به اعدادش هم نباید بیش از حد تکیه کرد: یک island را روی یک صفحه و در یک محیط تست محلی اندازه گرفته بودیم، نه در ترافیک واقعی کاربران.

تصمیم نهایی از هر چیزی که در آن آزمایش سنجیده بودیم سخت‌گیرانه‌تر بود: دیزاین سیستم جدید باید صفر وابستگی runtime داشته باشد.

با این قاعده React کنار می‌رفت، که هدف اصلی هم همین بود. اما Lit و حتی کتابخانه‌ی state machine هم کنار می‌رفتند؛ همان کتابخانه‌ای که آزمایش تازه نشان داده بود «به اندازه‌ی کافی خوب» است. بخشی از دلیل را در خود آزمایش پیدا کردیم. وقتی بررسی کردیم حجم 33.7KB آن island از کجا آمده است، دیدیم 18.6% آن متعلق به یک positioning engine است که هرگز اجرا نشده. این موتور از طریق وابستگی‌های state machine مربوط به combobox وارد شده بود، در حالی که فهرست به‌صورت inline داخل مودال رندر می‌شد. یک کامپوننت، یک صفحه و 18.6% حجم اضافی و بی‌استفاده. قرار بود حدود 25 کامپوننت دیگر بسازیم و این هزینه چند برابر می‌شد.

وابستگی‌ای که نتوانید با این دقت بررسی کنید، بدون اینکه متوجه شوید همراه پکیج منتشر خواهد شد.

چه مشکلاتی در این مسیر داشتیم و چه کردیم

گفتن تصمیم یک جمله بود، اما پایبندی به آن 9 مشکل به همراه داشت. در ادامه، هر مشکل و کاری را که برای رفعش کردیم می‌خوانید.

محاسبه‌ی استایل‌ها روی هر دستگاه

مشکل: در هر رندر و روی دستگاه هر کاربر، همان JSON با حجم 320KB پردازش می‌شد تا یک رنگ نمایش داده شود.

راه‌حل: محاسبه را یک‌بار هنگام ریلیز انجام دهیم و خروجی را به‌صورت یک stylesheet استاتیک منتشر کنیم.

همان محاسبه همچنان انجام می‌شود؛ فقط از دستگاه کاربر به مرحله‌ی بیلد ما منتقل شده است. به‌جای اعتماد به نتیجه، آن را بررسی کردیم: مقدار نهایی هر توکن را در تمام 240 ترکیب ممکن از استایل برند، تم، حالت روشن یا تاریک و دستگاه، در یک مرورگر واقعی با خروجی resolver قدیمی در runtime مقایسه کردیم. 655,560 مقایسه، بدون هیچ اختلافی. تغییر dark mode دیگر به re-render نیاز نداشت و با تغییر یک attribute روی <html> انجام می‌شد.

همان محاسبه است، اما در زمان دیگری انجام می‌شود. کامپایلر هنگام ریلیز، مقادیر همه‌ی حالت‌ها را یک‌بار محاسبه می‌کند؛ در runtime فقط بخش مربوط به حالت انتخاب‌شده از stylesheet آماده اعمال می‌شود.

هر صفحه کل matrix توکن‌ها را با خود می‌آورد

مشکل: stylesheet کامپایل‌شده حدود 3,000 نام در 28 بلاک attribute دارد و حجمش پس از gzip برابر با 41KB است. حتی صفحه‌ای با دو المنت هم هزینه‌ی تمام آن را می‌پرداخت.

راه‌حل: یک subsetter که فقط بخش مورد استفاده‌ی اپ را نگه دارد.

این subsetter اپ را اسکن می‌کند تا المنت‌ها و آیکن‌های مورد استفاده را پیدا کند. سپس تمام ارجاع‌های var() آن‌ها را دنبال می‌کند و فقط مقادیر مربوط به تنظیماتی را نگه می‌دارد که اپ روی <html> قرار می‌دهد. خروجی شامل همین بخش مورد نیاز است. برای دو المنت: 175 متغیر از 2,960 متغیر، 5 بلاک از 28 بلاک و حجمی برابر با 2.0KB.

حذف در دو مرحله انجام می‌شود: اول مقادیر استایل، تم، حالت نمایش و دستگاه که اپ استفاده نمی‌کند؛ سپس نام توکن‌هایی که هیچ‌کدام از المنت‌های مورد استفاده به آن‌ها ارجاع نمی‌دهند.

آنچه باقی ماند، بیشتر نام بود

مشکل: پس از حذف موارد بلااستفاده، بیشتر فایل را نام توکن‌ها تشکیل می‌داد؛ با میانگین 39 کاراکتر برای هر نام. minifierها هم نام custom property را تغییر نمی‌دهند، چون ممکن است بخشی از API مورد استفاده‌ی دیگران باشد.

راه‌حل: نام‌ها را با همان شرطی تغییر دهیم که Closure Compiler در advanced mode به کار می‌برد.

در stylesheet کامل، حدود 80% حجم فایل به نام‌ها اختصاص دارد: حدود 2,900 نام یکتا که در مجموع 8,000 بار تکرار شده‌اند. اگر CSS یا JavaScript خود اپ به نامی اشاره کند، آن نام extern محسوب می‌شود و تغییر نمی‌کند. نامی که فقط داخل فایل subset استفاده شده باشد کوتاه می‌شود؛ مثلاً --dds-theme-primary-primary-base به --da تبدیل می‌شود. نام‌های جدید یک namespace تک‌کاراکتری دارند تا با propertyهای خود اپ تداخل پیدا نکنند. نامی که در runtime ساخته شود قابل شناسایی نیست؛ در این حالت ابزار تغییر نام را برای کل اجرا غیرفعال می‌کند و نام فایل را گزارش می‌دهد. یک تست در مرورگر هم مقدار نهایی همه‌ی توکن‌ها را در دو فایل مقایسه می‌کند. این بخش را بیشتر از همه دوست داریم، چون حجم را از جایی کم می‌کند که کسی انتظارش را ندارد.

چرا سراغ نام‌ها رفتیم؟ حذف کامنت‌ها و whitespace و کوتاه کردن مقادیر، روی هم پس از فشرده‌سازی حدود 1% صرفه‌جویی دارد. انتقال مقادیر تکراری به متغیرهای مشترک حتی حجم را بیشتر می‌کند: ارجاع var() به 8 بایت نیاز دارد، اما یک back-reference در فشرده‌سازی حدود 3 بایت است.

همه‌ی آیکن‌ها یا هیچ‌کدام

مشکل: سیستم قدیمی کل جدول glyphها را با یک dynamic import بارگذاری می‌کرد؛ حدود 430KB پس از gzip، فارغ از اینکه صفحه کدام آیکن‌ها را لازم داشت.

راه‌حل: برای هر glyph یک قانون CSS داشته باشیم و با همان اسکن، قوانین مورد نیاز صفحه را انتخاب کنیم.

حجم هر glyph پس از gzip حدود 280 بایت است. چهار آیکن روی هم 813 بایت می‌شوند. stylesheet کامل هم وجود دارد و حجمش پس از gzip حدود 200KB است، اما هرگز کامل ارسال نمی‌شود. اگر نام آیکن از یک expression به دست بیاید، اسکن نمی‌تواند آن را شناسایی کند و باید نامش را در یک flag مشخص کنید. نبودن یک glyph به‌شکل یک باکس خالی دیده می‌شود؛ به‌جای اینکه بی‌سروصدا 200KB بارگذاری شود.

همین flag یک‌بار برایمان دردسر درست کرد. در یک اپ، اسکن صدها glyph را به‌اشتباه تشخیص می‌داد. برای کنار گذاشتن این نتایج از --icons none استفاده شده بود و فهرستی دستی جای آن‌ها را گرفته بود. به‌مرور، فهرست با سورس هماهنگ نماند. none چهار glyph را حذف کرد که نامشان صریحاً در سورس نوشته شده بود، اما ابزار چیزی گزارش نکرد. اپ با باکس‌های خالی منتشر شد. حالا --icons none نام تمام glyphهایی را که حذف می‌کند اعلام می‌کند و --explain به‌تفکیک هر فایل سورس نشان می‌دهد اسکن کدام المنت‌ها و glyphها را از آن پیدا کرده است.

چهار آیکن با دو روش. در روش قدیمی، دانلود کل جدول باید تمام می‌شد تا حتی یک آیکن بتواند رندر شود.

خودِ المنت‌های ما DOM می‌ساختند

مشکل: حدود یک‌چهارم المنت‌ها در connectedCallback نود می‌ساختند. هرکدام باعث hydration mismatch و خطای #418 در React می‌شدند و برای crawlerها هم نامرئی بودند.

راه‌حل: مالکیت DOM را از المنت بگیریم.

المنت‌ها را همان‌طور نوشته بودیم که آموزش‌ها نشان می‌دهند: <dds-checkbox> خودش <input> می‌ساخت، <dds-icon-button> یک <button> داخلی و یک spinner می‌ساخت و <dds-timer> سلول‌های ارقامش را ایجاد می‌کرد. سرور HTML را می‌فرستاد، React آن را تحویل می‌گرفت و بعد المنت بازنویسی‌اش می‌کرد. در نتیجه React با DOMی روبه‌رو می‌شد که خودش رندر نکرده بود. این دقیقاً همان تضمینی بود که ماه‌ها برای ساختنش وقت گذاشته بودیم.

راه‌حل، بهتر کردن hydration نبود؛ مالکیت DOM را از المنت گرفتیم.

این نسخه با تغییرات ناسازگار منتشر شد:

  • خود host همان کنترل است. <dds-button> دکمه‌ای داخل خود ندارد؛ خودش یک دکمه است. برای role، ARIA و مشارکت در فرم از ElementInternals استفاده می‌کند.

  • سرور کنترل‌های بومی مرورگر را رندر می‌کند و المنت همان‌ها را به کار می‌گیرد. حالا برای <dds-checkbox> باید <input> را خودتان وارد کنید. المنت وضعیت input را با :has(> input:checked) و بدون JavaScript دنبال می‌کند. onChange شما هم روی همان نودی قرار می‌گیرد که React ساخته است.

  • اجزای بصری خود المنت با pseudo-element ساخته می‌شوند. هر spinner به یک ::after با حلقه‌ای از CSS تبدیل شد.

همان gate که قبلاً این استثناها را نگه می‌داشت، حالا با یک assertion خالی بودن map را بررسی می‌کند. فقط یک المنت هنوز متن می‌نویسد: تایمر شمارش معکوس. سرور نمی‌تواند مقدار آن را بداند، بنابراین تایمر ارقام سلول‌هایی را بازنویسی می‌کند که سرور ساخته است؛ با همان نوع ارقامی که سرور فرستاده. این استثنا جداگانه نگه داشته می‌شود و فقط اجازه داریم دامنه‌ی آن را محدودتر کنیم.

متن داخل attribute فقط بعد از JavaScript وجود دارد

مشکل: CSS نمی‌تواند attribute را به متن تبدیل کند؛ بنابراین <dds-badge label="Sale"> برای crawler و کاربری که هنوز JavaScript را دریافت نکرده، یک تگ خالی است.

راه‌حل: متن قابل مشاهده همیشه در یک child node قرار بگیرد و attributeها فقط ظاهر را مشخص کنند.

یک gate تمام مثال‌های مستندشده را دو بار رندر می‌کند: یک‌بار با browsing context و یک‌بار بدون آن. اگر متن‌ها متفاوت باشند، تست رد می‌شود. propهای متنی سیستم قدیمی به children تبدیل شده‌اند؛ lint مربوط به contract هم پیش از انتشار، attributeهایی را شناسایی می‌کند که باید به‌صورت child نوشته می‌شدند.

متنی که در attribute قرار گرفته، فقط بعد از اجرای JavaScript ظاهر می‌شود. متن داخل child node از همان اولین بایت در دسترس است.

راه‌اندازی دیزاین سیستم در هر اپ، یک چک‌لیست برای توسعه‌دهنده داشت

مشکل: برای استفاده از دیزاین سیستم در هر اپ، توسعه‌دهنده باید تم، استایل، پشتیبانی از dark mode و مسیر فایل‌های خروجی را مشخص می‌کرد؛ attributeهای لازم را روی <html> می‌گذاشت؛ یک build hook برای به‌روز نگه داشتن subset اضافه می‌کرد؛ stylesheet تولیدشده را import می‌کرد و فایل‌های تولیدشده را در git نادیده می‌گرفت. این تنظیمات هم باید جایی ثبت می‌شدند که reviewer بتواند بررسی‌شان کند، نه اینکه فقط به‌شکل چند flag در دو hook تکرار شوند.

راه‌حل: دستور init در اولین راه‌اندازی چهار سؤال می‌پرسد و همه‌ی این مراحل را انجام می‌دهد. پاسخ‌ها در فایل dds.config.json کنار package.json ذخیره می‌شوند؛ بنابراین hookها فقط دستور dds-uikit را بدون آرگومان اجرا می‌کنند.

اگر تنظیمی را با flag در خط فرمان مشخص کنید، فقط برای همان اجرا بر مقدار ثبت‌شده در فایل اولویت دارد. به این ترتیب، CI می‌تواند تنظیمات را بدون تغییر فایل override کند. دستور config هم اجازه می‌دهد بعداً به همان سؤال‌ها دوباره پاسخ دهید و تنظیمات را تغییر دهید. اگر اپ پیش از اضافه شدن فایل تنظیمات راه‌اندازی شده باشد، اولین اجرای config، flagهای قبلی را از hookها به فایل تنظیمات منتقل می‌کند.

این تنها ابزاری است که سورس اپ مصرف‌کننده را تغییر می‌دهد. تمام مراحل آن متنی و idempotent هستند.

برای کارهای دیگر هم CLI چند ابزار دارد که هرکدام یک مشکل مشخص را برطرف می‌کنند:

هر consumer باید API المنت‌ها را حدس می‌زد

مشکل: هم توسعه‌دهنده در ادیتور و هم agent در چت باید نحوه‌ی استفاده از المنت‌ها را حدس می‌زدند. در سیستم جدید، متن به‌جای prop از طریق children به المنت داده می‌شد؛ تغییری که autocomplete و agentهای کدنویسی به‌راحتی در آن اشتباه می‌کنند.

راه‌حل: API هر المنت را یک‌بار در JSDoc تعریف کنیم و تمام مستندات و اطلاعات مورد نیاز ابزارها را از همان تعریف بسازیم.

هنگام بیلد، تعریف‌های JSDoc در یک manifest جمع می‌شوند. همه‌ی خروجی‌ها از همین فایل ساخته می‌شوند: تایپ‌های JSX؛ داده‌های سفارشی HTML و CSS برای VS Code، شامل autocomplete و توضیحات hover برای تگ‌ها، attributeها و تمام توکن‌ها؛ یک snippet برای هر المنت؛ فایل llms.txt برای قرار گرفتن در context window؛ یک صفحه‌ی Markdown برای هر المنت؛ یک skill برای Claude؛ ruleهای Cursor؛ و lint مربوط به قرارداد. دستور init اپ را برای استفاده از این خروجی‌ها تنظیم می‌کند و با هر اسکن، نسخه‌های کپی‌شده در اپ به‌روز می‌شوند. برای agentهایی که به دسترسی زنده نیاز دارند، دستور dds-uikit mcp همان فهرست APIها و همان lint را از طریق MCP در اختیارشان می‌گذارد. ارتباط JSON-RPC روی stdio را هم خودمان پیاده کرده‌ایم تا پکیج بدون وابستگی بماند. هیچ‌کدام از این خروجی‌ها را دستی نمی‌نویسیم. با تغییر یک خط JSDoc و اجرای دوباره‌ی بیلد، تایپ‌ها، اطلاعات ادیتور، مستندات، اطلاعات agentها و lint همگی به‌روز می‌شوند.

تعریف API فقط یک منبع دارد. manifest یک‌بار در هر ریلیز ساخته می‌شود و تمام مستندات و فایل‌هایی که در اختیار مصرف‌کننده قرار می‌گیرند، از همان تولید می‌شوند.

قواعدی که به‌صورت متن نوشته شده بودند، به‌مرور در عمل رعایت نمی‌شدند

مشکل: قراردادهایی که فقط می‌نوشتیم و دستی بررسی می‌کردیم، ظرف چند هفته دیگر در عمل رعایت نمی‌شدند. اما قراردادهایی که برایشان تست داشتیم، همچنان رعایت می‌شدند.

راه‌حل: برای هر قاعده یک تست بنویسیم و دو فهرست استثنا را طوری محدود کنیم که فقط بتوان از آن‌ها کم کرد، نه اینکه مورد جدیدی به آن‌ها افزود.

این بررسی‌ها شامل قرارداد توکن، قرارداد zero-mutation، قرارداد boolean-attribute و قرارداد CSS specificity هستند. یک gate هم بررسی می‌کند که هر المنت، فقط با CSS و بدون JavaScript، باکسی با اندازه‌ی غیرصفر داشته باشد. اگر دیزاین سیستمی بدون فریمورک می‌سازید، زمان و هزینه‌ی این بررسی‌ها را از ابتدا بخشی از ساخت سیستم حساب کنید، نه هزینه‌ای اضافه برای تست.

تست‌ها در repo خودمان و در یک مرورگر اجرا می‌شوند، اما همین قواعد باید در فریمورک‌های دیگران هم رعایت شوند. برای همین، کنار تست‌ها یک محیط آزمایش با ده اپ موقتی داریم: vanilla، React 18 و 19، Vue، Svelte، Next، Nuxt، SvelteKit، Astro و یک صفحه‌ی ساده‌ی CDN. هر اپ پکیج را از یک tarball تازه بسته‌بندی‌شده نصب می‌کند و در تمام حالت‌های رندر مورد پشتیبانی‌اش اجرا می‌شود. در هر اجرا سه سؤال داریم:

  1. آیا المنت پیش از اجرای JavaScript، باکس و اندازه دارد؟

    برای بررسی، جلوی تمام درخواست‌های اسکریپت را می‌گیریم و اندازه را می‌سنجیم.

  2. آیا هنگام upgrade شدن المنت، DOM تغییر کرده است؟

    DOM را قبل و بعد از آن مقایسه می‌کنیم.

  3. آیا فریمورک هنگام hydration مشکلی گزارش کرده است؟

    خروجی کنسول را می‌خوانیم.

حالت رندر در این آزمایش‌ها تغییر می‌کند، اما معیار قبولی در همه‌ی آن‌ها پاسخ همین سه سؤال است.

چقدر وزن دارد

حجم‌های جدول پس از bundling، minification و فشرده‌سازی با gzip اندازه‌گیری شده‌اند. ستون custom element مربوط به بیلد نسخه‌ی 1.0.0 است و ستون React حجم‌های سیستم قبلی را نشان می‌دهد:

اپ فروشگاه در هر صورت React را همراه خود دارد. حتی اگر حجم React را کنار بگذاریم، runtime خود دیزاین سیستم قدیمی، پیش از اولین المنت استایل‌دار، 27KB بود. در نسخه‌ی جدید این مقدار 427 بایت است.

این 427 بایت تمام فریمورک است: یک base class و یک helper برای ثبت المنت‌ها. با اضافه شدن هر المنت، فقط JavaScript و CSS همان المنت به خروجی اضافه می‌شود.

جمع‌بندی

دیزاین سیستمی با صفر وابستگی در runtime حاصل یک ترفند نیست؛ حاصل پایبندی به مجموعه‌ای از محدودیت‌هاست. در این پست دیدیم این محدودیت‌ها چه هزینه‌ای برایمان داشتند و چه چیزی در مقابل به دست آوردیم: استایل‌هایی که پیش از JavaScript در دسترس‌اند، باندلی که فقط کد و استایل مورد نیاز صفحه را دارد، المنت‌هایی که در همه‌ی فریمورک‌های آزمایش‌شده بدون مشکل از hydration عبور می‌کنند و یک API مشترک که هم انسان‌ها و هم agentها از آن می‌خوانند.

سه تصمیم پروژه را جلو برد و هیچ‌کدام، کنار گذاشتن React نبود: مالکیت DOM با سرور باشد، API یک‌بار تعریف شود و هر قاعده یک تست داشته باشد. هر تصمیم پاسخی به مشکلی بود که در مسیر با آن روبه‌رو شدیم.

اگر می‌خواهید همین مسیر را بروید، انتخاب کتابخانه بخش آسان کار است. ابتدا مشخص کنید چه کسی اجازه دارد نود بسازد و تستی بنویسید که رعایت این قاعده را بررسی کند. بقیه‌ی تصمیم‌ها را بر همین اساس بگیرید.

تیم فرانت دیجی‌کالا