Vue.jsで一覧画面の無限スクロールを実装する:サンプルJSONから一覧データを取得
作成日:2026.09.03
Vue.js単体のViteプロジェクトで、サンプルJSONからデータを取得し、IntersectionObserverで画面下部の監視要素を検知して5件ずつカード表示する無限スクロールを実装します。fetchのエラー処理や再読み込み、全件表示後の監視解除も扱い、サンプルJSONを一括取得するモックと、実際のAPIによるページングの違いも説明します。
目次
一覧画面のデータが多い場合、最初からすべての項目を表示するのではなく、画面を下へスクロールしたタイミングで続きを表示したくなるケースもあるかと思います。
今回は、Vue.js単体のViteプロジェクトで、将棋の対局結果を一覧表示する画面を作ります。対局結果のサンプルJSONを一度取得し、5件ずつカードを追加表示します。
画面下部の監視用要素には、ブラウザのIntersectionObserverを使います。今回はデータベースや本格的なAPIは用意せず、無限スクロールの画面制御に範囲を絞って紹介します。
前提環境
今回の動作確認環境は以下の通りです。
- OS: Windows 11
- ターミナル: PowerShell
- Node.js: v20.20.2
- npm: 10.8.2
- Vue.js: 3.5.41
- Vite: 8.2.0
- JavaScript構成
Vue.jsとViteのプロジェクトは、以前の記事「ViteでVue.jsプロジェクトを始める:ファイル構成と開発・ビルドの流れ」で作成したものを使います。プロジェクトの作成方法や、npm run devの基本的な使い方は、そちらを参照してください。
Vue.js、Vite、Node.jsは更新されるため、実際に新しいプロジェクトを作成するときは、Vue.js公式のクイックスタートとVite公式のGetting Startedで対象バージョンとNode.jsの要件を確認します。
今回作成する画面
今回は、将棋の対局結果を次のようなカード形式で表示します。
- 対局日
- 棋戦
- 先手
- 後手
- 勝者
1回の追加表示件数は5件、サンプルJSONの件数は20件です。初期表示では5件だけを表示し、画面下部の監視要素が表示領域へ入るたびに5件ずつ追加します。
無限スクロールの処理の流れ
今回の処理は、次のようにします。
ブラウザ
↓ ページを表示
Vue.js
↓ fetchでJSONを一度取得
public/data/shogi-games.json
↓
対局結果20件を保持
↓ 末尾の監視要素が表示される
表示件数を5件追加
↓
すべて表示するまで繰り返す
今回のサンプルでは、サンプルJSONを最初に一度だけ取得します。その後の「追加読み込み」は、すでに取得した配列から表示する範囲を広げる処理です。
このため、実際のAPIへページ単位でアクセスする無限スクロールとは異なります。大量のデータを扱う場合は、API側でページングを行い、必要なページだけ取得する構成が良いと思います。
サンプルJSONを用意する
まず、プロジェクトのpublicディレクトリ内にdataディレクトリを作成します。
New-Item -ItemType Directory -Force .\public\data
public/data/shogi-games.jsonを作成し、次の内容を記述します。
[
{
"id": 1,
"date": "2026-08-05",
"tournament": "順位戦B級2組",
"sente": "斎藤明日斗",
"gote": "羽生善治",
"winner": "斎藤明日斗"
},
{
"id": 2,
"date": "2026-08-05",
"tournament": "順位戦B級2組",
"sente": "屋敷伸之",
"gote": "渡辺和史",
"winner": "渡辺和史"
},
{
"id": 3,
"date": "2026-08-05",
"tournament": "順位戦B級2組",
"sente": "石井健太郎",
"gote": "丸山忠久",
"winner": "丸山忠久"
},
{
"id": 4,
"date": "2026-08-05",
"tournament": "順位戦B級2組",
"sente": "郷田真隆",
"gote": "戸辺誠",
"winner": "郷田真隆"
},
{
"id": 5,
"date": "2026-08-05",
"tournament": "順位戦B級2組",
"sente": "深浦康市",
"gote": "行方尚史",
"winner": "深浦康市"
},
{
"id": 6,
"date": "2026-08-05",
"tournament": "順位戦B級2組",
"sente": "佐藤和俊",
"gote": "三浦弘行",
"winner": "三浦弘行"
},
{
"id": 7,
"date": "2026-08-05",
"tournament": "順位戦B級2組",
"sente": "佐々木慎",
"gote": "木村一基",
"winner": "木村一基"
},
{
"id": 8,
"date": "2026-08-05",
"tournament": "順位戦B級2組",
"sente": "青嶋未来",
"gote": "岡部怜央",
"winner": "青嶋未来"
},
{
"id": 9,
"date": "2026-08-05",
"tournament": "順位戦B級2組",
"sente": "谷川浩司",
"gote": "横山泰明",
"winner": "谷川浩司"
},
{
"id": 10,
"date": "2026-08-05",
"tournament": "順位戦B級2組",
"sente": "古賀悠聖",
"gote": "松尾歩",
"winner": "松尾歩"
},
{
"id": 11,
"date": "2026-08-05",
"tournament": "順位戦B級2組",
"sente": "阿久津主税",
"gote": "村山慈明",
"winner": "阿久津主税"
},
{
"id": 12,
"date": "2026-08-07",
"tournament": "棋王戦コナミグループ杯",
"sente": "豊島将之",
"gote": "中村太地",
"winner": "豊島将之"
},
{
"id": 13,
"date": "2026-08-07",
"tournament": "リコー杯女流王座戦",
"sente": "西山朋佳",
"gote": "中澤沙耶",
"winner": "西山朋佳"
},
{
"id": 14,
"date": "2026-08-07",
"tournament": "ユニバーサル杯女流名人戦",
"sente": "福間香奈",
"gote": "磯谷祐維",
"winner": "磯谷祐維"
},
{
"id": 15,
"date": "2026-08-08",
"tournament": "将棋日本シリーズ",
"sente": "斎藤慎太郎",
"gote": "伊藤匠",
"winner": "斎藤慎太郎"
},
{
"id": 16,
"date": "2026-08-10",
"tournament": "王座戦",
"sente": "本田奎",
"gote": "中川大輔",
"winner": "本田奎"
},
{
"id": 17,
"date": "2026-08-10",
"tournament": "朝日杯将棋オープン戦",
"sente": "炭崎俊毅",
"gote": "井上慶太",
"winner": "炭崎俊毅"
},
{
"id": 18,
"date": "2026-08-10",
"tournament": "朝日杯将棋オープン戦",
"sente": "都成竜馬",
"gote": "菅井竜也",
"winner": "菅井竜也"
},
{
"id": 19,
"date": "2026-08-13",
"tournament": "竜王戦",
"sente": "柵木幹太",
"gote": "服部慎一郎",
"winner": "服部慎一郎"
},
{
"id": 20,
"date": "2026-08-14",
"tournament": "竜王戦6組",
"sente": "神崎健二",
"gote": "森本才跳",
"winner": "森本才跳"
}
]
Viteでは、プロジェクトルートのpublicディレクトリに置いたファイルは、開発時にはルートパスから配信されます。今回のJSONは/data/shogi-games.jsonで取得できます。
Vue.jsで対局結果を表示する
src/App.vueを次の内容へ置き換えます。
<script setup>
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
} from 'vue';
const dataUrl = '/data/shogi-games.json';
const pageSize = 5;
const games = ref([]);
const visibleCount = ref(0);
const isLoading = ref(false);
const errorMessage = ref('');
const sentinel = ref(null);
let observer = null;
const visibleGames = computed(() => {
return games.value.slice(0, visibleCount.value);
});
const hasMore = computed(() => {
return visibleCount.value < games.value.length;
});
function formatDate(value) {
return new Intl.DateTimeFormat('ja-JP', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(new Date(`${value}T00:00:00+09:00`));
}
async function loadGames() {
if (isLoading.value) {
return;
}
isLoading.value = true;
errorMessage.value = '';
try {
const response = await fetch(dataUrl);
if (!response.ok) {
throw new Error(`HTTP status: ${response.status}`);
}
const data = await response.json();
if (!Array.isArray(data)) {
throw new Error('JSONの形式が正しくありません。');
}
games.value = data;
visibleCount.value = Math.min(pageSize, data.length);
await nextTick();
observeSentinel();
} catch (error) {
games.value = [];
visibleCount.value = 0;
errorMessage.value = '対局結果を読み込めませんでした。';
console.error(error);
} finally {
isLoading.value = false;
}
}
function showNextGames() {
if (isLoading.value || !hasMore.value) {
return;
}
visibleCount.value = Math.min(
visibleCount.value + pageSize,
games.value.length,
);
if (!hasMore.value && observer !== null) {
observer.disconnect();
}
}
function onIntersect(entries) {
const entry = entries[0];
if (entry && entry.isIntersecting) {
showNextGames();
}
}
function observeSentinel() {
if (sentinel.value === null) {
return;
}
if (observer !== null) {
observer.disconnect();
}
observer = new IntersectionObserver(onIntersect, {
root: null,
rootMargin: '200px 0px',
threshold: 0,
});
observer.observe(sentinel.value);
}
function retry() {
loadGames();
}
onMounted(loadGames);
onBeforeUnmount(() => {
if (observer !== null) {
observer.disconnect();
}
});
</script>
<template>
<div class="page">
<header class="page-header">
<p class="eyebrow">SHOGI RESULTS</p>
<h1>将棋の対局結果</h1>
<p>
2026年8月の対局結果を、画面をスクロールしながら確認します。
</p>
</header>
<main>
<section aria-labelledby="results-heading">
<div class="section-heading">
<h2 id="results-heading">対局結果一覧</h2>
<p>{{ visibleGames.length }} / {{ games.length }}件を表示</p>
</div>
<div v-if="visibleGames.length > 0" class="game-list">
<article
v-for="game in visibleGames"
:key="game.id"
class="game-card"
>
<div class="game-card__header">
<time :datetime="game.date">
{{ formatDate(game.date) }}
</time>
<span>{{ game.tournament }}</span>
</div>
<h3>{{ game.sente }} 対 {{ game.gote }}</h3>
<dl class="game-card__detail">
<div>
<dt>先手</dt>
<dd>{{ game.sente }}</dd>
</div>
<div>
<dt>後手</dt>
<dd>{{ game.gote }}</dd>
</div>
<div>
<dt>結果</dt>
<dd>{{ game.winner }}の勝ち</dd>
</div>
</dl>
</article>
</div>
<p v-else-if="isLoading" class="status" aria-live="polite">
対局結果を読み込んでいます。
</p>
<div v-if="errorMessage" class="error-box" role="alert">
<p>{{ errorMessage }}</p>
<button type="button" @click="retry">
もう一度読み込む
</button>
</div>
<p
v-else-if="!hasMore && games.length > 0"
class="status"
aria-live="polite"
>
すべての対局結果を表示しました。
</p>
<div
v-else
ref="sentinel"
class="sentinel"
aria-hidden="true"
></div>
</section>
</main>
<footer class="page-footer">
<p>
データ出典:
<a href="https://www.shogi.or.jp/game/?month=202608" target="_blank">
日本将棋連盟 月間対局結果
</a>
</p>
</footer>
</div>
</template>
gamesには、JSONから取得した対局結果全件を保存します。一方、画面へ渡すvisibleGamesは、visibleCountの件数だけをsliceで切り出しています。
初期表示ではvisibleCountを5にしています。その後、監視要素が表示されたらshowNextGamesを呼び出し、表示件数を5件増やします。
fetchでサンプルJSONを取得する
loadGamesでは、fetchを使ってJSONを取得しています。
const response = await fetch(dataUrl);
if (!response.ok) {
throw new Error(`HTTP status: ${response.status}`);
}
const data = await response.json();
fetchは、通信に失敗した場合はPromiseがrejectされますが、HTTP 404や500のようなエラーステータスでは、レスポンスを受け取った時点でPromiseがresolveされます。そのため、response.okを確認してからresponse.json()を呼び出します。
今回はJSONが配列であることも確認しています。想定していないオブジェクトや文字列が返った場合は、画面へ表示せずエラーとして扱います。
IntersectionObserverで画面末尾を監視する
画面の一番下に、監視対象となる空のdivを置いています。
<div
v-else
ref="sentinel"
class="sentinel"
aria-hidden="true"
></div>
ref="sentinel"を付けた要素は、Vue.jsのrefで取得できます。対局結果を取得して表示件数を変更した直後は、DOMの更新が完了するまでawait nextTick()で待ってからIntersectionObserverを作成し、この要素を監視します。
observer = new IntersectionObserver(onIntersect, {
root: null,
rootMargin: '200px 0px',
threshold: 0,
});
observer.observe(sentinel.value);
root: nullは、ブラウザのviewportを基準にする設定です。rootMarginには上下方向の余白を指定しているため、監視要素が完全に画面へ入る少し前にコールバックが呼ばれます。
コールバックでは、交差状態になったときだけ追加表示を実行します。
function onIntersect(entries) {
const entry = entries[0];
if (entry && entry.isIntersecting) {
showNextGames();
}
}
MDNのIntersection Observer APIの説明にもあるように、このAPIは対象要素とviewportなどとの交差状態が変化したときにコールバックを実行します。スクロールイベントを毎回処理して位置を計算する方法とは異なり、要素の表示状態を監視する形で実装できます。
二重の追加表示を防ぐ
IntersectionObserverのコールバックは、監視対象の状態が変化すると呼び出されます。追加表示処理の中で、次のページの取得や表示に時間がかかる構成へ変更した場合、同じ処理が重複しないように状態を確認します。
function showNextGames() {
if (isLoading.value || !hasMore.value) {
return;
}
visibleCount.value = Math.min(
visibleCount.value + pageSize,
games.value.length,
);
}
今回のサンプルでは、JSONの取得は初回の1回だけです。ただし、実際のAPIへ置き換える場合は、isLoadingを「ページ取得中」の状態として使い、取得中に次のリクエストを送らないようにします。
また、すべてのデータを表示した後は、監視を解除しています。
if (!hasMore.value && observer !== null) {
observer.disconnect();
}
監視を解除しないままにすると、画面をスクロールしたときに不要なコールバックが呼ばれ続ける可能性があります。コンポーネントが破棄されるときもdisconnectを呼び出します。
エラー時に再読み込みボタンを表示する
JSONの取得に失敗した場合は、一覧を空にしてエラーメッセージを表示します。
catch (error) {
games.value = [];
visibleCount.value = 0;
errorMessage.value = '対局結果を読み込めませんでした。';
console.error(error);
}
テンプレートでは、エラーメッセージと再読み込みボタンを表示します。
<div v-if="errorMessage" class="error-box" role="alert">
<p>{{ errorMessage }}</p>
<button type="button" @click="retry">
もう一度読み込む
</button>
</div>
再読み込みボタンから同じloadGamesを呼び出します。エラーのたびに画面全体を再読み込みするのではなく、データ取得だけをやり直す構成です。
カードのスタイルを設定する
Viteで作成したプロジェクトには、初期状態のスタイルが用意されています。今回は説明用画面に合わせるため、src/assets/main.cssを次の内容へ置き換えます。
:root {
color: #172033;
background: #f3f5f9;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
margin: 0;
}
button,
a {
font: inherit;
}
a {
color: #1859a9;
}
.page {
width: min(100% - 32px, 860px);
margin: 0 auto;
}
.page-header {
padding: 64px 0 32px;
}
.eyebrow {
margin: 0 0 8px;
color: #b24d2c;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.16em;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 12px;
font-size: clamp(2rem, 5vw, 3.4rem);
}
.page-header p:last-child {
margin-bottom: 0;
color: #526078;
}
.section-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.section-heading h2 {
margin-bottom: 0;
}
.section-heading p {
margin-bottom: 0;
color: #526078;
font-size: 0.9rem;
}
.game-list {
display: grid;
gap: 16px;
}
.game-card {
padding: 20px;
border: 1px solid #dce2ec;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 8px 24px rgb(34 48 75 / 8%);
}
.game-card__header {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
color: #526078;
font-size: 0.9rem;
}
.game-card__header span {
color: #b24d2c;
font-weight: 700;
text-align: right;
}
.game-card h3 {
margin-bottom: 20px;
font-size: 1.35rem;
}
.game-card__detail {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin: 0;
}
.game-card__detail div {
padding-top: 12px;
border-top: 1px solid #e8ebf1;
}
.game-card__detail dt {
color: #68758a;
font-size: 0.8rem;
}
.game-card__detail dd {
margin: 4px 0 0;
font-weight: 700;
}
.status,
.error-box {
margin: 24px 0;
padding: 20px;
border-radius: 12px;
text-align: center;
}
.status {
color: #526078;
}
.error-box {
border: 1px solid #e2a79a;
background: #fff3ef;
color: #7e2818;
}
.error-box p {
margin-bottom: 12px;
}
.error-box button {
padding: 8px 14px;
border: 0;
border-radius: 8px;
background: #b24d2c;
color: #ffffff;
cursor: pointer;
}
.sentinel {
height: 1px;
}
.page-footer {
padding: 40px 0 64px;
color: #68758a;
font-size: 0.85rem;
}
@media (max-width: 640px) {
.page-header {
padding-top: 40px;
}
.section-heading,
.game-card__header {
display: block;
}
.section-heading p,
.game-card__header span {
display: block;
margin-top: 6px;
text-align: left;
}
.game-card__detail {
grid-template-columns: 1fr;
}
}
カードの最後にある.sentinelは高さ1pxの要素です。高さがほとんどなくても、viewportとの交差を検知する目的では利用できます。
開発サーバーで動作を確認する
プロジェクトのルートディレクトリで、開発サーバーを起動します。
npm run dev
表示されたURLをブラウザで開きます。最初に5件のカードが表示され、ページ下部へスクロールすると、次の5件が追加されます。
今回のJSONは20件なので、次のような状態を確認できます。
- 初期表示: 5件
- 1回目の追加後: 10件
- 2回目の追加後: 15件
- 3回目の追加後: 20件
- 全件表示後: 「すべての対局結果を表示しました。」を表示
ブラウザの開発者ツールを開き、Networkタブでshogi-games.jsonを確認すると、JSONのリクエストが初回表示時に1回だけ発生していることを確認できます。
読み込み失敗を確認する
確認用に、dataUrlのファイル名を存在しない値へ変更します。
const dataUrl = '/data/not-found.json';
ブラウザを再読み込みすると、エラーメッセージと「もう一度読み込む」ボタンが表示されます。元のdataUrlへ戻してボタンをクリックすると、JSONを再取得できます。
確認が終わったら、dataUrlを/data/shogi-games.jsonへ戻します。
JSONの形式を変更した場合
JSONのトップレベルを配列ではなくオブジェクトへ変更すると、Array.isArray(data)の確認でエラーになります。
{
"games": []
}
APIのレスポンスがこのような形式の場合は、data.gamesを使うように処理を変更します。ただし、APIの形式が決まっていない段階で、画面側に複数の形式を推測させる処理を追加するのは避けた方がよいかと思います。
本物のAPIへ置き換える場合
今回のサンプルでは20件のJSONを一度に取得しています。データが少ない場合はこれでも動作を確認できますが、対局結果が増え続ける画面では、最初から全件をダウンロードする構成は向いていません。
実際のAPIへ置き換える場合は、例えば次のようなレスポンスにします。
{
"data": [
{
"id": 1,
"date": "2026-08-05",
"tournament": "順位戦B級2組",
"sente": "斎藤明日斗",
"gote": "羽生善治",
"winner": "斎藤明日斗"
}
],
"next_page": 2,
"has_more": true
}
Vue.js側では、全件をgamesへ置き換えるのではなく、取得したページを追加します。次のコードは、今回のモック実装には組み込んでいない概念例です。
games.value = [
...games.value,
...data.data,
];
visibleCount.value = games.value.length;
// ページ番号や次ページの有無は、別の状態として管理する
currentPage.value = data.next_page;
hasMoreFromApi.value = data.has_more;
この場合は、currentPageやhasMoreFromApiを別途refで定義し、今回のhasMore(表示済み件数から計算する値)とは分けて管理します。
この場合は、次の点も設計が必要です。
- ページ番号方式とカーソル方式のどちらを使うか
- 取得済みページを再取得しない方法
- 同じ対局結果が重複した場合の扱い
- 新しい対局結果が追加された場合の並び順
- ログインユーザーごとの閲覧権限
- 通信失敗時にどのページから再試行するか
今回の記事のコードは、これらを実装したものではありません。サンプルJSONを一括取得して表示件数を増やす、画面側のモックとして利用してください。
無限スクロールの注意点
データ量が多い場合は一括取得しない
無限スクロールという名前でも、データを無制限にブラウザへ保持してよいわけではありません。大量のデータを一度に取得すると、通信量やメモリ使用量が増えます。
データ量が多い場合は、API側でページングし、表示に必要な件数だけ取得します。さらに、古いデータを削除する仮想スクロールや、一定件数ごとのページネーションを検討することもあります。
「もっと見る」ボタンとの使い分け
無限スクロールは、利用者が操作しなくても続きを読み込める一方で、ページの末尾へ到達しにくくなります。フッターを見せたい画面や、表示位置を共有したい画面では、「もっと見る」ボタンや通常のページネーションの方が扱いやすい場合があります。
表示状態を利用者へ伝える
読み込み中、全件表示済み、エラー発生中などの状態を画面へ表示します。画面が変化していても、利用者が何を待っているのか分からないと操作しにくいためです。
今回の例では、読み込み中と全件表示済みのメッセージにaria-live="polite"を指定し、エラーにはrole="alert"を指定しています。実際の画面では、表示内容や読み上げのされ方を確認した上で調整してください。
ビルドして確認する
開発サーバーでの確認が終わったら、プロダクション用にビルドします。
npm run build
npm run preview
npm run buildを実行すると、distディレクトリに公開用のファイルが作成されます。public/data/shogi-games.jsonも、ビルド後のdist/data/shogi-games.jsonへコピーされます。
npm run previewでビルド結果をローカル確認し、対局結果のカード表示と追加表示が開発時と同じように動くことを確認します。Viteのプレビューサーバーは、本番用のWebサーバーそのものではありません。公開時は、利用するホスティングサービスの手順に合わせてdistの内容を配置してください。
まとめ
今回は、Vue.js単体のViteプロジェクトで、将棋の対局結果をサンプルJSONから取得し、無限スクロールで5件ずつ表示しました。
IntersectionObserverで一覧末尾の監視要素を検知し、visibleCountを増やすことで、スクロールに合わせて表示件数を追加しています。JSONの取得失敗時はエラーメッセージと再読み込みボタンを表示し、全件表示後は監視を解除しました。
今回の構成は、無限スクロールの画面制御を確認するためのモックです。サンプルJSONを一括取得しているため、実際の大量データ向けの構成ではありません。本番で利用する場合は、API側のページング、重複取得防止、通信失敗時の再試行、認証・認可、通常のページネーションとの使い分けまで含めて設計する必要があります。
参考資料
奈良市を拠点に、27年以上の経験を持つフリーランスWebエンジニア、阿部辰也です。
これまで、ECサイトのバックエンド開発や業務効率化システム、公共施設の予約システムなど、多彩なプロジェクトを手がけ、企業様や制作会社様のパートナーとして信頼を築いてまいりました。
【制作会社・企業様向けサポート】
Webシステムの開発やサイト改善でお困りの際は、どうぞお気軽にご相談ください。小さな疑問から大規模プロジェクトまで、最適なご提案を心を込めてさせていただきます。
ぜひ、プロフィールやWeb制作会社様向け業務案内、一般企業様向け業務案内もご覧くださいね。
Laravel 13+Vue.jsで画像・CSVを非同期アップロードする:FormData・ファイル検証・非公開保存
2026.08.31
Laravel 13のBladeに組み込んだVue.jsから、FormDataを使って画像またはCSVを非同期で送信する方法を解説します。JPEG・PNGの画像検証、CSVの文字コード変換と簡易的な内容確認、20MBのサイズ制限、非公開ディスクへの保存、CSRF・バリデーションエラー処理を実装し、fetchを基本にaxiosとの違いも整理します。
Laravel 13+Vue.jsで登録・編集フォームを非同期化する:fetchとaxiosを比較
2026.08.26
Laravel 13のBlade画面にVue.jsを組み込み、membersテーブルのnameとemailを登録・編集するフォームを作ります。Vue.jsからLaravelのWebルートへJSONを送信し、CSRFトークン、バリデーションエラー、送信中・通信失敗の状態を扱います。リクエスト送信にはfetchとaxiosを使う方法を比較し、追加パッケージの有無やエラー処理の違いを整理します。
CodeIgniter 4のViewにVue.jsを組み込む
2026.08.20
CodeIgniter 4のView全体はサーバー側で描画したまま、一部分だけVue.jsを組み込む方法を解説します。Viteの開発サーバーからJavaScriptを読み込む方法、本番ビルドしたアセットをmanifestから取得してApacheで配信する方法、ControllerからViewへ渡したデータをVue.jsで検索する方法を確認します。
Laravel 13のBladeにVue.jsを組み込む
2026.08.18
Laravel 13のBlade画面にVue.jsを部分的に組み込み、Controllerから渡した商品データを使って商品名を検索する方法を解説します。Viteの設定、BladeからVue.jsへの初期データの渡し方、Js::fromとdata-*属性の比較、npm run devによる確認方法、ブラウザ内検索とサーバー側検索の使い分けを扱います。