ESLint との格闘の末
はじめに
過去に作成した画像分析サーバレス Web アプリのフロントエンドを Vue から React + TypeScript + Material-UI の組み合わせに変更
ソースは下記 GitHub に公開
アプリ概要
- ブラウザ上で画像を選択、その画像のファイル名表示とプレビュー
- API 経由で画像をアップロード
- アップロードした画像の識別結果を表示
構成図
フロントエンドは React, バックエンドは Chalice で構築
バックエンド
- Chalice を用いて API Gateway エンドポイントを作成し、Lambda から Rekognition, Translate を使用するバックエンドを構築
- 詳細はまずやってみる機械学習 ~AWS SAGEMAKER/REKOGNITIONとVUEで作る画像判定WEBアプリケーション1を参照
フロントエンド
開発環境の構築からアプリの構築まで
React 開発環境
- 本アプリは、開発環境のテンプレートとして下記のリンク先の
04-advanced
を拝借2 - Linter の設定など全部済んでいるため、至れり尽せりで非常にオススメ
Material-UI
- 今回は v5.0.0 (プレビュー版) を利用
- インストール方法は公式を参照
package.json
"dependencies": {
"@material-ui/core": "^5.0.0-alpha.25",
"@material-ui/icons": "^5.0.0-alpha.26",
"@material-ui/lab": "5.0.0-alpha.25",
(略)
}
Atomic Design
- パーツ・コンポーネント単位で定義していく UI デザイン手法の Atomic Design で設計3
- Template / Pages / Organisms / Molecules / Atoms の構成要素に分けて画面を設計
- 詳細は 下記参照
Templates
- 全体のレイアウト、テーマを設定
- 赤枠 (header) :
AppBar
コンポーネントでタイトル部を作成 - 青枠 (main) : アプリのメインで
children
にバインド - 緑枠 (footer) : Copyright用に作成
- 赤枠 (header) :
GenericTemplate.tsx
import React from 'react';
import CssBaseline from '@material-ui/core/CssBaseline';
import {
createMuiTheme,
createStyles,
makeStyles,
Theme,
ThemeProvider,
} from '@material-ui/core/styles';
import { AppBar, Link, Typography } from '@material-ui/core';
const muiTheme = createMuiTheme({
typography: {
h5: {
fontWeight: 800,
fontFamily: ['sans-serif'].join(','),
},
fontFamily: [
'Noto Sans JP',
'Lato',
'游ゴシック Medium',
'游ゴシック体',
'Yu Gothic Medium',
'YuGothic',
'ヒラギノ角ゴ ProN',
'Hiragino Kaku Gothic ProN',
'メイリオ',
'Meiryo',
'MS Pゴシック',
'MS PGothic',
'sans-serif',
].join(','),
},
palette: {
mode: 'light', // dark is "dark mode"
},
});
const useStyles = makeStyles((theme: Theme) =>
createStyles({
appbar: {
padding: theme.spacing(2),
Height: '200px',
},
}),
);
// Link => eslint を disable にする必要がある?
// 参考:https://next.material-ui.com/components/links/#main-content
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/anchor-is-valid */
const Copyright = () => (
<Typography variant="body2" align="center">
{'Copyright © '}
{new Date().getFullYear()}
<Link href="https://github.com/takanassyi/react-and-rekognition">
{' '}
takanassyi{' '}
</Link>
All rights reserved.
</Typography>
);
export interface GenericTemplateProps {
children: React.ReactNode;
}
const GenericTemplate: React.FC<GenericTemplateProps> = ({ children }) => {
const classes = useStyles();
return (
<ThemeProvider theme={muiTheme}>
<CssBaseline />
<div>
<header>
<AppBar position="static" className={classes.appbar}>
<Typography variant="h6">
Image Classification Example (React Frontend App Ver.)
</Typography>
</AppBar>
</header>
</div>
<div>
<main>{children}</main>
</div>
<div>
<footer>
<Copyright />
</footer>
</div>
</ThemeProvider>
);
};
export default GenericTemplate;
Pages
-
Template
からデザインを継承 -
Grid
でレイアウト -
Pages
をトップにprops
でOrganisms
,Molecules
へデータと関数を伝播 -
http://<<YOUR ENDOPOINT URL>>
に Chalice で構築したエンドポイントの URL を設定 - axios で API 実行 (本来は別のソースに切り出すべき?)
Page.tsx
import React, { useState } from 'react';
import { Grid, Theme, createStyles, makeStyles } from '@material-ui/core';
import DisplayResult from 'Components/Organisms/DisplayResult';
import UploadImage from 'Components/Organisms/UploadImage';
import GenericTemplate from 'Components/Templates/GenericTemplate';
import axios from 'axios';
import { Result } from 'utils/utils';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
grid: {
padding: theme.spacing(2),
},
}),
);
const Page: React.FC = () => {
const classes = useStyles();
const [image, setImage] = useState<string | null | ArrayBuffer | undefined>(
null,
);
const [fileName, setFileName] = useState<string>('');
const [pending, setPending] = useState<boolean>(false);
const [items, setItems] = useState<Result[]>([]);
const getImage = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files === null) return;
try {
const reader = new FileReader();
reader.readAsDataURL(e.target.files[0]);
reader.onload = () => {
setImage(reader.result);
if (e.target.files === null) return;
setFileName(e.target.files[0].name);
};
} catch {
console.error('Error');
}
};
// TODO:型を明確にしながら axios を使う
// https://qiita.com/keyakko/items/ec536545d2faa9cabc84
const fetchData = async () => {
const config = {
headers: {
'content-type': 'application/octet-stream',
},
};
const resultAxios = await axios.post<string>(
'http://<<YOUR ENDOPOINT URL>>/api/rekognition',
image,
config,
);
const results: Result[] = [];
resultAxios.data.split(',').map((text, id) => results.push({ id, text }));
setItems(results);
setPending(false);
};
const uploadImage = () => {
if (typeof image !== 'string') {
return;
}
setPending(true);
void fetchData();
};
return (
<GenericTemplate>
<Grid container spacing={2} className={classes.grid}>
<Grid item sm={6}>
<UploadImage
image={image}
getImage={getImage}
fileName={fileName}
uploadImage={uploadImage}
pending={pending}
/>
</Grid>
<Grid item sm={6}>
<DisplayResult items={items} />
</Grid>
</Grid>
</GenericTemplate>
);
};
export default Page;
Organisms
- Page を左右2つの領域に分割
- 左側 (赤枠) : 画像の選択、アップロードに関する領域
- 右側 (青枠) : 画像認識の結果を表示する領域
UploadImage.tsx
import React from 'react';
import { Grid, Typography } from '@material-ui/core';
import DisplayImage from 'Components/Molecules/DisplayImage';
import SelectImage from 'Components/Molecules/SelectImage';
type UploadImageProps = {
image: string | null | ArrayBuffer | undefined;
fileName: string;
getImage: (event: React.ChangeEvent<HTMLInputElement>) => void;
uploadImage: () => void;
pending: boolean;
};
// ブレークポイントとGrid item/containerの解説
// https://blog.katsubemakito.net/react/react1st-28-materialui
// 12を超えると次の行に送られる
const UploadImage: React.FC<UploadImageProps> = (props: UploadImageProps) => {
const { image, fileName, getImage, uploadImage, pending } = props;
return (
<Grid container spacing={2}>
<Grid item sm={12}>
<Typography variant="h5">Select Image file</Typography>
</Grid>
<Grid item sm={12}>
<Grid container spacing={2} alignItems="flex-end">
<SelectImage fileName={fileName} getImage={getImage} />
</Grid>
</Grid>
<Grid item sm={12}>
<Grid container spacing={2} alignItems="flex-end">
<DisplayImage
image={image}
pending={pending}
uploadImage={uploadImage}
/>
</Grid>
</Grid>
</Grid>
);
};
export default UploadImage;
DisplayResult.tsx
import React from 'react';
import {
Grid,
Paper,
Typography,
createStyles,
makeStyles,
Theme,
// colors,
} from '@material-ui/core';
import { Result, ResultProps } from 'utils/utils';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
paper: {
width: '100%',
padding: theme.spacing(1),
},
}),
);
const DisplayResult: React.FC<ResultProps> = (props: ResultProps) => {
const classes = useStyles();
const { items } = props;
return (
<Grid container spacing={2}>
<Grid item sm={12}>
<Typography variant="h5">Result - Rekognition</Typography>
</Grid>
<Grid item sm={12}>
<Grid container>
{items.map((result: Result) => (
<Paper
className={classes.paper}
key={result.id}
square
variant="outlined"
>
<Typography>{result.text}</Typography>
</Paper>
))}
</Grid>
</Grid>
</Grid>
);
};
export default DisplayResult;
Molecules
-
UploadImage
を更に上下に分割- 上側(黄枠) : 画像の選択、ファイル名表示(SelectImage.tsx)
- 下側(緑枠) : 画像のプレビュー、アップロードボタン表示(DisplayImage.tsx)
SelectImage.tsx
import React from 'react';
import { Button, Grid, Typography } from '@material-ui/core';
import { Image } from '@material-ui/icons';
type SelectImageProps = {
fileName: string;
getImage: (event: React.ChangeEvent<HTMLInputElement>) => void;
};
const SelectImage: React.FC<SelectImageProps> = (props: SelectImageProps) => {
const { fileName, getImage } = props;
return (
<>
<Grid item>
<Button
startIcon={<Image />}
color="primary"
variant="contained"
component="label"
>
Select
<input
id="img"
type="file"
accept="image/*,.png,.jpg,.jpeg,.gif"
hidden
onChange={(e: React.ChangeEvent<HTMLInputElement>) => getImage(e)}
/>
</Button>
</Grid>
<Grid item>
<Typography>{fileName}</Typography>
</Grid>
</>
);
};
export default SelectImage;
DisplayImage.tsx
import React from 'react';
import { createStyles, makeStyles, Grid } from '@material-ui/core';
import { Cloud } from '@material-ui/icons';
import LoadingButton from '@material-ui/lab/LoadingButton';
const useStyles = makeStyles(() =>
createStyles({
img: {
maxWidth: '100%',
height: 'auto',
},
}),
);
type DisplayImageProps = {
image: string | null | ArrayBuffer | undefined;
uploadImage: () => void;
pending: boolean;
};
const DisplayImage: React.FC<DisplayImageProps> = (
props: DisplayImageProps,
) => {
const classes = useStyles();
const { image, uploadImage, pending } = props;
return (
<>
{typeof image === 'string' ? (
<>
<Grid item sm={12}>
<img alt="detectimage" src={image} className={classes.img} />
</Grid>
<Grid item sm={12}>
<LoadingButton
pending={pending}
variant="contained"
startIcon={<Cloud />}
onClick={uploadImage}
>
Upload
</LoadingButton>
</Grid>
</>
) : (
<></>
)}
</>
);
};
export default DisplayImage;
Atoms
- ラベル、ボタンなど最小単位を集めたもの
- 今回は Material-UI のコンポーネントそのまま使用
utils
- Rekognition の結果を格納するための型を定義
-
id
はDisplayResult.tsx
のmap
で複数の要素を並べるときにユニークな key 指定をするために付与4
utils.ts
export type Result = {
id: number;
text: string;
};
export type ResultProps = {
items: Result[];
};
おわりに
- シンプルなアプリだが、フロントエンドとバックエンドを統合することで、それぞれの技術要素の理解につながった
- シンプルが故、Atomic Design の適用は大袈裟だったかも
-
useState
のみで実装しているため、無駄な再描画が多い懸念- 他のフックを利用して効率的な再描画が必要
- axios の処理について型付けができていない
- axios の Post 処理は切り出して
utils
へ切り出したほうが良い5
- axios の Post 処理は切り出して