【Vue3でwebアプリをつくろう2】ファイルを分割する(Vue CLIを使わずにコンポーネントを外部ファイル化)

記事の一覧はこちらから。

前回,Vue3の基本的な機能を使って英文法を学習するアプリをつくりました。しかしながら,一つのファイルの中に色々と詰め込むとコードの解読がかなり煩雑になり,これ以上アプリの機能を拡張することは困難です。

アプリがある程度の規模になった状態でさらに拡張していくためには,機能ごとにファイルを分割していくべきです。

開発に必要なもの

ローカルでwebサーバーを動かすためのXAMPPと,コードを書くためのVisual Sutio Codeをインストールしてください。ここからはXAMPPでApacheを起動していることが前提になります。

Vue CLIを使わないでもファイル分割は可能

Vueでコードを分割する場合,一般的にはVue CLIを用いて単一ファイルコンポーネントというコードを書きながら開発していきます。とは言え,非エンジニアの人にとってはVue CLIを用いた開発環境の構築はかなりやっかいな作業です。そこで,ここでは開発に用いるツールをXAMPPとVS Codeに限定して話を進めていきたいと思います。

プロ向けの開発環境がなくても,Vueのファイルを分割して書くことは可能です。非エンジニアの人はここで紹介する方法を試してみると良いでしょう。

ファイルの分割

一度,前回の記事のコードを確認してください。

全体の構造としては

<html>
<head> ~ </head>
<body>
  ブロック要素
<script>
  JavaScriptのコード
</script>
<style>
  スタイルシート
</style>
</body>
</html>

となっています。

まず,これをHTMLとJavaScript,スタイルシートの3つのファイルに分割しましょう。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>fasgram 英文法トレーニングwebアプリ</title>
  <script src="https://unpkg.com/vue@next"></script>
  <script src="main.js" type="module"></script>
  <link rel="stylesheet" href="fasgram2.css">
</head>
<body>
  <div id="header" class="size-4">fasgram 英文法トレーニングwebアプリ動作サンプル</div>
  <div id="container">
	  <div id="root"></div>
  </div>
</body>
</html>

HTMLのファイルです。

<script src="https://unpkg.com/vue@next"></script>でVueを読み込みます。

次に,<script src="main.js" type="module"></script>でJavaScriptのファイルmain.jsを読み込みます。あとで述べますが,ファイル分割で必要になるexportimportの機能を使うためにtype="module"を書き加えておきましょう。

また,<link rel="stylesheet" href="fasgram2.css">でスタイルシートfasgram2.cssを読み込みます。

こうして,JavaScriptとスタイルシートを別ファイルにして,それらを読み込むことで動作させます。

JavaScriptが読み込まれると,Vueが<div id="root"></div>の部分にアプリを表示していきます。

さらにJavaScriptのコードを分割していきましょう。

const texts={ ~ } //問題文データ
選択肢のシャッフル
~
const RootComponent = { ~ }  //ルートコンポーネント
const app = Vue.createApp(RootComponent); //Vueのインスタンス
app.component('expository', { ~ }); //解説文コンポーネント
const elem = document.querySelector('#root'); 
app.mount(elem); //アプリケーションのマウント

全体の構造は上のようになっています。ここから,問題文データと選択肢のシャッフル,解説文コンポーネントのコードを別ファイルに分割したいと思います。

配列のimportとexport

