Chapter 09

コンポーネント

OJK
OJK
2021.11.15に更新

いざアプリケーションを作りはじめると、部分毎に分けて HTML・CSS・JavaScript をまとめて記述したくなります。このまとまりのことを “コンポーネント/Component” といいます。

残念ながら、petite-vue にはファイル単位で HTML・CSS・JavaScript をまとめてコンポーネントとして扱う機能はありません。HTML は index.html に、JavaScript は script.js に、それぞれ全てのコンポーネントを記述することになります。それでもコアの部分を切り出して別の場所に移動するだけでアプリケーションの構成全体の見通しはよくなるので、習作であっても何かしらアプリケーションを制作するときはコンポーネントに分けましょう。

本チャプターでは、以下のモックアップアプリをコンポーネントに分けていきたいと思います。まずはこれら 3 つのファイルを用意して実行し、アプリケーションの動作を確認してみてください。

ソースコード
index.html
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>すごいアプリ</title>
  <link href="style.css" rel="stylesheet">
  <style>
    [v-cloak] { display: none; }
  </style>
</head>

<body v-cloak>

  <!-- ヘッダー -->
  <header>
    <h1>すごいアプリ</h1>
  </header>

  <!-- アプリ部分 -->
  <div id="app">

    <!-- 画面切替 -->
    <nav>
      <ul>
        <li @click="setScreen(1)">すごいこと</li>
        <li @click="setScreen(2)">すごいやつ</li>
        <li @click="setScreen(0)">つかいかた</li>
      </ul>
    </nav>

    <!-- 画面1 -->
    <div id="screen1" v-if="screen == 1">
      <form @submit.prevent autocomplete="off">
        <label>
          身近にあったすごいこと<br>
          <input id="matter" v-model="sMatter">
        </label>
        <button @click="addMatter">入力</button>
      </form>
      <p>すごいことリスト</p>
      <ul>
        <li v-for="matter in sMatters">{{matter}}</li>
      </ul>
    </div>

    <!-- 画面2 -->
    <div id="screen2" v-else-if="screen == 2">
      <form @submit.prevent autocomplete="off">
        <label>
          身近にいたすごいやつ<br>
          <input id="person" v-model="sPerson">
        </label>
        <button @click="addPerson">入力</button>
      </form>
      <p>すごいやつリスト</p>
      <ul>
        <li v-for="person in sPeople">{{person}}</li>
      </ul>
    </div>

    <!-- 画面3 -->
    <div id="howto" v-else>
      <p>
        この「すごいアプリ」は、あなたの身近にあったすごいことや身近にいるすごいやつを登録して、あなたの好きなときに「すごいなぁ…」と感心できるアプリです
      </p>
      <p>つかいかた</p>
      <ul>
        <li>身近にすごいことがあれば「すごいこと」メニューからそのことを追加します</li>
        <li>身近にすごいやつがいれば「すごいやつ」メニューからその人を追加します</li>
        <li>感心したいときにそれらを見ます
        </li>
      </ul>
      <small>[注意]このアプリにデータの保存機能はありません</small>
    </div>

  </div>

  <!-- 付録情報 -->
  <div id="links">
    君も世界のすごいことに<a href="https://www.guinnessworldrecords.jp/"> >>>[登録!]</a><br>
    君もすごいやつコミュに<a href="https://mensa.jp/"> >>>[参加!]</a>
  </div>

  <!-- フッター -->
  <footer>
    ― すごいアプリ制作委員会 ―
  </footer>

  <!-- <script src="https://unpkg.com/vue@next"></script> -->
  <script src="https://unpkg.com/petite-vue"></script>
  <script src="script.js"></script>
</body>

</html>
script.js
'use strict';

PetiteVue.createApp({
  // データプロパティ
  screen: 0,
  sMatter: '',
  sPerson: '',
  sMatters: [],
  sPeople: [],

  // メソッド
  setScreen(n) {
    this.screen = n;
  },
  addMatter() {
    if (this.sMatter != '') {
      this.sMatters.push(this.sMatter);
      this.sMatter = '';
    }
  },
  addPerson() {
    if (this.sPerson != '') {
      this.sPeople.push(this.sPerson);
      this.sPerson = '';
    }
  },
}).mount();
style.css
body {
  width: 375px;
  margin: 15px auto;
  box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.5);
}

