Amplify StudioがどのようにFigmaデータをReact componentに変換しているのか

こんにちは!Amplifyの改善を行っている @watilde です。この記事は AWS Amplify Advent Calendar 2021 と Figma Advent Calendar 2021 の 25 日目の記事です。

Amplify Studio の発表

2021年の12月3日に開催された re:Invent にて Amplify Studio が発表されました。新機能としてFigmaでデザインしたコンポーネントを Amplify Studio にてデータベース内のテーブルのスキーマと紐付けし、可読性の高い React のコンポーネントとして出力する “Figma to code” が追加されました。

https://docs.amplify.aws/console/

本記事では、amplifyコマンドがどのように Figma, Amplify Studio で設定したコンポーネントをコード化しているのか、関連レポジトリの amplify-cliamplify-codegen-ui の中身を見ながら解説を行います。この記事を通じて、UI/UXデザイナー、アプリケーションエンジニアが Amplify Studio の利用を行う際に知っておくと捗るかもしれない “Figma to code” の仕組みの一部について理解が深まれば幸いです。

1. Figmaでのコンポーネント作成

まずは今回の “Figma to code” を行うためのコンポーネントを Figma 上で作成します。作成をするにあたって AWS Amplify UI Kit を複製し CardC を微調整して画像、タイトル、価格の表示を行うことにします。

AWS Amplify UI Kitより、CardCのみを微調整

2. Figmaで作成したコンポーネントとデータの紐付け

次に Amplify Studio にて Figma と同期して作成した CardC を読み込みます。読み込んだ CardC の要素と、データベースのスキーマの紐付け、及び紐付けたスキーマとプロパティの紐付けを行います。

Amplify Studio 上でのコンポーネントの編集

これにより amplify pull を実施することで手元に下記のような React component が生成されます。

import React from "react";
import { getOverrideProps } from "@aws-amplify/ui-react/internal";
import { Flex, Image, Text } from "@aws-amplify/ui-react";
export default function CardC(props) {
  const { home, overrides: overridesProp, ...rest } = props;
  const overrides = { ...overridesProp };
  return (
    <Flex
      padding="0px 0px 0px 0px"
      backgroundColor="rgba(255,255,255,1)"
      gap="0"
      width="320px"
      position="relative"
      justifyContent="center"
      direction="column"
      {...rest}
      {...getOverrideProps(overrides, "Flex")}
    >
      <Image
        padding="0px 0px 0px 0px"
        alignSelf="stretch"
        shrink="0"
        src={home?.image_url}
        width="320px"
        position="relative"
        height="408px"
        {...getOverrideProps(overrides, "Flex.Image[0]")}
      ></Image>
      <Flex
        padding="16px 16px 16px 16px"
        alignSelf="stretch"
        position="relative"
        shrink="0"
        gap="16px"
        direction="column"
        {...getOverrideProps(overrides, "Flex.Flex[0]")}
      >
        <Flex
          padding="0px 0px 0px 0px"
          alignSelf="stretch"
          position="relative"
          shrink="0"
          gap="8px"
          direction="column"
          {...getOverrideProps(overrides, "Flex.Flex[0].Flex[0]")}
        >
          <Text
            padding="0px 0px 0px 0px"
            alignSelf="stretch"
            color="rgba(13.000000175088644,26.000000350177288,38.0000015348196,1)"
            textAlign="left"
            shrink="0"
            display="flex"
            justifyContent="flex-start"
            fontFamily="Inter"
            width="288px"
            fontSize="16px"
            lineHeight="24px"
            position="relative"
            fontWeight="700"
            direction="column"
            children={home?.address}
            {...getOverrideProps(overrides, "Flex.Flex[0].Flex[0].Text[0]")}
          ></Text>
        </Flex>
        <Text
          padding="0px 0px 0px 0px"
          alignSelf="stretch"
          color="rgba(13,26,38,1)"
          textAlign="left"
          shrink="0"
          display="flex"
          justifyContent="flex-start"
          fontFamily="Inter"
          width="288px"
          fontSize="32px"
          lineHeight="40px"
          position="relative"
          fontWeight="700"
          direction="column"
          children={`${"Price: $"}${home?.price}`}
          {...getOverrideProps(overrides, "Flex.Flex[0].Text[0]")}
        ></Text>
      </Flex>
    </Flex>
  );
}

