作者欄を追加しキャッシュのバージョン管理を整理した(ことのはっと開発記)

はじめに

縦書きの名言画像を作成するPWAアプリ「ことのはっと」に、新しい機能を追加しました。今回実装したのは、名言に作者名を添える機能と、前回の入力内容を自動復元する機能です。さらに、開発中に発生したバージョン管理の課題も解決しました。

この記事では、実際の開発過程で直面した問題と解決策を、コードと一緒に記録します。

実装した機能の概要

今回追加した主な機能は以下の通りです。

  1. 名言に作者名を添える機能では、モーダルに新しい入力欄を追加し、縦書きレイアウトで控えめに表示します。
  2. 自動復元機能では、アプリを閉じても前回の入力内容が保持され、次回起動時に自動的に復元されます。
  3. また、開発中にPWAのキャッシュが更新されない問題が発生したため、バージョン管理システムも改善しました。

作者欄機能の実装

モーダルのHTML構造の変更とスタイルの調整

まずモーダル内に作者名の入力欄を追加しました。

<div class="input-section">
    <textarea id="textInput" class="text-input" 
            placeholder="ここに名言を入力してください&#10;改行で縦の列が分かれます"></textarea>
    <input id="authorInput" class="author-input" 
           placeholder="作者名(省略可)" 
           maxlength="20">
</div>
Code language: JavaScript (javascript)

作者名は省略可能とし、文字数制限を20文字に設定しました。これは縦書きレイアウトでの表示バランスを考慮した設定です。

作者入力欄には、テキスト入力欄と統一感のあるスタイルを適用しました。

.text-input {
    width: 100%;
    min-height: 200px;
    border: 2px solid var(--color-border);
    border-radius: var(--border-radius);
    padding: var(--spacing-md);
    font-family: inherit;
    font-size: 1rem;
    line-height: 1.6;
    resize: vertical;
    transition: border-color 0.2s ease;
    margin-bottom: var(--spacing-sm);
}

.author-input {
    width: 100%;
    border: 2px solid var(--color-border);
    border-radius: var(--border-radius);
    padding: var(--spacing-sm);
    font-family: inherit;
    font-size: 1rem;
    line-height: 1.4;
    transition: border-color 0.2s ease;
}
Code language: CSS (css)

作者入力欄のフォントサイズを1remに設定したのは、iOS Safariの自動ズーム機能を無効化するためです。16px未満のフォントサイズのinput要素をタップすると、iOS Safariが自動的にズームしてしまいます。これを防ぐには、フォントサイズを16px以上にしました。

アプリケーション側の制御

JavaScriptでは、現在のテキストと作者名を管理する変数を追加します。

constructor() {
    this.currentText = '';
    this.currentAuthor = '';
    this.currentGradient = '純白';
    this.currentColor = '#1a1a1a';
    // ...
}
Code language: JavaScript (javascript)

DOM要素の取得処理にも作者入力欄を追加します。

this.elements = {
    // 既存の要素...
    authorInput: document.getElementById('authorInput'),
    // ...
};
Code language: JavaScript (javascript)

リアルタイム更新のイベントリスナーも設定します。

// 作者入力リアルタイム更新
this.elements.authorInput.addEventListener('input', () => {
    this.currentAuthor = this.elements.authorInput.value;
    this.saveLastInput(); // 自動保存
    this.render();
});
Code language: JavaScript (javascript)

縦書きでの作者表示

作者名は名言本文の最後の列に、控えめに表示します。Canvas描画エンジンに作者専用の描画メソッドを追加しました。

drawAuthorLine(line, x, fontSize, availableHeight) {
    if (!line || line.length === 0) return;
    
    const authorFontSize = fontSize * 0.8;  // 0.8em
    const authorCharHeight = authorFontSize * (this.charSpacing + 0.2);  // 行間を0.2em追加
    
    this.ctx.save();
    this.ctx.font = `500 ${authorFontSize}px ${this.fontFamily}`;  // フォントウェイト500
    this.ctx.globalAlpha = 0.8;  // アルファ値0.8
    
    // 下揃え配置
    const totalHeight = line.length * authorCharHeight;
    const startY = this.height - this.padding.bottom - totalHeight + authorFontSize / 2;
    
    line.forEach((char, charIndex) => {
        const y = startY + charIndex * authorCharHeight;
        
        if (y >= this.padding.top && y <= this.height - this.padding.bottom) {
            this.drawCharacter(char, x, y, authorFontSize);
        }
    });
    
    this.ctx.restore();
}
Code language: JavaScript (javascript)

作者名は本文より小さく(0.8倍)、薄く(透明度0.8)、フォントウェイトも控えめ(500)に設定しています。また、下揃えで配置することで、名言の最後に署名のように表示されます。

