JSひろばアプリ開発3日目:Vueアプリの全体像

前回の記事では、Piniaを入れて大まかなレイアウトを作成しました。

今回はPiniaやemit()、テンプレートrefを使うのでVueの要素が盛りだくさんです。

今日で大まかなフロントエンドの部分を完了させます!

作業日2022年12月12日
作業にかけた時間3時間
合計作業時間9時間
作業内容ボタンのコンポーネントの作成
モックのデータを作成
グローバルで使うデータをPiniaに保管させる
Emitを使いデータを親コンポーネントに送る
ライブラリコンポーネントのRefにアクセスする

3つのファインダーコンポーネント

では、このJSひろばのアプリのメイン機能としてこの4つのファインダーがありましたがおすすめ検索機能はキーワード検索とほぼ同じなのでいったん取り除くことにしました。

これは、ユーザーがJavaScriptのサンプルコードを見つけるための機能になります。

  1. キーワード検索:ユーザーが入力したキーワードからコードの例を検索。
  2. まずはこれ:難易度順や人気順などからコードの例を検索
  3. 記事検索:あさめしコードの記事にリンクさせるための例。コードの特集を紹介
  4. 履歴検索:ユーザーが実行した履歴がここに残ります。

ボタンのコンポーネント

各ボタンを押すとそれぞれのボタンに付属した内容が同じダイアログ(ポップアップ)のコンポーネント内で表示されるようにします。

まずは、各4種類の検索機能のコンポーネントをcomponents/finderフォルダに作成します。最初の中身はただのテキストでOKです。次にApp.vueに戻りPrimeVueにあるDialogコンポーネントをコピペします。

それから、各ボタンを押したときにそれぞれのコンポーネントの内容が表示されるようにロジックを作成すればOKです。

途中経過ですが、こんな感じになりました。

<template>
  <Logo/>

  <div class="flex flex-wrap justify-content-center gap-2 m-2">
    <Button
      :label="keywordBtn.label"
      :finderTitle="keywordBtn.label"
      :icon="keywordBtn.icon"
      @click="openDialog('Keyword')"
    />
    <Button
      :label="osusumeBtn.label"
      :finderTitle="osusumeBtn.label"
      :icon="osusumeBtn.icon"
      @click="openDialog('Osusume')"
    />
    <Button
      :label="articleBtn.label"
      :finderTitle="articleBtn.label"
      :icon="articleBtn.icon"
      @click="openDialog('Article')"
    />
    <Button
      :label="historyBtn.label"
      :finderTitle="historyBtn.label"
      :icon="historyBtn.icon"
      @click="openDialog('History')"
    />
  </div>

  <Dialog
    v-model:visible="isShowDialog"
    :breakpoints="{ '960px': '75vw', '640px': '90vw' }"
    :style="{ width: '50vw' }"
    :closable="false"
  >
    <template #header>
      <div class="min-w-full flex justify-content-between">
        <div v-if="isShowKeyword" class="flex align-items-center">{{ keywordBtn.label }}</div>
        <div v-if="isShowOsusume" class="flex align-items-center">{{ osusumeBtn.label }}</div>
        <div v-if="isShowArticle" class="flex align-items-center">{{ articleBtn.label }}</div>
        <div v-if="isShowHistory" class="flex align-items-center">{{ historyBtn.label }}</div>

        <Button icon="pi pi-times" @click="closeDialog" class="flex align-items-center"/>
      </div>
    </template>

    <KeywordFinder v-if="isShowKeyword" />
    <OsusumeFinder v-if="isShowOsusume" />
    <ArticleFinder v-if="isShowArticle" />
    <HistoryFinder v-if="isShowHistory" />
  </Dialog>

  <Terminal welcomeMessage="JSターミナル" prompt="$" />
</template>

<script setup>
// VueAPIs
import { reactive, ref, onMounted, onBeforeUnmount } from "vue";
// PrimeVue
import TerminalService from "primevue/terminalservice";
// Pinia
import { useStore } from "@/store/store.js";
// Components
import Logo from "@/components/logo/Logo.vue";
import KeywordFinder from "@/components/finder/KeywordFinder.vue";
import OsusumeFinder from "@/components/finder/OsusumeFinder.vue";
import ArticleFinder from "@/components/finder/ArticleFinder.vue";
import HistoryFinder from "@/components/finder/HistoryFinder.vue";

// ButtonProps
const keywordBtn = {
  label: "キーワード検索",
  icon: "pi pi-external-link",
};
const osusumeBtn = {
  label: "まずはこれから",
  icon: "pi pi-external-link",
};
const articleBtn = {
  label: "記事をさがす",
  icon: "pi pi-external-link",
};
const historyBtn = {
  label: "履歴からもう一度",
  icon: "pi pi-external-link",
};

// Pinia
const store = useStore();
const finderHistory = store.finderHistory;

// END Pinia

// Modal Related

const isShowKeyword = ref(false);
const isShowOsusume = ref(false);
const isShowArticle = ref(false);
const isShowHistory = ref(false);

const isShowDialog = ref(false);

const openDialog = (finder) => {
  isShowKeyword.value = false;
  isShowOsusume.value = false;
  isShowArticle.value = false;
  isShowHistory.value = false;
  switch (finder) {
    case "Keyword":
      isShowKeyword.value = true;
      break;
    case "Osusume":
      isShowOsusume.value = true;
      break;
    case "Article":
      isShowArticle.value = true;
      break;
    case "History":
      isShowHistory.value = true;
      break;
  }
  isShowDialog.value = true;
};

const closeDialog = () => {
  isShowKeyword.value = false;
  isShowOsusume.value = false;
  isShowArticle.value = false;
  isShowHistory.value = false;
  isShowDialog.value = false;
};
// END Modal Related

