برنتی / می‌نویسم که فکر کنم

هزار آیکون در یک میلی‌ثانیه: کش کردن رسم SVG به‌جای memo

1405.07.18·دنیای جاوااسکریپت

این روزها دارم آیکون‌های یک پروژه‌ی ری‌اکتی را از فونت آیکون به 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های قسمت‌ها ممکن است در هر رندر عوض شوند. در نسخه‌ی کامل، renderIconNodes propهای هر قسمت را روی گروه همان قسمت می‌گذارد.
  • روی سرور هم مشکلی پیش نمی‌آید. کش بین درخواست‌ها مشترک است، ولی چیزی جز 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ها ارزان‌ترین راه برای این است که ری‌اکت اصلاً سراغش نرود.

§ فهرست « دنیای جاوااسکریپت »
  1. 1405.07.18هزار آیکون در یک میلی‌ثانیه: کش کردن رسم SVG به‌جای memoکامپوننت آیکونی که SVG را از داده می‌سازد در هر رندر کمی کار اضافه می‌کند. اندازه گرفتم memo کجا کمک می‌کند و کجا نه، و با نگه داشتن عناصر رسم هر آیکون، رندر مجدد هزار آیکون را از ۳.۶ به حدود ۱ میلی‌ثانیه رساندم.
  2. 1405.07.04ساختن هوکی شبیه useSyncExternalStore از صفربا useState و useEffect قدم‌به‌قدم هوکی می‌سازم که به یک store بیرونی subscribe شود، باگ هر نسخه را نشان می‌دهم و آخر کار توضیح می‌دهم چرا نسخه‌ی خودِ ری‌اکت کاری می‌کند که از کد کاربر برنمی‌آید.
  3. 1405.06.29مشکل Tearing در ری‌اکت چیست؟تیرینگ زمانی رخ می‌دهد که یک مقدار بیرون از چرخه‌ی رندر ری‌اکت حین رندر تغییر کند و کامپوننت‌های مختلف در یک فریم، دو مقدار متفاوت از همان داده را نشان بدهند.
  4. 1405.06.29چطور در دیجیکالا با useSyncExternalStore جلوی رندرهای اضافه را گرفتیموقتی داده‌ی ویجت‌های یک لیست با تأخیر و به‌صورت پلکانی می‌رسد، نگه‌داشتن آن در یک state یا Context مشترک باعث می‌شود کل لیست با هر آپدیت دوباره رندر شود؛ useSyncExternalStore به‌همراه React.memo راه می‌دهد فقط همان ویجتی که داده‌اش عوض شده رندر شود.
  5. 1399.06.03دنیای جاوااسکریپت - مقدمهآموزش ساده و روان اصول جاوااسکریپت با مدل ذهنی Dan Abramov
  6. 1399.06.01دنیای جاوااسکریپت ۵ - Prototypesمفهوم ساده‌ی Prototypes یا نمونه اولیه در جاوااسکریپت
  7. 1399.05.31دنیای جاوااسکریپت ۴ - mutate یا تغییر دادن object"مثال‌هایی از تغییر در object
  8. 1399.05.31دنیای جاوااسکریپت ۳ - انواع روش‌های مقایسه برابریانواع روش‌های مقایسه در جاوااسکریپت Object.is
  9. 1399.05.31دنیای جاوااسکریپت ۲ - متغیرها در جاوااسکریپتمتغیرها یا Variables در جاوااسکریپت و تفاوت آنها با مقادیر یا Values
  10. 1399.05.24دنیای جاوااسکریپت ۱ - انواع مقدارها یا Valuesجاوااسکریپت شامل ۹ نوع از مقادیر هست که ۷ مورد آن از نوع Primitive و یا اولیه و اصلی و ۲ مورد دیگر که از نوع Object و Function می‌باشند.