自動復元機能の実装

LocalStorageを使った状態保存と起動時の自動復元

前回の入力内容を使うことも多いので、LocalStorageに保存することにしました。JSON形式でテキストと作者名をセットで保存します。

saveLastInput() {
    try {
        const data = {
            text: this.currentText,
            author: this.currentAuthor,
            timestamp: new Date().toISOString()
        };
        localStorage.setItem('kotono_hat_last_input', JSON.stringify(data));
    } catch (error) {
        console.warn('入力内容の保存に失敗しました:', error);
    }
}
Code language: JavaScript (javascript)

LocalStorageの容量制限やブラウザの設定によってエラーが発生する可能性があるため、try-catch文でエラーハンドリングを行います。

アプリ起動時に、保存されたデータを取得して復元します。

restoreLastInput() {
    const lastInput = this.getLastInput();
    if (lastInput) {
        this.currentText = lastInput.text;
        this.currentAuthor = lastInput.author;
    }
}

getLastInput() {
    try {
        const stored = localStorage.getItem('kotono_hat_last_input');
        if (stored) {
            const data = JSON.parse(stored);
            if (data && typeof data.text === 'string') {
                return {
                    text: data.text,
                    author: data.author || ''
                };
            }
        }
    } catch (error) {
        console.warn('前回の入力内容の復元に失敗しました:', error);
    }
    return null;
}
Code language: JavaScript (javascript)

データの妥当性チェックを行い、不正なデータの場合は無視します。作者名が存在しない古いデータとの互換性も考慮しています。

リアルタイム保存の実装

ユーザーが入力中にブラウザを閉じてしまっても内容が失われないよう、複数のタイミングで自動保存を実行します。

// ページ離脱時の自動保存
window.addEventListener('beforeunload', () => {
    this.saveLastInput();
});

// ページの可視性変更時の自動保存
document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
        this.saveLastInput();
    }
});
Code language: JavaScript (javascript)

beforeunloadイベントはページを離れる前に発火し、visibilitychangeイベントはタブが非アクティブになった時に発火します。この二重の仕組みで、様々な状況での自動保存を実現しています。

履歴管理機能の拡張

データ構造の変更

既存の履歴管理システムに作者情報を追加します。後方互換性を保つため、作者情報は省略可能な設計にしました。

addToHistory(text, author = '') {
    if (!text || typeof text !== 'string' || !text.trim()) {
        return;
    }
    
    const trimmedText = text.trim();
    const trimmedAuthor = author ? author.trim() : '';
    const history = this.getHistory();
    
    const newItem = {
        id: this.generateId(),
        text: trimmedText,
        author: trimmedAuthor,
        timestamp: new Date().toISOString()
    };
    
    history.unshift(newItem);
    // ...
}
Code language: JavaScript (javascript)

既存データとの互換性

古いバージョンで保存されたデータには作者情報が含まれていません。データ移行処理で、古いデータに空の作者情報を追加します。

migrateOldData() {
    try {
        const stored = localStorage.getItem(this.storageKey);
        if (!stored) return;
        
        const data = JSON.parse(stored);
        const migratedData = data.map(item => {
            if (typeof item === 'string') {
                return {
                    id: this.generateId(),
                    text: item,
                    author: '',
                    timestamp: new Date().toISOString()
                };
            }
            
            if (item && typeof item === 'object' && item.id && item.text && item.timestamp) {
                if (!item.hasOwnProperty('author')) {
                    item.author = '';
                }
                return item;
            }
            
            return null;
        }).filter(item => item !== null);
        
        this.saveHistory(migratedData);
    } catch (error) {
        console.warn('データ移行に失敗しました:', error);
        this.clearHistory();
    }
}
Code language: JavaScript (javascript)

データ移行に失敗した場合は、安全のため履歴をクリアします。これにより、アプリが正常に動作することを保証します。

PWAのキャッシュ更新問題と解決策

問題の発生

機能追加後にCSSファイルを更新しても、既存ユーザーのブラウザでスタイルが反映されない問題が発生しました。これはPWAのService Workerがファイルをキャッシュしているためです。

PWAでは、一度キャッシュされたファイルは、Service Workerのキャッシュ名が変更されるまで更新されません。つまり、CSSやJavaScriptファイルを更新しても、ユーザーには古いバージョンが表示され続けます。

バージョン管理の課題と一元管理システムの構築

当初のバージョン管理では、複数のファイルにバージョン情報が散らばっていました。

  • Service Worker(sw.js)のキャッシュ名
  • manifest.jsonのアプリバージョン
  • HTMLファイルのCSSクエリパラメータ

バージョンアップの度に、これらすべてを手動で更新する必要があり、更新漏れが発生しやすい状況でした。この問題を解決するため、バージョン情報を一元管理するシステムを構築しました。

