← 上一章:CRUD 分解動作 - 簡易票選系統實作(下) 下一章:Model 基本操作 →

Layout, Render 與 View Helper

在上個章節介紹了 CRUD 的分解動作並實作了一個簡單的投票系統,接下來這個章節要花點時間介紹在 Rails 專案 MVC 架構的 V。

版型 Layout

隨便打開一個在 app/views 目錄裡的檔案,例如上個章節的候選人列表頁面:

<h1>候選人列表</h1>

<%= link_to "新增候選人", new_candidate_path %>

<table class="table">
  <thead>
    <tr>
      <td>投票</td>
      ...[略]...
      <td>
        <%= link_to "編輯", edit_candidate_path(candidate) %>
        <%= link_to "刪除", candidate_path(candidate), method: "delete", data: { confirm: "確認刪除" } %>
      </td>
    </tr>
    <% end %>
  </tbody>
</table>

在這個檔案(index.html.erb)裡,看不到任何 <html><title><body> 之類的 HTML 標籤,但檢視實際網頁的原始碼又都有,這是怎麼回事呢?

yield

以上面這個例子來說,Controller 在處理 View 的時候,並不只是單純的只取用 index.html.erb 這個檔案,而是會先取用 Layout 檔案的內容(預設是 app/views/layouts/application.html.erb),然後把 index.html.erb 的內容填到 <%= yield %> 裡。

image

版型的好處,就是不需要重複的寫一堆長得一樣的 HTML 標籤,例如每個頁面的頁首跟頁尾通常不會有什麼變化,這種就是版型適用的地方。讓我們看一下 app/views/layouts/application.html.erb 的內容:

<!DOCTYPE html>
<html>
  <head>
    <title>MyCandidates</title>
    <%= csrf_meta_tags %>
    <%= stylesheet_link_tag    'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
  </head>

  <body>
    <div class="container">
    <%= yield %>
    </div>
  </body>
</html>

這裡有幾行需要說明一下:

  1. csrf_meta_tags 方法會在頁面上產生 <csrf-param><csrf-token> 兩個 <meta> 標籤,用途主要是確保網站較不容易受到 CSRF(Cross-site request forgery)攻擊。
  2. stylesheet_link_tag 方法會轉換成 CSS 的 <link rel="stylesheet"> 標籤。
  3. javascript_include_tag 方法會轉換成 JavaScript 的 <script> 標籤。

CSRF 攻擊是什麼?

CSRF(Cross-site request forgery)中文翻譯成「跨站請求偽造」。通常 CSRF 攻擊的目的不一定是直接獲取使用者帳戶的控制權或個資,但可以用其它使用者的名義執行某些操作。

舉個例子來說,假設我想攻擊某個 Blog 平台,我可能可以猜到後台的路徑,例如對 /admin/posts/2 路徑使用 DELETE 方法,就可以刪除編號 2 號的文章。雖然我沒有這個網站後台的使用權限,但我知道 A 先生有,所以我就假裝寄一封「恭喜你,你得到了最新的 iphone 8 手機」的 Email 給 A 先生,但事實上這個得獎的連結點下去就是會對 /admin/posts/2 網址發送 DELETE,又剛好 A 先生如果處於登入狀態,那篇文章就藉由 A 先生的權限被刪除了!

只能一個版型嗎?

如果你喜歡,可以有多種款式的版型,預設的版型是 app/views/layouts/application.html.erb。舉個例子來說,例如我想要增加一款給後台專用的版型,可以在 app/views/layouts/ 目錄下新增一個 backend.html.erb,內容如下:

<!DOCTYPE html>
<html>
  <head>
    <title>網站管理系統</title>
    <%= csrf_meta_tags %>
    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
  </head>

  <body>
    <h1>我是後台</h1>
    <div class="container">
      <%= yield %>
    </div>
  </body>
</html>

要套用這個版型有幾種做法:

1. 整個 Controller 都套用同一個版型

在 Controller 裡使用 layout 方法:

class CandidatesController < ApplicationController
  layout "backend"

  # ...[略]...
end

這樣整個 Controller 的頁面在處理的時候就都會套用 backend 版型了。

2. 只有特定 Action 套某個版型

在特定 Action 裡使用 render 方法:

class CandidatesController < ApplicationController

  def index
    @candidates = Candidate.all
    render layout: "backend"
  end

  # ...[略]...
end

這樣就只有這個 index 的頁面會套用 backend 版型,其它沒特別交待的還是會用預設版型。

3. 如果都不要?

