Chapter 10

ビューとテンプレート

koga1020
koga1020
2021.11.24に更新

ビューとテンプレート

前提: このガイドでは、入門ガイドの内容を理解し、Phoenixアプリケーションを起動していることを前提としています。

前提: リクエストライフサイクルのガイドの内容を前提としています

Phoenixビューの主な仕事は、ブラウザやAPIクライアントに送信されるレスポンスの本文をレンダリングすることです。ほとんどの場合、テンプレートを使用してレスポンスを作成しますが、手作業で作成することもできます。その方法を学びます。

テンプレートのレンダリング

Phoenixでは、コントローラーからビュー、そしてそれらがレンダリングするテンプレートに至るまで、強力な命名規則を前提としています。PageController は、lib/hello_web/templates/page/ ディレクトリにあるテンプレートをレンダリングするために、PageView を必要とします。これらはすべてカスタマイズ可能ですが(詳細は Phoenix.ViewPhoenix.Template を参照してください)、Phoenixの規約に従うことを推奨します。

新しく生成されたPhoenixアプリケーションには、ErrorViewLayoutViewPageView の3つのビューモジュールがあり、これらはすべて lib/hello_web/views/ ディレクトリにあります。

LayoutView を簡単に見てみましょう。

defmodule HelloWeb.LayoutView do
  use HelloWeb, :view
end

十分にシンプルです。1行だけ、use HelloWeb, :view があります。この行は HelloWeb で定義された view/0 関数を呼び出して、ビューやテンプレートの基本的なインポートと設定を行います。

ビューで作成したインポートやエイリアスはすべて、テンプレートでも利用できます。これは、テンプレートがそれぞれのビュー内の関数に効果的にコンパイルされているからです。たとえば、ビュー内で関数を定義した場合、テンプレートから直接呼び出すことができます。実際に見てみましょう。

アプリケーションのレイアウトテンプレート lib/hello_web/templates/layout/root.html.heex を開き、この行を変更します。

<%= live_title_tag assigns[:page_title] || "Hello", suffix: " · Phoenix Framework" %>

title/0 関数を呼び出すには、このようにします。

<title><%= title() %></title>

それでは、LayoutViewtitle/0 関数を追加してみましょう。

defmodule HelloWeb.LayoutView do
  use HelloWeb, :view

  def title() do
    "Awesome New Title!"
  end
end

ホーム画面をリロードすると、新しいタイトルが表示されるはずです。テンプレートはビューの中でコンパイルされているので、単に title() としてビュー関数を呼び出すことができますが、そうでなければ HelloWeb.LayoutView.title() と入力しなければなりません。

覚えているかもしれませんが、Elixirのテンプレートは「HTML+EEx」の略である .heex を使用しています。EExはElixirのライブラリで、<%= expression %> を使ってElixirの式を実行し、その結果をテンプレートに補間します。これは、@ のショートカットで設定したアサインを表示するためによく使われます。たとえば、コントローラーの中で、次のように実行します。

  render(conn, "show.html", username: "joe")

そうすると、テンプレートの中で <%= @username %> のように当該ユーザー名にアクセスできます。アサインや関数の表示に加えて、Elixirのほとんどの式を使うことができます。たとえば、条件式を持つためには、次のようになります。

<%= if some_condition? do %>
  <p>Some condition is true for user: <%= @username %></p>
<% else %>
  <p>Some condition is false for user: <%= @username %></p>
<% end %>

ループも可能です。

<table>
  <tr>
    <th>Number</th>
    <th>Power</th>
  </tr>
<%= for number <- 1..10 do %>
  <tr>
    <td><%= number %></td>
    <td><%= number * number %></td>
  </tr>
<% end %>
</table>

上の例で、<%= %><% %> の使い分けに気付きましたか?テンプレートに何かを出力するすべての式は、必ず等号(=)を使用しなければなりません。これが含まれていない場合、コードは実行されますが、テンプレートには何も挿入されません。

HTML拡張

