【教師オリジナルアプリを作ろう】はじめてのVue.js(2) 英語句並べかえアプリを作る

前回の投稿,【教師オリジナルアプリを作ろう】はじめてのVue.js 英語・語句選択問題アプリを作ってみるでシンプルな英語学習アプリを作ってみました。ここでは引き続き,Vue.jsを用いた英語句並べかえアプリの作り方を学習します。

アプリの基本的な構造は前回とあまり変わりませんが,問題文やボタンの表示を制御するコードが増えています。

Vue.jsって便利?

今回作成したwebアプリのように,一つの問題に答えたら次の問題に移っていくようなタイプの動作に関しては,Vue.jsは非常に便利だと思います。

以前,同じアプリをVue.js無しで作っていますが,async と Promise を駆使しながら,DOM操作のためのコードも必要になるので,jQuery使っても正直しんどい,後から見直したときに何をやっているのかが分かりにくい...という感じでした。async と Promise という,プログラミング初心者にとって扱いやすいとは言えない仕組みから解放されただけでも十分価値があります。

今回作るもの

上から日本文,英文,選択肢が表示されます。正しい英文になるようにボタンを順にクリックします。ボタンをクリックすると,下線部がクリックされた語句に置き換えられます。

正解の場合には「正解」の文字を表示をし,「次へ」ボタンを表示します。ボタンをクリックすると次の問題が表示されます。

誤った順にボタンをクリックした場合は不正解となり,正解が赤文字で表示されます。

データの準備

const all_texts = [
		{ "ja_st": "私は翌日の授業に遅れないように早く寝た。",
		"sort_st": "I went to bed early # to class next day.",
		"sort_opt": ["so","as","not","to","be","late"]
		},
		{ "ja_st": "私のルームメートは私が買ったケーキを食べたかもしれない。",
		"sort_st": "My roommate #.",
		"sort_opt": ["might","have","eaten","the cake","I","bought"]
		},
		{ "ja_st": "彼は森の中で迷子になった。さらに悪いことに,雨が降り始めた。",
		"sort_st": "He was lost in the forest.#, it began to rain.",
		"sort_opt": ["to","make","the","matter","worse"]
		},
		{ "ja_st": "外国語を習得するには多大な努力が必要だ。",
		"sort_st": "It takes # a foreign language.",
		"sort_opt": ["a","lot","of","effort","to","master"]
		},
		{ "ja_st": "多くの人々は新鮮な空気が無料であることを当然とみなしている。",
		"sort_st": "Many people # fresh air is free.",
		"sort_opt": ["take","it","for","granted","that"]
		}];

表示される問題文のデータall_textsです。ja_stは一番上に表示される日本文,sort_stは問題文,sort_optは選択肢でボタンとして表示されます。sort_st#の部分は,あとで選択肢の数に応じて下線に置き換えます。また,sort_optはあとで正解・不正解を判定する際にも利用します。そのため,配列は正解の順に並べます。

初期値の設定 new Vue() el: data:

Vue.jsは初めにデータの初期値を設定する必要があります。