如果因為某些原因,完全不想套任何版型,在 Controller 可以這樣寫:

class CandidatesController < ApplicationController
  layout false

  # ...[略]...
end

在特定 Action 可以這樣:

class CandidatesController < ApplicationController

  def index
    @candidates = Candidate.all
    render layout: false
  end

  # ...[略]...
end

預設版型?

前面提到說預設的版型是 app/views/layouts/application.html.erb,這句話其實不完全正確。真正的預設版型應該是「跟 Controller 同名」的版型。

舉例來說,有個 Controller 叫做 CandidatesController,它的版型檔案會先到 app/views/layouts/ 目錄下找 candidates.html.erb 檔案,如果找不到才換找 application.html.erb

只能一個 yield 嗎?

這個 yield 就是用來填空的「坑」,不一定只有一個,想要的話也可以有很多個,而且還可以幫這些坑標記名字:

<!DOCTYPE html>
<html>
  <head>
    <title><%= yield :my_title %></title>
    <%= csrf_meta_tags %>
    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
  </head>

  <body>
    <div class="container">
    <%= yield %>
    </div>
  </body>
</html>

除了原本的 <%= yield %> 之外,另外在 <title> 標籤裡加上 <%= yield :my_title %>,意思就是把這個坑標記成 my_title。要填這個指定名字的坑,有兩種做法:

使用 provide

你可以使用 provide 方法,指定要幫 my_title 提供資料:

<% provide :my_title, "你好,我是 Title" %>

<h1>候選人列表</h1>

<%= link_to "新增候選人", new_candidate_path %>

<table class="table">
  <thead>
    <tr>
      <td>投票</td>
      <td>候選人姓名</td>
...[略]...

使用 content_for

或是使用 content_for 來填坑:

<% content_for :my_title do %>
你好,我是 Title
<% end %>

<h1>候選人列表</h1>

<%= link_to "新增候選人", new_candidate_path %>

<table class="table">
  <thead>
    <tr>
      <td>投票</td>
      <td>候選人姓名</td>
...[略]...

不管哪一種方式都可以,檢視原始碼就可以發現填坑的效果了:

image

局部渲染(Partial Render)

局部渲染是一種在 Rails 專案很常見的程式碼整理手法之一。在前一個章節整理表單的時候,有用到了這樣的寫法:

<%= render "form" %>

其實完整的寫法是這樣寫:

<%= render partial: "form" %>

但用前面簡單的寫法在這邊也是可以的。這行的意思是會去同一個目錄找 _form 這個檔案,並且把檔案內容安插在這個地方。注意這個檔案必須要是底線開頭的,否則會出現 ActionView::MissingTemplate 的錯誤訊息:

image

通常局部渲染適用於「可以重複使用」的程式碼,像是在上個章節的表單就是一例。

較好的設計

雖然 <%= render "form" %> 這樣短短一行很容易用,但這樣不見得是個好的設計。一個容易重複使用的局部樣版,不應該依賴滿天飛的實體變數。舉個例子來說,如果想要做一個叫做橫幅廣告的局部樣版,可能會這樣寫(檔案名稱 _banner.html.erb):

<div class="advertisement">
  <div>廣告</div>
  <div> <%= @content %> </div>
</div>

使用的時候這樣用:

<%= render "banner" %>

也就是說,這個 _banner.html.erb 會自己去空氣中抓看看有沒有 @content 這個實體變數給它,有的話就印出來。但事實上不是每個頁面都有這東西可以抓,所以不見得容易重複使用。比較建議的設計會是這樣:

<div class="advertisement">
  <div>廣告</div>
  <div> <%= content %> </div>
</div>

讓這個局部樣版裡只有普通的區域變數,然後在使用它的時候,把值傳主動傳給它:

<%= render partial: "banner", locals: {content: "我是廣告的內容"} %>

注意:這樣的寫法 partial 參數不能省略。

這個局部樣版就會得到一個 content 的區域變數。這樣做似乎變得之前更麻煩,但這樣的設計可以讓這個局部樣版變得更像一個「元件」,它被動的等著你餵它資料,而不是自己伸手去空中抓,這樣一來不管在哪個頁面都可適用。

上面這個寫法可以再精簡成這樣:

<%= render "banner", content: "我是廣告的內容" %>

魔術 render

在上一章的「候選人列表」的程式碼中,中間有一段 each 迴圈,不斷的印出資料:

<h1>候選人列表</h1>