// コンソール関係
onMounted(() => {
  TerminalService.on("command", commandHandler);
});

onBeforeUnmount(() => {
  TerminalService.off("command", commandHandler);
});

const commandHandler = (text) => {
  let response;
  let argsIndex = text.indexOf(" ");
  let command = argsIndex !== -1 ? text.substring(0, argsIndex) : text;

  try {
    response = eval(command);
  } catch (e) {
    response = e.message;
  }

  TerminalService.emit("response", response);
};

// End コンソール関係
</script>


  • v-bindでボタンの中身をscriptで書いたものをバインドさせています。
  • v-ifを使ってボタンを押したときに対応するダイアログがポップアップで表示されるようにしました。
  • また、ダイアログが開いているときに他のボタンを押すと押した方のダイアログが開くようになりました。
  • ダイアログのタイトルとクローズボタンはtemplate内に設定しました。(headerのpropでも良いのですがボタンを閉じるときのファンクションがカスタムできなさそうだったので手動でボタンを足しました。)

実際の画面はこんな感じです。

キーワード検索

まだ、データベースの細かい構成とデータの量を吟味していないので詳しくはすすめられません。ここで検討すべき課題はこちらになります。

  • 例のコードをすべてロードする(サーバーにリクエストを送りブラウザ側で保管する)と膨大な量になるのでどうやって負担を減らせるか考える。
  • 例えば、DjangoモデルにIDと説明文とキーワード(JavaScriptの予約語(Reserved Words))だけを保管させたAPIをロードさせるようにする。そしてユーザーがクリックしたとにコードと記事(ある場合)のリクエストを送る。

このファインダー機能のコンポーネントは複雑になるので作成はいったん後回しにします。

Piniaの作成

Piniaを使ってグローバルにアクセスできるストレージを作成します。とりあえず、モックのJSONファイルを作成してテーブルとして表示できるところまでやってみます。

import { defineStore } from 'pinia'
import { reactive } from 'vue';

export const useStore = defineStore('member', ()=> {
  const keywordData = [
    {
      "id":1,
      "title":"ES6のアロー関数",
      "keyword":"function"
    },
    {
      "id":2,
      "title":"配列の最後を取り除く",
      "keyword":"pop"
    },  
    {
      "id":3,
      "title":"ES6のアロー関数",
      "keyword":"function"
    },   
  ]

  const articleData = reactive([
    {
      "id":1,
      "title":"配列の最後を取り除く",
      "url":"https://asameshicode.com"
    },
    {
      "id":2,
      "title":"配列の最後を取り除く",
      "url":"https://asameshicode.com"
    }
  ])

  const historyData = reactive([
    {
      "id":1,
      "command":"const name = 'It is me!'",
    },
    {
      "id":2,
      "command":"const name = 'It is me!'",
    }
  ])


  return {
    keywordData,
    osusumeData,
    articleData,
    historyData
  }
})

これを各テーブルで表示させるとこんな感じになりました。

履歴のデータを保管する

各コマンドを実行した後に、その履歴をオブジェクトの配列に保管します。常に最新の履歴が先に来るようにしたいため、pop()ではなく、unshift()を使っています。

コンソール関係のコードはこのような感じです。

// コンソール関係
onMounted(() => {
  TerminalService.on("command", commandHandler);
});

onBeforeUnmount(() => {
  TerminalService.off("command", commandHandler);
});

const commandHandler = (text) => {
  let response;
  let argsIndex = text.indexOf(" ");
  let command = argsIndex !== -1 ? text.substring(0, argsIndex) : text;

  try {
    response = eval(command);
  } catch (e) {
    response = e.message;
  }
  createHistoryData(command)
  TerminalService.emit("response", response);
};

const createHistoryData = (command) => {
  let obj = {
    id:historyData.length + 1,
    command: command
  }
  historyData.unshift(obj)
  histo

これで入力したコマンドがオブジェクトとして配列に保管されました。テーブルにも問題なく表示されていることが分かります。

コードをコンソールにコピーする機能

では、検索機能で探したコマンドをコンソールにコピーするボタンのファンクションを作成していきます。ドキュメンテーションにはないですがこのターミナルがどうやって入力されたテキストをバインドしているか見てみましょう。

VueのGoogleエクステンションツールを使います。

VueのタブからTerminalのコンポーネントを開きます。すると、DataのところにcommandTextがあるのがわかります。ここがnullになっていますがターミナルにテキストが入力されると同時に更新されました。

では、このデータにコピーしたいコピーをバインドさせるようにします。

まず、ここのcommandTextにアクセスするには<Terminal ref=”terminal”/>のようにrefを使ってこのコンポーネント内にあるデータ、関数にアクセスすることができますね。(Compostion APIの場合は対象のコンポーネント側でexpose()されて公開されている必要がありますが。)

あとは、テーブル側のボタンにemit()を設定して親コンポーネントにコマンドのデータを送ってあげます。

親コンポーネント側では、Emitを受け取ったときに発火するイベントを下記の様に設定してあげました。

<temnplate>
  <Terminal welcomeMessage="JSターミナル" prompt="$" ref="terminal" />
</temnplate>

//下記はScript内
const emitPaste = (command) => {
  terminal.value.commandText = command;
  closeDialog();
};

細かいスタイルの変更

ここまでできたら、大体のレイアウトは決まったのでスタイルをキレイにしていきます。

特に、モバイルとデスクトップで共通のアプリを使えるようにしたいのでその設定も必要です。

とりあえずこんな感じになりました。ポップアップで表示されるダイアログは実際のデータが入ってからスタイリングをしたいと思います。

では、フロントエンドはこれくらいにして次回からにバックエンドにかかりましょう。

Django Vue フルスタック

お疲れ様です。