Amazonで商品を見るセール会場へ

Figmaプラグイン│大量のレイヤーをまとめて処理する方法│コンポーネント情報出力

Figmaプラグインでコンポーネント情報をCSVに出力するときは、ノード数や階層の深さに応じて処理の分割が必要になります。

しかし、数万〜数十万規模になると処理が固まったり、UI が極端に重くなったりすることがあります。

この記事では「処理を分割しながらCSVを書き出すFigmaプラグイン」の実装方法を紹介します。

Figmaファイル内にあるすべてのコンポーネント情報を重複なく収集して、CSVに書き出すプラグインです。

Figmaファイル内のすべてのコンポーネント情報を重複なく収集してCSVに書き出すプラグインの概要。

コンポーネントおよびコンポーネントセット(バリアントがあるものはそれぞれ書き出す)を出力対象にしています。

バリアントを含むコンポーネントおよびコンポーネントセットを出力対象としている様子。
目次

大量のレイヤーを処理するための課題

全レイヤーを愚直に走査して配列に詰め、一度に UI へ渡すと、次の問題が発生します。

  • メインスレッドが長時間ブロックされて Figma が固まる
  • UI 側に逐次ログを出しすぎるとブラウザ描画がボトルネックになる
  • CSV 文字列をそのまま連結し続けると、メモリ使用量が急増する

これら3つの課題を解決するために、次の工夫を取り入れました。

大量のレイヤーを安定して処理するプラグインの設計

完成したプラグインの動作の流れは以下のとおりです。

  1. 「開始」を押すと処理が走り、UI に最新のレイヤー名が表示される
  2. 処理の進捗を表示し、途中で「中止」も選べる
  3. 最後に「CSV エクスポート」を押すと BOM 付き UTF-8 の CSV が保存される

処理を分割しても、使えるメモリやドキュメントの構成によって扱える規模は変わります。

具体的には以下の3つの工夫をしています。

非同期バッチ処理

大きな配列を一気に処理せず、バッチ単位に分割してループの末尾で await sleep(0)を実行します。

これにより、イベントループに制御が戻り、UI の再描画やメッセージ処理を行えます。

処理中のUIは「最新1行のみ」表示

ログをすべて追加していくと DOM が肥大化して描画が重くなります。

最新の処理対象だけを更新表示することで、負荷を抑えながら進行状況を視覚化できます。

CSVはチャンク配列で構築

巨大な文字列を直接連結し続けると、メモリ消費が大きくなります。

小さな文字列ブロック(チャンク)を配列に pushしていき、最後にまとめてファイル化します。

実際のプラグイン│コンポーネントおよびコンポーネントセットの情報を出力

Figmaファイル内にあるすべてのコンポーネント情報を重複なく収集して、CSVに書き出すプラグインの実装です。

コンポーネントおよびコンポーネントセットの情報を出力する実際のプラグイン画面。

Figmaのほかの操作方法は、まとめ記事から確認できます。

manifest.json

{
  "name": "ファイル内の全コンポーネント情報を出力",
  "id": "prefix-scanner",
  "api": "1.0.0",
  "main": "code.js",
  "ui": "ui.html",
  "editorType": ["figma"]
}

code.js

figma.showUI(__html__, { visible: true, width: 440, height: 220 });

let isCancelled = false;
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));

// レイヤーパス
function getLayerPath(node) {
  const path = [];
  let cur = node;
  while (cur && cur.type !== "PAGE" && cur.type !== "DOCUMENT") {
    path.unshift(cur.name);
    cur = cur.parent;
  }
  return path.join(" / ");
}

// UI → 本体
figma.ui.onmessage = (msg) => {
  if (!msg || typeof msg !== "object") return;
  if (msg.type === "start") {
    isCancelled = false;
    run();
  }
  if (msg.type === "cancel") {
    isCancelled = true;
  }
};

