ひらがなボードアプリ開発記(2):モジュール分割とPWA対応への道

はじめに

ウェブアプリケーション開発では、コードが大きくなるにつれて管理が難しくなります。特に、ひとつのファイルにすべての機能を詰め込んだ「モノリシック」なコードは、バグの修正や機能追加が困難になります。今回は、日本語学習向けの「ひらがなボード」アプリを例に、コードの分割とPWA(Progressive Web Application)対応について紹介します。

モノリシックコードの問題点

最初に作成したひらがなボードアプリは、HTML、CSS、JavaScriptが一つのファイルに詰め込まれていました。アプリの機能としては以下を実装していました。

  • ひらがなキーボードによる文字入力
  • 濁点・半濁点・小文字変換機能
  • 入力したひらがなの音声再生
  • テキスト全体の連続再生

このような構成では、コードの見通しが悪く、特定の機能を修正する際に全体に影響が及ぶリスクがありました。例えるなら、整理されていない工具箱から必要な道具を探す作業のようなものです。

モジュール分割の計画

コードを整理するために、まずは機能ごとのグループ分けを行いました。これは、大きな部屋を用途別に区切るようなものです。

主な分割カテゴリは以下の通りです:

  1. 初期化と設定関数:アプリ起動時に実行される処理
  2. 音声処理関数:ひらがなの発音を扱う処理
  3. 文字操作・変換関数:濁点付加や小文字化などの処理
  4. テキスト操作関数:表示テキストの管理

この分類に基づいて、以下のファイル構成を計画しました:

  • index.html:基本的なHTML構造
  • styles.css:スタイル定義
  • main.js:メインの初期化処理
  • ui.js:ユーザーインターフェース関連
  • audio.js:音声処理関連
  • kana.js:ひらがな文字処理関連
  • events.js:イベント処理関連

HTML構造の整理

まず、HTMLファイルから JavaScript と CSS を分離しました。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 rel="stylesheet" href="styles.css">
</head>
<body>
    <div class="container">
        <!-- キーボードとテキスト表示領域 -->
    </div>
    
    <script src="js/kana.js"></script>
    <script src="js/audio.js"></script>
    <script src="js/ui.js"></script>
    <script src="js/events.js"></script>
    <script src="js/main.js"></script>
</body>
</html>
Code language: HTML, XML (xml)

機能別ファイルの作成

次に、JavaScript のコードを機能ごとにファイルに分けました。例えば、音声処理に関する関数は audio.js に集約しています。

// audio.js の例
/**
 * ひらがなボード - 音声処理モジュール
 * 
 * このモジュールはWeb Audio APIを使用して
 * ひらがな文字の発音を再生する機能を提供します。
 */

// グローバル変数(音声関連)
let audioContext = null;  // Web Audio コンテキスト
let audioBuffers = {};  // 音声バッファ
let isAudioInitialized = false;  // 初期化フラグ
let isPlaying = false;  // 再生中フラグ

/**
 * Web Audio APIの初期化
 */
async function initAudio() {
    // AudioContextの作成
    audioContext = new (window.AudioContext || window.webkitAudioContext)();
    
    // サスペンド状態なら再開
    if (audioContext.state === 'suspended') {
        await audioContext.resume();
    }
    
    return audioContext;
}

// その他の音声関連関数...
Code language: JavaScript (javascript)

各ファイルには、その役割と含まれる機能を説明するコメントを追加しました。これは、プログラムを開く人が「この部屋には何があるのか」をすぐに理解できるようにするためです。

初回タップ時の音声再生問題

モジュール分割後、最初のひらがなボタンをタップしても音が鳴らないという問題が発生しました。これはモバイルブラウザの Web Audio API の仕様に関連していました。

ブラウザは、ユーザーの操作(タップやクリック)がない限り音声の再生を許可しません。これはバッテリー消費や意図しない音声再生を防ぐための制限です。しかし、初期のコードでは初期化と音声再生のタイミングが適切に同期されていませんでした。

問題を解決するために、音声再生関数(playSound)を次のように修正しました:

async function playSound(kana) {
    // 空白は無音として処理
    if (kana === ' ') {
        return Promise.resolve();
    }
    
    // 音声が初期化されていない場合は初期化する
    if (!isAudioInitialized) {
        try {
            await initAudio();
            isAudioInitialized = true;
            console.log('Audio initialized successfully on first sound play');
        } catch (error) {
            console.error('Failed to initialize audio:', error);
            return Promise.resolve();
        }
    }
    
    // 以下、既存の処理(音声再生など)
    // ...
}
Code language: JavaScript (javascript)

この修正により、最初のタップで音声APIを初期化し、すぐに音声再生を行うようになりました。これは「車のエンジンをかけてから走り出す」という自然な流れに似ています。

拗音(きゃ、しゅなど)の音声再生機能

次に取り組んだのは、「きゃ」のような拗音の適切な発音です。「や」を入力した後に「小」ボタンを押して「ゃ」に変換するとき、前の文字「き」と組み合わせて「きゃ」として発音させる必要がありました。

この機能のために、playYoonSound関数を新たに作成しました:

/**
 * 拗音(きゃ、しゅなど)の音声を再生
 * @param {string} mainChar - 主音(き、しなど)
 * @param {string} smallChar - 小文字(ゃ、ゅなど)
 * @returns {Promise} - 再生完了を表すPromise
 */
