این روزها دارم آیکونهای یک پروژهی ریاکتی را از فونت آیکون به SVG میبرم. آیکونها از سیستم دیزاین میآیند و هر کدام بهجای یک کامپوننت جدا، یک تکه داده است: فهرستی از تگها و attributeها که کامپوننت Icon در زمان اجرا به عناصر SVG تبدیلش میکند.
کد که آماده شد، اولین نگرانیام این بود که این عناصر در هر رندر از نو ساخته میشوند و کامپوننت هم memo نشده. کند نیست؟ جوابش را نمیدانستم، پس بهجای حدس زدن اندازه گرفتم. نتیجه این شد که memo راهحل خوبی نیست، ولی با حدود ده خط کد، رندر مجدد هزار آیکون از ۳.۶ میلیثانیه به حدود ۱ میلیثانیه رسید؛ حتی سریعتر از SVG دستنویس.
آیکون بهشکل داده
هر آیکون آرایهای از گرههاست و هر گره یک تاپل [tag, attrs, children?]:
type IconNode = readonly [tag: string, attrs: Record<string, string>, children?: readonly IconNode[]];
export const receiptIcon = {
name: "receipt",
nodes: [
["path", { d: "M6 3h12v18l-2-1.5-2 1.5-2-1.5-2 1.5-2-1.5L6 21z" }],
["g", { "data-part": "check" }, [["path", { d: "m9 8 2 2 4-4" }]]],
// ...
],
} as const;
کامپوننت Icon این گرهها را با createElement به element تبدیل میکند:
function renderIconNodes(nodes: readonly IconNode[]): ReactNode[] {
return nodes.map(([tag, attrs, children], key) =>
createElement(tag, { key, ...attrs }, ...(children ? renderIconNodes(children) : []))
);
}
export function Icon({ icon, size = 24, ...props }: IconProps) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} {...props}>
{renderIconNodes(icon.nodes)}
</svg>
);
}
هر آیکون یک ماژول جداست، پس فقط آیکونهایی که یک صفحه import میکند در bundle همان صفحه میآیند. گروههایی که data-part دارند را هم میشود جدا رنگ یا انیمیت کرد؛ نسخهی کامل Icon برای همین یک prop به اسم parts دارد که به هر قسمت className یا style خودش را میدهد. Lucide هم آیکونهایش را به همین شکل داده نگه میدارد و موقع رندر از رویش element میسازد.
اندازه گرفتن
صحنهی آزمایش ساده بود: هزار آیکون در یک لیست که والدشان دوباره رندر میشود، بیآنکه propهای خود آیکونها عوض شوند. در صفحهی واقعی این اتفاق مدام میافتد؛ یک state بالای لیست عوض میشود و همهی فرزندها هم دوباره رندر میشوند. اندازهگیری روی build نسخهی production ریاکت ۱۹ و در jsdom انجام شد و عددها در چند اجرای پشتسرهم ثابت ماندند.
| نسخه | mount (میلیثانیه) | رندر مجدد (میلیثانیه) |
|---|---|---|
Icon فعلی، ساختن از داده | ۳۳ | ۳.۶ |
| SVG دستنویس، مثل خروجی SVGR | ۳۲ | ۱.۹ |
memo(Icon) با propهای ثابت | ۳۳ | ۰.۱۵ |
memo(Icon) با style={{…}} درونخطی | ۳۵ | ۳.۸ |
از همین جدول دو چیز معلوم است. اول اینکه mount در همهی حالتها تقریباً یکی است، چون بیشتر هزینهاش ساختن گرههای DOM است و فرقی نمیکند elementها از کجا آمده باشند. دوم اینکه نگرانی بیپایه نبود: ساختن از داده در هر رندر مجدد، برای هر آیکون حدود ۱.۷ میکروثانیه بیشتر از SVG دستنویس خرج دارد.
این عدد را باید در اندازهی واقعیاش دید. یک صفحهی معمولی بیست تا صد آیکون دارد و رندر مجدد صد آیکون در کل حدود ۰.۴ میلیثانیه طول میکشد، آن هم در بودجهی ۱۶ میلیثانیهای یک فریم. مشکل جدیای نیست. ولی اگر بشود با چند خط کد حذفش کرد، دلیلی برای نگه داشتنش نمیماند.
چرا memo نه
اولین فکر memo است و جدول هم نشان میدهد چرا وسوسهانگیز است: با propهای ثابت، رندر مجدد به ۰.۱۵ میلیثانیه میرسد. ردیف بعدی همان جدول اما تکلیف را روشن میکند. memo فقط وقتی کار میکند که همهی propها از یک رندر تا رندر بعد همان شیء قبلی باشند، و کد واقعی پر از style={{ marginInlineStart: 4 }} و onClick={() => open(id)} و parts={{ dots: { className } }} است که در هر رندر شیء تازه میسازند. آنوقت memo هم propها را مقایسه میکند و هم دوباره رندر میکند، و نتیجه ۳.۸ میلیثانیه است؛ از نسخهی بدون memo هم کندتر.
مشکل دیگری هم هست که در جدول دیده نمیشود. Icon جنریک است و نوع parts را از روی آیکونی که میگیرد میسازد؛ برای همین parts={{ handle: ... }} روی آیکونی که قسمت handle ندارد خطای TypeScript میدهد. memo این پارامتر جنریک را از بین میبرد و آن خطا دیگر گرفته نمیشود، مگر اینکه خروجیاش را دستی cast کنید.
جای درست memo معمولاً یک سطح بالاتر است: ردیف یا کارتی که واقعاً دوباره رندر میشود، نه آیکونهایی که برگهای درختاند.
رسم آیکون هیچوقت عوض نمیشود
چیزی که مسئله را حل کرد یک مشاهدهی ساده بود. از propهای Icon فقط امثال size و color و className روی خود <svg> اثر دارند. خطوط داخل آیکون، یعنی همان pathها و circleها، برای یک آیکون مشخص همیشه یکیاند. پس دلیلی ندارد در هر رندر از نو ساخته شوند.
ریاکت هم برای همین حالت میانبر دارد. موقع reconcile، اگر element یک فرزند دقیقاً همان شیئی باشد که در رندر قبل بود، propهایش هم همان شیء قبلی است؛ ریاکت از کل آن زیردرخت میگذرد و حتی propهایش را هم مقایسه نمیکند. پس اگر elementهای رسم هر آیکون را یک بار بسازیم و نگه داریم، در رندرهای بعد فقط خود <svg> بررسی میشود:
const drawings = new WeakMap<IconDefinition, ReactNode[]>();
export function iconDrawing(icon: IconDefinition): ReactNode[] {
let drawing = drawings.get(icon);
if (!drawing) {
drawing = renderIconNodes(icon.nodes);
drawings.set(icon, drawing);
}
return drawing;
}
export function Icon({ icon, size = 24, parts, ...props }: IconProps) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} {...props}>
{parts ? renderIconNodes(icon.nodes, parts) : iconDrawing(icon)}
</svg>
);
}
چند نکته دربارهی این کد:
- کلید
WeakMapخود شیء آیکون است. آیکونها ثابتهای ماژولاند، پس رسم هر آیکون فقط یک بار ساخته میشود و اگر آیکون روزی از حافظه پاک شود، رسمش هم همراهش میرود. - همان آرایه بین همهی نمونههای یک آیکون مشترک است. element در ریاکت شیء تغییرناپذیری است و رندر کردن یک element در چند جای درخت مجاز است.
- اگر
partsداده شود کش کنار میرود، چون propهای قسمتها ممکن است در هر رندر عوض شوند. در نسخهی کامل،renderIconNodespropهای هر قسمت را روی گروه همان قسمت میگذارد. - روی سرور هم مشکلی پیش نمیآید. کش بین درخواستها مشترک است، ولی چیزی جز elementهای تغییرناپذیر در آن نیست.
این همان کاری است که پلاگین @babel/plugin-transform-react-constant-elements با JSXهای ثابت میکند، و React Compiler هم چیزی شبیه به آن را خودکار انجام میدهد. این پروژه React Compiler ندارد، برای همین دستی نوشتمش.
نتیجه
| نسخه | mount (میلیثانیه) | رندر مجدد (میلیثانیه) |
|---|---|---|
Icon قبلی | ۳۳ | ۳.۶ |
| SVG دستنویس | ۳۲ | ۱.۹ |
Icon با رسم کششده | ۳۲ | ۰.۹ تا ۱.۰ |
رندر مجدد حدود ۳.۶ برابر سریعتر شد و mount تغییری نکرد. propهای درونخطی هم این بار فرقی نمیکنند، چون فقط <svg> بیرونی بررسی میشود و بقیهی درخت دستنخورده میماند.
برای من جالبترین ردیف، مقایسه با SVG دستنویس بود. کامپوننتی که SVGR تولید میکند هم در هر رندر همهی pathهایش را با JSX از نو میسازد و ریاکت باید propهای تکتکشان را مقایسه کند. یعنی آیکونِ دادهمحور با کش از آیکونِ دستنویس هم سریعتر است.
مطمئن شدن از اینکه کش واقعاً استفاده میشود
بهینهسازیای که تستی پشتش نباشد، با اولین refactor بیصدا از بین میرود. تست اول ساده است: iconDrawing برای یک آیکون همیشه باید همان آرایه را برگرداند. ولی میخواستم مطمئن شوم خود Icon هم در رندر مجدد از همان آرایه استفاده میکند. برای همین Icon را داخل یک کامپوننت کوچک مثل تابع صدا زدم تا element برگشتیاش را ببینم:
test("re-renders new root props with the same drawing elements", () => {
const returned: ReactElement<{ children: [ReactNode, ReactNode] }>[] = [];
function Spy(props: IconProps) {
const element = Icon(props);
returned.push(element);
return element;
}
const { rerender } = render(<Spy icon={receiptIcon} color="red" />);
rerender(<Spy icon={receiptIcon} color="blue" size={32} />);
const before = returned[0].props.children[1];
const after = returned[returned.length - 1].props.children[1];
expect(after).toBe(before);
expect(after).toBe(iconDrawing(receiptIcon));
});
بعد کش را موقتاً برداشتم تا ببینم این تست شکست میخورد یا نه، و خورد. تستی که هیچوقت قرمز نشده باشد، معلوم نیست واقعاً چیزی را میسنجد.
خودتان امتحان کنید
یک نسخهی کوچک از همین سه کامپوننت را در صفحهی آزمایش کش کردن رسم آیکون گذاشتهام. دکمهی اندازهگیری هر سه نسخه را mount میکند، والدشان را سی بار دوباره رندر میکند و زمانها را در جدول مینویسد. تیک style درونخطی را بزنید تا ببینید memo چطور به سرعت نسخهی بدون کش برمیگردد.
روی سیستم خودم، با Chrome و build نسخهی production سایت و هزار آیکون، این عددها را گرفتم:
| نسخه | رندر مجدد (میلیثانیه) |
|---|---|
fresh، ساختن در هر رندر | ۲.۶ |
cached | ۱.۳ |
memo با propهای ثابت | ۰.۲۵ |
memo با style درونخطی | ۲.۷ |
فاصلهی اینجا حدود دو برابر است، نه ۳.۶ برابر. آیکون نمونه کوچکتر است، کامپوننتش سادهتر است و مرورگر هم با jsdom فرق دارد. ولی جهت همان است: کش در هر حالتی کمک میکند و memo فقط وقتی که propها ثابت بمانند.
جمعبندی
کل تغییر حدود ده خط بود و API کامپوننت دست نخورد. چیزی که از این کار برایم ماند بیشتر از خود عددهاست: نگرانی «این که memo نشده» را باید اندازه گرفت، چون memo روی برگهای درخت یا کاری نمیکند یا اوضاع را بدتر میکند. وقتی بخشی از خروجی یک کامپوننت هیچوقت عوض نمیشود، نگه داشتن همان elementها ارزانترین راه برای این است که ریاکت اصلاً سراغش نرود.