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

← آزمایش‌ها

کش کردن رسم آیکون

§ کش کردن رسم آیکون

سه نسخه از یک کامپوننت آیکون که از داده ساخته می‌شود: یکی عناصر SVG را در هر رندر از نو می‌سازد، یکی آن‌ها را یک بار می‌سازد و نگه می‌دارد، و یکی با memo پیچیده شده. با دکمه‌ی اندازه‌گیری، هر سه mount می‌شوند و والدشان سی بار دوباره رندر می‌شود؛ زمان‌ها در جدول خروجی می‌آیند.

پیش‌نمایش

تعداد آیکون:

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

خروجی

هنوز چیزی ثبت نشده.

کد

import { createElement, memo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { flushSync } from "react-dom";
import { Button } from "@/components/ui/button.tsx";
import { Checkbox } from "@/components/ui/checkbox.tsx";
import { Typography } from "@/components/ui/typography.tsx";
import { useLogger } from "@/components/pages/tests/experiment.tsx";

// An icon as data, the shape a design-system export ships: [tag, attrs, children?].
type IconNode = readonly [tag: string, attrs: Record<string, string>, children?: readonly IconNode[]];

// A receipt with a check mark, two lines and two dots: about the size of a real icon.
const RECEIPT: readonly IconNode[] = [
  ["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" }]]],
  ["path", { d: "M9 13h6" }],
  ["path", { d: "M9 16h3" }],
  [
    "g",
    { "data-part": "dots", fill: "currentColor", stroke: "none" },
    [
      ["circle", { cx: "13", cy: "16", r: ".8" }],
      ["circle", { cx: "15", cy: "16", r: ".8" }],
    ],
  ],
];

function renderNodes(nodes: readonly IconNode[]): ReactNode[] {
  return nodes.map(([tag, attrs, children], key) =>
    createElement(tag, { key, ...attrs }, ...(children ? renderNodes(children) : []))
  );
}

type IconProps = { icon: readonly IconNode[]; size?: number; style?: CSSProperties };

function Svg({ size = 14, style, children }: Omit<IconProps, "icon"> & { children: ReactNode }) {
  return (
    <svg
      viewBox="0 0 24 24"
      width={size}
      height={size}
      fill="none"
      stroke="currentColor"
      strokeWidth={1.5}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
      style={style}
    >
      {children}
    </svg>
  );
}

// 1. Builds the drawing's elements again on every render.
function FreshIcon({ icon, ...props }: IconProps) {
  return <Svg {...props}>{renderNodes(icon)}</Svg>;
}

// 2. Builds them once per icon and hands React the same elements on every render,
//    so React skips the drawing and only checks the <svg> around it.
const drawings = new WeakMap<readonly IconNode[], ReactNode[]>();
function drawing(icon: readonly IconNode[]) {
  let elements = drawings.get(icon);
  if (!elements) drawings.set(icon, (elements = renderNodes(icon)));
  return elements;
}
function CachedIcon({ icon, ...props }: IconProps) {
  return <Svg {...props}>{drawing(icon)}</Svg>;
}

// 3. Skips the whole icon, but only while every prop keeps its identity.
const MemoIcon = memo(FreshIcon);

const VARIANTS = { fresh: FreshIcon, cached: CachedIcon, memo: MemoIcon };
type Variant = keyof typeof VARIANTS;

const COUNTS = [500, 1000, 2000];
const RERENDERS = 30;

function IconGrid({ variant, count, inlineStyle }: { variant: Variant; count: number; inlineStyle: boolean }) {
  const Icon = VARIANTS[variant];
  return (
    <div className="flex flex-wrap gap-1 h-32 overflow-hidden text-subText" dir="ltr">
      {Array.from({ length: count }, (_, i) => (
        // With the checkbox on: a new style object on every render, as most call sites write it.
        <Icon key={i} icon={RECEIPT} style={inlineStyle ? { opacity: 1 } : undefined} />
      ))}
    </div>
  );
}

const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve)));

export function IconDrawingCacheDemo() {
  const { logTable } = useLogger();
  const [count, setCount] = useState(1000);
  const [inlineStyle, setInlineStyle] = useState(false);
  const [grid, setGrid] = useState<{ variant: Variant; key: number } | null>(null);
  const [, setTick] = useState(0);
  const [running, setRunning] = useState(false);
  const mounts = useRef(0);

  // Mounts a fresh grid of the variant, then re-renders its parent RERENDERS times.
  // The icons' props never change, so all the work is React deciding what to skip.
  async function time(variant: Variant) {
    await nextFrame();
    let start = performance.now();
    flushSync(() => setGrid({ variant, key: ++mounts.current }));
    const mount = performance.now() - start;

    // Timed together and averaged: one re-render is close to the timer's resolution.
    start = performance.now();
    for (let i = 0; i < RERENDERS; i++) flushSync(() => setTick((n) => n + 1));
    return { variant, mount, rerender: (performance.now() - start) / RERENDERS };
  }

  async function measure() {
    setRunning(true);
    const variants = Object.keys(VARIANTS) as Variant[];
    // One unmeasured pass first, so the first variant isn't timed while the JIT is still cold.
    for (const variant of variants) await time(variant);
    const results = [];
    for (const variant of variants) results.push(await time(variant));

    const fresh = results[0].rerender;
    logTable(
      results.map(({ variant, mount, rerender }) => ({
        variant,
        icons: String(count),
        "mount ms": mount.toFixed(1),
        "re-render ms": rerender.toFixed(2),
        "per icon µs": ((rerender * 1000) / count).toFixed(2),
        "vs fresh": `${(fresh / rerender).toFixed(1)}x`,
      })),
      `${count}-${inlineStyle ? "inline" : "stable"}`
    );
    setRunning(false);
  }

  return (
    <div className="flex flex-col gap-5">
      <div className="flex flex-wrap items-center gap-3">
        <span className="text-[.85rem] text-subText">تعداد آیکون:</span>
        {COUNTS.map((n) => (
          <Button
            key={n}
            variant={n === count ? "primary" : "secondary"}
            onClick={() => setCount(n)}
            disabled={running}
            className="font-mono"
          >
            {n}
          </Button>
        ))}
      </div>

      <Checkbox
        label="به هر آیکون یک style درون‌خطی هم بده (در هر رندر یک شیء تازه)"
        checked={inlineStyle}
        onChange={(e) => setInlineStyle(e.target.checked)}
        disabled={running}
      />

      <div className="flex flex-col gap-2">
        <Button onClick={measure} disabled={running} className="self-start">
          {running ? "در حال اندازه‌گیری…" : "اندازه‌گیری هر سه نسخه"}
        </Button>
        <Typography variant="faint" className="max-w-[var(--measure)]">
          زمان‌ها در مرورگر خودتان گرفته می‌شوند. در حالت توسعه، ری‌اکت کندتر است و StrictMode هر رندر را دو بار اجرا
          می‌کند؛ پس به نسبت‌ها نگاه کنید، نه به عددها.
        </Typography>
      </div>

      {grid && <IconGrid key={grid.key} variant={grid.variant} count={count} inlineStyle={inlineStyle} />}
    </div>
  );
}