.heex テンプレートには、<%= %> による Elixir の式の補間が可能なだけでなく、HTML を意識した拡張機能が備わっています。たとえば、HTMLインジェクションにつながる「<」や「>」を含む値を補間しようとすると、何が起こるかを見てみましょう。

<%= "<b>Bold?</b>" %>

このテンプレートをレンダリングすると、ページ上にリテラルの <b> が表示されます。これは、ユーザーがページ上にHTMLコンテンツを注入できないことを意味します。もし、ユーザーにそれを許したいのであれば、raw を呼び出すことができますが、それには細心の注意が必要です。

<%= raw "<b>Bold?</b>" %>

HEExテンプレートのもう1つのスーパーパワーは、HTMLのバリデーションと属性のムダのない補間構文です。次のように書くことができます。

<div title="My div" class={@class}>
  <p>Hello <%= @username %></p>
</div>

単に key={value} とすることもできることに注意してください。HEExは、属性やクラスのリストを削除するための false のような特殊な値を自動的に処理します。

キーワードリストやマップで動的な数の属性を補間するには、次のようにします。

<div title="My div" {@many_attributes}>
  <p>Hello <%= @username %></p>
</div>

また、試しに </div> を削除するか、それを <div-typo> のように修正してみてください。HEExテンプレートはエラーで教えてくれます。

HTMLコンポーネント

HEExが提供する最後の機能は、コンポーネントの考え方です。コンポーネントとは、ローカル(同じモジュール)またはリモート(外部モジュール)のいずれかになることができる純粋な関数です。

HEExでは、HTMLのような記法を使って、テンプレート内で直接その関数コンポーネントを呼び出すことができます。たとえば、リモート関数の場合

<MyApp.Weather.city name="Kraków"/>

ローカル関数は、先頭にドットを付けて呼び出すことができます。

<.city name="Kraków"/>

ここで、コンポーネントは次のように定義されます。

defmodule MyApp.Weather do
  use Phoenix.Component

  def city(assigns) do
    ~H"""
    The chosen city is: <%= @name %>.
    """
  end

  def country(assigns) do
    ~H"""
    The chosen country is: <%= @name %>.
    """
  end
end

上の例では、~H のsigilを使って、HEExのテンプレートをモジュールに直接埋め込んでいます。すでに city コンポーネントを呼び出していますが、country コンポーネントを呼び出しても違いはありません。

<div title="My div" {@many_attributes}>
  <p>Hello <%= @username %></p>
  <MyApp.Weather.country name="Brazil" />
</div>

コンポーネントについては、Phoenix.Componentで詳しく解説しています。

テンプレートのコンパイルを理解する

テンプレートをビューにコンパイルする際には、単純に render 関数としてコンパイルされます。

このことを証明するには、lib/hello_web/views/page_view.exPageView モジュールに次の関数を一時的に追加します。

defmodule HelloWeb.PageView do
  use HelloWeb, :view

  def render("index.html", assigns) do
    "rendering with assigns #{inspect Map.keys(assigns)}"
  end
end

さて、mix phx.server でサーバーを起動して http://localhost:4000 にアクセスすると、メインテンプレートページの代わりにレイアウトヘッダーの下に次のテキストが表示されるはずです。

rendering with assigns [:conn]

独自の render/2 を定義することで、テンプレートよりも優先度が高くなります。新たに追加した句を単に削除することで、テンプレートはまだ存在していることを確認できます。

非常にすっきりしていますよね?コンパイル時に、Phoenixはすべての *.html.heex テンプレートをプリコンパイルし、それぞれのビューモジュール上で render/2 関数句に変換します。実行時には、すべてのテンプレートはすでにメモリにロードされています。ディスクの読み込み、複雑なファイルのキャッシング、テンプレートエンジンの計算は必要ありません。

テンプレートを手動でレンダリングする

これまでのところ、Phoenixがすべてを配置し、ビューをレンダリングしてくれています。しかし、ビューを直接レンダリングすることもできます。

新しいテンプレート lib/hello_web/templates/page/test.html.heex を作成して遊んでみましょう。

This is the message: <%= @message %>

