[Vue.js3]10.非同期処理

  1. 10.1 非同期処理
    1. 非同期処理とは
      1. 同期処理と非同期処理の違い
      2. プログラミングで非同期処理を行う場面
      3. async(エイシンク)とは
        1. なせセットで使う?
      4. Promise(プロミス)とは
        1. Promise の3つの状態
      5. 基本のルール(2つのキーワード)
      6. 基本構文
        1. パターン1:モダンな非同期API(Fetchなど)を使う場合(推奨)
        2. パターン2:古いコールバック関数を Promise 化したい場合
      7. Vue3(Composition API)での4つの書き方パターン
        1. 1. 普通の関数(Function宣言)
        2. 2. アロー関数(変数に代入するタイプ)
        3. 3. イベントハンドラーに直接書く
        4. 4. ファイルサイクルフックの中に書く
  2. 10.2 IndexedDB
    1. 特徴
    2. 他の保存方法との違い
    3. デメリットと解決策
    4. ライブラリを使わない実装
      1. ストアファイル members.ts
        1. IndexedDB の初期化と接続を行う共通関数 getDatabase()
          1. 構文の役割
          2. データベースオブジェクトの再利用
        2. 会員情報を管理する Pinia ストア defineStore()
          1. 主な役割
          2. State(状態)
          3. Getter(算出プロパティ)
          4. Actions(処理・メソッド)
      2. 会員リスト表示 MemberList.vue
      3. メンバー詳細表示 MemberDetail.vue
      4. メンバー追加 MemberAdd.vue
    5. 外部ライブラリを利用する実装
      1. Dexie.js をプロジェクトにインストール
      2. ストアファイル members.ts を Dexie.js 用に修正
        1. 1. Dexie データベースの定義
        2. 2.会員情報を管理する Pinia ストア
      3. 画面側のコンポーネント
  3. MySQL + PHP
    1. 動作環境
    2. 大まかな仕組み
    3. 作業ステップ
    4. MySQLテーブルを作成
    5. PHPプログラム(API)作成
      1. 動作確認
    6. Vue3(Piniaストア)の通信処理を書き換える
      1. 通信用ライブラリ axios と標準の fetch のどちらを使うか
      2. axios インストール
      3. members.ts
    7. アプリをビルド(製品化)しサーバーへアップロード
      1. ビルド
      2. アップロード
      3. 動作確認

10.1 非同期処理

非同期処理とは

時間のかかる処理の完成を待たずに次の処理をどんどん先に進める仕組みのこと。
これにより、データの読み込み中であっても画面がフリーズせず、ユーザーが別の操作を続けられる快適なアプリをつくることが出来る。

同期処理と非同期処理の違い

日常の行動に例えると

  • 同期処理(順番にやる)
    • お湯を沸かし始めてから、完全に沸騰するまで台所でじっと待つ
    • 沸騰し終わったら、ようやく次の作業(野菜を切るなど)を始める
  • 非同期処理(平行して進める)
    • お湯を火にかけたら、沸騰を待たずにすぐに野菜を切り始める
    • お湯が沸いたら(バックグラウンドで完了したら)、そちらの処理(麵を入れるなど)に戻る

プログラミングで非同期処理を行う場面

主に完了までに数秒以上の「待ち時間」が発生する処理で使われる

  • サーバーからデータを取得する通信(API通信)
  • 大きなファイルや画像の読み込み・書き込み
  • タイマーを使った一定時間後の処理

async(エイシンク)とは

async(エイシンク)は、「この関数は非同期処理を行いますよ」とコンピューターに教えるための目印(キーワード)で、asynchronous(非同期)の略から来ている