function playYoonSound(mainChar, smallChar) {
    // 拗音の組み合わせを作成
    const yoon = mainChar + smallChar;
    
    // 拗音がマッピングに存在するか確認
    if (HIRAGANA_TO_ROMAJI[yoon]) {
        // 拗音の音声ファイルがあればそれを再生
        return playSound(yoon);
    } else {
        // なければ個別に両方の音を連続再生
        return playSound(mainChar)
            .then(() => new Promise(resolve => setTimeout(resolve, 150)))
            .then(() => playSound(smallChar));
    }
}
Code language: JavaScript (javascript)

そして、小文字変換時にこの関数を呼び出すように実装しました:

// 小文字変換時、前の文字との組み合わせで拗音を作成
if (newChar !== lastChar && currentText.length >= 2) {
    const prevChar = currentText[currentText.length - 2];
    // 変換前に処理して新しい文字を適用
    currentText = currentText.slice(0, -1) + newChar;
    updateText();
    
    // 拗音として再生
    playYoonSound(prevChar, newChar);
    
    // 他の処理...
}
Code language: JavaScript (javascript)

PWAへの対応

最後に、オフラインでも使えるPWA(Progressive Web App)機能を実装しました。PWAは、ウェブアプリをスマートフォンのホーム画面に追加でき、オフライン時にも動作する機能を持つウェブアプリケーションです。

PWA化には主に次の要素が必要でした:

  1. マニフェストファイル(manifest.json): アプリの基本情報を定義
  2. サービスワーカー(service-worker.js): オフライン動作のための仕組み
  3. アイコン: ホーム画面に表示されるアイコン

マニフェストファイルの作成

マニフェストファイルでは、アプリの名前や表示形式、アイコンなどを定義します。

{
  "name": "ひらがなボード",
  "short_name": "ひらがな",
  "description": "日本語のひらがなを入力し、発音を学習するためのアプリケーション",
  "start_url": "index.html",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4285f4",
  "icons": [
    {
      "src": "icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}
Code language: JSON / JSON with Comments (json)

サービスワーカーの実装

サービスワーカーは、ブラウザのバックグラウンドで動作する JavaScript プログラムで、ネットワークリクエストを制御します。冷蔵庫の中から食材を取り出すように、キャッシュからファイルを取り出す役割を担います。

const CACHE_NAME = 'hiragana-board-v1';

// ベースパスを動的に取得
const BASE_PATH = self.location.pathname.replace(/\/[^\/]*$/, '/');

// キャッシュするファイルリスト
const URLS_TO_CACHE = [
  './',
  './index.html',
  './styles.css',
  './js/main.js',
  // 他のファイル...
];

// インストール時にキャッシュ
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        return cache.addAll(URLS_TO_CACHE);
      })
  );
});

// フェッチリクエスト時のキャッシュ戦略
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        // キャッシュがあればそれを返す
        if (response) {
          return response;
        }
        
        // キャッシュがなければネットワークリクエスト
        return fetch(event.request);
      })
  );
});
Code language: PHP (php)

サービスワーカーのパス問題

サービスワーカーの実装で重要なポイントは、ファイルパスの指定です。最初は絶対パス(/index.htmlなど)を使用していましたが、サブディレクトリにデプロイした場合に問題が発生しました。

Service Worker registration failed: 
TypeError: Failed to register a ServiceWorker for scope ('https://app.example.jp/') 
with script ('https://app.example.jp/service-worker.js'): 
A bad HTTP response code (404) was received when fetching the script.
Code language: JavaScript (javascript)

この問題を解決するため、相対パス(./index.htmlなど)を使用し、サービスワーカーの登録コードも修正しました。

if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
        // 現在のページの相対パスでサービスワーカーを登録
        const swPath = new URL('./service-worker.js', window.location.href).pathname;
        
        navigator.serviceWorker.register(swPath)
            .then((registration) => {
                console.log('Service Worker registered with scope:', registration.scope);
            })
            .catch((error) => {
                console.error('Service Worker registration failed:', error);
            });
    });
}
Code language: JavaScript (javascript)

教科書体フォントの適用

最後に、日本語学習アプリにふさわしい教科書体フォントを適用しました。フォントは「Honoka Antique Kaku」を使用し、CSSでフォントの定義を追加しました。

@font-face {
  font-family: 'Honoka Antique Kaku';
  src: url('fonts/Honoka-Shin-Antique-Kaku_M.otf') format('opentype');
  font-weight: normal;
  font-style: normal;
  font-display: swap;
}

body {
    font-family: 'Honoka Antique Kaku', sans-serif;
    /* 他のスタイル */
}
Code language: CSS (css)

また、フォントファイルもサービスワーカーのキャッシュリストに追加しました。

まとめ

ひらがなボードアプリの開発を通じて、コードのモジュール化とPWA対応の重要性を学びました。最初はシンプルな単一ファイルで始まったプロジェクトも、機能追加と共に複雑化します。そのような状況で、コードを適切に分割することで保守性が向上し、バグの修正や機能追加が容易になります。

また、PWA対応によってオフラインでも使用可能なアプリケーションとなり、より多くのユーザーに価値を提供できるようになりました。Web Audio APIの初期化の問題や、サービスワーカーのパス指定など、いくつかの技術的な課題もありましたが、それらを解決することで実用的なアプリケーションが完成しました。

最終的に、モジュール分割されたコード構成、オフライン対応のPWA機能、日本語学習に適した教科書体フォントを備えたひらがなボードアプリが完成しました。