#app {
  padding: 0 20px 10px;
}

nav ul {
  padding-left: 0;
  list-style-type: none;
  display: flex;
  justify-content: space-between;
}

nav li {
  width: 100px;
  text-align: center;
  background-color: coral;
  border-radius: 5px;
  color: white;
  padding: 2px;
  cursor: pointer;
}

header {
  background-color: royalblue;
  height: 75px;
}

header h1 {
  color: white;
  text-align: center;
  line-height: 75px;
  margin: 0;
}

form {
  margin-bottom: 15px;
}

input {
  width: 275px;
  height: 20px;
}

form button {
  color: white;
  background-color: royalblue;
  border-style: none;
  padding: 2px 5px;
  line-height: 22px;
  margin-left: 5px;
}

form + p {
  color: royalblue;
  font-weight: bold;
  border-bottom: solid 3px royalblue;
  margin-top: 16px;
}

form + p + ul {
  padding-left: 2px;
  margin-top: 0;
}

form + p + ul li {
  list-style-type: none;
}

form + p + ul li::before {
  content: '🔥 ';
}

#howto p:first-child {
  border: solid royalblue 4px;
  padding: 5px 10px;
}

#howto ul {
  margin-top: 0;
  padding-left: 24px;
}

#howto li {
  margin-bottom: 8px;
}

#howto p:nth-of-type(2) {
  font-weight: bold;
  color: royalblue;
  margin-bottom: 4px;
}

#links {
  margin: 0 10px 10px;
  padding: 8px 20px 5px;
  color: white;
  background-color: coral;
}

a {
  color: white;
  text-decoration: none;
}

a:visited {
  color: white;
}

footer {
  font-size: 0.8em;
  color: white;
  background-color: royalblue;
  text-align: center;
  padding: 10px;
}

HTML のコンポーネント化

HTML のコンポーネント化(正確には断片化というべきですが)には template 要素を使います。template 要素といえば、v-if や v-for で仮の親要素(ラッパー要素)として紹介してきましたが、こちらのほうが本来の用途です。

さて、サンプルのような単純なアプリケーションでもそれなりのコード量になるので、全体像を掴むのが辛くなってきます。主要なタグだけ抽出すると以下のようにわかりやすくなります。

index.html
<!-- ヘッダー -->
<header></header>

<!-- アプリ部分 -->
<div id="app">
  <!-- 画面切替(screenの値を設定) -->
  <nav></nav>

  <!-- 画面1 -->
  <div id="screen1" v-if="screen == 1">...</div>
  <!-- 画面2 -->
  <div id="screen2" v-else-if="screen == 2"></div>
  <!-- 画面3 -->
  <div id="howto" v-else></div>
</div>

<!-- 付録情報 -->
<div id="links"></div>

<!-- フッター -->
<footer></footer>

アプリケーションの中心となる 3 つの画面の部分だけでも省略して書けると見通しが良くなりそうですね。ということで、それら 3 箇所を template 要素に切り出してみましょう。

ここでは画面 1 のみ例示します。
切り出し前の div 要素は以下のとおり。id 属性と v-if が付いています。ここから div 要素の中身だけを取り出して template 要素にそっくり移動します。

template要素に切り出す前の画面1の記述
<!-- 画面1 -->
<div id="screen1" v-if="screen == 1">
  <form @submit.prevent autocomplete="off">
    <label>
      身近にあったすごいこと<br>
      <input id="matter" v-model="sMatter">
    </label>
    <button @click="addMatter">入力</button>
  </form>
  <p>すごいことリスト</p>
  <ul>
    <li v-for="matter in sMatters">{{matter}}</li>
  </ul>
</div>

template 要素は同じ HTML ファイルの body 要素内であればどこに記述しても構いません。ここでは body 要素の末尾、script 要素の手前に記述することにします。

切り出した template 要素には id 属性が必須です。id 名は「元の id 名-template」や「元の id 名-tmpl」としておくと混乱しないでしょう。

画面1の内容をtemplate要素に切り出し
<template id="screen1-tmpl">
  <form @submit.prevent autocomplete="off">
    <label>
      身近にあったすごいこと<br>
      <input id="matter" v-model="sMatter">
    </label>
    <button @click="addMatter">入力</button>
  </form>
  <p>すごいことリスト</p>
  <ul>
    <li v-for="matter in sMatters">{{matter}}</li>
  </ul>