プログラミング(JavaScriptやPython、C#など)では、基本的に async と await(アウェイト)という2つの言葉をセットにして使う

なせセットで使う?

非同期処理は「待ち時間を無視して進む」のが基本だが、時には「このデータが返ってこないと次の計算ができないから、ここだけは待って!」という場面があり、それを実現するのが async / await 構文である

  • async: 関数の先頭につけて、非同期関数であることを宣言する
  • await: async 関数の中だけで使えて「ここの通信が終わるまで一時停止して待つ」という指示を出す

Promise(プロミス)とは

Promiseとは、「未来に結果(成功データまたはエラー)を返すことを約束するオブジェクト(箱)」

通信などは一瞬では終わらない為、「処理が終わったらこの箱の中にデータを入れておくね」と先に Promiseという箱を返す。

Promise の3つの状態

Promise の箱には、必ず以下の3つのうちどれかの状態がある

  • Pending(待機中): まだ処理が終わっていない状態
  • Fulfilled(成功): 無事にデータが取れて完了した状態
  • Rejected(失敗): エラーが発生して失敗した状態

基本のルール(2つのキーワード)

  1. async(非同期関数を宣言する)
    • 非同期処理を含みたい関数の前に必ず async を付ける
    • async が付いた関数は、自動的に Promise を返す関数になる
  2. await(処理を待つ)
    • async 関数の中だけで使える
    • Promise の処理(API通信など)が完了して結果がでるまで、そこで処理を一時停止する

基本構文

現代の TypeScript では、「何を非同期処理の対象にするか」によって2パターンの書き方がある

パターン1:モダンな非同期API(Fetchなど)を使う場合(推奨)

実行したい非同期処理がすでに Promise を返すもの(fetch など)であれば、await と try/catch を使い、上から下へ同期処理のように記述する

TypeScript
async function 関数名(): Promis<戻り値の型> {
  try {
    // 1. await を使って非同期処理の結果を直接受け取る
    const 結果 = await すでにPromiseを返す非同期処理();
    
    // 2. 成功時の処理:そのまま値を return すれば自動的に Promise 化される
    return 結果;
  } catch (error) {
    // 3. 失敗時の処理:エラーを throw すれば自動的に Reject 扱いになる
    throw new Error("エラーメッセージ");
  }
}
パターン2:古いコールバック関数を Promise 化したい場合

「非同期処理」の部分が、どうしても new Promise を使わないといけない古い仕様の処理(例:setTimeout など)である場合、async を外し、通常の関数として Promise を返す

TypeScript
// async を外し、通常の関数として new Promise をそのまま return する
function 関数名(): Promise<戻り値の型> {
  return new Promise<戻り値の型>(
    (resolve, reject) => {
      // 古いコールバック型の非同期処理を実行
      古い非同期API((error, 結果) => {
        if (error) {
          // 処理が失敗した場合、
          // 引数としてエラーメッセージを埋め込んだErrorオブジェクトを渡し、
          // reject()を実行
          reject(new Error("エラーメッセージ"));
        } else {
          // 処理が成功した場合、引数として戻り値を渡してresolbe()を実行
          resolbe(結果);
        }
      });
    }
  );
}

IndexedDBのAPI(request.onsuccess など)は Promise に対応していない古いイベント駆動型のAPIのためパターン2になる

Vue3(Composition API)での4つの書き方パターン

Vue3 の <script setup>内では、アロー関数(矢印の構文)などと一緒に使うことが多いため、以下のパターンを覚えておくと便利である。

1. 普通の関数(Function宣言)
async function fetchData(): Promise<void> {
  const res = swait fetch('...');
}
2. アロー関数(変数に代入するタイプ)
const fetchData = async (): Promise<void> => {
  const res = await fetch('...');
}
3. イベントハンドラーに直接書く
<button @click="async () => { await doSomething() }">実行</button>

推奨されない3つの理由

  1. エラーハンドリングができない
    テンプレート内では try-catch 構文が書けない為、非同期処理(API通信など)でエラーが発生した時にアプリがクラッシュする原因になる
  2. 可読性(読みやすさ)が下がる
    HTMLのコードが長くなり、何を行っている処理なのかがパッと見で分かりにくくなる
  3. テストが難しくなる
    テンプレート内にロジックが埋め込まれるため、ユニットテストでその非同期処理だけをテストすることが困難になる
4. ファイルサイクルフックの中に書く

onMounted などのフック自体を async にすることも可能

import { onMounted } from 'vue

onMounted(async (): Promise<void> => {
  const ret await fetch('...');
});

10.2 IndexedDB

IndexedDBとは、Webブラウザ上に大量のデータを保存できる、高機能な「組み込みデータベース」のこと。
ユーザーのパソコンやスマートフォンのブラウザ内に直接データを保管するため、サーバーと通信をしていなくてもデータの読み書きを高速に行うことが出来る。

特徴

  • 圧倒的な大容量
    • 一般的な localStoreage(約5MB)に比べ、ブラウザや端末の空き容量に応じたギガバイト単位の大量データを保存できる
  • 多様なデータ形式に対応
    • 文字列だけでなくJavaScriptのオブジェクト、配列、画像などのバイナリデータ(Blob)もそのまま保存可能
  • オフラインでも動作する
    • ネットが繋がらない環境でもデータを読み込めるため、PWA(Progressive Web Apps)などの本格的なアプリ開発に必須の技術である

他の保存方法との違い

Webのデータ保存方法としてよく比較される「localStorage」や「Cookie」との違い

特徴CookielocalStorageIndexedDB
主な用途ログイン状態の管理など簡易的な設定の保存など大容量データの管理・本格アプリ
容量の目安約4KB(極小)約5MB(小)端末の空き容量に依存(大容量)
データ形式文字列のみ文字列のみオブジェクト・画像・ファイル等
検索機能なしなし「インデックス」を使った高速検索

デメリットと解決策

  • コードが複雑で扱いづらい
    • 標準の IndexedDB API は古い設計(非同期のイベント駆動型)になっており、そのままコードを書くとコードが長くなる
  • 対策
    • 実務では Promise や async/await で直接あつかえるようにする Dexie.js や localForage などの外部ライブラリを組み合わせて使うのが一般的

ライブラリを使わない実装

ストアファイル members.ts

TypeScript
import { ref, computed, toRaw } from 'vue';
import { defineStore } from 'pinia';
import type { Member } from '@/interfaces.ts';

const DB_NAME = 'asyncdb';     // データベース名
const STORE_NAME = 'members';  // オブジェクトストア名
const DB_VERSION = 1;          // データベースバージョン番号

// 接続済みのデータベースオブジェクトを保持する変数
let _database: IDBDatabase | null = null;

//-------------------------------------
// IndexedDB の初期化と接続を行う共通関数
//-------------------------------------
function getDatabase(): Promise<IDBDatabase> {
  
  // 既に接続済みの場合、そのまま返す
  if (_database) {
    return Promise.resolve(_database);
  }

  // 1. 未接続の場合、非同期処理の状態を監視するオブジェクトを作成
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    // 初めて開いた時や既存データベースより大きいバージョン番号で
    // 開いた時に呼ばれるイベントハンドラ
    // オブジェクトストア(テーブル)を作成している
    request.onupgradeneeded = () => {
      const db = request.result;
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        db.createObjectStore(STORE_NAME, { keyPath: 'id' });
      }
    };

    // 2. データベースの準備が正常に完了した時に呼ばれるイベントハンドラ
    request.onsuccess = () => {
      _database = request.result;
      resolve(_database);
    };

    // 3. エラーが発生した時に呼ばれるイベントハンドラ
    request.onerror = event => {
      const target = event.target as IDBRequest;
      const errorReason = target?.error?.message || '不明なエラー';
      reject(new Error('DBのオープンに失敗しました。理由: ${errorReason}'));
    };
  });
}

//-------------------------------------
// 会員情報を管理する Pinia ストア
//-------------------------------------
export const useMembersStore = defineStore('members', () => {

  // State(状態)
  const memberList = ref<Map<number, Member>>(new Map<number, Member>());

  // Getters(算出プロパティ)
  const getById = computed(() => {
    return (id: number): Member => {
      // stateの値にアクセスするため memberList.value を使用
      return memberList.value.get(id) as Member;
    };
  });

  const isMemberListEmpty = computed(() => {
    return memberList.value.size == 0;
  });

  // Actions(処理・メソッド)
  const prepareMemberList = async (): Promise<void> => {
    const memberListMap = new Map<number, Member>();

    try {
      const db = await getDatabase();
      // IndexedDBの古いコールバック処理をPromise化
      await new Promise<void>((resolve, reject) => {
       // (1) トランザクション発行
        const transaction = db.transaction(STORE_NAME, 'readonly');
        
        // (2) オブジェクトストアにアクセス
        const store = transaction.objectStore(STORE_NAME);
        
        // (3) 全データ取得(非同期処理リクエスト)
        const request = store.getAll();

        // (4) 成功時
        request.onsuccess = () => {
          const membersArray = request.result as Member[];
          for (const member of membersArray) {
            memberListMap.set(member.id, member);
          }
          resolve();
        };

        // (4) 失敗時
        request.onerror = () => {
          reject(request.error);
        };
      });
    } catch (error) {
      console.error('IndexedDBからのデータ取得に失敗しました:', error);
    }
    memberList.value = memberListMap;
  };

  const insertMember = async (member: Member): Promise<void> => {
    memberList.value.set(member.id, member);

    try {
      const db = await getDatabase();
      
      // IndexedDBの古いコールバック処理をPromise化
      await new Promise<void>((resolve, reject) => {
        // (1) トランザクション発行
        const transaction = db.transaction(STORE_NAME, 'readwrite');
        
        // (2) オブジェクトストアにアクセス
        const store = transaction.objectStore(STORE_NAME);
        
        // (3) データ更新(非同期処理リクエスト)
        const rawMember = toRaw(member);
        const request = store.put(rawMember);

        // (4) 成功時
        request.onsuccess = () => {
          resolve();
        };

        // (4) 失敗時
        request.onerror = () => {
          reject(request.error);
        };
      });
    } catch (error) {
      console.error('EndexedDBへの保存に失敗しました:', error);
    }
  };

  // 外部のコンポーネントに公開する要素
  return {
    memberList,
    getById,
    isMemberListEmpty,
    prepareMemberList,
    insertMember
  };
});
IndexedDB の初期化と接続を行う共通関数 getDatabase()