..[略]..
  <tbody>
    <% @candidates.each do |candidate| %>
    <tr>
      <td><%= link_to "投給這位", "#" %></td>
      <td><%= candidate.name %>(年齡:<%= candidate.age %> 歲)</td>
      <td><%= candidate.party %></td>
      <td><%= candidate.politics %></td>
      <td><%= candidate.votes %></td>
      <td>
        <%= link_to "編輯", edit_candidate_path(candidate) %>
        <%= link_to "刪除", candidate_path(candidate), method: "delete", data: { confirm: "確認刪除" } %>
      </td>
    </tr>
    <% end %>
  </tbody>
</table>

這段如果使用局部樣版來整理,可以把中間那段抽出來:

<h1>候選人列表</h1>

..[略]..
  <tbody>
    <%= render "candidate" %>
  </tbody>
</table>

然後在 app/views/candidates 目錄底下新增一個檔案 _candidate.html.erb

<% @candidates.each do |candidate| %>
<tr>
  <td><%= link_to "投給這位", vote_candidate_path(candidate), method: "post", data: { confirm: "確認要投給這位候選人嗎?!" }, class: "btn btn-danger btn-xs" %></td>
  <td><%= candidate.name %>(年齡:<%= candidate.age %> 歲)</td>
  <td><%= candidate.party %></td>
  <td><%= candidate.politics %></td>
  <td><%= candidate.votes %></td>
  <td>
    <%= link_to "編輯", edit_candidate_path(candidate) %>
    <%= link_to "刪除", candidate_path(candidate), method: "delete", data: { confirm: "確認刪除" } %>
  </td>
</tr>
<% end %>

這樣的改法感覺沒什麼了不起的,就是把原來的內容整個剪到另一個檔案而已,它需要的 @candidates 實體變數也還是自己往空中抓,前面才剛講這樣不是好的設計。的確,如果只是這樣的話其實也不需要改了。Rails 針對這樣的用法,有提供 render 方法一個 collection 參數:

<%= render partial: "candidate", collection: @candidates %>

然後 _candidate.html.erb 檔案的內容就可以把外層的 each 迴圈拿掉,變成這樣:

<tr>
  <td><%= link_to "投給這位", vote_candidate_path(candidate), method: "post", data: { confirm: "確認要投給這位候選人嗎?!" }, class:"btn btn-danger btn-xs" %></td>
  <td><%= candidate.name %>(年齡:<%= candidate.age %> 歲)</td>
  <td><%= candidate.party %></td>
  <td><%= candidate.politics %></td>
  <td><%= candidate.votes %></td>
  <td>
    <%= link_to "編輯", edit_candidate_path(candidate) %>
    <%= link_to "刪除", candidate_path(candidate), method: "delete", data: { confirm: "確認刪除" } %>
  </td>
</tr>

因為傳了 collection 參數的關係,在局部樣版裡即使拿掉了 each 迴圈,它還是可以正常運作,而且呈現的畫面還是跟原來的一樣。這樣一來,在 _candidate.html.erb 檔案裡的都只剩區域變數,不需要再依賴空中的實體變數。

而且 render 方法還可以再短一點,把這一行:

<%= render partial: "candidate", collection: @candidates %>

直接改成這樣:

<%= render @candidates %>

_candidate.html.erb 檔案不需要改,整個還是可以正常運作。短短一行,而且還不用寫迴圈就可以達到跟原來用 each 迴圈一樣的效果,很神奇吧!

但這樣的寫法其實有點過於魔術,而且依賴不少 Rails 裡的「慣例」。如果要讓 <%= render @candidates %> 可以正常運作的話,需要完成以下幾件事:

  1. 局部樣版的檔名必須是那包資料的「單數」,以上面這個例子來說,那包資料叫做 @candidates,所以 Partial 的檔名就是 _candidate.html.erb
  2. 局部樣版檔案必須而且放在對的位置,以這個例子來說,檔案是放在 app/views/candidates 目錄裡。
  3. 局部樣版裡不需要寫迴圈(寫了反而會多跑一層迴圈),裡面用到的區域變數必須是單數,例如 candidate

如果沒有按照這些慣例就會出現找不到檔案或路徑的錯誤訊息。

View Helper

除了上面提到的局部樣版,View Helper 也是用來整理程式碼很常用的手法。其實平常大家在寫的 link_toimage_tag 或是 form_for,它都是一種 View Helper。

雖然 View Helper 通常是寫在 View 的 HTML 標籤裡,但其實 View Helper 本身是標準的 Ruby 程式碼,使用起來就跟在呼叫一般的 Ruby 方法一樣。