</template>

元の場所に残された div 要素は開始タグと終了タグだけになります。v-if を使っている場合は div 要素を template 要素で囲んで、v-if は template 要素に移します。こうしないと v-if が正しく機能しません[1]。 v-if を使っていない場合は div 要素を template 要素で囲う必要はありません。

元の場所に残されたdiv要素
<template v-if="screen == 1">
  <div id="screen1"></div>
</template>

残された元の div 要素にはもうひとつ重要な属性(ディレクティブ)を設定しないといけないのですが、先に JavaScript の説明に移ります。

JavaScript のコンポーネント化

JavaScript のほうは次のように「関数」として切り出して、createApp メソッドの外に記述します(どこに置いても構いません)。ちょっと独特に見えるかもしれませんが、関数定義の先頭からオブジェクトを return して、そのオブジェクト内に記述していきます。

画面1のJavaScript コンポーネント
function Screen1() {
  return {
    // 対応するtemplate要素の指定
    $template: '#screen1-tmpl',

    // データプロパティ
    sMatter: '',
    sMatters: [],

    // メソッド
    addMatter() {
      if (this.sMatter != '') {
        this.sMatters.push(this.sMatter);
        this.sMatter = '';
      }
    }
  };
}

関数名は自由に付けて構いませんが、HTML 側の div 要素の id 名に合わせるのがよいと思います。先頭を大文字にするのがどうやら慣習のようです。本講座では、この関数を「コンポーネント関数」と以降は呼ぶことにします。

コンポーネント関数に切り出すのは、画面 1 にのみ関係するデータプロパティとメソッドです。複数のコンポーネントにまたがって利用されるものを持ってきてはいけません。コンポーネント関数内で定義したデータプロパティやメソッドは他のコンポーネントから参照できないからです。

また、$template という見慣れないプロパティがありますが、ここで「このコンポーネントと対応する template 要素」を id 名で指定します。この記述が無い or 間違っていると、HTML 側で template 要素に切り出した部分が置き換えられないので気をつけてください。

これまで「Vue を使う」という注釈を見てきた人は気づいたかと思いますが、関数定義でいきなりオブジェクトを return する書き方は、本家 Vue のデータプロパティと同じです(data 関数)。React など、他の JavaScript フレームワークでもよく見られる書き方ですので、こういうものだと慣れてください。

3 つの画面をコンポーネント関数に切り出すと、createApp メソッドは次のようになります。

残ったcreateAppメソッド(未完成)
PetiteVue.createApp({
  // データプロパティ
  screen: 0,

  //メソッド
  setScreen(n) {
    this.screen = n;
  }
}).mount();

これではまだ動きません。コンポーネント関数と createApp メソッドが紐付いていないからです。紐付けは簡単で、createApp メソッドの引数内にコンポーネント関数の名前を列挙するだけです。ここでは Screen1、Screen2、Screen3 という名前を付けたものとします。

createAppメソッド(ひとまず完成)
PetiteVue.createApp({
  // データプロパティ
  screen: 0,

  // メソッド
  setScreen(n) {
    this.screen = n;
  }

  // コンポーネント関数
  Screen1,
  Screen2,
  Screen3
}).mount();

では、HTML に戻って仕上げましょう。テンプレートと置き換える予定の div 要素と JavaScript 側のコンポーネント関数を v-scope ディレクティブ によって結びつけます。

<template v-if="screen == 1">
  <div id="screen1" v-scope="Screen1()"></div>
</template>

v-scope の値は「関数名」ではなく「関数呼び出し」であることに気をつけてください。ここでは関数を登録するのではなく、関数から return されるオブジェクトを受け渡すからです。

ここまでのコード
index.html
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>すごいアプリ</title>
  <link href="style.css" rel="stylesheet">
  <style>
    [v-cloak] { display: none; }
  <style>
</head>