IndexedDBにアクセスする前に必ず行う接続・初期化(ストア作成)を共通化した

IndexedDBは古いイベント駆動型の設計(コールバック形式)になっているため、そのままではVue3の Composition API などで奇麗に同期的なコードが書けない。それを解決するためにPromiseで包む(プロミス化する)必要がある。

構文の役割
return new Promise((resolve, reject) => { ... });

このコードは主に以下の3つの要素で構成されている

  1. new Promise(…)
    • 非同期処理の状態(成功か失敗か)を監視するオブジェクトを新しく作成して返す
  2. resolve(解決)
    • 非同期処理が「正常に完了したとき」に呼び出されるイベントハンドラ
    • 引数に渡したデータが、呼び元の await の戻り値になる
  3. reject(拒否)
    • 非同期処理で「エラーが発生したとき」に呼び出されるイベントハンドラ
    • 呼び出し元の try-catch ブロックのエラー(datch)にデータが引き渡される
データベースオブジェクトの再利用

変数 _database に接続済みのデータベースオブジェクトを保存している。
getDatabase() 開始時に接続済みならばこの _database を返す。

会員情報を管理する Pinia ストア defineStore()
主な役割
  • 会員データの状態管理(State)
  • 特定の会員検索や空チェックなどの算出プロパティの提供(Getters)
  • IndexedDB との非同期同期(読み込み・書き込み)(Action)
State(状態)
  • memberList
    会員データを保持する空のMapオブジェクトを準備。
    Actionsのメソッド PrepareMemberListで全ての会員をセット、insertMember で新規会員追加または既存会員情報を更新している。
Getter(算出プロパティ)
  • getById
    指定されたIDの会員情報を取得する
  • isMemberListEmpty
    会員リストが空(データが0件)かどうかを判定
Actions(処理・メソッド)

IndexedDBにアクセスし、データ取得、新しい会員情報の登録、会員情報の更新をするメソッドを定義している。

「IndexedDBの古いイベント駆動(コールバック)処理を、Vue3のモダンな async/await(Promise)で扱えるように包み直す(プロミス化する)」ための処理である。

全体的な処理の流れの概念図

【await Promise 開始】(データをすべて持ってくるまで、ここで一旦処理をストップ)
   │
   ├─(1) トランザクション(読み込み専用)を発行
   ├─(2) オブジェクトストア(members)にアクセス
   ├─(3) getAll() を実行(ブラウザに「全データ頂戴」とリクエスト)
   │      │
   │      └─► [ブラウザが裏でデータを集める(非同期処理)]
   │
   ├─(4)【成功時】onsuccess が発火 ──► データをMapに詰め替え ──► resolve() で await を解除!
   └─(4)【失敗時】onerror が発火   ──► エラーを検知         ──► reject() でエラーを投げる
await new Promise<void>((resolve, reject) => { ... })
  • IndexedDBは処理の完了を onsuccess などのイベントで通知する古い設計(コールバック式)のため、そのままでは await できない。
  • Promise で包むことで、「成功したら resolve を呼び、失敗したら reject を呼ぶまでこの場所(await)でプログラムの実行をストップして待つ」という状態を作っている。
const transaction = db.transaction(STORE_NAME, "readonly");
  • データベースに対してトランザクションI(取引・手続きのまとまり)を開始する。
  • 第2引数に “readonly”(読み込み専用)を指定することで、安全かつ高速にデータを読み込むモードでデータベースをロックする(書き込み時は “radwrite”にする)
const store = transaction.objectStore(STORE_NAME);
  • トランザクションを経由して、実際に会員データが格納されている箱(オブジェクトストア)を開く
const request = store.getAll();
  • ストア内にあるすべてのレコード(全会員のオブジェクト)を一度に取得するリクエストを発行
  • この行を実行した瞬間は、まだデータの取得はできていない。「ブラウザにデータを集めるように命令を出した」だけの状態。
  request.onsuccess = () => {
    const membersArray = request.result as Member[]; // データが配列で届く
    for (const member of membersArray) {
      memberListMap.set(member.id, member); // 配列からMapへ詰め替え
    }
    resolve(); // 【超重要】完了をPromiseに通知
  };
  • ブラウザが裏でデータの準備を完了すると、この onsuccess(イベントハンドラ)が自動的に実行される。
  • request.result の中に、全会員のデータが JavaScript の「配列」として入っている。
  • fro…of ループを使い、扱いやすいように伊地知的な memberListMap(Mapオブジェクト)へデータを1件ずつ詰め替えている。
  • 最後に resolve() を実行することで、外側で待っていた await が解除され、次の処理(Pinia の State への代入)へと進む。
  request.onerror = () => {
    reject(request.error); // 【重要】失敗をPromiseに通知
  };
});
  • 万が一、何らかの理由(ディスク容量不足やデータベースの破損など)でデータの取得に失敗いした場合に実行される。
  • rejext() を実行することで、外側の try-catch ブロックの catch (error) 部分にエラーオブジェクトを放り投げ、処理を安全に中断させる。

メソッド

  • prepareMemberList メソッド
    IndexedDBからすべての会員データを取得し、State(memberList)に展開する。
    ※画面表示時(初期化時)に必ず呼び出す。
  • insertMember メソッド
    新しい会員情報を登録、または既存の会員情報をこうしんする。
    State を更新したあと、IndexedDBへ永続化(保存)する
保存直前の toRaw() について
        // (3) データ更新(非同期処理リクエスト)
        const rawMember = toRaw(member);
        const request = store.put(rawMember);

IndexedDBは純粋なJavaScriptのオブジェクトしか保存できないため、Vue3のreactiveオブジェクト(Proxyという特殊な仕組み)をこのまま保存するとエラーが発生する。
toRow()は、reactive()やreadonly()などで生成されたリアクティブなProxyオブジェクトから、ラップされていない元の生(未加工)のオブジェクトを取得する為のVue3関数である。

会員リスト表示 MemberList.vue

IndexedDB の非同期処理(データの取得完了)を待つために、コンポーネント側にローディング状態(通信中フラグ)を管理するリアクティブ変数を追加した。

Vue
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { RouterLink } from "vue-router";
import { useMembersStore } from "@/stores/members";

const membersStore = useMembersStore();

// 1. データ取得中かどうかを管理するフラグ(初期値は true = 取得中)
const isLoading = ref(true);

// 2. コンポーネントがマウントされたら非同期でデータを取得する
onMounted(async () => {
  try {
    // IndexedDBからのデータ取得が終わるのを待つ
    await membersStore.prepareMemberList();
  } catch (error) {
    console.error("データの読み込みに失敗しました:", error);
  } finally {
    // 成功・失敗に関わらず、処理が終わったら取得中フラグを落とす
    isLoading.value = false;
  }
});
</script>

