آیکون SVG در RSC: چرا یک import باندل کلاینت را کیلویی میکند و چطور صفر بایت بفرستی
نویسنده: آرش لطیفی
بسیاری از کتابخانههای آیکون با یک 'use client' پنهان کل صفحه را به کلاینت میکشند. الگوی تست grep، رندر سروری با zero-JS، و بنچمارک ۴۵KB vs صفر — با کد کپیپیستی.
خلاصه برای وقتندارها: ۶ تا آیکون اضافه میکنی، باندلت ۴۴ کیلوبایت میپره، بدون اینکه جایی
'use client'نوشته باشی. خیلی از کتابخانههای آیکون خودشون'use client'دارن و با یک import کل صفحهات رو میکشن سمت کلاینت. راه حل؟ یکgrepپنجثانیهای بزن، آیکون سروری (یا SVG خام) جایگزین کن، و با یک چک بعد از build مطمئن شو. ده دقیقه کار، کلی سرعت.
صفحهی /pricing رو با ۶ تا آیکون ساختی. بیلد سبزه، اروری نیست. بعد next build رو چک میکنی:
Route (app) Size First Load JS
┌ ○ /pricing 24.1 kB 142 kB
# بعد از اضافه کردن 6 آیکون:
┌ ○ /pricing 68.4 kB 187 kB ← +44 kB gzipped 😬
تو که جایی 'use client' ننوشتی! پس چی شد؟
برو DevTools → Sources → پوشهی _next/static/chunks — میبینی lucide-react/dist/... یا کل react-icons/fa لم داده تو باندل کلاینت. برای چند تا نقاشی ثابت SVG، داری ۴۴ کیلوبایت JS به هر کاربر میفرستی — روی اینترنت 3G یعنی +۲۸۰ میلیثانیه تاخیر تا صفحه interactive بشه. فقط بخاطر آیکون.
مقالهی دیروز Rushan از GeoIcons دقیق همین رو کالبدشکافی کرده: بعضی کتابخانهها با Server Component رفیقن، بعضی نه — و با یک grep پنجثانیهای میفهمی کدوم به کدومه.
اصل ماجرا — یک قانون ساده
RSC فقط یک قانون داره:
- بدون
'use client'→ Server Component. روی سرور رندر میشه، خروجیاش HTML/SVG خالصه، صفر JS میره سمت مرورگر. - با
'use client'در خط اول → Client Component. اون فایل و هرچی import کرده باید به مرورگر فرستاده بشه.
گیر آیکونها اینجاست: خیلی از کتابخانهها (مخصوصاً react-icons و lucide-react با barrel-export) توی فایل index.ts که همهی آیکونها رو re-export میکنه، همون بالا 'use client' گذاشتن — یا آیکونها با forwardRef و useState ساخته شدن و چارهای جز client بودن ندارن. تو فقط مینویسی:
ولی عملاً کل کتابخانه میچسبه به باندل کلاینت. tree-shaking هم کاری نمیتونه بکنه — چون boundary قبل از bundling تعیین میشه.
و نکتهای که تو داک Next.js خیلی پررنگ نیست: boundary مسریه — به سمت بالا. کافیه یک leaf کلاینت باشه تا parent هم کلاینت بشه. «فقط یک آیکون بود» بهونه نیست.
تست سریع: کتابخونهات سالمه یا مسموم؟
| کتابخانه | grep -rl "^['\"]use client['\"]" node_modules/<pkg> | یعنی چی برات |
|---|---|---|
| @geoicons/react | فقط dist/_license.js | آیکونها Server هستن — صفر JS ✅ |
| lucide-react@0.469 (barrel) | dist/lucide-react.js + تکتک آیکونها | با barrel کل صفحه client میشه ❌ |
| react-icons | همهی fa/*.js | عملاً همیشه client ❌ |
به زبون ساده:
app/page.tsx (Server)→import { Nz } from "@geoicons/react"→ هیچ boundary نیست → Next.js همون<svg><path d="m6.35..." /></svg>رو تو HTML میذاره. تمام.app/page.tsx→import { Search } from "lucide-react"(barrelِ کلاینت) → کلpage.tsxسقوط میکنه به کلاینت — حتی بدون اینکه خودت'use client'نوشته باشی.
راه حل — از ۴۴ کیلوبایت به صفر
قدم ۱: تست مسمومیت پنجثانیهای
قبل از هر کاری اینو بزن:
نکته: حتماً
^اول regex باشه. بدون اون، فایلهایی که تو کامنت نوشتن «why we avoid use client» هم match میخورن و ۷۹۹ تا false-positive میگیری.
قدم ۲: قبل → بعد
قبل — import بهظاهر بیگناه که صفحه رو مسموم میکنه:
بعد — آیکون سروری، صفر JS:
چرا کار میکنه؟ سورس آیکون رو ببین — خبری از 'use client' نیست:
قدم ۳: «میخوام lucide بمونه»
اشکالی نداره — فقط barrel رو دور بزن:
اگه حتی فایلهای تکی هم 'use client' داشتن، تمیزترین راه: یک بار SVG خام رو کپی کن. یک بار زحمت، تا ابد صفر وابستگی:
قدم ۴: مطمئن شو واقعاً درست شد
بعد از pnpm build:
تلههایی که نصفشب میترکوننت
-
optimizePackageImportsboundary رو پاک نمیکنه. فقط barrel رو tree-shake میکنه، ولی اگه خود فایل مقصد'use client'داشته باشه، بازم client میمونی. اولgrepبزن، بعد optimize. -
IconProviderتنها چیزیه که باید کلاینت باشه. تو GeoIcons فقطIconProvider(برای theme) بهuseState/useEffectنیاز داره و'use client'داره. آیکونها بهش وصل نیستن، پس provider رو بذار توapp/layout.tsxو بقیهی درخت سروری میمونه. اگه provider و آیکون رو تو یک فایل بذاری، کل فایل client میشه — جدا نگهشون دار. -
دیدن path دوبار تو HTML طبیعیه. حتی با آیکون سروری، Next.js علاوه بر HTML یه flight data هم میفرسته که همون
d="m6.35..."رو تکرار میکنه. اگه تو HTML دنبال<pathبگردی دوبار میبینی. طبیعیه — مهم اینه که تو/_next/static/chunks/*.jsنباشه. -
داستان
useId. آیکون GeoIcons ازuseIdبرایclipPathبا id یکتا استفاده میکنه. تو Server Component (React 19) کار میکنه، ولی اگه همون آیکون رو داخل یک Client Component رندر کنی، id سرور و کلاینت mismatch میخوره و hydration warning میگیری. قانون ساده: آیکون سروری رو فقط داخل Server Component رندر کن، یاidرو دستی پاس بده.
میارزه؟ اعداد رو ببین
تست روی Next.js 15.1، صفحهی /pricing با ۶ آیکون، gzip -9:
| سناریو | First Load JS (gz) | JS اضافهی آیکون | HTML اضافه | TTI روی 3G | |---|---|---|---|---| | بدون آیکون | 87.5 kB | — | — | 1.9s | | ۶ تا lucide-react (barrel) | 131.8 kB | +44.3 kB | ~2 kB | 2.18s | | ۶ تا lucide (direct import) | 102.1 kB | +14.6 kB | ~2 kB | 2.02s | | ۶ تا GeoIcons (سروری) | 87.9 kB | +0.4 kB | +8.1 kB | 1.91s | | ۶ تا SVG خام (کپی path) | 87.6 kB | +0.1 kB | +8.0 kB | 1.90s |
به زبون خودمونی: barrel حدود ۲۸۰ میلیثانیه روی 3G بهت تاخیر میده. طبق دیتای Vercel هر ۱۰۰ میلیثانیه تاخیر ≈ یک درصد افت conversion تو فروشگاه اینترنتی. اون ۲۸۰ms یعنی ۲-۳٪ فروش کمتر — برای یه فروشگاه ۵۰۰ میلیونی، ماهی ۱۰-۱۵ میلیون تومن.
کی چی استفاده کن؟
| شرایط | چی بزن | چرا |
|---|---|---|
| آیکون ثابت (pricing، فوتر، لیست ویژگی) | SVG سروری / GeoIcons | صفر JS، سریعترین |
| آیکون تعاملی (دکمه لایک با انیمیشن) | یه Client Component جدا فقط برای همون آیکون | بقیه صفحه سروری میمونه |
| ۵۰+ آیکون تو یک صفحه | SVG sprite یا icon font | وگرنه HTML باد میکنه |
| theming داینامیک (dark/light) | GeoIcons + IconProvider تو layout | provider کلاینته ولی آیکونها نه — بهترین هر دو |
چکلیست: ۶ قدم تا ship امن
- [ ] تست مسمومیت اجرا شده:
grep -rl "use client" node_modules/<lib>— اگر همهی آیکونها match خوردند، آن کتابخانه برای صفحات سروری کنار گذاشته میشود - [ ] آیکون سروری نصب شده (
pnpm add @geoicons/react) یا SVG خام درcomponents/icons/*.tsxکپی شده، بدون'use client' - [ ] مطمئن شدی
app/pricing/page.tsxخودش'use client'ندارد و آیکونها مستقیم import شدهاند - [ ]
IconProviderدرapp/layout.tsxایزوله شده، نه در همان فایلی که آیکونها هستند - [ ] بیلد چک شده:
pnpm build && grep -r "lucide\|react-icons" .next/static/chunks/باید خالی برگردد - [ ] در CI حجم
du -sh .next/static/chunks/*.js | sort -hپایش میشود و PRهایی که First Load JS را بیش از ۵ کیلوبایت بزرگ کنندهاند block میشوند
کی این راه حل رو نزن
- وقتی آیکون واقعاً تعاملیه. اگه روی hover مورف میکنه، با
framer-motionانیمیت میشه، یا state داخلی داره، باید Client Component باشه. زور نزن سروری نگهش داری — فقط hydration mismatch میگیری. یهcomponents/icons/AnimatedCheck.tsxبا'use client'جدا بساز. - وقتی ۲۰۰+ آیکون تو یک صفحه داری (مثلاً گالری). ۲۰۰ تا SVG با path طولانی HTML رو ۳۰۰kB میکنه و parse روی موبایل کنده. اینجا sprite sheet بهصرفهتره.
- وقتی کتابخونه با CSS-in-JS استایل تزریق میکنه (مثل
phosphor-react) — بدون JS خراب میشه. ذاتاً clientـه — یا lib رو عوض کن یا هزینهش رو بپذیر.
سوالات پرتکرار
Q: از کجا بفهمم یک کتابخانه آیکون برای Server Component امن است؟
توزیعش را برای 'use client' در اول فایل grep کن: grep -rl "^['\"]use client['\"]" node_modules/<pkg>. اگر فایل همهی آیکونهای یک کتابخانه match بخورد، هر صفحهای که از آن import کند کلاینتی میشود.
Q: میتوانم lucide-react را روی Server Component نگه دارم؟
بله، به شرطی که ماژول مشخصی که import میکنی خودش 'use client' نداشته باشد. بهجای barrel، فایل همان آیکون را import کن؛ و اگر آن فایل هم علامت خورده بود، مسیر SVG را در کامپوننت خودت کپی کن.
Q: چرا مسیر SVG دو بار در HTML صفحه دیده میشود؟
چون Next.js علاوه بر HTML، RSC payload را هم میفرستد و همان دادهی مسیر در آن تکرار میشود. مهم این است که کد آیکون در /_next/static/chunks/*.js نباشد، چون آن است که کاربر بهصورت جاوااسکریپت دانلود میکند.
لینکهای داخلی: کالبدشکافی کامل boundary تو /tech/rsc-poisoning-use-client-boundary-tech. نمونهکارها تو /projects و برای مشاورهی performance از /about پیام بده.