<body v-cloak>

  <!-- ヘッダー -->
  <header>
    <h1>すごいアプリ</h1>
  </header>

  <!-- アプリ部分 -->
  <div id="app">

    <!-- 画面切替 -->
    <nav>
      <ul>
        <li @click="setScreen(1)">すごいこと</li>
        <li @click="setScreen(2)">すごいやつ</li>
        <li @click="setScreen(0)">つかいかた</li>
      </ul>
    </nav>

    <!-- 画面1 -->
    <template v-if="screen == 1">
      <div id="screen1" v-scope="Screen1()"></div>
    </template>

    <!-- 画面2 -->
    <template v-else-if="screen == 2">
      <div id="screen2" v-scope="Screen2()"></div>
    </template>

    <!-- 画面3 -->
    <template v-else>
      <div id="howto" v-scope="Screen3()"></div>
    </template>
  </div>

  <!-- 付録情報 -->
  <div id="links">
    君も世界のすごいことに<a href="https://www.guinnessworldrecords.jp/"> >>>[登録!]</a><br>
    君もすごいやつコミュに<a href="https://mensa.jp/"> >>>[参加!]</a>
  </div>

  <!-- フッター -->
  <footer>
    ― すごいアプリ制作委員会 ―
  </footer>


  <!-- **テンプレート** -->

  <!-- 画面1 -->
  <template id="screen1-tmpl">
    <form @submit.prevent autocomplete="off">
      <label>
        身近にあったすごいこと<br>
        <input id="matter" v-model="sMatter">
      </label>
      <button @click="addMatter">入力</button>
    </form>
    <p>すごいことリスト</p>
    <ul>
      <li v-for="matter in sMatters">{{matter}}</li>
    </ul>
  </template>

  <!-- 画面2 -->
  <template id="screen2-tmpl">
    <form @submit.prevent autocomplete="off">
      <label>
        身近にいたすごいやつ<br>
        <input id="person" v-model="sPerson">
      </label>
      <button @click="addPerson">入力</button>
    </form>
    <p>すごいやつリスト</p>
    <ul>
      <li v-for="person in sPeople">{{person}}</li>
    </ul>
  </template>

  <!-- 画面3 -->
  <template id="howto-tmpl">
    <p>
      この「すごいアプリ」は、あなたの身近にあったすごいことや身近にいるすごいやつを登録して、あなたの好きなときに「すごいなぁ…」と感心できるアプリです
    </p>
    <p>つかいかた</p>
    <ul>
      <li>身近にすごいことがあれば「すごいこと」メニューからそのことを追加します</li>
      <li>身近にすごいやつがいれば「すごいやつ」メニューからその人を追加します</li>
      <li>感心したいときにそれらを見ます
      </li>
    </ul>
    <small>[注意]このアプリにデータの保存機能はありません</small>
  </template>

  <script src="https://unpkg.com/petite-vue"></script>
  <script src="script.js"></script>
</body>

</html>
script.js
'use strict';

/* メイン */
PetiteVue.createApp({

  // データプロパティ
  screen: 0,

  // メソッド
  setScreen(n) {
    this.screen = n;
  },

  // コンポーネント関数
  Screen1,
  Screen2,
  Screen3
}).mount();

/* 画面1のコンポーネント関数 */
function Screen1() {
  return {
    $template: '#screen1-tmpl',

    // データプロパティ
    sMatter: '',
    sMatters: [],

    // メソッド
    addMatter() {
      if (this.sMatter != '') {
        this.sMatters.push(this.sMatter);
        this.sMatter = '';
      }
    },
  };
}

/* 画面2のコンポーネント関数 */
function Screen2() {
  return {
    $template: '#screen2-tmpl',

    // データプロパティ
    sPerson: '',
    sPeople: [],

    // メソッド
    addPerson() {
      if (this.sPerson != '') {
        this.sPeople.push(this.sPerson);
        this.sPerson = '';
      }
    },
  };
}

/* 画面3のコンポーネント関数 */
function Screen3() {
  return {
    $template: '#howto-tmpl',
  };
}

さあ、これで一応はできました。動かしてみましょう。コンポーネント化する前と同じように動くでしょうか?

画面を切り替えながら、実際に項目を登録した人は気づいたかと思いますが、このままでは画面を切り替えたときに登録したデータが消えてしまいます。「つかいかた」には「このアプリにデータの保存機能はありません」と小さく書かれていますが、それはアプリケーションを再起動したときに残らないという意味です。画面を切り替えただけで消えないでほしいですね。

なぜこういうことが起こるかというと、v-if によって div 要素が DOM ツリー から削除されるときに、コンポーネント関数が確保していたメモリ領域が解放されてしまうからです(たぶん)。したがって、画面が切り替わっても消されたくないデータプロパティはコンポーネント関数から外に退避する必要があります。