<template>
  <h1>会員管理</h1>
  <nav id="breadcrumbs">
    <ul>
      <li>
        <RouterLink v-bind:to="{ name: 'AppTop' }"> TOP </RouterLink>
      </li>
      <li>会員リスト</li>
    </ul>
  </nav>
  <section>
    <h2>会員リスト</h2>
    <p>
      新規登録は<RouterLink v-bind:to="{ name: 'MemberAdd' }">こちら</RouterLink
      >から
    </p>

    <section>
      <!-- 3. ローディング中の表示 -->
      <p v-if="isLoading">データ取得中・・・</p>

      <!-- データ取得が完了した後の表示 -->
      <ul v-else>
        <li v-if="membersStore.isMemberListEmpty">会員情報は存在しません。</li>

        <li v-for="[id, member] in membersStore.memberList" v-bind:key="id">
          <RouterLink v-bind:to="{ name: 'MemberDetail', params: { id: id } }">
            IDが{{ id }}の{{ member.name }}さん
          </RouterLink>
        </li>
      </ul>
    </section>
  </section>
</template>
  1. データ取得中かどうかを管理するフラグ
    • True = 取得中、False = 取得済み
    • データ読み込み後、成功・失敗に限らず False に変更し、画面が「取得中」のままフリーズするのを防いでいる
  2. onMounted と async/await
    • 画面が表示されたタイミング(onMounted)で非同期に変更した prepareMemberList()を await で待機させる。
  3. <section>の v-if / v-else による分岐
    • 読み込み中は isLoading が true のため「データ取得中・・・」の文言だけが表示され、読み込みが終わると自動的に ul タグのリスト表示(またはデータ無しメッセージ)に切り替わる。

メンバー詳細表示 MemberDetail.vue

リスト画面同様に IndexedDB からのデータ取得を待つ処理が必要。
ブラウザのURLから直接この詳細画面を開いた場合(リロード時など)は、まだ Pinia のストア(状態)が空っぽのため、画面表示時(onMounted)にデータを準備し、その間は「データ取得中・・・」と表示させる制御を追加した。

また、member の参照や localName の算出でエラーが起きないよう、データが取得できるまでは安全に処理をスキップするようにしている。

Vue
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { RouterLink, useRoute } from "vue-router";
import { useMembersStore } from "@/stores/members";

const route = useRoute();
const id = Number(route.params.id);
const membersStore = useMembersStore();

// 1. データ取得中かどうかを管理するフラグ
const isLoading = ref(true);

// 2. 画面表示時に IndexedDB からデータを準備する
onMounted(async () => {
  try {
    // ストアが空(リロード時など)の場合に備えてデータをロード
    await membersStore.prepareMemberList();
  } catch (error) {
    console.error("データの読み込みに失敗しました:", error);
  } finally {
    isLoading.value = false;
  }
});

// 3. 取得した会員情報をストアから取得(データが無い場合は undefined)
const member = computed(() => {
  return membersStore.getById(id);
});

// 備考の表示切替(データ取得完了後に計算する)
const localNote = computed((): string => {
  // 未だデータがない、または会員が存在しない場合はハイフンを返す
  if (!member.value) {
    return "---";
  }

  let localNote = "---";
  if (member.value.note !== undefined && member.value.note.trim() !== "") {
    localNote = member.value.note;
  }

  return localNote;
});
</script>

<template>
  <h1>会員管理</h1>
  <nav id="breadcrumbs">
    <ul>
      <li>
        <RouterLink v-bind:to="{ name: 'AppTop' }"> TOP </RouterLink>
      </li>
      <li>
        <RouterLink v-bind:to="{ name: 'MemberList' }"> 会員リスト </RouterLink>
      </li>
      <li>会員詳細情報</li>
    </ul>
  </nav>
  <section>
    <h2>会員詳細情報</h2>

    <!-- 4. 取得中の表示 -->
    <p v-if="isLoading">データ取得中・・・</p>

    <!-- 取得完了後の表示 -->
    <template v-else>
      <!-- 会員データが見つかった場合 -->
      <dl v-if="member">
        <dt>ID</dt>
        <dd>{{ member.id }}</dd>
        <dt>名前</dt>
        <dd>{{ member.name }}</dd>
        <dt>メールアドレス</dt>
        <dd>{{ member.email }}</dd>
        <dt>備考</dt>
        <dd>{{ localNote }}</dd>
      </dl>

      <!-- URLのIDに該当する会員がデータベースに居なかった場合 -->
      <p v-else>該当する会員情報は存在しません。</p>
    </template>
  </section>
</template>
  1. データ取得中かどうかを管理するフラグ
    • True = 取得中、False = 取得済み
    • データ読み込み後、成功・失敗に限らず False に変更し、画面が「取得中」のままフリーズするのを防いでいる
  2. onMounted と async/await
    • 画面が表示されたタイミング(onMounted)で非同期に変更した prepareMemberList()を await で待機させる。
  3. 取得した会員情報をストアから取得
    • データが無い場合は undefined
    • 非同期で後からデータが入ってくるため、computed で包んでリアクティブに追従させている。
  4. <p><template>の v-if / v-else による分岐
    • 読み込み中は isLoading が true のため「データ取得中・・・」の文言だけが表示され、読み込みが終わると自動的に dl タグの詳細表示(またはデータ無しメッセージ)に切り替わる。
    • <template v-else>を活用し、不要なタグを増やさずにローディング完了後の表示を分岐させている。

メンバー追加 MemberAdd.vue

IndexedDB への保存処理(insertMember)を非同期(async/await)に変更したため、登録処理を行う onAdd メソッドも async/await に変更している。
これを行わないと「IndexedDB への保存が完了する前に、画面が会員リストに切り替わってしまう(その結果、一覧に新しいデータが反映されない)」という不具合が起きてしまうため、確実に保存を待ってから画面遷移させている。

Vue
<script setup lang="ts">
import { reactive, ref } from "vue";
import { RouterLink, useRouter } from "vue-router";
import type { Member } from "@/interfaces";
import { useMembersStore } from "@/stores/members";

const router = useRouter();
const membersStore = useMembersStore();

// 1. 登録処理中かどうかを管理するフラグ(二重送信防止用)
const isSubmitting = ref(false);

// リアクティブなMemberオブジェクトを用意
const member: Member = reactive({
  id: 0,
  name: "",
  email: "",
  points: 0,
  note: "",
});