これはコントローラーのどのアクションにも対応していません。これを IEx セッションで実行してみましょう。プロジェクトのルートで iex -S mix を実行し、テンプレートを明示的にレンダリングします。
試しに、Phoenix.View.render/3 を、ビュー名、テンプレート名、渡したい代入のセットで呼び出してみると、レンダリングされたテンプレートが文字列として得られました。

iex(1)> Phoenix.View.render(HelloWeb.PageView, "test.html", message: "Hello from IEx!")
%Phoenix.LiveView.Rendered{
  dynamic: #Function<1.71437968/1 in Hello16Web.PageView."test.html"/1>,
  fingerprint: 142353463236917710626026938006893093300,
  root: false,
  static: ["This is the message: ", ""]
}

上記で得られた出力はあまり役に立ちません。これは、Phoenixがレンダリングされたテンプレートをどのように保持しているかを示す内部表現です。幸いなことに、render_to_string/3 で文字列に変換できます。

iex(2)> Phoenix.View.render_to_string(HelloWeb.PageView, "test.html", message: "Hello from IEx!")
"This is the message: Hello from IEx!"

ずいぶん良くなりましたね。ちょっとしたお遊びとして、HTMLのエスケープを試してみましょう。

iex(3)> Phoenix.View.render_to_string(HelloWeb.PageView, "test.html", message: "<script>badThings();</script>")
"This is the message: &lt;script&gt;badThings();&lt;/script&gt;"

ビューとテンプレートを共有する

これで Phoenix.View.render/3 を使いこなせるようになったので、他のビューやテンプレートの内部からビューやテンプレートを共有する準備ができました。
render/3 を使ってテンプレートを構成し、最後にPhoenixがすべてのテンプレートを適切な表現に変換してブラウザに送信します。

たとえば、レイアウトの中から test.html テンプレートをレンダリングしたい場合は、レイアウト lib/hello_web/templates/layout/root.html.heex から直接 render/3 を呼び出すことができます。

<%= Phoenix.View.render(HelloWeb.PageView, "test.html", message: "Hello from layout!") %>

ウェルカムページにアクセスすると、レイアウトからのメッセージが表示されるはずです。

Phoenix.View はテンプレートに自動的にインポートされるので、Phoenix.View モジュール名を省略して、単に render(....) を直接呼び出すこともできます。

<%= render(HelloWeb.PageView, "test.html", message: "Hello from layout!") %>

同じビュー内でテンプレートをレンダリングしたい場合は、ビュー名を省略して render("test.html", message: "Hello from sibling template!") を呼び出すだけでも構いません。たとえば、lib/hello_web/templates/page/index.html.heex を開いて、先頭に次のように追加します。

<%= render("test.html", message: "Hello from sibling template!") %>

さて、ウェルカムページにアクセスすると、テンプレートの結果も表示されています。

レイアウト

レイアウトは単なるテンプレートです。他のテンプレートと同じようにビューを持っています。新しく生成されたアプリでは、lib/hello_web/views/layout_view.ex となります。レンダリングされたビューから得られる文字列がどのようにレイアウト内に行き着くのか不思議に思うかもしれません。これはいい質問ですね。lib/hello_web/templates/layout/root.html.heex を見てみると、<body> のちょうど真ん中あたりにこのような記述があります。

<%= @inner_content %>

言い換えれば、内部テンプレートは @inner_content 代入に配置されます。

JSONをレンダリングする

ビューの仕事はHTMLテンプレートをレンダリングするだけではありません。ビューの目的はデータの表示です。データの袋を与えられた場合、ビューの目的は、HTML、JSON、CSV、その他のフォーマットを与えられた場合に、意味のある方法でそのデータを表示することです。今日の多くのウェブアプリは、リモートクライアントにJSONを返しますが、PhoenixビューはJSONレンダリングに最適です。

PhoenixはJasonというライブラリを使ってJSONをエンコードしているので、ビューの中では、レスポンスしたいデータをリストやマップとしてフォーマットするだけで、あとはPhoenixが処理してくれます。