3. Amplify Studio から取得されるデータ構造

このセクションにて、このそれなりに可読性の高い React component がどのように生成されるのかを調べていきます。コマンドのトリガーとしては amplify-cli の pull を実行した後に amplify-cli/packages/amplify-util-uibuilder の generateComponents コマンドが実行されることとなります。今回は読むべきエントリーポイントとして amplify-cli/packages/amplify-util-uibuilder/commands/generateComponents.js#L25-L27 から見ていくことにします。

spinner.start('Generating UI components...');
const generatedComponentResults = generateUiBuilderComponents(context, componentSchemas.entities);

このメソッドでは Amplify Studio で生成したコンポーネントを context 経由で componentSchemas として受け取っています。これが React component に変換する元となるデータとなります。この componentSchemas のデータは、少し大きいですが以下になります。

{
  "appId": "<App ID>",
  "bindingProperties": {
    "home": {
      "bindingProperties": {
        "model": "Home"
      },
      "type": "Data"
    }
  },
  "children": [
    {
      "children": [],
      "componentType": "Image",
      "name": "image",
      "properties": {
        "padding": {
          "value": "0px 0px 0px 0px"
        },
        "alignSelf": {
          "value": "stretch"
        },
        "shrink": {
          "value": "0"
        },
        "src": {
          "bindingProperties": {
            "field": "image_url",
            "property": "home"
          },
          "configured": true
        },
        "width": {
          "value": "320px"
        },
        "position": {
          "value": "relative"
        },
        "height": {
          "value": "408px"
        }
      }
    },
    {
      "children": [
        {
          "children": [
            {
              "children": [],
              "componentType": "Text",
              "name": "Classic Long Sleeve T-Shirt",
              "properties": {
                "padding": {
                  "value": "0px 0px 0px 0px"
                },
                "alignSelf": {
                  "value": "stretch"
                },
                "color": {
                  "value": "rgba(13,26,38,1)"
                },
                "textAlign": {
                  "value": "left"
                },
                "shrink": {
                  "value": "0"
                },
                "display": {
                  "value": "flex"
                },
                "label": {
                  "bindingProperties": {
                    "field": "address",
                    "property": "home"
                  },
                  "configured": true,
                  "importedValue": "Classic Long Sleeve T-Shirt"
                },
                "justifyContent": {
                  "value": "flex-start"
                },
                "fontFamily": {
                  "value": "Inter"
                },
                "width": {
                  "value": "288px"
                },
                "fontSize": {
                  "value": "16px"
                },
                "lineHeight": {
                  "value": "24px"
                },
                "position": {
                  "value": "relative"
                },
                "fontWeight": {
                  "value": "700"
                },
                "direction": {
                  "value": "column"
                }
              }
            }
          ],
          "componentType": "Flex",
          "name": "Main Text",
          "properties": {
            "padding": {
              "value": "0px 0px 0px 0px"
            },
            "alignSelf": {
              "value": "stretch"
            },
            "position": {
              "value": "relative"
            },
            "shrink": {
              "value": "0"
            },
            "gap": {
              "value": "8px"
            },
            "direction": {
              "value": "column"
            }
          }
        },
        {
          "children": [],
          "componentType": "Text",
          "name": "$99 USD",
          "properties": {
            "padding": {
              "value": "0px 0px 0px 0px"
            },
            "alignSelf": {
              "value": "stretch"
            },
            "color": {
              "value": "rgba(13,26,38,1)"
            },
            "textAlign": {
              "value": "left"
            },
            "shrink": {
              "value": "0"
            },
            "display": {
              "value": "flex"
            },
            "label": {
              "concat": [
                {
                  "value": "Price: $"
                },
                {
                  "bindingProperties": {
                    "field": "price",
                    "property": "home"
                  }
                }
              ],
              "configured": true,
              "importedValue": "$99 USD"
            },
            "justifyContent": {
              "value": "flex-start"
            },
            "fontFamily": {
              "value": "Inter"
            },
            "width": {
              "value": "288px"
            },
            "fontSize": {
              "value": "32px"
            },
            "lineHeight": {
              "value": "40px"
            },
            "position": {
              "value": "relative"
            },
            "fontWeight": {
              "value": "700"
            },
            "direction": {
              "value": "column"
            }
          }
        }
      ],
      "componentType": "Flex",
      "name": "Card Area",
      "properties": {
        "padding": {
          "value": "16px 16px 16px 16px"
        },
        "alignSelf": {
          "value": "stretch"
        },
        "position": {
          "value": "relative"
        },
        "shrink": {
          "value": "0"
        },
        "gap": {
          "value": "16px"
        },
        "direction": {
          "value": "column"
        }
      }
    }
  ],
  "componentType": "Flex",
  "createdAt": "2021-12-24T11:58:55.261Z",
  "environmentName": "<Env name>",
  "id": "<ID>",
  "modifiedAt": "2021-12-24T11:58:55.262Z",
  "name": "CardC",
  "overrides": {},
  "properties": {
    "padding": {
      "value": "0px 0px 0px 0px"
    },
    "backgroundColor": {
      "value": "rgba(255,255,255,1)"
    },
    "gap": {
      "value": "0"
    },
    "width": {
      "value": "320px"
    },
    "position": {
      "value": "relative"
    },
    "justifyContent": {
      "value": "center"
    },
    "direction": {
      "value": "column"
    }
  },
  "sourceId": "1420:1493",
  "variants": []
}