// メイン処理
async function run() {
  const t0 = Date.now();
  let produced = 0;

  const report = (phase, current, total) => {
    figma.ui.postMessage({
      type: "progress",
      phase,
      current,
      total,
      produced,
      elapsedMs: Date.now() - t0,
    });
  };

  const exclude = new Set();

  // 1) COMPONENT_SET を先に
  const sets = figma.root.findAll((n) => n.type === "COMPONENT_SET");
  const SET_BATCH = 16; // 環境に合わせて調整
  for (let i = 0; i < sets.length; i += SET_BATCH) {
    if (isCancelled) break;
    const batch = sets.slice(i, i + SET_BATCH);

    const rows = [];
    for (const setNode of batch) {
      const children = Array.isArray(setNode.children) ? setNode.children : [];
      for (const child of children) {
        rows.push({
          parentName: setNode.name,
          parentId: setNode.id,
          variantName: child.name,
          variantId: child.id,
          path: getLayerPath(child),
        });
        exclude.add(child.id);
      }
    }
    if (rows.length) {
      produced += rows.length;
      figma.ui.postMessage({ type: "append", rows });
    }
    report("COMPONENT_SET", Math.min(i + SET_BATCH, sets.length), sets.length);
    await sleep(0);
  }

  // 2) 孤立 COMPONENT(セット配下でないもの)
  const comps = figma.root.findAll((n) => n.type === "COMPONENT");
  const COMP_BATCH = 64;
  for (let i = 0; i < comps.length; i += COMP_BATCH) {
    if (isCancelled) break;
    const batch = comps.slice(i, i + COMP_BATCH);

    const rows = [];
    for (const comp of batch) {
      if (exclude.has(comp.id)) continue;
      rows.push({
        parentName: comp.name,
        parentId: comp.id,
        variantName: "",
        variantId: "",
        path: getLayerPath(comp),
      });
    }
    if (rows.length) {
      produced += rows.length;
      figma.ui.postMessage({ type: "append", rows });
    }
    report("COMPONENT", Math.min(i + COMP_BATCH, comps.length), comps.length);
    await sleep(0);
  }

  figma.ui.postMessage({
    type: "done",
    cancelled: isCancelled,
    produced,
    elapsedMs: Date.now() - t0,
  });
}