まず、バージョン設定専用のファイルを作成します。

// version-config.js
const VERSION_CONFIG = {
    APP_VERSION: '1.6.6.22',        // アプリ全体のバージョン
    CACHE_VERSION: '22',            // キャッシュ用(シンプルな連番)
    CSS_VERSION: '22',              // CSS用
    BUILD_DATE: '2025-06-08'        // ビルド日付
};

if (typeof window !== 'undefined') {
    window.VERSION_CONFIG = VERSION_CONFIG;
}

if (typeof module !== 'undefined') {
    module.exports = VERSION_CONFIG;
}
Code language: JavaScript (javascript)

Service Workerでは、importScriptsを使ってバージョン設定を読み込みます。

// sw.js
importScripts('version-config.js');

const CACHE_NAME = `kotono-hat-v${VERSION_CONFIG.CACHE_VERSION}`;
const STATIC_CACHE_NAME = `kotono-hat-static-v${VERSION_CONFIG.CACHE_VERSION}`;
const DYNAMIC_CACHE_NAME = `kotono-hat-dynamic-v${VERSION_CONFIG.CACHE_VERSION}`;

const STATIC_FILES = [
    '/',
    '/index.html',
    `/styles.css?v=${VERSION_CONFIG.CSS_VERSION}`,
    '/js/app.js',
    // ...
];
Code language: JavaScript (javascript)

HTMLファイルでは、動的にCSSファイルのクエリパラメータを設定します。

<!-- index.html -->
<script src="version-config.js"></script>
<script>
    document.write(`<link rel="stylesheet" href="styles.css?v=${VERSION_CONFIG.CSS_VERSION}">`);
</script>
Code language: HTML, XML (xml)

このシステムにより、version-config.jsの数値を更新するだけで、すべてのキャッシュが無効化され、新しいファイルが配信されます。

UI改善の細かな工夫

あとは細かな気付いたUIの不自然さを修正しました。

ヘッダーデザインの調整

アプリの用途をより明確にするため、ヘッダーにサブタイトルを追加しました。

<header class="app-header">
    <div class="app-icon"></div>
    <div class="app-subtitle">縦書きメーカー</div>
    <h1 class="app-title">ことのはっと</h1>
    <button class="header-share-button" id="shareButton">
        <!-- SVGアイコン -->
    </button>
</header>
Code language: HTML, XML (xml)

サブタイトルは小さく控えめに表示し、メインタイトルのサイズも調整してバランスを改善しました。

モバイルでのスクロール制御

スマートフォンでテキスト入力時に画面が自動スクロールした後、モーダルを閉じても元の位置に戻らない問題がありました。この問題は、モーダルを閉じる際にスクロール位置を上に戻す処理で解決しました。

closeModal() {
    this.elements.modalOverlay.classList.remove('active');
    this.scrollToTop();
}

scrollToTop() {
    window.scrollTo({
        top: 0,
        left: 0,
        behavior: 'smooth'
    });
    
    // iOS Safari対応
    document.body.scrollTop = 0;
    document.documentElement.scrollTop = 0;
}
Code language: JavaScript (javascript)

スムーススクロールを使用することで、自然な動きでヘッダーが見える位置まで戻ります。

開発で得られた知見

PWAのキャッシュ戦略

PWAにおけるキャッシュ管理は、ユーザー体験に直結する重要な要素です。適切なバージョン管理システムなしには、ユーザーが最新の機能を利用できない状況が発生します。

今回構築したバージョン管理システムは、以下の利点があります。単一ファイルの更新で全体のキャッシュを制御でき、更新漏れによるバグを防げます。また、バージョン番号の管理が簡潔になり、デバッグ時にどのバージョンが動作しているかを容易に確認できます。

iOS Safariの特殊な動作

モバイルWebアプリを開発する際は、iOS Safariの独特な動作に注意が必要です。16px未満のフォントサイズの入力欄をタップすると自動ズームが発生する問題や、キーボード表示時のビューポート変更など、様々な特殊ケースがあります。

これらの問題は、適切な対処法を知っていれば比較的簡単に解決できます。

まとめ

今回の開発では、作者欄追加と自動復元機能の実装を通じて、PWAアプリの機能拡張手法を実践しました。特にバージョン管理システムの一元化により、開発効率と保守性が大幅に向上しました。

実装した主要な機能は、作者名を縦書きで控えめに表示する機能、前回の入力内容を自動復元する機能、そしてバージョン情報を一元管理するシステムです。また、iOS Safariの自動ズーム防止やスクロール位置制御など、モバイルユーザビリティの改善も行いました。

PWAにおけるキャッシュ管理とデータ移行の重要性、そしてモバイル環境での特殊な動作への対処法についても、実践的な知見を得ることができました。