// [登録]ボタンクリックで呼び出されるメソッドを async 化
const onAdd = async (): Promise<void> => {
  // すでに処理中の場合は何もしない(連打防止)
  if (isSubmitting.value) return;
  isSubmitting.value = true;

  console.log(member);

  try {
    // 2. IndexedDB への保存完了を確実に待つ
    await membersStore.insertMember(member);

    // 保存が成功したら一覧画面へ遷移する
    router.push({ name: "MemberList" });
  } catch (error) {
    console.error("データの登録に失敗しました:", error);
    alert("登録に失敗しました。もう一度お試しください。");
  } finally {
    isSubmitting.value = false;
  }
};
</script>

<template>
  <h1>会員管理</h1>
  <nav id="breadcrumbs">
    <ul>
      <li>
        <RouterLink v-bind:to="{ name: 'AppTop' }"> AppTop </RouterLink>
      </li>
      <li>
        <RouterLink v-bind:to="{ name: 'MemberList' }"> 会員リスト </RouterLink>
      </li>
      <li>会員情報追加</li>
    </ul>
  </nav>
  <section>
    <h2>会員情報追加</h2>
    <p>情報を入力し、登録ボタンをクリックしてください。</p>

    <form v-on:submit.prevent="onAdd">
      <dl>
        <dt><label for="addId">ID </label></dt>

        <!-- 登録処理中は入力を無効化(desabled)して安全性を高める -->
        <dd>
          <input
            type="number"
            id="addId"
            v-model.number="member.id"
            :disabled="isSubmitting"
            required
          />
        </dd>
        <dt><lavel for="addName">名前 </lavel></dt>
        <dd>
          <input
            type="text"
            id="addName"
            v-model="member.name"
            :disabled="isSubmitting"
            required
          />
        </dd>
        <dt><lavel for="addEmail">メールアドレス </lavel></dt>
        <dd>
          <input
            type="email"
            id="addEmail"
            v-model="member.email"
            :disabled="isSubmitting"
            required
          />
        </dd>
        <dt><lavel for="addPoints">保有ポイント </lavel></dt>
        <dd>
          <input
            type="number"
            id="addPoints"
            v-model="member.points"
            :disabled="isSubmitting"
            required
          />
        </dd>
        <dt><lavel for="addNote">備考 </lavel></dt>
        <dd>
          <textarea
            id="addNote"
            v-model="member.note"
            :disabled="isSubmitting"
          ></textarea>
        </dd>
      </dl>

      <!-- 処理中のボタン表示切り替えと連打無効化 -->
      <button type="submit" :disabled="isSubmitting">
        {{ isSubmitting ? "登録中" : "登録" }}
      </button>
    </form>
  </section>
</template>
  1. isSubmitting による二重送信防止
    • IndexecDB への保存には一瞬だが時間がかかる。ユーザーが「登録」ボタンを連打して何度も書き込み処理が発生したり、エラーが起きたりするのを防ぐために処理中はボタンと入力フォームを disabled(無効化)にしている。
  2. await membersStore.insertMember(member)
    • 非同期になった Pinia のアクションを await で待機させる。
    • IndexedDBへの書き込みが正常に終わった後に router.push が実行されるため、遷移後のリスト画面に必ずデータが存在する状態を作れる。

外部ライブラリを利用する実装

目的に応じて選べる主な外部ライブラリとその比較

ツールデータモデル用途複雑な検索TypeScript対応ライブラリの重さ開発のしやすさPiniaとの同期
Dexie.jsオブジェクト形式
(RDB・テーブル風)
複雑なデータ構造・検索が必要なアプリ非常に得意
(where().equals()当)
完璧
(非常に型安全)
普通
(約20KB)
非常に簡単で直感的アクション内で手動同期
localForagekey-value
(localStrage風)
設定値やセッション情報などの単純保存不可
(全件取得してJSで処理)
普通普通
(約8KB)
非常に簡単プラグインで自動同期
idb低レイヤー・軽量バンドルサイズを削りつつ、機能もフルで使いたい時可能
(だたしコードが複雑)
良好
(型定義はやや難解)
軽量
(約1.5KB)
生の知識が必要で難解プラグイン等で手動/自動
idb-keyval超極小・シンプルPiniaのStateを丸ごと1つの塊として保存したい時不可
(全件取得してJSで処理)
良好
(ジェネリスク対応)
超極小
(約500byte)
最も簡単
(1分で導入可)
プラグイン等で手動/自動

Dexie.jsを使って実装していきます。

Dexie.js をプロジェクトにインストール

ターミナルで次のインストールコマンドを実行する

npm install dexie

数秒~数十秒で完了し、画面が入力待ちに戻る。

ストアファイル members.ts を Dexie.js 用に修正

members.ts
import { ref, computed, toRaw } from 'vue'; // toRawは不要
import { defineStore } from 'pinia';
import type { Member } from '@/interfaces.ts';
import Dexie, { type Table } from 'dexie'; // 追加

//--------------------------------------
// 1. Dexie データベースの定義
//--------------------------------------
class MemberDatabase extends Dexie {   // 1-1.
  // member テーブルの型を定義
  members!: Table<Member, number>;       // 1-2.

  constructor() {                        // 1-3.
    //super('MemberDatabase');
    super('asyncdb');

    // バージョンとストア(主キーは id)を定義
    this.version(1).stores({
      members: 'id',
    });
  }
}

// データベースインスタンスの生成
const db = new MemberDatabase();         // 1-4.

/**
 * 2.会員情報を管理する Pinia ストア
 *
 * 【主な役割】
 * - 会員データの状態(State)管理
 * - IndexedDB との非同期同期(読み込み・書き込み)
 * - 特定の会員検索や空チェックなどの算出プロパティ(Getters)の提供
 */