見るべきポイントは a) データのバインディング b) コンポーネントの情報 の2つかなと思います。

3-a. データのバインディング

データのバインディングは Amplify Studio の Content で設定している Home テーブルのスキーマをモデルとしてを渡しているようです。

{
  "bindingProperties": {
    "home": { // バインドしたスキーマのエイリアス名
      "bindingProperties": {
        "model": "Home" // テーブル名の指定
      },
      "type": "Data"
    }
  }
}

このモデルのカラム情報を、実際のコンポーネントに紐付けしています。

{
  "componentType": "Text",
  "name": "$99 USD",
  "properties": {
    "label": {
      "concat": [
        {
          "value": "Price: $"
        },
        {
          "bindingProperties": {
            "field": "price", // スキーマ内のカラム名
            "property": "home" // スキーマのエイリアス
          }
        }
      ],
      "configured": true,
      "importedValue": "$99 USD"
    }
  }
}

3-b. コンポーネントの情報

コンポーネントの情報としては、ジェネーレーターて定義されている componentType にて、どのようなコンポーネントの種類かを指定しているようです。この2つの情報を元に、データを React コンポーネントに変換しています。

{
  "componentType": "Text",
  "name": "$99 USD"
}
{
  "componentType": "Flex",
  "name": "Card Area"
}
{
  "componentType": "Image",
  "name": "image"
}

4. データを React コンポーネントに変換

データを受けとって実際に React コンポーネントに変換している処理は amplify-cli/packages/amplify-util-uibuilder/commands/utils/createUiBuilderComponent.js の以下の部分で行われているようです。

rendererManager.renderSchemaToTemplate(schema);

この rendererManager は amplify-codegen-ui のパッケージにて実装されています。読み進めていくと amplify-codegen-ui/packages/codegen-ui-react/lib/react-studio-template-renderer.ts の renderComponentInternal にて変換を行っているそうです。

この内部にて、バインドされたスキーマとコンポーネントのデータをコード化しているようです。最後に transpile にて prettier を使ってコードの整形を行っているようです。

const componentText = prettier.format(transpiledCode, { parser: 'typescript', plugins: [parserTypescript] });

所感

内部実装を読むことで、汎用性のあるデータ構造を元に React コンポーネントの生成をしていることが分かりました。実装的には 1) React 以外のコンポーネントの生成 2) tsx での吐き出し が意識されていることが分かります。一連の “Figma to code” が OSS として開発されており、OSS は日々その形を変化させるので今後の改善が楽しみであるところです。

また、今回の記事を書くにあたって Amplify Studio をいじりましたが、特に詰まる部分はなく、UI/UX デザイナーとしても、アプリケーションエンジニアとしても及第点な体験となっているのではないのかなと思います。引き続き Amplify Studio の動向に期待していきます。