ひとつの手は createApp メソッドの直下に戻すことですが(それで意図どおり動いてしまうのですが)、これは抜け穴であって、本来はコンポーネントのメソッドから(親に当たる)createApp のプロパティを変更できるというのはまずい仕様です。現在の petite-vue(version 0.2.3)ではそれができてしまうのですが、いつ抜け穴が閉じられてしまうかわかりません。

もちろん、これを解決する正式な方法を petite-vue は備えています。一般に “グローバルステート/Global state”、もしくはグローバルステートを管理するのに一般的に使われる定数名で “ストア/store” と呼ばれます。

グローバルステート(ストア)

petite-vue のグローバルステート(以下ではストアと呼びます)は PetiteVue.reactive メソッド によって生成されます。reactive メソッドは createApp メソッドの外側で呼び出して、戻り値を定数に受け取ります(定数名は大抵 store です)。

ストア
const store = PetiteVue.reactive({
  // グローバルに管理したいデータプロパティを定義
  // そのデータプロパティを操作するメソッド(action)を定義
});

createApp メソッドとストア定数(store)を紐づけて、HTML やコンポーネント関数からストアが利用できるようにします。コンポーネント関数の紐づけと同じで、createApp メソッドの引数内に単にストア定数名を書くだけです。

ストアの登録
PetiteVue.createApp({
  // ストア
  store,

  // 略
});

これでストアのデータプロパティやメソッドに、HTML からは「store.データプロパティ名/メソッド名」、createApp やコンポーネントのメソッドからは「this.store.データプロパティ名/メソッド名」でアクセスできます。

具体例を見てみましょう。
サンプルアプリケーションで「すごいこと/すごいやつ」のリストデータを保持しているのは sMatters と sPeople ですので、それらを reactive メソッドの引数内で定義します。

ストア
const store = PetiteVue.reactive({
  sMatters: [],
  sPeople: []
});

本来は、ストアのデータプロパティはコンポーネントから直接変更せず、同じストア内で定義した action と呼ばれるメソッドからのみ変更します。しかし本講座ではそこまで厳密にやらず、コンポーネントのメソッドからストアのデータプロパティを直接変更することにします。

画面 1 のコンポーネント関数の addMatter メソッドからストアを利用してみましょう。this と sMatters の間に「store」の文字を挟み込むだけです。

コンポーネントからストアを利用
addMatter() {
  if (this.sMatter != '') {
    this.store.sMatters.push(this.sMatter); // ←
    this.sMatter = '';
  }
}

あとは、HTML からも v-for のところで sMatters が使われています。ここも修正します。こちらでは this は不要です。

HTML側からストアを利用
<li v-for="matter in store.sMatters">{{matter}}</li>

画面 2 のほうも同様に修正してください。
これで画面を切り替えても、登録したリストは表示されたままになったかと思います。

最終的なコード
index.html
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>すごいアプリ</title>
  <link href="style.css" rel="stylesheet">
  <style>
    [v-cloak] { display: none; }
  <style>
</head>