export const useMembersStore = defineStore('members', () => {
  //=======================================
  // 2-1. State(状態)
  //=======================================
  /** 会員データを保持するMapオブジェクト(キーは会員ID) */
  const memberList = ref<Map<number, Member>>(new Map<number, Member>());

  //=======================================
  // 2-2. Getters(算出プロパティ)
  //=======================================
  /**
   * 指定されたIDの会員情報を取得する
   * @param id 検索したい会員のID
   * @returns 該当する会員オブジェクト。存在しない場合は undefined
   */
  const getById = computed(() => {
    return (id: number): Member => {
      // stateの値にアクセスするため memberList.value を使用
      return memberList.value.get(id) as Member;
    };
  });

  /**会員リストが空(データが0件)かどうかを判定する */
  const isMemberListEmpty = computed(() => {
    return memberList.value.size == 0;
  });

  //=======================================
  // 2-3. Actions(処理・メソッド)
  //=======================================
  /**
   * Dexieを使ってデータを全件取得し、State(memberList)に展開する。
   * ※画面表示時(初期化時)に必ず呼び出す。
   * @returns 非同期処理の Promise
   */
  const prepareMemberList = async (): Promise<void> => {
    // 空のMapを用意
    const memberListMap = new Map<number, Member>();
    try {
      const membersArray = await db.members.toArray();     // 2-4.
      for (const member of membersArray) {
        memberListMap.set(member.id, member);
      }
    } catch (error) {
      console.error('Dexieからのデータ取得に失敗しました:', error);
    }

    // 最新のデータを State へ反映
    memberList.value = memberListMap;
  };

  /**
   * 新しい会員情報を登録、または既存の会員情報を更新する
   * State を更新したあと、IndexedDBへ永続化(保存)する
   * @param member 登録・更新する会員オブジェクト
   * @returns 非同期処理の Promise
   */
  const insertMember = async (member: Member): Promise<void> => {
    // ステート(ref)のmemberListに会員情報を追加
    // ローカルの状態を先行して更新(UIの応答性を上げるため)
    memberList.value.set(member.id, member);

    try {
      const rawMember = toRaw(member);
      const safeData = JSON.parse(JSON.stringify(rawMember));
      await db.members.put(safeData);                           // 2-5.
    } catch (error) {
      console.error('Dexitへの保存に失敗しました:', error);
    }
  };

  // 外部のコンポーネントに公開する要素
  return {
    memberList,
    getById,
    isMemberListEmpty,
    prepareMemberList,
    insertMember,
  };
});
1. Dexie データベースの定義
  1. Dexieクラスを継承した独自のデータベースクラスを作成
  2. member テーブルの型を定義
    • 「members」は、ブラウザのIndexedDBないに作られる実際のテーブル名(ストア名)と同じ名前にする。
    • 「!」は確実割り当て評定子で、TypeScriptに裏側で確実に初期化されるからエラーを出さないように伝える役割を持つ。
    • 「:」は、型定義の合図。ここから右に型を書くというTypeScriptの標準的な記号
    • 「Table」は、Dexie.jsが提供しているIndexedDBのテーブル(オブジェクトストア)を表す専用の型。
    • 「<Member, number>」のMemberは、テーブルに保存されるデータ1件分(レコード)がどんな型(オブジェクト)をしているかを指定。
      numberは、主キーの型を指定している。
  3. constructor():初期化ブロック
    • supre(‘asyncdb’);:親クラスである Dexie の機能を呼び出している。引数に渡した文字列がブラウザ(IndexedDB)に実際に作られるデータベースの名前となる。
    • this.version(1):このデータベース構造の「版(バージョン)」を 1 に設定している。
      将来、アプリのアップデートで「新しいテーブルを追加したい」「検索用のインデックスを増やしたい」となったときは、この数字を 2 や 3 に上げて構造を書き換えていくことになる。
    • .stores({…}):データベースの中に作成するテーブルの名前と、検索に使用するキー(インデックス)をまとめて定義するメソッド。
    • members: ‘id’:members は作成するテーブルの名前で、’id’ はそのテーブルの主キー(プライマリキー)の指定である。
  4. const db = new MemberDatabase();
    • クラスとして定義した MemberDatabase を元に、実際にプログラム中で操作できる「データベースの実体(インスタンス)」を生成し、db という名前の定数に代入している。
    • この処理により、これ以降のコードで db.members.toArray() や db.member.put() といった直感的なデータベース操作が出来るようになる。
    • この1行が Pinia(export const useMemberStore = …) の外側に書かれているのは、もしこれを Pinia ストアの内側に書いてしまうと、画面が切り替わってストアが読み込まれるたびに、何度もデータベースの接続を作る直すという無駄で思い処理が発生してしまう為である。外側に書いておくことで、アプリが起動した時に1つだけのデータベース接続(シングルトン)が作られ、ストア内のすべての関数がその同じ db を安全に共有して使いまわせるようになる。
2.会員情報を管理する Pinia ストア
  1. State(状態)
    • 変更することなく、そのまま使える
  2. Getter(算出プロパティ)
    • 変更することなく、そのまま使える
  3. Action(処理・メソッド)
    • 複雑なイベントハンドラや Promise のラップ処理が全て消え、直感的な async/await だけのシンプルなコードに変わった。
  4. 全件取得
    • 下記1行で全件取得が完了
      const membersArray = await db.members.toArray();
  5. データ保存
    • 下記1行で IndesedDB への保存が完了
      await db.members.put(safeData);
toRaw()
const rawMember = toRaw(member);

Vue3では画面の自動更新を実現するために定義したオブジェクトを Proxy(プロキシ)という特殊な膜で包み込んで常に監視している。
コンポーネントからストアへ渡されてきた member もこの Proxy に包まれた状態になっている。
twoRaw() は Vew3 が公式に提供している関数で、これを使うことでオブジェクトを傷つけることなく表面の監視の膜(Proxy)だけを剥ぎ取り中身の生の JavaScript オブジェクトを取り出すことができる。

JSON.parese()
const safeData = JSON.parse(JSON.stringify(rawMember));

ネストされた深い部分の膜も特殊な機能もすべて消し去り、100%安全なデータにする。
オブジェクトの中にさらに別のオブジェクトや配列が深く入っている場合(ネストしている場合)toRaw() だけでは一番外側の膜しか剥せず、内部の深い部分に Proxy の監視が残ってしまうことがある。
また、Vue が内部的に付与した特殊な隠しプロパティ(リレーション情報など)が残っていることもある。
そこで、このJSONトリック(ディープコピー)を使って全てを剥ぎ取る。

  1. JSON.stringify(rawMember)(文字化)
    • オブジェクトを一瞬だけだたの「1本の長い文字列(テキスト)」に変換
    • テキストデータ(文字列)になった瞬間、Vueのデータ監視システムや特殊な機能、裏側の繋がりなどは全て強制的に消滅し、ただの文字の羅列になる
  2. JSON.pares(…)(復元) 
    • ただの文字列をもう一度 JavaScript のオブジェクトの形に組み立て直す

※toRaw()とまとめて書く

const safeData = JSON.pares(JSON.stringify(toRaw(member)));

JSON.stringifyだけでも、ほとんどのケースで完全に同じように動作する。
しかし、コードをパッと見た時、JSON.pares(JSON.stringify(member))だけの場合、なぜここでわざわざディープコピーをしているのか伝わりにくい場合がある。TypeScriptや他の開発者への配慮として ToRawも添えておくというイメージを持つ。

また、TypeScriptを使っている場合、VueのProxyオブジェクトをそのままJSON.stringfy()に放り込むと、設定やVueのバージョンによっては「型が複雑すぎて文字列に変換できません」という警告やエラーを出してくることがある。
あらかじめ Vue 公式の機能である toRaw() をとおして「普通のオブジェクト」に変換しておくことで予期せぬエラーを未然に防ぐ役割を果たしている。

画面側のコンポーネント

画面側のソースコード(MemberList.vue, MemberDetail.vue, MemberAdd.vue)は一切変更する必要はない。

Pinia の prepareMemberList() と insertMember( member) という関数の名前と、それが Promise を返すという仕組み(インターフェース)自体は何も変わっていないため、内部がネイティブの IndexedDB から Dexie.js にすり替わってもそのまま完全に動作する。

MySQL + PHP