export const allData = [
{
	category: "不定詞",
	expository: {
		item: "不定詞の否定",
・・・・・・
];

問題文データです(中身は省略)。このように配列の宣言の前にexportを加えます。これをallData.jsとして保存します。

import {allData} from './allData.js'

main.jsの側ではimportを書いてデータを受け取ります。importは一般的にファイルの先頭に書きます。またimport

import {名前} from ‘ファイル名’

という形で記述します。ファイル名の前には./../を付けないといけないことになっています。ファイルが同じフォルダ内にあるなら./を付けましょう。

これで,別ファイルで作った配列allDatamain.jsの側で使えるようになります。扱うものが変数やオブジェクトであっても書き方は同じです。

関数のimportとexport

export function shuffleOptions(elem) {
 ・・・・・・
 return elem;
}

選択肢のボタンをランダムに入れ替えるコードを関数にして,外部ファイルから取り込むことにします。

関数はfunctions.jsに書き,先ほどと同様に関数の宣言の前にexportを書き加えます。

import {shuffleOptions} from './functions.js'

//コードの中で
shuffleOptions();
//と書くと,関数が実行される。

main.jsimportを書きます。このように関数も変数と同じように取り込むことができます。

Vueコンポーネントのimportとexport

最後にVueのコンポーネントを外部から取り込んでみましょう。

export const componentExpository = {
	template: `
	<div class="expository">
    ・・・・・・
	</div>`,
	props: ['data']
};

はじめ,コンポーネントはapp.component('コンポーネント名', { ~ });の形で書いていましたが,{ ~ }に記述していた部分をcomponentExpositoryというオブジェクトとして書いておきます。これをvueComponents.jsとして保存します。

import {componentExpository} from './vueComponents.js'

//コンポーネントの設置
app.component('expository', componentExpository);

main.jsの側でimportしましょう。従来,{ ~ }で記述していた部分をオブジェクトに置き換えます。

まとめ

ここでは,今まで一つのファイルの中に書いていたコードを複数のファイルに分割する方法を学びました。これによって,今後アプリに新しい機能を追加する場合にコードが複雑になるのを防ぎ,情報を整理しながら開発を進めていくことができます。

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

<!DOCTYPE html>
<html lang="ja">
<head>
	<meta charset="utf-8">
	<meta name="viewport" content="width=device-width,initial-scale=1">
	<title>fasgram 英文法トレーニングwebアプリ</title>
	<script src="https://unpkg.com/vue@next"></script>
	<script src="main.js" type="module"></script>
	<link rel="stylesheet" href="fasgram2.css">
</head>
<body>
	<div id="header" class="size-4">fasgram 英文法トレーニングwebアプリ動作サンプル</div>
	<div id="container">
		<div id="root"></div>
	</div>
</body>
</html>
import {allData} from './allData.js'
import {componentExpository} from './vueComponents.js'
import {shuffleOptions} from './functions.js'

const texts = shuffleOptions(alldata); //選択肢のシャッフル

//ルートコンポーネント
const RootComponent = {
	data() { return { 
		index: 0, //問題番号
		correct_incorrect: '',		//正解不正解の判定
		texts: texts,							//問題文
		show: {										//表示・非表示の制御
			question: true,					//問題文
			expository: false,			//解説文
			options: true,					//選択肢のボタン
			next: false							//「次へ」ボタン
		}
	}},
	//テンプレート
	template: `
		<transition name="fade" @after-leave="afterLeave">
			<div class="frame-round-white" v-if="show.question">
				<p class="instruction">適切な英文になるように,( )に当てはまる語句を1つクリックしなさい。</p>
				<p class="ja-st">{{ jaSentence }}</p>
				<p class="en-st" v-html="enSentence()"></p>
			</div>
		</transition>
		<div id="btn-options" v-if="show.options">
			<button class="btn-option"
				v-for="opt in opts"
				@click="judgement(opt)">
				{{ opt }}
			</button>
		</div>
		<div class="btn-wrapper">
			<button id="btn_next" class="btn-option" 
				v-if="show.next" @click="goNext">
				次へ
			</button>
		</div>
		<transition name="fade">
			<expository :data="expositoryObj" v-if="show.expository"></expository>
		</transition>`,
	//算出プロパティ
	computed: {
		jaSentence() { return this.texts[this.index].ja },
		opts() { return this.texts[this.index].select.opt },
		expositoryObj() { return this.texts[this.index].expository }
	},
	//メソッドの定義
	methods: {
    //英文の表示
 		enSentence() {
			let replacement;
			if(this.correct_incorrect == 'correct') {
				replacement = `
					<span class="answer-correct">
						${this.texts[this.index].select.answer}
					</span>`;
			} else if(this.correct_incorrect == 'incorrect') {
				replacement = `
					<span class="answer-incorrect">
						${this.texts[this.index].select.answer}
					</span>`;
			} else {
				replacement = '(    )';
			}
			return this.texts[this.index].select.sentence.replace('#', replacement);
		},
		//正解・不正解の判定
		judgement(opt) {
			this.show.options = false;
			this.show.next = true;
			if(opt == this.texts[this.index].select.answer) {
				this.correct_incorrect = 'correct';
			} else {
				this.correct_incorrect = 'incorrect';
				this.show.expository = true;
			}
		},
		//次の問題に進む
		goNext() {
			this.show.question = false; //問題文を消す
		},
		afterLeave() {
			if(this.index >= this.texts.length - 1) { //次の問題がなければ終了
				this.show.next = false; //「次に」ボタンを消す
				this.show.expository = false; //解説文を消す
				console.log('終了');
			} else {
				this.show.next = false; //「次に」ボタンを消す
				this.show.expository = false; //解説文を消す
				this.correct_incorrect = ''; //正解不正解の判定をリセット
				this.index++;	//次の問題に進む
				this.show.question = true; //問題文を表示
				this.show.options = true; //選択肢を表示
			}
		}
	}
}
//Vueのインスタンス
export const app = Vue.createApp(RootComponent);
app.component('expository', componentExpository);
//アプリケーションのマウント
const elem = document.querySelector('#root');
app.mount(elem);
export const allData = [
{
	category: "不定詞",
	expository: {
		item: "不定詞の否定",
		en: "not to ~",
		ja: "~しないために",
		detail: "不定詞の否定の形は not to 動詞原形 となります。間違えて to not ~ としやすいので注意しましょう。" },
	en: "My father told me not to work too hard.",
	ja: "私の父は私にあまりに働きすぎないように言った。",
	fill_sentence: "My father told me # work too hard.",
	fill_answer: "not to",
	select: {
		sentence: "My father told me # work too hard.",
		opt: ["not to","don't","not","without"],
		answer: "not to" }},
{
	category: "不定詞",
	expository: {
		item: "expect ~ to ・・・",
		en: "expect ~ to ・・・",
		ja: "~が・・・すると予想,期待する。",
		detail: "" },
	en: "I liked his new house, but I hadn't expected it to be so small.",
	ja: "私は彼の新しい家を気に入ったが,それがあれほど小さいとは予想していなかった。",
	fill_sentence: "I liked his new house, but I hadn't expected it # so small.",
	fill_answer: "to be",
	select: {
		sentence: "I liked his new house, but I hadn't expected it # so small.",
		opt: ["be","of being","to be","to being"],
		answer: "to be" }}
]
//選択肢のボタンが自動的にシャッフルされ,ボタンが表示される順番が変わる
//必要がなければ削除してよい
export function shuffleOptions(elem) {
	for(let i = 0; i < elem.length; i++) {
		let opts = elem[i].select.opt;
		for(let j = 0; j < 100; j++) {
			let a = Math.floor(Math.random() * opts.length);
			let b = Math.floor(Math.random() * opts.length);
			[opts[a], opts[b]] = [opts[b], opts[a]];
		}
		elem[i].select.opt = opts;
	}
	return elem;
}
export const componentExpository = {
	template: `
	<div class="expository">
		<p class="expository-item">
			<span class="material-icons">hdr_strong</span>
			<span v-html="this.data.item" />
		</p>
		<p class="expository-phrase-wrapper">
			<span class="expository-en" v-html="this.data.en" />
			<span class="expository-ja" v-html="this.data.ja" />
		</p>
		<p class="expository-detail" v-html="this.data.detail" />
	</div>`,
	props: ['data']
};
@charset "UTF-8";
@import url("https://fonts.googleapis.com/css2?family=Noto+Serif&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Kosugi+Maru&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Material+Icons&display=swap");
body { font-family: 'Noto Serif', 'Kosugi Maru', serif;
    font-size: 18px; background-color: #fafafa; margin: 0px; padding: 0px; }
p { margin: 0.5rem; }
span { display: inline-block; }
#header { padding: 4px; background-color: #51ab9f; color: #fff;
    box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.16); }
#container { max-width: 680px; margin-right: auto; margin-left: auto;
    padding: 10px; }