<body v-cloak>

  <!-- ヘッダー -->
  <header>
    <h1>すごいアプリ</h1>
  </header>

  <!-- アプリ部分 -->
  <div id="app">

    <!-- 画面切替 -->
    <nav>
      <ul>
        <li @click="setScreen(1)">すごいこと</li>
        <li @click="setScreen(2)">すごいやつ</li>
        <li @click="setScreen(0)">つかいかた</li>
      </ul>
    </nav>

    <!-- 画面1 -->
    <template v-if="screen == 1">
      <div id="screen1" v-scope="Screen1()"></div>
    </template>

    <!-- 画面2 -->
    <template v-else-if="screen == 2">
      <div id="screen2" v-scope="Screen2()"></div>
    </template>

    <!-- 画面3 -->
    <template v-else>
      <div id="howto" v-scope="Screen3()"></div>
    </template>
  </div>

  <!-- 付録情報 -->
  <div id="links">
    君も世界のすごいことに<a href="https://www.guinnessworldrecords.jp/"> >>>[登録!]</a><br>
    君もすごいやつコミュに<a href="https://mensa.jp/"> >>>[参加!]</a>
  </div>

  <!-- フッター -->
  <footer>
    ― すごいアプリ制作委員会 ―
  </footer>


  <!-- **テンプレート** -->

  <!-- 画面1 -->
  <template id="screen1-tmpl">
    <form @submit.prevent autocomplete="off">
      <label>
        身近にあったすごいこと<br>
        <input id="matter" v-model="sMatter">
      </label>
      <button @click="addMatter">入力</button>
    </form>
    <p>すごいことリスト</p>
    <ul>
      <li v-for="matter in store.sMatters">{{matter}}</li>
    </ul>
  </template>

  <!-- 画面2 -->
  <template id="screen2-tmpl">
    <form @submit.prevent autocomplete="off">
      <label>
        身近にいたすごいやつ<br>
        <input id="person" v-model="sPerson">
      </label>
      <button @click="addPerson">入力</button>
    </form>
    <p>すごいやつリスト</p>
    <ul>
      <li v-for="person in store.sPeople">{{person}}</li>
    </ul>
  </template>

  <!-- 画面3 -->
  <template id="howto-tmpl">
    <p>
      この「すごいアプリ」は、あなたの身近にあったすごいことや身近にいるすごいやつを登録して、あなたの好きなときに「すごいなぁ…」と感心できるアプリです
    </p>
    <p>つかいかた</p>
    <ul>
      <li>身近にすごいことがあれば「すごいこと」メニューからそのことを追加します</li>
      <li>身近にすごいやつがいれば「すごいやつ」メニューからその人を追加します</li>
      <li>感心したいときにそれらを見ます
      </li>
    </ul>
    <small>[注意]このアプリにデータの保存機能はありません</small>
  </template>

  <script src="https://unpkg.com/petite-vue"></script>
  <script src="script.js"></script>
</body>

</html>
script.js
'use strict';

/* ストアの定義 */
const store = PetiteVue.reactive({
  sMatters: [],
  sPeople: []
  // actionは割愛
});

/* メイン */
PetiteVue.createApp({
  // データプロパティ
  screen: 0,

  // メソッド
  setScreen(n) {
    this.screen = n;
  },

  // ストア
  store,

// コンポーネント
  Screen1,
  Screen2,
  Screen3
}).mount();

/* 画面1のコンポーネント関数 */
function Screen1() {
  return {
    $template: '#screen1-tmpl',

    // データプロパティ
    sMatter: '',

    // メソッド
    addMatter() {
      if (this.sMatter != '') {
        this.store.sMatters.push(this.sMatter);
        this.sMatter = '';
      }
    }
  };
}

/* 画面2のコンポーネント関数 */
function Screen2() {
  return {
    $template: '#screen2-tmpl',

    // データプロパティ
    sPerson: '',

    // メソッド
    addPerson() {
      if (this.sPerson != '') {
        this.store.sPeople.push(this.sPerson);
        this.sPerson = '';
      }
    }
  };
}

/* 画面3のコンポーネント関数 */
function Screen3() {
  return {
    $template: '#howto-tmpl',
  };
}

スタイルを切り出す

template 要素に囲まれた部分は、petite-vue(や JavaScript)から読み込まれるまで「存在しないこと」になります。また、style 要素は head 要素の外に書くこともできます。これを組み合わせると、特定のコンポーネントだけにしか関係しない CSS を template 要素の中に書くこともできます。

例えば、サンプルアプリケーションの画面 3 には、そこだけに適用されている「#howto」で始まるスタイル記述が複数個あります。これを template 要素の中に持ってくることで、HTML と CSS をコンポーネントにまとめることができます。

画面3のHTML&CSS
<!-- 画面3 -->
<template id="howto-tmpl">
  <style>
    #howto p:first-child {
      border: solid royalblue 4px;
      padding: 5px 10px;
    }
    #howto ul {}
    #howto li {}
    #howto p:nth-of-type(2) {}
  </style>

  <p>
    この「すごいアプリ」は、あなたの身近にあったすごいことや身近にいるすごいやつを登録、あなたの好きなときに「すごいなぁ…」と感心できるアプリです
  </p>
  <p>つかいかた</p>
  <!-- 略 -->
</template>

ファイル単位で管理する本格的なコンポーネント(本家 Vue や Svelte など)では style 要素を HTML の上に持ってくる書き方が主流な気がしますが、どちらに置いても構いません。

template 要素の中に入れたのなら「#howto」は外してもいいんじゃないの?と思うかもしれませんが、style 要素はどこに記述してもファイル全体に適用されるので「#howto」は外せません。一時は、style 要素の親要素配下にスコープ(有効範囲)を制限する scoped 属性というのがあったのですが、非推奨となりました(が、再導入が検討されているようです)。

