ファイル内のコンポーネントをCSVへ出力する場合、出力処理だけでなく、検索自体の負荷も確認する必要があります。この例ではページを順に読み込み、レイヤーを200件単位で走査してUIへ進行状況を送ります。コンポーネントセット内のバリアントも1行ずつ出力します。
出力する列と処理範囲
| 列 | 内容 |
|---|---|
| 親コンポ名・親ノードID | セット内ならセット、それ以外はコンポーネント自身 |
| バリアント名・バリアントID | コンポーネントセット直下のコンポーネントの場合に記録 |
| レイヤーパス | ページ内の親から対象までの名前の並び |
対象は実際のCOMPONENTノードです。インスタンスの参照元ライブラリ全体を収集するものではありません。CSVを中止後に保存した場合は、その時点までの部分的な結果です。
全件検索と処理分割の違い
findAllで全件を先に収集すると、その検索中は後段のバッチ化だけでは負荷を抑えられません。掲載例はページごとにloadAsyncし、スタックで子要素をたどります。200件ごとにイベントループへ制御を返して中止メッセージを処理します。
ページ読み込み自体の待ち時間や、読み込んだページのメモリが消えることまでは保証しません。CSVもUI側のチャンク配列に保持するため、メモリ使用量は出力件数とともに増えます。処理の分割は、無制限に扱えるという意味ではありません。
出典:PageNodeのloadAsync、全ページ読み込みの注意点。
ファイルを作成する
FigmaのNew pluginで生成したmanifestを基に、次の設定を使います。掲載例ではidを省略しています。自分のmanifestに割り当てられたidがある場合はその値を保ち、サンプルの任意文字列に置き換えません。3ファイルを同じフォルダへ保存してください。
manifest.json
{
"name": "コンポーネントCSV書き出し",
"api": "1.0.0",
"main": "code.js",
"ui": "ui.html",
"editorType": [
"figma"
],
"documentAccess": "dynamic-page",
"networkAccess": {
"allowedDomains": [
"none"
]
}
}code.js
figma.showUI(__html__, { width: 440, height: 260 });
let busy = false, cancelled = false;
const yieldTask = () => new Promise(resolve => setTimeout(resolve, 0));
function getLayerPath(node) {
const names = []; let n = node;
while (n && n.type !== "PAGE" && n.type !== "DOCUMENT") { names.unshift(n.name); n = n.parent; }
return names.join(" / ");
}
figma.ui.onmessage = async msg => {
if (!msg) return;
if (msg.type === "cancel") { cancelled = true; return; }
if (msg.type !== "start" || busy) return;
busy = true; cancelled = false;
figma.ui.postMessage({ type: "reset" });
let visited = 0, produced = 0;
const seen = new Set();
try {
const pages = Array.from(figma.root.children);
for (let pi = 0; pi < pages.length && !cancelled; pi++) {
const page = pages[pi];
figma.ui.postMessage({ type: "progress", page: page.name, pages: pages.length, currentPage: pi+1, visited, produced });
await page.loadAsync();
const stack = Array.from(page.children).reverse();
while (stack.length && !cancelled) {
const rows = [];
for (let batch = 0; batch < 200 && stack.length && !cancelled; batch++) {
const node = stack.pop();
if (node.removed) continue;
visited++;
if (node.type === "COMPONENT" && !seen.has(node.id)) {
seen.add(node.id);
const parent = node.parent;
const inSet = parent && parent.type === "COMPONENT_SET";
rows.push({ parentName: inSet ? parent.name : node.name, parentId: inSet ? parent.id : node.id,
variantName: inSet ? node.name : "", variantId: inSet ? node.id : "", path: getLayerPath(node) });
}
if ("children" in node)
for (let i = node.children.length-1; i >= 0; i--) stack.push(node.children[i]);
}
if (rows.length) { produced += rows.length; figma.ui.postMessage({ type: "append", rows }); }
figma.ui.postMessage({ type: "progress", page: page.name, pages: pages.length, currentPage: pi+1, visited, produced });
await yieldTask();
}
}
figma.ui.postMessage({ type: "done", cancelled, produced });
} catch (_) { figma.ui.postMessage({ type: "error", message: "走査に失敗しました。途中のCSVは全件ではありません。" }); }
finally { busy = false; }
};ui.html
<!doctype html><html lang="ja"><meta charset="utf-8">
<button id="start">開始</button><button id="cancel" disabled>中止</button><button id="save" disabled>CSV保存</button>
<p id="status">待機中</p><p id="latest"></p>
<script>
const start = document.getElementById("start"), cancel = document.getElementById("cancel"), save = document.getElementById("save");
const status = document.getElementById("status"), latest = document.getElementById("latest");
let chunks = [], count = 0;
function cell(value) {
let s = String(value == null ? "" : value);
if (/^[=+@\-\t\r\n]/.test(s)) s = "'" + s;
return '"' + s.replace(/"/g, '""') + '"';
}
function send(type) { parent.postMessage({ pluginMessage: { type } }, "*"); }
start.onclick = () => { start.disabled = true; cancel.disabled = false; save.disabled = true; status.textContent = "開始を要求…"; send("start"); };
cancel.onclick = () => { cancel.disabled = true; status.textContent = "中止を要求…"; send("cancel"); };
window.onmessage = event => {
if (event.source !== parent) return;
const msg = event.data && event.data.pluginMessage;
if (!msg) return;
if (msg.type === "reset") {
count = 0; chunks = ["\uFEFF" + ["親コンポ名","親ノードID","バリアント名","バリアントID","レイヤーパス"].map(cell).join(",") + "\r\n"];
latest.textContent = "";
} else if (msg.type === "append") {
chunks.push(msg.rows.map(r => [r.parentName,r.parentId,r.variantName,r.variantId,r.path].map(cell).join(",")).join("\r\n") + "\r\n");
count += msg.rows.length;
const last = msg.rows[msg.rows.length-1]; latest.textContent = last.path;
} else if (msg.type === "progress") status.textContent = `ページ${msg.currentPage}/${msg.pages} ${msg.page} / 走査${msg.visited}件 / 出力${msg.produced}行`;
else if (msg.type === "done" || msg.type === "error") {
start.disabled = false; cancel.disabled = true; save.disabled = !count;
status.textContent = msg.type === "error" ? msg.message : (msg.cancelled ? "中止(途中まで)" : "完了") + `:${count}行`;
}
};
save.onclick = () => {
const url = URL.createObjectURL(new Blob(chunks, { type: "text/csv;charset=utf-8" }));
const a = document.createElement("a"); a.href = url; a.download = "components.csv";
document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000);
};
</script></html>実行・中止・CSV確認
- 複製ファイルで起動し、開始を押します。
- 現在のページ、走査件数、出力行数を確認します。全レイヤー総数を先に求めないため、全体のパーセントは表示しません。
- 中止を押した場合は、処理が制御を返した時点で停止します。
- 完了または中止の表示後にCSVを保存します。
- BOM付きUTF-8として読み込み、ID列を文字列として扱います。
カンマ・引用符・改行をCSVとして処理し、式と解釈されやすい値には先頭にアポストロフィーを付けます。元のIDを変えていたゼロ幅文字の付与は使いません。今回の改稿で20万レイヤーの現行動作を再検証したものではありません。元記事の大規模実績を、今回のコードの性能保証として引き継がず、実ファイルの件数と時間を確認してください。
進捗表示だけの小さな実装はプログレスバーの記事で確認できます。
元記事の操作例
以下は元記事で掲載していた開発時の画面です。今回のコード改稿後に撮影した検証画像ではありません。現行の表示や変更結果は、複製したファイルで確認してください。


検証範囲と関連ガイド
今回確認したのは掲載コードの構文と、該当する入力・例外処理のローカル模擬検証です。Figmaデスクトップアプリ上での動作確認とは区別しています。改稿後のコードと、元記事が参照していたGitHub上の版が一致するとは限りません。
開発環境と登録手順はFigmaプラグイン開発入門、公開はCommunityへの申請を参照してください。