MySQL、PHPを使って、これまでブラウザ内(IndexedDB / Dexie.js)で動いていた仕組みをサーバー管理(Webシステム)に変更していく。

動作環境

CORESERVERで借りているサーバーに「vue3.sugi-koubou.com」というサイトを作りました。
このサーバーで動かして行こうとおもいます。
データベースはMySQL(MariaDB)、PHPはphp83で、ファイル転送はWinSCPで行います。

Visual Studio Codeでプロジェクトを作成し、動作確認したあとビルドしてサーバーにアップします。

大まかな仕組み

[Vue3(画面)]⇔[PHP(中継・データ処理)]⇔[MySQL(保存)]

作業ステップ

  1. MySQLテーブルを作成
    • データベース内に members という名前のテーブルを作成
    • これまで Member 型で使ていた項目を列(カラム)として定義する
  2. PHPプログラム(API)作成
    • Vue3からのリクエストを受け取り、MySQLを操作して結果を返す中継プログラムをPHPで作成
    • 処理1:MySQLからデータを全件取得し、JSON形式で Vue3に返す処理
    • 処理2:Vue3 から送られてきた会員データを MySQL に INSERT または UPDATE する
  3. Vue3(Piniaストア)の通信処理を書き換える
    • これまで db.members.put() などでブラウザに保存していた処理を、サーバーのPHPへデータを送信する処理(API通信)に書き換える
    • ツール:JavaScript 標準の fetch() または、よく使われるライブラリ axios(アクシオス)を使用
    • 書換対象:Pinia ストアの prepareMemberList と insertMember の中身を PHP への通信処理に差し替える
  4. Vue3 アプリをビルド(製品化)
    • サーバーにアップロードできる形にファイルを変換
    • コマンド:npm run build
    • 結果:プロジェクト内に dist というフォルダが出来上がり、その中にサーバーにアップできる HTML や JavaScript ファイル一式が書き出される
  5. サーバーへアップロード
    • FTPソフトやサーバー管理画面のファイルマネージャーを使ってファイルを配置
    • Vue3のファイル:dist フォルダの中身を公開フォルダに丸ごとアップロード

MySQLテーブルを作成

作業は、コアサーバーの管理画面から phpMyAdmin を開き行います。

次のSQLを実行しテーブルを作成

CREATE TABLE IF NOT EXISTS members (
    id INT NOT NULL,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255) NOT NULL,
    points INT NOT NULL DEFAULT 0,
    note TEXT,
    PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

IndexedDBと同様のテーブルにしています。これまでのプログラムをそのまま使用するので、idは自動連番(AUTO_ONCREMENT)にはせず、プログラム側の id をそのまま主キーとして保存する設定にしている。

PHPプログラム(API)作成

ファイル名は api.php にしまた。パソコン上でコーディングした後 WinSCPでサーバーにアップします。

PHP
<?php
// 1. Vue3 application(開発環境や別ドメイン)からのアクセスを許可する設定
// 1-1. header(...)
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Headers: Content-Type");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Content-Type: application/json; charset=UTF-8");

// 1-2. OPTIONSリクエスト(事前確認通信)が来たらその時点で終了する
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    exit(0);
}

// 2.データベース接続
// 2-1. 接続情報 (自身の環境の値をセット)
$db_host = 'localhost';
$db_port = '●●●●';
$db_user = '●●●●●●●●●●';
$db_pass = '●●●●●●●●';
$db_name = '●●●●●●●●●●';

try {
    // 2-2. 接続
    $pdo = new PDO(
        "mysql:host={$db_host};port={$db_port};dbname={$db_name};charset=utf8mb4",
        $db_user,
        $db_pass,
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]
    );
} catch (PDOException $e) {
    echo json_encode(["error" => "データベース接続失敗: ", $e->getMessage()]);
    exit;
}

$method = $_SERVER['REQUEST_METHOD'];

//===============================================
// 3. GET リクエスト:データを全件取得して返す
//===============================================
if ($method === 'GET') {
    try {
        $stmt = $pdo->query("SELECT * FROM members");
        $members = $stmt->fetchAll();

        // points と id を数値型に変換して型を合わせる
        foreach ($members as &$m) {
            $m['id'] = (int)$m['id'];
            $m['points'] = (int)$m['points'];
        }

        echo json_encode($members);

    } catch (Exception $e) {
        echo json_encode(["error" => $e->getMessage()]);
    }
}