ただ、ここまでがんばってコンポーネント化したいなら、本家 Vue を使って単一ファイルコンポーネント(ファイル単位でコンポーネントを管理する)を使うのがよいかもしれません。単一の HTML ファイルに CSS まで入れ込むと、どうしてもコード量が膨れ上がってしまうので…

petite-vue の解説でいうのもなんですが、本家 Vue を使って単一ファイルコンポーネントを使うなら、Svelte という別のフレームワーク(実際はコンパイラですが)のほうが petite-vue に似たシンプルさがあるように思います。ファイル単位でコンポーネントを管理するためには、いずれにせよターミナルで CLI(コマンドラインインタフェース)を使って開発を進めることになります。CLI を使うなら、著者としては Vue よりも Svelte のほうが記法が初心者向きでお勧めです。

JavaScript ファイルを分割する

本章は付録です。余裕のある人は読んでもらって、できれば挑戦してください。

本チャプターの冒頭で、JavaScript も script.js に全てのコンポーネントを記述する…という話をしましたが、JavaScript については ECMAScript モジュール(通称 ES モジュール)という JavaScript の機能を使うことでファイル単位に分割できます(詳しくはこちらを参照)。
ただし、ES モジュールを利用するためにはファイルをウェブサーバに置かなければなりません。ローカル(手元の PC)のフォルダ内にある index.html をブラウザで開くだけではエラーになります。

ただ、幸いにも(?)、Visual Studio Code(VSCode)の Live Server 拡張機能はローカルでウェブサーバを動かして、その上でファイルをプレビューしています。つまり、VSCode + Live Server の構成でこれまで勉強してきた人(本講座シリーズではずっとそうでしたね)は、すでに ES モジュールを使用する条件を満たしています。

OJK の授業の受講者への余談

『文系大学生のための』シリーズではずっと VSCode + Live Server を使ってきたのに、本講座『文系大学生のための petite-vue』ではそれを前提としませんでした。最初から ES モジュールを利用した petite-vue の使い方で進めてもよかったのですが、授業の受講生の中には Live Server がなぜか動かないという人が毎年出てくるので、そういうときに「ブラウザで index.html を開いて、再読み込みしながら確認して」という対策ができるように考慮しました。

petite-vue を ES モジュールから読み込む

ということで、まずは petite-vue を ES モジュールを使った記法に書き換えてみましょう。書き換えはほんの数箇所です。

まず、HTML ファイル(index.html)の script 要素の部分を以下のように変更します。

HTML側の変更
<script src="script.js" type="module"></script>
<!-- 削除
  <script src="https://unpkg.com/petite-vue"></script>
-->

自前のスクリプト(script.js)の読み込みのほうに「type="module"」を追加します。これで、このスクリプトは モジュール/module として扱われるようになります[2]。それから、script 要素による petite-vue(PetiteVue オブジェクト)の読み込みは削除します。petite-vue の機能は JavaScript 側で読み込みます。

続いて、JavaScript ファイル(script.js)を以下のように変更します。

JavaScript側の変更
import { createApp, reactive } from 'https://unpkg.com/petite-vue?module';

const store = reactive({ /* 略 */ });

createApp({
  store,
  /* 略 */
}).mount();

JavaScript ファイル(script.js)の先頭で import 命令 によって petite-vue の機能(関数)を読み込みます。ここで createApp と reactive を関数として読み込みます。createApp と reactive が Petite オブジェクトのメソッドから関数に格上げされたので、「Petite.」は削除します(残っていると動きません)。

なお、先頭に記述していた 'use strict' は不要になります。というのも、type="module" で読み込まれたスクリプトは自動的に strict モードになるからです。

これでアプリケーションは動くはずです。

コンポーネント関数とストアをファイルに分ける

では、ファイルに分けていきましょう。ちょっと面倒ですが、script.js を次の 4 つのファイルに分けましょう。

  • main.js … createApp と Screen3
  • store.js … store (reactive)
  • screen1.js … Screen1
  • screen2.js … Screen2

Screen3 は短すぎるので createApp とまとめました。こういこともできます。

