Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

はじめに

この文書はWeb開発の入門ガイドです。 Web開発を進めていくための助けになれば幸いです。

目次

― この文書は © 2023 MDN Web Docsプロジェクト協力者 クリエイティブ・コモンズ CC BY SA 2.5 ライセンスのもとに利用を許諾されています。 元の文書: https://developer.mozilla.org/ja/docs/Learn/Getting_started_with_the_web/What_will_your_website_look_like

Web開発入門

Web開発を始める前に考えておかなければいけないことがあります。Webサイトは様々なことができます。しかし複雑なものを開発するとしても、はじめはできるだけ単純なものから少しずつ理解を深めていくべきでしょう。

まずは、見出し、画像、段落のある単純なWebページを作ることから始めましょう。

  1. 何についてのWebページ? 犬、ニューヨーク、それともパックマン?
  2. どんな情報? タイトルといくつかの段落、それからページに表示させたい画像を考えます。
  3. どんな見た目? 簡単で大まかな言葉で言うと?背景色は?適切なフォントはフォーマル?漫画?太字で派手?繊細?

デザインをスケッチする

次に、ペンと紙を取ってサイトの見た目をどういう風にしたいのか大まかに描き出します。はじめてのシンプルなWebページでは、描き出すものもあまりないかもしれませんが、作る上での習慣にしましょう。(ヴァン・ゴッホのようになる必要はありません)

紙に描いたWebサイトのラフ画とスケッチ

Note 現実の複雑なWebサイトの場合でも、デザインチームは普通、ラフスケッチを描くことから始めます。その後、グラフィックエディターや Web の技術を使って、デジタルのモックアップを作るのです。

多くの場合、Webの開発チームには、グラフィックデザイナーとユーザーエクスペリエンス (UX) デザイナーがいます。グラフィックデザイナーは、Webサイトの見た目を作り上げます。 UX デザイナーは、もう少し抽象的な役割を持っていて、サイトを訪れるユーザーがWebサイトでどういう経験をし、どのように操作するかということを考えます。

この時点で、Webページについて、どう表現したいかをまとめ始めていきましょう。

テキストエディター

Visual Studio Codeなどのテキストエディターを使用して忘れないようにメモしておきましょう。

フォルダー

フォルダーは簡単に見つけることができる場所、たとえばデスクトップ上、ホームフォルダーの中、Cドライブのルートなどに置きましょう。

  1. Webサイトプロジェクトを保存する場所を選択してください。ここでは web-projects (またはそのようなもの)という新しいフォルダーを作成します。これはWebサイトのプロジェクト全体を保存するところです。
  2. フォルダーの中に、最初のWebサイトを格納する別のフォルダーを作成します。それを test-site と呼びましょう(もっとユニークなものでもOK)。

コンテンツ

  • タイトル: Mozilla is cool (例)
  • 内容: Mozilla is cool (例)

テーマカラー

色を選ぶときは、「カラー選択ツール」と検索し、好みの色を見つけましょう。色をクリックすると、 #fcba03 のような "#" + 6 桁の奇妙なコードが出てきます。これはヘキサコード(16 進数コード、0, 1, 2, ..., 9, a, b, ..., f までの16種類の数字を使うコード)と呼ばれ、選んだ色を表します。このコードはあとで使うのでコピーしておきましょう。

画像

画像を探すには、Google 画像検索にアクセスし、ぴったりなものを探しましょう。

  1. 欲しい画像が見つかったら、クリックして拡大表示にします。
  2. 画像を右クリック(Mac では Ctrl +クリック)し、[名前を付けて画像を保存...] を選択して、画像を安全に保存する場所を選択します。または、後で使用するためにブラウザーのアドレスバーから画像のWebアドレスをコピーします。

Google 画像検索での検索語句の検索結果

なお、Web上のほとんどの画像には、 Google 画像検索にあるものも含め、著作権があります。あなたが著作権を侵害してしまうことを防ぐために、 Google のライセンスフィルターを使うと良いでしょう。 [ツール] ボタンをクリックすると、 [ライセンス] オプションが下に表示されます。「クリエイティブ・コモンズ ライセンス」などの選択肢を選択してください。

Google 画像検索でクリエイティブ・コモンズ ライセンスの画像を取得するための検索結果のフィルタリング

Note
クリエイティブ・コモンズ・ライセンス (CCライセンス) とは

CCライセンスとはインターネット時代のための新しい著作権ルールで、作品を公開する作者が「この条件を守れば私の作品を自由に使って構いません。」という意思表示をするためのツールです。

CCライセンスを利用することで、作者は著作権を保持したまま作品を自由に流通させることができ、受け手はライセンス条件の範囲内で再配布やリミックスなどをすることができます。

クリエイティブ・コモンズ・ライセンスとは | クリエイティブ・コモンズ・ジャパン より

― この文書は © 2023 MDN Web Docsプロジェクト協力者 クリエイティブ・コモンズ CC BY SA 2.5 ライセンスのもとに利用を許諾されています。 元の文書: https://developer.mozilla.org/ja/docs/Learn/Getting_started_with_the_web/Dealing_with_files

ファイルの扱い

Webサイトは、テキストコンテンツ、コード、スタイルシート、メディアコンテンツなど、多くのファイルで構成されています。ここでは注意すべきいくつかの点を説明します。

コンピューター上でWebサイトがあるべき場所

コンピューター上のWebサイトの開発作業している時もWebサイトのファイルとフォルダーの構造は実際のWebサイトと同じようにしましょう。

フォルダーは簡単に見つけることができる場所、たとえばデスクトップ上、ホームフォルダーの中、Cドライブのルートなどに置きましょう。

  1. Webサイトプロジェクトを保存する場所を選択してください。ここでは web-projects (またはそのようなもの)という新しいフォルダーを作成します。これはWebサイトのプロジェクト全体を保存するところです。
  2. フォルダーの中に、最初のWebサイトを格納する別のフォルダーを作成します。それを test-site と呼びましょう(もっとユニークなものでもOK)。

ファイル名・フォルダー名には日本語・大文字・空白を使わない

この文書ではフォルダーやファイルに空白のない全て半角小文字の名前を付けるよう求めています。理由は次の通りです。

  1. 多くのコンピューター、特にWebサーバーでは、大文字と小文字が区別されます。例えば、Webサイトの test-site/MyImage.jpg に画像を置いて、別のファイルから画像を test-site/myimage.jpg として呼び出そうとすると、動作しないかもしれません。
  2. ブラウザー間、Webサーバー間、プログラミング言語間で、空白の扱いが一貫していません。例えば、ファイル名に空白を使用すると、システムによってはそのファイル名を 2 つのファイル名として扱うことがあります。サーバーによっては、ファイル名の空白を "%20" (URL の空白の文字コード)に置き換えるので、リンクが壊れてしまう結果になります。my_file.html のように単語をアンダースコアで区切るよりは、my-file.html のようにハイフンで区切った方がよいでしょう。

Webサイトはどのような構成にするべきか

Webサイトプロジェクトで最も一般的なフォルダー構成は、(1) 目次の HTML ファイル、(2) 画像ファイル、(3) スタイルシート (見た目に関するコード)、(4) スクリプトファイル (JavaScriptのコード) を入れるフォルダーです。作成してみましょう。

  1. index.html: このファイルには、一般的にあなたのホームページの内容、つまりあなたが最初にあなたのサイトに行ったときに見るテキストと画像が含まれています。テキストエディターを使用して、 index.html という名前の新しいファイルを作成し、 test-site フォルダー内に保存します。
  2. images フォルダー: このフォルダーにはサイトで使用するすべての画像を入れます。test-site フォルダーの中に images という名前のフォルダーを作成します。
  3. styles フォルダー: このフォルダーには、コンテンツのスタイルを設定するための CSS コード(例えばテキストと背景色の設定など)を入れます。 styles というフォルダーを test-site のフォルダーの中に作成します。
  4. scripts フォルダー: このフォルダーには、サイトに対話機能を追加するために使用されるすべての JavaScript コード(クリックされたときにデータを読み込むボタンなど)が含まれます。 scripts というフォルダーを test-site のフォルダーの中に作成します。

Note Windows では、既定で有効になっている既知のファイルの種類の拡張子を表示しないというオプションがあるため、ファイル名の表示に問題が発生することがあります。一般に、 Windows エクスプローラーで [フォルダーオプション...] オプションを選択し、[登録されている拡張子は表示しない] チェックボックスをオフにし、 [OK] をクリックすることで、これをオフにすることができます。お使いの Windows のバージョンに関する詳細な情報については、Webで検索してください。

ファイルパス

ファイルをお互いに呼び出すためには、ファイルパスを提供する必要があります。

画像ファイルは既存の画像を自由に選択して、以下の手順で使用することができます。

  1. 以前に選択した画像を images フォルダーにコピーします。

  2. index.html ファイルを開き、次のコードをファイルに挿入します。それが今のところ何を意味するのか気にしないでください。シリーズの後半で構造を詳しく見ていきます。

    <!doctype html>
    <html lang="ja">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width" />
        <title>テストページ</title>
      </head>
      <body>
        <img src="" alt="テスト画像" />
      </body>
    </html>
    
  3. <img src="" alt="テスト画像"> という行は、ページに画像を挿入する HTML コードです。画像がどこにあるのかを HTML に伝える必要があります。画像は images ディレクトリー内にあり、index.html と同じディレクトリーにあります。ファイル構造の中で index.html からその画像に移動するのに必要なファイルパスは images/your-image-filename です。例えば、私たちの画像は firefox-icon.png と呼ばれており、ファイルパスは images/firefox-icon.png になります。

  4. src="" コードの二重引用符の間の HTML コードにファイルパスを挿入してください。

  5. alt 属性の内容を入れようとしている画像の説明に変更してください。今回は、 alt="Firefoxのロゴ" とします。

  6. HTML ファイルを保存し、Webブラウザーに読み込みます(ファイルをダブルクリックします)。新しいWebページに画像が表示されます。

ファイルパスの一般的なルールは次の通りです。

  • 呼び出し元の HTML ファイルと同じディレクトリーにある対象ファイルにリンクするには、ファイル名を使用します。例えば my-image.jpg
  • サブディレクトリー内のファイルを参照するには、パスの前にディレクトリー名とスラッシュを入力します。例えば subdirectory/my-image.jpg
  • 呼び出し元の HTML ファイルの上階層のディレクトリー内にある対象ファイルにリンクするには、2 つのドットを記述します。例えば、index.htmltest-site のサブフォルダー内にあり、my-image.jpgtest-site 内にある場合、../my-image.jpg を使用して index.html から my-image.jpg を参照できます。
  • 例えば ../subdirectory/another-subdirectory/my-image.jpg など、好きなだけ組み合わせることができます。

Note Windows のファイルシステムでは、スラッシュ (/) ではなくバックスラッシュまたは¥記号を使用します(例 : C:\Windows)。これは HTML では使用できません。Windows でWebサイトを開発している場合でも、コード内ではスラッシュを使用する必要があります。

他にするべきこと

今のところは以上です。フォルダー構造は次のようになります。

macOS X の finder におけるファイル構造。images フォルダーに画像が入っており、scripts と styles フォルダーは空で、あと index.html がある

― この文書は © 2023 MDN Web Docsプロジェクト協力者 クリエイティブ・コモンズ CC BY SA 2.5 ライセンスのもとに利用を許諾されています。 元の文書: https://developer.mozilla.org/ja/docs/Learn/Getting_started_with_the_web/HTML_basics

HTMLの基本

HTML (HyperText Markup Languageハイパーテキスト・マークアップ・ランゲージ)は、Webページの構造を記述するための言語です。例えば、コンテンツは段落、箇条書きのリスト、画像の使用、データテーブルなどの組み合わせで構成されています。

HTML は一連の 要素 で構成されており、これらの要素がコンテンツのさまざまな部分を囲み、一定の表示や動作をさせることができます。タグで囲むと、単語や画像をどこかにハイパーリンクさせたり、単語を斜体にしたり、フォントを大きくしたり小さくしたりすることができます。 例えば、次のようなコンテンツがあるとします。

My cat is very grumpy

行を独立させたい場合は、段落タグで囲んで段落であることを指定することができます。

<p>My cat is very grumpy</p>

HTML 要素の中身

この段落要素についてもう少し詳しく見ていきましょう。

開始タグ、 'my cat is very grumpy' と読めるコンテンツ、終了タグがある段落要素

要素は主に以下のようなもので構成されています。

  1. 開始タグ (opening tag): これは、要素の名前(この場合は p)を山括弧で囲んだものです。どこから要素が始まっているのか、どこから効果が始まるのかを表します。 — 今回の場合どこから段落が始まるかを表しています。
  2. 終了タグ (closing tag): これは、要素名の前にスラッシュが入っていることを除いて開始タグと同じです。どこで要素が終わるのかを表しています。 — この場合は、段落が終わる場所を表します。終了タグの書き忘れは、初心者のよくある間違いで、おかしな結果になることがあります。
  3. コンテンツ (content): 要素の内容です。今回の場合はただのテキストです。
  4. 要素 (element): 開始タグ、終了タグ、コンテンツで要素を構成します。

要素には属性 (attribute) を設定することができます。このような感じです。

class 属性 class=editor-note が強調された段落の開始タグ

属性には、実際のコンテンツには表示させたくない、要素に関する追加情報が含まれています。ここでは、 class が属性の名前で、 editor-note が属性のです。 class 属性では、要素に一意ではない識別子を与えることができ、それを使って要素(および同じ class 値を持つ他の要素)にスタイル情報などのターゲットを設定することができます。 一部の属性、たとえば required には値がありません。

値を設定する属性は常に次のような形式になります。

  1. 要素名(すでにいくつか属性がある場合はひとつ前の属性)との間の空白
  2. 属性名とそれに続く等号
  3. 引用符で囲まれた属性の値

Note ASCII のホワイトスペース(または " ' ` = < > のいずれかの文字)を含まない単純な属性値は引用符を省略することができますが、コードを一貫性のあるものにし、理解を容易にするため、すべての属性値を引用符で囲むことをお勧めします。

要素の入れ子

要素の中に他の要素を入れることもできます。これをネスト(または入れ子)と言います。もしあなたの猫が「とっても」機嫌が悪いことを表したいとしたら、「とっても」という単語を <strong> 要素に入れて、単語の強調を表すことができます。

<p>My cat is <strong>very</strong> grumpy.</p>

しかしながら要素は正しく入れ子にしなければなりません。上記の例では、まず始めに <p> 要素が開始され、その次に <strong> 要素が開始されています。その場合は、必ず <strong> 要素、 <p> 要素の順で終了しなければなりません。次の例は間違いです。

<p>My cat is <strong>very grumpy.</p></strong>

要素は確実に他の要素の中もしくは外で開始し、終了する必要があります。上記の例のように要素が重複してしまうと、Webブラウザーは言おうとしていることを推測してもっとも良いと思われる解釈をするため、予期せぬ結果になることがあります。そうならないよう気を付けましょう!

空要素

コンテンツを持たない要素もあります。そのような要素を 空要素 (void element) と呼びます。すでに HTML ページにある <img> 要素を例に見ていきましょう。

<img src="images/firefox-icon.png" alt="テスト画像" />

この要素は 2 つの属性を持っていますが、終了タグ </img> がありませんし、内部にコンテンツもありません。これは画像要素は、その機能を果たすためにコンテンツを囲むものではないからです。画像要素の目的は、画像を HTML ページの表示させたいところに埋め込むことです。

HTML 文書の構造

ここまでは HTML 要素について見てきましたが、しかし、要素単体ではあまり役には立ちません。ここからはどのようにしてそれぞれの要素を組み合わせ、 HTML ページ全体を作っていくのかを勉強していきましょう。ファイルの扱いで出てきた index.html に書いてあるコードをもう一度見てみましょう。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>テストページ</title>
  </head>
  <body>
    <img src="images/firefox-icon.png" alt="テスト画像" />
  </body>
</html>

この中にあるものは以下の通りです。

  • <!DOCTYPE html>文書型宣言です。これは必須の前置きです。昔々、 HTML がまだ出来たばかりの頃(1991 ~ 2 年)、文書型宣言は HTML ページが正しい HTML と見なされるために従わなければならない、一連のルールへのリンクとして機能することを意味していました。つまり、自動エラーチェックなどの有益なものを表すことができました。しかし、最近ではあまり機能しておらず、文書が正しく動作するために必要なだけです。今はこれだけ知っていれば大丈夫です。
  • <html></html> — <html> 要素です。この要素は、このページのすべてのコンテンツを囲み、ルート要素と呼ばれることもあります。ここでは文書の主要な言語を設定する lang 属性も指定します。
  • <head></head> — <head> 要素です。この要素は、ページの閲覧者に向けて表示するためのコンテンツではない、 HTML ページに含めたいものをすべて収めるための入れ物です。検索エンジン向けの キーワード やページのディスクリプション(説明書き)、ページの見た目を変更するための CSS、文字コードの宣言などを含みます。
  • <meta charset="utf-8"> — この要素は、大部分の書き言葉の文字のほとんどを含む UTF-8 を文書で使用するように設定しています。基本的には、文書はどんなテキストコンテンツでも扱えるようになります。これを設定しない理由はありませんし、後でいくつかの問題を回避するのに役立ちます。
  • <title></title> — <title> 要素です。ページのタイトルを指定しています。このタイトルはページが読み込まれた時にブラウザーのタブに表示されます。また、ブックマークやお気に入りに登録した時の説明にも使われます。
  • <meta name="viewport" content="width=device-width"> — このビューポート属性は、このページがある幅のビューポートで描画されることを保証し、モバイルブラウザーがビューポートより広い幅でページを描画した上で縮小して表示するのを防止します。
  • <body></body> — <body> 要素です。これには、テキスト、画像、ビデオ、ゲーム、再生可能な音声トラックなど、ページを訪れたWebの利用者に表示したいすべてのコンテンツが含まれます。

画像

もう一度 <img> 要素について見ていくことにしましょう。

<img src="images/firefox-icon.png" alt="テスト画像" />

前に説明したように、ページのこれが現れたところに画像を埋め込みます。画像ファイルのパスを値に持つ src (source) 属性を指定することによってその画像を表示できます。

また、 alt (alternative; 代替) 属性も指定しています。 alt 属性は、以下のような理由で画像を見られない人に向けて説明するテキストを指定するものです。

  1. 目が不自由な人。著しく目の不自由な人はよくスクリーンリーダーと呼ばれるツールを使っていて、それが画像の alt 属性の内容を読み上げます。
  2. 何らかの理由で画像の表示に失敗した場合。例えば、 src 属性の中のパスをわざと正しくないものに変更してみてください。ページを保存したり再読み込みしたりすると、画像の場所に下記のようなものが表示されるでしょう。

テスト画像という言葉

alt テキストのキーワードは「説明文」です。 alt テキストは、その画像が何を伝えているのかを読者が十分に理解できるような情報を提供する必要があります。この例では、現在のテキストである「テスト画像」は全く意味がありません。 Firefox のロゴであれば、「Firefox のロゴ: 地球の周りを燃えるような狐が囲んでいる。」の方がずっと良いでしょう。

画像に良い代替文字列を付けてみましょう。

Note アクセシビリティについて詳しくは MDN のアクセシビリティのページを参照してください。

テキストのマークアップ

この節では、文字列をマークアップするために使用する基本的な HTML 要素をいくつか見ていきます。

見出し

見出し要素により、コンテンツの特定の部分を見出し、または小見出しとして指定することができます。本にメインタイトル、章立て、サブタイトルがあるように、 HTML 文書にも見出しがあります。 HTML には <Heading_Elements", "<h1> - <h6>> の 6 段階の見出しがありますが、よく使うのはせいぜい 3 から 4 まででしょう。

<!-- 4 段階の見出し -->
<h1>メインタイトル</h1>
<h2>最上位の見出し</h2>
<h3>小見出し</h3>
<h4>孫見出し</h4>

Note HTML の中で <!----> の間にあるものは、すべて HTML コメントです。ブラウザーは、コードを表示する際にコメントを無視します。つまり、コメントはページ上では表示されず、コードの中に表示されるだけです。 HTMLコメントは、コードやロジックに関する有用なメモを書き込むためのものです。

それでは、あなたの HTML の <img> 要素の上に適切なタイトルを付けてみましょう。

Note 見出しレベル 1 には、暗黙のスタイルがあることがわかりますね。テキストを大きくしたり、太くしたりするために見出し要素を使用しないでください。見出し要素はアクセシビリティSEO などの理由で使用されているからです。段階を飛ばすことなく、意味のある見出しの並びをページ上に作るようにしてください。

段落

先に説明したように、 <p> 要素は段落を示しています。通常の文章を書くときにはこの要素を頻繁に使うことになるでしょう。

<p>This is a single paragraph</p>

サンプルテキストを (「Webサイトをどんな外見にするか」から持ってきてください) 1 つまたは複数の段落に入れ、 <img> 要素のすぐ下に配置してください。

リスト

Webのコンテンツの多くはリストであり、 HTML にはリストのための特別な要素があります。リストのマークアップは、常に 2 つ以上の要素で構成されています。最も一般的なリストの種類は、順序付きリストと順序なしリストです。

  1. 順序なしリストは、お買い物リストのようにアイテムの順番が特に関係ない時に使います。順序なしリストは <ul> 要素で囲みます。
  2. 順序付きリストは料理のレシピのようにアイテムの順番が関係ある時に使います。順序付きリストは <ol> 要素で囲みます。

リストの中に入るそれぞれのアイテムは <li> (list item) 要素の中に書きます。

例えば、次の段落の一部をリストにしたい場合、

<p>
  At Mozilla, we're a global community of technologists, thinkers, and builders
  working together…
</p>

以下のようにマークアップします。

<p>At Mozilla, we're a global community of</p>

<ul>
  <li>technologists</li>
  <li>thinkers</li>
  <li>builders</li>
</ul>

<p>working together…</p>

ページに番号付きリストと番号なしリストを追加してみましょう。

リンク

リンクはとても重要です。これがWebをWebたらしめているものです。リンクを追加するには、シンプルな要素 <a> を使う必要があります。 a は "anchor" を省略したものです。段落中の文字をリンクにするには次の手順で行います。

  1. リンクにしたい文字を選びます。今回は "Mozilla Manifesto" を選びました。

  2. 選んだ文字を <a> 要素で囲みます。

    <a>Mozilla Manifesto</a>
    
  3. このように <a> 要素に href 属性を追加します。

    <a href="">Mozilla Manifesto</a>
    

    このリンクのリンク先になるWebアドレスを、この属性の値に書き込みます。

    <a href="https://www.mozilla.org/en-US/about/manifesto/">
      Mozilla Manifesto
    </a>
    

アドレスの先頭にある https://http:// の部分(プロトコルと言います)を書き忘れると、予期せぬ結果となってしまうかもしれません。リンクを作ったら、ちゃんとそれが遷移したいところに行ってくれるかを確かめましょう。

Note href は属性名として変に思うかもしれません。覚えにくかったら、 hrefhypertext reference を表しているということを覚えておきましょう。

もしまだやってないのなら、ページにリンクを追加してみましょう。

まとめ

説明に沿ってやってきたら以下のようなページが出来上がっているかと思います (もちろん画像やテキストの内容はみなさんの自由です)。

Webページのスクリーンショットで、 Firefox のロゴ、「Mozilla is cool」という見出し、そして 2 段落のテキストが表示されています。

もし途中で行き詰まってしまったら、「サンプルコード」と見比べてみましょう。

― この文書は © 2023 MDN Web Docsプロジェクト協力者 クリエイティブ・コモンズ CC BY SA 2.5 ライセンスのもとに利用を許諾されています。 元の文書: https://developer.mozilla.org/ja/docs/Learn/Getting_started_with_the_web/CSS_basics

CSSの基本

CSS (Cascading Style Sheets) は、Webページのスタイルを設定するコードです。ここでは、始めるのに必要なものを紹介します。ここでは、テキストを赤くするにはどうすればいいのか?コンテンツを(Webページの)レイアウトの中で特定の場所に表示するにはどうすればいいのか?背景画像と色を使って Webページをどのように飾るのか?というような疑問に答えていきます。

例えば、この CSS は段落のテキストを選択し、色を赤に設定しています。

p {
  color: red;
}

それでは試してみましょう。テキストエディターを使用して、(上記の) 3 行の CSS 新しいファイルに貼り付けてください。そのファイルを style.css として styles という名前のディレクトリーに保存してください。

コードを働かせるには、この(上記の) CSS を HTML 文書に適用する必要があります。そうしないと、このスタイルはブラウザーの HTML 文書の表示に影響しません。

  1. index.html ファイルを開き、先頭(<head> タグと </head> タグの間)に以下の行を貼り付けてください。

    <link href="styles/style.css" rel="stylesheet" />
    
  2. index.html を保存し、ブラウザーで読み込んでください。次のように表示されるはずです。

Firefoxのロゴといくつかの段落です。段落のテキストは、 CSS によって赤くスタイル付けされています。

段落のテキストが赤くなっていれば、おめでとう! CSS が動作しています。

CSS ルールセットの構造

赤い段落テキストの CSS コードを分解して、その仕組みを理解してみましょう。

CSS の p の宣言で、color を red にする

全体の構造はルールセットと呼びます (ルールセットという語はよく、単にルールとも呼ばれます)。それぞれの部分の名前にも注意してください。

  • セレクター (Selector)
    • : これはルールセットの先頭にある HTML 要素名です。これはスタイルを設定する要素 (この例の場合は <p> 要素) を定義します。別の要素をスタイル付けするには、セレクターを変更してください。
  • 宣言 (Declaration)
    • : color: red; のような単一のルールです。これは要素のプロパティのうち、スタイル付けしたいものを指定します。
  • プロパティ (Property)
    • : これらは、 HTML 要素をスタイル付けするための方法です。 (この例では、 color は <p> 要素のプロパティです。) CSS では、ルールの中で影響を与えたいプロパティを選択します。
  • プロパティ値 (Property value)
    • : プロパティの右側には—コロンの後に—プロパティ値があります。与えられたプロパティの多くの外観から 1 つを選択します。 (例えば、 color の値は red 以外にもたくさんあります。)

構文の他の重要な部分に注意してください。

  • セレクターを除き、それぞれのルールセットは中括弧 ({}) で囲む必要があります。
  • それぞれの宣言内では、コロン (:) を使用してプロパティと値を分離する必要があります。
  • それぞれのルールセット内でセミコロン (;) を使用して、それぞれの宣言と次のルールを区切る必要があります。

一つのルールセットで複数のプロパティ値を変更するには、次のようにセミコロンで区切って書いてください。

p {
  color: red;
  width: 500px;
  border: 1px solid black;
}

複数の要素の選択

複数の要素を選択して、そのすべてに一つのルールセットを適用することもできます。複数のセレクターはカンマで区切ります。たとえば、以下のようになります。

p,
li,
h1 {
  color: red;
}

さまざまな種類のセレクター

セレクターにはさまざまな種類があります。上記の例では、要素セレクターを使用しており、特定の種類の要素をすべて選択しています。しかし、もっと特定の要素を選択することもできます。ここでは、より一般的なセレクターの種類を紹介します。

セレクター名 選択するもの
要素セレクター(タグまたは型セレクターと呼ばれることもあります) 指定された型のすべての HTML 要素。 p
<p> を選択
ID セレクター 指定された ID を持つページ上の要素です。指定された HTML ページでは、各 id 値は一意でなければなりません。 #my-id
<p id="my-id"> または <a id="my-id"> を選択
クラスセレクター 指定されたクラスを持つページ上の要素です。同じクラスの複数のインスタンスが 1 つのページに現れることがあります。 .my-class
<p class="my-class"> および <a class="my-class"> を選択
属性セレクター 指定された属性を持つページ上の要素です。 img[src]
<img src="myimage.png"> は選択するが <img> は選択しない
擬似クラスセレクター 指定された要素が指定された状態にあるとき。(例えば、マウスポインターが上に乗っている(ホバー)状態。) a:hover
<a> を、マウスポインターがリンク上にあるときのみ選択。

他にも様々なセレクターがあります。詳しくは、 MDN のセレクターガイドをご覧ください。

フォントとテキスト

CSS の基本をいくつか勉強しましたので、style.css ファイルにいくつかのルールと情報を追加して、この例を見栄え良くしましょう。

HTML 本文内にテキストを配置する要素 (<h1>, <li>, <p>) のフォントの大きさを設定します。また、見出しを中央揃えにします。最後に、 2 つ目のルールセット (下記) を展開して、行の高さや文字の間隔などの設定を行い、本文のコンテンツを読みやすくしましょう。

h1 {
  font-size: 60px; /* px は「ピクセル」 (pixels) の意味。60 ピクセルの高さのフォントになります */
  text-align: center;
}

p,
li {
  font-size: 16px;
  line-height: 2;
  letter-spacing: 1px;
}

px の値はお好みで調整してください。進行中の作品は、このようになるはずです。

Firefoxのロゴといくつかの段落。 sans-serif フォントが設定され、フォントの大きさ、行の高さ、文字の間隔が調整され、メインページの見出しが中央に配置されています。

CSS: ボックスのすべて

CSS を書いていて気づくことがあります。それは、その多くがボックスに関するものだということです。これには、サイズ、色、位置の設定が含まれます。ページ上のほとんどの HTML 要素は、他の箱の上に置かれた箱と考えることができます。

大きな箱や木箱が積み重なっている状態

Photo from https://www.geograph.org.uk/photo/3418115 Copyright © Jim Barton cc-by-sa/2.0

CSS のレイアウトは、主にボックスモデルに基づいています。ページ上のスペースを占める各ボックスには、次のようなプロパティがあります。

  • padding: コンテンツの周囲のスペースです。以下の例では、段落テキストの周りのスペースです。
  • border: padding のすぐ外側にある実線
  • margin: 要素の外側の周りの空間

3 つのボックスがお互いの内側に配置されています。外側から内側に向かって、 margin, border, padding と書かれています。

この節では次のものを使用します。

  • width (要素の幅)
  • background-color: 要素の内容と padding の背後にある色
  • color: 要素のコンテンツ (通常はテキスト) の色
  • text-shadow: 要素内のテキストに影を設定します
  • display: 要素の表示モードを設定します (これについてはまだ心配しないでください)

続けて、さらに CSS を追加していきましょう。 style.css の一番下に、これらの新しいルールを追加し続けます。値を変えてどうなるか実験してみましょう。

ページの色を変更する

html {
  background-color: #00539f;
}

このルールはページ全体の背景色に設定を行います。上記のカラーコードを、Webサイトをどんな外見にするかで選んだ色に変更しましょう。

本文のスタイル付け

body {
  width: 600px;
  margin: 0 auto;
  background-color: #ff9500;
  padding: 0 20px 20px 20px;
  border: 5px solid black;
}

次は <body> 要素です。ここにはいくつかの宣言がありますので、 1 行ずつ見て行きましょう。

  • width: 600px; — これにより body は常に 600 ピクセルの幅になります。
  • margin: 0 auto;marginpadding などのプロパティに 2 つの値を設定すると、最初の値は要素の上下の辺に影響します(この場合は 0 になります)。2 番目の値は左右に影響します(ここで auto は残った水平方向の余白を左右に均等に配分する特別な値です)。 margin の構文で説明しているように、 1 つ、2 つ、3 つ、4 つの値を使用することもできます。
  • background-color: #FF9500; — これは要素の背景色を設定します。このプロジェクトでは body の背景色に明るいオレンジ色を使用して、 <html> 要素の暗い青とは対照的にしました。(気軽に試してみてください。)
  • padding: 0 20px 20px 20px; — これはパディングに 4 つの値を設定します。これは、コンテンツの周りに少しのスペースを確保するためです。今回は body の上にパディングを設定せず、左・下・右に 20 ピクセルを設定します。値は上・右・下・左の順に設定されます。margin と同様、 padding の構文で説明されているように、 1 つ、 2 つ、または 3 つの値を使用することもできます。
  • border: 5px solid black; — これは境界の太さ、スタイル、色の値を設定します。この場合は、 body の全側面に 5 ピクセルの太さの黒ベタの境界線を設定します。

メインページのタイトルの配置とスタイル付け

h1 {
  margin: 0;
  padding: 20px 0;
  color: #00539f;
  text-shadow: 3px 3px 1px black;
}

body の上部にひどい隙間があることに気づいたかもしれません。これは CSS をまったく適用していなくても、ブラウザーが(他のものの中で) <Heading_Elements", "h1> 要素に既定のスタイルを適用するためです。それは悪い考えのように見えるかもしれませんが、スタイルのないページにも一定の読みやすさを求めるためのものです。隙間をなくすために、 margin: 0; を設定して既定のスタイルを上書きします。

次に見出しの上下のパディングを 20 ピクセルに設定します。

続いて、見出しテキストが HTML の背景色と同じ色になるように設定します。

最後に、 text-shadow は要素のテキストコンテンツに影を適用します。 4 つの値は次のとおりです。

  • 最初はピクセル値で、影のテキストからの水平オフセット、どれだけ横に移動するかを設定します。
  • 2 番目はピクセル値で、影のテキストから垂直オフセット、どれだけ下に移動するかを設定します。
  • 3 番目のピクセル値で、影をぼかす半径を設定します。値が大きいほどぼやけた影を生成します。
  • 4 番目の値は、影の基本色を設定します。

いろいろな値を試して、表示方法の変化を確認してみてください。

画像のセンタリング

img {
  display: block;
  margin: 0 auto;
}

次に、画像を中央に配置して見栄えを良くします。本文のときと同じように、 margin: 0 auto のトリックを使うこともできます。しかし、 CSS を機能させるために追加の設定が必要になる違いがあります。

<body> はブロック要素であるため、ページの中でスペースを占めます。ブロック要素は、マージンやその他の余白を開ける値を適用することができます。一方、画像はインライン要素です。インライン要素にマージンやその他の余白を開ける値を適用することはできません。画像にマージンを適用するには、display: block; を使用して画像にブロックレベルの動作を指定する必要があります。

Note 上記の手順は、本体に設定されている幅 (600 ピクセル) よりも小さい画像を使用していることを前提としています。画像が大きい場合、それは本文をあふれ、ページの残りの部分にはみ出します。これを修正するには、1) 画像編集ソフトを使用して画像の幅を縮小するか、2) CSS を使用して、 width プロパティでより小さな値を <img> 要素に設定し、画像の大きさを変更します。

Note display: block; や、ブロックレベル/インラインの区別がまだ理解できなくても心配しないでください。 CSS の勉強を続けていくうちに意味が分かってくるはずです。さまざまな display の値の違いについて詳しくは、 MDN の display のリファレンスページを参照してください。

まとめ

完成すると次のようなページが表示されます。

Firefoxのロゴを中央に配置し、ヘッダーと段落を配置しています。これで、ページ全体の背景が青くなり、中央に配置されたメインコンテンツストリップの背景がオレンジになるなど、きれいなスタイルになりました。

もし途中で行き詰まってしまったら、「サンプルコード」と見比べてみましょう。

― この文書は © 2023 MDN Web Docsプロジェクト協力者 クリエイティブ・コモンズ CC BY SA 2.5 ライセンスのもとに利用を許諾されています。 元の文書: https://developer.mozilla.org/ja/docs/Learn/Getting_started_with_the_web/JavaScript_basics

JavaScriptの基本

JavaScriptは世界で最も普及しているプログラミング言語です1

JavaScriptは強力なプログラミング言語であり、Webサイトに対話操作を追加することができます。 ブレンダン・アイク (Brendan Eich) によって考案されました。

JavaScript は汎用性が高く、初心者にもやさしいものです。経験を積めば、ゲーム、 2D や 3D のアニメーション、包括的なデータベース駆動型のアプリなどが作れるようになります。

JavaScript は比較的コンパクトですが、一方でとても柔軟性があります。開発者は JavaScript 言語のコアをベースに多種多様なツールを作成し、最小限の労力で膨大な様々な機能を利用できるようにしました。例えば以下のようなものがあります。

  • ブラウザーのアプリケーションプログラミングインターフェイス (API)。Webブラウザーに組み込まれた API により、動的な HTML の作成、 CSS スタイルの設定、ユーザーのWebカメラからの動画ストリームの収集や操作、三次元グラフィックや音声サンプルの生成などの機能を提供します。
  • 開発者が他のコンテンツプロバイダーのサイト(Twitter や Facebook など)から機能を組み込むことを可能にする、サードパーティの API。
  • すばやくサイトやアプリケーションを構築することができ、 HTML に組み込み可能なサードパーティのフレームワークやライブラリー。

コアの JavaScript 言語が上記のツールとどのように違うのか、その詳細を紹介することは、 JavaScript の軽い入門者向けの書籍であるこの記事の範囲外です。詳細は MDN の JavaScript 学習領域や、 MDN の他の部分で詳しく学ぶことができます。

以下では、コア言語のいくつかの側面について紹介します。またブラウザーの API 機能についてもいくつか説明します。楽しみましょう!

"Hello world!" の例

JavaScript は、最も人気のある現代のWeb技術のひとつです。 JavaScript のスキルが上がれば、Webサイトのパワーと創造性は新たな次元に入るでしょう。

しかし、 JavaScript を使いこなせるようになるのは HTML や CSS よりも少し難しいです。小さなものから始め、小さく確実な手順で作業を続ける必要があるかもしれません。始めるにあたって、"hello world!" を表示する例(基本的なプログラミング例の標準)を作りながら、基本的な JavaScript をページに追加する方法を紹介しましょう。

  1. 最初にテストサイトに行き、 scripts という名前の新しいフォルダーを作成してください。それから、この scripts フォルダーの中に main.js という新しいファイルを作成して保存してください。

  2. index.html ファイルの </body> 終了タグの直前に新しい行で、以下の新しい要素を追加してください。

    <script src="scripts/main.js"></script>
    
  3. これは CSS の <link> 要素の時の作業と基本的に同じです。これは JavaScript をページに適用するので、(CSS の時と同じく、ページ上の何に対しても) HTML に影響を与えることができます。

  4. main.js ファイルに次のコードを追加してください。

    const myHeading = document.querySelector("h1");
    myHeading.textContent = "Hello world!";
    
  5. 最後に、 HTML と JavaScript を書いたファイルを保存したことを確認し、ブラウザーで index.html を読み込んでください。

"hello world" の見出しが firefox のロゴの上にある

Note 上記の説明で <script> 要素を HTML ファイルの末尾付近に置いたのは、ブラウザーがファイルに現れる順番でコードを読み込むからです。

JavaScript が先に読み込まれ、まだ読み込まれていない HTML に影響を与えることになると、問題が生じる可能性があります。 JavaScript を HTML ページの下部に配置することは、この依存関係に対応する一つの方法です。その他の方法については、スクリプトの読み込み方針をご覧ください。

何が起きたのか

JavaScript を使用して、見出しの文字列が Hello world! に変更されました。最初に document.querySelector() 関数を使用して見出しを選択し、 myHeading と呼ばれる変数に格納しています。これは CSS のセレクターを使用するのととてもよく似ています。要素に対して何かをしたくなったら、まずその要素を選択する必要があります。

その後、 myHeading 変数の textContent プロパティ(見出しの内容を表す)の値を Hello world! に設定します。

Note 上の例で使用した機能はどちらもドキュメントオブジェクトモデル (DOM) API の一部であり、これを使って文書を操作することができます。

言語の短期集中コース

どのように動作するかをよりよく理解できるように、 JavaScript 言語の基本機能のいくつかを説明しましょう。これらの機能はすべてのプログラミング言語に共通しているので、これらの基本をマスターすれば、ほとんど何でもプログラムできるようになります!

Note この記事では、 JavaScript コンソールにサンプルコードを入力して、何が起こるのかを確認してみます。 JavaScript コンソールの詳細については、開発者ツールに慣れる (Firefoxの場合は ブラウザー開発ツールを探る)を参照しましょう。

変数

変数は、値を格納できる入れ物です。まず、 let というキーワードと、その後に任意の名前を指定することで、変数を宣言します。

let myVariable;

Note 行末のセミコロンは文が終わる場所を示します。単一の行で複数の文を区切る場合には絶対に必要です。しかし、個々の文の末尾に置くことが良い習慣だと信じている人もいます。使用する場面と使用しない場合については他のルールもあります。詳しくは Your Guide to Semicolons in JavaScript を参照してください。

Note 変数にはほとんど何でも名前を付けることができますが、いくらかの制約があります(変数の命名規則についてはこの記事を参照してください)。自信がない場合は、有効かどうか変数名を調べることができます。

Note JavaScript は大文字と小文字を区別します。 myVariablemyvariable とは異なる変数です。コードで問題が発生している場合は、大文字・小文字をチェックしてください。

変数を宣言したら、以下のように値を割り当てることができます。

myVariable = "Bob";

好みに応じて、両方の操作を同一の行で行うことができます。

let myVariable = "Bob";

変数の値は、名前で呼び出すだけで取得することができます。

myVariable;

変数に値を代入した後で、変更することもできます。

let myVariable = "Bob";
myVariable = "Steve";

なお、変数は様々なデータ型の値を保持することもできます。

変数 説明
文字列 一連のテキストで、文字列と呼ばれます。値が文字列であることを示すには、単一引用符または二重引用符で囲む必要があります。 let myVariable = 'Bob'; または
let myVariable = "Bob";
数値 数値です。数値は引用符で囲みません。 let myVariable = 10;
論理型 論理値です。これは真か偽かの値です。 truefalse は特別なキーワードで、引用符は必要ありません。 let myVariable = true;
配列 単一の参照で複数の値を格納できる構造です。 let myVariable = [1,'Bob','Steve',10];
配列の各メンバーは次のように参照します。
myVariable[0], myVariable[1], など。
オブジェクト 基本的には何でも格納できます。 JavaScript のすべてがオブジェクトであり、変数に格納することができます。学ぶ際にはこれを覚えておいてください。 let myVariable = document.querySelector('h1');
上記のすべての例も同様です。

ではなぜ変数が必要なのでしょうか。何か面白いプログラミングをするには変数が必要です。値が変更できなければ、挨拶のメッセージをパーソナライズしたり、画像ギャラリーに表示されている画像を変更するなどの動的な操作ができないのです。

コメント

コメントは、ブラウザーから無視される、コードの間に入れられた短いテキストスニペットです。CSS と同じように、JavaScript のコードではコメントを付けることができます。

/*
挟まれているすべてがコメントです。
*/

コメントに改行が含まれていない場合、次のように 2 つのスラッシュの後ろに記載する方が簡単です。

// これはコメントです

演算子

演算子は、2 つの値 (または変数) に基づいて結果を生成する数学的な記号です。次の表では、JavaScript コンソールで試してみるいくつかの例とともに、最も単純な演算子をいくつか見ることができます。

演算子 説明 記号
加算 2 つの数値を足し合わせたり、 2 つの文字列を結合したりします。 + 6 + 9;
'Hello ' + 'world!';
減算、乗算、除算 基本的な数学の計算を実施します。 -, *, / 9 - 3;
8 * 2; // JS での乗算はアスタリスク
9 / 3;
代入 すでに出てきました。変数に値を割り当てます。 = let myVariable = 'Bob';
厳密等価 これは、2 つの値が等しく、かつデータ型が同じであるかどうかを調べます。 true/false (論理値)の結果を返します。 === let myVariable = 3;
myVariable === 4;
否定、非等価 その後にあるものと論理的に反対の値を返します。たとえば truefalse に換えます。等価演算子と一緒に使用されると、否定演算子は 2 つの値が等しくないかどうかを調べます。 !, !==

「否定」の場合は次の通りです。基本の式が true であれば、反転するので比較結果は false となります。

let myVariable = 3;
!(myVariable === 3);

「非等価」は異なる構文ですが、基本的に同じ結果になります。ここでは「myVariable が 3 と等しくない」ことを調べます。次の例では false を返します。 myVariable は 3 と等しいからです。

let myVariable = 3;
myVariable !== 3;

他にも演算子はもっとたくさんありますが、今のところはこれで十分です。全体の一覧については、式と演算子を参照してください。

Note データ型を混在させると、計算を実行するときに奇妙な結果になる可能性があるため、変数を正しく参照し、期待通りの結果を得るように注意してください。例えばコンソールに '35' + '25' と入力してみてください。期待通りの結果にならないのはなぜでしょうか。引用符は数字を文字列に変換するので、数字を加算するのではなく、文字列を連結する結果になったのです。 35 + 25 を入力すれば、正しい結果が得られます。

条件分岐

条件分岐は、ある式が true を返すかどうかをテストし、その結果次第でそれぞれのコードを実行するコード構造です。条件分岐のよくある形は if...else 文です。例えば以下の通りです。

let iceCream = "チョコレート";
if (iceCream === "チョコレート") {
  alert("やった!チョコレートアイス大好き!");
} else {
  alert("あれれ、でもチョコレートが好きなのに......");
}

if () の中の式が条件です。ここでは等価演算子を使用して、変数 iceCreamチョコレートという文字列を比較し、2 つが等しいかどうかを調べています。この比較が true を返した場合、コードの最初のブロックが実行されます。比較が真でない場合、最初のブロックはスキップされ、 else 文の後にある 2 番目のコードブロックが代わりに実行されます。

関数

関数は、再利用したい機能をパッケージ化する方法です。プロシージャが必要なときは、毎回コード全体を書くのではなく関数名を使って関数を呼び出すことができます。すでにいくつかの関数の仕様を見てきました。例えば次のようなものです。

let myVariable = document.querySelector("h1");
alert("hello!");

これらの関数、 document.querySelectoralert は、必要なときにいつでも使えるようブラウザーに組み込まれています。

もし変数名に見えるものがあったとしても、その後に括弧 () が付いていれば、おそらくそれは関数です。関数は普通、仕事をするのに必要な小さなデータである引数を取ります。引数は括弧の中に入れ、複数の引数がある場合はカンマで区切ります。

例えば、 alert() 関数はブラウザーのウィンドウにポップアップボックスを表示しますが、ポップアップボックスに何を書き込むかを関数に指示するために、文字列を引数として渡す必要があります。

嬉しいことに、自分で関数を定義することができます。次の例では、引数として 2 つの数値をとり、それらを乗算するという単純な関数を記載します。

function multiply(num1, num2) {
  let result = num1 * num2;
  return result;
}

上記の関数をコンソールで実行し、いくつかの引数を指定してテストしてみてください。例えば次のようなものです。

multiply(4, 7);
multiply(20, 20);
multiply(0.5, 3);

Note return 文は result の値を関数内から関数の外に戻すことをブラウザーに指示し、それを利用できるようにします。これが必要な理由は、関数内で定義された変数が、その関数内でしか利用できないためです。これは変数のスコープと呼ばれています(変数のスコープのより詳しい説明をお読みください)。

イベント

Webサイトを本当にインタラクティブにするには、イベントが必要です。イベントは、ブラウザーの中で起きていることを検出し、その応答としてコードを実行するコード構造です。最も分かりやすい例は click イベントで、マウスで何かをクリックするとブラウザーによって発行されるものです。これを実行するには、コンソールに以下のように入力してから、現在のWebページ上をクリックしてください。

document.querySelector("html").addEventListener("click", function () {
  alert("痛っ! つつかないで!");
});

要素にイベントハンドラーを取り付ける方法はいくつもあります。ここでは <html> 要素を選択しています。そして、addEventListener() 関数を呼び出し、待ち受けるイベントの名前 ('click') とイベントが発生したときに実行する関数を渡します。

先ほど addEventListener() に渡した関数は、名前を持たないので無名関数と呼ばれます。無名関数の書き方として、アロー関数と呼ばれるものがあります。アロー関数は () =>function () の代わりに使用します。

document.querySelector("html").addEventListener("click", () => {
  alert("痛っ! つつかないで!");
});

Webサイトの例を膨らませる

さて、 JavaScript の基本のおさらいが終わったところで、例題のサイトに新しい機能を追加してみましょう。

先に進む前に、 main.js ファイルの現在の内容を削除して、空のファイルを保存してください。そうしないと、 "Hello world!" の例で使用した既存のコードが、これから追加する新しいコードと衝突してしまいます。

画像の切り替えの追加

このセクションでは、 DOM API 機能をもっと使用して、サイトに画像を追加しましょう。画像をクリックすると JavaScript を使用して 2 つの画像を切り替えることができます。

  1. まずサイトに掲載したいと思う別な画像を見つけてください。最初の画像と同じサイズか、できるだけ近いものを使用してください。

  2. この画像を images フォルダーに保存してください。

  3. この画像の名前を firefox2.png に変更してください。

  4. main.js ファイルに次の JavaScript を入力してください。

    const myImage = document.querySelector("img");
    
    myImage.onclick = () => {
      const mySrc = myImage.getAttribute("src");
      if (mySrc === "images/firefox-icon.png") {
        myImage.setAttribute("src", "images/firefox2.png");
      } else {
        myImage.setAttribute("src", "images/firefox-icon.png");
      }
    };
    
  5. index.html をブラウザーに読み込みます。画像をクリックすると、もう一方の画像に変わるでしょう。

何が起こったのでしょうか。<img> 要素への参照を変数 myImage に格納しました。次に、この変数の onclick イベントハンドラープロパティに、名前のない関数(「無名」関数)を代入しました。そうすれば、この要素がクリックされるたびに次の動きをします。

  1. 画像の src 属性の値を取得します。

  2. 条件分岐を使って、src の値が元の画像のパスと等しいかどうかをチェックします。

    1. そうであれば、src の値を 2 番目の画像へのパスに変更し、もう一方の画像が強制的に <img> 要素の中に読み込まれるようにします。
    2. そうでない(すでに変更されている)場合、src の値を元の画像のパスに戻して、元の状態に戻ります。

パーソナライズされた挨拶メッセージの追加

次に、もう 1 つの小さなコードを追加し、ユーザーがサイトにアクセスしたときに、ページの表題をパーソナライズされた挨拶メッセージに変更してみましょう。この挨拶メッセージは、ユーザーがサイトを離れて後で戻った時にも保存されるようにします。Web Storage API を使用して保存しましょう。したがって、必要な時にいつでもユーザーと挨拶メッセージを変更できるオプションも用意しましょう。

  1. index.html では、 <script> 要素の直前に次の行を追加します。

    <button>ユーザーを変更</button>
    
  2. main.js では、次のコードを下記のとおりにファイルの最後に配置します。これは新しいボタンと見出しへの参照を変数に格納します。

    let myButton = document.querySelector("button");
    let myHeading = document.querySelector("h1");
    
  3. パーソナライズされた挨拶を設定する以下の関数を追加しましょう。まだ何も起こりませんが、すぐに修正します。

    function setUserName() {
      const myName = prompt("あなたの名前を入力してください。");
      localStorage.setItem("name", myName);
      myHeading.textContent = `Mozilla is Cool, ${myName}`;
    }
    

    setUserName() 関数では、prompt() 関数を使用して、alert() のようにダイアログボックスを表示しています。しかし、prompt()alert() とは異なり、ユーザーにデータを入力するよう求め、ユーザーが OK を押した後に変数にそのデータを格納します。この場合、ユーザーに名前を入力するよう求めます。次に、localStorage と呼ばれる API を呼び出すことで、ブラウザーにデータを格納して後で受け取ることができます。 localStorage の setItem() 関数を使って、'name' と呼ばれるデータを作成し、 myName に入っているユーザーから入力されたデータを格納します。最後に、見出しの textContent に文字列と新しく格納されたユーザーの名前を設定します。

  4. 以下のような条件ブロックを追加します。最初に読み込んだときにアプリを構造化するので、これを初期化コードと呼ぶこともできます。

    if (!localStorage.getItem("name")) {
      setUserName();
    } else {
      const storedName = localStorage.getItem("name");
      myHeading.textContent = `Mozilla is Cool, ${storedName}`;
    }
    

    このブロックでは、最初に name のデータが存在しているかどうかをチェックするために否定演算子(! で表される論理否定)を使用しています。存在しない場合は、作成するために setUserName() 関数が実行されます。存在する場合は(つまり、以前の訪問時にユーザーが設定した場合)、 getItem() を使用して格納された名前を受け取り、 setUserName() の中で行ったのと同様に、見出しの textContent に文字列とユーザーの名前を設定します。

  5. 最後に、以下の onclick イベントハンドラーをボタンに設定します。クリックすると、setUserName() 関数が実行されます。これでユーザーがボタンを押すことで、好きな時に新しい名前を設定できるようになります。

    myButton.onclick = () => {
      setUserName();
    };
    

ユーザー名か null か

この例を実行してユーザー名を入力するダイアログボックスが出たとき、キャンセルボタンを押してみてください。結果として "Mozilla is cool, null" というタイトルが表示されるでしょう。これはプロンプトをキャンセルしたときに、値が null、つまり値がないことを示す JavaScript の特殊な値に設定されるためです。

また何も入れずに OK を押してみてください。結果として "Mozilla is cool," というタイトルが表示され、これは理由が明白です。

この問題を避けるには、ユーザーが null や空白の名前を入力していないかチェックするよう、setUserName() 関数を書き換えます。

function setUserName() {
  const myName = prompt("あなたの名前を入力してください。");
  if (!myName) {
    setUserName();
  } else {
    localStorage.setItem("name", myName);
    myHeading.textContent = `Mozilla is Cool, ${myName}`;
  }
}

人間の言葉で言うと、 myName に値がない場合や、nullの場合、 最初から setUserName() を実行します。値がない場合(上記の式が真でない場合)には、localStorage に値を設定して、見出しのテキストにも設定します。

まとめ

最後までこの記事の手順に従った場合は、最終的に次のようなページが表示されているでしょう。

ヘッダー、中央の大きなロゴ、内容、ボタンなどの要素を作成した後の HTML ページの最終的な外観

もし途中で行き詰まってしまったら、「サンプルコード」と見比べてみましょう。


  1. JetBrainsのレポート https://www.jetbrains.com/ja-jp/lp/devecosystem-2023/javascript/

Web開発研修

この章は、実践的なWeb開発スキルを短期間で身につけることを目的とした研修カリキュラムです。ハンズオン中心の構成で、ローカル環境のセットアップからAPI設計、Edge/サーバーレス技術(Hono/HonoX)を使ったフルスタック開発、そしてチームでの開発演習までを扱います。

学習の目標

  • モダンなローカル開発環境を自信を持って構築できる
  • HTTP/RESTや非同期処理の基礎を理解し、外部APIと連携できる
  • HonoやHonoXを使った軽量なサーバー/APIを設計・実装できる
  • 型安全(TypeScript)とテストを取り入れた堅牢な開発フローを実践できる
  • 小さなチームでプロジェクトを企画・実装し、成果を発表できる

前提条件

  • HTML/CSS/JavaScript の基礎知識

進め方(推奨)

  1. 環境構築パート(Environment)を最初に終わらせ、ローカルで実行できる状態を作る
  2. REST APIと非同期処理の章でHTTPの基本とfetch等の使い方を押さえる
  3. Honoハンズオンでサーバー側の基本を学び、HonoXでフロントとAPIの連携を実践する
  4. 演習(Practice)で小さなプロジェクトを作り、最後に発表・レビューを行う

所要時間は全体で数週間〜1ヶ月程度(学習ペースや研修スコープにより変動)を想定しています。短期間で集中的に進める場合は、演習を縮小して重要なトピックに絞ると良いです。

演習と評価

各パートには演習課題があり、手を動かして学ぶことを重視しています。最終的には小規模なチームでの開発演習を通して以下を評価します:

  • 要件定義と設計の妥当性
  • 実装の正確さと型安全性(TypeScript)
  • テストの有無と品質(ユニット/統合)
  • デプロイやドキュメントの整備

成果はプロジェクトのデモと簡単なレポートで共有してください。

フィードバック / 問い合わせ

学習中の質問・改善提案・教材の誤りなどは「質問・提案・問題の報告」をご覧ください。研修担当者やメンターに直接相談しても構いません。

Web開発環境構築

REST APIと非同期処理

Honoハンズオン

HonoXによるフルスタック構築

Web開発演習

Web開発環境構築

モダンWebアーキテクチャ概要

現代のWebアプリケーション開発では、フロントエンド、バックエンド、インフラが連携しながらも、それぞれが独立した役割を担っています。モダンなWebアーキテクチャの全体像を把握し、実際の開発で使われているパターンや最新技術を一緒に学んでいきましょう。

フロントエンド、バックエンド、インフラの役割分担

フロントエンドの役割

フロントエンドは、ユーザーが直接触れる部分を担当します(まさにWebサイトの「顔」ですね)。

主な責務:

  • ユーザーインターフェース(UI)の構築
  • ユーザーエクスペリエンス(UX)の最適化
  • データの表示・入力処理
  • バックエンドとの通信
// React.jsの例
import React, { useState, useEffect } from "react";

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    // バックエンドAPIからデータを取得
    fetch(`/api/users/${userId}`)
      .then((response) => response.json())
      .then((userData) => setUser(userData));
  }, [userId]);

  return <div>{user ? <h1>Hello, {user.name}!</h1> : <p>Loading...</p>}</div>;
}

バックエンドの役割

バックエンドは、アプリケーションのロジックとデータ処理を担当します (頭脳のようなものです)。

主な責務:

  • ビジネスロジックの実装
  • データベースとの連携
  • API(Application Programming Interface)の提供
  • セキュリティ・認証の管理
// Node.js + Honoの例
import { Hono } from "hono";

const app = new Hono();

// ユーザー情報を取得するAPI
app.get("/api/users/:id", async (c) => {
  try {
    const user = await database.getUser(c.req.param("id"));
    return c.json(user);
  } catch (error) {
    return c.json({ error: "User not found" }, 404);
  }
});

インフラストラクチャの役割

インフラは、アプリケーションを動かすための基盤を提供します(筋肉・骨格のようなものです)。

主な責務:

  • サーバーの管理・運用
  • データベースの管理
  • セキュリティ・監視
  • スケーリング(負荷対応)

現代の代表的なアーキテクチャパターン

1. モノリシックアーキテクチャ

特徴: すべての機能が1つのアプリケーションに統合されている従来型のアーキテクチャです。

メリット:

  • 開発・デプロイが簡単
  • 小規模チームに適している
  • トランザクション管理がしやすい

デメリット:

  • 機能追加時の影響範囲が大きい
  • 技術スタックの変更が困難
  • スケーリングが非効率

モノリシックアプリのイメージ:

アプリ
ユーザー管理
商品管理
注文処理
決済処理

2. マイクロサービスアーキテクチャ

特徴: 機能ごとに独立したサービスに分割し、API経由で連携するアーキテクチャです。

メリット:

  • 各サービスを独立して開発・デプロイ可能
  • 適切な技術スタックを選択可能
  • 障害の影響を局所化できる

デメリット:

  • システム全体の複雑性が増加
  • サービス間通信のオーバーヘッド
  • 分散システムの管理が必要

マイクロサービスのイメージ:

API Gatewayを介して各サービスが連携

ユーザーサービス
ユーザー管理
プロフィール管理

↑↓

商品サービス
商品カタログ
在庫管理

↑↓

注文サービス
注文処理

↑↓

決済サービス
決済処理

サーバーレス・エッジコンピューティングの最新技術動向

サーバーレスとは?

特徴: サーバー管理を不要にし、関数単位でコードを実行できるクラウドサービスの形態です。

メリット:

  • サーバー管理が不要
  • オートスケーリング
  • 使用量に応じた料金体系

代表的なサービス:

  • AWS Lambda
  • Vercel Functions
  • Cloudflare Workers
// Cloudflare Workers の例
export default {
  async fetch(request) {
    return new Response("Hello from Serverless!");
  },
};

エッジコンピューティングとは?

エッジコンピューティングは、データ処理をユーザーに近い場所(エッジ)で行うサーバーレス技術です(まるでコンビニのように、身近な場所でサービスを提供するイメージです)。

なぜエッジコンピューティングが注目されているのか?

従来の課題:

  • 中央サーバーまでの通信遅延
  • 帯域幅の制限
  • 単一障害点のリスク

エッジコンピューティングの解決策:

  • レイテンシの削減: ユーザーに近い場所での処理
  • 帯域幅の節約: 必要最小限のデータ転送
  • 可用性の向上: 分散処理による障害耐性

実際の活用事例

1. CDN(Content Delivery Network)

// Cloudflare Workers の例
export default {
  async fetch(request) {
    const country = request.cf.country;
    return new Response(`Hello from ${country}!`);
  },
};

2. サーバーレス・エッジプラットフォーム

主要サービス:

サービス特徴主な用途
Cloudflare WorkersV8エンジンベース、高速起動API、リダイレクト処理
AWS Lambda@EdgeCloudFront統合認証、A/Bテスト
Vercel Edge FunctionsNext.js統合パーソナライゼーション

実践例:地域別コンテンツ配信

// Vercel のサーバーレス関数(Edge Functions)の例
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  const country = request.geo?.country || "US";

  // 国別に異なるコンテンツを配信
  const url = request.nextUrl.clone();
  url.pathname = `/${country.toLowerCase()}${url.pathname}`;

  return NextResponse.rewrite(url);
}

アーキテクチャ選択のトレードオフ

コンウェイの法則:組織とアーキテクチャの関係

コンウェイの法則(Conway's Law)
「システムを設計する組織は、その組織のコミュニケーション構造をコピーした設計を生み出すように制約される」
— Melvin Conway, 1967

この法則が示唆することは明快です。アーキテクチャは技術的な選択である前に、組織的な選択であるということです。

なぜアーキテクチャと組織構造は一致するのか?

チーム間の調整コストがその答えです。

モノリシックな組織 → モノリシックなコード
  ├─ 全員が同じコードベースで作業
  └─ 変更時は全員の調整が必要

分散した組織 → マイクロサービス
  ├─ 各チームが独立したサービスを所有
  └─ API契約さえ守れば独立して開発可能

各アーキテクチャが前提とする組織構造

1. モノリシック:密なコミュニケーションが可能な小規模チーム

最適な組織:

  • 1つのチーム(3-8人程度)
  • 物理的に近い場所で作業
  • 頻繁な対面コミュニケーション

なぜこの構造が必要か?
すべてのコードが1つのリポジトリにあり、変更の影響範囲が広いため、チームメンバー全員が全体を把握している必要があります。これは小規模チームでしか実現できません。

// 1つの変更が広範囲に影響
function updateUserProfile(userId, data) {
  // ユーザー管理
  const user = await db.users.update(userId, data);
  // 通知システム(同じコードベース内)
  await notificationService.send(user);
  // メール送信(同じコードベース内)
  await emailService.sendWelcome(user);
  // 全ての機能が密結合している
}

2. マイクロサービス:自律的なチームが並行で動く大規模組織

最適な組織:

  • 複数の独立したチーム(各3-8人)
  • チームごとに異なる専門性・技術スタック
  • 明確なAPI契約による非同期コミュニケーション

なぜこの構造が必要か?
サービス間の境界がチーム間の境界と一致することで、各チームは他チームへの依存を最小限に抑えながら開発できます。

      +───────API──────+
      |             |           |
[ユーザーサービス] [注文サービス] [決済サービス]  … システム
      |             |           |
[ユーザーチーム]  [注文チーム]  [決済チーム]     … 組織

各チームは自分のサービスに責任を持ち、他のチームとはAPI経由でのみやり取りします。

技術特性の比較

特性モノリシックマイクロサービス
初期開発速度⭐⭐⭐
トランザクション管理容易困難
チーム調整コスト
障害の影響範囲全体局所的

逆コンウェイ戦略:アーキテクチャから組織を設計する

興味深いことに、この法則は逆方向にも適用できます。目指すアーキテクチャに合わせて組織構造を設計するという戦略です。

例:モノリスからマイクロサービスへの移行

Step 1: アーキテクチャの分割計画
  ├─ ユーザー管理サービス
  ├─ 商品管理サービス
  └─ 注文管理サービス

Step 2: チーム構造の再編成
  ├─ ユーザーチーム(3名)
  ├─ 商品チーム(4名)
  └─ 注文チーム(5名)

Step 3: 責任範囲の明確化
  各チームが対応するサービスのエンドツーエンドを担当
  (設計、開発、テスト、運用、監視)

この戦略により、組織構造とアーキテクチャが一致し、開発効率が向上します。

意思決定のフレームワーク

アーキテクチャを選択する際は、以下の質問に答えてみてください。

1. チームの現在の構造は?

  • 全員が密にコミュニケーションできる → モノリシック or サーバーレス
  • 複数の独立したチームがある → マイクロサービス
  • 1-3人の小規模チーム → サーバーレス

2. 将来のチーム拡張計画は?

  • 大きくしない(〜10人) → モノリシック or サーバーレス
  • 複数チームに拡大予定 → マイクロサービスを検討
  • 不確定 → サーバーレス(柔軟性が高い)

3. チーム間の調整コストをどう考えるか?

  • 頻繁な調整が苦にならない → モノリシック
  • 調整コストを最小化したい → マイクロサービス or サーバーレス
  • インフラ管理を避けたい → サーバーレス

4. 既存の組織文化は?

  • 密なコラボレーション文化 → モノリシック
  • 自律的なチーム文化 → マイクロサービス
  • スタートアップ的な柔軟性 → サーバーレス

失敗パターン:組織とアーキテクチャのミスマッチ

❌ 失敗パターン1:小規模チームでマイクロサービス

  • 問題:3人チームが10個のサービスを管理
  • 結果:サービス間の調整に時間を取られ、開発速度が低下

❌ 失敗パターン2:大規模組織でモノリシック

  • 問題:20人が同じコードベースで作業
  • 結果:変更の度に全員の調整が必要、デプロイが週1回に

❌ 失敗パターン3:インフラ知識がないままマイクロサービス

  • 問題:Kubernetes、サービスメッシュ、分散トレーシングの運用負荷
  • 結果:機能開発よりインフラ管理に時間を取られる

設計のポイント

  1. 組織構造とアーキテクチャを一致させる
    • 無理に流行りのアーキテクチャを採用せず、チームの実態に合わせる
  2. 段階的に移行する
    • 一気に変えず、モノリス→サーバーレス→マイクロサービスのように段階的に
  3. チームの自律性を最大化する
    • 各チームが独立してデプロイできる粒度でサービスを分割する
  4. API契約を明確にする
    • チーム間のコミュニケーションコストを減らすため、明確なインターフェースを定義
  5. 測定可能な指標を持つ
    • デプロイ頻度
    • リードタイム
    • 変更失敗率
    • 復旧時間(MTTR)

Note
アーキテクチャの選択は、技術的な最適解を求めることではなく、組織の現実と目標を反映したトレードオフの選択です。完璧なアーキテクチャは存在しません。あるのは、現在のチームと事業フェーズに最も適したアーキテクチャだけです。

2025年のトレンドと将来展望

  1. フルスタック フレームワークの進化
    • Next.js 15、Nuxt 4 などの新機能
    • App Router、Server Components の普及
  2. サーバーレス優先アーキテクチャ
    • エッジでの動的レンダリング
    • 最適化の自動化
  3. AI統合アーキテクチャ
    • LLM API の活用
    • リアルタイム AI処理
  4. 型安全性
    • TypeScript の標準化
    • エンドツーエンドの型安全性

注目のフレームワーク: Astro

---
// サーバーサイドで実行
const posts = await fetch('/api/posts').then(r => r.json())
---

<Layout>
  <h1>My Blog</h1>
  <!-- 静的HTML -->
  <PostList posts={posts} />

  <!-- 必要な部分のみ JavaScript -->
  <SearchBox client:load />
</Layout>

https://docs.astro.build/ja/getting-started/

ポイント

🎯 重要なコンセプト

  • フロントエンド: ユーザーインターフェースとユーザー体験を担当
  • バックエンド: ビジネスロジックとデータ処理を担当
  • インフラ: アプリケーションの実行基盤を提供
  • サーバーレス: 運用負荷軽減、自動スケーリング、エッジでの実行が可能
  • エッジコンピューティング: ユーザーに近い場所での処理により、速度と効率を向上

🏗️ アーキテクチャパターン

  • モノリシック: シンプルだが拡張性に制限
  • マイクロサービス: 高い柔軟性だが複雑性も増加

🚀 選択のポイント

  • プロジェクト規模: チームサイズと要件の複雑さを考慮
  • 技術的制約: 既存システムとの統合要件
  • 運用リソース: 管理・保守の工数とスキル
  • 将来の拡張性: ビジネス成長への対応力

💡 実践への第一歩

まずは小さなプロジェクトでサーバーレス関数を試してみることから始めましょう。理論だけでなく、実際に手を動かすことで、それぞれのアーキテクチャの特性を体感できるはずです。

現代のWeb開発は選択肢が豊富ですが(時には選択肢が多すぎて迷ってしまいますが)、基本的な役割分担と各パターンのトレードオフを理解していれば、適切な技術選択ができるようになります。一緒に頑張りましょう!

ローカル開発環境セットアップ

自分のパソコンでWeb開発を始めるために必要な環境の準備について一緒に学んでいきましょう。最初は設定することがたくさんあって大変に感じるかもしれませんが、一度セットアップしてしまえば快適に開発できるようになります。

学習目標

  • Web開発に必要なツールの全体像を理解する
  • どのパソコンでも同じように開発できる環境を構築する
  • 効率的で使いやすい開発環境を作る

開発環境の全体像

基本的なツール構成

Web開発に必要なツールはいくつかあり、それぞれに役割があります。

必須ツール:

  • エディタ: VS Code (推奨), Cursor, Zed(コードを書くためのソフト)
  • ランタイム: Node.js (推奨), Deno, Bun
  • バージョン管理: Git (推奨)
  • ツール管理: mise (推奨), asdf, volta
  • AI支援ツール: GitHub Copilot, Codex, Claude Code, Gemini CLI

推奨ツール:

  • 仮想環境: WSL2 (Windows), Docker
  • ターミナル: Windows Terminal, Warp, WezTerm, iTerm2
  • シェル: Bash, Zsh, Fish
  • ブラウザ: Chrome, Safari, Firefox
  • HTTP クライアント: curl, Thunder Client (VS Code)

OS別セットアップガイド

Windows (推奨: WSL2 使用)

  1. WSL2 セットアップ
# 管理者権限でPowerShellを起動
wsl --set-default-version 2
wsl --install Ubuntu

# WSL2での作業推奨
wsl
  1. Windows Tools
# Windows Terminal (推奨)
winget install Microsoft.WindowsTerminal

# VS Code
winget install Microsoft.VisualStudioCode

# Git for Windows
winget install Git.Git
  1. WSL2内でのセットアップ
# Ubuntu/Debian内で実行
sudo apt update && sudo apt upgrade -y
sudo apt install curl build-essential git -y

macOS

  1. Homebrew インストール
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
  1. 基本ツール
# 開発ツール
xcode-select --install

# エディタとブラウザ
brew install --cask visual-studio-code
brew install --cask google-chrome

# ターミナル
brew install --cask wezterm  # または iterm2

Linux (Ubuntu/Debian)

  1. システム更新とビルドツール
sudo apt update && sudo apt upgrade -y
sudo apt install curl build-essential git -y
  1. VS Code インストール
# 公式リポジトリから
curl -sSL https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add -
echo "deb [arch=amd64] https://packages.microsoft.com/repos/vscode stable main" | sudo tee /etc/apt/sources.list.d/vscode.list
sudo apt update
sudo apt install code
  1. Google Chrome インストール
# 公式リポジトリから
curl -sSL https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.list
sudo apt update
sudo apt install google-chrome-stable

コマンドライン操作

基本的なターミナル操作

# ナビゲーション
pwd                     # 現在のディレクトリを表示
ls -la                  # ファイル一覧(詳細表示)
ls -la | grep node      # grep でフィルタリング
cd directory            # ディレクトリ移動
cd -                    # 前のディレクトリに戻る
cd ~                    # ホームディレクトリに移動

# ディレクトリ・ファイル操作
mkdir -p path/to/dir    # 階層ディレクトリ作成
touch file.txt          # 空ファイル作成
cp -r source dest       # ディレクトリをコピー
mv old_name new_name    # ファイル/ディレクトリ名変更
rm -rf directory        # ディレクトリを強制削除

# ファイル内容操作
cat file.txt            # ファイル全体表示
head -n 10 file.txt     # 先頭10行表示
tail -n 10 file.txt     # 末尾10行表示
grep "pattern" file.txt # パターン検索
find . -name "*.js"     # ファイル検索

プロセス管理

# プロセス操作
ps aux                  # 全プロセス表示
pgrep node              # Node.jsプロセスを検索
top                     # リアルタイムプロセス監視
kill pid                # プロセスID指定で終了
pkill node              # プロセス名で全て終了

# バックグラウンド実行
node --run dev &        # バックグラウンドで実行
Ctrl+Z                  # プロセスを一時停止
jobs                    # ジョブ一覧
fg                      # フォアグラウンドに復帰

モダンシェル環境の構築

Git設定

# グローバル設定
git config --global user.name "Your Name"
git config --global user.email "your.email@example.com"
git config --global init.defaultBranch main
git config --global core.editor "code --wait"

# SSH キー生成
ssh-keygen -t ed25519 -C "your.email@example.com"

# SSH設定 (~/.ssh/config)
Host github.com
  HostName github.com
  User git

開発支援ツールのインストール

# mise経由でツールインストール
mise install node@24
mise use --global node@24

# グローバルパッケージ
npm i -g @biomejs/biome

環境確認とテスト

# 研修用ディレクトリに移動
mkdir -p web-dev-2025/test
cd test-environment

# mise.toml 作成
mise use node@24

# 動作確認
node -v
node -e 'console.log(process.version)'

# package.json 作成
npm init -y

# 依存関係インストール
npm i -D react react-dom typescript @types/react @types/react-dom

まとめ

開発環境構築のポイント

  • 一貫性: mise によるツールバージョン管理
  • 再現性: 設定ファイルによる環境の再現
  • 効率性: ターミナル操作の習得と自動化

🔄 継続的改善

  • 新しいツールの評価と導入
  • チームでの共有

miseによるツール管理

この章では、開発ツールのバージョン管理を簡単にしてくれる「mise」と、Web開発に欠かせない「Node.js」について一緒に学んでいきましょう。最初は設定が少し面倒に感じるかもしれませんが、一度覚えてしまえば開発がグッと楽になります。

学習目標

  • Node.jsの役割とJavaScriptランタイムについて理解する
  • miseを使った開発ツールの管理方法を覚える
  • プロジェクトごとに違うバージョンを使い分ける方法を学ぶ
  • パッケージマネージャの基本的な使い方を学ぶ

Node.jsって何?

JavaScriptが動く場所

ブラウザー

  • Chrome (V8エンジン)
  • Firefox (SpiderMonkeyエンジン)
  • Safari (JavaScriptCoreエンジン)

サーバーサイド

  • Node.js (V8エンジン)
  • Deno (V8エンジン)
  • Bun (JavaScriptCoreエンジン)

従来、JavaScriptはブラウザでしか動きませんでした。しかし、Node.jsの登場により、サーバーでもJavaScriptが使えるようになったのです。

Node.jsの魅力

技術的な特徴:

  • V8 エンジン: Googleが開発した高性能なJavaScriptエンジン
  • イベントループ: 非同期処理がとても得意
  • 豊富なエコシステム: npmで何十万ものパッケージが利用可能

主な用途:

  • Webサーバーの作成(Express、Fastify、Hono等)
  • ビルドツールの実行(Vite、Webpack等)
  • コマンドラインツールの開発
  • フロントエンド開発環境(React、Vue等)

Node.jsのバージョンについて

Node.jsは定期的に新しいバージョンがリリースされます。基本的にはLTS(Long Term Support)版を選んでおけば安心です。

# Node.js のリリースサイクル
偶数バージョン (20, 22, 24) → LTS版(長期サポート)
奇数バージョン (21, 23, 25)  → Current版(最新機能)

# おすすめのLTSバージョン
24.x.x  # 最新のアクティブLTS

miseって何?

基本的な概念

mise は、プログラミング言語や開発ツールのバージョンを管理してくれる便利なツールです。「このプロジェクトではNode.js 22を使って、あのプロジェクトではNode.js 24を使いたい」といった要望を簡単に実現できます。

mise で管理できる主なツール:

  • Node.js: JavaScriptランタイム
  • Python: プログラミング言語
  • Go: プログラミング言語
  • pnpm: Node.jsパッケージマネージャ

他にも多数の言語やツールをサポートしています。詳しくは 公式ドキュメント を参照してください。

miseの魅力

  • 統一されたコマンド: 異なる言語やツールを同じ方法で管理できる
  • プロジェクト単位の設定: mise.toml ファイルでバージョンを指定
  • 高速: Rustで作られているのでとても速い
  • 自動切り替え: フォルダ移動時に自動でバージョンが切り替わる

従来のバージョン管理ツールとの比較

従来は言語ごとに異なるツールを使う必要がありました:

ツール管理対象速度設定ファイル
mise多言語・ツールとても速いmise.toml
asdf多言語・ツール普通.tool-versions
nvmNode.jsのみ普通.nvmrc
pyenvPythonのみ普通.python-version
rbenvRubyのみ普通.ruby-version

miseなら1つのツールで全部管理できるので、覚えることが少なくて済みます。

miseをインストールしよう

1. インストール方法

# mise のインストール (全プラットフォーム共通)
curl https://mise.run | sh

# または、各OS固有の方法
# Windows (PowerShell): irm https://mise.run/install.ps1 | iex
# macOS: brew install mise
# Linux: curl https://mise.run | sh

2. シェル設定

Bash

echo "eval \"\$(${HOME}/.local/bin/mise activate bash)\"" >> ~/.bashrc
source ~/.bashrc

Zsh

echo "eval \"\$(${HOME}/.local/bin/mise activate zsh)\"" >> ~/.zshrc
source ~/.zshrc

Fish

echo "${HOME}/.local/bin/mise activate fish | source" >> ~/.config/fish/config.fish

3. Node.jsのインストール

# プロジェクトディレクトリで実行
cd my-project

# Node.js最新LTS版をインストール
mise use node@24

# パッケージマネージャ pnpm もインストール
mise use pnpm@latest

これで mise.toml というファイルが自動的に作成されます:

[tools]
node = "24"
pnpm = "latest"

このファイルをGitで管理することで、チーム全員が同じバージョンのツールを使えるようになります。

Node.jsが使えるか確認しよう

# Node.jsのバージョン確認
node --version
# → v24.x.x と表示されればOK

# npmのバージョン確認(Node.jsに標準で付属)
npm --version

# pnpmのバージョン確認
pnpm --version

パッケージマネージャについて

パッケージマネージャって何?

Node.jsの世界では、他の人が作った便利なコード(パッケージ)を簡単に使うことができます。そのパッケージを管理してくれるのが「パッケージマネージャー」です。

主なパッケージマネージャー

1. npm(Node Package Manager)

Node.jsと一緒にインストールされる標準のパッケージマネージャーです。

npm install package-name       # パッケージをインストール
npm install --save-dev package-name  # 開発用パッケージとしてインストール
npm run script-name           # スクリプトを実行

2. pnpm(推奨)

「performant npm」の略で、高速で効率的なパッケージマネージャーです。

pnpm add package-name         # パッケージをインストール
pnpm add -D package-name      # 開発用パッケージとしてインストール
pnpm run script-name          # スクリプトを実行

pnpmの利点:

  • 高速: npmより3倍以上速い
  • 省ディスク: 同じパッケージを複数プロジェクトで共有
  • 厳格: 依存関係の問題を早期発見

このカリキュラムでは pnpm を使用することを推奨します。

package.jsonの基本

package.jsonって何?

package.json は、プロジェクトの設定と依存関係を記録するファイルです。

{
  "name": "my-project",
  "version": "1.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "test": "vitest"
  },
  "dependencies": {
    "react": "^18.2.0"
  },
  "devDependencies": {
    "vite": "^5.0.0"
  }
}

重要なフィールド

  • name: プロジェクト名
  • version: バージョン番号
  • scripts: pnpm run で実行できるコマンド
  • dependencies: 本番環境で必要なパッケージ
  • devDependencies: 開発環境のみで必要なパッケージ

スクリプトの実行

# package.jsonのscriptsに定義されたコマンドを実行
pnpm run dev      # 開発サーバー起動
pnpm run build    # ビルド実行
pnpm run test     # テスト実行

基本的な使い方(mise)

1. 利用可能なツールの確認

# 利用可能なツール一覧
mise search

# Node.jsの利用可能バージョン
mise ls-remote node

# インストール済みツール確認
mise ls

2. バージョンの設定

グローバル設定

# システム全体のデフォルトバージョン
mise use --global node@24
mise use --global pnpm@latest

プロジェクト設定

# プロジェクトディレクトリで実行
cd my-project
mise use node@24
mise use pnpm@latest

3. 現在のバージョン確認

# 現在使用中のバージョン
mise current

トラブルシューティング

  1. mise が見つからない
# パスの確認
which mise
echo $PATH

# シェル設定の再読み込み
source ~/.bashrc  # or ~/.zshrc
  1. 古いバージョンマネージャとの競合
# nvm, pyenv などを無効化
# ~/.bashrc から該当行を削除またはコメントアウト
# export PATH="$HOME/.nvm:$PATH"  # ← これをコメントアウト
  1. プラグインのインストールエラー
# キャッシュクリア
mise cache clear

# プラグイン再インストール
mise plugin uninstall node
mise plugin install node

実習課題

1. 環境確認

# miseのバージョン確認
mise --version

# Node.jsのバージョン確認
node --version

# pnpmのバージョン確認
pnpm --version

2. 簡単なプロジェクトの作成

# プロジェクトフォルダ作成
mkdir my-first-project
cd my-first-project

# Node.jsとpnpmの設定
mise use node@24
mise use pnpm@latest

# package.jsonの作成
pnpm init

3. パッケージのインストールと実行

# Viteをインストール
pnpm add -D vite

# package.jsonにスクリプトを追加(手動で編集)
# "scripts": {
#   "dev": "vite"
# }

# 開発サーバー起動
pnpm run dev

ポイント

この章で学んだことをまとめておきます。

Node.jsについて:

  • JavaScriptランタイム: ブラウザ以外でもJavaScriptを実行できる環境
  • V8エンジン: Googleが開発した高性能なJavaScriptエンジンを使用
  • LTS版: 長期サポート版で、安定性を重視するプロジェクトにおすすめ
  • 豊富なエコシステム: npmで何十万ものパッケージが利用可能

miseについて:

  • 統一管理: 複数の開発ツールのバージョンを1つのツールで管理
  • プロジェクト単位: フォルダごとに異なるバージョンを自動切り替え
  • チーム開発: mise.tomlで全員が同じ環境を構築可能

パッケージマネージャについて:

  • npm: Node.js標準のパッケージマネージャ
  • pnpm: 高速で効率的、このカリキュラムで推奨
  • package.json: プロジェクトの設定と依存関係を記録

これらを使うことで:

  • ✅ 異なるプロジェクトで異なるNode.jsバージョンを簡単に使い分けられる
  • ✅ チーム全員が同じツールバージョンで開発できる
  • ✅ 新しいメンバーの環境構築が簡単になる
  • ✅ 豊富なnpmパッケージを活用できる
  • ✅ モダンなWeb開発ツールが使える

最初は覚えることが多くて大変かもしれませんが、miseとNode.jsに慣れてしまえば、Web開発がとても効率的になりますよ。実際に手を動かしながら、少しずつ覚えていきましょう!

VS Code環境設定

この章では、Web開発に欠かせないエディタ「Visual Studio Code(VS Code)」について一緒に学んでいきましょう。VS Codeは無料で使えて、しかもとても高機能なエディタです。最初は設定が少し大変かもしれませんが、一度設定してしまえばとても快適に開発できるようになります。

学習目標

  • VS Codeの基本的な使い方を覚える
  • Web開発に便利な拡張機能を知る
  • 効率的なコーディング環境を作る
  • AI開発ツールとの連携方法を学ぶ

VS Codeってどんなエディタ?

VS Codeの魅力

VS Codeにはこんな素晴らしい特徴があります:

使いやすさ:

  • Language Server Protocol (LSP): プログラミング言語のサポートが充実
  • 豊富な拡張機能: 必要な機能を自由に追加できる
  • TypeScript統合: マイクロソフト製なので、TypeScriptとの相性が抜群
  • リモート開発: コンテナやクラウド環境でも開発できる

パフォーマンス:

  • Electronベースながら軽快に動作(非常に最適化されています)
  • 大きなファイルでも安定して動作
  • メモリ使用量も効率的

VS Codeの画面構成を覚えよう

ワークスペースの構成

VS Codeの画面は、いくつかのエリアに分かれています。最初は覚えにくいかもしれませんが、慣れてしまえばとても使いやすいですよ。

キーボードショートカット

ファイル・ナビゲーション

# ファイル操作
Ctrl/Cmd + N           # 新規ファイル
Ctrl/Cmd + O           # ファイルを開く
Ctrl/Cmd + P           # クイックオープン(ファイル検索)
Ctrl/Cmd + Shift + P   # コマンドパレット
Ctrl/Cmd + W           # タブを閉じる
Ctrl/Cmd + Shift + T   # 最近閉じたタブを再度開く

# ナビゲーション
Ctrl/Cmd + G           # 行番号で移動
Ctrl/Cmd + Shift + O   # シンボル検索(関数・変数)
F12                    # 定義へ移動
Alt + F12              # 定義をピーク表示
Ctrl/Cmd + -           # 前の位置に戻る

編集・検索

# 基本編集
Ctrl/Cmd + /           # コメントアウト
Ctrl/Cmd + [           # インデント減らす
Ctrl/Cmd + ]           # インデント増やす
Shift + Alt + F        # フォーマット

# マルチカーソル
Ctrl/Cmd + D           # 選択した単語と同じものを次々選択
Ctrl/Cmd + Shift + L   # 選択した単語と同じものを全て選択
Alt + Click            # マルチカーソル
Ctrl/Cmd + Alt + Up/Down # カーソルを上下に追加

# 検索・置換
Ctrl/Cmd + F           # ファイル内検索
Ctrl/Cmd + H           # 置換
Ctrl/Cmd + Shift + F   # 全体検索
Ctrl/Cmd + Shift + H   # 全体置換

ワークスペース管理

# パネル・サイドバー
Ctrl/Cmd + B           # サイドバー表示切り替え
Ctrl/Cmd + J           # パネル表示切り替え
Ctrl/Cmd + `           # ターミナル表示切り替え

# エディタ管理
Ctrl/Cmd + \           # エディタを分割
Ctrl/Cmd + 1/2/3       # エディタグループ間移動
Ctrl/Cmd + Shift + E   # Explorer表示
Ctrl/Cmd + Shift + G   # Git表示

Web開発推奨拡張機能

コード品質・フォーマッター

Biome

{
  "biome.enabled": true,
  "editor.defaultFormatter": "biomejs.biome",
  "editor.formatOnSave": true
}
  • ESLint + Prettier の代替
  • 超高速なリンター・フォーマッター
  • Rust製で軽量

Git統合強化

GitLens

  • インラインブレーム表示
  • コミット履歴のリッチな可視化
  • ファイル履歴とHeatmap

HTTP・API開発

Thunder Client

  • VSCode統合APIクライアント
  • リクエスト履歴とコレクション管理
  • 環境変数サポート

AI支援開発

GitHub Copilot

{
  "github.copilot.enable": {
    "typescript": true,
    "typescriptreact": true,
    "javascript": true,
    "javascriptreact": true
  }
}
  • AIによるコード提案
  • コンテキストを理解したコード生成
  • ドキュメント生成支援

開発効率化

言語サポート

Pretty TypeScript Errors

  • TypeScriptのエラーメッセージを読みやすく整形
  • エラーの原因と解決策を視覚的に表示
  • 型エラーの理解を大幅に向上

TypeScriptのエラーメッセージは、初心者にとって理解しにくいことがあります。この拡張機能は、エラーメッセージを色分けし、構造化して表示することで、問題の把握と解決を容易にします。

特に複雑な型エラーや、ジェネリクスに関するエラーメッセージが読みやすくなり開発効率が向上します。

UI/UXサポート

Tailwind CSS IntelliSense

  • クラス名補完
  • カラープレビュー
  • CSS値のホバー表示

まとめ

ポイント

この章で学んだ重要なことをまとめておきますね。

  • VS Code: 無料で高機能なコードエディタ
  • 拡張機能: 必要な機能を自由に追加できる仕組み
  • IntelliSense: コード補完や型情報の表示機能
  • 統合ターミナル: エディタ内でコマンドを実行できる機能
  • デバッガー: コードの動作を詳細に確認できるツール

VS Codeを使うことで:

  • ✅ 効率的なコード編集ができる
  • ✅ 豊富な拡張機能で機能を拡張できる
  • ✅ 統合開発環境としてすべての作業を一箇所で完結できる
  • ✅ Gitとの連携でバージョン管理が簡単
  • ✅ AI支援でコード作成が効率化される

最初は設定や拡張機能の選択に迷うかもしれませんが、まずは基本的な機能から慣れていき、必要に応じて少しずつカスタマイズしていくのがおすすめです。VS Codeは開発者にとって強力な味方になってくれますよ!

Git・GitHub基礎

この章では、Web開発に欠かせないGitとGitHubについて一緒に学んでいきましょう。Gitは最初は少し難しく感じるかもしれませんが、慣れてしまえばとても便利なツールです。気楽に読み進めてくださいね。

学習目標

  • Gitの基本的な仕組みとバージョン管理について理解する
  • GitHubを使って自分のコードを管理できるようになる
  • ブランチを使った開発の流れを覚える
  • チームでの開発に必要なPull Requestの使い方を学ぶ

Gitって何?

バージョン管理システム(VCS)

みなさんは、大切な文書やファイルを編集するとき、「保存するまえに念のためコピーを作っておこう」と思ったことはありませんか?Gitはそのような「ファイルの履歴管理」を自動的にやってくれる便利なツールです。

Gitの特徴

Gitには他のツールにない素晴らしい特徴があります。

  • 分散型: チーム全員が完全な履歴を持つ(一人が消しても大丈夫!)
  • 高速: ほとんどの操作がサクサク動く
  • ブランチ: 並行開発がとても簡単
  • 非線形開発: 複数人での開発に最適化されている

基本的な仕組み

Gitには3つの重要な場所があります。最初は覚えにくいかもしれませんが、この図を頭に入れておくと後で理解が深まりますよ。

Gitの3つの領域

Working tree, staging area, and Git directory

画像: https://git-scm.com/book/en/v2/Getting-Started-What-is-Git%3F より引用

  1. Working Directory - 作業ディレクトリ。ファイルやフォルダーの実体があります。ここでファイルを編集します。
  2. Staging Area - コミットする前に変更内容を一時的にまとめておく (ステージング) するための領域です。git add することで変更内容が「ステージング」として扱われます。
  3. Repository - あらゆる変更履歴を保存しておく保管庫。git commit することで「ステージング」にある変更内容が「メッセージ」とともに「コミット」に移されます。.git ディレクトリ内のファイルによって管理されます。この .git ディレクトリを同期 (push/pull) することによって共同編集を可能にします。

Gitをインストールしてみよう

それでは、実際にGitをインストールして使ってみましょう!お使いのOSに合わせて進めてくださいね。

Windows (WSL) ・Linux の場合

# Ubuntu/Debian の場合
sudo apt update
sudo apt install git

# CentOS/RHEL の場合
sudo yum install git

Windows (ネイティブ) の場合

# Git for Windows のインストール
# https://gitforwindows.org/ からダウンロードして実行してください

# または Chocolatey を使う場合(上級者向け)
choco install git

# または winget を使う場合(Windows 10/11)
winget install Git.Git

macOSの場合

# Homebrew でインストール(推奨)
brew install git

# Xcode Command Line Tools でも可能
xcode-select --install

最初の設定をしよう

Gitをインストールしたら、必ず最初に自分の情報を設定しましょう。これをしないとコミットができませんからね。

# あなたの名前とメールアドレスを設定します
git config --global user.name "あなたの名前"
git config --global user.email "your.email@example.com"

# デフォルトブランチ名を設定(最近は main が一般的です)
git config --global init.defaultBranch main

# エディタの設定(VS Codeを使う場合)
git config --global core.editor "code --wait"

# 設定の確認
git config --list

Note: GitHubで使用するメールアドレスと同じものを設定することをおすすめします。

基本的なGitの操作を覚えよう

それでは、実際にGitを使って作業をしてみましょう。最初は一つずつゆっくりと進めていきますね。

1. はじめてのリポジトリを作ってみよう

# 新しいプロジェクト用のフォルダを作ります
mkdir my-project
cd my-project

# Gitリポジトリとして初期化(これでGit管理が開始されます)
git init

# 最初のファイルを作ってみましょう
echo "# My Project" > README.md
git add README.md
git commit -m "Initial commit"

これで最初のコミット(保存ポイント)ができました!

2. 日常的な作業の流れ

普段の開発では、この3つの操作を繰り返します。慣れてしまえば自然にできるようになりますよ。

# ファイルを編集した後...

git status          # 何が変更されたかチェック
git add .           # すべての変更をステージング(次のコミットに含める準備)
git commit -m "新しい機能を追加"  # コミット(保存ポイントを作成)

# 特定のファイルだけコミットしたい場合
git add src/index.js
git commit -m "index.jsを更新"

3. 履歴を確認してみよう

作業の履歴を見ることができます。これがGitの魅力の一つですね。

# コミットの履歴を見る
git log             # 詳細な履歴
git log --oneline   # 簡潔に一行で表示(見やすいです)
git log --graph     # ブランチの分岐を視覚的に表示

# 変更内容を詳しく確認
git diff            # まだコミットしていない変更内容
git diff --cached   # コミット予定の変更内容
git show HEAD       # 最新コミットの詳細

ブランチを使ってみよう

ブランチは、Gitの中でも特に便利な機能です。「元のコードを壊さずに新しい機能を試せる」と考えてください。

1. ブランチの基本操作

# 現在のブランチを確認
git branch          # ローカルのブランチ一覧
git branch -r       # リモートのブランチ一覧
git branch -a       # すべてのブランチ

# 新しいブランチを作って移動
git branch feature/new-feature     # ブランチを作成
git checkout feature/new-feature   # ブランチに移動

# 上記を一度にやる(便利です!)
git checkout -b feature/new-feature

# さらに新しいGitでは(2.23以降)
git switch -c feature/new-feature

2. ブランチ戦略について

チームで開発するときの基本的なパターンをご紹介しますね。

GitHub Flow(シンプルで推奨)

main        ─────●─────●─────●─────
             ↗        ↓ ↗        ↓
feature     ●─●─●──●─●   ●─●──●─●

このやり方は:

  • main ブランチは常に安定版
  • 機能追加は feature ブランチで行う
  • 完成したら main にマージ

Git Flow(複雑なプロジェクト向け)

main        ─────●─────●─────●─────
             ↗   ↓  ↗  ↓  ↗  ↓
develop   ─────●─────●─────●─────
             ↗     ↓ ↗     ↓
feature     ●───●───●   ●───●

初心者の方はまずGitHub Flowから始めることをおすすめします。

3. ブランチをまとめよう(マージとリベース)

機能ができたら、メインのブランチに統合する必要があります。2つの方法があります。

マージ(Merge): 2つのブランチを合体させる

# feature ブランチの作業を main に取り込む
git switch main
git merge feature/new-feature

# マージコミットを作らない場合(きれいな履歴になります)
git merge --ff-only feature/new-feature

リベース(Rebase): 履歴をきれいに整理する

# feature ブランチを main の最新状態に合わせる
git switch feature/new-feature
git rebase main

# 履歴を整理したい場合(上級者向け)
git rebase -i HEAD~3

最初はマージだけ覚えれば十分ですよ。

GitHubを使ってみよう

GitHubは、Gitで管理しているプロジェクトをクラウド上で保存・共有できるサービスです。GitHubがあることで、チームでの開発がとても簡単になります。

1. リモートリポジトリに接続してみよう

# GitHubでリポジトリを作成した後、以下のコマンドで接続します
git remote add origin https://github.com/ユーザー名/リポジトリ名.git
git push -u origin main

2. 基本的なGitHub操作

# 作業を同期する
git fetch origin       # リモートの最新情報を取得
git pull origin main   # main ブランチの最新を取得

# 自分の作業をアップロード
git push origin feature/new-feature

# 強制的にプッシュ(履歴を書き換えた場合など、注意が必要です)
git push --force-with-lease origin feature/new-feature

HTTPS vs SSH: GitHubとの接続方法は2つあります

  • HTTPS: https://github.com/ユーザー名/リポジトリ名.git(ファイアウォールやプロキシの内側にいる場合でもアクセス可能)
  • SSH: git@github.com:ユーザー名/リポジトリ名.git(GitHub CLIを使わずに設定可能)

3. GitHub CLI を使う方法 (おすすめ)

GitHub CLI を使うことでWebブラウザーを使ってより安全にHTTPSでアクセスすることができます。

Windows (WSL)・Linux でのインストール方法:

# Ubuntu/Debian の場合
sudo apt update
sudo apt install gh
gh auth login

詳しくは「gh auth login」をご覧ください。

別の方法: SSH鍵の設定

SSH鍵を設定することでGitHub CLIを使わずに設定することも可能です。

# SSH鍵を生成(初回のみ)
ssh-keygen -t ed25519

# 生成された公開鍵をGitHubに登録
# ~/.ssh/id_ed25519.pub の内容をコピーして
# GitHubの Settings > SSH and GPG keys で登録

# 接続テスト
ssh -T git@github.com

Pull Requestを使ってみよう

Pull Request(PR)は、GitHubでチーム開発をする際の基本的な仕組みです。「この変更をレビューしてもらって、問題なければメインブランチに取り込んでください」という意味ですね。

1. Pull Requestの基本的な流れ

# 1. 新しい機能用のブランチを作成
git switch -c fix/issue-123

# 2. 機能を実装してテスト
echo "新機能実装" >> src/feature.js
git add .
git commit -m "Fix #123: 新しい機能を追加"

# 3. GitHubにプッシュ
git push origin fix/issue-123
  1. GitHubのWebサイトでPull Requestを作成
  2. チームメンバーがレビュー
  3. 問題なければ main ブランチにマージ

3. リモートにプッシュ

git push origin fix/issue-123

4. GitHub でPull Request作成


### 2. PRのベストプラクティス

**良いPRの例:**
```markdown
## 概要
ユーザー認証機能を追加しました。

## 変更内容
- [ ] ログイン画面の実装
- [ ] JWT トークンの実装
- [ ] パスワードハッシュ化
- [ ] 単体テストの追加

## 確認方法
1. `npm run dev` で開発サーバー起動
2. `http://localhost:3000/login` にアクセス
3. テストユーザーでログイン確認

## 関連Issue
Fixes #123

3. コードレビューのポイント

# レビュー前のセルフチェック
git diff main...HEAD --name-only  # 変更ファイル一覧
git diff main...HEAD              # 変更内容の確認

# コミット履歴の整理
git rebase -i main  # squash、fixup等で整理

実践的なGitワークフロー

1. チーム開発での標準フロー

# 1. 最新のmainを取得
git checkout main
git pull origin main

# 2. 機能ブランチ作成
git checkout -b feature/user-profile

# 3. 開発・コミット
git add .
git commit -m "Add user profile component"

# 4. 定期的にmainをマージ(競合回避)
git fetch origin
git rebase origin/main

# 5. PR作成前の最終チェック
git log --oneline main..HEAD  # 追加したコミット確認

# 6. プッシュとPR作成
git push origin feature/user-profile

2. 緊急修正(Hotfix)フロー

# production 環境の緊急修正
git checkout main
git pull origin main
git checkout -b hotfix/security-fix

# 修正・テスト
git add .
git commit -m "Fix security vulnerability"

# 即座にマージ・デプロイ
git checkout main
git merge hotfix/security-fix
git push origin main
git tag v1.2.1  # タグ付け
git push origin v1.2.1

高度なGit操作

1. 履歴の修正

# 最後のコミットメッセージを修正
git commit --amend -m "正しいメッセージ"

# 過去のコミットを修正(Interactive Rebase)
git rebase -i HEAD~3
# pick → edit でコミット選択し、修正後
git commit --amend
git rebase --continue

2. 変更の取り消し

# 作業ディレクトリの変更を破棄
git checkout -- filename.js
git restore filename.js  # 新しいコマンド

# ステージングを取り消し
git reset HEAD filename.js
git restore --staged filename.js  # 新しいコマンド

# コミットを取り消し
git reset --soft HEAD~1  # コミットのみ取り消し
git reset --hard HEAD~1  # すべて取り消し(危険)

3. 作業の一時保存

# 作業を一時保存
git stash push -m "作業中の変更"
git stash

# 一時保存した作業を復元
git stash pop
git stash apply stash@{0}

# 一時保存の確認
git stash list
git stash show stash@{0}

.gitignoreの活用

基本的な.gitignore

# 依存関係
node_modules/
venv/
env/

# ビルド成果物
dist/
build/
*.min.js

# ログファイル
*.log
logs/

# OS固有
.DS_Store
Thumbs.db

# IDE固有
.vscode/
.idea/
*.swp

# 環境設定
.env
.env.local

プロジェクト別例

React プロジェクト

node_modules/
build/
.env
npm-debug.log
.DS_Store

Python プロジェクト

__pycache__/
*.py[cod]
venv/
.env
.pytest_cache/

トラブルシューティング

よくある問題と解決方法

開発中によく遭遇する問題と、その解決方法をご紹介しますね。

1. マージコンフリクト(競合)が起きた場合

# マージで競合が発生したとき
git status  # どのファイルで競合しているかチェック

# ファイルを手動で編集して競合を解決後
git add conflicted-file.js
git commit -m "競合を解決"

2. 間違ったブランチで作業してしまった場合

# 現在の変更を一時的に保存
git stash

# 正しいブランチに移動
git checkout correct-branch
git stash pop  # 保存した変更を復元

3. プッシュできない場合

# リモートの最新情報を取得して統合
git fetch origin
git rebase origin/main

# または、マージで統合する場合
git pull --rebase origin main

セキュリティについて

機密情報の管理

大切なパスワードやAPIキーなどは、絶対にGitにコミットしないように注意しましょう。

# .env ファイルの例(機密情報を保存)
DB_PASSWORD=secret123
API_KEY=abcdef123456

# .gitignore に追加して、Gitが無視するように設定
echo ".env" >> .gitignore

ポイント

この章で学んだ重要なことをまとめておきますね。

  • Git: ファイルの変更履歴を自動で管理してくれる便利なツール
  • リポジトリ: プロジェクトの全履歴が保存される場所
  • コミット: 作業の区切りとなる保存ポイント
  • ブランチ: 元のコードを壊さずに新機能を開発できる仕組み
  • GitHub: Gitで管理しているプロジェクトをクラウドで共有・管理できるサービス
  • Pull Request: チームでのコードレビューと統合の仕組み

Git・GitHubを使うことで:

  • ✅ コードの変更履歴を完全に追跡できる
  • ✅ チーム開発での効率的な作業分担ができる
  • ✅ 分散型による自然なバックアップが作られる
  • ✅ Pull Requestでコードの品質を維持できる

最初は覚えることが多くて大変かもしれませんが、慣れてしまえばとても便利なツールです。実際に手を動かしながら、少しずつ覚えていきましょう!

React環境構築

この章では、現代のWebアプリ開発に欠かせない「React」の環境構築について一緒に学んでいきましょう。Reactは最初は少し難しく感じるかもしれませんが、コンポーネントという考え方に慣れてしまえば、とても効率的にWebアプリが作れるようになりますよ。

学習目標

  • Reactの基本的な考え方とコンポーネントについて理解する
  • Viteを使った最新のReact開発環境を作る
  • シンプルなTodoアプリを作りながらReactに慣れる
  • ビルドとデプロイの基本を覚える

Reactって何?

基本的な概念

React は、Meta(旧Facebook)が開発したユーザーインターフェース(UI)を作るためのJavaScriptライブラリです。「部品(コンポーネント)を組み合わせてWebページを作る」という考え方が特徴です。

Reactの魅力

  • コンポーネントベース: UIを再利用できる部品として作成
  • 宣言的なUI: 「どう表示するか」ではなく「何を表示するか」を記述
  • 仮想DOM: 画面更新が高速で効率的
  • 単方向データフロー: データの流れが分かりやすい

Reactの基本的な概念

Reactには押さえておくべき重要な概念がいくつかあります。ここでは最低限必要なものだけ紹介します。

Note: より詳しい内容はReact公式ドキュメント(日本語)で学べます。

1. コンポーネント

コンポーネントは、UIの一部分を担当する再利用可能な部品です。

// 関数コンポーネント(現在の主流)
const Welcome = ({ name }) => {
  return <h1>こんにちは、{name}さん!</h1>;
};

「コンポーネント」という名前が難しそうに聞こえるかもしれませんが、入力(Props)を受け取り、出力として見た目(JSX)を返すただのJavaScriptの関数です。 Reactではこれを <Welcome name="太郎" /> のように使うことができ、画面上に「こんにちは、太郎さん!」と表示されます。

2. Props(プロップス)

親コンポーネントから子コンポーネントへデータを渡す仕組みです。

// Props - 親から子への値の渡し方
function Button({ text, onClick }) {
  return <button onClick={onClick}>{text}</button>;
}

ただの関数の引数です。JavaScriptの関数なので文字列だけでなく数値や関数、オブジェクトなどあらゆるものを渡すことができます。

3. State(状態)

コンポーネントが持つ内部データです。useStateを使って管理します。

// State - コンポーネントの内部状態
import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

Viteによるプロジェクト作成

1. Viteとは

Vite(ヴィート)は、高速で軽量なビルドツール・開発サーバーです。

特徴:

  • 高速: ES modules とネイティブESMを活用
  • 🔥 HMR: Hot Module Replacement
  • 📦 最適化: Rollup ベースのプロダクションビルド
  • 🔧 設定不要: ゼロコンフィグで開始可能

2. プロジェクト作成

# Viteでプロジェクト作成
pnpm create vite my-react-app --template react-ts

# プロジェクトに移動
cd my-react-app

# 依存関係インストール
pnpm install

# 開発サーバー起動
pnpm run dev

3. プロジェクト構造

my-react-app/
├── public/           # 静的ファイル
│   └── vite.svg
├── src/             # ソースコード
│   ├── assets/      # アセット(画像、CSS等)
│   ├── components/  # コンポーネント
│   ├── hooks/       # カスタムフック
│   ├── types/       # TypeScript型定義
│   ├── App.tsx      # メインアプリコンポーネント
│   ├── main.tsx     # エントリーポイント
│   └── index.css    # グローバルCSS
├── index.html       # HTMLテンプレート
├── package.json     # 依存関係・スクリプト
├── tsconfig.json    # TypeScript設定
└── vite.config.ts   # Vite設定

TypeScript設定

Viteで作成されたプロジェクトには、既に最適な設定が含まれています。パスエイリアスを使いたい場合は以下を追加します。

// tsconfig.json
{
  "extends": "@tsconfig/vite-react/tsconfig.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src"]
}
# ベース設定のインストール
pnpm add -D @tsconfig/vite-react

Note Viteはtsconfig.jsonのpaths設定を自動的に認識するため、vite.config.tsでの追加設定は不要です。

基本的なReactアプリケーション構築

シンプルなTodoアプリ

型定義

// src/types/todo.ts
export interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

メインアプリケーション

// src/App.tsx
import { useState } from "react";
import type { Todo } from "@/types/todo";

function App() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [input, setInput] = useState("");

  const addTodo = () => {
    if (input.trim()) {
      setTodos([...todos, { id: Date.now(), text: input, completed: false }]);
      setInput("");
    }
  };

  const toggleTodo = (id: number) => {
    setTodos(
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo,
      ),
    );
  };

  return (
    <div className="app">
      <h1>Todo App</h1>
      <div>
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Add a new todo..."
        />
        <button onClick={addTodo}>Add</button>
      </div>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id} onClick={() => toggleTodo(todo.id)}>
            <span
              style={{
                textDecoration: todo.completed ? "line-through" : "none",
              }}
            >
              {todo.text}
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

Note 実際のアプリでは、コンポーネントを分割して再利用性を高めます。上記は学習用の最小構成です。

React Hooks の基本

Reactには「Hooks」という機能があります。最もよく使う2つを紹介します。

useState - 状態管理

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

useEffect - 副作用処理

import { useState, useEffect } from "react";

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then(setUser);
  }, [userId]); // userIdが変わったら再実行

  return <div>{user?.name}</div>;
}

Note より詳しくはReact公式ドキュメントを参照してください。

ビルドとデプロイ

プロダクションビルド

pnpm run build      # ビルド実行
pnpm run preview    # ビルド結果の確認

環境変数

# .env.local
VITE_API_BASE_URL=http://localhost:3001/api
// 使用例
const apiUrl = import.meta.env.VITE_API_BASE_URL;

Vercel デプロイ (任意)

npm i -g vercel
vercel --prod

Note VercelはViteプロジェクトを自動検出するため、設定ファイルは通常不要です。

トラブルシューティング

ホットリロードが効かない

# 開発サーバーを再起動
Ctrl+C
pnpm run dev

ビルドエラー

# 依存関係を再インストール
rm -rf node_modules pnpm-lock.yaml
pnpm install

参考リンク

まとめ

この章では、React環境構築の基本を学びました。

ポイント

  • Vite: 高速な開発サーバーとビルドツール
  • コンポーネント: UIを部品として作る考え方
  • Props: 親から子へデータを渡す仕組み
  • State: コンポーネントが持つ内部データ
  • Hooks: useStateuseEffectが基本

Reactは最初は難しく感じるかもしれませんが、コンポーネントを作りながら慣れていくことが一番の近道です。まずは小さなアプリから始めて、少しずつ機能を追加していきましょう!

TypeScript導入

この章では、JavaScriptに型の安全性を追加してくれる「TypeScript」について一緒に学んでいきましょう。TypeScriptは最初は少し複雑に感じるかもしれませんが、慣れてくるとバグが格段に減って、より安心してコードが書けるようになりますよ。

学習目標

  • TypeScriptの基本的な考え方と型システムを理解する
  • JavaScriptプロジェクトにTypeScriptを導入する方法を学ぶ
  • 型を使ってバグを予防する方法を覚える
  • 最新のTypeScript開発環境を構築する

TypeScriptって何?

基本的な概念

TypeScriptは、Microsoftが開発したJavaScriptの「型付き版」です。普通のJavaScriptに「型」という概念を追加することで、コードをより安全に、そして書きやすくしてくれます。

TypeScriptの魅力

  • 早期エラー発見: コードを書いている段階でバグを発見できる
  • 開発体験の向上: 自動補完やリファクタリング機能が充実
  • コードがドキュメントになる: 型が仕様書の役割を果たす
  • 大規模開発に強い: チーム開発でも安心してコードが書ける
  • JavaScript互換: 既存のJavaScriptコードをそのまま使える

JavaScriptとの違い

項目JavaScriptTypeScript
型システム動的(実行時に決まる)静的(事前に決める)
エラー発見実行してみないと分からない書いている時点で分かる
開発支援基本的なものとても充実
学習コスト低い少し高い
ビルド工程そのまま実行可能コンパイルが必要

最初は少し大変かもしれませんが、慣れてしまえばJavaScriptには戻れなくなるほど便利ですよ。

TypeScriptの基本的な書き方

1. 基本的な型

プリミティブ型

// 基本型
let message: string = "Hello TypeScript";
let count: number = 42;
let isActive: boolean = true;
let data: null = null;
let value: undefined = undefined;

// 型推論(推奨)
let name = "Alice"; // string 型として推論
let age = 30; // number 型として推論
let isStudent = false; // boolean 型として推論

配列とオブジェクト

// 配列
let numbers: number[] = [1, 2, 3, 4, 5];
let names: Array<string> = ["Alice", "Bob", "Charlie"];

// オブジェクト
let person: {
  name: string;
  age: number;
  isStudent?: boolean; // オプショナルプロパティ
} = {
  name: "Alice",
  age: 30,
};

// より複雑なオブジェクト
let config: {
  apiUrl: string;
  timeout: number;
  features: {
    auth: boolean;
    cache: boolean;
  };
} = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  features: {
    auth: true,
    cache: false,
  },
};

2. インターフェースと型エイリアス

インターフェース定義

// User インターフェース
interface User {
  readonly id: number; // 読み取り専用
  name: string;
  email: string;
  age?: number; // オプショナル
}

// インターフェースの使用
const createUser = (userData: User): User => {
  return {
    id: Date.now(),
    ...userData,
  };
};

const user: User = {
  id: 1,
  name: "Alice",
  email: "alice@example.com",
};

型エイリアス

// 基本的な型エイリアス
type UserId = number;
type UserRole = "admin" | "user" | "guest";

// 複雑な型
type APIResponse<T> = {
  data: T;
  status: "success" | "error";
  message?: string;
};

// 使用例
type UserResponse = APIResponse<User>;

const fetchUser = async (id: UserId): Promise<UserResponse> => {
  // API呼び出しロジック
  return {
    data: { id, name: "Alice", email: "alice@example.com" },
    status: "success",
  };
};

3. 関数の型定義

関数シグネチャ

// 基本的な関数
function add(a: number, b: number): number {
  return a + b;
}

// アロー関数
const multiply = (a: number, b: number): number => a * b;

// オプション引数とデフォルト値
const greet = (name: string, title?: string, prefix = "Mr."): string => {
  return `Hello, ${title || prefix} ${name}`;
};

// 可変長引数
const sum = (...numbers: number[]): number => {
  return numbers.reduce((total, num) => total + num, 0);
};

高階関数の型定義

// コールバック関数の型
type EventHandler<T> = (event: T) => void;
type Transformer<T, U> = (input: T) => U;

// 使用例
const handleClick: EventHandler<MouseEvent> = (event) => {
  console.log("Clicked at:", event.clientX, event.clientY);
};

const doubleNumbers: Transformer<number[], number[]> = (numbers) => {
  return numbers.map((n) => n * 2);
};

4. ジェネリクス

基本的なジェネリクス

// ジェネリック関数
function identity<T>(arg: T): T {
  return arg;
}

// 使用例
const stringValue = identity("hello"); // string型
const numberValue = identity(42); // number型

// 複数の型パラメータ
function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}

const nameAge = pair("Alice", 30); // [string, number]

ジェネリクス制約

// インターフェース制約
interface Lengthwise {
  length: number;
}

function logLength<T extends Lengthwise>(arg: T): T {
  console.log(arg.length);
  return arg;
}

logLength("hello"); // OK
logLength([1, 2, 3]); // OK
// logLength(42)          // Error: number に length プロパティはない

5. ユニオン型とインターセクション型

ユニオン型(複数の型のうち一つ)

type Status = "loading" | "success" | "error";
type StringOrNumber = string | number;

// 判別可能なユニオン
interface LoadingState {
  status: "loading";
}
interface SuccessState {
  status: "success";
  data: any;
}
interface ErrorState {
  status: "error";
  error: string;
}

type AppState = LoadingState | SuccessState | ErrorState;

const handleState = (state: AppState) => {
  switch (state.status) {
    case "loading":
      console.log("Loading...");
      break;
    case "success":
      console.log("Data:", state.data);
      break;
    case "error":
      console.log("Error:", state.error);
      break;
  }
};

インターセクション型(複数の型を結合)

type Person = { name: string } & { age: number };

const person: Person = { name: "Alice", age: 30 };

プロジェクトへのTypeScript導入

1. 新規プロジェクトでのセットアップ

Vite + React + TypeScript

# プロジェクト作成
pnpm create vite my-ts-app --template react-ts
cd my-ts-app
pnpm install

# 開発サーバー起動
pnpm run dev

2. 既存JavaScriptプロジェクトの移行

# TypeScriptの追加
pnpm add -D typescript @types/node

# tsconfig.json作成
npx tsc --init

移行の流れ

  1. .js.ts ファイル名変更
  2. 型注釈を段階的に追加
// Before (JavaScript)
function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price, 0);
}

// After (TypeScript)
interface Item {
  price: number;
  name: string;
}

function calculateTotal(items: Item[]): number {
  return items.reduce((sum, item) => sum + item.price, 0);
}

3. tsconfig.json の設定

React プロジェクト向け設定

# 推奨ベース設定のインストール
pnpm add -D @tsconfig/vite-react
{
  "extends": "@tsconfig/vite-react/tsconfig.json",
  "compilerOptions": {
    // パスエイリアス設定
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src"]
}

Note > @tsconfig/vite-react は Vite + React に最適化された設定を提供します。必要に応じて compilerOptions で上書き可能です。

React + TypeScript実践

1. コンポーネントの型定義

基本的なコンポーネント

import { ReactNode } from "react";

// Props インターフェース
interface ButtonProps {
  children: ReactNode;
  variant?: "primary" | "secondary";
  size?: "small" | "medium" | "large";
  disabled?: boolean;
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
}

// コンポーネント定義
export const Button = ({
  children,
  variant = "primary",
  size = "medium",
  disabled = false,
  onClick,
}: ButtonProps) => {
  return (
    <button
      className={`btn btn--${variant} btn--${size}`}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  );
};

// 使用例
const App = () => {
  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    console.log("Clicked!", event.currentTarget);
  };

  return (
    <Button variant="primary" onClick={handleClick}>
      Click me
    </Button>
  );
};

フォームコンポーネント

import { useState, FormEvent, ChangeEvent } from "react";

interface LoginFormData {
  email: string;
  password: string;
}

interface LoginFormProps {
  onSubmit: (data: LoginFormData) => Promise<void>;
}

export const LoginForm = ({ onSubmit }: LoginFormProps) => {
  const [formData, setFormData] = useState<LoginFormData>({
    email: "",
    password: "",
  });

  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
  };

  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setIsSubmitting(true);

    try {
      await onSubmit(formData);
    } catch (error) {
      console.error("Login failed:", error);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        name="email"
        value={formData.email}
        onChange={handleChange}
        placeholder="Email"
        required
      />
      <input
        type="password"
        name="password"
        value={formData.password}
        onChange={handleChange}
        placeholder="Password"
        required
      />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Logging in..." : "Login"}
      </button>
    </form>
  );
};

2. カスタムHooksの型定義

useLocalStorage Hook

import { useState } from "react";

type SetValue<T> = T | ((val: T) => T);

function useLocalStorage<T>(
  key: string,
  initialValue: T,
): [T, (value: SetValue<T>) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = (value: SetValue<T>) => {
    try {
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(`Error setting localStorage:`, error);
    }
  };

  return [storedValue, setValue];
}

// 使用例
const UserSettings = () => {
  const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");

  return (
    <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
      Toggle theme: {theme}
    </button>
  );
};

useApi Hook

import { useState, useEffect } from "react";

interface UseApiResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => void;
}

function useApi<T>(url: string): UseApiResult<T> {
  const [state, setState] = useState<{
    data: T | null;
    loading: boolean;
    error: Error | null;
  }>({ data: null, loading: true, error: null });

  const fetchData = async () => {
    try {
      setState((prev) => ({ ...prev, loading: true, error: null }));
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const data: T = await response.json();
      setState({ data, loading: false, error: null });
    } catch (error) {
      setState({
        data: null,
        loading: false,
        error: error instanceof Error ? error : new Error("Unknown error"),
      });
    }
  };

  useEffect(() => {
    fetchData();
  }, [url]);

  return { ...state, refetch: fetchData };
}

型定義ファイルの管理

プロジェクト構造

src/
├── types/
│   ├── index.ts    # 再エクスポート
│   ├── user.ts     # ユーザー型
│   └── api.ts      # API型
├── components/
└── hooks/

型定義の例

// src/types/user.ts
export interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "user" | "guest";
}

export interface CreateUserRequest {
  name: string;
  email: string;
  password: string;
}
// src/types/api.ts
export interface ApiResponse<T> {
  data: T;
  status: "success" | "error";
  message?: string;
}
// src/types/index.ts
export * from "./user";
export * from "./api";

型ガードと型の絞り込み

型ガード関数

export function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    "email" in value
  );
}

// 使用例
const processUserData = (data: unknown) => {
  if (isUser(data)) {
    console.log(`User: ${data.name}`);
  }
};

高度なTypeScript機能

ユーティリティ型

よく使う組み込み型

interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}

// Partial - すべてオプショナル
type PartialUser = Partial<User>;

// Pick - 特定のプロパティのみ
type PublicUser = Pick<User, "id" | "name" | "email">;

// Omit - 特定のプロパティを除外
type UserWithoutPassword = Omit<User, "password">;

// Required - すべて必須
type RequiredUser = Required<Partial<User>>;

条件型とマップ型

// 条件型
type NonNullable<T> = T extends null | undefined ? never : T;

// マップ型
type ReadOnly<T> = {
  readonly [K in keyof T]: T[K];
};

type PickByType<T, U> = {
  [K in keyof T as T[K] extends U ? K : never]: T[K];
};

トラブルシューティング

よくある問題と解決法

  1. any型の乱用

    // ❌ 悪い例
    const fetchData = (): any => {
      // ...
    };
    
    // ✅ 良い例
    interface ApiResponse<T> {
      data: T;
      status: number;
    }
    
    const fetchData = <T,>(): Promise<ApiResponse<T>> => {
      // ...
    };
    
  2. null/undefined エラー

    // ❌ 危険
    const getUserName = (user: User | null) => {
      return user.name; // user が null の可能性
    };
    
    // ✅ 安全
    const getUserName = (user: User | null): string | null => {
      return user?.name ?? null;
    };
    
  3. 型アサーションの過度な使用

    // ❌ 危険
    const data = response as User;
    
    // ✅ 安全
    const isUser = (data: unknown): data is User => {
      // 型ガード実装
    };
    
    if (isUser(data)) {
      // data は User 型として使用可能
    }
    

まとめ

TypeScriptの利点:

  • 型安全性: コンパイル時のエラー検出でバグ予防
  • 開発効率: IntelliSense・リファクタリング支援
  • 可読性: 型情報がドキュメントとして機能
  • スケーラビリティ: 大規模開発での保守性向上

導入のベストプラクティス:

  1. 段階的な導入で学習コストを分散
  2. strict モードで厳密な型チェック
  3. 適切な型定義ファイル管理
  4. ユーティリティ型の活用でDRY原則

Biomeによるコード品質管理

この章では、コードの品質を自動的にチェック・整形してくれる「Biome」について一緒に学んでいきましょう。Biomeは比較的新しいツールですが、従来のESLintやPrettierよりもずっと高速で、設定も簡単です。使い始めると、コードがとてもきれいに保たれるようになりますよ。

学習目標

  • Biomeを使ったコードチェックと整形方法を覚える
  • ESLintやPrettierの代わりとしてBiomeを使う
  • コードの品質を自動で保つ仕組みを理解する
  • エディタとの連携やチーム開発での活用方法を学ぶ

Biomeって何?

基本的な概念

Biomeは、JavaScript、TypeScript、JSON、CSSのコードを「きれいに」「正しく」してくれるツールです。従来は複数のツールを組み合わせて使っていた機能を、一つのツールで提供してくれます。

Biomeの魅力

  • とても高速: Rustという言語で作られているので、従来ツールより10倍以上速い
  • オールインワン: リンティング・フォーマット・import整理を一つで
  • 設定不要: デフォルトの設定ですぐに使える
  • エディタ連携: VS Codeでリアルタイムにチェックしてくれる
  • 簡単導入: 既存のプロジェクトにも簡単に追加できる

従来のツールとの比較

今まではいくつかのツールを組み合わせる必要がありました:

機能ESLintPrettierBiome
コードチェック
見た目整形一部
処理速度普通普通とても速い
設定の難しさ複雑簡単とても簡単
プラグイン依存多い少ないなし

Biome一つで全部できるので、覚えることが少なくて済みますね。

Biomeをインストールしよう

1. プロジェクトへのインストール

npm install --save-dev @biomejs/biome

2. 初期設定

# Biome設定ファイルの生成
npx @biomejs/biome init

3. package.jsonスクリプト設定

{
  "scripts": {
    "lint": "biome lint ./src",
    "lint:fix": "biome lint --write ./src",
    "format": "biome format ./src",
    "format:fix": "biome format --write ./src",
    "check": "biome check ./src",
    "check:fix": "biome check --write ./src"
  }
}

コマンドラインでの実行

リンティング

# リンティングチェック
pnpm run lint

# 自動修正付きリンティング
pnpm run lint:fix

# 特定ファイルのリンティング
pnpm exec biome lint src/App.tsx

フォーマッティング

# フォーマットチェック
pnpm run format

# フォーマット適用
pnpm run format:fix

# 特定ファイルのフォーマット
pnpm exec biome format --write src/App.tsx

統合コマンド(推奨)

# リンティング・フォーマット・import整理を一括実行
pnpm run check:fix

# ドライラン(何が変更されるかプレビュー)
pnpm run check

2. 基本的な設定例

基本的なbiome.json

{
  "$schema": "https://biomejs.dev/schemas/2.0.5/schema.json",
  "formatter": {
    "enabled": true
  },
  "linter": {
    "enabled": true
  },
  "assist": {
    "enabled": true
  }
}

エディタ統合

VS Code 設定

拡張機能のインストール

# VS Code拡張機能
code --install-extension biomejs.biome

settings.json

{
  // Biome を優先フォーマッタに設定
  "editor.defaultFormatter": "biomejs.biome",

  // 保存時にフォーマット適用
  "editor.formatOnSave": true,

  // 保存時にコード修正
  "editor.codeActionsOnSave": {
    "quickfix.biome": "explicit",
    "source.organizeImports.biome": "explicit"
  },

  // 他のフォーマッタを無効化
  "prettier.enable": false,
  "eslint.enable": false
}

トラブルシューティング

よくある問題と解決法

特定ルールの無効化

// ファイル全体で無効化
// @biome-ignore lint/suspicious/noExplicitAny: legacy code

// 行単位で無効化
const data: any = getValue(); // @biome-ignore lint/suspicious/noExplicitAny: external API

フォーマット結果が期待と違う

{
  "formatter": {
    "indentStyle": "space",
    "indentSize": 2,
    "lineWidth": 100,
    "ignore": ["**/*.generated.ts"]
  }
}

まとめ

この章で学んだポイント:

  • Biomeの基本: リンティング・フォーマット・import整理を一つのツールで可能
  • インストールと設定: npm install --save-dev @biomejs/biomenpx @biomejs/biome init で簡単導入
  • VS Code連携: 保存時に自動でコード品質チェック・整形
  • 実践的な使い方: pnpm run check:fix でコード品質を自動改善

Biomeを使うことで、コードの品質を保ちながら、フォーマットやリンティングの設定に悩む時間を減らせます。まずはVS Codeの拡張機能をインストールして、保存時の自動フォーマットから試してみましょう!

AI支援ツール活用法

AI支援ツールは、コーディング作業を支援してくれる強力なツールです。コード補完、リファクタリングの提案、バグ修正、ドキュメント生成など、様々な場面で活躍します。 ここでは主要なAI支援ツールの導入方法と使い方を学びましょう。

AI支援ツールの導入

# Claude Code
npm i -g @anthropic-ai/claude-code
# インストール後 claude コマンドで起動

# Gemini CLI
npm i -g @google/gemini-cli
# インストール後 gemini コマンドで起動

# Codex
npm i -g @openai/codex
# インストール後 codex コマンドで起動

# GitHub Copilot CLI
npm i -g @github/copilot
# インストール後 copilot コマンドで起動

学習リソース

プロジェクトでの活用例

実際のプロジェクトで活用する場合の例:

# プロジェクトディレクトリで起動
cd your-project
claude

# 例: HTMLファイルの生成
> 簡単なHTMLファイルを作成して

# 例: ファイルの検索
> このディレクトリにあるJavaScriptファイルを一覧表示して

# 例: 依存関係の説明
> package.jsonを読んで、使用している依存関係を説明して

# 例: コンポーネントの生成
> Reactのユーザープロフィールコンポーネントを作成して
> 名前、メールアドレス、アバター画像を表示する機能が欲しい

# 例: バグ修正の支援
> src/utils/formatDate.jsにバグがあるみたい。確認して修正して

# 例: リファクタリング
> このファイルのコードをTypeScriptに変換して、型定義も追加して

指示に応じて適切なファイル操作やコード生成を行ってくれます。

注意点

AI支援ツールは強力ですが、いくつか注意すべき点があります:

セキュリティ

  • 機密情報を含めない: APIキーやパスワードなどの機密情報をプロンプトに含めないようにしましょう
  • 依存関係の確認: 生成されたコードが安全なライブラリやフレームワークを使用しているか確認しましょう

効果的な使い方

  • 具体的な指示: 曖昧な指示より具体的な指示の方が良い結果が得られます
  • コンテキストの提供: 関連するファイルや要件を明示することで、より適切な提案が得られます

コードの品質管理

  • 生成コードのレビュー: AIが生成したコードは必ず自分で確認しましょう
  • テストの実施: 生成されたコードが期待通り動作するかテストしましょう

ポイント

  • AI支援ツールを使う際は、セキュリティコード品質に注意が必要
  • 具体的な指示コンテキスト提供で、より良い結果が得られる
  • 生成されたコードは必ずレビューとテストを行う

基本的な開発の流れ

この章では、Web開発の基本的な流れについて学びましょう。最初は工程が多く感じるかもしれませんが、一つずつ理解していけば、効率的で安全な開発ができるようになります。

学習目標

  • モダンなWeb開発の作業の流れを理解する
  • コードを書いてから公開するまでの工程を知る
  • チームで効率的に開発する方法を学ぶ

現代のソフトウェア開発

  • 変化の加速: 技術革新や市場の需要は急速に変化しており予測可能性は低下
  • エコシステムの複雑化: マイクロサービス、クラウド、モバイル、IoTなど、多様な技術が絡み合うことで、システム全体の複雑さは増加
  • 利用者中心の要求: ユーザーエクスペリエンスの重要性が高まり、迅速なフィードバックループを通じた継続的な改善が求められるように

開発の全体像

前提

これから紹介する一連の開発の流れはあくまで一例です。 実践の方法は現場によって異なります。そして最も大切な点はまず現実の問題に向き合い然るべき価値を提供するということです。

開発から公開までの流れ

開発者がやること

  1. 計画: どんな機能を作るか考える
  2. 開発: 実際にコードを書く
  3. テスト: 正しく動くか確認する
  4. レビュー: 他の人にコードを見てもらう
  5. 公開: ユーザーが使えるようにする

1. 計画:何を作るか決める

Issue(課題)を作る

GitHubで「これを作りたい」「これを修正したい」という課題(Issue)を作ります。

## やりたいこと

ユーザープロフィール画面を追加したい

## 詳細

- ユーザー名を表示
- アイコン画像を表示
- 自己紹介文を表示

## 確認事項

- [ ] デザインは決まっているか
- [ ] データはどこから取得するか
- [ ] 編集機能は必要か

タスクを分解する

大きな機能は小さなタスクに分けると、進めやすくなります。

プロフィール画面の作成
  ├─ プロフィールページのUI作成
  ├─ ユーザーデータ取得API連携
  ├─ 画像アップロード機能
  └─ プロフィール編集機能

2. 開発:コードを書く

ブランチを作る

まず、作業用のブランチを作ります。

# mainブランチから最新のコードを取得
git checkout main
git pull origin main

# 新しいブランチを作成
git checkout -b feature/user-profile

コードを書く

開発を進めていきます。

# 開発サーバーを起動
pnpm run dev

# ブラウザで確認しながらコードを書く
# http://localhost:5173

こまめに保存(コミット)する

# 変更したファイルを確認
git status

# 変更を追加
git add src/pages/Profile.tsx

# コミット(保存)
git commit -m "feat: add user profile page UI"

コミットメッセージの書き方:

feat: 新機能の追加
fix: バグ修正
docs: ドキュメント更新
style: コードの見た目の修正(動作は変わらない)
refactor: コードの整理
test: テストの追加・修正

3. テスト:動作確認する

手動テスト

実際にブラウザで動かして確認します。

# 開発サーバーで確認
pnpm run dev

# 本番環境に近い状態で確認
pnpm run build
pnpm run preview

確認項目:

  • ✅ 画面が正しく表示されるか
  • ✅ ボタンを押したら期待通りの動作をするか
  • ✅ エラーが出ていないか
  • ✅ スマホでも正しく表示されるか

自動テスト(慣れてきたら)

コードが正しく動くか、自動でチェックできます。

# テストを実行
pnpm run test

# 型チェック
pnpm run type-check

# コード品質チェック
pnpm run check

4. レビュー:見てもらう

GitHubにプッシュする

# リモートにプッシュ
git push origin feature/user-profile

Pull Requestを作る

GitHubでPull Request(PR)を作成します。

## 変更内容

ユーザープロフィール画面を追加しました

## スクリーンショット

(画面のスクリーンショットを貼る)

## 確認方法

1. ログインする
2. 右上のアイコンをクリック
3. 「プロフィール」をクリック

## チェックリスト

- [x] 動作確認済み
- [x] テスト追加済み
- [x] ドキュメント更新済み

レビューを受ける

チームメンバーがコードを確認してくれます。

よくあるレビューコメント:

  • 「この部分、もっとシンプルに書けそう」
  • 「エラー処理を追加した方がいいかも」
  • 「変数名をもっとわかりやすくしよう」

修正する

レビューでの指摘を修正します。

# コードを修正

# 修正をコミット
git add .
git commit -m "fix: improve error handling"

# プッシュ
git push origin feature/user-profile

5. 公開:ユーザーに届ける

マージする

レビューが承認されたら、mainブランチにマージします。

# GitHubのUIで「Merge Pull Request」ボタンをクリック

自動デプロイ

mainブランチにマージされると、自動的に公開されます(CI/CDが設定されている場合)。

GitHubにプッシュ
    ↓
自動でテスト実行
    ↓
テストが成功
    ↓
自動でビルド
    ↓
自動で公開(デプロイ)
    ↓
ユーザーが使える!

よくある開発パターン

パターン1:小さな修正

# ブランチ作成
git switch -c fix/button-color

# 修正
# (コードを修正)

# コミット・プッシュ
git add .
git commit -m "fix: change button color to blue"
git push origin fix/button-color

# PR作成 → レビュー → マージ

パターン2:新機能の追加

# Issue作成(GitHubで)

# ブランチ作成
git switch -c feature/search-function

# 開発
# (複数回コミット)
git commit -m "feat: add search UI"
git commit -m "feat: add search API"
git commit -m "test: add search tests"

# テスト実行
pnpm run test
pnpm run check

# プッシュ
git push origin feature/search-function

# PR作成 → レビュー → 修正 → マージ

チーム開発のルール

1. mainブランチは常に動く状態にする

  • mainブランチに直接コミットしない
  • 必ずブランチを作って作業する
  • PRでレビューを受けてからマージ

2. わかりやすいコミットメッセージを書く

良い例:

git commit -m "feat: add user login functionality"
git commit -m "fix: resolve profile image upload error"

悪い例:

git commit -m "update"
git commit -m "fix bug"

3. 小さく分けて、こまめにコミット

良い例:

git commit -m "feat: add login form UI"
git commit -m "feat: add login validation"
git commit -m "feat: add login API integration"

悪い例:

# 1週間分の変更を一度にコミット
git commit -m "add login feature"

4. PRは早めに作る

  • 完成してからPRを作るのではなく
  • 早めに作って「Draft PR」として共有
  • 途中でも見てもらえる

便利なツール

開発を助けるツール

ツール用途コマンド例
Vite開発サーバー・ビルドpnpm run dev
Biomeコード品質チェックpnpm run check
TypeScript型チェックpnpm run type-check
Vitestテスト実行pnpm run test
Gitバージョン管理git status

よく使うコマンド

# 開発開始
pnpm run dev

# コード品質チェック
pnpm run check

# 自動修正
pnpm run check:fix

# テスト実行
pnpm run test

# ビルド
pnpm run build

トラブルシューティング

1. コミットできない

# エラー: コード品質チェックで失敗

# 自動修正を試す
pnpm run check:fix

# それでもダメなら、エラー内容を確認して手動で修正
pnpm run check

2. ブランチを間違えた

# 現在のブランチを確認
git branch

# 正しいブランチに切り替え
git switch feature/correct-branch

# ブランチを作り直す場合
git switch main
git switch -c feature/new-branch

3. コンフリクトが起きた

# mainの最新を取得
git checkout main
git pull origin main

# 自分のブランチに戻る
git checkout feature/my-branch

# mainの変更を取り込む
git merge main

# コンフリクトを解消(ファイルを手動で編集)
# <<<<<<< HEAD と >>>>>>> の部分を修正

# 解消後、コミット
git add .
git commit -m "merge: resolve conflicts with main"

ポイント

この章で学んだことをまとめます。

開発の流れ:

  1. 計画: Issueで何を作るか決める
  2. 開発: ブランチを作ってコードを書く
  3. テスト: 動作確認とコード品質チェック
  4. レビュー: PRを作ってチームに見てもらう
  5. 公開: マージして自動デプロイ

大切なこと:

  • ✅ mainブランチは常に動く状態を保つ
  • ✅ 小さく分けて、こまめにコミット
  • ✅ わかりやすいメッセージを書く
  • ✅ 早めにPRを作って相談する
  • ✅ レビューは学びの機会

使うツール:

  • Git: バージョン管理
  • GitHub: コード共有・レビュー
  • Vite: 開発サーバー
  • Biome: コード品質チェック
  • TypeScript: 型チェック

最初は覚えることが多くて大変かもしれませんが、この流れに慣れると、安全で効率的な開発ができるようになります。チームで協力しながら、少しずつ慣れていきましょう!

REST APIと非同期処理

REST API基礎

Web開発において重要なREST APIの基本的な概念と仕組みについて学んでいきましょう。REST APIを理解することで、Webアプリケーション開発の土台を築けます。

REST APIとは

REST(Representational State Transfer)APIは、Webサービス間でのデータのやり取りを行うためのアーキテクチャパターンです。簡単に言うと、「決まった方法でデータを取得したり送信したりするためのルール」というわけです。

RESTの基本原則

REST APIは以下の重要な原則に基づいています:

  1. ステートレス:サーバーは前回のやり取りを覚えていません(気楽な関係ですね)
  2. 統一インターフェース:決まった方法でリソースにアクセスします
  3. 階層化システム:複数のサーバーを経由しても動作します
  4. キャッシュ可能:レスポンスをキャッシュして効率化できます

HTTPメソッドの基本

REST APIは主に以下のHTTPメソッドを使ってリソースを操作します:

CRUD操作とHTTPメソッドの対応

操作HTTPメソッド意味
CreatePOST新しいリソースを作成ユーザー登録
ReadGETリソースを取得ユーザー情報の表示
UpdatePUT/PATCHリソースを更新プロフィール変更
DeleteDELETEリソースを削除アカウント削除

実際のAPIエンドポイントの例

GET    /api/users        # 全ユーザーの一覧を取得
GET    /api/users/123    # ID 123のユーザー情報を取得
POST   /api/users        # 新しいユーザーを作成
PUT    /api/users/123    # ID 123のユーザー情報を更新
DELETE /api/users/123    # ID 123のユーザーを削除

見覚えのあるパターンですよね。この一貫性がRESTの魅力です。

ステータスコード

APIからのレスポンスには、処理結果を示すHTTPステータスコードが付きます:

よく使われるステータスコード

  • 200 OK:成功
  • 201 Created:作成成功
  • 400 Bad Request:リクエストが不正
  • 401 Unauthorized:認証が必要
  • 404 Not Found:リソースが見つからない
  • 500 Internal Server Error:サーバーエラー

JSON形式でのデータ交換

REST APIでは、JSONフォーマットでデータのやり取りを行うのが一般的です:

ユーザー情報のJSONレスポンス例

{
  "id": 123,
  "name": "田中太郎",
  "email": "tanaka@example.com",
  "createdAt": "2025-01-01T00:00:00Z",
  "profile": {
    "age": 30,
    "city": "東京"
  }
}

JSONは読みやすく、JavaScriptとの相性も抜群です(便利ですよね)。

実際のREST APIの例

JSONPlaceholder API

練習によく使われる無料のテストAPIをご紹介します:

GET https://jsonplaceholder.typicode.com/posts/1

このリクエストを送ると、以下のようなレスポンスが返ってきます:

{
  "userId": 1,
  "id": 1,
  "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
  "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum..."
}

やってみよう!

ブラウザで以下のURLにアクセスしてみてください:

  • https://jsonplaceholder.typicode.com/posts
  • https://jsonplaceholder.typicode.com/users

実際のJSON形式のデータが確認できます。これがREST APIの基本的な動作です。

REST APIの設計原則

リソース指向の設計

  • URLはリソース(モノ)を表現します
  • 動詞ではなく名詞を使用します
Good: GET /api/users/123
Bad:  GET /api/getUser?id=123

ネストしたリソースの表現

関連するリソースは階層構造で表現します:

GET /api/users/123/posts     # ユーザー123の投稿一覧
GET /api/posts/456/comments  # 投稿456のコメント一覧

認証とセキュリティ

APIキーによる認証

多くのAPIでは、リクエストヘッダーにAPIキーを含めて認証します:

Authorization: Bearer your-api-key-here

リクエスト制限

APIには通常、リクエスト回数の制限(レート制限)があります:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99

Note: セキュリティ上、APIキーは環境変数で管理し、コードに直接書かないようにしましょう。

まとめ

REST APIを理解することで、以下のことができるようになります:

ポイント

  • REST API: Web上でのデータ交換の標準的な方法
  • HTTPメソッド: GET(取得)、POST(作成)、PUT(更新)、DELETE(削除)
  • ステータスコード: 処理結果を示す3桁の数字
  • JSON形式: APIでのデータ交換の標準フォーマット
  • リソース指向: URLはリソース(モノ)を表現する
  • ステートレス: サーバーは前回のやり取りを覚えていない

次の記事では、HTTPリクエストとJSONの詳細について学んでいきましょう。実際にブラウザの開発者ツールを使って、APIの動作を確認してみます。

GitHubで学ぶREST API実践

GitHubのREST APIを題材に、VS Code拡張のThunder Clientとfetchの両アプローチでAPIを扱う基礎をまとめました。認証(PAT)の扱い、代表的なエンドポイント、トラブルシューティングまで一気に押さえます。

この記事で学べること

  • Thunder Clientの導入と基本操作
  • 読み取りAPI(非認証)と認証APIの違い
  • Personal Access Token(PAT)の発行と安全な使い方
  • GitHub APIをThunder Clientとfetchで叩く実例

Thunder Clientとは(導入と基本操作)

Thunder Clientは、VS Code上で動作する軽量なRESTクライアントです。APIの試行錯誤やリクエスト保存が簡単にできます。

インストール

  1. VS Codeを開き、拡張機能ビューを開く
  2. "Thunder Client" を検索してインストール
    • マーケットプレイス: https://marketplace.visualstudio.com/items?itemName=rangav.vscode-thunder-client

Thunder Client の基本的な使い方

リクエストの作成と編集

  1. HTTPメソッドの選択
    • 右側のペインで [GET] をクリックし、POSTPUT などのメソッドを選択可能です。
  2. URLの入力
    • Enter URL フィールドに対象APIのURLを入力します。
    • 例: https://www.thunderclient.com/welcome
  3. クエリパラメータの指定
    • Query タブで、HTTPクエリパラメータを編集します。
    • 例: ?parameter1=value1&parameter2=value2 の形式で記述。
  4. ヘッダーの編集
    • Headers タブでHTTPリクエストヘッダーを指定します。
    • 必要に応じて Authorization ヘッダーや Content-Type を設定。
  5. リクエストの送信
    • 編集が終わったら、右上の Send ボタンをクリックしてリクエストを発行します。

リクエストの保存

  • 左側のペインで Activity タブを選択し、履歴を確認可能。
  • リクエストを保存したい場合は ... ボタンをクリックし、Save to Collection を選択。

GitHub APIの基本(非認証と認証)

非認証でできること(読み取り)

公開情報(ユーザーや公開リポジトリなど)はトークンなしで取得できます。ただしレート制限が厳しめです(未認証はおおむね1時間に60リクエスト程度)。

const res = await fetch("https://api.github.com/users/octocat");
const data = await res.json();
console.log(data.login, data.public_repos);

認証(PAT)

  1. GitHub PAT発行ページ にアクセス。
  2. Generate new token をクリック。
  3. トークンの名前を指定し、必要な権限(例: user)にチェックを入れる。
  4. トークンを生成し、表示された値をメモします。(※ 生成したトークンは安全に保管(環境変数など)。フロントエンドに埋め込まないこと。)

Thunder ClientでGitHub APIを叩く

  • URL: https://api.github.com/users/{username}(例: octocat
  • 認証が必要なAPI(例: /user)は、HeadersにAuthorization: token <PAT>(または Authorization: Bearer <PAT>)を設定

レスポンス例:

{
  "login": "octocat",
  "id": 583231,
  "public_repos": 8
}

fetchでGitHub APIを叩く(サーバー側推奨)

トークンは環境変数で管理し、サーバー側から呼び出すのが原則です。

// 自分のユーザー情報を取得(認証が必要)
const token = process.env.GITHUB_TOKEN as string;
const res = await fetch("https://api.github.com/user", {
  headers: {
    Authorization: `Bearer ${token}`,
    Accept: "application/vnd.github+json",
  },
});
const me = await res.json();

代表的なエンドポイント:

  • ユーザー情報: GET /users/{username}(公開)/ GET /user(認証)
  • リポジトリ一覧: GET /users/{username}/repos
  • Issue作成: POST /repos/{owner}/{repo}/issues
// Issue作成(サーバー側で実行)
await fetch("https://api.github.com/repos/OWNER/REPO/issues", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
    Accept: "application/vnd.github+json",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ title: "バグ報告", body: "再現手順..." }),
});

トラブルシューティング

  • 401/403: 認証情報不足。Authorizationヘッダー、トークンスコープ、レート制限を確認
  • 422 Unprocessable Entity: 入力不足・不正。必須パラメータやJSONの形を見直す
  • レート制限: 未認証は特に制限が厳しい。認証+適切なヘッダーを付与

演習

  1. 非認証で /users/octocat を取得し、public_repos を表示
  2. PATを作成し、認証付きで /user を叩いて自分の情報を取得
  3. (発展)サーバー側で自分のリポジトリにIssueを1件作成
  4. (応用)Thunder Clientで別の公開API(例: OpenWeather)を叩いてレスポンスを観察

OpenWeatherの例:

  • URL: http://api.openweathermap.org/data/2.5/weather
  • Query: ?q=London&appid={API_KEY}

参考リンク

  • GitHub REST API v3: https://docs.github.com/ja/rest
    • 認証: https://docs.github.com/ja/rest/overview/authenticating-to-the-rest-api
  • Thunder Client: https://marketplace.visualstudio.com/items?itemName=rangav.vscode-thunder-client

HTTPリクエストとJSON

HTTPリクエストの仕組みとJSONデータの基本をつかみます。実際に動かしながら気楽にいきましょう。

この記事で学べること

  • HTTPリクエスト/レスポンスのしくみ(メソッド・URL・ヘッダーフィールド・ボディ)
  • ステータスコードとContent-Type
  • JSONの基本と注意点(数値/日付/ネスト)
  • ブラウザとJavaScriptでJSONを扱う実例

HTTPのしくみを分解

HTTP(HyperText Transfer Protocol)は、クライアント(例: ブラウザ)とサーバーが会話するためのルールです。会話の1往復をもう少し細かく見てみましょう。

プロトコル

― この画像は © 2012 Karl Dubost クリエイティブ・コモンズ CC BY 3.0 ライセンスのもとに利用を許諾されています。

二者間でのコミュニケーションが成立するためには3つの要素が含まれています。

  • シンタックスSyntax (コードの文法)
  • セマンティクスSemantics (コードの意味)
  • タイミングTiming (速度合わせと順序付け)

「挨拶」を例に考えてみましょう。 腰を曲げるジェスチャー、これはお辞儀のためのシンタックスです。日本ではそういう慣習ですね。お辞儀をすることで「どうも、こんにちは」という意味づけが行われます。これはセマンティクスです。二者間で特定のタイミングでこれらが発生したとき、一連の出来事として成立します。どちらもお辞儀をし、お互いに理解することによって「挨拶」として成立した、となるわけです。

Web上でのやり取りも同じです。 HTTPはサーバー・クライアントの二者関係で行われます。 クライアントはサーバーに対して要求リクエストを送り、クライアントからの要求リクエストを受け取るとサーバーは応答レスポンスを返します。

HTTPの仕様にある具体例を挙げます。 次のようなコードの送受信を行います。

リクエストの構成

  • メソッド: 何をしたいか(GET/POST/PUT/PATCH/DELETE など)
  • URL: どこに(https://www.example.com/hello.txt など)
  • ヘッダー: 追加情報(認証やデータ形式)※HTTP/1.1仕様では「ヘッダーフィールド (Header Fields)」とも表記されます
  • ボディ: 本文(POST/PUT で送るJSONなど)

具体例:

GET /hello.txt HTTP/1.1
User-Agent: curl/7.64.1
Host: www.example.com
Accept-Language: en, mi

Note
HTTP/1.1 と HTTP/2

HTTP/1.1は1995年に公開され、2022年に最新版に改定されました。 HTTP/1.1は現在も使われ続けています。 一方、HTTP/2は2022年に公開されました。 HTTP/2はHTTP/1.1とは異なり複数のメッセージを同時に扱える、コンピューターにとってより効率的な形式のシンタックスが特徴の新しい仕様です。 HTTP/2ではリクエストラインの代わりに一貫してフィールドを使うなどHTTP/1.1と文法が大きく異なりますがその意味は全く変わりません。

HTTP/2 仕様のリクエストの例:

  GET /resource HTTP/1.1           HEADERS
  Host: example.org          ==>     + END_STREAM
  Accept: image/jpeg                 + END_HEADERS
                                       :method = GET
                                       :scheme = https
                                       :authority = example.org
                                       :path = /resource
                                       host = example.org
                                       accept = image/jpeg

レスポンスの構成

  • ステータスライン: (例) HTTP/1.1 200 OK
  • ヘッダーフィールド: (例) Content-Type: text/plain
  • ボディ: データ本体

具体例:

HTTP/1.1 200 OK
Date: Mon, 27 Jul 2009 12:28:53 GMT
Server: Apache
Last-Modified: Wed, 22 Jul 2009 19:15:56 GMT
ETag: "34aa387-d-1568eb00"
Accept-Ranges: bytes
Content-Length: 51
Vary: Accept-Encoding
Content-Type: text/plain

Hello World! My content includes a trailing CRLF.

ステータスコード (Status Codes)


― 画像: HTTP Cats より

「ステータスコード (Status Codes)」はそのリソースの存在やアクセス可否などをサーバーが伝えるためのものです。 サーバーはレスポンスを返すとき、最初にステータスコードを返します。

サーバーレスポンス:

HTTP/1.1 200 OK

この例ではステータスコード 200 を返しています。 ステータスコードは100〜599までの3桁の整数で表されます。 レスポンスはステータスコードの100の位で大きく分類されます。

  • 1xx (情報): リクエストを受信しました。プロセスを続行します。
  • 2xx (成功): リクエストは正常に受信、理解され、受け入れられました。
  • 3xx (リダイレクト): リクエストを完了するにはさらにアクションを実行する必要があります。
  • 4xx (クライアントエラー): リクエストに不正な構文が含まれているか、リクエストを実行できません。
  • 5xx (サーバーエラー): サーバーは有効なリクエストを実行できません。

Note
418 I'm a teapot

私はティーポットなのでコーヒーを入れることを拒否しました、という意味のステータスコードです。 1998年のエイプリルフールに公開されました。 現在でもステータスコード 418IANA HTTP Status Code Registry によって管理されています。

JSONの基本(JavaScript Object Notation)

JSONは「データをテキストで表す決まり」です。JavaScriptとの相性が良く、Web APIでよく使われます。

よく使う型

  • オブジェクト: { "id": 1, "name": "Taro" }
  • 配列: [1, 2, 3][{"id":1},{"id":2}]
  • 文字列/数値/真偽値/null: "hello", 42, true, null

Note: JSONに日付型はありません。通常はISO文字列(例: "2025-01-01T00:00:00Z")として扱い、必要に応じてアプリ側でDate型に変換します。

具体例:公開APIを叩いてみる

まずは読み取りだけの安全なAPIで体験しましょう。学習用途で有名なJSONPlaceholderを使います。

エンドポイント例:

  • GET https://jsonplaceholder.typicode.com/posts/1

ブラウザでURLを開くだけでもOKです。ネットワーク通信を詳しく見たいときは、Chromeの開発者ツール > Network タブを開いてみましょう(面白いですよ)。

レスポンス例(抜粋):

{
  "userId": 1,
  "id": 1,
  "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
  "body": "quia et suscipit..."
}

JavaScriptでJSONを扱う

JavaScriptではfetchを使って簡単にJSONを取得できます。

基本のパターン

async function getPost() {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts/1", {
    headers: {
      Accept: "application/json",
    },
  });

  if (!res.ok) {
    throw new Error(`HTTP ${res.status}`);
  }

  const data = await res.json(); // Content-Type: application/json が前提
  return data;
}

getPost().then(console.log).catch(console.error);

JSONが返らないときの安全策

async function safeParseJSON(res) {
  const ct = res.headers.get("content-type") || "";
  if (ct.includes("application/json")) return res.json();
  return res.text(); // プレーンテキスト等にフォールバック
}

リクエストボディにJSONを送る

async function createPost(post) {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify(post),
  });

  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return await res.json();
}

createPost({ title: "Hello", body: "World", userId: 1 })
  .then(console.log)
  .catch(console.error);

Note: JSON.stringifyを忘れると、サーバーは意図しない形式([object Object]など)を受け取り、400系エラーになることがあります。

ステータスコードとエラーの見分け方

  • 2xx: 成功 => res.ok が true
  • 4xx: クライアント側の問題 => res.ok が false
  • 5xx: サーバー側の問題 => res.ok が false

JavaScriptのfetchは「ネットワークに到達したら」例外を投げません。HTTP 404でもres.okがfalseになるだけです(ちょっと紛らわしいですよね)。

async function request(url) {
  const res = await fetch(url);
  if (!res.ok) {
    let message = `HTTP ${res.status}`;
    try {
      const err = await res.json();
      message = err.message || message;
    } catch {}
    throw new Error(message);
  }
  return res.json();
}

やってみよう!

  1. ブラウザで https://jsonplaceholder.typicode.com/users を開く
  2. Chrome開発者ツールのNetworkタブでレスポンスヘッダー(Content-Type)を確認
  3. fetchで同じURLを読み込み、配列長をconsole.logしてみる
const url = "https://jsonplaceholder.typicode.com/users";
fetch(url)
  .then((r) => r.json())
  .then((users) => console.log("件数:", users.length));

ポイント(まとめ)

  • HTTPは「メソッド・URL・ヘッダー・ボディ」のセット
  • JSONはテキスト表現のオブジェクト。日付は文字列で扱うのが基本
  • Content-Typeを見て正しくパース(response.json() or response.text()
  • fetchは404でも例外にしない。res.okを必ず確認
  • 送信時はContent-Type: application/jsonJSON.stringifyを忘れずに

fetch APIの基本

JavaScriptでAPIリクエストを送るためのfetch APIについて学んでいきましょう。前回Thunder Clientで体験したAPIリクエストを、今度はコードで実装してみます。

fetch APIとは

fetch APIは、JavaScriptでHTTPリクエストを送るためのモダンな方法です。ブラウザに標準搭載されており、Promiseベースで使いやすく設計されています(昔のXMLHttpRequestより遥かに簡単です)。

fetch APIの特徴

  • Promise ベース: async/await で読みやすいコードが書ける
  • 標準搭載: 追加ライブラリ不要
  • 柔軟: あらゆるHTTPリクエストに対応
  • モダン: 現代的なJavaScriptの書き方

基本的な使い方

最もシンプルなGETリクエスト

// 基本形
fetch('https://jsonplaceholder.typicode.com/posts/1')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('エラー:', error));

// async/await版(推奨)
async function getPost() {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error('エラー:', error);
  }
}

getPost();

レスポンスの確認

fetch APIは、サーバーからレスポンスが返ってくれば成功とみなします:

async function getUser() {
  const response = await fetch('/api/users/123');

  // ステータスコードをチェック
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  const user = await response.json();
  return user;
}

POSTリクエストでデータを送信

ユーザー作成の例

async function createUser(userData) {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(userData)
  });

  if (!response.ok) {
    throw new Error(`作成に失敗しました: ${response.status}`);
  }

  const newUser = await response.json();
  return newUser;
}

// 使用例
const userData = {
  name: '田中太郎',
  email: 'tanaka@example.com'
};

createUser(userData)
  .then(user => console.log('作成されたユーザー:', user))
  .catch(error => console.error('エラー:', error));

フォームデータの送信

async function updateProfile(formData) {
  const response = await fetch('/api/profile', {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      name: formData.get('name'),
      email: formData.get('email')
    })
  });

  return await response.json();
}

よく使うパターン

認証付きリクエスト

async function authenticatedRequest(url, options = {}) {
  const token = localStorage.getItem('authToken');

  const response = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`,
      ...options.headers,
    }
  });

  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }

  return await response.json();
}

// 使用例
try {
  const userProfile = await authenticatedRequest('/api/profile');
  console.log(userProfile);
} catch (error) {
  console.error('認証エラーまたはリクエストエラー:', error);
}

クエリパラメータの追加

function buildURL(baseURL, params) {
  const url = new URL(baseURL);
  Object.keys(params).forEach(key =>
    url.searchParams.append(key, params[key])
  );
  return url.toString();
}

async function searchUsers(query) {
  const url = buildURL('/api/users', {
    search: query,
    page: 1,
    limit: 10
  });

  const response = await fetch(url);
  return await response.json();
}

// 使用例
const results = await searchUsers('田中');
// リクエスト先: /api/users?search=%E7%94%B0%E4%B8%AD&page=1&limit=10

エラーハンドリング

包括的なエラー処理

async function apiRequest(url, options = {}) {
  try {
    const response = await fetch(url, {
      headers: {
        'Content-Type': 'application/json',
      },
      ...options,
    });

    // HTTPエラーをチェック
    if (!response.ok) {
      // サーバーからのエラーレスポンスを読み取り
      let errorMessage = `HTTP ${response.status}`;

      try {
        const errorData = await response.json();
        errorMessage = errorData.message || errorMessage;
      } catch {
        // JSON以外のエラーレスポンスの場合
        errorMessage = await response.text();
      }

      throw new Error(errorMessage);
    }

    // Content-Typeをチェックして適切にパース
    const contentType = response.headers.get('content-type');
    if (contentType && contentType.includes('application/json')) {
      return await response.json();
    } else {
      return await response.text();
    }

  } catch (error) {
    // ネットワークエラーやその他の例外
    if (error instanceof TypeError) {
      throw new Error('ネットワークエラー: サーバーに接続できません');
    }
    throw error;
  }
}

実用的な使用例

// ユーザー情報を取得して画面に表示
async function displayUserInfo(userId) {
  try {
    const user = await apiRequest(`/api/users/${userId}`);

    // 画面更新
    document.getElementById('userName').textContent = user.name;
    document.getElementById('userEmail').textContent = user.email;

  } catch (error) {
    // エラーメッセージを表示
    document.getElementById('errorMessage').textContent =
      `ユーザー情報の取得に失敗しました: ${error.message}`;
  }
}

リクエストのキャンセル

長時間のリクエストをキャンセルできるようにしましょう:

async function fetchWithTimeout(url, options = {}, timeout = 5000) {
  // AbortControllerでキャンセル可能にする
  const controller = new AbortController();

  // タイムアウト設定
  const timeoutId = setTimeout(() => controller.abort(), timeout);

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal
    });

    clearTimeout(timeoutId);
    return response;

  } catch (error) {
    clearTimeout(timeoutId);

    if (error.name === 'AbortError') {
      throw new Error('リクエストがタイムアウトしました');
    }
    throw error;
  }
}

// 使用例
try {
  const response = await fetchWithTimeout('/api/slow-endpoint', {}, 3000);
  const data = await response.json();
  console.log(data);
} catch (error) {
  console.error(error.message); // "リクエストがタイムアウトしました" など
}

実践的なAPI クライアントクラス

再利用しやすいAPIクライアントを作ってみましょう:

class APIClient {
  constructor(baseURL, defaultHeaders = {}) {
    this.baseURL = baseURL;
    this.defaultHeaders = {
      'Content-Type': 'application/json',
      ...defaultHeaders
    };
  }

  async request(endpoint, options = {}) {
    const url = `${this.baseURL}${endpoint}`;

    const response = await fetch(url, {
      headers: {
        ...this.defaultHeaders,
        ...options.headers,
      },
      ...options,
    });

    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }

    return await response.json();
  }

  // 便利メソッド
  async get(endpoint, params = {}) {
    const url = new URL(`${this.baseURL}${endpoint}`);
    Object.keys(params).forEach(key =>
      url.searchParams.append(key, params[key])
    );

    return this.request(url.pathname + url.search);
  }

  async post(endpoint, data) {
    return this.request(endpoint, {
      method: 'POST',
      body: JSON.stringify(data),
    });
  }

  async put(endpoint, data) {
    return this.request(endpoint, {
      method: 'PUT',
      body: JSON.stringify(data),
    });
  }

  async delete(endpoint) {
    return this.request(endpoint, {
      method: 'DELETE',
    });
  }
}

// 使用例
const api = new APIClient('/api', {
  'Authorization': 'Bearer your-token-here'
});

// GET /api/users?page=1
const users = await api.get('/users', { page: 1 });

// POST /api/users
const newUser = await api.post('/users', {
  name: '佐藤花子',
  email: 'sato@example.com'
});

まとめ

fetch APIを使うことで、JavaScriptからAPIリクエストを簡単に送れるようになりました:

ポイント

  • fetch API: JavaScriptでHTTPリクエストを送るモダンな方法
  • async/await: Promise ベースで読みやすいコード
  • エラーハンドリング: response.ok でステータスをチェック
  • JSON処理: response.json() でデータを取得
  • 柔軟性: GET、POST、PUT、DELETE すべて対応
  • キャンセル: AbortController でリクエスト中断可能

次の記事では、Reactのコンポーネント内でfetch APIを使う方法について学んでいきましょう。useEffectフックと組み合わせて、コンポーネントのライフサイクルに合わせたAPI呼び出しを実装します。

useEffectによる非同期処理

Reactコンポーネントでデータ取得をしたい。そんなときに欠かせないのがuseEffectです。発火のタイミング、クリーンアップ、依存配列などの基本を、実例で身につけましょう(簡単にできます)。

この記事で学べること

  • useEffectの基本と依存配列の意味
  • データ取得のベストプラクティス(AbortControllerでのキャンセル)
  • ローディング/エラー表示のパターン
  • ありがちな落とし穴(無限ループなど)

useEffectの基本

useEffectは「レンダーのあと」に実行される副作用(データ取得や購読など)を記述するためのフックです。

import { useEffect, useState } from "react";

function UserName({ id }: { id: number }) {
  const [name, setName] = useState<string>("");

  useEffect(() => {
    document.title = `User ${id}`; // レンダー後に副作用
  }, [id]); // idが変わるたびに実行

  return <h1>{name || "loading..."}</h1>;
}

Note: 依存配列([])が空だと、マウント時に1回だけ実行されます。

データ取得(非同期)を正しく書く

useEffect内でasync関数を直接渡すのではなく、中で宣言して呼び出します(細かいですが大事です)。

import { useEffect, useState } from "react";

type User = { id: number; name: string };

export function UserCard({ id }: { id: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [error, setError] = useState<string>("");
  const [loading, setLoading] = useState<boolean>(false);

  useEffect(() => {
    const controller = new AbortController();
    const { signal } = controller;

    const load = async () => {
      try {
        setLoading(true);
        setError("");
        const res = await fetch(
          `https://jsonplaceholder.typicode.com/users/${id}`,
          { signal },
        );
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data: User = await res.json();
        setUser(data);
      } catch (e) {
        if ((e as any)?.name === "AbortError") return; // アンマウント時の中断
        setError((e as Error).message);
      } finally {
        setLoading(false);
      }
    };

    load();
    return () => controller.abort(); // クリーンアップで中断
  }, [id]);

  if (loading) return <p>読み込み中...</p>;
  if (error) return <p style={{ color: "crimson" }}>エラー: {error}</p>;
  if (!user) return null;
  return (
    <div>
      <h2>{user.name}</h2>
      <small>ID: {user.id}</small>
    </div>
  );
}

Note: ループやダブルフェッチを避けるため、依存配列にuserloadingを安易に入れないようにしましょう。必要最小限にするのがコツです。

無限ループを避けるコツ

  • 依存配列には「外から与えられる値」や「関数の安定化済み参照(useCallbackなど)」のみを入れる
  • データをsetStateした結果に依存して再度fetchしないようにする
  • オブジェクト/配列リテラルは毎回新しい参照になるので注意(useMemoで安定化)

型安全に書く(ざっくり)

TypeScriptでは、受け取るJSONの型を定義しておくと安心です。

type Todo = {
  id: number;
  title: string;
  completed: boolean;
};

JSONをTodoにパースして使うだけで、プロパティのタイプミスに気づけます(助かりますよね)。

やってみよう!

  1. 上のUserCardをコピーして、idを切り替えるボタンを用意
  2. 切り替え時に前のリクエストがキャンセルされることを確認(Networkタブで(canceled)が出るはず)
  3. https://httpstat.us/404にリクエストしてエラー表示をテスト

ポイント(まとめ)

  • useEffectは「レンダー後の副作用」を書く場所
  • 非同期はAbortControllerでキャンセル対応を入れる
  • 依存配列を最小化して無限ループを回避
  • ローディング/エラー/成功の3状態をUIで明示

参考リンク

  • React Docs: useEffect https://react.dev/reference/react/useEffect
  • MDN: AbortController https://developer.mozilla.org/ja/docs/Web/API/AbortController

useSWR入門

データ取得をもっと楽に、もっと速く。そんな願いを叶えるのが Vercel 製のデータフェッチングライブラリ「SWR」です。ReactのuseEffect + fetchよりもシンプルに、キャッシュや再検証(Revalidation)まで面倒を見てくれます(便利です)。

この記事で学べること

  • SWRの基本概念(Stale-While-Revalidate)
  • 最小コードでのデータ取得
  • ローディング・エラー表示のパターン
  • グローバル設定(SWRConfig)
  • 再検証のタイミング制御(focus/reconnect/interval)
  • ミューテーション(書き込みとキャッシュ更新)
  • 依存キー・条件付きフェッチ

基本概念:Stale-While-Revalidate とは

SWRは「手元のキャッシュ(stale)をすぐ表示しつつ、裏で最新データを取りに行き(revalidate)、更新できたらUIを差し替える」という戦略です。ユーザーは待たされず、でもデータは新鮮。いいとこ取りというわけです。

まずは使ってみる

インストール(プロジェクトで一度だけ)

npm i swr

基本の使い方:

import useSWR from "swr";

const fetcher = (url: string) => fetch(url).then((r) => r.json());

export default function Profile() {
  const { data, error, isLoading } = useSWR(
    "https://jsonplaceholder.typicode.com/users/1",
    fetcher,
  );

  if (isLoading) return <p>読み込み中...</p>;
  if (error) return <p>エラーが発生しました</p>;

  return (
    <div>
      <h2>{data.name}</h2>
      <small>ID: {data.id}</small>
    </div>
  );
}

Note: fetcherは「URLを受け取ってデータを返す関数」。SWRはこのfetcherにURL(キー)を渡して実行します。

ローディング・エラー・データ

SWRは状態管理も内蔵しています。

  • isLoading: まだ最初のデータがない状態
  • error: フェッチに失敗したときのエラー
  • data: フェッチ済みデータ(キャッシュを含む)

すでにキャッシュがあればisLoadingでもdataがある、という状態も起こり得ます(SWRの肝です)。

SWRConfig でグローバル設定

import { SWRConfig } from "swr";

const fetcher = (url: string) => fetch(url).then((r) => r.json());

export function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <SWRConfig
      value={{
        fetcher,
        shouldRetryOnError: true,
        errorRetryCount: 3,
        revalidateOnFocus: true,
        revalidateOnReconnect: true,
      }}
    >
      {children}
    </SWRConfig>
  );
}

Note: ここで指定したfetcherがデフォルトになります。各コンポーネントで省略可能に(楽ですね)。

再検証タイミングをコントロール

  • revalidateOnFocus: タブに戻ったら再取得(ユーザーに最新を見せる)
  • revalidateOnReconnect: ネットワーク復帰で再取得
  • refreshInterval: 定期ポーリング(ms)。0で無効
const { data } = useSWR("/api/notifications", { refreshInterval: 10_000 });

依存キーと条件付きフェッチ

キー(第1引数)にnullを渡すとフェッチしません。必要な条件がそろうまで待てます。

function UserDetail({ id }: { id?: number }) {
  const { data, error, isLoading } = useSWR(
    id ? `/api/users/${id}` : null, // idがないときはフェッチしない
  );
  // ...
}

キーを配列で表現して、fetcher側で受け取ることもできます。

const fetchUser = (_key: string, id: number) =>
  fetch(`/api/users/${id}`).then((r) => r.json());
const { data } = useSWR(["user", 123], fetchUser);

ミューテーションでUIを即時更新

mutateはキャッシュを書き換え、UIを即時反映させる関数です。サーバー反映を待たずに「先に見た目を更新」できます(便利)。

import useSWR, { mutate } from "swr";

function LikeButton({ postId }: { postId: number }) {
  const key = `/api/posts/${postId}`;
  const { data } = useSWR(key);

  const onLike = async () => {
    // 楽観的更新(optimistic UI)
    mutate(
      key,
      { ...data, likes: (data?.likes ?? 0) + 1 },
      { revalidate: false },
    );
    try {
      await fetch(`${key}/like`, { method: "POST" });
      // サーバー確定後に再検証
      mutate(key);
    } catch {
      // 失敗したら再検証で正しい値に戻す
      mutate(key);
    }
  };

  return <button onClick={onLike}>👍 {data?.likes ?? 0}</button>;
}

やってみよう!

  1. URLを/users/2に変えて結果の差を確認
  2. 同じコンポーネントを2つ置いて、2回目が高速表示(キャッシュ命中)されることを体験
  3. ネットワークを「Slow 3G」にしてSWRの体験を比較(Chrome DevTools > Network)
  4. refreshInterval: 5000 を設定して、一定間隔でデータが更新される様子を確認
  5. mutateで「楽観的更新」を体験(いいねボタンなど)
  6. 条件付きフェッチで「フォーム入力完了まで待つ」UIを実装

ポイント(まとめ)

  • SWRは「キャッシュ優先+裏で再取得」の戦略
  • useSWR(key, fetcher) が基本形
  • 状態(loading/error/data)を内蔵していてUIが簡単
  • SWRConfigで全体方針を一括設定
  • フォーカス/再接続/ポーリングで最新化タイミングを制御
  • mutateでキャッシュを書き換えてUIを即反映
  • nullキーで条件付きフェッチ、配列キーで柔軟に渡す

参考リンク

  • SWR 公式 https://swr.vercel.app/ja
  • JSONPlaceholder https://jsonplaceholder.typicode.com/

エラーハンドリング戦略

API連携では「うまくいかないとき」にどう振る舞うかが品質を左右します。ユーザー体験を損なわず、開発中も原因を素早く特定できる戦略をまとめます(安心感が違います)。

この記事で学べること

  • エラーの分類(ユーザー起因/ネットワーク/サーバー)
  • 表示・ログ・再試行の基本設計
  • フロントエンドの実装パターン(fetch/SWR)

エラーの分類

  • 入力エラー(400系): フォームのバリデーション結果など
  • 認証/認可エラー(401/403): ログインや権限が必要
  • リソース未検出(404): URLやID間違い
  • レート制限(429): 呼びすぎ注意。待って再試行
  • サーバーエラー(5xx): サーバー側の問題。時間を置いて再試行
  • ネットワークエラー: オフライン/タイムアウト/プロキシ問題など

UIでの基本方針

  • 明確なメッセージ: 「何が起きたか」「次に何をすべきか」
  • 再試行ボタン: ユーザー主体で回復できる道を残す
  • 重要データはスケルトン/プレースホルダ表示で認知負荷を軽減
  • クリティカル時のみダイアログ。通常は画面内に控えめに表示

fetch の標準実装

export async function requestJSON<T>(
  input: RequestInfo,
  init?: RequestInit,
): Promise<T> {
  try {
    const res = await fetch(input, init);
    if (!res.ok) {
      let message = `HTTP ${res.status}`;
      try {
        const body = await res.json();
        message = body.message || message;
      } catch {}
      throw new Error(message);
    }
    const ct = res.headers.get("content-type") || "";
    if (ct.includes("application/json")) return res.json();
    throw new Error("Unexpected content type");
  } catch (e) {
    if ((e as any)?.name === "AbortError") throw e; // キャンセルはそのまま
    // ネットワーク系
    if (e instanceof TypeError) {
      throw new Error(
        "ネットワークに接続できません(オフライン/プロキシ/SSL など)",
      );
    }
    throw e;
  }
}

SWRでの戦略

import useSWR, { SWRConfig } from "swr";

const fetchJSON = <T,>(url: string) =>
  fetch(url).then(async (r) => {
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    return r.json() as Promise<T>;
  });

function User() {
  const { data, error, isLoading, mutate } = useSWR("/api/me", fetchJSON);
  if (isLoading) return <p>読み込み中</p>;
  if (error) return <button onClick={() => mutate()}>再試行</button>;
  return <div>{data.name}</div>;
}

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <SWRConfig
      value={{
        onError: (err) => {
          // グローバル通知やSentry送信など
          console.error("[SWR Error]", err);
        },
        shouldRetryOnError: true,
        errorRetryCount: 3,
      }}
    >
      {children}
    </SWRConfig>
  );
}

ログと監視

  • ユーザー向け表示と開発者向けログを分離(混ぜない)
  • 予期せぬ例外はSentryなどに送信(個人情報に注意)
  • 再現手順/レスポンス/トレースIDを記録して解析可能に

やってみよう!

  1. 404/500/ネットワーク遮断を擬似的に作り、UIメッセージと再試行動作を確認
  2. SWRのerrorRetryCountを増やしてバックオフ動作を観察

ポイント(まとめ)

  • エラーは種類ごとに対処を分ける(メッセージ/再試行/遅延)
  • fetchはres.okContent-Typeを確認して丁寧に処理
  • SWRではonErrormutateで回復体験を設計
  • ログは「利用者の安心」と「開発者の調査」を両立

参考リンク

  • MDN: fetch https://developer.mozilla.org/ja/docs/Web/API/Fetch_API
  • SWR 公式 https://swr.vercel.app/ja

プロキシ確認ガイド

社内ネットワークやセキュリティが厳しい環境では、プロキシ設定が必須になることがあります。まずは自分の環境で通信ができているか確認し、問題がある場合のみ設定を行えば大丈夫です。

このガイドでは、各種開発ツール (npm、Git、Thunder Client、AWS CLI等) でプロキシ環境下での通信確認方法と設定方法を学んでいきましょう。

HTTP通信の確認方法

Webブラウザー

この文が見えていればOKです。

NPM

Step1. Node.jsのインストール

Step2. ターミナルでコマンドを実行

ターミナルで次のコマンドを実行して、レジストリとHTTP通信できることを確認します。

npm ping

数秒以内に “npm notice PONG” というメッセージが表示されていればOKです。

“ERROR: connect ECONNREFUSED” など “ERROR” から始まるメッセージが表示される場合はNGです❌

Git

Step1. Gitのインストール

Step2. ターミナルでコマンドを実行

GitがHTTP通信できることを確認します。

GitHubのリポジトリのクローンを行うコマンド:

$ git clone <https://github.com/octo-org/octo-template.git>
Cloning into 'octo-template'...
remote: Enumerating objects: 3, done.
remote: Counting objects: 100% (3/3), done.
remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
Receiving objects: 100% (3/3), done.

期待通りリポジトリがクローンされていればOKです。

“fatal: unable to access” や “Failed to connect to …” というメッセージが表示される場合はNGです❌

リモートリポジトリの一覧を確認するコマンド: git ls-remote

$ cd octo-template
$ git ls-remote
From <https://github.com/octo-org/octo-template.git>
c85af0e5e5798047462143a13c1b455ee1275a64        HEAD
c85af0e5e5798047462143a13c1b455ee1275a64        refs/heads/main
b9b26d9eaaea5750bf9a937a6683294a3786b449        refs/pull/3/head
8e3150bce9b6af556e6ebd7307db3bfd0d7852db        refs/pull/3/merge

期待通りリモートリポジトリが表示されていればOKです。

“fatal: unable to access” や “Failed to connect to …” というメッセージが表示される場合はNGです❌

AWS CLI

Step1. AWS CLIのインストール

Step2. 初期設定

Step3. ターミナルでコマンドを実行

AWS CLIがHTTP通信できることを確認します。

例えば: S3バケットの一覧を確認するコマンド

aws s3 ls

S3バケットの一覧が期待通り表示されていればOKです。

“Failed to connect to proxy URL:” や “Unable to …” から始まるメッセージが表示される場合はNGです❌

AWS SDK

確認するコマンドはありません。 AWS SDKのプロキシの設定後、変更したコードが期待通り動作すればOKです。

VSCode (Visual Studio Code)

確認するコマンドはありません。 拡張機能のインストールなど期待通り動作すればOKです。

Thunder Client

Step1. インストール

Step2. リクエストの編集

  • VSCodeを起動 > Thunder Client > [New Request] を選択
  • 右側のペイン > [GET] … HTTP メソッドの選択
  • 右側のペイン > (その右隣) Enter Url … URL の入力

Step3. リクエストの送信

  • 右側のペイン > Send ボタン … リクエストの送信

画面上に “Status: 200 OK” というメッセージが表示されていればOKです。

“Status: ERROR” というメッセージが表示される場合はNGです❌


HTTP通信が可能な環境では、以降の設定は不要です。

Webブラウザーのプロキシの設定

  • Chrome/Edge/Safari: システムのネットワーク設定から行います。それぞれのOSの設定を確認してください。
    • Windows 11: Windows でプロキシ サーバーを使用する - Microsoft サポート [スタート] ボタンを選択し、[設定] > [ネットワークとインターネット] > [プロキシ] >[手動プロキシセットアップ] で、[プロキシ サーバーを使用] の横にある [セットアップ] を選択 > [プロキシ サーバーの編集] ダイアログ ボックス > [プロキシ サーバーを使用]有効化・[プロキシ IP アドレス] および [ポート] ボックスに、プロキシ サーバー名または IP アドレスとポートをそれぞれのボックスに入力・[ローカル (イントラネット) アドレスにプロキシ サーバーを使用しない] チェック ボックスをオン > [保存] を選択
    • Mac: Macでプロキシサーバ設定を入力する - Apple サポート (日本)
  • Firefox: https://support.mozilla.org/ja/kb/connection-settings-firefox

NPMのプロキシの設定

環境変数 HTTP_PROXYHTTPS_PROXY に適切なプロキシのURLを設定します。

下記ではプロキシのURLの例として http://user:pass@proxy.example.com:8080 を示しています。実際の自分の環境に合わせたURLに変更して実行してください。

Windows - PowerShellの場合

$env:HTTP_PROXY="<http://user:pass@proxy.example.com:8080>"$env:HTTPS_PROXY="<http://user:pass@proxy.example.com:8080>"

Windows - コマンドプロンプトの場合

set HTTP_PROXY=http://user:pass@proxy.example.com:8080
set HTTPS_PROXY=http://user:pass@proxy.example.com:8080

上記以外 - BashやZshなどの場合

export HTTP_PROXY=http://user:pass@proxy.example.com:8080
export HTTPS_PROXY=http://user:pass@proxy.example.com:8080

設定を行ったら、通常通り npm コマンドを実行して、エラーが出ないことを確認してみましょう。

Gitのプロキシの設定

NPMのプロキシの設定と同様に、環境変数 HTTP_PROXYHTTPS_PROXY を設定します。

AWS CLIのプロキシの設定

NPMのプロキシの設定と同様に、環境変数 HTTP_PROXYHTTPS_PROXY を設定します。

AWS SDKのプロキシの設定

Step1. NPMのプロキシの設定と同様に、環境変数 HTTP_PROXYHTTPS_PROXY を設定します。

Step2. @smithy/node-http-handlerproxy-agent をインストールします。

npm i @smithy/node-http-handler proxy-agent

https://docs.aws.amazon.com/ja_jp/sdk-for-javascript/v3/developer-guide/node-configuring-proxies.html

Step3. ProxyAgentを使います。

例:

import { S3Client } from "@aws-sdk/client-s3";
import { NodeHttpHandler } from "@smithy/node-http-handler";
import { ProxyAgent } from "proxy-agent";
const agent = new ProxyAgent();
const s3Client = new S3Client({
  requestHandler: new NodeHttpHandler({
    httpAgent: agent,
    httpsAgent: agent,
  }),
});
export default s3Client;

VSCodeのプロキシの設定

プロキシ環境下で使用する場合、拡張機能のインストールに失敗することがあります。 そういったケースでは、設定 > Http: Proxy (http.proxy) にプロキシのURLを指定すると機能します。

Thunder Clientのプロキシの設定

VSCode の REST API 試験用拡張機能 Thunder Clientをプロキシ環境下で使用する場合、次のようなエラーメッセージと共にlocalhostへのアクセスに失敗する恐れがあります。

Connection was forcibly closed by a peer.

そういったケースでは、設定 > Thunder-client: Exclude Proxy Host List (thunder-client.excludeProxyHostList) > “localhost” のように設定すると、Thunder Clientがlocalhostへ直接アクセスするようになります。

例:

(thunder-client.excludeProxyHostList 設定はここです ↑)

Honoハンズオン

Hono概要とEdge-first思想

Web開発のフレームワークの世界で、最近注目を集めているHono(炎)について学んでいきましょう。「なんでまた新しいフレームワークが...?」と思われるかもしれませんが、HonoにはEdge時代にぴったりな特徴があります。

Honoとは何か?

HonoはTypeScript/JavaScriptで書かれた、軽量でモダンなWebフレームワークです。特徴を簡単にまとめると:

  • 軽量:驚くほど小さなバンドルサイズ
  • 高速:ベンチマークで高いパフォーマンス
  • Edge-first:Cloudflare Workers、Deno、Bun など様々なランタイムで動作
  • 型安全:TypeScriptファーストの設計
  • Express風:親しみやすいAPI設計

「Express.jsに慣れている人なら、すぐに使い始められますよ。」

Edge-first思想とは?

「Edge-first」という言葉、よく聞くけれど実際のところ何でしょうか?これは現代のWebアプリケーション開発における重要な考え方です。

従来のサーバー構成の課題

従来、Webアプリケーションは以下のような構成が一般的でした:

  • 中央集権的なサーバー:1つの場所(データセンター)にサーバーを配置
  • 地理的な制約:ユーザーとサーバーの距離が遠いと、レスポンス時間が長くなる
  • スケールの課題:トラフィック増加時の対応が複雑

Edge Computingの登場

Edge Computingは、これらの課題を解決する仕組みです:

従来のアーキテクチャ:
ユーザー(東京) → インターネット → サーバー(米国) → レスポンス

Edgeアーキテクチャ:
ユーザー(東京) → Edge(東京) → レスポンス

「距離が近い分、当然速くなりますよね。」

Edge-firstの利点

  1. 低レイテンシ

    • ユーザーに近い場所で処理を実行
    • 体感速度の大幅な向上
  2. 高可用性

    • 複数の拠点に分散配置
    • 一部の障害が全体に影響しない
  3. 自動スケーリング

    • 需要に応じて自動的にリソースを調整
    • 開発者がインフラを意識する必要が少ない
  4. グローバル対応

    • 世界中のユーザーに同じ速度でサービス提供
    • 地域ごとの最適化も可能

HonoがEdge環境で輝く理由

1. マルチランタイム対応

Honoは様々なランタイムで動作します:

  • Cloudflare Workers:世界200以上の拠点で実行
  • Deno Deploy:35拠点のグローバルエッジネットワーク
  • Bun:高速なJavaScriptランタイム
  • Node.js:従来のサーバー環境でも利用可能

「1つのコードで、どこでも動くって便利ですよね。」

2. 軽量設計

import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => c.text('Hello Hono!'))

export default app

このシンプルなコードでWebサーバーが完成。バンドルサイズは驚くほど小さく、Edge環境での起動時間を最小限に抑えます。

3. 型安全な開発体験

import { Hono } from 'hono'

const app = new Hono()

app.get('/api/users/:id', (c) => {
  const id = c.req.param('id') // 型安全にパラメータを取得
  return c.json({ id, name: 'John Doe' })
})

TypeScriptとの親和性が高く、開発時のミスを防げます。

実際のEdge環境での活用例

1. API Gateway

const app = new Hono()

// 認証ミドルウェア
app.use('/api/*', async (c, next) => {
  const token = c.req.header('Authorization')
  // トークン検証ロジック
  await next()
})

// リクエストの振り分け
app.get('/api/users/*', (c) => {
  // ユーザー関連のAPIへプロキシ
})

app.get('/api/products/*', (c) => {
  // 商品関連のAPIへプロキシ
})

「Edge環境で認証やルーティングを処理して、バックエンドの負荷を軽減できます。」

2. 静的サイトの動的機能追加

import { Hono } from 'hono'
import { serveStatic } from 'hono/cloudflare-workers'

const app = new Hono()

// 静的ファイルの配信
app.use('/*', serveStatic({ root: './dist' }))

// 動的なAPIエンドポイント
app.get('/api/search', async (c) => {
  const query = c.req.query('q')
  // 検索処理をEdgeで実行
  return c.json({ results: [...] })
})

3. A/Bテストの実装

app.get('/feature', (c) => {
  const userId = c.req.header('x-user-id')
  const variant = getUserVariant(userId) // Edge環境で高速に判定

  if (variant === 'A') {
    return c.html('<h1>Version A</h1>')
  } else {
    return c.html('<h1>Version B</h1>')
  }
})

他フレームワークとの比較

フレームワークバンドルサイズEdge対応型安全性学習コスト
Hono⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Express.js⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Fastify⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Next.js⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

「HonoはEdge環境に特化している分、この用途では他を圧倒していますね。」

まとめ

Honoは単なる新しいフレームワークではなく、Edge-first時代の要求に応える設計思想を持ったツールです。軽量、高速、そして型安全な開発体験を提供しながら、様々なランタイムで動作する柔軟性を兼ね備えています。

次の章では、実際にHonoを使って「Hello World」アプリケーションを作成し、ローカル環境での実行方法を学んでいきましょう。

ポイント

  • Hono:軽量で高速なEdge-firstフレームワーク
  • Edge-first思想:ユーザーに近い場所でアプリケーションを実行する考え方
  • マルチランタイム対応:Cloudflare Workers、Deno、Bunなど様々な環境で動作
  • 型安全性:TypeScriptファーストの設計で開発生産性を向上
  • シンプルなAPI:Express.js風の親しみやすいインターフェース

参考文献

Hello Worldとローカル実行

Honoの概要を理解したところで、実際に手を動かして最初のアプリケーションを作成してみましょう。「百聞は一見にしかず」ということで、実践を通じて学んでいきます。

プロジェクトの初期化

まず、新しいプロジェクトを作成しましょう。

# 新しいディレクトリを作成
mkdir hono-tutorial
cd hono-tutorial

# package.jsonを初期化
npm init -y

# Honoをインストール
npm install hono

# 開発用の依存関係をインストール
npm install -D typescript @types/node tsx

「tsxは、TypeScriptファイルを直接実行できる便利なツールです。」

最初のHello World

src/index.tsファイルを作成します:

import { Hono } from "hono";

const app = new Hono();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

export default app;

「たったこれだけでWebサーバーが完成です!Express.jsを使ったことがある方なら、すぐに理解できますよね。」

ローカル実行のセットアップ

Node.js環境での実行

src/server.tsファイルを作成します:

import { serve } from "@hono/node-server";
import app from "./index";

const port = 3000;
console.log(`Server is running on port ${port}`);

serve({
  fetch: app.fetch,
  port,
});

@hono/node-serverをインストールしましょう:」

npm install @hono/node-server

package.jsonの設定

package.jsonのscriptsセクションを更新します:

{
  "name": "hono-tutorial",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "start": "node dist/server.js",
    "build": "tsc"
  },
  "dependencies": {
    "hono": "^3.12.0",
    "@hono/node-server": "^1.8.0"
  },
  "devDependencies": {
    "typescript": "^5.0.0",
    "@types/node": "^20.0.0",
    "tsx": "^4.7.0"
  }
}

TypeScript設定

tsconfig.jsonファイルを作成します:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "allowJs": true,
    "strict": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}

実行してみよう!

開発サーバーを起動します:

npm run dev

ブラウザでhttp://localhost:3000にアクセスすると、「Hello Hono!」が表示されます。

ファイルを保存すると自動的にサーバーが再起動される(ホットリロード)ので、開発がとても快適です。

基本的なルートを追加

src/index.tsを拡張して、いくつかのルートを追加してみましょう:

import { Hono } from "hono";

const app = new Hono();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

app.get("/hello/:name", (c) => {
  const name = c.req.param("name");
  return c.text(`Hello, ${name}!`);
});

app.get("/json", (c) => {
  return c.json({
    message: "Hello World",
    timestamp: new Date().toISOString(),
  });
});

app.get("/html", (c) => {
  return c.html(`
    <html>
      <head>
        <title>Hono App</title>
      </head>
      <body>
        <h1>Hello from Hono!</h1>
        <p>This is HTML response</p>
      </body>
    </html>
  `);
});

export default app;

これで以下のエンドポイントが利用できます:

  • GET / - テキストレスポンス
  • GET /hello/:name - パラメータ付きレスポンス
  • GET /json - JSON レスポンス
  • GET /html - HTML レスポンス

さまざまなランタイムでの実行

Honoの魅力の1つは、同じコードが様々な環境で動作することです。

Bunでの実行

Bunがインストールされている場合:

// bun-server.ts
import app from "./src/index";

export default {
  port: 3000,
  fetch: app.fetch,
};

実行:

bun run bun-server.ts

Denoでの実行

// deno-server.ts
import app from "./src/index.ts";

Deno.serve(app.fetch);

実行:

deno run --allow-net deno-server.ts

同じHonoアプリケーションコードが、Node.js、Bun、Denoで動作します。素晴らしいですね!

開発時のTIPS

1. ホットリロードの活用

tsx watchを使うことで、ファイルの変更を自動的に検知してサーバーを再起動できます:

npm run dev

2. デバッグ情報の表示

開発時は、リクエストの詳細情報を確認したい場合があります:

import { Hono } from "hono";
import { logger } from "hono/logger";

const app = new Hono();

// ログ出力ミドルウェアを追加
app.use("*", logger());

app.get("/", (c) => {
  console.log("Request received:", c.req.url);
  return c.text("Hello Hono!");
});

export default app;

3. CORSの設定(フロントエンドとの連携時)

フロントエンドアプリケーションから呼び出す場合:

import { Hono } from "hono";
import { cors } from "hono/cors";

const app = new Hono();

// CORS設定
app.use(
  "*",
  cors({
    origin: ["http://localhost:3000", "http://localhost:5173"],
    allowMethods: ["GET", "POST", "PUT", "DELETE"],
  }),
);

// 以降のルート定義...

エラーのトラブルシューティング

よくあるエラーと対処法

1. モジュール読み込みエラー

Error [ERR_MODULE_NOT_FOUND]: Cannot find module

対処法:

  • package.json"type": "module"が設定されているか確認
  • import文でファイル拡張子が正しく指定されているか確認

2. ポートが使用中のエラー

Error: listen EADDRINUSE: address already in use :::3000

対処法:

# 使用中のプロセスを確認
lsof -i :3000

# または別のポートを使用
serve({
  fetch: app.fetch,
  port: 3001
})

3. TypeScriptの型エラー

Property 'param' does not exist on type 'HonoRequest'

対処法:

  • @types/nodeがインストールされているか確認
  • TypeScript設定が正しいか確認

ディレクトリ構造の確認

現在のプロジェクト構造は以下のようになっているはずです:

hono-tutorial/
├── package.json
├── tsconfig.json
├── src/
│   ├── index.ts      # メインのアプリケーション
│   └── server.ts     # Node.js用のサーバー起動
├── bun-server.ts     # Bun用(オプション)
└── deno-server.ts    # Deno用(オプション)

次のステップ

Hello Worldアプリケーションが動作するようになったら、次の章でルーティングについてより詳しく学んでいきましょう。RESTful APIの設計方法や、より複雑なルート定義について説明します。

やってみよう!

  1. 基本的なAPIエンドポイントを追加

    • /ping - "pong"を返すエンドポイント
    • /time - 現在時刻を返すエンドポイント
  2. パラメータを使った動的なレスポンス

    • /greet/:language/:name - 言語に応じた挨拶を返すエンドポイント
  3. 異なるランタイムでの実行

    • BunやDenoがインストールされていれば、同じコードで動作確認

ポイント

  • シンプルなセットアップ:最小限の設定でHonoアプリケーションを開始
  • ホットリロードtsx watchによる開発効率の向上
  • マルチランタイム対応:Node.js、Bun、Denoで同じコードが動作
  • 型安全性:TypeScriptによる開発時のエラー検知
  • Express風API:親しみやすいルート定義方法

参考文献


created: 2025-09-09 12:55:30+09:00


created: 2025-09-09 12:55:35+09:00

ミドルウェア活用

ミドルウェアは、リクエストとレスポンスの間で実行される処理のことです。認証、ログ出力、CORS設定、エラーハンドリングなど、アプリケーション全体で共通して必要な機能を効率的に実装できます。Honoのミドルウェアシステムについて詳しく学んでいきましょう。

ミドルウェアの基本概念

ミドルウェアの動作流れ

import { Hono } from 'hono'

const app = new Hono()

// ミドルウェア1(前処理)
app.use('*', async (c, next) => {
  console.log('Before request processing')
  await next() // 次の処理へ
  console.log('After request processing')
})

// ミドルウェア2(認証)
app.use('/api/*', async (c, next) => {
  console.log('Authentication check')
  await next()
})

// ルートハンドラー
app.get('/api/data', (c) => {
  console.log('Route handler')
  return c.json({ message: 'Hello' })
})

export default app

実行順序:

  1. Before request processing
  2. Authentication check
  3. Route handler
  4. After request processing

next()を呼ぶことで、次の処理に制御を渡せます。」

ミドルウェアの種類

// 1. 全ての経路に適用
app.use('*', middleware)

// 2. 特定のパスに適用
app.use('/api/*', middleware)

// 3. 特定のメソッドとパスに適用
app.use('GET', '/admin/*', middleware)

// 4. 複数パスに適用
app.use(['/api/*', '/admin/*'], middleware)

組み込みミドルウェア

Honoには便利な組み込みミドルウェアが用意されています。

1. Logger(ログ出力)

import { Hono } from 'hono'
import { logger } from 'hono/logger'

const app = new Hono()

app.use('*', logger())

app.get('/', (c) => c.text('Hello'))

// コンソール出力例:
// GET / 200 - 2.34ms

2. CORS(Cross-Origin Resource Sharing)

import { cors } from 'hono/cors'

// 基本的なCORS設定
app.use('*', cors())

// カスタム設定
app.use('/api/*', cors({
  origin: ['https://example.com', 'https://app.example.com'],
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowHeaders: ['Content-Type', 'Authorization'],
  credentials: true
}))

// 動的なorigin設定
app.use('/api/*', cors({
  origin: (origin) => {
    return origin?.endsWith('.example.com') ? origin : 'https://example.com'
  }
}))

3. JWT認証

import { jwt } from 'hono/jwt'

app.use('/api/protected/*', jwt({
  secret: 'your-secret-key'
}))

// JWTが検証されたルート
app.get('/api/protected/user', (c) => {
  const payload = c.get('jwtPayload')
  return c.json({ user: payload })
})

4. Basic認証

import { basicAuth } from 'hono/basic-auth'

app.use('/admin/*', basicAuth({
  username: 'admin',
  password: 'secret'
}))

// 複数ユーザー対応
app.use('/admin/*', basicAuth({
  verifyUser: (username, password, c) => {
    return username === 'admin' && password === 'secret123' ||
           username === 'user' && password === 'user123'
  }
}))

5. プリティ印刷

import { prettyJSON } from 'hono/pretty-json'

app.use('*', prettyJSON())

app.get('/api/data', (c) => {
  return c.json({
    users: [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' }
    ]
  })
})

// JSONが整形されて出力される

6. セキュアヘッダー

import { secureHeaders } from 'hono/secure-headers'

app.use('*', secureHeaders())

// カスタム設定
app.use('*', secureHeaders({
  contentSecurityPolicy: {
    defaultSrc: ["'self'"],
    styleSrc: ["'self'", "'unsafe-inline'"],
    scriptSrc: ["'self'"]
  },
  crossOriginEmbedderPolicy: false
}))

カスタムミドルウェアの作成

基本的なミドルウェア

// リクエスト時刻を記録するミドルウェア
const timing = async (c: any, next: any) => {
  const start = Date.now()
  await next()
  const end = Date.now()
  console.log(`Request took ${end - start}ms`)
}

app.use('*', timing)

リクエストIDミドルウェア

const requestId = async (c: any, next: any) => {
  const id = crypto.randomUUID()
  c.set('requestId', id)
  c.header('X-Request-ID', id)
  await next()
}

app.use('*', requestId)

app.get('/test', (c) => {
  const requestId = c.get('requestId')
  return c.json({ requestId })
})

API制限ミドルウェア(Rate Limiting)

interface RateLimit {
  count: number
  resetTime: number
}

const rateLimitMap = new Map<string, RateLimit>()

const rateLimit = (maxRequests: number, windowMs: number) => {
  return async (c: any, next: any) => {
    const ip = c.req.header('CF-Connecting-IP') ||
               c.req.header('X-Forwarded-For') ||
               'unknown'

    const now = Date.now()
    const limit = rateLimitMap.get(ip)

    if (!limit || now > limit.resetTime) {
      // 新しいウィンドウ
      rateLimitMap.set(ip, {
        count: 1,
        resetTime: now + windowMs
      })
      await next()
    } else if (limit.count < maxRequests) {
      // まだ制限内
      limit.count++
      await next()
    } else {
      // 制限超過
      return c.json({
        error: 'Too many requests',
        retryAfter: Math.ceil((limit.resetTime - now) / 1000)
      }, 429)
    }
  }
}

// 使用例:1分間に10回まで
app.use('/api/*', rateLimit(10, 60 * 1000))

キャッシュミドルウェア

const cache = new Map<string, { data: any, expires: number }>()

const cacheMiddleware = (ttlSeconds: number) => {
  return async (c: any, next: any) => {
    const key = `${c.req.method}:${c.req.url}`
    const cached = cache.get(key)

    if (cached && cached.expires > Date.now()) {
      // キャッシュヒット
      c.header('X-Cache', 'HIT')
      return c.json(cached.data)
    }

    // 元の処理を実行
    await next()

    // レスポンスをキャッシュ(JSONの場合のみ)
    const response = c.res
    if (response.headers.get('content-type')?.includes('application/json')) {
      const data = await response.clone().json()
      cache.set(key, {
        data,
        expires: Date.now() + (ttlSeconds * 1000)
      })
      c.header('X-Cache', 'MISS')
    }
  }
}

app.use('/api/cache/*', cacheMiddleware(300)) // 5分間キャッシュ

エラーハンドリングミドルウェア

グローバルエラーハンドラー

const errorHandler = async (c: any, next: any) => {
  try {
    await next()
  } catch (error) {
    console.error('Global error:', error)

    if (error instanceof z.ZodError) {
      return c.json({
        error: 'Validation Error',
        details: error.errors
      }, 400)
    }

    if (error.message === 'Unauthorized') {
      return c.json({ error: 'Authentication required' }, 401)
    }

    return c.json({
      error: 'Internal Server Error',
      message: 'An unexpected error occurred'
    }, 500)
  }
}

app.use('*', errorHandler)

非同期エラーのキャッチ

const asyncHandler = (fn: Function) => {
  return async (c: any, next: any) => {
    try {
      await fn(c, next)
    } catch (error) {
      // エラーを次のエラーハンドラーに渡す
      throw error
    }
  }
}

// 使用例
app.get('/api/data', asyncHandler(async (c) => {
  const data = await riskyAsyncOperation()
  return c.json(data)
}))

ミドルウェアの組み合わせ

複数のミドルウェアを効果的に組み合わせる例:

import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { jwt } from 'hono/jwt'
import { prettyJSON } from 'hono/pretty-json'

const app = new Hono()

// 1. 全体的なミドルウェア
app.use('*', logger())
app.use('*', cors({
  origin: ['http://localhost:3000', 'https://myapp.com'],
  credentials: true
}))
app.use('*', prettyJSON())

// 2. API用のミドルウェア
app.use('/api/*', async (c, next) => {
  c.header('X-API-Version', 'v1.0.0')
  await next()
})

// 3. 保護されたルート用のミドルウェア
app.use('/api/protected/*', jwt({ secret: 'secret' }))
app.use('/api/protected/*', rateLimit(100, 60 * 1000)) // 分間100リクエスト

// 4. 管理者用のミドルウェア
app.use('/api/admin/*', basicAuth({
  username: 'admin',
  password: 'secret'
}))

// ルート定義
app.get('/api/public', (c) => c.json({ message: 'Public API' }))
app.get('/api/protected/user', (c) => c.json({ message: 'Protected API' }))
app.get('/api/admin/stats', (c) => c.json({ message: 'Admin API' }))

export default app

条件付きミドルウェア

特定の条件でのみミドルウェアを実行:

const conditionalAuth = async (c: any, next: any) => {
  const path = c.req.url
  const method = c.req.method

  // GET リクエストは認証不要
  if (method === 'GET') {
    await next()
    return
  }

  // POST/PUT/DELETE は認証必須
  const token = c.req.header('Authorization')
  if (!token) {
    return c.json({ error: 'Authorization required' }, 401)
  }

  // トークン検証...
  await next()
}

app.use('/api/posts/*', conditionalAuth)

ミドルウェアのテスト

import { describe, it, expect } from 'vitest'

describe('Rate Limit Middleware', () => {
  it('should allow requests within limit', async () => {
    const app = new Hono()
    app.use('*', rateLimit(2, 1000))
    app.get('/', (c) => c.json({ ok: true }))

    // 最初のリクエスト
    const res1 = await app.request('/')
    expect(res1.status).toBe(200)

    // 2回目のリクエスト
    const res2 = await app.request('/')
    expect(res2.status).toBe(200)

    // 3回目のリクエスト(制限超過)
    const res3 = await app.request('/')
    expect(res3.status).toBe(429)
  })
})

パフォーマンス考慮事項

ミドルウェアの順序

// ❌ 非効率な順序
app.use('*', heavyComputationMiddleware) // 重い処理
app.use('/api/*', authMiddleware)        // 認証

// ✅ 効率的な順序
app.use('/api/*', authMiddleware)        // 認証(早期リターン可能)
app.use('/api/*', heavyComputationMiddleware) // 重い処理

メモリリーク対策

// ❌ メモリリークの可能性
const globalCache = new Map()

const badCacheMiddleware = async (c: any, next: any) => {
  globalCache.set(c.req.url, 'some data') // 無制限にデータが蓄積
  await next()
}

// ✅ サイズ制限付きキャッシュ
class LRUCache<K, V> {
  private cache = new Map<K, V>()

  constructor(private maxSize: number) {}

  set(key: K, value: V) {
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value
      this.cache.delete(firstKey)
    }
    this.cache.set(key, value)
  }

  get(key: K): V | undefined {
    const value = this.cache.get(key)
    if (value !== undefined) {
      // LRU: 最近使用したものを末尾に移動
      this.cache.delete(key)
      this.cache.set(key, value)
    }
    return value
  }
}

const cache = new LRUCache<string, any>(1000)

やってみよう!

実践的なミドルウェアを作成してみましょう:

  1. アクセス統計ミドルウェア

    • エンドポイントごとのアクセス数を記録
    • 統計情報をAPIで取得可能
  2. リクエストサイズ制限ミドルウェア

    • JSONペイロードのサイズを制限
    • 大きすぎるリクエストを拒否
  3. セッションミドルウェア

    • シンプルなセッション管理機能
    • メモリ内でセッションを管理

ポイント

  • ミドルウェアチェーンnext()による処理の連携
  • 組み込みミドルウェア:認証、CORS、ログなどの便利な機能
  • カスタムミドルウェア:アプリケーション固有の処理を共通化
  • エラーハンドリング:グローバルなエラー処理の実装
  • パフォーマンス:ミドルウェアの順序とメモリ使用量の最適化

参考文献

ファイル構成とコード分割

アプリケーションが成長するにつれ、すべてのコードを1つのファイルに書くのは現実的ではありません。適切なファイル構成とコード分割により、保守性と開発効率を大幅に向上させることができます。Honoアプリケーションでの効果的な構成方法について学んでいきましょう。

基本的なプロジェクト構造

小規模プロジェクトの構造

hono-app/
├── package.json
├── tsconfig.json
├── src/
│   ├── index.ts          # メインアプリケーション
│   ├── server.ts         # サーバー起動
│   ├── routes/           # ルート定義
│   │   ├── users.ts
│   │   ├── posts.ts
│   │   └── auth.ts
│   ├── middleware/       # カスタムミドルウェア
│   │   ├── auth.ts
│   │   ├── validation.ts
│   │   └── logger.ts
│   ├── types/           # 型定義
│   │   ├── user.ts
│   │   └── post.ts
│   └── utils/           # ユーティリティ関数
│       ├── crypto.ts
│       └── validation.ts
└── dist/               # ビルド結果

中・大規模プロジェクトの構造

hono-app/
├── package.json
├── tsconfig.json
├── src/
│   ├── app.ts              # アプリケーション設定
│   ├── server.ts           # サーバー起動
│   ├── config/             # 設定ファイル
│   │   ├── index.ts
│   │   ├── database.ts
│   │   └── environment.ts
│   ├── modules/            # 機能モジュール
│   │   ├── users/
│   │   │   ├── routes.ts
│   │   │   ├── handlers.ts
│   │   │   ├── types.ts
│   │   │   ├── validation.ts
│   │   │   └── services.ts
│   │   ├── posts/
│   │   │   ├── routes.ts
│   │   │   ├── handlers.ts
│   │   │   ├── types.ts
│   │   │   └── services.ts
│   │   └── auth/
│   │       ├── routes.ts
│   │       ├── handlers.ts
│   │       ├── middleware.ts
│   │       └── types.ts
│   ├── shared/            # 共通コンポーネント
│   │   ├── middleware/
│   │   ├── types/
│   │   ├── utils/
│   │   └── constants/
│   ├── database/          # データベース関連
│   │   ├── models/
│   │   ├── migrations/
│   │   └── seeds/
│   └── tests/            # テストファイル
│       ├── integration/
│       ├── unit/
│       └── helpers/
├── docker/               # Docker設定
├── scripts/             # ビルドスクリプト
└── dist/               # ビルド結果

ルートの分割

基本的なルート分割

src/routes/users.ts:

import { Hono } from 'hono'

const users = new Hono()

users.get('/', (c) => {
  return c.json({ message: 'Get all users' })
})

users.get('/:id', (c) => {
  const id = c.req.param('id')
  return c.json({ message: `Get user ${id}` })
})

users.post('/', async (c) => {
  const body = await c.req.json()
  return c.json({ message: 'Create user', data: body }, 201)
})

export { users }

src/routes/posts.ts:

import { Hono } from 'hono'

const posts = new Hono()

posts.get('/', (c) => {
  return c.json({ message: 'Get all posts' })
})

posts.get('/:id', (c) => {
  const id = c.req.param('id')
  return c.json({ message: `Get post ${id}` })
})

export { posts }

src/index.ts:

import { Hono } from 'hono'
import { users } from './routes/users'
import { posts } from './routes/posts'

const app = new Hono()

app.route('/api/users', users)
app.route('/api/posts', posts)

export default app

「このようにルートを分割することで、機能ごとにファイルを整理できますね。」

ハンドラーの分離

より複雑なロジックはハンドラー関数として分離しましょう:

src/handlers/userHandlers.ts:

import { Context } from 'hono'
import { User, CreateUserRequest } from '../types/user'
import { UserService } from '../services/userService'

export class UserHandlers {
  constructor(private userService: UserService) {}

  async getAllUsers(c: Context) {
    const page = parseInt(c.req.query('page') || '1')
    const limit = parseInt(c.req.query('limit') || '10')

    try {
      const result = await this.userService.getUsers({ page, limit })
      return c.json(result)
    } catch (error) {
      return c.json({ error: 'Failed to fetch users' }, 500)
    }
  }

  async getUserById(c: Context) {
    const id = c.req.param('id')

    try {
      const user = await this.userService.getUserById(id)
      if (!user) {
        return c.json({ error: 'User not found' }, 404)
      }
      return c.json(user)
    } catch (error) {
      return c.json({ error: 'Failed to fetch user' }, 500)
    }
  }

  async createUser(c: Context) {
    try {
      const body = await c.req.json<CreateUserRequest>()
      const user = await this.userService.createUser(body)
      return c.json(user, 201)
    } catch (error) {
      return c.json({ error: 'Failed to create user' }, 400)
    }
  }
}

src/routes/users.ts:

import { Hono } from 'hono'
import { UserHandlers } from '../handlers/userHandlers'
import { UserService } from '../services/userService'

const users = new Hono()
const userService = new UserService()
const userHandlers = new UserHandlers(userService)

users.get('/', (c) => userHandlers.getAllUsers(c))
users.get('/:id', (c) => userHandlers.getUserById(c))
users.post('/', (c) => userHandlers.createUser(c))

export { users }

サービス層の実装

ビジネスロジックをサービス層に分離:

src/services/userService.ts:

import { User, CreateUserRequest } from '../types/user'
import { DatabaseConnection } from '../database/connection'

export interface PaginationOptions {
  page: number
  limit: number
}

export interface PaginatedResult<T> {
  data: T[]
  pagination: {
    page: number
    limit: number
    total: number
    totalPages: number
  }
}

export class UserService {
  constructor(private db: DatabaseConnection) {}

  async getUsers(options: PaginationOptions): Promise<PaginatedResult<User>> {
    const { page, limit } = options
    const offset = (page - 1) * limit

    const [users, total] = await Promise.all([
      this.db.query('SELECT * FROM users LIMIT ? OFFSET ?', [limit, offset]),
      this.db.query('SELECT COUNT(*) as count FROM users')
    ])

    return {
      data: users,
      pagination: {
        page,
        limit,
        total: total[0].count,
        totalPages: Math.ceil(total[0].count / limit)
      }
    }
  }

  async getUserById(id: string): Promise<User | null> {
    const result = await this.db.query('SELECT * FROM users WHERE id = ?', [id])
    return result[0] || null
  }

  async createUser(userData: CreateUserRequest): Promise<User> {
    const id = crypto.randomUUID()
    const user = {
      id,
      ...userData,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString()
    }

    await this.db.query(
      'INSERT INTO users (id, name, email, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)',
      [user.id, user.name, user.email, user.createdAt, user.updatedAt]
    )

    return user
  }
}

型定義の管理

src/types/user.ts:

export interface User {
  id: string
  name: string
  email: string
  createdAt: string
  updatedAt: string
}

export interface CreateUserRequest {
  name: string
  email: string
}

export interface UpdateUserRequest {
  name?: string
  email?: string
}

export interface UserFilters {
  name?: string
  email?: string
  createdAfter?: string
  createdBefore?: string
}

src/types/api.ts:

export interface ApiResponse<T> {
  data: T
  message?: string
}

export interface ErrorResponse {
  error: string
  message?: string
  details?: any[]
  timestamp: string
}

export interface PaginationMeta {
  page: number
  limit: number
  total: number
  totalPages: number
}

ミドルウェアの分割

src/middleware/validation.ts:

import { Context, Next } from 'hono'
import { z } from 'zod'

export const validate = (schema: z.ZodSchema) => {
  return async (c: Context, next: Next) => {
    try {
      const body = await c.req.json()
      const validatedData = schema.parse(body)
      c.set('validatedData', validatedData)
      await next()
    } catch (error) {
      if (error instanceof z.ZodError) {
        return c.json({
          error: 'Validation failed',
          details: error.errors.map(err => ({
            field: err.path.join('.'),
            message: err.message
          }))
        }, 400)
      }
      return c.json({ error: 'Invalid request body' }, 400)
    }
  }
}

src/middleware/auth.ts:

import { Context, Next } from 'hono'
import { jwt } from 'hono/jwt'

export const authenticateUser = async (c: Context, next: Next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '')

  if (!token) {
    return c.json({ error: 'Authentication required' }, 401)
  }

  try {
    // JWTトークンの検証ロジック
    const payload = await verifyJWT(token)
    c.set('user', payload)
    await next()
  } catch (error) {
    return c.json({ error: 'Invalid token' }, 401)
  }
}

export const requireRole = (role: string) => {
  return async (c: Context, next: Next) => {
    const user = c.get('user')

    if (!user || user.role !== role) {
      return c.json({ error: 'Insufficient permissions' }, 403)
    }

    await next()
  }
}

設定管理

src/config/index.ts:

interface Config {
  port: number
  database: {
    url: string
    maxConnections: number
  }
  jwt: {
    secret: string
    expiresIn: string
  }
  cors: {
    origins: string[]
  }
}

export const config: Config = {
  port: parseInt(process.env.PORT || '3000'),
  database: {
    url: process.env.DATABASE_URL || 'sqlite://./app.db',
    maxConnections: parseInt(process.env.DB_MAX_CONNECTIONS || '10')
  },
  jwt: {
    secret: process.env.JWT_SECRET || 'your-secret-key',
    expiresIn: process.env.JWT_EXPIRES_IN || '24h'
  },
  cors: {
    origins: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000']
  }
}

モジュール化されたアプリケーション

src/modules/users/index.ts:

import { Hono } from 'hono'
import { UserService } from './services'
import { UserHandlers } from './handlers'
import { userValidation } from './validation'
import { authenticateUser } from '../../shared/middleware/auth'

export function createUserModule() {
  const app = new Hono()
  const userService = new UserService()
  const userHandlers = new UserHandlers(userService)

  // 公開エンドポイント
  app.get('/', (c) => userHandlers.getAllUsers(c))
  app.get('/:id', (c) => userHandlers.getUserById(c))

  // 認証が必要なエンドポイント
  app.use('/*', authenticateUser)
  app.post('/', userValidation.create, (c) => userHandlers.createUser(c))
  app.put('/:id', userValidation.update, (c) => userHandlers.updateUser(c))
  app.delete('/:id', (c) => userHandlers.deleteUser(c))

  return app
}

src/app.ts:

import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { createUserModule } from './modules/users'
import { createPostModule } from './modules/posts'
import { createAuthModule } from './modules/auth'
import { config } from './config'

export function createApp() {
  const app = new Hono()

  // グローバルミドルウェア
  app.use('*', logger())
  app.use('*', cors({
    origin: config.cors.origins,
    credentials: true
  }))

  // ルートマウント
  app.route('/api/users', createUserModule())
  app.route('/api/posts', createPostModule())
  app.route('/api/auth', createAuthModule())

  // ヘルスチェック
  app.get('/health', (c) => c.json({ status: 'ok' }))

  // 404ハンドラー
  app.notFound((c) => c.json({ error: 'Not Found' }, 404))

  return app
}

ユーティリティ関数の整理

src/shared/utils/crypto.ts:

import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'

export class CryptoUtils {
  static async hashPassword(password: string): Promise<string> {
    return bcrypt.hash(password, 10)
  }

  static async verifyPassword(password: string, hash: string): Promise<boolean> {
    return bcrypt.compare(password, hash)
  }

  static generateJWT(payload: object, secret: string, expiresIn: string): string {
    return jwt.sign(payload, secret, { expiresIn })
  }

  static verifyJWT(token: string, secret: string): any {
    return jwt.verify(token, secret)
  }
}

src/shared/utils/validation.ts:

import { z } from 'zod'

export const commonSchemas = {
  id: z.string().uuid(),
  email: z.string().email(),
  password: z.string().min(8),
  pagination: z.object({
    page: z.number().min(1).default(1),
    limit: z.number().min(1).max(100).default(10)
  })
}

export const createPaginationSchema = (filters?: z.ZodRawShape) => {
  const base = {
    page: z.coerce.number().min(1).default(1),
    limit: z.coerce.number().min(1).max(100).default(10)
  }

  return z.object(filters ? { ...base, ...filters } : base)
}

ビルドとデプロイ設定

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "allowJs": true,
    "strict": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "baseUrl": "./src",
    "paths": {
      "@/*": ["*"],
      "@/types/*": ["types/*"],
      "@/utils/*": ["shared/utils/*"],
      "@/middleware/*": ["shared/middleware/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "tests/**/*"]
}

やってみよう!

実際にコード分割を行ったプロジェクトを作成してみましょう:

  1. ブログAPI

    • ユーザー管理モジュール
    • 記事管理モジュール
    • コメント管理モジュール
    • 認証モジュール
  2. ECサイトAPI

    • 商品管理
    • 注文管理
    • ユーザー管理
    • 決済処理
  3. タスク管理API

    • プロジェクト管理
    • タスク管理
    • チーム管理
    • 通知システム

ベストプラクティス

1. フォルダー構造の一貫性

// ❌ 不一致な構造
src/
├── userRoutes.ts
├── post-handlers.ts
├── auth_middleware.ts

// ✅ 一貫した構造
src/
├── routes/
│   ├── users.ts
│   └── posts.ts
├── handlers/
│   ├── users.ts
│   └── posts.ts

2. 循環依存の回避

// ❌ 循環依存
// userService.ts
import { PostService } from './postService'

// postService.ts
import { UserService } from './userService'

// ✅ 共通インターフェースを使用
// interfaces/index.ts
export interface IUserService { ... }
export interface IPostService { ... }

3. 適切な抽象化レベル

// ✅ レイヤー分離
Controller -> Service -> Repository -> Database

ポイント

  • モジュール化:機能ごとにファイルとディレクトリを分割
  • レイヤー分離:ハンドラー、サービス、リポジトリの明確な分離
  • 型安全性:共通の型定義でタイプセーフティを確保
  • 設定管理:環境変数と設定ファイルの適切な管理
  • 再利用性:共通コンポーネントとユーティリティの活用

参考文献


created: 2025-09-03 12:00:00+09:00

TypeScriptとHono型システム

HonoはTypeScriptファーストのフレームワークとして設計されており、強力な型システムを提供しています。これにより、開発時にエラーを早期発見でき、IDEでの補完機能も充実します。この章では、HonoとTypeScriptの組み合わせを最大限活用する方法について学んでいきましょう。

Honoの型安全性の基礎

Context型の活用

import { Hono, Context } from 'hono'

const app = new Hono()

// Context型を明示的に指定
app.get('/users/:id', (c: Context) => {
  const id = c.req.param('id') // string型として推論される
  const page = c.req.query('page') // string | undefined型として推論される
  
  return c.json({
    userId: id,
    page: page ? parseInt(page) : 1
  })
})

「Context型を指定することで、c.req.param()c.req.query()の戻り値が適切に型推論されます。」

ジェネリック型を使った型安全なAPI

interface User {
  id: string
  name: string
  email: string
}

interface CreateUserRequest {
  name: string
  email: string
}

app.post('/users', async (c) => {
  // 型安全なJSONパース
  const body = await c.req.json<CreateUserRequest>()
  
  const user: User = {
    id: crypto.randomUUID(),
    name: body.name, // TypeScriptが型をチェック
    email: body.email
  }
  
  // 型安全なJSONレスポンス
  return c.json<User>(user, 201)
})

Honoの高度な型機能

型付きルートパラメータ

// ルートパラメータの型を定義
type UserParams = {
  id: string
}

type PostParams = {
  userId: string
  postId: string
}

app.get('/users/:id', (c) => {
  const { id } = c.req.param() // 自動的に型推論される
  return c.json({ userId: id })
})

app.get('/users/:userId/posts/:postId', (c) => {
  const { userId, postId } = c.req.param()
  return c.json({ userId, postId })
})

型付きクエリパラメータ

interface SearchQuery {
  q?: string
  page?: string
  limit?: string
  sort?: 'name' | 'date'
}

app.get('/search', (c) => {
  const query = c.req.query() // Record<string, string>型
  
  // より型安全な方法
  const q = c.req.query('q')
  const page = parseInt(c.req.query('page') || '1')
  const limit = parseInt(c.req.query('limit') || '10')
  const sort = c.req.query('sort') as 'name' | 'date' | undefined
  
  return c.json({ q, page, limit, sort })
})

Zodとの連携による高度なバリデーション

import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'

// スキーマ定義
const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().min(0).max(150),
  tags: z.array(z.string()).optional()
})

const UpdateUserSchema = CreateUserSchema.partial()

type CreateUserRequest = z.infer<typeof CreateUserSchema>
type UpdateUserRequest = z.infer<typeof UpdateUserSchema>

// バリデーションミドルウェアの使用
app.post('/users', zValidator('json', CreateUserSchema), async (c) => {
  const user = c.req.valid('json') // CreateUserRequest型で型推論される
  
  // userの各プロパティが型安全にアクセス可能
  console.log(user.name, user.email, user.age)
  
  return c.json({ message: 'User created', user })
})

zValidatorを使うことで、バリデーションと型推論が同時に行われます。非常に便利ですね。」

カスタム型定義

レスポンス型の統一

interface ApiResponse<T> {
  data: T
  message?: string
  timestamp: string
}

interface ErrorResponse {
  error: string
  message?: string
  details?: any[]
  timestamp: string
  path: string
}

interface PaginatedResponse<T> {
  data: T[]
  pagination: {
    page: number
    limit: number
    total: number
    totalPages: number
  }
  timestamp: string
}

// 型安全なレスポンスヘルパー
const createApiResponse = <T>(data: T, message?: string): ApiResponse<T> => ({
  data,
  message,
  timestamp: new Date().toISOString()
})

const createErrorResponse = (
  error: string,
  message?: string,
  details?: any[],
  path?: string
): ErrorResponse => ({
  error,
  message,
  details,
  timestamp: new Date().toISOString(),
  path: path || ''
})

app.get('/users/:id', async (c) => {
  const id = c.req.param('id')
  
  try {
    const user = await getUserById(id)
    
    if (!user) {
      const errorResponse = createErrorResponse(
        'USER_NOT_FOUND',
        `User with ID ${id} not found`,
        undefined,
        c.req.url
      )
      return c.json(errorResponse, 404)
    }
    
    const response = createApiResponse(user, 'User retrieved successfully')
    return c.json(response)
    
  } catch (error) {
    const errorResponse = createErrorResponse(
      'INTERNAL_ERROR',
      'An unexpected error occurred',
      [error],
      c.req.url
    )
    return c.json(errorResponse, 500)
  }
})

環境変数の型定義

interface Environment {
  PORT: number
  DATABASE_URL: string
  JWT_SECRET: string
  JWT_EXPIRES_IN: string
  NODE_ENV: 'development' | 'production' | 'test'
  CORS_ORIGINS: string[]
  LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error'
}

const parseEnvironment = (): Environment => {
  const requiredEnvVars = {
    PORT: parseInt(process.env.PORT || '3000'),
    DATABASE_URL: process.env.DATABASE_URL || 'sqlite://app.db',
    JWT_SECRET: process.env.JWT_SECRET || (() => {
      throw new Error('JWT_SECRET is required')
    })(),
    JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || '24h',
    NODE_ENV: (process.env.NODE_ENV as Environment['NODE_ENV']) || 'development',
    CORS_ORIGINS: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],
    LOG_LEVEL: (process.env.LOG_LEVEL as Environment['LOG_LEVEL']) || 'info'
  }
  
  return requiredEnvVars
}

export const env = parseEnvironment()

型安全なミドルウェア

ジェネリックミドルウェア

interface AuthenticatedUser {
  id: string
  email: string
  role: 'user' | 'admin'
}

// 型安全な認証ミドルウェア
const authenticateUser = async (c: Context, next: Next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '')
  
  if (!token) {
    return c.json({ error: 'Authentication required' }, 401)
  }
  
  try {
    const user: AuthenticatedUser = await verifyToken(token)
    c.set('user', user) // ユーザー情報を Context に設定
    await next()
  } catch (error) {
    return c.json({ error: 'Invalid token' }, 401)
  }
}

// 認証されたユーザー情報を型安全に取得
app.get('/profile', authenticateUser, (c) => {
  const user = c.get('user') as AuthenticatedUser // 型アサーションが必要
  return c.json({
    id: user.id,
    email: user.email,
    role: user.role
  })
})

より型安全なアプローチ

// カスタム Context 型を定義
interface AuthenticatedContext extends Context {
  get(key: 'user'): AuthenticatedUser
}

// 型ガード関数
const isAuthenticated = (c: Context): c is AuthenticatedContext => {
  return c.get('user') !== undefined
}

app.get('/profile', authenticateUser, (c) => {
  if (!isAuthenticated(c)) {
    return c.json({ error: 'Authentication required' }, 401)
  }
  
  const user = c.get('user') // AuthenticatedUser型で推論される
  return c.json({
    id: user.id,
    email: user.email,
    role: user.role
  })
})

データベースとの型連携

Prismaとの組み合わせ

// schema.prisma
/*
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Post {
  id       String @id @default(cuid())
  title    String
  content  String
  authorId String
  author   User   @relation(fields: [authorId], references: [id])
}
*/

import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

// Prismaの型を直接使用
app.get('/users', async (c) => {
  const users = await prisma.user.findMany({
    include: {
      posts: true
    }
  })
  
  // users は User & { posts: Post[] } 型で推論される
  return c.json(users)
})

app.post('/users', zValidator('json', CreateUserSchema), async (c) => {
  const userData = c.req.valid('json')
  
  const user = await prisma.user.create({
    data: userData
  })
  
  return c.json(user, 201)
})

DrizzleORMとの組み合わせ

import { drizzle } from 'drizzle-orm/better-sqlite3'
import { text, integer, sqliteTable } from 'drizzle-orm/sqlite-core'

// スキーマ定義
export const users = sqliteTable('users', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  age: integer('age'),
  createdAt: text('created_at').notNull()
})

// 型推論
export type User = typeof users.$inferSelect
export type CreateUser = typeof users.$inferInsert

const db = drizzle(sqlite)

app.get('/users', async (c) => {
  const allUsers = await db.select().from(users)
  // allUsers は User[] 型で推論される
  return c.json(allUsers)
})

型安全なテスト

import { describe, it, expect } from 'vitest'
import { testClient } from 'hono/testing'

describe('User API', () => {
  const client = testClient(app)
  
  it('should create user', async () => {
    const userData: CreateUserRequest = {
      name: 'John Doe',
      email: 'john@example.com'
    }
    
    const res = await client.users.$post({
      json: userData
    })
    
    expect(res.status).toBe(201)
    
    const user = await res.json()
    expect(user).toMatchObject({
      name: userData.name,
      email: userData.email
    })
  })
  
  it('should validate input', async () => {
    const invalidData = {
      name: '', // 空文字列は無効
      email: 'invalid-email'
    }
    
    const res = await client.users.$post({
      json: invalidData
    })
    
    expect(res.status).toBe(400)
    
    const error = await res.json()
    expect(error).toHaveProperty('error', 'Validation failed')
  })
})

TypeScriptの設定最適化

厳密な設定

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "baseUrl": "./src",
    "paths": {
      "@/*": ["*"],
      "@/types/*": ["types/*"],
      "@/utils/*": ["utils/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

型チェックスクリプト

package.json:

{
  "scripts": {
    "type-check": "tsc --noEmit",
    "type-check:watch": "tsc --noEmit --watch",
    "lint": "eslint src/**/*.ts",
    "lint:fix": "eslint src/**/*.ts --fix"
  }
}

実践的な型設計パターン

状態管理の型安全性

type LoadingState<T> = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string }

interface ApiState<T> {
  data: LoadingState<T>
  refetch: () => Promise<void>
}

// 使用例
app.get('/users/:id', async (c) => {
  const id = c.req.param('id')
  let state: LoadingState<User> = { status: 'loading' }
  
  try {
    const user = await getUserById(id)
    if (user) {
      state = { status: 'success', data: user }
    } else {
      state = { status: 'error', error: 'User not found' }
    }
  } catch (error) {
    state = { status: 'error', error: error.message }
  }
  
  return c.json(state)
})

やってみよう!

型安全性を活用したAPIを作成してみましょう:

  1. 強い型付きのCRUD API

    • Zodバリデーション付き
    • Prisma/DrizzleORMとの連携
    • エラーハンドリングの型安全性
  2. 認証システムの型定義

    • JWT ペイロードの型定義
    • ロールベースアクセス制御
    • ミドルウェアの型安全性
  3. テストコードの型安全性

    • テストケースでの型推論
    • モックデータの型定義

ポイント

  • 型推論の活用:TypeScriptの強力な型推論を最大限活用
  • Zodとの連携:バリデーションと型定義の統一
  • カスタム型定義:アプリケーション固有の型システム構築
  • 厳密な設定:TypeScriptコンパイラの厳密な設定活用
  • 型安全なテスト:テストコードでも型安全性を確保

参考文献

エラーハンドリング

堅牢なWebアプリケーションを構築するには、適切なエラーハンドリングが不可欠です。予期しないエラーからアプリケーションを保護し、ユーザーに分かりやすいエラーメッセージを提供する方法について学んでいきましょう。

エラーハンドリングの基本概念

エラーの種類

Webアプリケーションで発生するエラーは大きく分けて以下の種類があります:

  1. クライアントエラー(4xx)

    • バリデーションエラー(400 Bad Request)
    • 認証エラー(401 Unauthorized)
    • 認可エラー(403 Forbidden)
    • リソースが見つからない(404 Not Found)
  2. サーバーエラー(5xx)

    • 内部サーバーエラー(500 Internal Server Error)
    • データベース接続エラー
    • 外部API連携エラー

Honoでのエラーハンドリング

基本的なエラーレスポンス

import { Hono } from 'hono'

const app = new Hono()

app.get('/users/:id', async (c) => {
  const id = c.req.param('id')

  // バリデーション
  if (!id || !/^\d+$/.test(id)) {
    return c.json({
      error: 'INVALID_ID',
      message: 'User ID must be a positive integer'
    }, 400)
  }

  try {
    const user = await getUserById(id)

    if (!user) {
      return c.json({
        error: 'USER_NOT_FOUND',
        message: `User with ID ${id} not found`
      }, 404)
    }

    return c.json(user)

  } catch (error) {
    console.error('Error fetching user:', error)
    return c.json({
      error: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred'
    }, 500)
  }
})

グローバルエラーハンドラーの実装

import { Hono } from 'hono'

class AppError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public details?: any
  ) {
    super(message)
    this.name = 'AppError'
  }
}

const app = new Hono()

// グローバルエラーハンドラー
const errorHandler = async (c: any, next: any) => {
  try {
    await next()
  } catch (error) {
    console.error('Global error handler:', error)

    if (error instanceof AppError) {
      return c.json({
        error: error.code,
        message: error.message,
        details: error.details,
        timestamp: new Date().toISOString(),
        path: c.req.url
      }, error.statusCode)
    }

    // 予期しないエラー
    return c.json({
      error: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred',
      timestamp: new Date().toISOString(),
      path: c.req.url
    }, 500)
  }
}

app.use('*', errorHandler)

// 使用例
app.get('/users/:id', async (c) => {
  const id = c.req.param('id')

  if (!id || !/^\d+$/.test(id)) {
    throw new AppError(400, 'INVALID_ID', 'User ID must be a positive integer')
  }

  const user = await getUserById(id)
  if (!user) {
    throw new AppError(404, 'USER_NOT_FOUND', `User with ID ${id} not found`)
  }

  return c.json(user)
})

「カスタムエラークラスを使うことで、エラーの詳細情報を構造化できますね。」

詳細なエラー分類

エラーコードの体系化

// エラーコード定義
export const ErrorCodes = {
  // バリデーションエラー
  VALIDATION_FAILED: 'VALIDATION_FAILED',
  INVALID_ID: 'INVALID_ID',
  INVALID_EMAIL: 'INVALID_EMAIL',
  REQUIRED_FIELD_MISSING: 'REQUIRED_FIELD_MISSING',

  // 認証・認可エラー
  AUTHENTICATION_REQUIRED: 'AUTHENTICATION_REQUIRED',
  INVALID_TOKEN: 'INVALID_TOKEN',
  TOKEN_EXPIRED: 'TOKEN_EXPIRED',
  INSUFFICIENT_PERMISSIONS: 'INSUFFICIENT_PERMISSIONS',

  // リソースエラー
  USER_NOT_FOUND: 'USER_NOT_FOUND',
  POST_NOT_FOUND: 'POST_NOT_FOUND',
  RESOURCE_ALREADY_EXISTS: 'RESOURCE_ALREADY_EXISTS',

  // ビジネスロジックエラー
  INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE',
  ORDER_ALREADY_PROCESSED: 'ORDER_ALREADY_PROCESSED',
  INVALID_OPERATION: 'INVALID_OPERATION',

  // システムエラー
  DATABASE_ERROR: 'DATABASE_ERROR',
  EXTERNAL_SERVICE_ERROR: 'EXTERNAL_SERVICE_ERROR',
  INTERNAL_ERROR: 'INTERNAL_ERROR'
} as const

type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes]

// エラークラスの拡張
class ValidationError extends AppError {
  constructor(message: string, details?: any) {
    super(400, ErrorCodes.VALIDATION_FAILED, message, details)
  }
}

class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super(404, `${resource.toUpperCase()}_NOT_FOUND`, `${resource} with ID ${id} not found`)
  }
}

class UnauthorizedError extends AppError {
  constructor(message = 'Authentication required') {
    super(401, ErrorCodes.AUTHENTICATION_REQUIRED, message)
  }
}

class ForbiddenError extends AppError {
  constructor(message = 'Insufficient permissions') {
    super(403, ErrorCodes.INSUFFICIENT_PERMISSIONS, message)
  }
}

非同期エラーのハンドリング

// 非同期処理をラップするヘルパー
const asyncHandler = (fn: Function) => {
  return async (c: any, next?: any) => {
    try {
      return await fn(c, next)
    } catch (error) {
      // エラーを上位のエラーハンドラーに渡す
      throw error
    }
  }
}

// 使用例
app.get('/users/:id', asyncHandler(async (c) => {
  const id = c.req.param('id')
  const user = await getUserById(id) // この処理でエラーが発生する可能性

  if (!user) {
    throw new NotFoundError('User', id)
  }

  return c.json(user)
}))

Zodバリデーションとエラーハンドリング

詳細なバリデーションエラー

import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'

const CreateUserSchema = z.object({
  name: z.string()
    .min(1, 'Name is required')
    .max(100, 'Name must be less than 100 characters'),
  email: z.string()
    .email('Invalid email format'),
  age: z.number()
    .int('Age must be an integer')
    .min(0, 'Age must be positive')
    .max(150, 'Age must be realistic'),
  tags: z.array(z.string())
    .max(10, 'Maximum 10 tags allowed')
    .optional()
})

// カスタムバリデーションエラーハンドラー
const validationErrorHandler = (result: any, c: any) => {
  if (!result.success) {
    const errors = result.error.errors.map((err: any) => ({
      field: err.path.join('.'),
      message: err.message,
      code: err.code
    }))

    return c.json({
      error: ErrorCodes.VALIDATION_FAILED,
      message: 'Validation failed',
      details: errors,
      timestamp: new Date().toISOString(),
      path: c.req.url
    }, 400)
  }
}

app.post('/users',
  zValidator('json', CreateUserSchema, validationErrorHandler),
  asyncHandler(async (c) => {
    const userData = c.req.valid('json')
    const user = await createUser(userData)
    return c.json(user, 201)
  })
)

カスタムZodエラーメッセージ

const UserUpdateSchema = z.object({
  name: z.string()
    .min(1, { message: 'お名前は必須です' })
    .max(100, { message: 'お名前は100文字以内で入力してください' }),
  email: z.string()
    .email({ message: 'メールアドレスの形式が正しくありません' }),
  bio: z.string()
    .max(1000, { message: '自己紹介は1000文字以内で入力してください' })
    .optional()
}).refine(
  (data) => data.name !== 'admin',
  {
    message: 'この名前は使用できません',
    path: ['name']
  }
)

データベースエラーのハンドリング

Prismaエラーの処理

import { PrismaClientKnownRequestError, PrismaClientValidationError } from '@prisma/client'

const handlePrismaError = (error: any) => {
  if (error instanceof PrismaClientKnownRequestError) {
    switch (error.code) {
      case 'P2002': // Unique constraint violation
        return new AppError(409, ErrorCodes.RESOURCE_ALREADY_EXISTS,
          'A record with this information already exists')

      case 'P2025': // Record not found
        return new NotFoundError('Resource', 'unknown')

      case 'P2003': // Foreign key constraint violation
        return new ValidationError('Referenced resource does not exist')

      default:
        return new AppError(500, ErrorCodes.DATABASE_ERROR, 'Database operation failed')
    }
  }

  if (error instanceof PrismaClientValidationError) {
    return new ValidationError('Invalid data provided')
  }

  return new AppError(500, ErrorCodes.DATABASE_ERROR, 'Database error occurred')
}

app.post('/users', asyncHandler(async (c) => {
  try {
    const userData = await c.req.json()
    const user = await prisma.user.create({ data: userData })
    return c.json(user, 201)

  } catch (error) {
    throw handlePrismaError(error)
  }
}))

外部API連携のエラーハンドリング

HTTPクライアントエラーの処理

class ExternalServiceError extends AppError {
  constructor(service: string, statusCode: number, message: string) {
    super(502, ErrorCodes.EXTERNAL_SERVICE_ERROR,
      `External service ${service} error: ${message}`)
  }
}

const callExternalAPI = async (url: string, options: any) => {
  try {
    const response = await fetch(url, options)

    if (!response.ok) {
      throw new ExternalServiceError(
        'Payment Service',
        response.status,
        `HTTP ${response.status}: ${response.statusText}`
      )
    }

    return await response.json()

  } catch (error) {
    if (error instanceof ExternalServiceError) {
      throw error
    }

    // ネットワークエラーなど
    throw new AppError(503, ErrorCodes.EXTERNAL_SERVICE_ERROR,
      'External service is currently unavailable')
  }
}

リトライ機能付きエラーハンドリング

const withRetry = async <T>(
  fn: () => Promise<T>,
  maxRetries: number = 3,
  delay: number = 1000
): Promise<T> => {
  let lastError: any

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await fn()
    } catch (error) {
      lastError = error

      // リトライしないエラーの判定
      if (error instanceof AppError && error.statusCode < 500) {
        throw error
      }

      if (attempt === maxRetries) {
        break
      }

      console.log(`Attempt ${attempt} failed, retrying in ${delay}ms...`)
      await new Promise(resolve => setTimeout(resolve, delay))
      delay *= 2 // Exponential backoff
    }
  }

  throw lastError
}

app.get('/external-data', asyncHandler(async (c) => {
  const data = await withRetry(() =>
    callExternalAPI('https://api.example.com/data', { method: 'GET' })
  )

  return c.json(data)
}))

ログとモニタリング

構造化ログ

interface LogContext {
  requestId: string
  userId?: string
  action: string
  resource?: string
  error?: {
    name: string
    message: string
    stack?: string
    code?: string
  }
  duration?: number
  statusCode: number
}

const logger = {
  error: (context: LogContext, message: string) => {
    console.error(JSON.stringify({
      level: 'error',
      message,
      timestamp: new Date().toISOString(),
      ...context
    }))
  },

  warn: (context: Partial<LogContext>, message: string) => {
    console.warn(JSON.stringify({
      level: 'warn',
      message,
      timestamp: new Date().toISOString(),
      ...context
    }))
  },

  info: (context: Partial<LogContext>, message: string) => {
    console.log(JSON.stringify({
      level: 'info',
      message,
      timestamp: new Date().toISOString(),
      ...context
    }))
  }
}

// 拡張されたエラーハンドラー
const enhancedErrorHandler = async (c: any, next: any) => {
  const requestId = crypto.randomUUID()
  const startTime = Date.now()

  c.set('requestId', requestId)

  try {
    await next()

    const duration = Date.now() - startTime
    logger.info({
      requestId,
      action: `${c.req.method} ${c.req.url}`,
      duration,
      statusCode: 200
    }, 'Request completed')

  } catch (error) {
    const duration = Date.now() - startTime
    const userId = c.get('user')?.id

    if (error instanceof AppError) {
      logger.warn({
        requestId,
        userId,
        action: `${c.req.method} ${c.req.url}`,
        duration,
        statusCode: error.statusCode,
        error: {
          name: error.name,
          message: error.message,
          code: error.code
        }
      }, 'Application error occurred')

      return c.json({
        error: error.code,
        message: error.message,
        details: error.details,
        timestamp: new Date().toISOString(),
        requestId
      }, error.statusCode)
    }

    // 予期しないエラー
    logger.error({
      requestId,
      userId,
      action: `${c.req.method} ${c.req.url}`,
      duration,
      statusCode: 500,
      error: {
        name: error.name,
        message: error.message,
        stack: error.stack
      }
    }, 'Unexpected error occurred')

    return c.json({
      error: ErrorCodes.INTERNAL_ERROR,
      message: 'An unexpected error occurred',
      timestamp: new Date().toISOString(),
      requestId
    }, 500)
  }
}

app.use('*', enhancedErrorHandler)

エラー通知システム

Slack通知

const notifyError = async (error: AppError, context: LogContext) => {
  // 重要なエラーのみ通知
  if (error.statusCode >= 500) {
    const slackMessage = {
      text: `🚨 Server Error Alert`,
      blocks: [
        {
          type: 'section',
          text: {
            type: 'mrkdwn',
            text: `*Error:* ${error.code}\n*Message:* ${error.message}\n*Request:* ${context.action}\n*Request ID:* ${context.requestId}`
          }
        }
      ]
    }

    try {
      await fetch(process.env.SLACK_WEBHOOK_URL!, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(slackMessage)
      })
    } catch (notificationError) {
      console.error('Failed to send Slack notification:', notificationError)
    }
  }
}

開発環境でのエラー表示

const isDevelopment = process.env.NODE_ENV === 'development'

const developmentErrorHandler = async (c: any, next: any) => {
  try {
    await next()
  } catch (error) {
    if (isDevelopment) {
      // 開発環境では詳細なエラー情報を返す
      return c.json({
        error: error.name,
        message: error.message,
        stack: error.stack,
        timestamp: new Date().toISOString()
      }, error.statusCode || 500)
    } else {
      // 本番環境では最小限の情報のみ
      return c.json({
        error: 'INTERNAL_ERROR',
        message: 'An error occurred',
        timestamp: new Date().toISOString()
      }, 500)
    }
  }
}

やってみよう!

堅牢なエラーハンドリングシステムを構築してみましょう:

  1. 多層エラーハンドリング

    • グローバルエラーハンドラー
    • 機能別エラーハンドラー
    • バリデーションエラーハンドラー
  2. ログとモニタリング

    • 構造化ログの実装
    • エラー通知システム
    • パフォーマンス監視
  3. ユーザーフレンドリーなエラー

    • 分かりやすいエラーメッセージ
    • 多言語対応
    • 回復可能なエラーの提示

ポイント

  • 一貫したエラー形式:統一されたエラーレスポンス構造
  • 適切な分類:エラーコードによる体系的な分類
  • 詳細なログ:トラブルシューティングに必要な情報の記録
  • グレースフルな処理:予期しないエラーからのアプリケーション保護
  • 開発効率:開発環境での詳細なエラー情報提供

参考文献


created: 2025-09-09 12:55:39+09:00

デプロイ戦略

Honoアプリケーションの真価は、様々な環境にデプロイできる柔軟性にあります。Edge環境からクラウド、従来のサーバーまで、それぞれの特性に応じた最適なデプロイ戦略について学んでいきましょう。

デプロイ先の選択肢

Edge環境

  • Cloudflare Workers:グローバルエッジネットワーク
  • Deno Deploy:高速なV8ベースの実行環境
  • Vercel Edge Functions:フロントエンドとの統合に最適
  • Netlify Edge Functions:Jamstackアーキテクチャに適合

クラウドプラットフォーム

  • AWS Lambda:サーバーレス環境
  • Google Cloud Run:コンテナベースのサーバーレス
  • Azure Container Instances:軽量コンテナ実行
  • Railway:シンプルなデプロイ体験

従来のホスティング

  • Node.js対応のVPS
  • Docker環境
  • Heroku(廃止予定)

Cloudflare Workersへのデプロイ

基本的なセットアップ

# Wranglerをインストール
npm install -g wrangler

# Cloudflareにログイン
wrangler login

# 新しいプロジェクトを作成
wrangler init my-hono-app

wrangler.tomlの設定:

name = "my-hono-app"
main = "src/index.ts"
compatibility_date = "2023-12-01"

# 環境変数
[env.production.vars]
NODE_ENV = "production"

# KVストレージ(オプション)
[[env.production.kv_namespaces]]
binding = "MY_KV"
id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
preview_id = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"

# D1データベース(オプション)
[[env.production.d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

Hono アプリケーションの調整:

import { Hono } from 'hono'

// Cloudflare Workers用の型定義
interface Env {
  NODE_ENV: string
  MY_KV: KVNamespace
  DB: D1Database
}

const app = new Hono<{ Bindings: Env }>()

app.get('/', (c) => {
  return c.text('Hello from Cloudflare Workers!')
})

app.get('/api/data', async (c) => {
  // KVストレージの使用
  const cachedData = await c.env.MY_KV.get('my-key')

  if (cachedData) {
    return c.json(JSON.parse(cachedData))
  }

  // D1データベースの使用
  const result = await c.env.DB.prepare(
    'SELECT * FROM users LIMIT 10'
  ).all()

  // データをキャッシュ
  await c.env.MY_KV.put('my-key', JSON.stringify(result), {
    expirationTtl: 3600 // 1時間でキャッシュ無効
  })

  return c.json(result)
})

export default app

「Cloudflare Workersでは、従来のファイルシステムやNode.jsの一部APIが使えないことに注意が必要です。」

デプロイ実行:

# 開発環境でテスト
wrangler dev

# 本番環境にデプロイ
wrangler deploy

# 環境変数の設定
wrangler secret put JWT_SECRET

Deno Deployへのデプロイ

GitHubとの連携デプロイ

deno.jsonの設定:

{
  "compilerOptions": {
    "allowJs": true,
    "lib": ["deno.window"],
    "strict": true
  },
  "tasks": {
    "dev": "deno run --allow-net --watch main.ts"
  }
}

Deno用のエントリーポイント:

import { serve } from "https://deno.land/std@0.208.0/http/server.ts"
import app from "./src/index.ts"

serve(app.fetch, { port: 8000 })

GitHubリポジトリにpush後、Deno Deployのダッシュボードで:

  1. プロジェクトを作成
  2. GitHubリポジトリを接続
  3. エントリーポイント(main.ts)を指定
  4. 環境変数を設定
  5. 自動デプロイが開始される

Vercelへのデプロイ

Vercel用の設定

vercel.json

{
  "functions": {
    "api/*.ts": {
      "runtime": "@vercel/node"
    }
  },
  "rewrites": [
    {
      "source": "/api/(.*)",
      "destination": "/api/index"
    }
  ]
}

api/index.ts

import { Hono } from 'hono'
import { handle } from 'hono/vercel'

const app = new Hono().basePath('/api')

app.get('/hello', (c) => {
  return c.json({
    message: 'Hello from Vercel!',
    timestamp: new Date().toISOString()
  })
})

app.get('/users/:id', async (c) => {
  const id = c.req.param('id')
  // データベースからユーザー情報を取得...
  return c.json({ userId: id })
})

export default handle(app)

環境変数の設定

# Vercel CLIでの環境変数設定
npm install -g vercel
vercel login

# プロジェクトのリンク
vercel link

# 環境変数の追加
vercel env add DATABASE_URL production
vercel env add JWT_SECRET production

# デプロイ実行
vercel deploy --prod

AWS Lambdaへのデプロイ

Serverless Frameworkを使用

npm install -g serverless
npm install serverless-plugin-typescript

serverless.yml

service: hono-lambda-app

provider:
  name: aws
  runtime: nodejs18.x
  stage: ${env:STAGE, 'dev'}
  region: ${env:AWS_REGION, 'us-east-1'}
  environment:
    NODE_ENV: ${env:NODE_ENV, 'development'}
    DATABASE_URL: ${env:DATABASE_URL}
    JWT_SECRET: ${env:JWT_SECRET}

plugins:
  - serverless-plugin-typescript

functions:
  api:
    handler: src/lambda.handler
    events:
      - http:
          path: /{proxy+}
          method: ANY
          cors: true
      - http:
          path: /
          method: ANY
          cors: true

package:
  exclude:
    - node_modules/**
    - .env
    - README.md

Lambda用のハンドラー:

import { Hono } from 'hono'
import { handle } from 'hono/aws-lambda'
import app from './index'

export const handler = handle(app)

デプロイ実行:

# 環境変数を設定してデプロイ
STAGE=production DATABASE_URL=xxx JWT_SECRET=yyy serverless deploy

Google Cloud Runへのデプロイ

Dockerfileの作成

FROM node:18-alpine

WORKDIR /app

# パッケージファイルをコピー
COPY package*.json ./

# 依存関係をインストール
RUN npm ci --only=production

# アプリケーションファイルをコピー
COPY . .

# TypeScriptをビルド
RUN npm run build

# 非rootユーザーを作成
RUN addgroup -g 1001 -S nodejs
RUN adduser -S hono -u 1001

# ファイルの所有者を変更
CHOWN -R hono:nodejs /app
USER hono

# ポートを公開
EXPOSE 8080

# アプリケーションを起動
CMD ["npm", "start"]

package.jsonのスクリプト調整:

{
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js",
    "dev": "tsx watch src/server.ts"
  }
}

Cloud Runへのデプロイ:

# Google Cloud CLIで認証
gcloud auth login

# プロジェクトを設定
gcloud config set project YOUR_PROJECT_ID

# イメージをビルドしてContainer Registryにプッシュ
gcloud builds submit --tag gcr.io/YOUR_PROJECT_ID/hono-app

# Cloud Runにデプロイ
gcloud run deploy hono-app \
  --image gcr.io/YOUR_PROJECT_ID/hono-app \
  --platform managed \
  --region us-central1 \
  --allow-unauthenticated \
  --set-env-vars "NODE_ENV=production,DATABASE_URL=${DATABASE_URL}"

CI/CDパイプラインの構築

GitHub Actionsでの自動デプロイ

.github/workflows/deploy.yml

name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm run test

      - name: Run type check
        run: npm run type-check

      - name: Run lint
        run: npm run lint

  deploy-cloudflare:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Deploy to Cloudflare Workers
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CF_API_TOKEN }}
          accountId: ${{ secrets.CF_ACCOUNT_ID }}
          command: deploy --env production

  deploy-vercel:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v3

      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod'

段階的デプロイメント

name: Staged Deployment

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  deploy-staging:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      # ... ステージング環境へのデプロイ

  deploy-production:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      # ... 本番環境へのデプロイ

  smoke-test:
    needs: deploy-production
    runs-on: ubuntu-latest
    steps:
      - name: Run smoke tests
        run: |
          curl -f ${{ secrets.PRODUCTION_URL }}/health || exit 1
          curl -f ${{ secrets.PRODUCTION_URL }}/api/ping || exit 1

パフォーマンス最適化

コードスプリッティング

// 動的インポートを使用して必要な時にロード
app.get('/admin/*', async (c) => {
  const { adminRoutes } = await import('./routes/admin')
  return adminRoutes.fetch(c.req, c.env)
})

app.get('/heavy-computation', async (c) => {
  // 重い処理は動的にロード
  const { performHeavyTask } = await import('./utils/heavy-computation')
  const result = await performHeavyTask()
  return c.json(result)
})

バンドル最適化

tsconfig.jsonでの最適化:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "tree-shaking": true,
    "sideEffects": false
  }
}

キャッシュ戦略

import { Hono } from 'hono'

const app = new Hono()

// 静的リソースの長期キャッシュ
app.get('/static/*', (c) => {
  c.header('Cache-Control', 'public, max-age=31536000, immutable')
  // 静的ファイルの配信...
})

// APIレスポンスの短期キャッシュ
app.get('/api/public-data', (c) => {
  c.header('Cache-Control', 'public, max-age=300') // 5分間キャッシュ
  return c.json({ data: 'public information' })
})

// キャッシュを無効にする
app.get('/api/user-data', (c) => {
  c.header('Cache-Control', 'private, no-cache, no-store, must-revalidate')
  return c.json({ sensitive: 'user data' })
})

モニタリングとログ

構造化ログの実装

interface LogData {
  level: 'debug' | 'info' | 'warn' | 'error'
  message: string
  timestamp: string
  requestId?: string
  userId?: string
  duration?: number
  error?: {
    name: string
    message: string
    stack?: string
  }
}

const logger = {
  info: (message: string, data: Partial<LogData> = {}) => {
    console.log(JSON.stringify({
      level: 'info',
      message,
      timestamp: new Date().toISOString(),
      ...data
    }))
  },

  error: (message: string, error: Error, data: Partial<LogData> = {}) => {
    console.error(JSON.stringify({
      level: 'error',
      message,
      timestamp: new Date().toISOString(),
      error: {
        name: error.name,
        message: error.message,
        stack: error.stack
      },
      ...data
    }))
  }
}

// ログミドルウェア
const loggingMiddleware = async (c: any, next: any) => {
  const requestId = crypto.randomUUID()
  const start = Date.now()

  c.set('requestId', requestId)

  logger.info('Request started', {
    requestId,
    method: c.req.method,
    url: c.req.url
  })

  try {
    await next()

    const duration = Date.now() - start
    logger.info('Request completed', {
      requestId,
      duration,
      status: c.res.status
    })

  } catch (error) {
    const duration = Date.now() - start
    logger.error('Request failed', error, {
      requestId,
      duration
    })
    throw error
  }
}

app.use('*', loggingMiddleware)

ヘルスチェックエンドポイント

app.get('/health', async (c) => {
  const checks = {
    server: 'ok',
    database: 'unknown',
    redis: 'unknown',
    external_api: 'unknown'
  }

  let overallStatus = 'ok'

  // データベース接続チェック
  try {
    await db.raw('SELECT 1')
    checks.database = 'ok'
  } catch (error) {
    checks.database = 'error'
    overallStatus = 'error'
  }

  // Redis接続チェック
  try {
    await redis.ping()
    checks.redis = 'ok'
  } catch (error) {
    checks.redis = 'error'
    overallStatus = 'degraded'
  }

  const statusCode = overallStatus === 'ok' ? 200 :
                    overallStatus === 'degraded' ? 200 : 503

  return c.json({
    status: overallStatus,
    timestamp: new Date().toISOString(),
    version: process.env.npm_package_version,
    checks
  }, statusCode)
})

セキュリティ対策

本番環境での設定

import { secureHeaders } from 'hono/secure-headers'
import { cors } from 'hono/cors'

const app = new Hono()

// セキュリティヘッダーの設定
app.use('*', secureHeaders({
  contentSecurityPolicy: {
    defaultSrc: ["'self'"],
    styleSrc: ["'self'", "'unsafe-inline'"],
    scriptSrc: ["'self'"],
    imgSrc: ["'self'", "data:", "https:"],
    connectSrc: ["'self'"]
  },
  crossOriginEmbedderPolicy: false
}))

// CORS設定
app.use('*', cors({
  origin: process.env.ALLOWED_ORIGINS?.split(',') || [],
  credentials: true,
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowHeaders: ['Content-Type', 'Authorization']
}))

// Rate limiting (Cloudflare Workers の場合)
const rateLimit = new Map<string, { count: number; resetTime: number }>()

app.use('/api/*', async (c, next) => {
  const clientIP = c.req.header('CF-Connecting-IP') || 'unknown'
  const now = Date.now()
  const windowMs = 60000 // 1分
  const maxRequests = 100

  const limit = rateLimit.get(clientIP)

  if (!limit || now > limit.resetTime) {
    rateLimit.set(clientIP, { count: 1, resetTime: now + windowMs })
  } else if (limit.count >= maxRequests) {
    return c.json({ error: 'Too many requests' }, 429)
  } else {
    limit.count++
  }

  await next()
})

やってみよう!

実際にデプロイパイプラインを構築してみましょう:

  1. マルチプラットフォームデプロイ

    • Cloudflare Workers
    • Vercel
    • AWS Lambda
    • それぞれの特性を活かした最適化
  2. CI/CDパイプライン

    • GitHub Actions での自動テスト
    • 段階的デプロイメント
    • ロールバック機能
  3. 監視システム

    • ヘルスチェックエンドポイント
    • エラー通知システム
    • パフォーマンス監視

ポイント

  • プラットフォーム選択:要件に応じた最適なデプロイ先の選択
  • 自動化:CI/CDパイプラインによる品質保証とデプロイの自動化
  • 監視体制:ログとメトリクスによる運用状況の把握
  • セキュリティ:本番環境でのセキュリティ対策の徹底
  • 最適化:各プラットフォームの特性を活かしたパフォーマンス最適化

参考文献

HonoXによるフルスタック構築


created: 2025-09-03 12:30:00+09:00

HonoX概要とアーキテクチャ

フルスタックWeb開発の世界で注目を集めているHonoX(ホノエックス)について学んでいきましょう。HonoXは、高速なHonoフレームワークをベースにした、モダンなメタフレームワークです。「HonoとNext.jsのいいとこ取りができるの?」と思う方もいるでしょう。その疑問にお答えしていきます。

HonoXとは何か?

HonoXは、Hono、Vite、各種UIライブラリを組み合わせた軽量でモダンなメタフレームワークです。Sonikフレームワークの後継として開発されており、フルスタックWebサイトとWeb APIの作成に特化しています。

主な特徴

  • ファイルベースルーティング:Next.jsのような直感的な路線構成
  • 超高速SSR:サーバーサイドレンダリングの高速実行
  • BYOR(Bring Your Own Renderer):好きなUIライブラリを選択可能
  • アイランドアーキテクチャ:必要な部分のみクライアントサイド実行
  • Edge-first設計:Cloudflare Workers、Deno等で動作

「現在はアルファ版のため、今後も変更の可能性があることを念頭に置いて学習していきましょう。」

他のフレームワークとの比較

Next.js との違い

特徴HonoXNext.js
サイズ軽量比較的重い
レンダラー自由選択(BYOR)React固定
Edge対応ネイティブサポート一部制限
学習コスト低〜中中〜高
生態系発展途上豊富

Remix、SvelteKit との違い

// HonoX - シンプルな構成
// app/routes/index.tsx
export default function HomePage() {
  return <h1>Hello HonoX!</h1>
}

// Next.js - より多くの設定が必要
// pages/index.tsx + _app.tsx + next.config.js

「HonoXは最小限の設定で始められ、必要に応じて機能を追加していくアプローチが特徴です。」

アーキテクチャの理解

レイヤー構成

┌─────────────────────────────────────┐
│           フロントエンド層          │
│    (JSX/React/Vue/その他)          │
├─────────────────────────────────────┤
│           HonoX メタ層             │
│   (ルーティング・SSR・ビルド)       │
├─────────────────────────────────────┤
│            Hono コア層             │
│     (HTTP処理・ミドルウェア)        │
├─────────────────────────────────────┤
│            ランタイム層            │
│  (Cloudflare Workers/Deno/Bun)     │
└─────────────────────────────────────┘

BYOR(Bring Your Own Renderer)システム

HonoXの最大の特徴は、様々なレンダラーを選択できることです:

// React使用例
// app/routes/_renderer.tsx
import { jsxRenderer } from 'hono/jsx-renderer'

export default jsxRenderer(({ children }) => {
  return (
    <html>
      <head>
        <title>My HonoX App</title>
      </head>
      <body>
        {children}
      </body>
    </html>
  )
})

// Vue使用例も可能(設定により)
// Solid.js、Preactなども対応

ファイルベースルーティング

app/
├── routes/
│   ├── _renderer.tsx     # レイアウトとレンダラー設定
│   ├── index.tsx         # / (ホームページ)
│   ├── about.tsx         # /about
│   ├── blog/
│   │   ├── _layout.tsx   # /blog/* の共通レイアウト
│   │   ├── index.tsx     # /blog
│   │   └── [id].tsx      # /blog/[id] (動的ルート)
│   └── api/
│       ├── posts.ts      # /api/posts (APIエンドポイント)
│       └── users/
│           └── [id].ts   # /api/users/[id]
└── server.ts             # サーバー設定

アイランドアーキテクチャ

概念の理解

アイランドアーキテクチャでは、静的なHTMLの「海」に、インタラクティブなJavaScriptコンポーネントの「島」を配置します:

// app/routes/product/[id].tsx
import { Counter } from '../islands/Counter'
import { AddToCart } from '../islands/AddToCart'

export default function ProductPage({ id }: { id: string }) {
  return (
    <div>
      {/* 静的コンテンツ(サーバーサイドのみ) */}
      <h1>商品詳細</h1>
      <p>商品ID: {id}</p>
      
      {/* インタラクティブな島(クライアントサイド) */}
      <Counter />
      <AddToCart productId={id} />
    </div>
  )
}

パフォーマンスの利点

// 従来のSPA(すべてJavaScript)
Bundle Size: 300KB → 全ページで読み込み

// HonoX アイランド(必要な部分のみ)
Static HTML: 50KB + Counter: 5KB + AddToCart: 8KB
= 合計63KB(大幅な削減)

レンダリング戦略

SSR(Server-Side Rendering)

// app/routes/posts/[id].tsx
import { Hono } from 'hono'

const app = new Hono()

// サーバー側でデータを取得してレンダリング
export async function getServerSideProps({ id }: { id: string }) {
  const post = await fetchPost(id)
  return {
    props: { post }
  }
}

export default function PostPage({ post }: { post: Post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  )
}

CSR(Client-Side Rendering)

// app/islands/DynamicContent.tsx
import { useState, useEffect } from 'react'

export function DynamicContent() {
  const [data, setData] = useState(null)
  
  useEffect(() => {
    // クライアントサイドでデータフェッチ
    fetch('/api/dynamic-data')
      .then(res => res.json())
      .then(setData)
  }, [])
  
  return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>
}

ミドルウェアとの統合

HonoXは、Honoの豊富なミドルウェア生態系を活用できます:

// app/server.ts
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { jwt } from 'hono/jwt'

const app = new Hono()

// Honoミドルウェアをそのまま使用
app.use('*', logger())
app.use('/api/*', cors())
app.use('/api/protected/*', jwt({ secret: 'secret' }))

// HonoXルートをマウント
app.route('/', import('./routes'))

export default app

ビルドとデプロイメント

Viteベースのビルドシステム

// vite.config.ts
import { defineConfig } from 'vite'
import honox from 'honox/vite'

export default defineConfig({
  plugins: [honox()],
  build: {
    rollupOptions: {
      output: {
        // アイランドごとに分割
        manualChunks: (id) => {
          if (id.includes('/islands/')) {
            return 'islands'
          }
        }
      }
    }
  }
})

多様なデプロイ先

// Cloudflare Workers用
// wrangler.toml
name = "honox-app"
main = "dist/index.js"

// Deno Deploy用
// deno.json
{
  "tasks": {
    "dev": "deno run --allow-all --watch ./server.ts"
  }
}

実践的なアプリケーション例

ブログサイトの構成

// app/routes/blog/_layout.tsx
export default function BlogLayout({ children }: { children: any }) {
  return (
    <div>
      <nav>
        <a href="/blog">Blog Home</a>
        <a href="/blog/categories">Categories</a>
      </nav>
      <main>{children}</main>
    </div>
  )
}

// app/routes/blog/[slug].tsx
export default function BlogPost({ slug, post }: any) {
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{post.publishedAt}</time>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  )
}

Eコマースサイトの例

// app/routes/shop/product/[id].tsx
import { ProductGallery } from '../../../islands/ProductGallery'
import { AddToCartButton } from '../../../islands/AddToCartButton'

export default function ProductPage({ product }: any) {
  return (
    <div>
      {/* 静的なSEO最適化コンテンツ */}
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>Price: ${product.price}</p>
      
      {/* インタラクティブコンポーネント */}
      <ProductGallery images={product.images} />
      <AddToCartButton productId={product.id} />
    </div>
  )
}

パフォーマンス特性

初期ロード時間の比較

Traditional SPA:
┌────────────────────────────────────┐
│ HTML: 2KB + JS Bundle: 300KB      │
│ = 302KB (First Contentful Paint)  │
└────────────────────────────────────┘

HonoX SSR + Islands:
┌────────────────────────────────────┐
│ HTML: 50KB (Immediate Render)      │
│ + Islands: 15KB (Progressive)      │
│ = 65KB Total                       │
└────────────────────────────────────┘

SEO最適化

// app/routes/_renderer.tsx
export default jsxRenderer(({ children, title, description }) => {
  return (
    <html>
      <head>
        <title>{title}</title>
        <meta name="description" content={description} />
        <meta property="og:title" content={title} />
        <meta property="og:description" content={description} />
      </head>
      <body>{children}</body>
    </html>
  )
})

開発体験の特徴

ホットリロード

# 開発サーバー起動
npm run dev

# ファイル保存時の自動更新
# - SSR部分: サーバー再起動
# - Island部分: HMR(Hot Module Replacement)

TypeScript統合

// 型安全なAPI呼び出し
export const api = new Hono()
  .get('/posts', (c) => c.json({ posts: [] }))
  .post('/posts', async (c) => {
    const body = await c.req.json()
    return c.json({ success: true })
  })

type ApiType = typeof api

// クライアント側で型安全
import type { ApiType } from './server'

注意すべきポイント

アルファ版の制約

  • API変更の可能性:今後のアップデートで破綻的変更あり
  • ドキュメント不足:公式ドキュメントが発展途上
  • コミュニティ規模:Next.jsと比べて小規模

適用場面の判断

// ✅ HonoX が適している場合
- 高速なSSRが必要
- Edge環境でのデプロイ
- 軽量なフルスタックアプリ
- レンダラーの自由度が欲しい

// ❌ HonoX が適していない場合  
- 大規模チーム開発
- 豊富なエコシステムが必要
- 安定性を重視するプロジェクト

まとめ

HonoXは、Honoの高速性とモダンなメタフレームワークの機能を組み合わせた魅力的な選択肢です。特にEdge環境での実行や、軽量なフルスタックアプリケーションの開発において威力を発揮します。

次の章では、実際にHonoXプロジェクトのセットアップを行い、開発環境を構築していきましょう。

ポイント

  • メタフレームワーク:Hono + Vite + UIライブラリの組み合わせ
  • BYOR:好きなレンダラー(React、Vue等)を選択可能
  • アイランドアーキテクチャ:必要な部分のみクライアントサイド実行
  • Edge-first:Cloudflare WorkersやDenoで高速実行
  • 軽量性:最小限の設定でフルスタック開発が可能

参考文献


created: 2025-09-03 12:30:00+09:00

プロジェクトセットアップ

HonoXの基本概念を理解したところで、実際にプロジェクトをセットアップしてみましょう。「新しいフレームワークのセットアップって複雑じゃないの?」と心配する方もいるかもしれませんが、HonoXは非常にシンプルに始められます。

前提条件の確認

必要な環境

# Node.jsのバージョン確認(18以上推奨)
node --version
# v18.0.0 以上

# npmのバージョン確認
npm --version
# 8.0.0 以上推奨

# 任意:パッケージマネージャー
pnpm --version  # または
bun --version   # または
yarn --version

開発ツールの準備

推奨するエディター設定:

  • VS Code + TypeScript拡張
  • Cursor (AI搭載エディター)
  • WebStorm (JetBrains製IDE)

プロジェクトの初期化

1. HonoXプロジェクトの作成

# npm使用
npm create honox my-honox-app

# pnpm使用
pnpm create honox my-honox-app

# bun使用
bun create honox my-honox-app

# プロジェクトディレクトリに移動
cd my-honox-app

create honoxコマンドが最も簡単な開始方法です。必要なファイルとディレクトリが自動生成されます。」

2. 手動セットアップ(詳細理解用)

より理解を深めるため、手動でセットアップしてみましょう:

# 空のプロジェクトディレクトリを作成
mkdir my-honox-app
cd my-honox-app

# package.jsonを初期化
npm init -y

3. 依存関係のインストール

# HonoXと関連パッケージ
npm install honox hono

# 開発依存関係
npm install -D @types/node typescript vite

# UI関連(Reactを使用する場合)
npm install react react-dom
npm install -D @types/react @types/react-dom

# ビルドツール
npm install -D @vitejs/plugin-react

プロジェクト構成の作成

基本的なディレクトリ構造

# ディレクトリの作成
mkdir -p app/routes app/islands app/components
mkdir -p public static

# 基本ファイルの作成
touch app/server.ts
touch vite.config.ts
touch tsconfig.json

package.jsonの設定

{
  "name": "my-honox-app",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite --port 3000",
    "build": "vite build",
    "preview": "vite preview",
    "type-check": "tsc --noEmit"
  },
  "dependencies": {
    "hono": "^4.0.0",
    "honox": "^0.1.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "@vitejs/plugin-react": "^4.0.0",
    "typescript": "^5.0.0",
    "vite": "^5.0.0"
  }
}

TypeScript設定

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "allowJs": true,
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "jsx": "react-jsx",
    "baseUrl": ".",
    "paths": {
      "@/*": ["./app/*"],
      "@/components/*": ["./app/components/*"],
      "@/islands/*": ["./app/islands/*"]
    }
  },
  "include": [
    "app/**/*",
    "vite.config.ts"
  ]
}

Vite設定

vite.config.ts:

import { defineConfig } from 'vite'
import honox from 'honox/vite'

export default defineConfig({
  plugins: [honox()],
  server: {
    port: 3000,
    open: true
  },
  resolve: {
    alias: {
      '@': '/app'
    }
  }
})

基本ファイルの作成

サーバーエントリーポイント

app/server.ts:

import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { serveStatic } from 'hono/bun'

const app = new Hono()

// ミドルウェアの設定
app.use('*', logger())

// 静的ファイルの配信
app.use('/static/*', serveStatic({ root: './' }))
app.use('/favicon.ico', serveStatic({ path: './public/favicon.ico' }))

export default app

レンダラー設定

app/routes/_renderer.tsx:

import { jsxRenderer } from 'hono/jsx-renderer'

export default jsxRenderer(({ children, title, description }) => {
  return (
    <html lang="ja">
      <head>
        <meta charSet="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>{title || 'My HonoX App'}</title>
        <meta name="description" content={description || 'HonoXで作成したアプリケーション'} />
        <link rel="icon" type="image/x-icon" href="/favicon.ico" />
      </head>
      <body>
        <div id="root">{children}</div>
      </body>
    </html>
  )
})

ホームページの作成

app/routes/index.tsx:

export default function HomePage() {
  return (
    <div>
      <h1>Welcome to HonoX!</h1>
      <p>高速でモダンなフルスタックフレームワーク</p>
      <nav>
        <ul>
          <li><a href="/about">About</a></li>
          <li><a href="/blog">Blog</a></li>
        </ul>
      </nav>
    </div>
  )
}

Aboutページ

app/routes/about.tsx:

export default function AboutPage() {
  return (
    <div>
      <h1>About Us</h1>
      <p>このサイトはHonoXで構築されています。</p>
      <a href="/">ホームに戻る</a>
    </div>
  )
}

スタイリングの設定

CSS Modulesの設定

app/styles/globals.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 
               'Helvetica Neue', Arial, sans-serif;
  line-height: 1.6;
  color: #333;
}

h1, h2, h3 {
  margin-bottom: 1rem;
}

p {
  margin-bottom: 1rem;
}

a {
  color: #0066cc;
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

Tailwind CSSの導入(オプション)

# Tailwind CSS のインストール
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

tailwind.config.js:

/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./app/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

環境変数の設定

開発環境設定

.env:

# アプリケーション設定
NODE_ENV=development
PORT=3000

# データベース設定(後で使用)
DATABASE_URL=sqlite://./dev.db

# API設定
API_BASE_URL=http://localhost:3000

.env.example:

# 環境変数のテンプレート
NODE_ENV=development
PORT=3000
DATABASE_URL=your_database_url
API_BASE_URL=your_api_base_url

環境変数の型定義

app/types/env.d.ts:

declare module 'process' {
  global {
    namespace NodeJS {
      interface ProcessEnv {
        NODE_ENV: 'development' | 'production' | 'test'
        PORT: string
        DATABASE_URL: string
        API_BASE_URL: string
      }
    }
  }
}

開発サーバーの起動

基本的な起動

# 開発サーバーの起動
npm run dev

# またはポート指定
npm run dev -- --port 3001

ホットリロードの確認

ファイルを編集して保存すると、自動的にブラウザが更新されることを確認しましょう:

// app/routes/index.tsx を編集
export default function HomePage() {
  return (
    <div>
      <h1>Welcome to HonoX! 🎉</h1> {/* 絵文字を追加 */}
      <p>高速でモダンなフルスタックフレームワーク</p>
    </div>
  )
}

デバッグ設定

VS Code設定

.vscode/launch.json:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Launch HonoX",
      "type": "node",
      "request": "launch",
      "program": "${workspaceFolder}/node_modules/.bin/vite",
      "args": ["--mode", "development"],
      "env": {
        "NODE_ENV": "development"
      },
      "console": "integratedTerminal"
    }
  ]
}

.vscode/settings.json:

{
  "typescript.preferences.importModuleSpecifier": "relative",
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "emmet.includeLanguages": {
    "typescript": "html",
    "typescriptreact": "html"
  }
}

品質管理ツールの設定

ESLintとPrettierの導入

# ESLint関連
npm install -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin

# Prettier関連  
npm install -D prettier eslint-config-prettier eslint-plugin-prettier

.eslintrc.js:

module.exports = {
  parser: '@typescript-eslint/parser',
  extends: [
    '@typescript-eslint/recommended',
    'prettier'
  ],
  plugins: ['@typescript-eslint'],
  rules: {
    '@typescript-eslint/no-explicit-any': 'warn',
    '@typescript-eslint/no-unused-vars': 'error'
  }
}

.prettierrc:

{
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5"
}

package.jsonスクリプトの更新

{
  "scripts": {
    "dev": "vite --port 3000",
    "build": "vite build",
    "preview": "vite preview",
    "type-check": "tsc --noEmit",
    "lint": "eslint app --ext .ts,.tsx",
    "lint:fix": "eslint app --ext .ts,.tsx --fix",
    "format": "prettier --write \"app/**/*.{ts,tsx}\"",
    "check": "npm run type-check && npm run lint"
  }
}

初回ビルドテスト

ビルドの実行

# 本番ビルドの実行
npm run build

# ビルド結果の確認
npm run preview

ビルド結果の構造

dist/
├── _worker.js          # Cloudflare Workers用
├── static/             # 静的アセット
│   ├── assets/
│   │   ├── index-[hash].js
│   │   └── index-[hash].css
│   └── favicon.ico
└── server/             # サーバーサイドコード
    └── index.js

よくある問題と解決方法

1. ポートが使用中エラー

# エラー: Port 3000 is already in use
# 解決方法:
lsof -ti:3000 | xargs kill -9
# または別のポートを使用
npm run dev -- --port 3001

2. TypeScriptエラー

// エラー: Cannot find module 'honox/vite'
// 解決方法:node_modules を再インストール
rm -rf node_modules package-lock.json
npm install

3. HMR(Hot Module Replacement)が動作しない

// vite.config.ts の server 設定を確認
export default defineConfig({
  plugins: [honox()],
  server: {
    hmr: {
      port: 3001, // 異なるポートを指定
    },
  },
})

追加設定(オプション)

GitHub Actions設定

.github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
          
      - name: Install dependencies
        run: npm ci
        
      - name: Type check
        run: npm run type-check
        
      - name: Lint
        run: npm run lint
        
      - name: Build
        run: npm run build

Docker設定

Dockerfile:

FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .
RUN npm run build

EXPOSE 3000

CMD ["npm", "run", "preview"]

やってみよう!

セットアップが完了したら、以下を試してみましょう:

  1. 新しいページの追加

    • /contact ページを作成
    • ナビゲーションにリンクを追加
  2. スタイルの適用

    • CSS Modulesまたは Tailwind CSS を使用
    • レスポンシブデザインの実装
  3. 環境変数の活用

    • APIエンドポイントの設定
    • 開発・本番環境の切り替え

ポイント

  • シンプルな開始create honox コマンドで即座にセットアップ完了
  • Viteベース:高速なホットリロードと効率的なビルド
  • TypeScript対応:型安全性を確保した開発環境
  • 柔軟な設定:プロジェクトに応じたカスタマイズが可能
  • 品質管理:ESLint、Prettierによるコード品質維持

参考文献

プロジェクト構造とディレクトリ設計


created: 2025-09-03 12:30:00+09:00

フロントエンドページ開発

HonoXでのフロントエンドページ開発は、従来のReactアプリケーション開発と似ている部分もありますが、SSR(サーバーサイドレンダリング)とアイランドアーキテクチャの恩恵を最大限活用できる点が大きく異なります。実際のページを作りながら学んでいきましょう。

基本的なページの作成

シンプルなページの実装

// app/routes/about.tsx
export default function AboutPage() {
  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-6">私たちについて</h1>
      <p className="text-lg leading-relaxed mb-4">
        このサイトは、HonoXを使って構築されたモダンなWebアプリケーションです。
      </p>
      <p className="text-gray-600">
        高速なSSRと効率的なアイランドアーキテクチャにより、
        優れたユーザー体験を提供します。
      </p>
    </div>
  )
}

「このページは完全にサーバーサイドでレンダリングされ、JavaScriptなしでも表示されます。」

メタデータの設定

// app/routes/about.tsx
export default function AboutPage() {
  return (
    <div>
      <title>私たちについて - My HonoX App</title>
      <meta name="description" content="HonoXで構築されたモダンなWebアプリケーションについて" />
      
      <div className="container mx-auto px-4 py-8">
        <h1>私たちについて</h1>
        {/* コンテンツ */}
      </div>
    </div>
  )
}

レンダラーとレイアウトシステム

グローバルレンダラーの設定

// app/routes/_renderer.tsx
import { jsxRenderer } from 'hono/jsx-renderer'

export default jsxRenderer(({ children, title, description }) => {
  return (
    <html lang="ja">
      <head>
        <meta charSet="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>{title || 'My HonoX App'}</title>
        <meta 
          name="description" 
          content={description || 'HonoXで構築された高速なWebアプリケーション'} 
        />
        
        {/* CSS フレームワークの読み込み */}
        <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
        
        {/* Google Fonts */}
        <link rel="preconnect" href="https://fonts.googleapis.com" />
        <link 
          href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" 
          rel="stylesheet" 
        />
        
        <style dangerouslySetInnerHTML={{
          __html: `
            body { 
              font-family: 'Inter', sans-serif; 
            }
          `
        }} />
      </head>
      <body>
        <div id="root">{children}</div>
      </body>
    </html>
  )
})

セクション別レイアウト

// app/routes/blog/_layout.tsx
import { Header } from '../../components/layout/Header'
import { Footer } from '../../components/layout/Footer'

export default function BlogLayout({ children }: { children: any }) {
  return (
    <div className="min-h-screen bg-gray-50">
      <Header 
        title="Blog"
        navigation={[
          { href: '/', label: 'ホーム' },
          { href: '/blog', label: 'ブログ', current: true },
          { href: '/about', label: 'About' },
        ]}
      />
      
      <main className="max-w-4xl mx-auto px-4 py-8">
        <div className="bg-white rounded-lg shadow-sm p-8">
          {children}
        </div>
      </main>
      
      <Footer />
    </div>
  )
}

動的ルートとデータフェッチ

パラメータを使った動的ページ

// app/routes/blog/[slug].tsx
interface BlogPostProps {
  slug: string
}

export default function BlogPost({ slug }: BlogPostProps) {
  // サーバーサイドでデータを取得(SSR)
  const post = getPostBySlug(slug)
  
  if (!post) {
    return (
      <div className="text-center py-12">
        <h1 className="text-2xl font-bold text-gray-900">記事が見つかりません</h1>
        <p className="mt-2 text-gray-600">指定された記事は存在しないか、削除された可能性があります。</p>
        <a href="/blog" className="mt-4 inline-block text-blue-600 hover:underline">
          ブログ一覧に戻る
        </a>
      </div>
    )
  }

  return (
    <article>
      <header className="mb-8">
        <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
        <div className="flex items-center text-gray-600 text-sm">
          <time dateTime={post.publishedAt}>
            {formatDate(post.publishedAt)}
          </time>
          <span className="mx-2">•</span>
          <span>{post.author.name}</span>
        </div>
        
        {/* タグ表示 */}
        <div className="mt-4 flex flex-wrap gap-2">
          {post.tags.map(tag => (
            <span 
              key={tag}
              className="px-3 py-1 bg-blue-100 text-blue-800 text-xs rounded-full"
            >
              {tag}
            </span>
          ))}
        </div>
      </header>
      
      <div 
        className="prose prose-lg max-w-none"
        dangerouslySetInnerHTML={{ __html: post.htmlContent }}
      />
      
      {/* ソーシャル共有ボタン(アイランド) */}
      <SocialShareButtons 
        title={post.title} 
        url={`https://example.com/blog/${slug}`}
      />
    </article>
  )
}

// サーバーサイドでのデータ取得
async function getPostBySlug(slug: string) {
  // データベースやCMSからデータを取得
  return {
    title: 'HonoXを始めよう',
    content: '...',
    htmlContent: '<p>HonoXは...</p>',
    publishedAt: '2024-01-15',
    author: { name: '田中太郎' },
    tags: ['HonoX', 'フロントエンド', 'チュートリアル']
  }
}

function formatDate(dateString: string): string {
  return new Date(dateString).toLocaleDateString('ja-JP')
}

ページネーション機能

// app/routes/blog/index.tsx
interface BlogIndexProps {
  page?: string
}

export default function BlogIndex({ page = '1' }: BlogIndexProps) {
  const currentPage = parseInt(page)
  const postsPerPage = 10
  
  const { posts, totalPages } = getPaginatedPosts(currentPage, postsPerPage)
  
  return (
    <div>
      <header className="mb-8">
        <h1 className="text-3xl font-bold">ブログ</h1>
        <p className="mt-2 text-gray-600">開発に関する記事を書いています</p>
      </header>
      
      {/* 記事一覧 */}
      <div className="space-y-8">
        {posts.map(post => (
          <article key={post.slug} className="border-b pb-8">
            <h2 className="text-2xl font-semibold mb-2">
              <a 
                href={`/blog/${post.slug}`}
                className="hover:text-blue-600 transition-colors"
              >
                {post.title}
              </a>
            </h2>
            
            <div className="text-gray-600 text-sm mb-3">
              <time dateTime={post.publishedAt}>
                {formatDate(post.publishedAt)}
              </time>
            </div>
            
            <p className="text-gray-700 mb-4">{post.excerpt}</p>
            
            <a 
              href={`/blog/${post.slug}`}
              className="text-blue-600 hover:underline font-medium"
            >
              続きを読む →
            </a>
          </article>
        ))}
      </div>
      
      {/* ページネーション */}
      <Pagination 
        currentPage={currentPage}
        totalPages={totalPages}
        basePath="/blog"
      />
    </div>
  )
}

インタラクティブ要素の統合

アイランドとの連携

// app/routes/contact.tsx
import { ContactForm } from '../islands/forms/ContactForm'
import { Map } from '../islands/ui/Map'

export default function ContactPage() {
  return (
    <div className="max-w-6xl mx-auto px-4 py-12">
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
        
        {/* 左側:静的コンテンツ */}
        <div>
          <h1 className="text-4xl font-bold mb-6">お問い合わせ</h1>
          
          <div className="space-y-6">
            <div>
              <h3 className="text-lg font-semibold mb-2">所在地</h3>
              <p className="text-gray-600">
                〒100-0001<br />
                東京都千代田区千代田1-1<br />
                千代田ビル 10F
              </p>
            </div>
            
            <div>
              <h3 className="text-lg font-semibold mb-2">営業時間</h3>
              <p className="text-gray-600">
                平日 9:00 - 18:00<br />
                土日祝日は休業
              </p>
            </div>
            
            <div>
              <h3 className="text-lg font-semibold mb-2">電話番号</h3>
              <p className="text-gray-600">03-1234-5678</p>
            </div>
          </div>
          
          {/* インタラクティブな地図(アイランド) */}
          <div className="mt-8">
            <h3 className="text-lg font-semibold mb-4">アクセス</h3>
            <Map 
              latitude={35.6762} 
              longitude={139.7653}
              zoom={15}
              className="w-full h-64 rounded-lg"
            />
          </div>
        </div>
        
        {/* 右側:インタラクティブフォーム(アイランド) */}
        <div>
          <h2 className="text-2xl font-semibold mb-6">メッセージを送る</h2>
          <ContactForm apiEndpoint="/api/contact" />
        </div>
        
      </div>
    </div>
  )
}

プログレッシブエンハンスメント

// app/routes/shop/product/[id].tsx
import { AddToCartButton } from '../../../islands/ecommerce/AddToCartButton'
import { ProductGallery } from '../../../islands/ui/ProductGallery'
import { ReviewForm } from '../../../islands/forms/ReviewForm'

interface ProductPageProps {
  id: string
}

export default function ProductPage({ id }: ProductPageProps) {
  const product = getProductById(id)
  
  if (!product) {
    return <ProductNotFound />
  }

  return (
    <div className="max-w-7xl mx-auto px-4 py-8">
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
        
        {/* 商品画像(インタラクティブギャラリー) */}
        <div>
          <ProductGallery 
            images={product.images}
            alt={product.name}
          />
        </div>
        
        {/* 商品情報 */}
        <div>
          <h1 className="text-3xl font-bold mb-4">{product.name}</h1>
          <p className="text-2xl font-semibold text-blue-600 mb-6">
            ¥{product.price.toLocaleString()}
          </p>
          
          <div className="prose mb-8">
            <p>{product.description}</p>
          </div>
          
          {/* スペック表(静的) */}
          <div className="mb-8">
            <h3 className="text-lg font-semibold mb-4">仕様</h3>
            <dl className="grid grid-cols-1 gap-2">
              {Object.entries(product.specifications).map(([key, value]) => (
                <div key={key} className="flex">
                  <dt className="font-medium w-24">{key}:</dt>
                  <dd className="text-gray-600">{value}</dd>
                </div>
              ))}
            </dl>
          </div>
          
          {/* 購入ボタン(インタラクティブ) */}
          <AddToCartButton 
            productId={product.id}
            price={product.price}
            inStock={product.inStock}
          />
          
          {/* 在庫表示(静的だが、JSで更新可能) */}
          <div className="mt-4 text-sm text-gray-600">
            {product.inStock > 0 
              ? `在庫: ${product.inStock}点` 
              : '在庫切れ'
            }
          </div>
        </div>
        
      </div>
      
      {/* レビューセクション */}
      <div className="mt-16">
        <h2 className="text-2xl font-bold mb-8">カスタマーレビュー</h2>
        
        {/* 既存レビュー(静的) */}
        <div className="space-y-6 mb-12">
          {product.reviews.map(review => (
            <div key={review.id} className="border-b pb-6">
              <div className="flex items-center mb-2">
                <div className="flex text-yellow-400">
                  {'★'.repeat(review.rating)}{'☆'.repeat(5-review.rating)}
                </div>
                <span className="ml-2 text-sm text-gray-600">
                  {review.author} - {formatDate(review.createdAt)}
                </span>
              </div>
              <p className="text-gray-700">{review.comment}</p>
            </div>
          ))}
        </div>
        
        {/* レビュー投稿フォーム(インタラクティブ) */}
        <ReviewForm productId={product.id} />
      </div>
    </div>
  )
}

レスポンシブデザインの実装

モバイルファーストアプローチ

// app/routes/index.tsx
export default function HomePage() {
  return (
    <div className="min-h-screen">
      
      {/* ヒーローセクション */}
      <section className="bg-gradient-to-r from-blue-600 to-purple-700 text-white">
        <div className="container mx-auto px-4 py-16 sm:py-24">
          <div className="max-w-3xl">
            <h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight mb-6">
              HonoXで始める
              <br className="hidden sm:block" />
              モダンWeb開発
            </h1>
            <p className="text-lg sm:text-xl leading-relaxed mb-8 opacity-90">
              高速なSSR、効率的なアイランドアーキテクチャ、
              そして優れた開発体験を提供します。
            </p>
            <div className="flex flex-col sm:flex-row gap-4">
              <a 
                href="/docs/getting-started"
                className="bg-white text-blue-600 px-8 py-3 rounded-lg font-semibold
                         hover:bg-gray-100 transition-colors text-center"
              >
                今すぐ始める
              </a>
              <a 
                href="/examples"
                className="border-2 border-white px-8 py-3 rounded-lg font-semibold
                         hover:bg-white hover:text-blue-600 transition-colors text-center"
              >
                サンプルを見る
              </a>
            </div>
          </div>
        </div>
      </section>
      
      {/* 特徴セクション */}
      <section className="py-16 sm:py-24">
        <div className="container mx-auto px-4">
          <div className="text-center mb-16">
            <h2 className="text-3xl sm:text-4xl font-bold mb-4">主な特徴</h2>
            <p className="text-lg text-gray-600 max-w-2xl mx-auto">
              HonoXが提供する強力な機能で、Webアプリケーション開発を加速させましょう
            </p>
          </div>
          
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
            {features.map((feature, index) => (
              <div key={index} className="text-center group">
                <div className="bg-blue-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:bg-blue-200 transition-colors">
                  <span className="text-2xl">{feature.icon}</span>
                </div>
                <h3 className="text-xl font-semibold mb-3">{feature.title}</h3>
                <p className="text-gray-600 leading-relaxed">{feature.description}</p>
              </div>
            ))}
          </div>
        </div>
      </section>
      
      {/* CTA セクション */}
      <section className="bg-gray-50 py-16 sm:py-24">
        <div className="container mx-auto px-4 text-center">
          <h2 className="text-3xl sm:text-4xl font-bold mb-4">
            今すぐHonoXを試してみませんか?
          </h2>
          <p className="text-lg text-gray-600 mb-8 max-w-2xl mx-auto">
            わずか数分でプロジェクトを立ち上げ、
            モダンなWebアプリケーションの開発を始められます。
          </p>
          
          {/* ニュースレター登録フォーム(インタラクティブ) */}
          <NewsletterSignup />
        </div>
      </section>
    </div>
  )
}

const features = [
  {
    icon: '⚡',
    title: '超高速SSR',
    description: 'サーバーサイドレンダリングによる高速な初期表示と優れたSEO'
  },
  {
    icon: '🏝️', 
    title: 'アイランドアーキテクチャ',
    description: '必要な部分のみJavaScriptを実行し、パフォーマンスを最適化'
  },
  {
    icon: '🔧',
    title: '柔軟性',
    description: 'ReactやVue等、好きなUIライブラリを選択可能'
  }
]

SEO最適化

構造化データの実装

// app/routes/blog/[slug].tsx
export default function BlogPost({ slug }: { slug: string }) {
  const post = getPostBySlug(slug)
  
  // 構造化データの作成
  const structuredData = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    "headline": post.title,
    "description": post.excerpt,
    "author": {
      "@type": "Person",
      "name": post.author.name
    },
    "datePublished": post.publishedAt,
    "dateModified": post.updatedAt,
    "mainEntityOfPage": {
      "@type": "WebPage",
      "@id": `https://example.com/blog/${slug}`
    }
  }

  return (
    <div>
      {/* SEO メタタグ */}
      <title>{post.title} | My Blog</title>
      <meta name="description" content={post.excerpt} />
      <meta property="og:title" content={post.title} />
      <meta property="og:description" content={post.excerpt} />
      <meta property="og:type" content="article" />
      <meta property="og:url" content={`https://example.com/blog/${slug}`} />
      {post.featuredImage && (
        <meta property="og:image" content={post.featuredImage} />
      )}
      
      {/* 構造化データ */}
      <script 
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
      />
      
      {/* 記事コンテンツ */}
      <article>
        {/* ... */}
      </article>
    </div>
  )
}

やってみよう!

HonoXでページ開発を実践してみましょう:

  1. ランディングページ

    • ヒーローセクション
    • 特徴紹介
    • お問い合わせフォーム
  2. ブログシステム

    • 記事一覧ページ
    • 個別記事ページ
    • カテゴリ別表示
  3. 商品カタログ

    • 商品一覧(フィルタリング機能)
    • 商品詳細ページ
    • ショッピングカート

ポイント

  • SSRの活用:SEOに優れた高速な初期表示
  • アイランド統合:必要な部分のみインタラクティブ化
  • レスポンシブデザイン:モバイルファーストアプローチ
  • メタデータ管理:適切なSEO最適化
  • プログレッシブエンハンスメント:段階的な機能向上

参考文献


created: 2025-09-03 12:30:00+09:00

API定義とサーバー関数

HonoXでは、フロントエンドとバックエンドの境界が曖昧になり、同一プロジェクト内でAPIエンドポイントとページを同時に開発できます。Honoの強力なAPIルーティング機能を活用して、型安全でスケーラブルなAPI設計を学んでいきましょう。

APIルートの基本

シンプルなAPIエンドポイント

// app/routes/api/hello.ts
import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => {
  return c.json({
    message: 'Hello from HonoX API!',
    timestamp: new Date().toISOString()
  })
})

export default app

RESTfulなCRUD API

// app/routes/api/posts/index.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

const app = new Hono()

// バリデーションスキーマ
const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
  tags: z.array(z.string()).optional()
})

const UpdatePostSchema = CreatePostSchema.partial()

// 投稿一覧取得 GET /api/posts
app.get('/', async (c) => {
  const page = parseInt(c.req.query('page') || '1')
  const limit = parseInt(c.req.query('limit') || '10')
  const tag = c.req.query('tag')

  try {
    const posts = await getPostsWithPagination({ page, limit, tag })
    return c.json({
      data: posts,
      pagination: {
        page,
        limit,
        hasNext: posts.length === limit
      }
    })
  } catch (error) {
    return c.json({ error: 'Failed to fetch posts' }, 500)
  }
})

// 投稿作成 POST /api/posts
app.post('/', zValidator('json', CreatePostSchema), async (c) => {
  const postData = c.req.valid('json')
  
  try {
    const newPost = await createPost({
      ...postData,
      authorId: c.get('user')?.id,
      publishedAt: new Date().toISOString()
    })
    
    return c.json(newPost, 201)
  } catch (error) {
    return c.json({ error: 'Failed to create post' }, 500)
  }
})

export default app

動的パラメータを使ったAPI

// app/routes/api/posts/[id].ts
import { Hono } from 'hono'

const app = new Hono()

// 特定の投稿取得 GET /api/posts/:id
app.get('/', async (c) => {
  const id = c.req.param('id')
  
  try {
    const post = await getPostById(id)
    
    if (!post) {
      return c.json({ error: 'Post not found' }, 404)
    }
    
    return c.json(post)
  } catch (error) {
    return c.json({ error: 'Failed to fetch post' }, 500)
  }
})

// 投稿更新 PUT /api/posts/:id
app.put('/', zValidator('json', UpdatePostSchema), async (c) => {
  const id = c.req.param('id')
  const updateData = c.req.valid('json')
  
  try {
    const updatedPost = await updatePost(id, {
      ...updateData,
      updatedAt: new Date().toISOString()
    })
    
    if (!updatedPost) {
      return c.json({ error: 'Post not found' }, 404)
    }
    
    return c.json(updatedPost)
  } catch (error) {
    return c.json({ error: 'Failed to update post' }, 500)
  }
})

// 投稿削除 DELETE /api/posts/:id
app.delete('/', async (c) => {
  const id = c.req.param('id')
  
  try {
    const deleted = await deletePost(id)
    
    if (!deleted) {
      return c.json({ error: 'Post not found' }, 404)
    }
    
    return c.json({ message: 'Post deleted successfully' })
  } catch (error) {
    return c.json({ error: 'Failed to delete post' }, 500)
  }
})

export default app

「HonoXでは、ファイル名がそのままエンドポイントになるので、APIの構造が直感的ですね。」

サーバー関数とデータ操作

データベース操作の抽象化

// app/lib/database/posts.ts
interface Post {
  id: string
  title: string
  content: string
  slug: string
  authorId: string
  publishedAt: string
  updatedAt: string
  tags: string[]
}

interface CreatePostData {
  title: string
  content: string
  authorId: string
  tags?: string[]
}

export class PostsService {
  // 投稿一覧取得(ページネーション付き)
  static async getPostsWithPagination({ 
    page = 1, 
    limit = 10, 
    tag 
  }: {
    page?: number
    limit?: number
    tag?: string
  }): Promise<Post[]> {
    const offset = (page - 1) * limit
    
    let query = `
      SELECT p.*, GROUP_CONCAT(t.name) as tags
      FROM posts p
      LEFT JOIN post_tags pt ON p.id = pt.post_id
      LEFT JOIN tags t ON pt.tag_id = t.id
      WHERE p.published_at IS NOT NULL
    `
    
    const params: any[] = []
    
    if (tag) {
      query += ` AND t.name = ?`
      params.push(tag)
    }
    
    query += `
      GROUP BY p.id
      ORDER BY p.published_at DESC
      LIMIT ? OFFSET ?
    `
    params.push(limit, offset)
    
    const rows = await db.query(query, params)
    
    return rows.map(row => ({
      ...row,
      tags: row.tags ? row.tags.split(',') : []
    }))
  }

  // 投稿作成
  static async createPost(data: CreatePostData): Promise<Post> {
    const id = crypto.randomUUID()
    const slug = generateSlug(data.title)
    const now = new Date().toISOString()
    
    await db.query(`
      INSERT INTO posts (id, title, content, slug, author_id, published_at, updated_at)
      VALUES (?, ?, ?, ?, ?, ?, ?)
    `, [id, data.title, data.content, slug, data.authorId, now, now])
    
    // タグの関連付け
    if (data.tags && data.tags.length > 0) {
      await this.associateTags(id, data.tags)
    }
    
    return this.getPostById(id)!
  }

  // タグの関連付け
  private static async associateTags(postId: string, tags: string[]): Promise<void> {
    for (const tagName of tags) {
      // タグが存在しない場合は作成
      let [tag] = await db.query('SELECT id FROM tags WHERE name = ?', [tagName])
      
      if (!tag) {
        const tagId = crypto.randomUUID()
        await db.query('INSERT INTO tags (id, name) VALUES (?, ?)', [tagId, tagName])
        tag = { id: tagId }
      }
      
      // 投稿とタグを関連付け
      await db.query(
        'INSERT IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)',
        [postId, tag.id]
      )
    }
  }
}

function generateSlug(title: string): string {
  return title
    .toLowerCase()
    .replace(/[^a-z0-9\s-]/g, '')
    .replace(/\s+/g, '-')
    .trim()
}

ファイルアップロードAPI

// app/routes/api/upload.ts
import { Hono } from 'hono'

const app = new Hono()

app.post('/', async (c) => {
  try {
    const formData = await c.req.formData()
    const file = formData.get('file') as File
    
    if (!file) {
      return c.json({ error: 'No file provided' }, 400)
    }
    
    // ファイル検証
    const maxSize = 5 * 1024 * 1024 // 5MB
    if (file.size > maxSize) {
      return c.json({ error: 'File too large' }, 400)
    }
    
    const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
    if (!allowedTypes.includes(file.type)) {
      return c.json({ error: 'Invalid file type' }, 400)
    }
    
    // ファイル保存
    const fileName = `${crypto.randomUUID()}.${getFileExtension(file.name)}`
    const filePath = `uploads/${fileName}`
    
    // Cloudflare Workers環境での例
    if (c.env?.BUCKET) {
      await c.env.BUCKET.put(fileName, file.stream())
    } else {
      // ローカル環境での保存
      const buffer = await file.arrayBuffer()
      await saveFileLocally(filePath, buffer)
    }
    
    return c.json({
      fileName,
      originalName: file.name,
      size: file.size,
      type: file.type,
      url: `/uploads/${fileName}`
    })
    
  } catch (error) {
    return c.json({ error: 'Upload failed' }, 500)
  }
})

function getFileExtension(filename: string): string {
  return filename.split('.').pop() || ''
}

export default app

認証とセキュリティ

JWT認証API

// app/routes/api/auth/login.ts
import { Hono } from 'hono'
import { sign } from 'hono/jwt'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1)
})

const app = new Hono()

app.post('/', zValidator('json', LoginSchema), async (c) => {
  const { email, password } = c.req.valid('json')
  
  try {
    // ユーザー認証
    const user = await authenticateUser(email, password)
    
    if (!user) {
      return c.json({ error: 'Invalid credentials' }, 401)
    }
    
    // JWTトークン生成
    const token = await sign(
      {
        sub: user.id,
        email: user.email,
        role: user.role,
        exp: Math.floor(Date.now() / 1000) + (24 * 60 * 60) // 24時間
      },
      c.env?.JWT_SECRET || 'secret'
    )
    
    return c.json({
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        role: user.role
      },
      token
    })
    
  } catch (error) {
    return c.json({ error: 'Authentication failed' }, 500)
  }
})

async function authenticateUser(email: string, password: string) {
  const [user] = await db.query(
    'SELECT * FROM users WHERE email = ?',
    [email]
  )
  
  if (!user) return null
  
  const isValid = await verifyPassword(password, user.password_hash)
  return isValid ? user : null
}

export default app

認証ミドルウェア

// app/lib/middleware/auth.ts
import { Context, Next } from 'hono'
import { verify } from 'hono/jwt'

export const authMiddleware = async (c: Context, next: Next) => {
  const authHeader = c.req.header('Authorization')
  
  if (!authHeader?.startsWith('Bearer ')) {
    return c.json({ error: 'Authentication required' }, 401)
  }
  
  const token = authHeader.slice(7)
  
  try {
    const payload = await verify(token, c.env?.JWT_SECRET || 'secret')
    c.set('user', payload)
    await next()
  } catch (error) {
    return c.json({ error: 'Invalid token' }, 401)
  }
}

export const requireRole = (role: string) => {
  return async (c: Context, next: Next) => {
    const user = c.get('user')
    
    if (!user || user.role !== role) {
      return c.json({ error: 'Insufficient permissions' }, 403)
    }
    
    await next()
  }
}

リアルタイム機能

WebSocket実装

// app/routes/api/chat/ws.ts
import { Hono } from 'hono'
import { upgradeWebSocket } from 'hono/ws'

const app = new Hono()

// WebSocket接続の管理
const connections = new Map<string, WebSocket>()

app.get(
  '/',
  upgradeWebSocket((c) => {
    return {
      onOpen(event, ws) {
        const userId = c.get('user')?.id
        if (userId) {
          connections.set(userId, ws)
          console.log(`User ${userId} connected`)
        }
      },
      
      onMessage(event, ws) {
        const data = JSON.parse(event.data.toString())
        
        switch (data.type) {
          case 'chat_message':
            broadcastMessage(data)
            break
          case 'typing':
            broadcastTyping(data)
            break
        }
      },
      
      onClose(event, ws) {
        const userId = findUserByWebSocket(ws)
        if (userId) {
          connections.delete(userId)
          console.log(`User ${userId} disconnected`)
        }
      }
    }
  })
)

function broadcastMessage(message: any) {
  const payload = JSON.stringify({
    type: 'new_message',
    data: message
  })
  
  connections.forEach((ws) => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send(payload)
    }
  })
}

export default app

データ検証とエラーハンドリング

包括的なバリデーション

// app/lib/validation/posts.ts
import { z } from 'zod'

export const PostValidation = {
  create: z.object({
    title: z.string()
      .min(1, 'タイトルは必須です')
      .max(200, 'タイトルは200文字以内で入力してください'),
    content: z.string()
      .min(10, '本文は10文字以上で入力してください')
      .max(10000, '本文は10,000文字以内で入力してください'),
    excerpt: z.string()
      .max(500, '抜粋は500文字以内で入力してください')
      .optional(),
    tags: z.array(z.string().max(50))
      .max(10, 'タグは10個まで追加できます')
      .optional(),
    publishedAt: z.string().datetime().optional(),
    status: z.enum(['draft', 'published']).default('draft')
  }),
  
  update: z.object({
    title: z.string().min(1).max(200).optional(),
    content: z.string().min(10).max(10000).optional(),
    excerpt: z.string().max(500).optional(),
    tags: z.array(z.string().max(50)).max(10).optional(),
    status: z.enum(['draft', 'published']).optional()
  }),
  
  query: z.object({
    page: z.coerce.number().min(1).default(1),
    limit: z.coerce.number().min(1).max(100).default(10),
    tag: z.string().optional(),
    status: z.enum(['draft', 'published']).optional(),
    author: z.string().optional()
  })
}

エラーレスポンスの統一

// app/lib/errors/api.ts
export class APIError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public details?: any
  ) {
    super(message)
    this.name = 'APIError'
  }
}

export const createErrorResponse = (error: any, c: Context) => {
  if (error instanceof APIError) {
    return c.json({
      error: {
        code: error.code,
        message: error.message,
        details: error.details
      },
      timestamp: new Date().toISOString()
    }, error.statusCode)
  }
  
  // 未処理のエラー
  console.error('Unhandled error:', error)
  return c.json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred'
    },
    timestamp: new Date().toISOString()
  }, 500)
}

パフォーマンス最適化

キャッシング戦略

// app/routes/api/posts/index.ts
import { cache } from 'hono/cache'

const app = new Hono()

// レスポンスキャッシュ
app.get(
  '/',
  cache({
    cacheName: 'posts-api',
    cacheControl: 'max-age=300' // 5分間キャッシュ
  }),
  async (c) => {
    const posts = await PostsService.getPostsWithPagination({})
    return c.json(posts)
  }
)

データベースクエリ最適化

// app/lib/database/optimized-queries.ts
export class OptimizedPostsService {
  // N+1問題を解決するバッチローディング
  static async getPostsWithAuthors(postIds: string[]) {
    const posts = await db.query(`
      SELECT 
        p.*,
        u.id as author_id,
        u.name as author_name,
        u.avatar as author_avatar
      FROM posts p
      INNER JOIN users u ON p.author_id = u.id
      WHERE p.id IN (${postIds.map(() => '?').join(',')})
    `, postIds)
    
    return posts
  }
  
  // インデックスを活用した高速検索
  static async searchPosts(query: string, limit = 20) {
    return await db.query(`
      SELECT p.*, 
             MATCH(p.title, p.content) AGAINST(? IN NATURAL LANGUAGE MODE) as relevance
      FROM posts p
      WHERE MATCH(p.title, p.content) AGAINST(? IN NATURAL LANGUAGE MODE)
      ORDER BY relevance DESC
      LIMIT ?
    `, [query, query, limit])
  }
}

やってみよう!

HonoXでAPI開発を実践してみましょう:

  1. ブログAPI

    • 投稿のCRUD操作
    • タグ機能
    • 検索機能
  2. 認証システム

    • ユーザー登録・ログイン
    • JWT認証
    • 権限管理
  3. ファイルアップロード

    • 画像アップロード
    • ファイル検証
    • サムネイル生成

ポイント

  • ファイルベースAPI:直感的なエンドポイント設計
  • 型安全性:zodによるバリデーションとTypeScript統合
  • Honoエコシステム:豊富なミドルウェアとユーティリティ
  • パフォーマンス:効率的なキャッシングとクエリ最適化
  • セキュリティ:認証・認可・入力検証の徹底

参考文献

型安全なAPI連携

HonoXの最大の魅力の一つは、フロントエンドとバックエンドの境界を超えた型安全性です。TypeScriptの強力な型システムを活用して、APIの呼び出しから応答まで、完全に型安全なアプリケーションを構築する方法について学んでいきましょう。

型安全APIクライアントの基礎

APIクライアントの型定義

// app/lib/api/types.ts
// API全体の型定義を一元管理

// 共通レスポンス型
export interface ApiResponse<T> {
  data: T
  message?: string
  timestamp: string
}

export interface PaginatedResponse<T> {
  data: T[]
  pagination: {
    page: number
    limit: number
    total: number
    hasNext: boolean
  }
}

export interface ErrorResponse {
  error: {
    code: string
    message: string
    details?: any[]
  }
  timestamp: string
}

// エンティティ型
export interface User {
  id: string
  email: string
  name: string
  role: 'user' | 'admin'
  createdAt: string
  updatedAt: string
}

export interface Post {
  id: string
  title: string
  content: string
  excerpt: string
  slug: string
  authorId: string
  author: User
  tags: string[]
  status: 'draft' | 'published'
  publishedAt: string
  createdAt: string
  updatedAt: string
}

// リクエスト型
export interface CreatePostRequest {
  title: string
  content: string
  excerpt?: string
  tags?: string[]
  status?: 'draft' | 'published'
}

export interface UpdatePostRequest {
  title?: string
  content?: string
  excerpt?: string
  tags?: string[]
  status?: 'draft' | 'published'
}

型安全なAPIクライアント

// app/lib/api/client.ts
import type {
  ApiResponse,
  PaginatedResponse,
  ErrorResponse,
  Post,
  CreatePostRequest,
  UpdatePostRequest
} from './types'

class APIError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public details?: any[]
  ) {
    super(message)
    this.name = 'APIError'
  }
}

class APIClient {
  private baseUrl: string
  private token?: string

  constructor(baseUrl = '/api') {
    this.baseUrl = baseUrl
  }

  setAuthToken(token: string) {
    this.token = token
  }

  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    const url = `${this.baseUrl}${endpoint}`

    const headers: HeadersInit = {
      'Content-Type': 'application/json',
      ...options.headers,
    }

    if (this.token) {
      headers.Authorization = `Bearer ${this.token}`
    }

    const response = await fetch(url, {
      ...options,
      headers,
    })

    if (!response.ok) {
      const error: ErrorResponse = await response.json()
      throw new APIError(
        response.status,
        error.error.code,
        error.error.message,
        error.error.details
      )
    }

    return response.json()
  }

  // Posts API
  async getPosts(params?: {
    page?: number
    limit?: number
    tag?: string
    author?: string
  }): Promise<PaginatedResponse<Post>> {
    const searchParams = new URLSearchParams()

    if (params?.page) searchParams.set('page', params.page.toString())
    if (params?.limit) searchParams.set('limit', params.limit.toString())
    if (params?.tag) searchParams.set('tag', params.tag)
    if (params?.author) searchParams.set('author', params.author)

    const endpoint = `/posts${searchParams.toString() ? `?${searchParams}` : ''}`
    return this.request<PaginatedResponse<Post>>(endpoint)
  }

  async getPost(id: string): Promise<ApiResponse<Post>> {
    return this.request<ApiResponse<Post>>(`/posts/${id}`)
  }

  async createPost(data: CreatePostRequest): Promise<ApiResponse<Post>> {
    return this.request<ApiResponse<Post>>('/posts', {
      method: 'POST',
      body: JSON.stringify(data)
    })
  }

  async updatePost(id: string, data: UpdatePostRequest): Promise<ApiResponse<Post>> {
    return this.request<ApiResponse<Post>>(`/posts/${id}`, {
      method: 'PUT',
      body: JSON.stringify(data)
    })
  }

  async deletePost(id: string): Promise<ApiResponse<{ message: string }>> {
    return this.request<ApiResponse<{ message: string }>>(`/posts/${id}`, {
      method: 'DELETE'
    })
  }
}

export const apiClient = new APIClient()

「型安全なAPIクライアントを使うことで、呼び出し時点でタイプミスや型の不一致を防げます。」

HonoRPCパターンの活用

RPC風のAPI設計

// app/lib/api/rpc.ts
import type { Context } from 'hono'
import type { Post, CreatePostRequest } from './types'

// サーバー側のハンドラー定義
export const postsHandlers = {
  list: async (c: Context) => {
    const page = parseInt(c.req.query('page') || '1')
    const limit = parseInt(c.req.query('limit') || '10')

    const posts = await getPostsWithPagination({ page, limit })
    return c.json({
      data: posts,
      pagination: { page, limit, hasNext: posts.length === limit }
    })
  },

  get: async (c: Context) => {
    const id = c.req.param('id')
    const post = await getPostById(id)

    if (!post) {
      return c.json({ error: 'Post not found' }, 404)
    }

    return c.json({ data: post })
  },

  create: async (c: Context) => {
    const postData = await c.req.json<CreatePostRequest>()
    const post = await createPost(postData)
    return c.json({ data: post }, 201)
  }
}

// 型推論のためのヘルパー型
export type PostsAPI = {
  [K in keyof typeof postsHandlers]: typeof postsHandlers[K]
}

クライアント側での型安全な呼び出し

// app/lib/api/posts-client.ts
import type { PostsAPI } from './rpc'

type ExtractResponseType<T> = T extends (c: any) => Promise<Response>
  ? T extends (c: any) => Promise<infer R>
    ? R extends Response
      ? any // Response型から実際の戻り値型を抽出
      : never
    : never
  : never

class PostsClient {
  async list(params: { page?: number; limit?: number } = {}) {
    const searchParams = new URLSearchParams()
    if (params.page) searchParams.set('page', params.page.toString())
    if (params.limit) searchParams.set('limit', params.limit.toString())

    const response = await fetch(`/api/posts?${searchParams}`)
    return response.json()
  }

  async get(id: string) {
    const response = await fetch(`/api/posts/${id}`)
    return response.json()
  }

  async create(data: CreatePostRequest) {
    const response = await fetch('/api/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    })
    return response.json()
  }
}

export const postsClient = new PostsClient()

React Hooks との統合

カスタムAPIフック

// app/lib/hooks/usePosts.ts
import { useState, useEffect } from 'react'
import { apiClient } from '../api/client'
import type { Post, PaginatedResponse } from '../api/types'

export function usePosts(params?: {
  page?: number
  limit?: number
  tag?: string
}) {
  const [posts, setPosts] = useState<Post[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  const [pagination, setPagination] = useState({
    page: 1,
    limit: 10,
    total: 0,
    hasNext: false
  })

  useEffect(() => {
    const fetchPosts = async () => {
      try {
        setLoading(true)
        setError(null)

        const response = await apiClient.getPosts(params)

        setPosts(response.data)
        setPagination(response.pagination)
      } catch (err) {
        if (err instanceof Error) {
          setError(err.message)
        } else {
          setError('Failed to fetch posts')
        }
      } finally {
        setLoading(false)
      }
    }

    fetchPosts()
  }, [params?.page, params?.limit, params?.tag])

  return {
    posts,
    loading,
    error,
    pagination,
    refetch: () => fetchPosts()
  }
}

export function usePost(id: string) {
  const [post, setPost] = useState<Post | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    const fetchPost = async () => {
      try {
        setLoading(true)
        setError(null)

        const response = await apiClient.getPost(id)
        setPost(response.data)
      } catch (err) {
        if (err instanceof Error) {
          setError(err.message)
        } else {
          setError('Failed to fetch post')
        }
      } finally {
        setLoading(false)
      }
    }

    if (id) {
      fetchPost()
    }
  }, [id])

  return { post, loading, error }
}

ミューテーション用フック

// app/lib/hooks/usePostMutations.ts
import { useState } from 'react'
import { apiClient } from '../api/client'
import type { CreatePostRequest, UpdatePostRequest, Post } from '../api/types'

export function useCreatePost() {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const createPost = async (data: CreatePostRequest): Promise<Post | null> => {
    try {
      setLoading(true)
      setError(null)

      const response = await apiClient.createPost(data)
      return response.data
    } catch (err) {
      if (err instanceof Error) {
        setError(err.message)
      } else {
        setError('Failed to create post')
      }
      return null
    } finally {
      setLoading(false)
    }
  }

  return {
    createPost,
    loading,
    error
  }
}

export function useUpdatePost() {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const updatePost = async (id: string, data: UpdatePostRequest): Promise<Post | null> => {
    try {
      setLoading(true)
      setError(null)

      const response = await apiClient.updatePost(id, data)
      return response.data
    } catch (err) {
      if (err instanceof Error) {
        setError(err.message)
      } else {
        setError('Failed to update post')
      }
      return null
    } finally {
      setLoading(false)
    }
  }

  return {
    updatePost,
    loading,
    error
  }
}

フォームとの型安全な統合

型安全なフォーム実装

// app/islands/forms/PostForm.tsx
import { useState } from 'react'
import { useCreatePost } from '../../lib/hooks/usePostMutations'
import type { CreatePostRequest } from '../../lib/api/types'

interface PostFormProps {
  onSuccess?: (post: Post) => void
  initialData?: Partial<CreatePostRequest>
}

export function PostForm({ onSuccess, initialData }: PostFormProps) {
  const [formData, setFormData] = useState<CreatePostRequest>({
    title: initialData?.title || '',
    content: initialData?.content || '',
    excerpt: initialData?.excerpt || '',
    tags: initialData?.tags || [],
    status: initialData?.status || 'draft'
  })

  const [validationErrors, setValidationErrors] = useState<
    Partial<Record<keyof CreatePostRequest, string>>
  >({})

  const { createPost, loading, error } = useCreatePost()

  const validateForm = (): boolean => {
    const errors: Partial<Record<keyof CreatePostRequest, string>> = {}

    if (!formData.title.trim()) {
      errors.title = 'タイトルは必須です'
    } else if (formData.title.length > 200) {
      errors.title = 'タイトルは200文字以内で入力してください'
    }

    if (!formData.content.trim()) {
      errors.content = '本文は必須です'
    } else if (formData.content.length < 10) {
      errors.content = '本文は10文字以上で入力してください'
    }

    if (formData.tags && formData.tags.length > 10) {
      errors.tags = 'タグは10個まで追加できます'
    }

    setValidationErrors(errors)
    return Object.keys(errors).length === 0
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    if (!validateForm()) return

    const post = await createPost(formData)
    if (post && onSuccess) {
      onSuccess(post)
    }
  }

  const updateField = <K extends keyof CreatePostRequest>(
    field: K,
    value: CreatePostRequest[K]
  ) => {
    setFormData(prev => ({ ...prev, [field]: value }))

    // バリデーションエラーをクリア
    if (validationErrors[field]) {
      setValidationErrors(prev => ({ ...prev, [field]: undefined }))
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      <div>
        <label htmlFor="title" className="block text-sm font-medium text-gray-700">
          タイトル
        </label>
        <input
          type="text"
          id="title"
          value={formData.title}
          onChange={(e) => updateField('title', e.target.value)}
          className={`mt-1 block w-full rounded-md border-gray-300 shadow-sm ${
            validationErrors.title ? 'border-red-500' : ''
          }`}
        />
        {validationErrors.title && (
          <p className="mt-1 text-sm text-red-600">{validationErrors.title}</p>
        )}
      </div>

      <div>
        <label htmlFor="content" className="block text-sm font-medium text-gray-700">
          本文
        </label>
        <textarea
          id="content"
          rows={10}
          value={formData.content}
          onChange={(e) => updateField('content', e.target.value)}
          className={`mt-1 block w-full rounded-md border-gray-300 shadow-sm ${
            validationErrors.content ? 'border-red-500' : ''
          }`}
        />
        {validationErrors.content && (
          <p className="mt-1 text-sm text-red-600">{validationErrors.content}</p>
        )}
      </div>

      <div>
        <label htmlFor="excerpt" className="block text-sm font-medium text-gray-700">
          抜粋(任意)
        </label>
        <textarea
          id="excerpt"
          rows={3}
          value={formData.excerpt}
          onChange={(e) => updateField('excerpt', e.target.value)}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700">
          ステータス
        </label>
        <select
          value={formData.status}
          onChange={(e) => updateField('status', e.target.value as 'draft' | 'published')}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        >
          <option value="draft">下書き</option>
          <option value="published">公開</option>
        </select>
      </div>

      {error && (
        <div className="text-red-600 text-sm">{error}</div>
      )}

      <button
        type="submit"
        disabled={loading}
        className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
      >
        {loading ? '保存中...' : '投稿を保存'}
      </button>
    </form>
  )
}

リアルタイム通信での型安全性

WebSocketクライアントの型安全実装

// app/lib/websocket/types.ts
export interface WebSocketMessage<T = any> {
  type: string
  data: T
  timestamp: string
}

export interface ChatMessage {
  id: string
  content: string
  authorId: string
  authorName: string
  createdAt: string
}

export type WebSocketMessageMap = {
  'chat_message': ChatMessage
  'user_joined': { userId: string; userName: string }
  'user_left': { userId: string; userName: string }
  'typing': { userId: string; userName: string; isTyping: boolean }
}

// app/lib/websocket/client.ts
export class TypeSafeWebSocketClient {
  private ws: WebSocket | null = null
  private listeners: Map<string, Set<(data: any) => void>> = new Map()

  connect(url: string) {
    this.ws = new WebSocket(url)

    this.ws.onmessage = (event) => {
      try {
        const message: WebSocketMessage = JSON.parse(event.data)
        this.handleMessage(message)
      } catch (error) {
        console.error('Failed to parse WebSocket message:', error)
      }
    }
  }

  private handleMessage(message: WebSocketMessage) {
    const listeners = this.listeners.get(message.type)
    if (listeners) {
      listeners.forEach(listener => listener(message.data))
    }
  }

  on<K extends keyof WebSocketMessageMap>(
    type: K,
    listener: (data: WebSocketMessageMap[K]) => void
  ) {
    if (!this.listeners.has(type)) {
      this.listeners.set(type, new Set())
    }
    this.listeners.get(type)!.add(listener)
  }

  off<K extends keyof WebSocketMessageMap>(
    type: K,
    listener: (data: WebSocketMessageMap[K]) => void
  ) {
    const listeners = this.listeners.get(type)
    if (listeners) {
      listeners.delete(listener)
    }
  }

  send<K extends keyof WebSocketMessageMap>(
    type: K,
    data: WebSocketMessageMap[K]
  ) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      const message: WebSocketMessage<WebSocketMessageMap[K]> = {
        type,
        data,
        timestamp: new Date().toISOString()
      }
      this.ws.send(JSON.stringify(message))
    }
  }
}

エラーハンドリングの型安全性

型安全なエラー処理

// app/lib/errors/types.ts
export type APIErrorCode =
  | 'VALIDATION_ERROR'
  | 'NOT_FOUND'
  | 'UNAUTHORIZED'
  | 'FORBIDDEN'
  | 'INTERNAL_ERROR'
  | 'NETWORK_ERROR'

export interface APIErrorDetails {
  field?: string
  message: string
  code?: string
}

export class TypedAPIError extends Error {
  constructor(
    public readonly statusCode: number,
    public readonly code: APIErrorCode,
    message: string,
    public readonly details?: APIErrorDetails[]
  ) {
    super(message)
    this.name = 'TypedAPIError'
  }

  isValidationError(): this is TypedAPIError & { code: 'VALIDATION_ERROR' } {
    return this.code === 'VALIDATION_ERROR'
  }

  isNotFoundError(): this is TypedAPIError & { code: 'NOT_FOUND' } {
    return this.code === 'NOT_FOUND'
  }

  isUnauthorizedError(): this is TypedAPIError & { code: 'UNAUTHORIZED' } {
    return this.code === 'UNAUTHORIZED'
  }
}

// app/lib/hooks/useErrorHandler.ts
export function useErrorHandler() {
  const handleError = (error: unknown) => {
    if (error instanceof TypedAPIError) {
      if (error.isValidationError()) {
        // バリデーションエラーの場合の処理
        return {
          type: 'validation',
          message: error.message,
          details: error.details
        }
      } else if (error.isUnauthorizedError()) {
        // 認証エラーの場合の処理
        return {
          type: 'auth',
          message: '認証が必要です'
        }
      } else if (error.isNotFoundError()) {
        // 404エラーの場合の処理
        return {
          type: 'not_found',
          message: 'リソースが見つかりません'
        }
      }
    }

    // その他のエラー
    return {
      type: 'unknown',
      message: '予期しないエラーが発生しました'
    }
  }

  return { handleError }
}

やってみよう!

型安全なAPI連携を実践してみましょう:

  1. 完全型安全なCRUD操作

    • 型定義からクライアント作成
    • React Hooks統合
    • エラーハンドリング
  2. リアルタイム機能

    • WebSocket通信の型安全実装
    • メッセージ型の管理
  3. フォーム統合

    • バリデーション付きフォーム
    • 型安全な入力処理

ポイント

  • エンドツーエンド型安全性:APIからUIまで一貫した型定義
  • 開発時エラー検出:コンパイル時の型チェックでバグを防止
  • 自動補完:IDEでの強力な開発支援
  • リファクタリング安全性:型に基づく安全な変更
  • ドキュメント効果:型定義が仕様書の役割を果たす

参考文献

認証とセッション管理

RPC実装とzodバリデーション

HonoXでは、RPC(Remote Procedure Call)パターンとzodバリデーションを組み合わせることで、より型安全で使いやすいAPI設計が可能になります。従来のREST APIとは異なるアプローチで、関数を呼び出すような直感的なAPI利用体験を実現しましょう。

RPC パターンの理解

従来のREST API vs RPC

// REST API パターン
const response = await fetch('/api/posts', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ title: 'Hello', content: '...' })
})
const post = await response.json()

// RPC パターン
const post = await api.posts.create({
  title: 'Hello',
  content: '...'
})

「RPCパターンでは、リモートの関数をローカルの関数のように呼び出せます。」

Hono RPC の基本実装

// app/lib/rpc/server.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

// バリデーションスキーマ
const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10).max(10000),
  excerpt: z.string().max(500).optional(),
  tags: z.array(z.string()).max(10).optional(),
  status: z.enum(['draft', 'published']).default('draft')
})

const UpdatePostSchema = CreatePostSchema.partial()

const PostQuerySchema = z.object({
  page: z.coerce.number().min(1).default(1),
  limit: z.coerce.number().min(1).max(100).default(10),
  tag: z.string().optional(),
  author: z.string().optional(),
  status: z.enum(['draft', 'published']).optional()
})

// RPC API定義
export const postsAPI = new Hono()

// 投稿一覧取得
postsAPI.get(
  '/',
  zValidator('query', PostQuerySchema),
  async (c) => {
    const query = c.req.valid('query')

    try {
      const posts = await PostService.getList(query)
      return c.json({
        success: true,
        data: posts,
        pagination: {
          page: query.page,
          limit: query.limit,
          hasNext: posts.length === query.limit
        }
      })
    } catch (error) {
      return c.json({
        success: false,
        error: 'Failed to fetch posts'
      }, 500)
    }
  }
)

// 投稿作成
postsAPI.post(
  '/',
  zValidator('json', CreatePostSchema),
  async (c) => {
    const postData = c.req.valid('json')
    const user = c.get('user') // 認証ミドルウェアから取得

    try {
      const post = await PostService.create({
        ...postData,
        authorId: user.id
      })

      return c.json({
        success: true,
        data: post
      }, 201)
    } catch (error) {
      return c.json({
        success: false,
        error: 'Failed to create post'
      }, 500)
    }
  }
)

// 投稿更新
postsAPI.put(
  '/:id',
  zValidator('json', UpdatePostSchema),
  async (c) => {
    const id = c.req.param('id')
    const updateData = c.req.valid('json')
    const user = c.get('user')

    try {
      const post = await PostService.update(id, updateData, user.id)

      if (!post) {
        return c.json({
          success: false,
          error: 'Post not found or unauthorized'
        }, 404)
      }

      return c.json({
        success: true,
        data: post
      })
    } catch (error) {
      return c.json({
        success: false,
        error: 'Failed to update post'
      }, 500)
    }
  }
)

// 投稿削除
postsAPI.delete('/:id', async (c) => {
  const id = c.req.param('id')
  const user = c.get('user')

  try {
    const success = await PostService.delete(id, user.id)

    if (!success) {
      return c.json({
        success: false,
        error: 'Post not found or unauthorized'
      }, 404)
    }

    return c.json({
      success: true,
      message: 'Post deleted successfully'
    })
  } catch (error) {
    return c.json({
      success: false,
      error: 'Failed to delete post'
    }, 500)
  }
})

// 型推論のためのAPIスキーマ
export type PostsAPI = typeof postsAPI

高度なzodバリデーション

複雑なスキーマ定義

// app/lib/schemas/user.ts
import { z } from 'zod'

// カスタムバリデーション関数
const isStrongPassword = (password: string): boolean => {
  const hasUpperCase = /[A-Z]/.test(password)
  const hasLowerCase = /[a-z]/.test(password)
  const hasNumbers = /\d/.test(password)
  const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password)

  return password.length >= 8 &&
         hasUpperCase &&
         hasLowerCase &&
         hasNumbers &&
         hasSpecialChar
}

// ユーザー関連スキーマ
export const UserSchemas = {
  // ユーザー登録
  register: z.object({
    email: z.string()
      .email('有効なメールアドレスを入力してください')
      .max(255, 'メールアドレスが長すぎます'),

    password: z.string()
      .min(8, 'パスワードは8文字以上である必要があります')
      .refine(isStrongPassword, {
        message: 'パスワードは大文字、小文字、数字、特殊文字を含む必要があります'
      }),

    confirmPassword: z.string(),

    name: z.string()
      .min(1, '名前は必須です')
      .max(100, '名前は100文字以内で入力してください')
      .regex(/^[^\s].*[^\s]$/, '名前の前後に空白を含めることはできません'),

    profile: z.object({
      bio: z.string()
        .max(500, '自己紹介は500文字以内で入力してください')
        .optional(),

      website: z.string()
        .url('有効なURLを入力してください')
        .optional()
        .or(z.literal('')),

      birthday: z.string()
        .regex(/^\d{4}-\d{2}-\d{2}$/, '日付はYYYY-MM-DD形式で入力してください')
        .optional()
        .refine((date) => {
          if (!date) return true
          const birthDate = new Date(date)
          const today = new Date()
          const age = today.getFullYear() - birthDate.getFullYear()
          return age >= 13 && age <= 120
        }, {
          message: '年齢は13歳以上120歳以下である必要があります'
        })
    }).optional()

  }).refine((data) => data.password === data.confirmPassword, {
    message: 'パスワードが一致しません',
    path: ['confirmPassword']
  }),

  // プロフィール更新
  updateProfile: z.object({
    name: z.string()
      .min(1, '名前は必須です')
      .max(100, '名前は100文字以内で入力してください')
      .optional(),

    profile: z.object({
      bio: z.string().max(500).optional(),
      website: z.string().url().optional().or(z.literal('')),
      avatar: z.string().url().optional(),
      preferences: z.object({
        theme: z.enum(['light', 'dark', 'auto']).default('auto'),
        language: z.enum(['ja', 'en']).default('ja'),
        notifications: z.object({
          email: z.boolean().default(true),
          push: z.boolean().default(false),
          marketing: z.boolean().default(false)
        }).default({})
      }).optional()
    }).optional()
  }),

  // ログイン
  login: z.object({
    email: z.string().email('有効なメールアドレスを入力してください'),
    password: z.string().min(1, 'パスワードを入力してください'),
    remember: z.boolean().optional().default(false)
  })
}

// 型推論
export type RegisterUserRequest = z.infer<typeof UserSchemas.register>
export type UpdateProfileRequest = z.infer<typeof UserSchemas.updateProfile>
export type LoginRequest = z.infer<typeof UserSchemas.login>

動的バリデーション

// app/lib/schemas/dynamic.ts
import { z } from 'zod'

// 条件付きバリデーション
export const createConditionalSchema = (userRole: 'admin' | 'user') => {
  const baseSchema = z.object({
    title: z.string().min(1).max(200),
    content: z.string().min(10).max(10000),
    status: z.enum(['draft', 'published'])
  })

  if (userRole === 'admin') {
    // 管理者は追加フィールドを設定可能
    return baseSchema.extend({
      featured: z.boolean().optional(),
      priority: z.number().min(0).max(10).optional(),
      scheduledAt: z.string().datetime().optional(),
      seoSettings: z.object({
        metaTitle: z.string().max(60).optional(),
        metaDescription: z.string().max(160).optional(),
        keywords: z.array(z.string()).max(10).optional()
      }).optional()
    })
  }

  return baseSchema
}

// 配列の長さに応じた動的バリデーション
export const createBatchSchema = <T extends z.ZodType>(
  itemSchema: T,
  maxItems: number = 100
) => {
  return z.array(itemSchema)
    .min(1, '少なくとも1つの項目が必要です')
    .max(maxItems, `最大${maxItems}件まで処理できます`)
    .refine(
      (items) => {
        // 重複チェック(IDベース)
        const ids = items.map((item: any) => item.id).filter(Boolean)
        return new Set(ids).size === ids.length
      },
      { message: '重複するIDが含まれています' }
    )
}

RPCクライアントの実装

型安全なクライアント生成

// app/lib/rpc/client.ts
import type { PostsAPI } from './server'

// RPC応答の型定義
interface RPCSuccess<T> {
  success: true
  data: T
}

interface RPCError {
  success: false
  error: string
  details?: any[]
}

type RPCResponse<T> = RPCSuccess<T> | RPCError

// APIクライアント基底クラス
abstract class BaseRPCClient {
  protected async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    const response = await fetch(`/api/rpc${endpoint}`, {
      headers: {
        'Content-Type': 'application/json',
        ...options.headers
      },
      ...options
    })

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`)
    }

    const result = await response.json()

    if (!result.success) {
      throw new RPCError(result.error, result.details)
    }

    return result.data
  }
}

export class RPCError extends Error {
  constructor(
    message: string,
    public details?: any[]
  ) {
    super(message)
    this.name = 'RPCError'
  }
}

// Posts用RPCクライアント
export class PostsRPCClient extends BaseRPCClient {
  async getList(params: {
    page?: number
    limit?: number
    tag?: string
    author?: string
    status?: 'draft' | 'published'
  } = {}) {
    const searchParams = new URLSearchParams()
    Object.entries(params).forEach(([key, value]) => {
      if (value !== undefined) {
        searchParams.set(key, String(value))
      }
    })

    const endpoint = `/posts?${searchParams.toString()}`
    return this.request<Post[]>(endpoint)
  }

  async getById(id: string) {
    return this.request<Post>(`/posts/${id}`)
  }

  async create(data: CreatePostRequest) {
    return this.request<Post>('/posts', {
      method: 'POST',
      body: JSON.stringify(data)
    })
  }

  async update(id: string, data: UpdatePostRequest) {
    return this.request<Post>(`/posts/${id}`, {
      method: 'PUT',
      body: JSON.stringify(data)
    })
  }

  async delete(id: string) {
    return this.request<{ message: string }>(`/posts/${id}`, {
      method: 'DELETE'
    })
  }
}

// グローバルクライアントインスタンス
export const rpcClient = {
  posts: new PostsRPCClient()
}

React Hooks統合

// app/lib/hooks/useRPC.ts
import { useState, useEffect, useCallback } from 'react'
import { rpcClient, RPCError } from '../rpc/client'

// 汎用RPC Hook
export function useRPC<T, P extends any[]>(
  rpcCall: (...args: P) => Promise<T>,
  deps: React.DependencyList = []
) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  const execute = useCallback(async (...args: P) => {
    try {
      setLoading(true)
      setError(null)
      const result = await rpcCall(...args)
      setData(result)
      return result
    } catch (err) {
      const errorMessage = err instanceof RPCError
        ? err.message
        : 'An unexpected error occurred'
      setError(errorMessage)
      throw err
    } finally {
      setLoading(false)
    }
  }, deps)

  return {
    data,
    loading,
    error,
    execute,
    refetch: () => execute(...([] as any))
  }
}

// Posts専用フック
export function usePostsList(params: Parameters<typeof rpcClient.posts.getList>[0] = {}) {
  const { data, loading, error, execute, refetch } = useRPC(
    rpcClient.posts.getList,
    [params.page, params.limit, params.tag, params.author, params.status]
  )

  useEffect(() => {
    execute(params)
  }, [execute, params])

  return {
    posts: data || [],
    loading,
    error,
    refetch
  }
}

export function usePost(id: string) {
  const { data, loading, error, execute } = useRPC(
    rpcClient.posts.getById,
    [id]
  )

  useEffect(() => {
    if (id) {
      execute(id)
    }
  }, [execute, id])

  return {
    post: data,
    loading,
    error
  }
}

// ミューテーション専用フック
export function useCreatePost() {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const createPost = async (data: CreatePostRequest) => {
    try {
      setLoading(true)
      setError(null)
      const result = await rpcClient.posts.create(data)
      return result
    } catch (err) {
      const errorMessage = err instanceof RPCError
        ? err.message
        : 'Failed to create post'
      setError(errorMessage)
      throw err
    } finally {
      setLoading(false)
    }
  }

  return {
    createPost,
    loading,
    error
  }
}

バリデーションエラーの高度な処理

フォームレベルでのエラー統合

// app/lib/validation/form-handler.ts
import { z } from 'zod'

interface ValidationError {
  field: string
  message: string
  code: string
}

interface ValidationResult<T> {
  success: boolean
  data?: T
  errors?: ValidationError[]
}

export class FormValidator<T extends z.ZodType> {
  constructor(private schema: T) {}

  validate(data: unknown): ValidationResult<z.infer<T>> {
    try {
      const validData = this.schema.parse(data)
      return {
        success: true,
        data: validData
      }
    } catch (error) {
      if (error instanceof z.ZodError) {
        return {
          success: false,
          errors: error.errors.map(err => ({
            field: err.path.join('.'),
            message: err.message,
            code: err.code
          }))
        }
      }

      return {
        success: false,
        errors: [{
          field: 'root',
          message: 'Validation failed',
          code: 'unknown'
        }]
      }
    }
  }

  validateField(data: unknown, fieldPath: string): ValidationResult<any> {
    try {
      const validData = this.schema.parse(data)
      return { success: true, data: validData }
    } catch (error) {
      if (error instanceof z.ZodError) {
        const fieldErrors = error.errors.filter(err =>
          err.path.join('.') === fieldPath
        )

        if (fieldErrors.length > 0) {
          return {
            success: false,
            errors: fieldErrors.map(err => ({
              field: err.path.join('.'),
              message: err.message,
              code: err.code
            }))
          }
        }
      }

      return { success: true }
    }
  }
}

リアルタイムバリデーション付きフォーム

// app/islands/forms/ValidatedPostForm.tsx
import { useState, useCallback } from 'react'
import { FormValidator } from '../../lib/validation/form-handler'
import { PostSchemas } from '../../lib/schemas/posts'
import { useCreatePost } from '../../lib/hooks/useRPC'

const postValidator = new FormValidator(PostSchemas.create)

export function ValidatedPostForm() {
  const [formData, setFormData] = useState({
    title: '',
    content: '',
    excerpt: '',
    tags: [],
    status: 'draft' as const
  })

  const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
  const [isValidating, setIsValidating] = useState(false)

  const { createPost, loading, error } = useCreatePost()

  // フィールド単位のバリデーション
  const validateField = useCallback((fieldName: string, value: any) => {
    setIsValidating(true)

    // デバウンス処理
    setTimeout(() => {
      const testData = { ...formData, [fieldName]: value }
      const result = postValidator.validateField(testData, fieldName)

      setFieldErrors(prev => ({
        ...prev,
        [fieldName]: result.errors?.[0]?.message || ''
      }))

      setIsValidating(false)
    }, 300)
  }, [formData])

  const updateField = (fieldName: string, value: any) => {
    setFormData(prev => ({ ...prev, [fieldName]: value }))
    validateField(fieldName, value)
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    // フォーム全体のバリデーション
    const validation = postValidator.validate(formData)

    if (!validation.success) {
      const errors: Record<string, string> = {}
      validation.errors?.forEach(error => {
        errors[error.field] = error.message
      })
      setFieldErrors(errors)
      return
    }

    try {
      const post = await createPost(validation.data)
      // 成功時の処理
      console.log('Post created:', post)
    } catch (err) {
      // エラーハンドリング
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      <div>
        <label className="block text-sm font-medium text-gray-700">
          タイトル
        </label>
        <input
          type="text"
          value={formData.title}
          onChange={(e) => updateField('title', e.target.value)}
          className={`mt-1 block w-full rounded-md border ${
            fieldErrors.title ? 'border-red-500' : 'border-gray-300'
          }`}
        />
        {fieldErrors.title && (
          <p className="mt-1 text-sm text-red-600">{fieldErrors.title}</p>
        )}
        {isValidating && (
          <p className="mt-1 text-sm text-gray-500">検証中...</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700">
          本文
        </label>
        <textarea
          value={formData.content}
          onChange={(e) => updateField('content', e.target.value)}
          rows={10}
          className={`mt-1 block w-full rounded-md border ${
            fieldErrors.content ? 'border-red-500' : 'border-gray-300'
          }`}
        />
        {fieldErrors.content && (
          <p className="mt-1 text-sm text-red-600">{fieldErrors.content}</p>
        )}
      </div>

      {error && (
        <div className="text-red-600 text-sm">{error}</div>
      )}

      <button
        type="submit"
        disabled={loading || isValidating}
        className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
      >
        {loading ? '保存中...' : '投稿を作成'}
      </button>
    </form>
  )
}

やってみよう!

RPC実装とzodバリデーションを実践してみましょう:

  1. 完全なRPC API

    • ユーザー管理システム
    • 投稿管理システム
    • コメント機能
  2. 高度なバリデーション

    • 条件付きバリデーション
    • カスタムバリデーター
    • リアルタイム検証
  3. クライアント統合

    • React Hooks統合
    • エラーハンドリング
    • 型安全性の確保

ポイント

  • RPCパターン:関数呼び出しのような直感的なAPI設計
  • zodバリデーション:スキーマファーストの型安全バリデーション
  • エラーハンドリング:構造化されたエラー処理
  • リアルタイム検証:ユーザー体験を向上させる即座のフィードバック
  • 型安全性:エンドツーエンドの型安全な通信

参考文献

SSR/CSR戦略

テスト戦略

デプロイとキャッシュ戦略

CRUD アプリケーション構築

HonoXでの学習の集大成として、実際のCRUD(Create、Read、Update、Delete)アプリケーションを構築してみましょう。ここまで学んだSSR、CSR、型安全性、バリデーション等の知識を組み合わせて、実用的なブログ管理システムを作成します。

プロジェクト概要

構築するアプリケーション

ブログ管理システム

  • 記事の作成・編集・削除・公開
  • タグ管理機能
  • 著者管理
  • コメント機能
  • 管理画面

技術スタック

{
  "framework": "HonoX",
  "runtime": "Node.js / Cloudflare Workers",
  "database": "SQLite / PostgreSQL",
  "validation": "Zod",
  "styling": "Tailwind CSS",
  "authentication": "JWT",
  "testing": "Vitest"
}

データベース設計

スキーマ定義

-- データベーススキーマ
-- schema.sql

-- ユーザー(著者)
CREATE TABLE users (
  id TEXT PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  name TEXT NOT NULL,
  password_hash TEXT NOT NULL,
  role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
  avatar_url TEXT,
  bio TEXT,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
);

-- 投稿
CREATE TABLE posts (
  id TEXT PRIMARY KEY,
  title TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL,
  content TEXT NOT NULL,
  excerpt TEXT,
  status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published')),
  author_id TEXT NOT NULL REFERENCES users(id),
  published_at TEXT,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
);

-- タグ
CREATE TABLE tags (
  id TEXT PRIMARY KEY,
  name TEXT UNIQUE NOT NULL,
  slug TEXT UNIQUE NOT NULL,
  color TEXT DEFAULT '#3B82F6',
  created_at TEXT NOT NULL
);

-- 投稿とタグの関連
CREATE TABLE post_tags (
  post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
  tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
  PRIMARY KEY (post_id, tag_id)
);

-- コメント
CREATE TABLE comments (
  id TEXT PRIMARY KEY,
  post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
  author_name TEXT NOT NULL,
  author_email TEXT NOT NULL,
  content TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
  created_at TEXT NOT NULL
);

-- インデックス
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_status ON posts(status);
CREATE INDEX idx_posts_published_at ON posts(published_at);
CREATE INDEX idx_comments_post_id ON comments(post_id);
CREATE INDEX idx_comments_status ON comments(status);

データベース操作クラス

// app/lib/database/connection.ts
import Database from 'better-sqlite3'

class DatabaseManager {
  private static instance: DatabaseManager
  private db: Database.Database

  private constructor() {
    this.db = new Database(process.env.DATABASE_PATH || './blog.db')
    this.initializeSchema()
  }

  static getInstance(): DatabaseManager {
    if (!DatabaseManager.instance) {
      DatabaseManager.instance = new DatabaseManager()
    }
    return DatabaseManager.instance
  }

  private initializeSchema() {
    // スキーマファイルを読み込んで実行
    const schema = readFileSync('./schema.sql', 'utf-8')
    this.db.exec(schema)
  }

  getDb(): Database.Database {
    return this.db
  }

  query(sql: string, params: any[] = []): any[] {
    try {
      const stmt = this.db.prepare(sql)
      return stmt.all(...params)
    } catch (error) {
      console.error('Database query error:', error)
      throw error
    }
  }

  run(sql: string, params: any[] = []): Database.RunResult {
    try {
      const stmt = this.db.prepare(sql)
      return stmt.run(...params)
    } catch (error) {
      console.error('Database run error:', error)
      throw error
    }
  }

  transaction<T>(fn: () => T): T {
    return this.db.transaction(fn)()
  }
}

export const db = DatabaseManager.getInstance()

モデル層の実装

Post モデル

// app/lib/models/Post.ts
import { db } from '../database/connection'
import { z } from 'zod'

export interface Post {
  id: string
  title: string
  slug: string
  content: string
  excerpt: string | null
  status: 'draft' | 'published'
  authorId: string
  author?: User
  tags?: Tag[]
  publishedAt: string | null
  createdAt: string
  updatedAt: string
}

export interface CreatePostData {
  title: string
  content: string
  excerpt?: string
  status?: 'draft' | 'published'
  authorId: string
  tags?: string[]
}

export class PostModel {
  // 投稿一覧取得
  static async getList(options: {
    page?: number
    limit?: number
    status?: 'draft' | 'published'
    authorId?: string
    tag?: string
  } = {}): Promise<{ posts: Post[], total: number }> {
    const {
      page = 1,
      limit = 10,
      status,
      authorId,
      tag
    } = options

    let whereClause = '1=1'
    const params: any[] = []

    if (status) {
      whereClause += ' AND p.status = ?'
      params.push(status)
    }

    if (authorId) {
      whereClause += ' AND p.author_id = ?'
      params.push(authorId)
    }

    if (tag) {
      whereClause += ` AND EXISTS (
        SELECT 1 FROM post_tags pt
        JOIN tags t ON pt.tag_id = t.id
        WHERE pt.post_id = p.id AND t.slug = ?
      )`
      params.push(tag)
    }

    // 総数取得
    const countQuery = `
      SELECT COUNT(*) as total
      FROM posts p
      WHERE ${whereClause}
    `
    const [countResult] = db.query(countQuery, params)
    const total = countResult.total

    // データ取得
    const offset = (page - 1) * limit
    const dataQuery = `
      SELECT
        p.*,
        u.name as author_name,
        u.avatar_url as author_avatar
      FROM posts p
      LEFT JOIN users u ON p.author_id = u.id
      WHERE ${whereClause}
      ORDER BY
        CASE WHEN p.published_at IS NOT NULL THEN p.published_at ELSE p.created_at END DESC
      LIMIT ? OFFSET ?
    `

    const posts = db.query(dataQuery, [...params, limit, offset])

    // タグ情報を別途取得
    const postsWithTags = await Promise.all(
      posts.map(async (post) => ({
        ...post,
        tags: await this.getPostTags(post.id)
      }))
    )

    return { posts: postsWithTags, total }
  }

  // 投稿詳細取得
  static async getById(id: string): Promise<Post | null> {
    const query = `
      SELECT
        p.*,
        u.name as author_name,
        u.email as author_email,
        u.avatar_url as author_avatar,
        u.bio as author_bio
      FROM posts p
      LEFT JOIN users u ON p.author_id = u.id
      WHERE p.id = ?
    `

    const [post] = db.query(query, [id])
    if (!post) return null

    const tags = await this.getPostTags(id)
    return { ...post, tags }
  }

  // スラッグで取得
  static async getBySlug(slug: string): Promise<Post | null> {
    const query = `
      SELECT
        p.*,
        u.name as author_name,
        u.avatar_url as author_avatar
      FROM posts p
      LEFT JOIN users u ON p.author_id = u.id
      WHERE p.slug = ? AND p.status = 'published'
    `

    const [post] = db.query(query, [slug])
    if (!post) return null

    const tags = await this.getPostTags(post.id)
    return { ...post, tags }
  }

  // 投稿作成
  static async create(data: CreatePostData): Promise<Post> {
    const id = crypto.randomUUID()
    const slug = this.generateSlug(data.title)
    const now = new Date().toISOString()
    const publishedAt = data.status === 'published' ? now : null

    return db.transaction(() => {
      // 投稿を作成
      db.run(`
        INSERT INTO posts (id, title, slug, content, excerpt, status, author_id, published_at, created_at, updated_at)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
      `, [
        id, data.title, slug, data.content, data.excerpt || null,
        data.status || 'draft', data.authorId, publishedAt, now, now
      ])

      // タグを関連付け
      if (data.tags && data.tags.length > 0) {
        this.associateTags(id, data.tags)
      }

      return this.getById(id)!
    })
  }

  // 投稿更新
  static async update(id: string, data: Partial<CreatePostData>, userId: string): Promise<Post | null> {
    // 権限チェック
    const [existingPost] = db.query('SELECT author_id FROM posts WHERE id = ?', [id])
    if (!existingPost || existingPost.author_id !== userId) {
      return null
    }

    const now = new Date().toISOString()
    const updates: string[] = []
    const params: any[] = []

    if (data.title) {
      updates.push('title = ?', 'slug = ?')
      params.push(data.title, this.generateSlug(data.title))
    }

    if (data.content !== undefined) {
      updates.push('content = ?')
      params.push(data.content)
    }

    if (data.excerpt !== undefined) {
      updates.push('excerpt = ?')
      params.push(data.excerpt)
    }

    if (data.status) {
      updates.push('status = ?')
      params.push(data.status)

      if (data.status === 'published' && !existingPost.published_at) {
        updates.push('published_at = ?')
        params.push(now)
      }
    }

    updates.push('updated_at = ?')
    params.push(now, id)

    return db.transaction(() => {
      // 投稿を更新
      if (updates.length > 1) {
        db.run(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`, params)
      }

      // タグを更新
      if (data.tags !== undefined) {
        // 既存のタグ関連を削除
        db.run('DELETE FROM post_tags WHERE post_id = ?', [id])

        // 新しいタグを関連付け
        if (data.tags.length > 0) {
          this.associateTags(id, data.tags)
        }
      }

      return this.getById(id)
    })
  }

  // 投稿削除
  static async delete(id: string, userId: string): Promise<boolean> {
    // 権限チェック
    const [existingPost] = db.query('SELECT author_id FROM posts WHERE id = ?', [id])
    if (!existingPost || existingPost.author_id !== userId) {
      return false
    }

    const result = db.run('DELETE FROM posts WHERE id = ?', [id])
    return result.changes > 0
  }

  // スラッグ生成
  private static generateSlug(title: string): string {
    let baseSlug = title
      .toLowerCase()
      .replace(/[^a-z0-9\s-]/g, '')
      .replace(/\s+/g, '-')
      .trim()

    // 重複チェック
    let slug = baseSlug
    let counter = 1

    while (true) {
      const [existing] = db.query('SELECT id FROM posts WHERE slug = ?', [slug])
      if (!existing) break

      slug = `${baseSlug}-${counter}`
      counter++
    }

    return slug
  }

  // タグの関連付け
  private static associateTags(postId: string, tagNames: string[]) {
    for (const tagName of tagNames) {
      // タグが存在するか確認、なければ作成
      let [tag] = db.query('SELECT id FROM tags WHERE name = ?', [tagName])

      if (!tag) {
        const tagId = crypto.randomUUID()
        const tagSlug = tagName.toLowerCase().replace(/\s+/g, '-')

        db.run(`
          INSERT INTO tags (id, name, slug, created_at)
          VALUES (?, ?, ?, ?)
        `, [tagId, tagName, tagSlug, new Date().toISOString()])

        tag = { id: tagId }
      }

      // 投稿とタグを関連付け
      db.run(`
        INSERT OR IGNORE INTO post_tags (post_id, tag_id)
        VALUES (?, ?)
      `, [postId, tag.id])
    }
  }

  // 投稿のタグ取得
  private static async getPostTags(postId: string): Promise<Tag[]> {
    return db.query(`
      SELECT t.id, t.name, t.slug, t.color
      FROM tags t
      JOIN post_tags pt ON t.id = pt.tag_id
      WHERE pt.post_id = ?
      ORDER BY t.name
    `, [postId])
  }
}

API層の実装

Posts API

// app/routes/api/posts/index.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { PostModel } from '../../../lib/models/Post'
import { PostSchemas } from '../../../lib/schemas/posts'
import { authMiddleware } from '../../../lib/middleware/auth'

const app = new Hono()

// 投稿一覧取得
app.get('/', zValidator('query', PostSchemas.listQuery), async (c) => {
  const query = c.req.valid('query')

  try {
    const { posts, total } = await PostModel.getList(query)

    return c.json({
      success: true,
      data: posts,
      pagination: {
        page: query.page,
        limit: query.limit,
        total,
        totalPages: Math.ceil(total / query.limit)
      }
    })
  } catch (error) {
    return c.json({
      success: false,
      error: 'Failed to fetch posts'
    }, 500)
  }
})

// 投稿作成
app.post('/',
  authMiddleware,
  zValidator('json', PostSchemas.create),
  async (c) => {
    const postData = c.req.valid('json')
    const user = c.get('user')

    try {
      const post = await PostModel.create({
        ...postData,
        authorId: user.id
      })

      return c.json({
        success: true,
        data: post
      }, 201)
    } catch (error) {
      return c.json({
        success: false,
        error: 'Failed to create post'
      }, 500)
    }
  }
)

export default app

// app/routes/api/posts/[id].ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { PostModel } from '../../../lib/models/Post'
import { PostSchemas } from '../../../lib/schemas/posts'
import { authMiddleware } from '../../../lib/middleware/auth'

const app = new Hono()

// 投稿取得
app.get('/', async (c) => {
  const id = c.req.param('id')

  try {
    const post = await PostModel.getById(id)

    if (!post) {
      return c.json({
        success: false,
        error: 'Post not found'
      }, 404)
    }

    return c.json({
      success: true,
      data: post
    })
  } catch (error) {
    return c.json({
      success: false,
      error: 'Failed to fetch post'
    }, 500)
  }
})

// 投稿更新
app.put('/',
  authMiddleware,
  zValidator('json', PostSchemas.update),
  async (c) => {
    const id = c.req.param('id')
    const updateData = c.req.valid('json')
    const user = c.get('user')

    try {
      const post = await PostModel.update(id, updateData, user.id)

      if (!post) {
        return c.json({
          success: false,
          error: 'Post not found or unauthorized'
        }, 404)
      }

      return c.json({
        success: true,
        data: post
      })
    } catch (error) {
      return c.json({
        success: false,
        error: 'Failed to update post'
      }, 500)
    }
  }
)

// 投稿削除
app.delete('/', authMiddleware, async (c) => {
  const id = c.req.param('id')
  const user = c.get('user')

  try {
    const success = await PostModel.delete(id, user.id)

    if (!success) {
      return c.json({
        success: false,
        error: 'Post not found or unauthorized'
      }, 404)
    }

    return c.json({
      success: true,
      message: 'Post deleted successfully'
    })
  } catch (error) {
    return c.json({
      success: false,
      error: 'Failed to delete post'
    }, 500)
  }
})

export default app

フロントエンド実装

投稿一覧ページ

// app/routes/blog/index.tsx
import { PostCard } from '../../components/blog/PostCard'
import { Pagination } from '../../components/ui/Pagination'

interface BlogIndexProps {
  page?: string
  tag?: string
}

export default async function BlogIndex({ page = '1', tag }: BlogIndexProps) {
  const currentPage = parseInt(page)
  const limit = 12

  // サーバーサイドでデータ取得
  const { posts, pagination } = await getPostsWithPagination({
    page: currentPage,
    limit,
    tag,
    status: 'published'
  })

  return (
    <div className="max-w-6xl mx-auto px-4 py-12">
      <header className="text-center mb-12">
        <h1 className="text-4xl font-bold text-gray-900 mb-4">
          {tag ? `タグ: ${tag}` : 'ブログ'}
        </h1>
        <p className="text-lg text-gray-600">
          Web開発とテクノロジーについての記事を書いています
        </p>
      </header>

      {posts.length === 0 ? (
        <div className="text-center py-12">
          <p className="text-gray-600">記事が見つかりませんでした。</p>
        </div>
      ) : (
        <>
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-12">
            {posts.map(post => (
              <PostCard key={post.id} post={post} />
            ))}
          </div>

          <Pagination
            currentPage={pagination.page}
            totalPages={pagination.totalPages}
            basePath={tag ? `/blog?tag=${tag}` : '/blog'}
          />
        </>
      )}
    </div>
  )
}

async function getPostsWithPagination(params: any) {
  // 実際のデータフェッチング実装
  const { posts, total } = await PostModel.getList(params)

  return {
    posts,
    pagination: {
      page: params.page,
      limit: params.limit,
      total,
      totalPages: Math.ceil(total / params.limit)
    }
  }
}

投稿詳細ページ

// app/routes/blog/[slug].tsx
import { CommentSection } from '../../islands/blog/CommentSection'
import { ShareButtons } from '../../islands/blog/ShareButtons'
import { RelatedPosts } from '../../islands/blog/RelatedPosts'

interface BlogPostProps {
  slug: string
}

export default async function BlogPost({ slug }: BlogPostProps) {
  // サーバーサイドで投稿を取得
  const post = await PostModel.getBySlug(slug)

  if (!post) {
    return <BlogPostNotFound />
  }

  // 関連投稿も取得
  const relatedPosts = await getRelatedPosts(post.id, post.tags?.map(t => t.id) || [])

  return (
    <article className="max-w-4xl mx-auto px-4 py-12">
      {/* SEOメタ情報 */}
      <title>{post.title} | My Blog</title>
      <meta name="description" content={post.excerpt || ''} />
      <meta property="og:title" content={post.title} />
      <meta property="og:description" content={post.excerpt || ''} />
      <meta property="og:type" content="article" />
      <meta property="og:url" content={`https://myblog.com/blog/${slug}`} />

      {/* 記事ヘッダー */}
      <header className="mb-8">
        <h1 className="text-4xl font-bold text-gray-900 mb-4">
          {post.title}
        </h1>

        <div className="flex items-center text-gray-600 text-sm mb-6">
          <img
            src={post.author.avatar_url || '/default-avatar.png'}
            alt={post.author_name}
            className="w-10 h-10 rounded-full mr-3"
          />
          <div>
            <p className="font-medium">{post.author_name}</p>
            <time dateTime={post.published_at}>
              {formatDate(post.published_at)}
            </time>
          </div>
        </div>

        {/* タグ */}
        {post.tags && post.tags.length > 0 && (
          <div className="flex flex-wrap gap-2">
            {post.tags.map(tag => (
              <a
                key={tag.id}
                href={`/blog?tag=${tag.slug}`}
                className="px-3 py-1 bg-blue-100 text-blue-800 text-xs rounded-full hover:bg-blue-200 transition-colors"
              >
                {tag.name}
              </a>
            ))}
          </div>
        )}
      </header>

      {/* 記事本文 */}
      <div className="prose prose-lg max-w-none mb-12">
        <div dangerouslySetInnerHTML={{ __html: post.content }} />
      </div>

      {/* ソーシャル共有ボタン(アイランド) */}
      <div className="border-t border-b py-8 mb-12">
        <ShareButtons
          title={post.title}
          url={`https://myblog.com/blog/${slug}`}
          text={post.excerpt}
        />
      </div>

      {/* 関連記事(アイランド) */}
      <section className="mb-12">
        <RelatedPosts
          currentPostId={post.id}
          initialData={relatedPosts}
        />
      </section>

      {/* コメント(アイランド) */}
      <section>
        <CommentSection postId={post.id} />
      </section>
    </article>
  )
}

function BlogPostNotFound() {
  return (
    <div className="max-w-4xl mx-auto px-4 py-12 text-center">
      <h1 className="text-3xl font-bold text-gray-900 mb-4">記事が見つかりません</h1>
      <p className="text-gray-600 mb-8">
        指定された記事は存在しないか、削除された可能性があります。
      </p>
      <a
        href="/blog"
        className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
      >
        ブログ一覧に戻る
      </a>
    </div>
  )
}

管理画面

// app/routes/admin/posts/index.tsx
import { PostsTable } from '../../../islands/admin/PostsTable'
import { requireAuth } from '../../../lib/middleware/auth'

export default function AdminPosts() {
  // 管理者権限をチェック(ミドルウェア)
  return (
    <div className="max-w-7xl mx-auto px-4 py-8">
      <div className="flex justify-between items-center mb-8">
        <h1 className="text-3xl font-bold text-gray-900">投稿管理</h1>
        <a
          href="/admin/posts/new"
          className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
        >
          新規投稿
        </a>
      </div>

      <PostsTable />
    </div>
  )
}

// app/routes/admin/posts/new.tsx
import { PostForm } from '../../../islands/admin/PostForm'

export default function NewPost() {
  return (
    <div className="max-w-4xl mx-auto px-4 py-8">
      <div className="mb-8">
        <h1 className="text-3xl font-bold text-gray-900">新規投稿</h1>
        <p className="text-gray-600 mt-2">新しいブログ投稿を作成します</p>
      </div>

      <PostForm
        mode="create"
        onSuccess={(post) => {
          window.location.href = `/admin/posts/${post.id}`
        }}
      />
    </div>
  )
}

インタラクティブコンポーネント(アイランド)

投稿フォーム

// app/islands/admin/PostForm.tsx
import { useState } from 'react'
import { useCreatePost, useUpdatePost } from '../../lib/hooks/usePostMutations'
import type { Post, CreatePostRequest } from '../../lib/api/types'

interface PostFormProps {
  mode: 'create' | 'edit'
  initialData?: Partial<Post>
  onSuccess?: (post: Post) => void
}

export function PostForm({ mode, initialData, onSuccess }: PostFormProps) {
  const [formData, setFormData] = useState<CreatePostRequest>({
    title: initialData?.title || '',
    content: initialData?.content || '',
    excerpt: initialData?.excerpt || '',
    status: initialData?.status || 'draft',
    tags: initialData?.tags?.map(t => t.name) || []
  })

  const [tagInput, setTagInput] = useState('')
  const { createPost, loading: createLoading, error: createError } = useCreatePost()
  const { updatePost, loading: updateLoading, error: updateError } = useUpdatePost()

  const loading = createLoading || updateLoading
  const error = createError || updateError

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    try {
      let post: Post

      if (mode === 'create') {
        post = await createPost(formData)
      } else {
        post = await updatePost(initialData!.id!, formData)
      }

      if (post && onSuccess) {
        onSuccess(post)
      }
    } catch (err) {
      // エラーハンドリングは各フックで行う
    }
  }

  const addTag = () => {
    if (tagInput.trim() && !formData.tags?.includes(tagInput.trim())) {
      setFormData(prev => ({
        ...prev,
        tags: [...(prev.tags || []), tagInput.trim()]
      }))
      setTagInput('')
    }
  }

  const removeTag = (tagToRemove: string) => {
    setFormData(prev => ({
      ...prev,
      tags: prev.tags?.filter(tag => tag !== tagToRemove) || []
    }))
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          タイトル
        </label>
        <input
          type="text"
          value={formData.title}
          onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          required
        />
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          本文
        </label>
        <textarea
          value={formData.content}
          onChange={(e) => setFormData(prev => ({ ...prev, content: e.target.value }))}
          rows={20}
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          required
        />
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          抜粋
        </label>
        <textarea
          value={formData.excerpt}
          onChange={(e) => setFormData(prev => ({ ...prev, excerpt: e.target.value }))}
          rows={3}
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          タグ
        </label>
        <div className="flex gap-2 mb-2">
          <input
            type="text"
            value={tagInput}
            onChange={(e) => setTagInput(e.target.value)}
            onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
            className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
            placeholder="タグを入力"
          />
          <button
            type="button"
            onClick={addTag}
            className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
          >
            追加
          </button>
        </div>

        <div className="flex flex-wrap gap-2">
          {formData.tags?.map(tag => (
            <span
              key={tag}
              className="inline-flex items-center px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full"
            >
              {tag}
              <button
                type="button"
                onClick={() => removeTag(tag)}
                className="ml-2 text-blue-600 hover:text-blue-800"
              >
                ×
              </button>
            </span>
          ))}
        </div>
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          ステータス
        </label>
        <select
          value={formData.status}
          onChange={(e) => setFormData(prev => ({
            ...prev,
            status: e.target.value as 'draft' | 'published'
          }))}
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
        >
          <option value="draft">下書き</option>
          <option value="published">公開</option>
        </select>
      </div>

      {error && (
        <div className="p-4 bg-red-50 border border-red-200 rounded-md">
          <p className="text-red-600">{error}</p>
        </div>
      )}

      <div className="flex gap-4">
        <button
          type="submit"
          disabled={loading}
          className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
        >
          {loading ? '保存中...' : mode === 'create' ? '投稿を作成' : '投稿を更新'}
        </button>

        <button
          type="button"
          onClick={() => window.history.back()}
          className="px-6 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50"
        >
          キャンセル
        </button>
      </div>
    </form>
  )
}

やってみよう!

完全なCRUDアプリケーションを構築してみましょう:

  1. データベース設計

    • 適切な正規化
    • インデックス設定
    • 制約の定義
  2. API実装

    • RESTful設計
    • バリデーション
    • エラーハンドリング
  3. フロントエンド実装

    • SSRによる高速表示
    • インタラクティブな管理機能
    • レスポンシブデザイン
  4. セキュリティ

    • 認証・認可
    • XSS対策
    • CSRF対策

ポイント

  • フルスタック開発:フロントエンドからバックエンドまで一貫した開発体験
  • 型安全性:データベースからUIまでエンドツーエンドの型安全性
  • パフォーマンス:SSRとアイランドアーキテクチャによる最適化
  • 保守性:適切な分離とモジュール化による可読性の向上
  • スケーラビリティ:成長に対応できる柔軟なアーキテクチャ

参考文献

Web開発演習


created: 2025-09-09 12:55:04+09:00


created: 2025-09-09 12:55:03+09:00


created: 2025-09-09 12:55:00+09:00


created: 2025-09-09 12:54:59+09:00


created: 2025-09-09 12:54:57+09:00


created: 2025-09-09 12:54:55+09:00

Web

WebとWebブラウザーについてまとめます。といっても、仕組みがわかると面白いかも、くらいの気持ちで気楽にいきましょう。

Webとは

"This is for everyone"

Tim Berners-Lee (@timberners_lee)

Webは情報共有とコミュニケーションのためのプラットフォームです。 Web上ではソーシャルメディア、ビデオストリーミング、オンラインショッピングなど様々な活動が行われます。(便利ですよね。)

World Wide Web

― 画像: https://worldwideweb.cern.ch/browser/ より

これは世界初のWebブラウザーWorldWideWebの画像です。

World Wide Web (Web) は、1989年に分散型ハイパーテキストシステム (distributed hypertext system)としてティム・バーナーズ・リーによって提案されたアイデアが元になっています。世界中に張り巡らされた蜘蛛の巣を連想して名付けられました。

Note
中央集権型 (centralized) と 非中央集権型 (decentralized) と 分散型 (distributed)


― 画像: ポール・バラン (1964) On Distributed Communications Networks より

Webは分散型のシステムです。中央集権型のシステムではありません。Web上で何か活動するとき、中央の機関からの許諾は一切必要ありません。いつでも、どこでも、誰でも自由に使うことができます。

Webブラウザー

Webブラウザーは、Webページの取得・描画を行うソフトウェアです。

代表的なWebブラウザーとしては、Google ChromeSafariMozilla FirefoxMicrosoft Edgeなどが挙げられます。

Webの標準化

主に3つの標準化団体が関わっています。

IETFはインターネットに関する全般的な技術、約束事、コミュニティで共有すべき事柄の管理を担っています。それらは RFC (Request for Comments) と呼ばれる形式で記録されています。

W3CとWHATWGはWebに関する仕様の管理を担っています。具体的にはW3CはCSSなど、WHATWGはHTML関連の仕様などを発行しています。 WHATWGの発行している仕様はIETFやW3Cの仕様とは異なり内容が確定することはありません。HTML Living Standardはその時点が常に最新版の標準仕様となっており、継続的に更新され続けています。

いずれの仕様もWeb上で公開されており、無償で閲覧可能、誰でも参加可能、自由に実装可能です。

ポイント

  • Web … 分散型ハイパーテキストシステム
  • Webブラウザー … Webページの取得・描画を行うソフトウェア
  • Webの標準化 … 無償で閲覧可能、誰でも参加可能、自由に実装可能

Webの仕組み

Webの誕生から現在に至るまでWeb上で出来ることやその役割は大きく変わりました。 しかし、それを支える基本的な仕組みと構成要素は実はあまり変わっていません。

Webはこれらの要素に支えられています。

  • URL … インターネット上のリソースの位置を特定するための識別子
  • HTTP … Webの転送用のプロトコル
  • コンテンツ … Webページなど

Note
変わり続けるルール

多くのルールを覚えることは決して重要ではありません。 なぜなら現実の問題は複雑でそれに合わせてルールも変わり続けていくものだからです。 大切なのは解決したい問題への理解を深めていくことなのです。 何を解決するためのルールなのか一緒に考えていきましょう。(この後も退屈な説明が続きますがどうかお付き合いください。)

例えばこれらの仕様はいずれも常に最新版の標準仕様となっており継続的に更新され続けています。

Webページ


― 画像: JavaScript とは - Web開発を学ぶ | MDN より

Webページはこれらの要素に支えられています。

  • HTML … Webページの構造を記述するための言語
  • CSS … Webページの見た目を記述するための言語
  • JavaScript … プログラミング言語

ポイント

  • Web … URL/HTTP/コンテンツ
  • Webページ … HTML/CSS/JavaScript

参考文献

URL - Uniform Resource Locator

URLはインターネット上のリソースの位置を特定するための文字列です。 住所と似ています。

URLの仕様からいくつか具体例を挙げます。

例:

https://example.com/
https://localhost:8000/search?q=text#hello
urn:isbn:9780307476463
file:///ada/Analytical%20Engine/README.md

これらはいずれもURLです。

URLを使ってリンクさせることができます。

ユニフォーム (uniform) と名前にあるのは、統一的なルールがあります、ということです。 Web上で「〇〇にアクセスしたい」と思ったときみんなで使える同じ表現があったほうが便利というわけですね。

ではURLにはどういうルールがあるのか詳しく見ていきましょう。

スキーム (Scheme)

URLの種別や性質を意味します。

https://example.com/

この例でいうと、先頭から : までの文字列 https が「スキーム」です。住所の例で言うと、郵便を表す記号「〒」の役割と似ています。URLはスキームごとにその書式が異なります。

Note
インターネット上で利用可能なURLスキームの一覧

Uniform Resource Identifier (URI) Schemes

インターネット上で利用可能なURLスキームの一覧はIANA (Internet Assigned Numbers Authority)によって管理されています。

https スキームは Hypertext Transfer Protocol Secure (RFC 9110) を意味します。その後に文字列 :// が続きます。

https スキームのURLの場合は、その後に「ホスト (Host)」「ポート (Port)」「パス (Path)」「クエリー (Query)」「フラグメント (Fragment)」と続きます。

Note
httphttps

前者は "Hypertext Transfer Protocol"、後者は "Hypertext Transfer Protocol Secure" を意味するスキームです。 "Secure" と付いているのは、必ず TLS (Transport Layer Security) の上でやり取りを行います、という意味です。 最近 http://... から始まるURLはあまり見かけないかと思います。 これはHTTPのセキュリティとプライバシーの問題が広く知られ、代わりにHTTP over TLSが使われるようになったためです。 TLSを使うことによってクライアント・サーバー間の通信が暗号化され、もし仮に傍受されても第三者による改ざんや解析は以前より難しくなりました。

ホスト (Host)

郵便番号と住所みたいなものです。「ポスト」じゃないですよ。

https://example.com/

この例でいうと、example.com の部分が「ホスト」です。 ホストはドメイン名またはIPアドレスです。 ドメイン名を見かけることが多いかと思います。

Note
ドメイン名

インターネットに接続しているすべてのコンピューターにはIPアドレスが割り当てられています。 ドメイン名はそうしたIPアドレスに人間が読めるように別の名前を付けたものです。 ドメイン名は Domain Name System (DNS) によって支えられています。 Internet Corporation for Assigned Names and Numbers (ICANN) を中心とした複数のドメイン管理事業者によって管理されており、世界中で使うことができるようになっています。

例: Google Public DNS に example.com を問い合わせる例

"Answer": [
  {
    "name": "example.com.",
    "type": 28 /* AAAA */,
    "TTL": 13460,
    "data": "2606:2800:220:1:248:1893:25c8:1946"
  }
]

ドメイン名 example.com はIPアドレス [2606:2800:220:1:248:1893:25c8:1946] の別名ですよ、という意味です。

ポート (Port)

ポート」が書いてあるURLは普段あまり見かけないかもしれませんね。 でもインターネット上で通信するとき必ず登場します。存在するからには一応紹介しておきます。

ホストの後にはポートを書くことができます。 ホストと : 文字の後にポート番号を書きます。 省略すると https スキームの場合は 443 が割り当てられています。

例えばこれらのURLは同じ意味です。

https://example.com/
https://example.com:443/

この場合どちらも 443 ポートを意味します。

オリジン (Origin)

スキーム・ホスト・ポートをまとめて扱うことがあります。 具体的にはセキュリティ上の理由から送信元の同一性を判定するケースです。 このとき使われるのが「オリジン」です。

https://localhost:8000/search?q=text#hello

例えばこの例では (https, localhost, 8000) の3つの組がオリジンです。 オリジンは https://localhost:8000 のように表現します。

JavaScriptでは location.origin でオリジンを取得できます。

Note
Webブラウザーのセキリティ機構

Webブラウザーには同一オリジンポリシーと呼ばれる保護機構があり、オリジン間のアクセスは原則禁止されています。 異なるオリジン間でのアクセスを許可するには、オリジン間リソース共有 (Cross-Origin Resource Sharing, CORS) の仕組みを使います。 CORSはリソースを提供する人が同一オリジンポリシーを緩和しオリジン間のアクセスを許可するための仕組みです。

パス (Path)

/ 文字で区切られた文字列が続きます。これが「パス」です。ホストの中のリソースの場所を意味します。 / は日本語でいう「の」みたいなものです。 階層構造を表現します。

https://example.com/

この場合パスは / です。

https://example.com/foo/bar

この場合パスは /for/bar です。

ちなみにJavaScriptでは location.pathname でパスを取得できます。

クエリー (Query)

? 文字で区切られた文字列が続くことがあります。場合によっては =& が含まれます。これは「クエリー」です。

https://localhost:8000/search?q=text#hello

例えばこの例では q=text がクエリーです。 = は日本語でいう「は」みたいなものです。

Google検索の例: https://www.google.com/search?q=answer+to+life+the+universe+and+everything

この場合クエリーは q=answer+to+life+the+universe+and+everything です。 q は "answer to life the universe and everything" ですよ、という意味です。

JavaScriptでは location.search でクエリーを取得できます。

フラグメント (Fragment)

# 文字で区切られた文字列が続くことがあります。これは「フラグメント」です。 URLの末尾にフラグメントがあるとき、そのリソースの中の一部分フラグメントを意味します。 HTMLの場合はフラグメントと id 属性の名前が一致するときその箇所を指定することができます。

https://localhost:8000/search?q=text#hello

例えばこの例では hello がフラグメントです。

JavaScriptでは location.hash でフラグメントを取得できます。

ポイント

  • URLはインターネット上のリソースの位置を特定するための識別子
  • https:// から始まるURLは https スキームのURL
  • https スキームのURLの構成要素 … ホスト、ポート、パス、クエリー、フラグメント

HTTP - Hypertext Transfer Protocol

HTTPはWebの転送用のプロトコルです。

URLがあればそのリソースがWeb上の「どこに」あるか知ることができます。 ではそのリソースには「どのように」アクセスしたらよいのでしょうか。

https スキームや http スキームのURLに対応するリソースにアクセスする手順プロトコル、それがHTTPです。

プロトコル

― この画像は © 2012 Karl Dubost クリエイティブ・コモンズ CC BY 3.0 ライセンスのもとに利用を許諾されています。

二者間でのコミュニケーションが成立するためには3つの要素が含まれています。

  • シンタックス (コードの文法)
  • セマンティクス (コードの意味)
  • タイミング (速度合わせと順序付け)

「挨拶」を例に考えてみましょう。 腰を曲げるジェスチャー、これはお辞儀のためのシンタックスです。日本ではそういう慣習ですね。お辞儀をすることで「どうも、こんにちは」という意味づけが行われます。これはセマンティクスです。二者間で特定のタイミングでこれらが発生したとき、一連の出来事として成立します。どちらもお辞儀をし、お互いに理解することによって「挨拶」として成立した、となるわけです。

Web上でのやり取りも同じです。 HTTPはサーバー・クライアントの二者関係で行われます。 クライアントはサーバーに対して要求リクエストを送り、クライアントからの要求リクエストを受け取るとサーバーは応答レスポンスを返します。

HTTPの仕様にある具体例を挙げます。 次のようなコードの送受信を行います。

クライアントリクエスト (クライアント側からサーバー側への送信):

GET /hello.txt HTTP/1.1
User-Agent: curl/7.64.1
Host: www.example.com
Accept-Language: en, mi

サーバーレスポンス (サーバー側からクライアント側への送信):

HTTP/1.1 200 OK
Date: Mon, 27 Jul 2009 12:28:53 GMT
Server: Apache
Last-Modified: Wed, 22 Jul 2009 19:15:56 GMT
ETag: "34aa387-d-1568eb00"
Accept-Ranges: bytes
Content-Length: 51
Vary: Accept-Encoding
Content-Type: text/plain

Hello World! My content includes a trailing CRLF.

リクエストの構成 (送る側):

  • メソッド (Method)
  • URL (Request Target)
  • ヘッダー (Header Fields)
  • ボディ (Body)

レスポンスの構成 (返す側):

  • ステータスライン (例: HTTP/1.1 200 OK — ステータスコードを含む)
  • ヘッダー (Header Fields)
  • ボディ (Body / Content)

詳しく見ていきましょう。

メソッドとURL(Request Line)

クライアントリクエストの1行目の GET /hello.txt HTTP/1.1 の部分は、リクエストライン (Request Line) と呼ばれます。どこに(URL)、どのような方法メソッドでアクセスしたいかをサーバーに伝えるためのものです。

クライアントリクエスト:

GET /hello.txt HTTP/1.1

この例はURL http://www.example.com/hello.txt にアクセスするためのクライアントリクエストです。GET メソッドでパス /hello.txt へのアクセスを要求しています。

GET メソッドは取得するために使われる最も基本的なメソッドで、リンクをクリックしたときやWebブラウザーのアドレスバーにURLを入力したとき送信されます。Webでのやり取りはこの「メソッドとURL」を含むリクエストラインをWebサーバーに伝えるところから始まります。

Note
HTTP/1.1 と HTTP/2

HTTP/1.1は1995年に公開され、2022年に最新版に改定されました。 HTTP/1.1は現在も使われ続けています。 一方、HTTP/2は2022年に公開されました。 HTTP/2はHTTP/1.1とは異なり複数のメッセージを同時に扱える、コンピューターにとってより効率的な形式のシンタックスが特徴の新しい仕様です。 HTTP/2ではリクエストラインの代わりに一貫してフィールドを使うなどHTTP/1.1と文法が大きく異なりますがその意味は全く変わりません。

HTTP/2 仕様のリクエストの例:

  GET /resource HTTP/1.1           HEADERS
  Host: example.org          ==>     + END_STREAM
  Accept: image/jpeg                 + END_HEADERS
                                       :method = GET
                                       :scheme = https
                                       :authority = example.org
                                       :path = /resource
                                       host = example.org
                                       accept = image/jpeg

ヘッダー(Headers / Fields)

: 文字で区切られた行が続きます。これは「ヘッダー(ヘッダーフィールド、Fields)」です。リクエストとレスポンスに関連する付帯情報を意味します。

Host: www.example.com

例えばこの場合、送信先 Host (ホスト) は www.example.com ですよ、という意味です。以降、本書では用語を統一して「ヘッダー」と呼びます(HTTP/1.1仕様では header field とも表記されます)。

ステータスラインとステータスコード


― 画像: HTTP Cats より

「ステータスコード (Status Codes)」はそのリソースの存在やアクセス可否などをサーバーが伝えるためのものです。 サーバーはレスポンスを返すとき、最初にステータスコードを返します。

サーバーレスポンスの先頭行(ステータスライン):

HTTP/1.1 200 OK

この例ではステータスコード 200 を返しています。 ステータスコードは100〜599までの3桁の整数で表されます。 レスポンスはステータスコードの100の位で大きく分類されます。

  • 1xx (情報): リクエストを受信しました。プロセスを続行します。
  • 2xx (成功): リクエストは正常に受信、理解され、受け入れられました。
  • 3xx (リダイレクト): リクエストを完了するにはさらにアクションを実行する必要があります。
  • 4xx (クライアントエラー): リクエストに不正な構文が含まれているか、リクエストを実行できません。
  • 5xx (サーバーエラー): サーバーは有効なリクエストを実行できません。

Note
418 I'm a teapot

私はティーポットなのでコーヒーを入れることを拒否しました、という意味のステータスコードです。 1998年のエイプリルフールに公開されました。 現在でもステータスコード 418IANA HTTP Status Code Registry によって管理されています。

ボディ(Body / Content)

ヘッダーの後に空行があり、その後に「ボディ (本文)」が続きます。 ボディはHTML、画像、動画、JSONなど、あらゆるデータになり得ます。

ポイント

  • HTTPはWebの転送用のプロトコル
  • HTTPはクライアントからのリクエストとサーバーからのレスポンスによってやり取りを行う
  • 用語の対応: ヘッダー ≒ フィールド(header fields)
  • HTTPの構成要素
    • リクエスト: メソッド/URL/ヘッダー/ボディ
    • レスポンス: ステータスライン/ヘッダー/ボディ

HTML

HTMLはWebページの構造を記述するための言語です。

「どこに」「どのように」アクセスするかというと、Webでは「URLに」「HTTPで」アクセスするわけですね。 では一体「何を」Webブラウザーは見せているのでしょうか。 それは「HTML」です。(この入門ガイドもそうですよ。)

もともとHTMLは主に科学文書の意味や構造を正確に記述するための言語として設計されました。 現在では、あらゆる文書やアプリの記述に応用されています。

文法と意味

HTMLはマークアップ言語 (Markup Language)と呼ばれるカテゴリーの言語です。 HTMLの仕様から具体的なコードの例を挙げます。

例:

<!DOCTYPE html>
<html lang="en">
 <head>
  <title>Sample page</title>
 </head>
 <body>
  <h1>Sample page</h1>
  <p>This is a <a href="demo.html">simple</a> sample.</p>
  <!-- this is a comment -->
 </body>
</html>

「タグ (Tag)」と呼ばれるマークでコンテンツのかたまりを囲みますマークアップします。 このかたまりは「要素 (Element)」と呼ばれます。


― 画像: 「HTML の基本」より

要素の中に別の要素を含めることもあります。

<p>This is a <a href="demo.html">simple</a> sample.</p>

この例では<p>: 段落要素の中に<a>: アンカー要素が含まれています。 リンクが含まれている文、その文を含む段落、という構造なわけです。

やってみよう!

基本的なルールが分かってきたところで、さっそく遊んでみましょう!

<p>ここは段落なのですよ。</p>

<p>HTMLを使えばインターネット上のあらゆるコンテンツに<a href="https://kou029w.github.io/intro-to-web-dev/web/html.html">リンク</a>できるのです。</p>

MDNで調べてみよう

MDN (MDN Web Docs) は、HTML、CSS、JavaScriptなどWeb技術に関するあらゆる文書を網羅的にまとめているサイトです。オープンソースで提供されており、誰でも自由に貢献することができます。MDNにアクセスすればWebブラウザーに組み込まれているあらゆるAPIの仕様やその機能を調べることができます。

Googleなどの検索エンジンで「MDN [調べたいキーワード]」または「site:developer.mozilla.org [調べたいキーワード]」を検索してみましょう。

ポイント

  • MDN … Webブラウザーに組み込まれているAPIの仕様や機能を調べることができる

API - Application Programming Interface

システムには情報やエネルギーなど外部とのやりとりするための境界面があり、それを「インターフェース」と呼びます。

API (Application Programming Interface) とは、アプリケーションソフトウェアのインターフェースを指す概念です。

家電製品を例に考えてみましょう。 家電製品はそのシステムの外部から供給された電力を消費して仕事をします。 このとき外部との境界には外部から電力を供給するためのインターフェースが存在します。 それはコンセントですね。 コンセントには定格電圧や形状など規格があります。 インターフェースとはそういったルールのことです。 家電製品にはコンセントがあるので専門的な技能が無くても手軽に電気的エネルギーにアクセスできるのです。 これは物理的な例ですが、外部との境界にインターフェースがあるのはWebも同じです。 JavaScriptから簡単に外部と情報をやりとりしたり、外部のサービスの機能を使うためにAPIがあります。

参考文献

開発者ツールに慣れる

Webブラウザーには開発者ツールが内蔵されています。 これを使うことでWebブラウザーが表示しているHTMLの状態を調べたり、どのリソース (URL) に、どのようにアクセスしているのか (HTTPメッセージの内容) 知ることができます。

ここではGoogle ChromeやMicrosoft EdgeなどChromium系ブラウザーの開発者ツールの基本的な使い方を説明します。

開発者ツールの起動方法

開発者ツールを起動するにはいくつかの方法があります。

  • 右クリック > [検証] を選択
  • [その他のツール] > [デベロッパー ツール] を選択
  • Windows/Linuxの場合: Ctrl+Shift+I
  • macOSの場合: ⌘ (Command)+⌥ (Option)+I

どの方法でもOKです。

Note
開発者ツールの翻訳

はじめて開発者ツールを起動したとき、メニューがすべて英語で表示されることがあります。 開発者ツールのメニューを翻訳するには開発者ツール上部の [Always match Chrome's language] を選択しましょう。

実際に手元のWebブラウザーから開発者ツールを起動してみましょう。

要素 (Elements)

画面上に表示されているHTML要素とその状態を知ること、一時的に編集することができます。

コンソール (Console)

JavaScriptの実行と、エラーメッセージなど実行しているコードを解析するための情報を知ることができます。

ソース (Sources)

実行しているコードの表示と一時停止 (ブレークポイントの設定)、そのコードを解析することができます。

ネットワーク (Network)

WebブラウザーがアクセスしているURLとそのHTTPメッセージの内容知ることができます。

  • 上側のペイン: タイムライン
  • 左側のペイン: リクエストの一覧
  • 右側のペイン: (リクエストの一覧から選択) リクエストヘッダー・プレビュー・レスポンスなどHTTPメッセージの詳細

ヘッダー

URLとリクエストのメソッド、レスポンスのステータスコードなどHTTPメッセージの基本的な情報を知ることができます。

プレビュー

(表示可能であれば) そのコンテンツを表示します。

レスポンス

そのコンテンツの生のデータを表示します。

ポイント

  • 開発者ツール … Webブラウザーが表示しているHTMLの状態を調べたり、どのリソースに、どのようにアクセスしているのか知ることができる

やってみよう!

  • 実際に手元のWebブラウザーで開発者ツールを起動していくつかのWebページにアクセスしてみよう

JavaScript

JavaScript Primer > 基本文法 > JavaScriptとは

JavaScriptを学びはじめる前に、まずJavaScriptとはどのようなプログラミング言語なのかを紹介します。

JavaScriptは主にWebブラウザーの中で動くプログラミング言語です。 ウェブサイトで操作をしたら表示が書き換わったり、ウェブサイトのサーバーと通信してデータを取得したりと現在のウェブサイトには欠かせないプログラミング言語です。 このようなJavaScriptを活用してアプリケーションのように操作できるウェブサイトをWebアプリとも言います。

JavaScriptはWebブラウザーだけではなく、Node.jsというサーバー側のアプリケーションを作る仕組みでも利用されています。 また、デスクトップアプリやスマートフォンアプリ、IoT(Internet of Things)デバイスでもJavaScriptを使って動かせるものがあります。 このように、JavaScriptはかなり幅広い環境で動いているプログラミング言語で、さまざまな種類のアプリケーションを作成できます。

― この文章は © 2023 jsprimer project クリエイティブ・コモンズ CC BY 4.0 ライセンスのもとに利用を許諾されています。

続きは JavaScript Primer > 基本文法 > JavaScriptとは を参照しましょう。

歴史

JavaScriptは、1995年にブレンダン・アイクによって、当初Netscape Navigatorのためのスクリプト言語として開発されました。当時のWebは非常にシンプルで静的なものであり、動的なユーザーインタラクションはほとんどありませんでした。

そんな中、当時Netscape Communicationsで働いていたブレンダン・アイクは上司から新しいスクリプト言語を開発するよう依頼され、C、Java、Self、Schemeなどの既存のプログラミング言語の概念を取り入れつつ、わずか10日間で初期バージョンのJavaScriptを開発しました。

動的なユーザーインタラクションやサーバーへの非同期通信(Ajax)など現在では当たり前となっている多くの機能が初めてWebブラウザー上で可能となり、またその後のWebブラウザー以外の技術の発展にも大きな影響を与えました。

ポイント

  • ECMAScript仕様による標準化
  • 仕様は毎年更新されている

値の評価と表示

JavaScript Primer > 基本文法 > 値の評価と表示

値の評価とは、入力した値を評価してその結果を返すことを示しています。 たとえば、次のような値の評価があります。

  • 1 + 1 という式を評価したら 2 という結果を返す
  • bookTitle という変数を評価したら、変数に代入されている値を返す
  • const x = 1;という文を評価することで変数を定義するが、この文には返り値はない

この値の評価方法を確認するために、Webブラウザー(以下ブラウザ)を使ってJavaScriptを実行する方法を見ていきます。

― この文章は © 2023 jsprimer project クリエイティブ・コモンズ CC BY 4.0 ライセンスのもとに利用を許諾されています。

続きは JavaScript Primer > 基本文法 > 値の評価と表示 を参照しましょう。

ポイント

  • Webブラウザーの開発者ツールのコンソール上でJavaScriptコードを評価する方法

やってみよう!

  • 実際に手元のWebブラウザーでJavaScriptを実行してみよう

変数と宣言

JavaScript Primer > 基本文法 > 変数と宣言

プログラミング言語には、文字列や数値などのデータに名前をつけることで、繰り返し利用できるようにする変数という機能があります。

JavaScriptには「これは変数です」という宣言をするキーワードとして、 constletvarの3つがあります。

varはもっとも古くからある変数宣言のキーワードですが、意図しない動作を作りやすい問題が知られています。 そのためECMAScript 2015で、varの問題を改善するためにconstletという新しいキーワードが導入されました。

この章ではconstletvarの順に、それぞれの方法で宣言した変数の違いについて見ていきます。

― この文章は © 2023 jsprimer project クリエイティブ・コモンズ CC BY 4.0 ライセンスのもとに利用を許諾されています。

続きは JavaScript Primer > 基本文法 > 変数と宣言 を参照しましょう。

ポイント

  • constは、再代入できない変数を宣言できる
  • letは、再代入ができる変数を宣言できる

やってみよう!

// 真空の光速度 (m/s)
const c = 299792458;

// 時間 (s)
let t = 0;

// 光の速度は変わらない
// c = 42;

// 時間は変わる
t = 0.001;

document.body.textContent = `${t} 秒間に光の進む距離: ${c * t} m`;

データ型とリテラル

JavaScript Primer > 基本文法 > データ型とリテラル

JavaScriptは動的型付け言語に分類される言語であるため、静的型付け言語のような変数の型はありません。 しかし、文字列、数値、真偽値といった値の型は存在します。 これらの値の型のことをデータ型と呼びます。

データ型を大きく分けると、プリミティブ型オブジェクトの2つに分類されます。

プリミティブ型(基本型)は、真偽値や数値などの基本的な値の型のことです。 プリミティブ型の値は、一度作成したらその値自体を変更できないというイミュータブル(immutable)の特性を持ちます。 JavaScriptでは、文字列も一度作成したら変更できないイミュータブルの特性を持ち、プリミティブ型の一種として扱われます。

一方、プリミティブ型ではないものをオブジェクト(複合型)と呼び、 オブジェクトは複数のプリミティブ型の値またはオブジェクトからなる集合です。 オブジェクトは、一度作成した後もその値自体を変更できるためミュータブル(mutable)の特性を持ちます。 オブジェクトは、値そのものではなく値への参照を経由して操作されるため、参照型のデータとも言います。

データ型を細かく見ていくと、7つのプリミティブ型とオブジェクトからなります。

  • プリミティブ型(基本型)
    • 真偽値(Boolean): trueまたはfalseのデータ型
    • 数値(Number): 423.14159 などの数値のデータ型
    • 巨大な整数(BigInt): ES2020から追加された9007199254740992nなどの任意精度の整数のデータ型
    • 文字列(String): "JavaScript" などの文字列のデータ型
    • undefined: 値が未定義であることを意味するデータ型
    • null: 値が存在しないことを意味するデータ型
    • シンボル(Symbol): ES2015から追加された一意で不変な値のデータ型
  • オブジェクト(複合型)
    • プリミティブ型以外のデータ
    • オブジェクト、配列、関数、クラス、正規表現、Dateなど

プリミティブ型でないものは、オブジェクトであると覚えていれば問題ありません。

typeof演算子を使うことで、次のようにデータ型を調べることができます。

console.log(typeof true); // => "boolean"
console.log(typeof 42); // => "number"
console.log(typeof 9007199254740992n); // => "bigint"
console.log(typeof "JavaScript"); // => "string"
console.log(typeof Symbol("シンボル")); // => "symbol"
console.log(typeof undefined); // => "undefined"
console.log(typeof null); // => "object"
console.log(typeof ["配列"]); // => "object"
console.log(typeof { key: "value" }); // => "object"
console.log(typeof function () {}); // => "function"

プリミティブ型の値は、それぞれtypeof演算子の評価結果として、その値のデータ型を返します。 一方で、オブジェクトに分類される値は"object"となります。

配列([])とオブジェクト({})は、どちらも"object"という判定結果になります。 そのため、typeof演算子ではオブジェクトの詳細な種類を正しく判定することはできません。 ただし、関数はオブジェクトの中でも特別扱いされているため、typeof演算子の評価結果は"function"となります。 また、typeof null"object"となるのは、歴史的経緯のある仕様のバグ[^1]です。

このことからもわかるようにtypeof演算子は、プリミティブ型またはオブジェクトかを判別するものです。 typeof演算子では、オブジェクトの詳細な種類を判定できないことは、覚えておくとよいでしょう。 各オブジェクトの判定方法については、それぞれのオブジェクトの章で見ていきます。

― この文章は © 2023 jsprimer project クリエイティブ・コモンズ CC BY 4.0 ライセンスのもとに利用を許諾されています。

続きは JavaScript Primer > 基本文法 > データ型とリテラル を参照しましょう。

ポイント

  • プリミティブ型とオブジェクトがある
  • リテラルはデータ型の値を直接記述できる構文として定義されたもの
  • プリミティブ型リテラル
    • 真偽値 … true false
    • 数値 … 42 3.14159 など
    • 文字列 … "JavaScript" など
    • BigInt … 9007199254740992n など
    • null … null

コメント

JavaScript Primer > 基本文法 > コメント

コメントはプログラムとして評価されないため、ソースコードの説明を書くために利用されています。 この書籍でも、JavaScriptのソースコードを解説するためにコメントを使っていきます。

コメントの書き方には、一行コメントと複数行コメントの2種類があります。

― この文章は © 2023 jsprimer project クリエイティブ・コモンズ CC BY 4.0 ライセンスのもとに利用を許諾されています。

続きは JavaScript Primer > 基本文法 > コメント を参照しましょう。

ポイント

  • // 以降から行末までが一行コメント
  • /**/ で囲まれた範囲が複数行コメント

やってみよう!

// これは一行コメント

/*
   これは
    複数行
      コメント
 */

// JavaScriptを使えば…

// 自由にテキストを書き換えることもできます!
document.body.textContent = "JavaScriptの世界からこんにちは!✨";

演算子

JavaScript Primer > 基本文法 > 演算子

演算子はよく利用する演算処理を記号などで表現したものです。 たとえば、足し算をする + も演算子の一種です。これ以外にも演算子には多くの種類があります。

演算子は演算する対象を持ちます。この演算子の対象のことを被演算子(オペランド)と呼びます。

次のコードでは、+演算子が値同士を足し算する加算演算を行っています。 このとき、+演算子の対象となっている12という2つの値がオペランドです。

1 + 2;

このコードでは+演算子に対して、前後に合計2つのオペランドがあります。 このように、2つのオペランドを取る演算子を二項演算子と呼びます。

// 二項演算子とオペランドの関係
左オペランド 演算子 右オペランド

また、1つの演算子に対して1つのオペランドだけを取るものもあります。 たとえば、数値をインクリメントする++演算子は、次のように前後どちらか一方にオペランドを置きます。

let num = 1;
num++;
// または
++num;

このように、1つのオペランドを取る演算子を単項演算子と呼びます。 単項演算子と二項演算子で同じ記号を使うことがあるため、呼び方を変えています。

この章では、演算子ごとにそれぞれの処理について学んでいきます。 また、演算子の中でも比較演算子は、JavaScriptでも特に挙動が理解しにくい暗黙的な型変換という問題と密接な関係があります。 そのため、演算子をひととおり見た後に、暗黙的な型変換と明示的な型変換について学んでいきます。

演算子の種類は多いため、すべての演算子の動作をここで覚える必要はありません。 必要となったタイミングで、改めてその演算子の動作を見るのがよいでしょう。

― この文章は © 2023 jsprimer project クリエイティブ・コモンズ CC BY 4.0 ライセンスのもとに利用を許諾されています。

続きは JavaScript Primer > 基本文法 > 演算子 を参照しましょう。

ポイント

  • 演算子はよく利用する演算処理を記号などで表現したもの
  • 四則演算や論理演算などさまざまな種類の演算子がある
  • 演算子には優先順位が定義されており、グループ化演算子で明示できる

条件分岐

JavaScript Primer > 基本文法 > 条件分岐

この章ではif文やswitch文を使った条件分岐について学んでいきます。 条件分岐を使うことで、特定の条件を満たすかどうかで行う処理を変更できます。

― この文章は © 2023 jsprimer project クリエイティブ・コモンズ CC BY 4.0 ライセンスのもとに利用を許諾されています。

続きは JavaScript Primer > 基本文法 > 条件分岐 を参照しましょう。

ポイント

  • if文

やってみよう!

let color = "white";

if (Math.random() < 0.5) {
  color = "green";
}

document.body.style.backgroundColor = color;
document.body.textContent = `今日のラッキーカラー: ${color}`;

関数と宣言

JavaScript Primer > 基本文法 > 関数と宣言

関数とは、ある一連の手続き(文の集まり)を1つの処理としてまとめる機能です。 関数を利用することで、同じ処理を毎回書くのではなく、一度定義した関数を呼び出すことで同じ処理を実行できます。

これまで利用してきたコンソール表示をするConsole APIも関数です。 console.logは「受け取った値をコンソールへ出力する」という処理をまとめた関数です。

この章では、関数の定義方法や呼び出し方について見ていきます。

― この文章は © 2023 jsprimer project クリエイティブ・コモンズ CC BY 4.0 ライセンスのもとに利用を許諾されています。

続きは JavaScript Primer > 基本文法 > 関数と宣言 を参照しましょう。

ポイント

  • 関数の宣言方法

やってみよう!

function 円の面積(r) {
  return Math.PI * r * r;
}

const r1 = 1;
const r2 = 3;

document.body.textContent = `
半径 ${r1} m の円の面積: ${円の面積(r1)} m²
半径 ${r2} m の円の面積: ${円の面積(r2)} m²
`;

Math.PIは円周率(およそ3.14159)を表すMathオブジェクトの静的プロパティです。

ループと反復処理

JavaScript Primer > 基本文法 > ループと反復処理

この章では、while文やfor文などの基本的な反復処理と制御文について学んでいきます。

プログラミングにおいて、同じ処理を繰り返すために同じコードを繰り返し書く必要はありません。 ループやイテレータなどを使い、反復処理として同じ処理を繰り返し実行できます。

また、for文などのような構文だけではなく、配列のメソッドを利用して反復処理を行う方法もあります。 配列のメソッドを使った反復処理もよく利用されるため、合わせて見ていきます。

― この文章は © 2023 jsprimer project クリエイティブ・コモンズ CC BY 4.0 ライセンスのもとに利用を許諾されています。

続きは JavaScript Primer > 基本文法 > ループと反復処理 を参照しましょう。

ポイント

  • while文
  • for文

やってみよう!

const text = "いろはにほへと";

for (const c of text) {
  document.body.textContent += `【${c}】`;
}

非同期処理

JavaScript Primer > 基本文法 > 非同期処理:Promise/Async Function

この章ではJavaScriptの非同期処理について学んでいきます。 非同期処理はJavaScriptにおけるとても重要な概念です。 また、ブラウザやNode.jsなどのAPIには非同期処理でしか扱えないものもあるため、非同期処理を避けることはできません。 JavaScriptには非同期処理を扱うためのPromiseというビルトインオブジェクト、さらにはAsync Functionと呼ばれる構文的なサポートがあります。

この章では非同期処理とはどのようなものかという話から、非同期処理での例外処理、非同期処理の扱い方を見ていきます。

― この文章は © 2023 jsprimer project クリエイティブ・コモンズ CC BY 4.0 ライセンスのもとに利用を許諾されています。

続きは JavaScript Primer > 基本文法 > 非同期処理:Promise/Async Function を参照しましょう。

ポイント

  • 非同期処理はその処理が終わるのを待つ前に次の処理を評価すること
  • await
    • 書式: await 関数()
    • 意味: await 式は Async Function 関数() が完了するまで待つ
  • Async Function … async function 関数() { <awaitの含まれる文>; }
    • 非同期処理を行う関数
    • await 式は Async Function の中で利用できる

一定時間待機

一定時間待機するAsync Function sleep() の作り方を解説します。

setTimeout(<コールバック関数>, <ミリ秒>) 関数は指定されたミリ秒後にコールバック関数を実行するための関数です。コールバック関数とは、簡単に言うと「後で使うために渡しておく関数」です。

次のコードは「3秒後にメッセージを表示する」という処理のプログラムです。メッセージの表示処理を resolve() 関数として宣言し、その関数名をコールバック関数として書きます。

function resolve() {
  console.log("3秒!");
}

setTimeout(resolve, 3000); // 3秒 = 3,000ミリ秒

実行すると3秒後にメッセージ"3秒!"が表示されたかと思います。 これでも十分短いコードですが、今度はPromiseとawaitを使って書き換えてみます。

await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("3秒!");

こうすることで上から下へと順番に実行する処理順で書けます。 これにより時間がかかるようなプログラムをもっと簡単に読みやすく書くことができます。

Note
現実世界と非同期処理

コンピューターの上ではどのようなタイミングで何を行うかプログラマーが自由に決めることができますが、現実の世界では同じ時刻でも様々な事象が常に起こっています。 私たちの日常生活は多くの非同期的なイベントで構成されていると言えます。 非同期処理の概念を理解しておくことは現実の問題をコンピューターの上で扱うのにとても役に立ちます。

関数を使用して指定されたミリ秒(ms)だけ待つように一般化してみましょう。 ここで await 式は通常の関数のなかでは利用できず、代わりにAsync Functionの中で利用できるという点に注意しましょう。

まとめるとこのようになります:

async function sleep(ms) {
  await new Promise((resolve) => setTimeout(resolve, ms));
}

使用方法:

await sleep(ミリ秒);

インターネットからのデータの取得

インターネットからデータを取得する際にも、その処理が完了するまで待つ必要があります。

書式:

let res = await fetch(取得するURL);
let data = await res.json();

最初の await 式でAPIからの応答を待ち、次に res.json() の処理の完了を待ちます。 このようにしてデータが完全に取得されるまでコードの実行が一時停止し、データが利用可能になると処理を再開します。

やってみよう!

const button = document.createElement("button");
button.textContent = "スタート";
document.body.append(button);

async function sleep(ms) {
  await new Promise(resolve => setTimeout(resolve, ms));
}

button.addEventListener("click", async function () {
  await sleep(3000); // クリックしてから3秒待つ

  document.body.append("3秒!");
});

暗黙的な型変換

JavaScript Primer > 基本文法 > 暗黙的な型変換

この章では、明示的な型変換と暗黙的な型変換について学んでいきます。

演算子」の章にて、 等価演算子(==)ではなく厳密等価演算子(===)の利用を推奨していました。 これは厳密等価演算子(===)が暗黙的な型変換をせずに、値同士を比較できるためです。

厳密等価演算子(===)では異なるデータ型を比較した場合に、その比較結果は必ずfalseとなります。 次のコードは、数値の1と文字列の"1"という異なるデータ型を比較しているので、結果はfalseとなります。

// `===`では、異なるデータ型の比較結果はfalse
console.log(1 === "1"); // => false

しかし、等価演算子(==)では異なるデータ型を比較した場合に、同じ型となるように暗黙的な型変換をしてから比較します。 次のコードでは、数値の1と文字列の"1"の比較結果がtrueとなっています。 これは、等価演算子(==)は右辺の文字列"1"を数値の1へと暗黙的な型変換をしてから、比較するためです。

// `==`では、異なるデータ型は暗黙的な型変換をしてから比較される
// 暗黙的な型変換によって 1 == 1 のように変換されてから比較される
console.log(1 == "1"); // => true

このように、暗黙的な型変換によって意図しない結果となるため、比較には厳密等価演算子(===)を使うべきです。

別の暗黙的な型変換の例として、数値と真偽値の加算を見てみましょう。 多くの言語では、数値と真偽値の加算のような異なるデータ型同士の加算はエラーとなります。 しかし、JavaScriptでは暗黙的な型変換が行われてから加算されるため、エラーなく処理されます。

次のコードでは、真偽値のtrueが数値の1へと暗黙的に変換されてから加算処理が行われます。

// 暗黙的な型変換が行われ、数値の加算として計算される
1 + true; // => 2
// 次のように暗黙的に変換されてから計算される
1 + 1; // => 2

JavaScriptでは、エラーが発生するのではなく、暗黙的な型変換が行われてしまうケースが多くあります。 暗黙的に変換が行われた場合、プログラムは例外を投げずに処理が進むため、バグの発見が難しくなります。 このように、暗黙的な型変換はできる限り避けるべき挙動です。

この章では、次のことについて学んでいきます。

  • 暗黙的な型変換とはどのようなものなのか
  • 暗黙的ではない明示的な型変換の方法
  • 明示的な変換だけでは解決しないこと

― この文章は © 2023 jsprimer project クリエイティブ・コモンズ CC BY 4.0 ライセンスのもとに利用を許諾されています。

続きは JavaScript Primer > 基本文法 > 暗黙的な型変換 を参照しましょう。

ポイント

  • 暗黙的な型変換がある
  • できるだけ === での比較や明示的な型変換をしたほうが読みやすい

文と式

JavaScript Primer > 基本文法 > 文と式

本格的に基本文法について学ぶ前に、JavaScriptというプログラミング言語がどのような要素からできているかを見ていきましょう。

― この文章は © 2023 jsprimer project クリエイティブ・コモンズ CC BY 4.0 ライセンスのもとに利用を許諾されています。

続きは JavaScript Primer > 基本文法 > 文と式 を参照しましょう。

ポイント

  • JavaScriptは文(Statement)と式(Expression)から構成される
  • 式は値を生成し、変数に代入できるもの
  • 文は処理の単位
  • 文の末尾にはセミコロン;をつける

オブジェクト

JavaScript Primer > 基本文法 > オブジェクト

オブジェクトはプロパティの集合です。プロパティとは名前(キー)と値(バリュー)が対になったものです。 プロパティのキーには文字列またはSymbolが利用でき、値には任意のデータを指定できます。 また、1つのオブジェクトは複数のプロパティを持てるため、1つのオブジェクトで多種多様な値を表現できます。

今までも登場してきた、配列や関数などもオブジェクトの一種です。 JavaScriptには、あらゆるオブジェクトの元となるObjectというビルトインオブジェクトがあります。 ビルトインオブジェクトは、実行環境にあらかじめ定義されているオブジェクトのことです。 ObjectというビルトインオブジェクトはECMAScriptの仕様で定義されているため、あらゆるJavaScriptの実行環境で利用できます。

この章では、オブジェクトの作成や扱い方、Objectというビルトインオブジェクトについて見ていきます。

― この文章は © 2023 jsprimer project クリエイティブ・コモンズ CC BY 4.0 ライセンスのもとに利用を許諾されています。

続きは JavaScript Primer > 基本文法 > オブジェクト を参照しましょう。

ポイント

  • オブジェクトはプロパティの集合
  • {}(オブジェクトリテラル)でのオブジェクトの作成や更新方法

配列

JavaScript Primer > 基本文法 > 配列

配列はJavaScriptの中でもよく使われるオブジェクトです。

配列とは値に順序をつけて格納できるオブジェクトです。 配列に格納したそれぞれの値のことを要素、それぞれの要素の位置のことをインデックスindex)と呼びます。 インデックスは先頭の要素から012のように0からはじまる連番となります。

またJavaScriptにおける配列は可変長です。 そのため配列を作成後に配列へ要素を追加したり、配列から要素を削除できます。

この章では、配列の基本的な操作と配列を扱う場合においてのパターンについて学びます。

― この文章は © 2023 jsprimer project クリエイティブ・コモンズ CC BY 4.0 ライセンスのもとに利用を許諾されています。

続きは JavaScript Primer > 基本文法 > 配列 を参照しましょう。

ポイント

  • 配列は値に順序をつけて格納できるオブジェクト
  • [](配列リテラル)での配列の作成や更新方法

やってみよう!

function drawFortune() {
  const fortunes = ["大吉", "中吉", "吉", "小吉", "凶", "大凶"];
  const i = Math.floor(Math.random() * fortunes.length);

  document.body.textContent = `あなたの運勢は... ${fortunes[i]}です!`;
}

drawFortune();
  • Math - JavaScript | MDN
    • Math.floor(x)x以下の最大の整数を返します。
    • Math.random()0以上1未満の疑似乱数を返します。

Webサイトを公開する

作成したWebサイトを無料で公開する方法を紹介します。

GitHubを使う

GitHub https://github.com を使うことでより本格的にWebサイトを公開できます (このガイドもそうです)。

GitHubを使うにはアカウントの作成が必要です。まずGitHubの無料アカウントを作成しましょう。

Webページを公開する流れ:

  1. GitHubでのアカウントの作成
  2. GitHub Pagesサイトの作成

自分に合った方法でWebサイトを公開してみましょう!

GitHubでのアカウントの作成

GitHub Pagesサイトの作成

Google Apps Script (GAS) で作るWebアプリ

Webブラウザー上で動作するアプリの作り方を紹介します。地図を表示するためのサードパーティAPI、Google Apps Script、そしてWebブラウザーの位置情報APIの使い方を説明します。

地図上に位置を表示する

地図を表示するためのサードパーティAPI (Leaflet) を使用し、地図上に位置を表示します。

書式

地図:

<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet"></script>
<script type="module">
  const map = L.map("map").setView([36, 138], 15);

  // OpenStreetMapのデータはOpen Database Licenseのもとに利用を許諾されています。
  L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
    attribution: `&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors`,
  }).addTo(map);
</script>
<h1>位置情報メモ</h1>
<div id="map" style="width: 500px; height: 500px"></div>

現在地の取得:

async function getLatLng() {
  const position = await new Promise((resolve, reject) =>
    navigator.geolocation.getCurrentPosition(resolve, reject),
  );

  return [position.coords.latitude, position.coords.longitude];
}

// [<緯度>, <経度>]
const here = await getLatLng();

丸いマーカーの表示:

L.circleMarker([<緯度>, <経度>]).addTo(map);

地図の移動:

map.flyTo([<緯度>, <経度>]);

サンプルコード (全体)

<!doctype html>
<meta charset="UTF-8" />
<title>GASで作るWebアプリ - 位置情報メモ</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet"></script>
<script type="module">
  /** 経緯度の取得 */
  async function getLatLng() {
    const position = await new Promise((resolve, reject) =>
      navigator.geolocation.getCurrentPosition(resolve, reject),
    );

    return [position.coords.latitude, position.coords.longitude];
  }

  const map = L.map("map").setView([36, 138], 15);

  // OpenStreetMapのデータはOpen Database Licenseのもとに利用を許諾されています。
  L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
    attribution: `&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors`,
  }).addTo(map);

  // 現在地
  const here = await getLatLng();
  // 現在地にマーカーを表示
  L.circleMarker(here).addTo(map);
  // 現在地に移動
  map.flyTo(here);
</script>

<h1>位置情報メモ</h1>
<div id="map" style="width: 500px; height: 500px"></div>

Note
[36, 138]は日本の地理的中心、北緯36度東経138度、長野県上伊那郡かみいなぐん辰野町たつのまちの区有林。

位置情報API (Geolocation API) を使用すると、自分の位置情報をWebアプリから取得することができます。 位置情報APIの初回使用時には、位置情報の許可を求められるので[許可]を選択します。

位置情報が地図上に表示されることを確認してみましょう。

スプレッドシートの作成

Googleスプレッドシートを作成し、Google Apps Scriptのプロジェクトを作成します。

事前準備

  • Googleアカウント

スプレッドシートの作成

https://sheet.new にアクセス、または「スプレッドシートのホーム画面」を開き、+をクリックします。

参考: Google スプレッドシートの使い方 - パソコン - Google ドキュメント エディタ ヘルプ

プロジェクトの作成

[拡張機能] > [Apps Script] を選択し、Google Apps Scriptのプロジェクトを作成します。

以下のコードをコピーして貼り付け、💾 [プロジェクトを保存] します。

// 最初のシート
const [sheet] = SpreadsheetApp.getActiveSpreadsheet().getSheets();

/**
 * @example 行全体の取得
 * const res = await fetch("https://script.google.com/{SCRIPTID}/exec");
 * const rows = await res.json();
 * // [
 * //   ["2006-01-02T15:04:05.999Z",1,2],
 * //   ["2006-01-02T15:04:06.000Z",3,4],
 * //   ...
 * // ]
 */
function doGet() {
  const rows = sheet.getDataRange().getValues().slice(1);
  return ContentService.createTextOutput(JSON.stringify(rows)).setMimeType(
    ContentService.MimeType.JSON,
  );
}

/**
 * @example 行の挿入
 * const row = [5,6];
 * await fetch("https://script.google.com/{SCRIPTID}/exec", { method: "POST", body: JSON.stringify(row) })
 */
function doPost(e) {
  const row = JSON.parse(e.postData.contents);
  sheet.appendRow([new Date(), ...row]);
  return doGet();
}

プロジェクトを保存できたら、そのプロジェクトを利用可能にデプロイします。

プロジェクトのデプロイ

プロジェクトを新しくデプロイするには [デプロイ] > [新しいデプロイ] から行います。

[種類の選択] ⚙ > [ウェブアプリ] を選択します。

[アクセスできるユーザー] > [全員] を選択し、[デプロイ] を選択します。

Googleアカウントへのアクセス許可を求められるのでアカウントを選択し、[Allow]許可 をクリックします

WebアプリのURLが表示されればデプロイ完了です。

データの送信にはこのWebアプリのURL (https://script.google.com/macros/s/AKf...) を使用します。

このURLはコピーしておきましょう。

使用方法

データの取得:

// ここはWebアプリのURLに書き換えます
const endpoint = "https://script.google.com/{SCRIPTID}/exec";
const res = await fetch(endpoint);
const rows = await res.json();

データの送信:

// ここはWebアプリのURLに書き換えます
const endpoint = "https://script.google.com/{SCRIPTID}/exec";
const row = [...<送信する内容>...];

await fetch(endpoint, { method: "POST", body: JSON.stringify(row) });

WebアプリのURLと送信する内容の部分は適宜変更して使用します。

送信してみよう!

サンプルコード:

const row = [42];
await fetch(endpoint, { method: "POST", body: JSON.stringify(row) });

endpoint =

レスポンス:

null

データを送信する

フォームからデータを送信してみましょう。

書式

HTMLとJavaScriptでコメント入力欄を作ります。

HTML:

<form>
  <input name="comment" placeholder="コメント" required />
  <button type="submit">送信</button>
</form>

JavaScript:

// ここはWebアプリのURLに書き換えます
const endpoint = "https://script.google.com/{SCRIPTID}/exec";

const form = document.querySelector("form");

form.addEventListener("submit", async function submit(e) {
  e.preventDefault();
  document.body.style.cursor = "wait";

  const formData = new FormData(form);
  const comment = formData.get("comment");
  const row = [comment];

  await fetch(endpoint, { method: "POST", body: JSON.stringify(row) });

  location.reload();
});

サンプルコード (全体)

<!doctype html>
<meta charset="UTF-8" />
<title>GASで作るWebアプリ - 位置情報メモ</title>
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet"></script>
<script type="module">
  /** 経緯度の取得 */
  async function getLatLng() {
    const position = await new Promise((resolve, reject) =>
      navigator.geolocation.getCurrentPosition(resolve, reject),
    );

    return [position.coords.latitude, position.coords.longitude];
  }

  const map = L.map("map").setView([36, 138], 15);

  // OpenStreetMapのデータはOpen Database Licenseのもとに利用を許諾されています。
  L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
    attribution: `&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors`,
  }).addTo(map);

  // 現在地
  const here = await getLatLng();
  // 現在地にマーカーを表示
  L.circleMarker(here).addTo(map);
  // 現在地に移動
  map.flyTo(here);

  // ここはWebアプリのURLに書き換えます
  const endpoint = "https://script.google.com/{SCRIPTID}/exec";

  const form = document.querySelector("form");

  form.addEventListener("submit", async function submit(e) {
    e.preventDefault();
    document.body.style.cursor = "wait";

    const formData = new FormData(form);
    const comment = formData.get("comment");
    const row = [comment];

    await fetch(endpoint, { method: "POST", body: JSON.stringify(row) });

    location.reload();
  });
</script>

<h1>位置情報メモ</h1>
<div id="map" style="width: 500px; height: 500px"></div>
<form>
  <input name="comment" placeholder="コメント" required />
  <button type="submit">送信</button>
</form>

コメントを入力し、[送信] を選択します。

スプレッドシートにコメントのデータが記録されていることを確認してみましょう。

位置情報を送信する

ここまで説明に沿ってやってきていたら comment の後ろに現在地を書き加えれば現在地の緯度・経度を送信できます。

const row = [comment];

const row = [comment, here[0], here[1]];

このように書き加えればOKです。

書式

// コメント, 緯度, 経度
const row = [comment, here[0], here[1]];

サンプルコード (全体)

<!doctype html>
<meta charset="UTF-8" />
<title>GASで作るWebアプリ - 位置情報メモ</title>
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet"></script>
<script type="module">
  /** 経緯度の取得 */
  async function getLatLng() {
    const position = await new Promise((resolve, reject) =>
      navigator.geolocation.getCurrentPosition(resolve, reject),
    );

    return [position.coords.latitude, position.coords.longitude];
  }

  const map = L.map("map").setView([36, 138], 15);

  // OpenStreetMapのデータはOpen Database Licenseのもとに利用を許諾されています。
  L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
    attribution: `&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors`,
  }).addTo(map);

  // 現在地
  const here = await getLatLng();
  // 現在地にマーカーを表示
  L.circleMarker(here).addTo(map);
  // 現在地に移動
  map.flyTo(here);

  // ここはWebアプリのURLに書き換えます
  const endpoint = "https://script.google.com/{SCRIPTID}/exec";

  const form = document.querySelector("form");

  form.addEventListener("submit", async function submit(e) {
    e.preventDefault();
    document.body.style.cursor = "wait";

    const formData = new FormData(form);
    const comment = formData.get("comment");
    const row = [comment, here[0], here[1]];

    await fetch(endpoint, { method: "POST", body: JSON.stringify(row) });

    location.reload();
  });
</script>

<h1>位置情報メモ</h1>
<div id="map" style="width: 500px; height: 500px"></div>
<form>
  <input name="comment" placeholder="コメント" required />
  <button type="submit">送信</button>
</form>

スプレッドシートに位置情報が記録されていることを確認してみましょう。

データを取得する

最後にスプレッドシートのデータを地図上に表示してみましょう。

書式

データの取得:

const res = await fetch(endpoint);
const rows = await res.json();

// 日付と時刻, コメント, 緯度, 経度
for (const [timestamp, comment, lat, lng] of rows) {
  // 日付と時刻
  const date = new Date(timestamp).toLocaleString();
  // …
}

マーカーの追加:

const popup = document.createElement("span");
popup.textContent = <表示する内容>;
L.marker([<緯度>, <経度>]).addTo(map).bindPopup(popup);

サンプルコード (全体)

<!doctype html>
<meta charset="UTF-8" />
<title>GASで作るWebアプリ - 位置情報メモ</title>
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet"></script>
<script type="module">
  /** 経緯度の取得 */
  async function getLatLng() {
    const position = await new Promise((resolve, reject) =>
      navigator.geolocation.getCurrentPosition(resolve, reject),
    );

    return [position.coords.latitude, position.coords.longitude];
  }

  const map = L.map("map").setView([36, 138], 15);

  // OpenStreetMapのデータはOpen Database Licenseのもとに利用を許諾されています。
  L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
    attribution: `&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors`,
  }).addTo(map);

  // 現在地
  const here = await getLatLng();
  // 現在地にマーカーを表示
  L.circleMarker(here).addTo(map);
  // 現座地に移動
  map.flyTo(here);

  // ここはWebアプリのURLに書き換えます
  const endpoint = "https://script.google.com/{SCRIPTID}/exec";

  const res = await fetch(endpoint);
  const rows = await res.json();

  for (const [timestamp, comment, lat, lng] of rows) {
    const date = new Date(timestamp).toLocaleString();
    const popup = document.createElement("span");
    popup.textContent = `${date}: ${comment}`;
    L.marker([lat, lng]).addTo(map).bindPopup(popup);
  }

  const form = document.querySelector("form");

  form.addEventListener("submit", async function submit(e) {
    e.preventDefault();
    document.body.style.cursor = "wait";

    const formData = new FormData(form);
    const comment = formData.get("comment");
    const row = [comment, here[0], here[1]];

    await fetch(endpoint, { method: "POST", body: JSON.stringify(row) });

    location.reload();
  });
</script>

<h1>位置情報メモ</h1>
<div id="map" style="width: 500px; height: 500px"></div>
<form>
  <input name="comment" placeholder="コメント" required />
  <button type="submit">送信</button>
</form>

やってみよう!

  • より魅力的にしていくにはどうすればよいか考えてみましょう
    • 例: CSSを使ってきれいな見た目にする?
    • 例: コードを整理する?
  • 思いついたら「とりあえずやってみる」

参考文献

Raspberry Piで温度センサーのデータの送信

Google Apps Scriptを利用してRaspberry Piからスプレッドシートにデータを送信する方法を説明します。

スプレッドシートの作成

Googleスプレッドシートを作成し、Google Apps Scriptのプロジェクトを作成します。

事前準備

  • Googleアカウント

スプレッドシートの作成

https://sheet.new にアクセス、または「スプレッドシートのホーム画面」を開き、+をクリックします。

参考: Google スプレッドシートの使い方 - パソコン - Google ドキュメント エディタ ヘルプ

プロジェクトの作成

[拡張機能] > [Apps Script] を選択し、Google Apps Scriptのプロジェクトを作成します。

以下のコードをコピーして貼り付け、💾 [プロジェクトを保存] します。

// 最初のシート
const [sheet] = SpreadsheetApp.getActiveSpreadsheet().getSheets();

/**
 * @example 行全体の取得
 * const res = await fetch("https://script.google.com/{SCRIPTID}/exec");
 * const rows = await res.json();
 * // [
 * //   ["2006-01-02T15:04:05.999Z",1,2],
 * //   ["2006-01-02T15:04:06.000Z",3,4],
 * //   ...
 * // ]
 */
function doGet() {
  const rows = sheet.getDataRange().getValues().slice(1);
  return ContentService.createTextOutput(JSON.stringify(rows)).setMimeType(
    ContentService.MimeType.JSON,
  );
}

/**
 * @example 行の挿入
 * const row = [5,6];
 * await fetch("https://script.google.com/{SCRIPTID}/exec", { method: "POST", body: JSON.stringify(row) })
 */
function doPost(e) {
  const row = JSON.parse(e.postData.contents);
  sheet.appendRow([new Date(), ...row]);
  return doGet();
}

プロジェクトを保存できたら、そのプロジェクトを利用可能にデプロイします。

プロジェクトのデプロイ

プロジェクトを新しくデプロイするには [デプロイ] > [新しいデプロイ] から行います。

[種類の選択] ⚙ > [ウェブアプリ] を選択します。

[アクセスできるユーザー] > [全員] を選択し、[デプロイ] を選択します。

Googleアカウントへのアクセス許可を求められるのでアカウントを選択し、[Allow]許可 をクリックします

WebアプリのURLが表示されればデプロイ完了です。

データの送信にはこのWebアプリのURL (https://script.google.com/macros/s/AKf...) を使用します。

このURLはコピーしておきましょう。

使用方法

データの取得:

// ここはWebアプリのURLに書き換えます
const endpoint = "https://script.google.com/{SCRIPTID}/exec";
const res = await fetch(endpoint);
const rows = await res.json();

データの送信:

// ここはWebアプリのURLに書き換えます
const endpoint = "https://script.google.com/{SCRIPTID}/exec";
const row = [...<送信する内容>...];

await fetch(endpoint, { method: "POST", body: JSON.stringify(row) });

WebアプリのURLと送信する内容の部分は適宜変更して使用します。

送信してみよう!

サンプルコード:

const row = [42];
await fetch(endpoint, { method: "POST", body: JSON.stringify(row) });

endpoint =

レスポンス:

null

温度センサーのデータの送信

それではRaspberry Piからスプレッドシートにデータを送信してみましょう。

温度センサー SHT30 を利用して温度のデータを送信します。

事前準備

  • Raspberry Pi
  • SHT30 (温度・湿度センサ)
  • 配線用のワイヤー

サンプルコード

次のようなNode.jsのコードを実行することでデータを送信します:

// ここはWebアプリのURLに書き換えます
const endpoint = "https://script.google.com/{SCRIPTID}/exec";

import { requestI2CAccess } from "node-web-i2c";
import SHT30 from "@chirimen/sht30";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const i2cAccess = await requestI2CAccess();
const port = i2cAccess.ports.get(1);
const sht30 = new SHT30(port, 0x44);
await sht30.init();

while (true) {
  const { humidity, temperature } = await sht30.readData();
  const row = [temperature];

  await fetch(endpoint, { method: "POST", body: JSON.stringify(row) });

  const message = `現在の温度は${temperature.toFixed(2)}度です`;

  console.log(endpoint, message);

  // 10秒待機
  await sleep(10000); // ms
}

スプレッドシートに温度センサーのデータが記録されていることを確認してみましょう。

グラフの作成

Googleスプレッドシートでグラフを作成します。

  1. グラフの列を選択します
  2. [挿入] > [グラフ] を選択します

グラフのタイトル

グラフの見た目を変更する方法をいくつか紹介します。

  1. 変更するグラフをダブルクリックします
  2. 右側の [カスタマイズ] を選択します
  3. [グラフと軸のタイトル] を選択します
  4. [グラフのタイトル] > タイトルテキストを入力します

縦軸の範囲の変更

縦軸の表示範囲は [縦軸] > [最小値]/[最大値] を入力します。

目盛り

目盛りの追加は [グリッドラインと目盛] > [主目盛り]/[補助目盛り] を選択します。

表示形式

  1. グラフの列を選択します
  2. [表示形式] メニューから表示形式を選択します

参考文献

Raspberry Piからスマートフォンにデータを送信する

ntfy.shを利用してRaspberry Piからスマートフォンにデータを送信する方法を説明します。

ntfy.shは、ユーザーに通知を送信するためのシンプルなサービスです。このサービスは特定のイベントや条件が発生したときに通知を送るために利用できます。

制約事項

(無料枠) 1日あたりのメッセージの上限は250件です。 10分あたり1件程度の通知を目安にしましょう。

その他にもAPIにはいくつかの制限があります。注意して利用しましょう。

制限説明
メッセージの長さ各メッセージの長さは最大 4,096 バイトです。長いメッセージは添付ファイルとして扱われます。
リクエストデフォルトでは、サーバーは訪問者あたり一度に 60 件のリクエストを許可し、その後 5 秒に 1 件の割合で許可されたリクエスト バケットを補充するように設定されています。
1 日あたりのメッセージデフォルトでは、メッセージ数はリクエスト制限によって制御されます。これはオーバーライドできます。 ntfy.sh では、1 日あたりのメッセージ制限は 250 です。
メールデフォルトでは、サーバーは訪問者ごとに一度に 16 通の電子メールを送信できるように設定されており、許可された電子メール バケットは 1 時間に 1 通の割合で補充されます。 ntfy.sh では、1 日あたりの制限は 5 です。
電話通話デフォルトでは、通話制限のある層を持つユーザーを除き、サーバーは電話通話を許可しません。
サブスクリプション制限デフォルトでは、サーバーは各訪問者がサーバーへの 30 接続を開いたままにすることを許可します。
添付ファイルのサイズ制限デフォルトでは、サーバーは添付ファイルのサイズが最大 15 MB、訪問者あたり合計で最大 100 MB、訪問者全体で最大 5 GB まで許可します。 ntfy.sh では、添付ファイルのサイズ制限は 2 MB で、訪問者あたりの合計は 20 MB です。
添付ファイルの有効期限デフォルトでは、サーバーは 3 時間後に添付ファイルを削除するため、訪問者の添付ファイルの合計制限からスペースが解放されます。
添付ファイルの帯域幅デフォルトでは、サーバーは 24 時間以内に訪問者ごとに 500 MB の添付ファイルの GET/PUT/POST トラフィックを許可します。それを超えるトラフィックは拒否されます。 ntfy.sh では、1 日の帯域幅制限は 200 MB です。
トピックの総数デフォルトでは、サーバーは 15,000 のトピックを許可するように構成されています。ただし、ntfy.sh サーバーにはより高い制限があります。

https://docs.ntfy.sh/publish/#limitations より

トピックの作成

ntfy.shを利用するには、まずトピックを作成します。

新しいトピックを作成してみましょう:

トピックにアクセスし[購読]することで通知を受け取ることができるようになります。 このときのURLは忘れないようにメモしておきます。

使用方法

メッセージの送信:

// ここはntfy.shのURLに書き換えます
const endpoint = "https://ntfy.sh/mytopic";
const message = `<メッセージ本文>`;
const res = await fetch(endpoint, { method: "POST", body: message });

送信してみよう!

サンプルコード:

const message = `現在の時刻は ${new Date().toTimeString()} です`;
await fetch(endpoint, { method: "POST", body: message });

endpoint =

レスポンス:

null

温度センサーのデータの送信

それではRaspberry Piからスマートフォンにデータを送信してみましょう。

温度センサー SHT30 を利用して温度のデータを送信します。

事前準備

  • Raspberry Pi
  • SHT30 (温度・湿度センサ)
  • 配線用のワイヤー

配線図

書式

// ここはntfy.shのURLに書き換えます
const endpoint = <ntfy.shのURL>;

await fetch(endpoint, { method: "POST", body: <送信する内容> });

ntfy.shのURLと送信する内容の部分を書き換えて使用します。

スマートフォンに温度センサーのデータが送信されていることを確認してみましょう。

サンプルコード

次のようなNode.jsのコードを実行することでデータを送信します:

// ここはntfy.shのURLに書き換えます
const endpoint = "https://ntfy.sh/536804b7-65aa-403f-97f6-7bd945e83491";

import { requestI2CAccess } from "node-web-i2c";
import SHT30 from "@chirimen/sht30";

const i2cAccess = await requestI2CAccess();
const port = i2cAccess.ports.get(1);
const sht30 = new SHT30(port, 0x44);
await sht30.init();

const { humidity, temperature } = await sht30.readData();
const message = `現在の温度は${temperature.toFixed(2)}度です`;

await fetch(endpoint, { method: "POST", body: message });

console.log(endpoint, message);

制約事項

(無料枠) 1日あたりのメッセージの上限は250件です。 10分あたり1件程度の通知を目安にしましょう。

参考: https://docs.ntfy.sh/publish/#limitations より

質問・提案・問題の報告

もし気になることなどあれば、Cosense または GitHub Issues からお気軽にお寄せください。

Scrapbox

  • 利用にはGoogleアカウントが必要です
  • 詳しくはCosenseの使い方をご参照ください

GitHub

  • 利用にはGitHubアカウントが必要です