自定 View Helper

除了 Rails 內建的方法,有時候在專案中也會寫一些自己會用的方法,例如可能有一段程式碼長這樣:

<tr>
  <td>
    <% if gender == 1 %><% elsif gender == 0 %><% else %>
      不想說
    <% end %>
  </td>
</tr>

gender 的值是 1 的時候印出 ,是 0 的時候就印出 ,不然就印出 不想說。雖然像這樣的寫法很簡單也很直覺,但這樣就會讓 View 開始有一些邏輯判斷了,這不是好事。View 的主要用途是輸出結果在畫面上,所以邏輯判斷越少越好。像上面這個例子,我們就可以把這段 if .. else .. 判斷,搬到 View Helper 裡放著。

View Helper 主要是放在 app/helpers 目錄裡。要寫在哪個檔案裡都可以(因為所有的 View Helper 都可以被 Rails 載入),但通常會放在跟功能有關的檔案裡。這裡我就先把 Helper 寫在 app/helpers/application_helper.rb 裡面:

module ApplicationHelper
  def user_gender(gender)
    if gender == 1
      "男"
    elsif gender == 0
      "女"
    else
      "不想說"
    end
  end
end

View Helper 是定義在 Ruby 的模組(Module)裡,Rails 在啟動的時候會把這些 View Helper 都載進來,讓大家在 View 可以直接使用。在上面這個例子,我在 ApplicationHelper 這個模組裡定義了一個 user_gender 方法,而且這個方法會接收一個參數,並根據參數的內容回傳「男」、「女」或「不想說」。有這個 View Helper 之後,原來在 View 的那段 if ... else ... 的邏輯判斷就可以改成這樣:

<tr>
  <td><%= user_gender(gender) %></td>
</tr>

適時的使用 View Helper 把需要邏輯判斷的部份抽出來,可以讓 View 的程式碼變得乾淨又清楚。

View Helper 的使用規則

雖然 View Helper 的方法寫在 app/helpers 目錄下的隨便哪個檔案都可以,但檔名以及模組名稱是有規則的:

  1. 模組名稱需為 XXXXHelper,例如 UserHelper 或是 PostHelper
  2. 若模組名稱是 UserHelper,檔案名稱為 user_helper.rb;如果是 PostHelper,檔名則為 post_helper.rb,依此類推。
  3. 雖然 View Helper 的方法要寫在哪個 Helper 模組都可以,但模組會按照模組的名字的英文字母順序依序載入,例如 PostHelper 會比 UserHelper 先載入,也就是說如果這兩個模組上有名字一樣的方法,先載入方法的會被後載入的覆蓋掉,這點需要特別注意。

讓原本的 Helper 更好用

其實內建的 View Helper 很好用,但偶爾會另外再加一些料,像是想要在 link_to 超連結的前面加上一個小 icon,你可能會這樣寫:

<h1>候選人列表</h1>

<%= link_to "<span class='glyphicon glyphicon-knight'></span> 新增候選人", new_candidate_path %>

結果你會得到這樣的畫面:

image

link_to 方法把 HTML 標籤「完整」的呈現在畫面上了,這應該不是你想要的!這是因為 link_to 後面接的那個字串會自動被逸出(escape),除非你使用 .html_safe 方法,主動告訴它說「這個字串是安全的」:

<h1>候選人列表</h1>

<%= link_to "<span class='glyphicon glyphicon-knight'></span> 新增候選人".html_safe, new_candidate_path %>

這樣就會正常了:

image

但這樣的寫法看起來有點囉嗦,所以如果這樣的寫法常用的話,我會建議把它包成一個 View Helper:

module ApplicationHelper
  def icon_link_to(label, path, icon)
    link_to path do
      content_tag :span, class: "glyphicon glyphicon-#{icon}" do
        label
      end
    end
  end
end

在自己定義的 icon_link_to 方法裡,除了使用了原本的 link_to,也再加上 content_tag 這個內建的 Helper 來包標籤,好處是這樣就不需要自己再加 .html_safe 了。原來的 View 就可以這個剛寫好的 icon_link_to 方法:

<h1>候選人列表</h1>

<%= icon_link_to "新增候選人", new_candidate_path, "knight" %>

除了看起來比原來的 link_to 簡單、乾淨外,在全站每個有需要的 icon 的連結也都可以使用,相當便利。

← 上一章:CRUD 分解動作 - 簡易票選系統實作(下) 下一章:Model 基本操作 →

Comments