//===============================================
// 4. POST リクエスト:データを追加・上書き更新する
//===============================================
if ($method === 'POST') {
    $json = file_get_contents('php://input');
    $data = json_decode($json, true);

    if (!$data || !isset($data['id'])) {
        echo json_encode(["error" => "不正なデータです"]);
        exit;
    }

    try {
        $sql = "INSERT INTO members (id, name, email, points, note)
                VALUES (:id, :name, :email, :points, :note)
                ON DUPLICATE KEY UPDATE
                name = :name, email = :email, points = :points, note = :note";

        $stmt = $pdo->prepare($sql);
        $stmt->execute([
            ':id'       => $data['id'],
            ':name'     => $data['name'],
            ':email'    => $data['email'],
            ':points'    => $data['points'],
            ':note'      => $data['note'] ?? null
        ]);

        echo json_encode(["success" => true]);

    } catch (Exception $e) {
        echo json_encode(["error" => $e->getMessage()]);
    }
}
  1. 初期設定と CORS解除
    1. header(…)
      • 本来ブラウザは、セキュリティのルール(CORS制限)により、別ドメインからの通信を拒否する。
      • このコードは、外部のアプリ(今回の場合は Vue3など)からこのPHPにアクセスできるようにするための設定。
      • ブラウザやアプリに対して「このデータはJSON形式で、どこからでもアクセスして良い」という許可書(ヘッダー)を送っている。
    2. OPTIONSリクエスト
      • $_SERVER[‘REQUEST_METHOD’]:特殊な変数で、アクセス方法(GETやPOSTなど)が格納されている
      • OPTIONS/exit(0):ブラウザが通信テストとして投げてくる OPTIONS という哀切(事前確認)を受け取ったら、なにもせずに「OK]とだけ返して終了する。
  2. データベース接続
    1. 接続情報
      • 接続に必要は、ホスト名、ポート番号、ユーザー名、ぱすぁーど、データベース名を設定
    2. $pdo = new PDO(…):PHPでデータベースを操作する標準的な仕組み(PDO)を使って接続。
  3. データ取得処理 [GET]
    • 画面から GET リクエストが来た時の処理
    • SELECT * FROM members:テーブルから全データを取得するSQL命令
    • foreach ($members as &$m):取得したデータを1件ずつループ処理
    • (int)キャスト:データベースから取得sたままだと数字も「文字列」になっていることがあるため、「数値」に変換している。&は元のデータを直接書き換えるための記号。
  4. データ保存・更新処理 [POST]
    • 画面から POST でデータが送られてきたときの処理
    • file_get_contents(‘php://input’):Vue3などのフロントエンドから送られてきた JSON データを受け取る定石の書き方。
    • json_decode(…, true):届いた JSON データを PHP の配列形式に戻す。
    • isset(…):変数や配列の中に指定したデータ(ここでは id)が存在するかをチェックしている。
    • ON DUPLICATE KEY UPDATE:SQLの構文で、「送られてきた id がまだなければ新しく登録(INSERT)し、すでに同じ id があれば中身を更新(UPDATE)する」という命令。
    • prepare(…)とexecute(…):データを安全に保存する為に、SQLの文章に直接データを埋め込まず、:name などの「仮のプレースホルダー」を置き、後から安全にデータを流し込むことでハッカーからの攻撃を防いている。
    • ?? null(Null合体演算子):もし(note)が送られてこなかったら、代わりに null を入れるという意味。

動作確認

作成した api.php をサーバーの公開ディレクトリにアップ、ブラウザで直接アクセスしてみる。

正しくデータベースに接続できていれば、画面に [] (まだデータが空という意味のマーク)とだけ表示される。もし、接続エラーが出る場合はエラーメッセージが表示される。

Vue3(Piniaストア)の通信処理を書き換える

通信用ライブラリ axios と標準の fetch のどちらを使うか

axios と fetch のどちらを使っても PHP との通信は可能であるが、それぞれに明確な特徴がある。

axios と fetch の比較

比較項目axios(外部ライブラリ)fetch(ブラウザ標準)
手軽さインストールが必要すぐ使える(インストール不要)
JSONの自動変換自動で変換される手動で変換が必要
エラー判定404や500エラーも自動でキャッチ404や500エラーを正常通信と判定する
共通設定(便利機能)URLの共通部分を1箇所にまとめられる毎回ふるURLを書く必要がある

違いの具体例

fetchの場合(標準機能)
const response = await fetch("URL", {
  method: "POST",
  headers: {"Content-Type": "application/json"},
  body: JSON.stringify(member)
});
if (!response.ok) {
  trow new Error("通信エラー");
}
axiosの場合(外部ライブラリ)
await axios.poist("URL", member);

Vue3 の Pinia ストアの中で使う場合、axios を選ぶとコードが圧倒的に短く、安全になる。
ここでは、axiosをインストールして使うことにします。

axios インストール

ターミナルで次のコマンドを実行。

npm install axios

members.ts

Pinia ストアをサーバー上のPHPと通信する形へ編集

members.ts
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
import type { Member } from '@/interfaces.ts';
import axios from 'axios'; // インストールした axios を読み込む

//======================================
// 1. 通信先(サーバーのPHP)のURL設定
//======================================
const API_URL = 'https://vue3.sugi-koubou.com/api.php';

/**
 * 会員情報を管理する Pinia ストア(MySQL・PHP通信版)
 */
export const useMembersStore = defineStore('members', () => {
  //=======================================
  // State(状態)
  //=======================================
  /** 会員データを保持するMapオブジェクト(キーは会員ID) */
  const memberList = ref<Map<number, Member>>(new Map<number, Member>());

  //=======================================
  // Getters(算出プロパティ)
  //=======================================
  /**
   * 指定されたIDの会員情報を取得する
   * @param id 検索したい会員のID
   * @returns 該当する会員オブジェクト。存在しない場合は undefined
   */
  const getById = computed(() => {
    return (id: number): Member => {
      // stateの値にアクセスするため memberList.value を使用
      return memberList.value.get(id) as Member;
    };
  });

  /**会員リストが空(データが0件)かどうかを判定する */
  const isMemberListEmpty = computed(() => {
    return memberList.value.size == 0;
  });

  //=======================================
  // Actions(処理・メソッド: PHPと通信する部分)
  //=======================================
  /**
   * PHPからデータを全件取得し、Stateに展開する
   * @returns 非同期処理の Promise
   */
  const prepareMemberList = async (): Promise<void> => {
    // 空のMapを用意
    const memberListMap = new Map<number, Member>();

    try {
      // 2. axios.get で PHP からデータを取得(JSONからの変換も自動)
      const response = await axios.get<Member[]>(API_URL);
      const membersArray = response.data;

      // 取得した配列をMapに詰め替える
      for (const member of membersArray) {
        memberListMap.set(member.id, member);
      }
    } catch (error) {
      console.error('サーバーからのデータ取得に失敗しました:', error);
    }

    // 最新のデータを State へ反映
    memberList.value = memberListMap;
  };

  /**
   * 新しい会員情報を登録、または既存の会員情報を更新する
   * @param member 登録・更新する会員オブジェクト
   * @returns 非同期処理の Promise
   */
  const insertMember = async (member: Member): Promise<void> => {
    // ステート(ref)のmemberListに会員情報を追加
    // ローカルの状態を先行して更新(UIの応答性を上げるため)
    memberList.value.set(member.id, member);

    try {
      // 3. axios.post でデータを送信
      // Proxy(監視の膜)の剥ぎ取りや JSON 文字列への変換は axios が自動で処理
      await axios.post(API_URL, member);
    } catch (error) {
      console.error('サーバーへの保存に失敗しました:', error);
    }
  };

  // 外部のコンポーネントに公開する要素
  return {
    memberList,
    getById,
    isMemberListEmpty,
    prepareMemberList,
    insertMember,
  };
});
  1. API_URL
    • サーバーのPHPのURL
  2. const response = await axios.get(API_URL);
    • 指定したURL(今回はPHP)に対してGETリクエストする命令
    • サーバー(PHP)からはただの長い文字列(JSONテキスト)としてデータが送られてくる。axios はそれを裏側で検知し、自動的に JavaScript のオブジェクト(配列)に変換(パース)して、response.data の中に格納してくれる。
  3. await axios.post(API_URL, member);
    • 指定したURL(PHP)に対してPOSTリクエストする命令
    • 第1引数(API_URL):データの送り先
    • 第2引数(member):サーバーへ送りたいデータ
    • axiosは通信する際、オブジェクトの純粋な値だけを読み取る為、toRaw などの処理がなくても自動的に奇麗なデータとして送信してくれる。
    • axiosは裏側で JSON.stringify(member) と同じ処理を自動で実行し、インターネットで送れるテキスト形式に変換してヘッダーに「これはJSONデータです」と書き加えて送信してくれる

アプリをビルド(製品化)しサーバーへアップロード

ビルド

以下のコマンドでVue3アプリをビルドします

npm run build

ビルドが成功すると、プロジェクトのフォルダ内に新しく dist という名前のフォルダが自動生成される。

api.php は public フォルダ内に作成しています。このファイルも dist フォルダの直下にそのまま自動コピーされている。

アップロード

FTPソフト(今回はWinCSP)を使って dist フォルダの中身をサーバーにアップ

動作確認

ブラウザでファイルをアップしたサイトを開きます

無事起動しました。

データの登録もできました。

コメント

タイトルとURLをコピーしました