たとえば、Figma にアイコンのセットがあり、React/Vue/その他にプロジェクトがあるとします。当然ですが、これらのアイコンを使用する必要があります。どうすればインポートできるでしょうか?
頑固な人なら、1 つずつダウンロードしてアセットとして追加することもできます。しかし、それは悲しい方法です。好きではありません。
悲しむのをやめて、代わりに素晴らしい人間になりましょう。必要なものは次のとおりです。
*これ以上は説明しません*
Figma トークンが必要になります。これを行う方法を説明する必要はないと思うので、代わりにヘルプ セクションを以下に示します: https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens 。
デフォルト設定で十分です。ファイルの内容への読み取り専用アクセスが必要です。
例として、 Material Design Icons (Community)を使用します。
ファイル キーとメイン ノード ID が必要になります。
前提条件が十分になったので、コーディングを始めましょう。スクリプトをメインのプロジェクト コードから分離したいので、ルートにscripts
フォルダーを作成し、その中にfetch-icons.ts
を作成します。TypeScript で作成する必要はありませんが、この記事がちょっと派手になるので、TypeScript で作成することにします。
ただし、これにはさらにいくつかの依存関係が必要になります。
yarn add -D tsx dotenv axios @figma/rest-api-spec
dotenv
は.env
変数を使用するために、 tsx
は TS スクリプトを実行するために (派手ですね、覚えていますか?)、 @figma/rest-api-spec
は型安全性のため、 axios
は単に使い慣れているからです。必要なのはdotenv
だけです (コードに API キーを追加しても構わない場合を除く)。最後に「純粋な」バージョンを追加します。今のところ、この設定で開始します。以下は開始するものです。
import * as dotenv from "dotenv"; dotenv.config(); const PERSONAL_ACCESS_TOKEN = String( process.env.VITE_FIGMA_PERSONAL_ACCESS_TOKEN ); // I've added my Figma token to the .env file, and so should you const FIGMA_API_URL = "https://api.figma.com/v1"; const FILE_KEY = "v50KJO82W9bBJUppE8intT"; // this one is from the URL const NODE_ID = "2402-2207"; // node-id query param, also from the URL
まず、メインフレームのメタデータを取得します。
import { GetFileNodesResponse, GetImagesResponse, HasChildrenTrait, } from "@figma/rest-api-spec"; // ... const fetchFrameData = async ( nodeId: string ): Promise<HasChildrenTrait> => { const { data } = await axios.get<GetFileNodesResponse>( `${FIGMA_API_URL}/files/${FILE_KEY}/nodes?ids=${nodeId}`, getConfig() ); return data.nodes[nodeId].document as HasChildrenTrait; };
子に関する情報を返します。子とは、画像(そのタイプはINSTANCE
になります)または再帰的に解析する他のフレームです。
const getImageNodes = ( frameData: HasChildrenTrait ): Record<string, string> => { const nodes: Record<string, string> = {}; for (const image of frameData.children.filter( (node) => node.type === 'INSTANCE' )) { // normalizeName simply converts 'check-box' to 'CheckBox' const name = normalizeName(image.name); const id = image.id; nodes[id] = name; } for (const frame of frameData.children.filter( (node) => node.type === 'FRAME' )) { Object.assign(nodes, getImageNodes(frame)); } return nodes; };
最後に、画像 ID が多数取得できたら、SVG ファイルの URL を取得します。
const fetchImageUrls = async ( nodeIds: string[] ): Promise<GetImagesResponse> => { const { data } = await axios.get<GetImagesResponse>( `${FIGMA_API_URL}/images/${FILE_KEY}?ids=${nodeIds.join(",")}&format=svg`, getConfig() ); return data; };
そして、最終的には次のようなオブジェクトの配列が作成されます。
{ name: 'CheckBox', url: 'https://figma-alpha-api.s3.us-west-2.amazonaws.com/images/7dd5bc31-4f26-4701-b155-7bf7dea45824' }
すでにご想像のとおり、すべてダウンロードしますが、それが最も興味深い部分ではありません。
SVG を取得するたびに、次のような結果が返されます。
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M17 6H7C3.69 6 1 8.69 [...] 9 17 9Z" fill="black" /> </svg>
各アイコンでsvg
タグは同一なので削除します (簡単にするためにサイズは同じであると仮定します)。しかし、さらに興味深いのは、すべてのfill=”black”
テンプレート リテラルのプレースホルダーに置き換え、結果として次のような関数を作成することです。
export const IconToggleOn = (color: string) => `<path d="M17 6H7C3.69 [...] 9 17 9Z" fill="${color}"/>`;
取得方法は次のとおりです。
const cleanupSvg = (data: string): string => { const svgTags = /<\/?svg.*?>/g; const colorFills = /fill=["'].+?["']/g; return data .replace(svgTags, "") .replace(colorFills, 'fill="${color}"') .replace(/\n/g, ""); };
当然、ロゴをインポートする必要がある場合は色を置き換える必要はありませんが、それらに対して異なるロジックを作成することは宿題になります。
さて、これが私たちの計画です:
ファイル全体を引用するつもりはありません。GitHub で確認できます。ダウンロード自体を見てみましょう。
const FILE_BATCH_SIZE = 10; const fileContent: string[] = []; const getPromise = async (item: IconData) => { try { const { data } = await axios.get<string>( item.url, getConfig("image/svg+xml") // returns necessary options and headers ); return { name: item.name, data }; } catch (err) { console.error(err); } }; for (let i = 0; i < iconsData.length; i += FILE_BATCH_SIZE) { const batch = await Promise.allSettled( iconsData.slice(i, i + FILE_BATCH_SIZE).map(getPromise) ); for (const icon of batch) { if (icon?.status === "fulfilled" && icon.value) { const iconName = `Icon${icon.value.name}`; const svgData = cleanupSvg(icon.value.data); fileContent.push( `export const ${iconName} = (color: string) =>\n \`${svgData}\`;\n` ); } } }
ここでバッチ処理が重要なのは、そうしないと、数千のファイルを並行してダウンロードしようとした場合に Figma がそれを好まなくなるためです。
バックエンドから受け取った名前でアイコンを取得する必要がある場合は、辞書を作成することもできます。
const iconArray: string[] = []; // ... for (const icon of batch) { if (icon?.status === "fulfilled" && icon.value) { const iconName = `Icon${icon.value.name}`; const svgData = cleanupSvg(icon.value.data); fileContent.push( `export const ${iconName} = (color: string) =>\n \`${svgData}\`;\n` ); iconArray.push(iconName); // <-- we added this } } // ... when all files are processed fileContent.push( `\r\nexport const iconDictionary: Record<string, (color: string) => string> = {\r\n ${iconArray.join( ",\r\n " )}\r\n};\r\n` );
このアプローチは、辞書経由でアイコンを 1 つだけ使用してもセット全体がバンドルに追加されるため、より悪いですが、時には不快なものを作成する必要があります。また、すべての\r\n
とスペースは、私の ESLint を満足させるためにここにあります。それらの量を調整する必要があるかもしれません。
最後のステップです。新しいファイルを作成しましょう。
import path from "path"; import { fileURLToPath } from "url"; // ... const PATH_TO_ICONS = "../src/assets/icons"; const currentPath = fileURLToPath(import.meta.url); const currentDirectory = path.dirname(currentPath); const fileName = "index.ts"; const filePath = path.join( path.resolve(currentDirectory, PATH_TO_ICONS), fileName ); // ... fs.writeFileSync(filePath, fileContent.join(""));
URL を取得した後でアイコンを並べ替えることをお勧めします。これは次のように簡単です。
iconsData.sort((a, b) => a.name.localeCompare(b.name));
ソートされたリストは読みやすく、さらに重要なことは、新しいアイコンが追加されるたびに読みやすい差分が表示されることです。そうでない場合、順序は保証されず、アイコンを 1 つ追加するだけですべてが台無しになる可能性があります。
アイコンの文字列、色、サイズをプロパティとして受け入れ、何か素敵なものを描画するコンポーネントが必要です。
import React, { useMemo } from "react"; import { IconSvg } from "../types/IconSvg"; interface IconProps { svg: IconSvg; // type IconSvg = (color: string) => string; color?: string; size?: number | string; } const refine = (x: string | number) => { return typeof x === "number" || !/\D+/.test(x) ? `${x}px` : x; }; const MyIcon: React.FC<IconProps> = ({ svg, color, size, }) => { const iconColor = useMemo(() => color ?? "black", [color]); const refinedSize = useMemo(() => refine(size ?? 24), [size]); const currentIcon = useMemo(() => svg(currentColor), [svg, currentColor]); return ( <svg viewBox="0 0 24 24" // make sure it's the same as in Figma dangerouslySetInnerHTML={{ __html: currentIcon }} style={{ width: refinedSize, minWidth: refinedSize, height: refinedSize, minHeight: refinedSize, }} ></svg> ); }; export default MyIcon;
サイズの調整を除けば、かなり簡単だと思います。初めて実装したとき、Chrome は数値を幅/高さの CSS プロパティとして受け入れましたが、Firefox は受け入れなかったので、 px
を追加しました。もうそうではないとしても、標準に従って想定されているとおりにしておきましょう。
そして、私が約束したオートコンプリートは次のとおりです。
これが私の調理方法です。お役に立てれば幸いです。
このアプローチの欠点は、アイコンがインライン化され、キャッシュされないことです。しかし、数バイトであれば大した問題ではないと思います。さらに、回転やホバー時の色の変更などを追加すれば、アイコンの小さなスイスアーミーナイフが完成します。
また、Figma で整理されたファイルが必要になります。しかし、このコードをすべて記述できるのであれば、デザイナーに整理してもらうこともできます。
React および Vue コンポーネントを含む完全なコードと、依存関係の少ない Node.js バージョンは、こちらでご覧いただけます: https://github.com/Smileek/fetch-icons