そこそこ実用的な翻訳アプリを開発してみる (5) フロントエンド側の実装

そこそこ実用的な翻訳アプリを開発しましたので、その構成や作り方を記録しておきます。[コード](https://github.com/yamachan/rtk-lt) は MITライセンスですので、参考になるところがあれば部分的にでも使ってみてください。

# 目次


* [(1) 概要編](https://qiita.com/yamachan360/items/5b20e7181d663d70e924)
* [(2) 開発環境の準備](https://qiita.com/yamachan360/items/d5c4fb2e07d3b9693c37)
* [(3) 翻訳サービスの実装](https://qiita.com/yamachan360/items/fa4af5f7b18781e5f283)
* [(4) ログサービスの実装](https://qiita.com/yamachan360/items/92699a3fc55969337b46)
* (5) フロントエンド側の実装 【このページ】
* [(6) 学習用データの作成](https://qiita.com/yamachan360/items/7de33c30331745b0d591)

# 楽しい React+Material-UI

[React](https://reactjs.org/) と [Material-UI](https://material-ui.com/) って楽しいですよね。

React の部品化の仕組みがいい感じに働いているのか、Material-UI の開発者たちの性格が良いのか、機能豊富なわりに、わりと素直に組み合わせて、わりと思ったとおりの動きをしてくれるフロントエンド側な開発環境だと思います。

今回のアプリは小規模なので、Router も Redux も使っていません。なので React 自体の素性の良さを感じられたのも嬉しいポイントでした。

# React のポイント

React をそのまま使う上で、大事なポイントはひとつしかないと思います。

* 親子関係をしっかりと把握しておくこと

そしてそれを守るために以下のルールが考えられます。

* 子は親から指定されたパラメーター(prop)に忠実に従うこと
* 子は何かあったら親にイベント(onXXX)で知らせること
* 上記が守られている限り、親は子の内面(state)に干渉しないこと

これら詳細については、コードを眺めたほうが雰囲気を掴みやすいかもしれません。

# サービス利用の準備

今回はバックエンド側に、翻訳とログのサービスを実装済みです。これらサービスにアクセスするため、[superagent](https://github.com/visionmedia/superagent) モジュールを導入しておきましょう。

```
npm install --save superagent
```

# フロントエンド側のコードを見ていこう

準備ができたところで、フロントエンド側のコードを見ていきましょう。
![qiita.gif](https://qiita-image-store.s3.amazonaws.com/0/193519/008ec80f-f1db-aa73-8eb0-5218e896b96e.gif)

## コンポーネントの簡単な構成図

主要部分は以下のようなコンポーネントに分かれています。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/193519/636b3a68-aa23-0bd8-b60a-5a4834cf70aa.png)
そして中心にある TranslationPanel コンポーネントの中身は以下のように TextField と Button を並べただけ、です。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/193519/b6002e91-d9f5-3c9d-38f0-357a4b3fab8b.png)
ちなみに「翻訳ログ」のタブのほうは以下のように List コンポーネントに ListItem が並び、その中に ListItemIcon と ListItemText が含まれている構成です。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/193519/599a41d8-7221-60c0-e281-5185eb55d881.png)
なお LoginPanel, MainPanel, TranslationPanel, AlertDialogSlide の4つが、今回、私が開発したコンポーネントです。それ以外は Material-UI の標準的な部品(コンポーネント)を使用しています。

さて、これらを実装しているコードをざっと見ていきましょう。

## すべての始まり App.jsx

最初はアプリのベースとなる App.jsx を見ていきます。React コンポーネントなので、`render()` 関数から見ていくのが良いでしょう。

```jsx:App.jsx
	render() {
		return (
            <div>
                <AppBar position="static" color="primary">
                    <Toolbar>
                        <Typography type="title" color="inherit">
                            {this.text('title')} &nbsp; &nbsp; by yamachan
                        </Typography>
                    </Toolbar>
                </AppBar>
                {this.state.user == null ?
                    <LoginPanel onLogin={u => this.setState({user:u})} lang={this.state.lang} /> :
                    <MainPanel user={this.state.user} lang={this.state.lang} />
                }
            </div>
		);
	}
```

ね、わりと単純でしょう。

`<AppBar>` タグは、アプリ上部にあるヘッダで、さきほどもありましたが以下の部分です。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/193519/bd631c6e-f328-16bc-d1be-9ff825da4597.png)
その下にある `this.state.user == null ?` で始まる三項演算子が、その下のメイン部分の表示をコントロールしています。

`this.state.user` という値が設定されていなければ、つまり null であれば、まだログイン前の状態ということになります。この場合には `<LoginPanel>` タグが表示されます。ログインが実行されれば、onLogin イベントが呼び出され、そこに記述された処理で `this.state.user` に値が設定されることがわかります。

`this.state.user` に値が設定されれば、`<LoginPanel>` の表示条件は失われますので、翻訳アプリ本体である `<MainPanel>` タグがかわりに表示されます。

なお `this.text()` 関数は今回、オマケ的な機能で、以下のように設定によってUIの表示を日本語/英語に切り替える役割をしています。

```jsx:App.jsx
    text(_key) {
        if (this.state.lang === 'ja') {
            return {
                title: '翻訳アプリ (ログ機能付き)'
            }[_key];
        } else {
            return {
                title: 'Translation App (with Log function)'
            }[_key];
        }
    }
```

## 最初だけ表示 LoginPanel.jsx

`<LoginPanel>` タグで表示されるのは以下のログインパネルです。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/193519/a3debde3-a275-dadf-b45f-4ddfef87201e.png)
この表示コードも、わりとシンプルにパーツを並べただけのものです。

```jsx:LoginPanel.jsx
	render() {
		return (
			<div>
			<Card style={{width:"16em", margin:"1em auto"}}>
				<CardContent>
					<TextField error={!this.state.user} required id="tf-user" label={this.text('id')} margin="normal"
					  value={this.state.user}
					  onChange={e => this.setState({user:e.target.value})} />
					<br/>
					<TextField id="tf-pw" label={this.text('pw')} type="password" margin="normal"
					  value={this.state.pw}
					  onChange={e => this.setState({pw:e.target.value})} />
				</CardContent>
				<CardActions>
					<Button variant="contained" color="primary"
					  disabled={!this.state.user}
					  onClick={e => this.checkLogin()} >
					{this.text('submit')}</Button>
					<Button variant="contained" color="secondary"
					  onClick={e => this.setState({user:"", pw:""})} >
					{this.text('clear')}</Button>
				</CardActions>
			</Card>
			<AlertDialogSlide
				dialogTitle={this.text('errorTitle')}
				dialogDescription={this.text('errorDescription')}
				open={this.state.open}
				afterClose={e => this.setState({open:false})}
				lang={this.lang} />
			</div>
		)
	}
```

全体を `<Card>` タグ(コンポーネント)で囲んでいること、CSS指定で中心に表示していること、などがポイントでしょうか。

また `<AlertDialogSlide>` というタグ(コンポーネント)を用意して、ログイン時のエラー表示をさせています。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/193519/3a377116-19ba-2d60-cf9c-ce962d127bf6.png)

なおユーザーID とパスワードを確認するロジックは、今回は省いてしまっています。スミマセン。

```jsx:LoginPanel.jsx
	checkLogin() {
		// ここは今回、手抜きしています！
		if (this.state.user == "guest" || this.state.user == "yamachan") {
			this.onLogin({user:this.state.user}); // 親にログオンのイベントを送る
		} else {
			this.setState({open:true}); // エラーダイアログの表示
		}
	}
```

## ここが大事さ MainPanel.jsx

さてログイン後に表示される `<MainPanel>` のコードを見ていきましょう。

ただ、さきほどのコンポーネント図でわかるように、翻訳機能は `<TranslationPanel>` と別コンポーネントとなっています。よって上のタブの部分と、翻訳ログの表示部分がこのコンポーネントに含まれます。

実際の表示部分のコードはこんな感じ。

```jsx:MainPanel.jsx
	render() {
		return (
			<div style={{padding:"1em"}}>
				<Tabs value={this.state.tab} onChange={(e,v) => this.setState({tab:v})}>
					<Tab label={this.text('tab_translate')} />
					<Tab label={this.text('tab_log')} />
				</Tabs>
				{this.state.tab === 0 && <TabContainer>
					<TranslatePanel
						user={this.user}
						lang={this.lang}
						onScore={e => this.getScore(e)}
					 />
				</TabContainer>}
				{this.state.tab === 1 && <TabContainer>
					<List component="nav">
						{this.state.log.map(o => (
							<ListItem key={o.text + '-=-'+ o.score + o.ttext}>
								<ListItemIcon><Icon>{this.star(o.score)}</Icon></ListItemIcon>
								<ListItemText primary={o.text} secondary={o.ttext} />
							</ListItem>
						))}
					</List>
				</TabContainer>}
			</div>
		)
	}
```

`this.state.tab === 0` の部分が、翻訳機能である `<TranslatePanel>` を表示します。子である `<TranslatePanel>` パネルに対し、ユーザー名(user)と表示言語(lang)を渡しているのがわかります。そして子からのイベントとして onScore を期待しているのがわかりますね。

`this.state.tab === 1` の部分が、翻訳ログを表示します。こちらは `<List>` コンポーネントを使用してログの一覧を表示しているのがわかります。

さて、子である `<TranslatePanel>` から onScore イベントが送られてきた時に、呼び出される関数 `this.getScore()` の中身を見ておきましょう。

```jsx:MainPanel.jsx
	getScore(_o) {
		request.post('/add').send(_o).end((err, res) => { // ログサービスの呼び出し
			if (err) {
				console.log('Error: ' + err);
			}
		});
		this.setState({log: this.state.log.concat(_o)}); // 表示用のログに追加
	}
```

翻訳結果が評価され、スコアが得られるたびに、ログサービスで記録されていくことがわかります。そしてこの記録されたログは、アプリ起動時に以下の部分で読み込まれます。

```jsx:MainPanel.jsx
	componentWillMount() {
		if (this.user && !!this.user.user) {
			request.get('/list/' + this.user.user).end((err, res) => { // ログサービスの呼び出し
				if (err) {
					console.log('Error: ' + err);
				} else {
					this.setState({log:JSON.parse(res.text)}); // 得られた翻訳ログを表示する
				}
			});
		}
	}
```

`componentWillMount` 関数は、この `<MainPanel>` コンポーネントが準備され、実際に表示される前に実行されます。ここでログサービスから過去の翻訳結果を得て、翻訳ログとして表示している、ということです。

※ `componentWillMount` 関数で必要な情報を集めてきて、得られた値を非同期に setState() で反映していくのは非常に React っぽいコードです

## そして最後は TranslationPanel.jsx

肝心の翻訳パネルですが、単純なわりに、使用しているコンポーネント数が多いので、`render()` 関数はスキップします。かわりにコンポーネント図をもう一度見てみてください。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/193519/b6002e91-d9f5-3c9d-38f0-357a4b3fab8b.png)
真ん中へんにある英語に翻訳、日本語に翻訳、クリアの3つのアクションボタン部分だけコードを抜き出してみました。

```jsx:TranslationPanel.jsx
				<Button variant="contained" color="primary"
				  disabled={!this.state.text}
				  onClick={e => this.traslate(true)} >{this.text('action_lang1')}</Button>&nbsp;
				<Button variant="contained" color="primary"
				  disabled={!this.state.text}
				  onClick={e => this.traslate(false)} >{this.text('action_lang2')}</Button>&nbsp;
				<Button variant="contained" color="secondary"
				  disabled={!this.state.text}
				  onClick={e => this.setState({text:"", ttext:""})} >{this.text('action_clear')}</Button>
```

`disabled={!this.state.text}` があることにより、上の「翻訳元の文章」欄が空の時はこれらのアクションボタンは disable となり、灰色の表示で利用できなくなります。なにかテキストを入力すれば色が復活して使えるようになります。この制御をこの短いコードで実装できています。

そして「英語に翻訳」「日本語に翻訳」の `onClick` に指定されている `translate` 関数をみてみましょう。

```jsx:TranslationPanel.jsx
	traslate(_en) {
		let req = {
			source: _en ? "ja" : "en",
			target: _en ? "en" : "ja",
			text: this.state.text
		}
		request.post('/translate').send(req).end((err, res) => {
			if (err) {
				console.log('Error: ' + err);
			} else {
				this.setState({ttext:res.text});
			}
		});
	}
```

バックエンドで実装した翻訳サービスを呼び出し、その結果を「翻訳結果」フィールドに反映させているのがわかります。

そして下にある5つの評価ボタンのコードをひとつだけ、抜き出してみます。

```jsx:TranslationPanel.jsx
				<Button variant="contained" color="secondary" size="small"
					disabled={!this.state.ttext}
					onClick={e => this.getScore(4)} >
					{this.text('score_4')}</Button>
```

こちらは `disabled={!this.state.ttext}` で、翻訳結果フィールドが空かどうかでボタンの有効化がコントロールされているのがわかります。そして `onClick` に指定されている `getScore` 関数をみてみましょう。

```jsx:TranslationPanel.jsx
	getScore(_score) {
		if (this.onScore) {
			this.onScore({
				text:this.state.text,
				ttext:this.state.ttext,
				score:_score,
				user:this.user,
				date:new Date()
			});
			this.setState({open:true})
		}
	}
```

`this.onScore` で、得られた評価を親(MainPanel) に報告している (イベントを発行している) のがわかります。最後にある `this.setState({open:true})` は以下のダイアログを表示させるためのコードです。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/193519/8af7e315-3f9a-7839-ed15-dbff8eae39d3.png)
このダイアログを閉じると以下の `afterScore` 関数が呼ばれます。

```jsx:TranslationPanel.jsx
	afterScore() {
		this.setState({text:"", ttext:"", open:false});
	}
```

この関数の中でダイアログを閉じる(open:false)と同時に、翻訳元の文章欄を空に(text:"")し、あわせて翻訳結果欄も空に(ttext:"")して、次回の入力に備えています。

# というわけで

さて、これで今回の翻訳アプリの全体をだいたい説明できたとおもいます。最後はオマケとして、AI の進化のための学習データを生成するツールを作成してみましょう。というわけで次回は [(6) 学習用データの作成](https://qiita.com/yamachan360/items/7de33c30331745b0d591) を実施します。

GitHub 上の [ソースコード](https://github.com/yamachan/rtk-lt) も参照してみてください。