let app = new Vue({
	el: '#root',
	data: {
		ja_st: all_texts[id].ja_st,
		sort_st: all_texts[id].sort_st.replace("#", words.join('')),
		sort_opt: shuffle(all_texts[id].sort_opt),
		sort_opt_show: new Array(all_texts[id].sort_opt.length).fill(true),
		sort_opt_record: new Array(),
		BS_show: true,
		description: '',
		btn_next: false
	},

初めに new Vue()を書いてオブジェクトを送ります。この中に必要な情報や処理を書きこんでいきます。

el: '#root'はVue.jsで制御するブロック要素を指定します。これによってHTMLの<div id="root"></div>で囲まれた部分でVue.jsが動作します。

dataは画面上に表示する情報の初期値を設定します。初めはid=0として,問題文のデータを取り出してdataに格納していきます。たとえば,ja_stには日本文が格納され,HTMLの中で{{ja_st}}と書くとその部分に日本文が表示される仕組みです。

問題文の置き換え

let words = new Array(all_texts[id].sort_opt.length).fill(' __ ');

・・・・・・

sort_st: all_texts[id].sort_st.replace("#", words.join('')),

初めにwordsという配列を用意して,選択肢の数だけ要素を作ります。そこに.fill(' __ ')を加えることで,それぞれの要素を下線の文字列に置き換えます。例えば,選択肢の数が4個なら,words = [' __ ', ' __ ', ' __ ', ' __ ']という配列が出来上がります。

そして,英文の#の部分を下線に置き換えます。words.join('')によって,配列をつなげて一つの文字列にした上で置き換えます。

選択肢のシャッフル

function shuffle(opt) {
	shuffled_opt = Array.from(opt);
	for(let i = shuffled_opt.length - 1; i >= 0; i--) {
		const j = Math.floor(Math.random()*(shuffled_opt.length));
		[shuffled_opt[i], shuffled_opt[j]] = [shuffled_opt[j], shuffled_opt[i]];
	}
	return shuffled_opt;
}

・・・・・・

		sort_opt: shuffle(all_texts[id].sort_opt),

選択肢をシャッフルします。シャッフルの作業は外部にshuffle()という関数を作って行います。シャッフルのアルゴリズムについて,説明は省略します。実際に動作させると,問題が表示される度に選択肢のボタンがランダムに並べかえられた状態で表示されるのが確認できると思います。sort_optには並べかえられた選択肢が配列として格納されます。

例えば,もともとの配列が["so","as","not","to","be","late"]の場合,シャッフルの結果,["be","to","not","late","so","as"]という新たな配列が作られます。

その他

		sort_opt_show: new Array(all_texts[id].sort_opt.length).fill(true),

sort_opt_showは選択肢のボタンの表示・非表示を制御する配列を格納します。例えばボタンの数が4個なら[true, true, true, true]という配列が作られます。

ボタンをクリックすると,そのボタンは画面上から消えます。例えば,左から3番目のボタンをクリックしたとき,配列は[true, true, false, true]となり,falseとなったボタンが非表示となる仕組みです。

		sort_opt_record: new Array(),

sort_opt_recordはクリックされたボタンの順番を記録します。初めは空の配列を用意しておきます。例えば,画面上に表示されたボタンを左から順に3個クリックすると,[0, 1, 2]という配列が作られます。

この記録は,あとで「一つ戻る」ボタンを押したときに前の状態に遡るために利用されます。

		BS_show: true,

「一つ戻る」ボタンの表示・非表示を制御します。BS_showtrueのときには画面上にボタンが表示され,falseのときには非表示となります。選択肢のボタンをすべてクリックした時点で「一つ戻る」ボタンは非表示にしたいので,これを制御するためのキーを用意しています。

		description: '',

正解・不正解の文字を表示する部分です。初めは空にしておきます。

		btn_next: false

「次へ」ボタンの表示・非表示を制御する部分です。初めは非表示とし,すべての選択肢がクリックされた時点で表示するようにします。

これで,データの初期値の設定はすべて終わりました。次に,HTML側を見ていきましょう。

HTMLの記述 {{~}} v-html

	<div id="root" class="round-white">
		<p id="ja_st">{{ja_st}}</p>
		<p id="sort_st" v-html="sort_st"></p>
		<button class="btn-option round-white"
			v-for="(option, index) in sort_opt"
			v-if="sort_opt_show[index]"
			@click="input(option, index)">
			{{option}}
		</button>
		<button class="btn-option round-white"
			v-if="BS_show" @click="backward">一つ戻る</button>
		<p v-html="description"></p>
		<button class="btn-option round-white btn-next" 
			v-if="btn_next"
			@click="go_next">
			次へ
		</button>
	</div>

Vue.jsは<div id="root"></div>で囲まれた部分で動作します。コードを見てみると,通常のHTML以外にVue.js独自のコードが混ざった状態で記述されていることに気づくでしょう。

		<p id="ja_st">{{ja_st}}</p>

{{ja_st}}と記述することで,dataに格納された日本文ja_stが表示されます。

		<p id="sort_st" v-html="sort_st"></p>

v-htmlはhtmlコードを表示します。上の{{~}}との違いは,{{~}}は格納された文字列をそのまま表示するのに対して,v-htmlは文字列の中に含まれる<span>などのhtmlのタグをちゃんと解釈して表示してくれるところです。ここでは,英文の一部の色を変えているので,htmlタグを使っています。

v-htmlはセキュリティ上の懸念があるため,なるべく使わない方が良いものです。どうしても必要な個所にだけ用います。

ボタンの表示 v-for v-if @click

		<button class="btn-option round-white"
			v-for="(option, index) in sort_opt"
			v-if="sort_opt_show[index]"
			@click="input(option, index)">
			{{option}}
		</button>

次に,選択肢のボタンを設置します。

			v-for="(option, index) in sort_opt"

v-forは繰り返し処理を行います。配列sort_optのそれぞれの要素を一つずつ処理します。

optionには配列の要素が格納されます。例えば,配列sort_opt=["so","as","not","to","be","late"]となっている場合,optionにはoption="so"option="as",・・・のように,配列の値が一つずつ格納されていきます。考え方としてはforeach文と同じです。こうして,配列の要素の数だけボタンが設置されます。

indexには,0,1,2,・・・と連続する数が一つずつ格納されます。この値はそれぞれのボタンが左から何番目であるかを示し,ボタンがクリックされたときにその値を記録します。そして,「一つ戻る」ボタンが押されたときに,その値を手がかりに前の状態に遡るようにします。

			v-if="sort_opt_show[index]"

v-ifは要素の表示・非表示を制御します。上で述べたように,初め配列sort_opt_showは[true, true, true, true]という状態になっていて,すべてのボタンが表示されます。しかし,ボタンがクリックされると[true, true, false, true]のようになり,falseとなったボタンは非表示になります。

			@click="input(option, index)">
			{{option}}

@clickはボタンがクリックされたときの処理を記述します。あとで登場するメソッドの中にinputという関数を作り,ボタンの文字列optionと番号indexを渡します。

最後に{{option}}でボタンの文字列を表示します。

その他のボタン

		<button class="btn-option round-white"
			v-if="BS_show" @click="backward">一つ戻る</button>
		

「一つ戻る」ボタンを設置します。初め,BS_showfalseになっているので非表示です。ボタンがクリックされるとメソッド内の関数backward()が実行され,一つ前の状態に遡るための処理が行われます。

		<p v-html="description"></p>

正解・不正解の文字を表示する部分です。初めは空の状態です。ここも文字に色を付けるのでv-htmlを用いています。

		<button class="btn-option round-white btn-next" 
			v-if="btn_next"
			@click="go_next">
			次へ
		</button>

「次へ」ボタンを表示します。初めは非表示で,選択肢のボタンがすべてクリックされるとbtn_nexttrueとなり,画面に表示されます。ボタンがクリックされるとメソッド内の関数go_next()が呼び出され,次の問題に移る処理を行います。

メソッド 選択肢のボタンのクリック

メソッドには様々な処理を行う関数を記述します。今回は,ボタンをクリックしたときに関数が実行されます。関数を順番に見ていきましょう。

input: function(option, index) {
			words[p] = option;
			this.sort_st = all_texts[id].sort_st.replace('#', words.join(' '));
			this.sort_opt_record.push(index);
			this.sort_opt_show[index] = false;
			p += 1;

選択肢のボタンがクリックされたときに実行される関数inputです。関数はクリックされたボタンの文字列optionとボタンが左から何番目かを示す番号indexを受け取ります。

初めはp=0となっていて,words[p]にクリックされたボタンの文字列が格納されます。上で述べたように,初めはwords = [' __ ', ' __ ', ' __ ', ' __ ']の状態になっているので,クリックされたボタンが"so"であれば,words = ['so', ' __ ', ' __ ', ' __ ']となります。

こうして,クリックされたボタンの文字を配列wordsに順に格納していきます。

			this.sort_st = all_texts[id].sort_st.replace('#', words.join(' '));

変更されたwordsをもとに,英文の下線部を書き換えたものをsort_stに格納します。ここで記述しているように,dataオブジェクトの中のsort_stに値を格納したい場合にはthis.sort_stとなり,先頭にthis.を付けます。付け忘れると「sort_stが見つからない」とエラーになるので注意しましょう。

sort_stの値を書き換えた時点で,画面表示も変更されます。DOM操作のコードを書かなくても良いところがVue.jsの特徴です。

			this.sort_opt_record.push(index);

クリックされたボタンの情報を記録するsort_opt_recordに新たな要素を追加します。.push()は配列の要素を追加する命令です。

例えば,すでに左から2番目と4番目のボタンをクリックしていて,次に1番目をクリックした場合,sort_opt_record=[1,3]からsort_opt_record=[1,3,0]へ変化します。番号は0から始まります。

			this.sort_opt_show[index] = false;

sort_opt_showfalseにして,indexで指定された番号のボタンを非表示にします。こうして,クリックされたボタンが画面から消える仕組みです。

			p += 1;
			if(p === this.sort_opt.length) {
				this.BS_show = false;
				if(words.join() === all_texts[id].sort_opt.join()) {
					this.description = '<p style="color:#ff6600">正解</p>';
				} else {
					this.description = '<p style="color:#6699ff">不正解</p>';
					this.sort_st = all_texts[id].sort_st.replace('#',
						'<span style="color:#ff6600">' +
						all_texts[id].sort_opt.join(' ') +
						'</span>');
				}
				this.btn_next = true;
			}

pを1つ増やします。pはボタンが何個押されたかを示しています。そして,pが選択肢のボタンの数と等しくなると,正解・不正解の判定を行います。まず,this.BS_show = falseで「一つ戻る」ボタンを非表示にして,クリックされた文字列と正解の文字列を比較します。

join()は配列をつなげて一つの文字列にします。

たとえば,all_texts[id].sort_opt=["so","as","not","to","be","late"]となっているとします。正しい順にボタンがクリックされると,words.join()="so,as,not,to,be,late"となり,all_texts[id].sort_opt.join()"so,as,not,to,be,late"となるので,文字列が一致し,正解であることを画面に表示します。

反対に,文字列どうしが一致しなければ,不正解であることを表示し,同時に正解の文字列を赤文字で表示します。

判定を終えるとthis.btn_next = trueで「次へ」ボタンを表示します。

「次へ」ボタンのクリック

		go_next: function() {
			id += 1;
			if(id === all_texts.length) {
				this.description = 'すべての問題が終了しました。';
				this.btn_next = false;
			} else {
				p = 0;
				words = new Array(all_texts[id].sort_opt.length).fill(' __ ');
				this.ja_st = all_texts[id].ja_st;
				this.sort_st = all_texts[id].sort_st.replace("#", words.join(''));
				this.sort_opt = shuffle(all_texts[id].sort_opt);
				this.sort_opt_show = new Array(all_texts[id].sort_opt.length).fill(true);
				this.sort_opt_record = new Array();
				this.BS_show = true;
				this.description = '';
				this.btn_next = false;
			}
		}

「次へ」ボタンをクリックしたときに呼び出される関数go_nextを見ていきましょう。

まず,id += 1として問題番号を一つ増やします。もし用意した問題数と一致したら,「すべての問題が終了しました。」というメッセージを表示して,プログラムを終了します。

反対に,次に表示できる問題が存在する場合は,dataで初期設定したときと同じように,問題文などのデータを新たに格納していきます。ja_stsort_stに新たな値を格納した時点で画面の表示も次の問題に切り替わります。

「一つ戻る」ボタンのクリック

backward: function() {
			if(p > 0) {
				const index = this.sort_opt_record.pop();
				this.$set(this.sort_opt_show, index, true);
				p -= 1;
				words[p] = ' __ ';
			}
			this.sort_st = all_texts[id].sort_st.replace('#', words.join(' '));
		},

「一つ戻る」ボタンがクリックされたときに呼び出される関数backwardです。このボタンがクリックされると直前のボタンがクリックされる前に戻ります。

たとえば,sort_opt_record=[2, 3, 1]であるとします。これは,選択肢のボタンが左から3番目,4番目,2番目の順にクリックされたことを意味します。値は0から始まります。

.pop()は配列の最後の要素を取り出し,indexに格納します。ここでは,index=1となり,同時にsort_opt_record=[2, 3]となって,配列の最後の要素が削除されます。ボタンを連続してクリックすると,同じ動作によって次々と前の状態に戻っていきます。

このようにして,今回のプログラムではpush()pop()を用いて記録の追加と遡りを実現します。

				this.$set(this.sort_opt_show, index, true);

次に,ボタンの表示・非表示を制御する配列sort_opt_showindexで指定された要素をtrueにして画面の表示に反映させます。index=1であれば,sort_opt_show=[true, false, false, false]ort_opt_show=[true, true, false, false]に変化して,一つ前の状態に戻ります。

ここで,this.$set()というVue.jsの命令を用いて配列の値を変更しています。通常,dataの中に作った要素の値が変更されると同時に画面に反映されるのですが,dataに配列を使用するとうまくいきません。

その原因については他のさまざまなサイトで言及されていますが,とりあえずうまくいかないときにはthis.$set()を用いて配列の値を変更すれば良い,と解釈しておきましょう。

				p -= 1;
				words[p] = ' __ ';

pを一つ前に戻し,入力された語句を再び下線に戻します。

			this.sort_st = all_texts[id].sort_st.replace('#', words.join(' '));

一つ前の状態に戻した配列をjoin()で結合してsort_stに格納し,英文を一つ前の状態に戻します。

コードの説明は以上です。

まとめ

ここでは,Vue.jsを用いて英語句並べかえ問題を表示するwebアプリを作成しました。前回作成した語句選択問題と比べて,遡りの処理が増えた分コードがやや複雑になりました。一方で,DOM操作が不要であるため,動作の流れが掴みやすくシンプルなコードを記述することができました。

裏側の処理が複雑になるほど,Vue.jsの恩恵を感じることができるでしょう。

最後にコード全体を示します。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>fasgram 英文法トレーニングwebアプリ Vue.js 版</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Kosugi+Maru&display=swap');
body{font-family:'Noto Serif','Kosugi Maru',serif;font-size: 24px;
	background-color: #fafafa;}
#header{font-size:0.75rem;}
#container{max-width:680px;}
#ja_st{font-size:1.0rem;}
#sort_st{font-size:1.25rem;}
.btn-next{width:100%;}
.round-white{margin:5px 2px;padding: 1.25rem 1.25rem;
	border:1px solid #ccc;border-radius:0.5em;
	background:#fff;box-shadow:0px 1px 3px rgba(0, 0, 0, 0.16);
	outline: none;}
.btn-option{font-family:'Noto Serif','Kosugi Maru',serif;font-size:1.25rem;}
</style>
</head>
<body>
<div id="container">
	<p id="header">英文法トレーニングwebアプリ fasgram Vue.js 版</p>
	<div id="root" class="round-white">
		<p id="ja_st">{{ja_st}}</p>
		<p id="sort_st" v-html="sort_st"></p>
		<button class="btn-option round-white"
			v-for="(option, index) in sort_opt"
			v-if="sort_opt_show[index]"
			@click="input(option, index)">
			{{option}}
		</button>
		<button class="btn-option round-white"
			v-if="BS_show" @click="backward">一つ戻る</button>
		<p v-html="description"></p>
		<button class="btn-option round-white btn-next" 
			v-if="btn_next"
			@click="go_next">
			次へ
		</button>
	</div>
</div>
<script>
const all_texts = get_texts();
let id = 0;
let p = 0;
let ans;
let words = new Array(all_texts[id].sort_opt.length).fill(' __ ');
function shuffle(opt) {
	shuffled_opt = Array.from(opt);
	for(let i = shuffled_opt.length - 1; i >= 0; i--) {
		const j = Math.floor(Math.random()*(shuffled_opt.length));
		[shuffled_opt[i], shuffled_opt[j]] = [shuffled_opt[j], shuffled_opt[i]];
	}
	return shuffled_opt;
}
let app = new Vue({
	el: '#root',
	data: {
		ja_st: all_texts[id].ja_st,
		sort_st: all_texts[id].sort_st.replace("#", words.join('')),
		sort_opt: shuffle(all_texts[id].sort_opt),
		sort_opt_show: new Array(all_texts[id].sort_opt.length).fill(true),
		sort_opt_record: new Array(),
		BS_show: true,
		description: '',
		btn_next: false
	},
	methods: {
		input: function(option, index) {
			words[p] = option;
			this.sort_st = all_texts[id].sort_st.replace('#', words.join(' '));
			this.sort_opt_record.push(index);
			this.sort_opt_show[index] = false;
			p += 1;
			if(p === this.sort_opt.length) {
				this.BS_show = false;
				if(words.join() === all_texts[id].sort_opt.join()) {
					this.description = '<p style="color:#ff6600">正解</p>';
				} else {
					this.description = '<p style="color:#6699ff">不正解</p>';
					this.sort_st = all_texts[id].sort_st.replace('#',
						'<span style="color:#ff6600">' +
						all_texts[id].sort_opt.join(' ') +
						'</span>');
				}
				this.btn_next = true;
			}
		},
		backward: function() {
			if(p > 0) {
				const index = this.sort_opt_record.pop();
				this.$set(this.sort_opt_show, index, true);
				p -= 1;
				words[p] = ' __ ';
			}
			this.sort_st = all_texts[id].sort_st.replace('#', words.join(' '));
		},
		go_next: function() {
			id += 1;
			if(id === all_texts.length) {
				this.description = 'すべての問題が終了しました。';
				this.btn_next = false;
			} else {
				p = 0;
				words = new Array(all_texts[id].sort_opt.length).fill(' __ ');
				this.ja_st = all_texts[id].ja_st;
				this.sort_st = all_texts[id].sort_st.replace("#", words.join(''));
				this.sort_opt = shuffle(all_texts[id].sort_opt);
				this.sort_opt_show = new Array(all_texts[id].sort_opt.length).fill(true);
				this.sort_opt_record = new Array();
				this.BS_show = true;
				this.description = '';
				this.btn_next = false;
			}
		}
	}
});
//問題文データ
function get_texts() {
	const all_texts = [
		{ "ja_st": "私は翌日の授業に遅れないように早く寝た。",
		"sort_st": "I went to bed early # to class next day.",
		"sort_opt": ["so","as","not","to","be","late"]
		},
		{ "ja_st": "私のルームメートは私が買ったケーキを食べたかもしれない。",
		"sort_st": "My roommate #.",
		"sort_opt": ["might","have","eaten","the cake","I","bought"]
		},
		{ "ja_st": "彼は森の中で迷子になった。さらに悪いことに,雨が降り始めた。",
		"sort_st": "He was lost in the forest.#, it began to rain.",
		"sort_opt": ["to","make","the","matter","worse"]
		},
		{ "ja_st": "外国語を習得するには多大な努力が必要だ。",
		"sort_st": "It takes # a foreign language.",
		"sort_opt": ["a","lot","of","effort","to","master"]
		},
		{ "ja_st": "多くの人々は新鮮な空気が無料であることを当然とみなしている。",
		"sort_st": "Many people # fresh air is free.",
		"sort_opt": ["take","it","for","granted","that"]
		}];
	return all_texts;
}
</script>
</body>
</html>