ui.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <style>
      body {
        font-family: system-ui, sans-serif;
        padding: 12px;
        font-size: 13px;
      }
      .row {
        display: flex;
        gap: 8px;
        align-items: center;
        margin-bottom: 8px;
      }
      .mono {
        font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
        white-space: pre;
      }
      #now {
        background: #f6f6f6;
        padding: 8px;
        border-radius: 6px;
        min-height: 1.5em;
      }
      button {
        padding: 6px 10px;
        border-radius: 6px;
        border: 1px solid #ccc;
        background: #fff;
        cursor: pointer;
      }
      #exportBtn[disabled] {
        opacity: 0.5;
        cursor: not-allowed;
      }
    </style>
  </head>
  <body>
    <div class="row">
      <button id="startBtn">開始</button>
      <button id="cancelBtn">中止</button>
      <button id="exportBtn" disabled>CSVエクスポート</button>
    </div>

    <div id="progress" class="mono">待機中</div>
    <div id="now" class="mono"></div>

    <script>
      // ==== CSV:巨大データでも軽いチャンク方式 ====
      const toCsvCell = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`;
      const toCsvIdCell = (v) =>
        `"'\u200B${String(v ?? "").replace(/"/g, '""')}"`;
      const headers = [
        "親コンポ名",
        "親ノードID",
        "バリアント名",
        "バリアントID",
        "レイヤーパス",
      ];

      const csvChunks = []; // Blobパーツ
      let rowsCount = 0;

      function resetCsv() {
        csvChunks.length = 0;
        rowsCount = 0;
        const headerLine = headers.map(toCsvCell).join(",") + "\n";
        csvChunks.push("\uFEFF" + headerLine); // BOM + header
      }
      function pushCsvRows(objs) {
        let buf = "";
        for (const r of objs) {
          buf +=
            [
              toCsvCell(r.parentName),
              toCsvIdCell(r.parentId),
              toCsvCell(r.variantName),
              toCsvIdCell(r.variantId),
              toCsvCell(r.path),
            ].join(",") + "\n";
        }
        csvChunks.push(buf);
        rowsCount += objs.length;
      }
      function saveCsv() {
        const blob = new Blob(csvChunks, { type: "text/csv;charset=utf-8;" });
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = `components_${new Date()
          .toISOString()
          .slice(0, 19)
          .replace(/[:T]/g, "-")}.csv`;
        document.body.appendChild(a);
        a.click();
        a.remove();
        URL.revokeObjectURL(url);
      }

      // ==== UI要素 ====
      const progEl = document.getElementById("progress");
      const nowEl = document.getElementById("now");
      const exportBtn = document.getElementById("exportBtn");

      // 本体 → UI
      onmessage = (event) => {
        const msg = event.data && event.data.pluginMessage;
        if (!msg) return;

        if (msg.type === "progress") {
          const { phase, current, total, produced, elapsedMs } = msg;
          const rps = elapsedMs
            ? (produced / (elapsedMs / 1000)).toFixed(1)
            : "-";
          progEl.textContent = `フェーズ:${phase} 進捗:${current}/${total} 出力:${produced}件 速度:${rps}/s 経過:${human(
            elapsedMs
          )}`;
        }

        if (msg.type === "append") {
          pushCsvRows(msg.rows);
          exportBtn.disabled = rowsCount === 0;

          // 最新1行だけ上書き表示
          const last = msg.rows[msg.rows.length - 1];
          nowEl.textContent = `${last.variantName || last.parentName}  —  ${
            last.path
          }`;
        }

        if (msg.type === "done") {
          nowEl.textContent = msg.cancelled
            ? `中止。累計${rowsCount}行 / 経過${human(msg.elapsedMs)}`
            : `完了。累計${rowsCount}行 / 経過${human(
                msg.elapsedMs
              )}(CSVエクスポート可)`;
          exportBtn.disabled = rowsCount === 0;
        }
      };

      function human(ms) {
        const s = Math.floor(ms / 1000);
        const m = Math.floor(s / 60);
        const h = Math.floor(m / 60);
        const sec = s % 60;
        const min = m % 60;
        return (h ? h + "h " : "") + (m ? min + "m " : "") + sec + "s";
      }

      // ボタン
      document.getElementById("startBtn").onclick = () => {
        resetCsv();
        nowEl.textContent = "処理を開始しました…";
        exportBtn.disabled = true;
        parent.postMessage({ pluginMessage: { type: "start" } }, "*");
      };
      document.getElementById("cancelBtn").onclick = () => {
        parent.postMessage({ pluginMessage: { type: "cancel" } }, "*");
      };
      document.getElementById("exportBtn").onclick = () => saveCsv();
    </script>
  </body>
</html>

出力される CSV の例は以下のとおりです。

プラグインによって出力されるCSVファイルの例。

Figmaプラグインの開発に関する記事は、ほかにもあります。

まとめ

大量のレイヤーを処理するプラグインを制作する際は、「ユーザーに処理の進行を伝えつつ、Figma の UI をフリーズさせない」設計が重要です。

  • 非同期バッチ処理でフリーズを防ぐ
  • UI は必要最小限の情報だけを表示して描画を軽量化する
  • CSV はチャンク配列で効率的に構築する

元の開発時には20万レイヤーでの出力を確認しました。

同じ件数でも階層や実行環境によって結果は変わるため、実際のファイルで処理時間とメモリ使用量を確認してください。

サービス

Service

デザイン制作に関心がありましたら、ぜひ詳細をご覧ください。

次に学ぶ・作業環境を選ぶ

学習を続けたい方や、作業環境を整えたい方は、目的に合うガイドをご覧ください。

Figmaのおすすめ書籍

Figma用PCの選び方

周辺機器の優先度

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次