ここからまず、import 文を整理します。
さきほどは script.js から関数 createApp と reactive の 2 つを読み込んでいましたが、ファイルを分けたことで、createApp は main.js からのみ、reactive は store.js からのみ、それぞれ読み込むことになります。

// main.jsの先頭
import { createApp } from 'https://unpkg.com/petite-vue?module';

// store.jsの先頭
import { reactive } from 'https://unpkg.com/petite-vue?module';

次に、別のファイルから使用される変数や関数の定義文にキーワード export を付けます。変数や関数のスコープ(有効範囲)は通常はファイル内に制限されます。他のファイルから使用するときは、スコープを広げるキーワード export を付ける必要があります。

具体的には、main.js の createApp 関数(の引数)にて、store、Screen1、Screen2 の 3 つを指定していましたね。これらを定義しているところに export を付けます。

// store.js
export const store = reactive({ /* 略 */ });

// screen1.js
export function Screen1() { /* 略 */};

// screen2.js
export function Screen2() { /* 略 */};

そして、export された変数や関数を main.js から利用するために、import 命令でこれらを読み込みます。createApp の読み込みの下に続けてください。

main.js
import { createApp } from 'https://unpkg.com/petite-vue?module';
import { store } from './store.js';
import { Screen1 } from './screen1.js';
import { Screen2 } from './screen2.js';

自前のスクリプトファイルから変数や関数を import するときは、from の後ろに相対パスで指定します。同じフォルダに置いている場合も「./」を省略できません(HTML や CSS とこの点が異なるので注意)。

最後に HTML ファイル(index.html)から分割した JavaScript ファイルを全てモジュールとして読み込みます。

分割したスクリプトファイルの読み込み
<script src="main.js" type="module"></script>
<script src="store.js" type="module"></script>
<script src="screen1.js" type="module"></script>
<script src="screen2.js" type="module"></script>

以上でファイル分割はおしまいです。いろいろと記述した気もしますが、実際のところ export と import を書いただけです。アプリケーションの動作確認してみてください。

ファイル分割はちょっと面倒かもしれませんが、JavaScript のコードが大きくなってきたらコンポーネント関数を見つけるだけで一苦労になってくるので、ファイルを分けて VSCode のタブで切り替えたほうがずっと楽になります。

ここまでのコード

index.html は割愛します。

main.js
import { createApp } from 'https://unpkg.com/petite-vue?module';
import { store } from './store.js';
import { Screen1 } from './screen1.js';
import { Screen2 } from './screen2.js';

createApp({
  // ストア
  store,

  // データプロパティ
  screen: 0,

  // メソッド
  setScreen(n) {
    this.screen = n;
  },

  // コンポーネント
  Screen1,
  Screen2,
  Screen3
}).mount();

/* 画面3のコンポーネント */
function Screen3() {
  return {
    $template: '#howto-tmpl'
  };
}
store.js
import { reactive } from 'https://unpkg.com/petite-vue?module';

/* ストア */
export const store = reactive({
  sMatters: [],
  sPeople: []
});
screen1.js
/* 画面1のコンポーネント */
export function Screen1() {
  return {
    $template: '#screen1-tmpl',

    // データプロパティ
    sMatter: '',
    // sMatters: [],

    // メソッド
    addMatter() {
      if (this.sMatter != '') {
        this.store.sMatters.push(this.sMatter);
        this.sMatter = '';
      }
    }
  };
}
screen2.js
/* 画面2のコンポーネント */
export function Screen2() {
  return {
    $template: '#screen2-tmpl',

    // データプロパティ
    sPerson: '',

    // メソッド
    addPerson() {
      if (this.sPerson != '') {
        this.store.sPeople.push(this.sPerson);
        this.sPerson = '';
      }
    }
  };
}

以上で petite-vue そのものの解説は終わりです。次回からは petite-vue を利用しつつも、fetch 関数によるローカルファイルの読み込みやウェブ API の使用に話を移します。

脚注
  1. 公式ドキュメントにも例がなかったので著者も苦戦しました。推測ですが、petite-vue では v-if の処理よりも template 要素と div 要素の置換が先に行われてしまうため、置換後に v-if の記述が無くなってしまい、正しく動作しないのだと思われます。 ↩︎

  2. “モジュール” はコンポーネントに似た概念で、組み合わせて使う “部品” という意味合いです。モジュール指定された JavaScript ファイルには一部分だけが書かれてるということを示します。 ↩︎