コントローラーから直接JSONを返してビューをスキップすることも可能ですが、Phoenixビューはより構造的なアプローチを提供します。それでは、PageController を例に、静的なページマップをHTMLではなくJSONで返した場合、どのようになるか見てみましょう。

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

  def show(conn, _params) do
    page = %{title: "foo"}

    render(conn, "show.json", page: page)
  end

  def index(conn, _params) do
    pages = [%{title: "foo"}, %{title: "bar"}]

    render(conn, "index.json", pages: pages)
  end
end

ここでは、show/2index/2 アクションが静的なページデータを返しています。テンプレート名として render/3"show.html" を渡す代わりに、"show.json" を渡しています。このようにして、異なるファイルタイプでパターンマッチを行うことで、HTMLとJSONのレンダリングを担当するビューを持つことができます。

defmodule HelloWeb.PageView do
  use HelloWeb, :view

  def render("index.json", %{pages: pages}) do
    %{data: Enum.map(pages, fn page -> %{title: page.title} end)}
  end

  def render("show.json", %{page: page}) do
    %{data: %{title: page.title}}
  end
end

ビューでは、render/2 関数が "index.json""show.json""page.json" でパターンマッチしているのがわかります。"index.json""show.json" はコントローラーから直接リクエストされたものです。これらはコントローラーから送られてきたassignにマッチします。Phoenixは .json という拡張子を理解しており、我々が返すデータ構造をJSONに変換してくれます。 "index.json" は次のように応答します。

{
  "data": [
    {
     "title": "foo"
    },
    {
     "title": "bar"
    },
 ]
}

そして "show.json" は次のようになります。

{
  "data": {
    "title": "foo"
  }
}

しかし、index.jsonshow.json の間には、ページをレンダリングする方法について同じロジックをエンコードしているため、重複があります。ページのレンダリングを別の関数に移し、render_many/3render_one/3 を使って再利用することで、この問題に対処できます。

defmodule HelloWeb.PageView do
  use HelloWeb, :view

  def render("index.json", %{pages: pages}) do
    %{data: render_many(pages, HelloWeb.PageView, "page.json")}
  end

  def render("show.json", %{page: page}) do
    %{data: render_one(page, HelloWeb.PageView, "page.json")}
  end

  def render("page.json", %{page: page}) do
    %{title: page.title}
  end
end

render_many/3関数は、レスポンスしたいデータ(pages)、ビュー、そしてビューに定義された render/2 関数にパターンマッチする文字列を受け取ります。これは pages の各アイテムをマッピングして、PageView.render("page.json", %{page: page}) を呼び出します。render_one/3 は同じシグネチャーに従い、最終的には render/2 にマッチする page.json を使用して、各 page がどのように見えるかを指定します。

このようにしてビューを構築すると、合成可能になるので便利です。たとえば、PageAuthorhas_many の関係(#注:has_many関係についてはまだ説明していません)を持っていて、リクエストによっては Page と一緒に Author のデータを送り返したいという状況を想像してみてください。新しい render/2 を使えば、簡単にこれを実現できます。

defmodule HelloWeb.PageView do
  use HelloWeb, :view
  alias HelloWeb.AuthorView

  def render("page_with_authors.json", %{page: page}) do
    %{title: page.title,
      authors: render_many(page.authors, AuthorView, "author.json")}
  end

  def render("page.json", %{page: page}) do
    %{title: page.title}
  end
end

assignで使用される名前はビューから決定されます。たとえば、PageView%{page: page} を、AuthorView%{author: author} を使用します。これは as オプションで上書きできます。ここで、著者ビューでは %{author: author} の代わりに %{writer: writer} を使うと仮定してみましょう。

def render("page_with_authors.json", %{page: page}) do
  %{title: page.title,
    authors: render_many(page.authors, AuthorView, "author.json", as: :writer)}
end

エラーページ

Phoenixには ErrorView というビューがあり、 lib/hello_web/views/error_view.ex にあります。この ErrorView の目的は、一般的な方法でエラーを一元的に処理することです。 このガイドで作成したビューと同様に、エラービューはHTMLとJSONの両方のレスポンスを返すことができます。詳細はカスタムエラーページのハウツーを参照してください。