.size-4 {font-size: 0.75rem;}
.instruction { font-size: 0.75rem; padding-bottom: 1rem; }
.ja-st { font-size: 1rem; font-weight: 400; }
.en-st { padding-left: 8px; font-size: 1.25rem; font-weight: 400; }
.frame-round-white { margin: 5px 2px; padding: 1rem 1rem;
    border: 1px solid #ccc; border-radius: 0.5em; background: #fff;
    box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.16);
    outline: none; }
.btn-wrapper {
    display: flex; justify-content: center; align-items: center;
    flex-wrap: wrap; width: auto; margin-top: 2rem; }
.btn-option {
    font-family: 'Noto Serif', 'Kosugi Maru', serif;
    font-size: 1.25rem; font-weight: 400; margin: 5px 2px;
    padding: 0.75rem 1.25rem; border: 1px solid #ccc;
    border-radius: 0.5rem; background: #fff;
    box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.16);
    outline: none; }
.expository {
    margin-top: 16px; padding: 8px; background: #fff;
    box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.16);
    border: 1px solid #ccc; border-radius: 8px; }
.expository-item {
    display: flex; vertical-align: center;
    font-size: 1rem; border-bottom: 2px solid #ccc; padding: 4px; }
.expository-phrase-wrapper {
    display: flex; align-items: center; }
.expository-en {
    color: #51ab9f; font-size: 1.5rem; font-weight: 700; }
.expository-ja {
    font-size: 1rem; font-weight: 700; padding-left: 1rem; }
.expository-detail { font-size: 1rem; }
.material-icons { margin-right: 4px; color: #51ab9f; }
.answer-correct { font-family: 'Kosugi Maru', serif; font-weight: 700;
    color: #51ab9f; border-bottom: 3px solid #51ab9f; }
.answer-incorrect { position: relative; font-family: 'Kosugi Maru', serif;
    font-weight: 700; color: #c6292c; }
.answer-incorrect:before {
    position: relative; font-weight: 900; content: '正解は';
    background: #51ab9f; color: #fff;
    border-radius: 5px; margin-right: 5px;
    padding: 3px 7px 1px; font-size: 0.7em; line-height: 1; }
.fade-enter-active,
.fade-leave-active {
    transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
    opacity: 0;
}