← 上一章: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 %>
裡。
版型的好處,就是不需要重複的寫一堆長得一樣的 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>
這裡有幾行需要說明一下:
csrf_meta_tags
方法會在頁面上產生<csrf-param>
跟<csrf-token>
兩個<meta>
標籤,用途主要是確保網站較不容易受到 CSRF(Cross-site request forgery)攻擊。stylesheet_link_tag
方法會轉換成 CSS 的<link rel="stylesheet">
標籤。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>
...[略]...
不管哪一種方式都可以,檢視原始碼就可以發現填坑的效果了:
局部渲染(Partial Render)
局部渲染是一種在 Rails 專案很常見的程式碼整理手法之一。在前一個章節整理表單的時候,有用到了這樣的寫法:
<%= render "form" %>
其實完整的寫法是這樣寫:
<%= render partial: "form" %>
但用前面簡單的寫法在這邊也是可以的。這行的意思是會去同一個目錄找 _form
這個檔案,並且把檔案內容安插在這個地方。注意這個檔案必須要是底線開頭的,否則會出現 ActionView::MissingTemplate
的錯誤訊息:
通常局部渲染適用於「可以重複使用」的程式碼,像是在上個章節的表單就是一例。
較好的設計
雖然 <%= 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 %>
可以正常運作的話,需要完成以下幾件事:
- 局部樣版的檔名必須是那包資料的「單數」,以上面這個例子來說,那包資料叫做
@candidates
,所以 Partial 的檔名就是_candidate.html.erb
。 - 局部樣版檔案必須而且放在對的位置,以這個例子來說,檔案是放在
app/views/candidates
目錄裡。 - 局部樣版裡不需要寫迴圈(寫了反而會多跑一層迴圈),裡面用到的區域變數必須是單數,例如
candidate
。
如果沒有按照這些慣例就會出現找不到檔案或路徑的錯誤訊息。
View Helper
除了上面提到的局部樣版,View Helper 也是用來整理程式碼很常用的手法。其實平常大家在寫的 link_to
、image_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
目錄下的隨便哪個檔案都可以,但檔名以及模組名稱是有規則的:
- 模組名稱需為
XXXXHelper
,例如UserHelper
或是PostHelper
。 - 若模組名稱是
UserHelper
,檔案名稱為user_helper.rb
;如果是PostHelper
,檔名則為post_helper.rb
,依此類推。 - 雖然 View Helper 的方法要寫在哪個 Helper 模組都可以,但模組會按照模組的名字的英文字母順序依序載入,例如
PostHelper
會比UserHelper
先載入,也就是說如果這兩個模組上有名字一樣的方法,先載入方法的會被後載入的覆蓋掉,這點需要特別注意。
讓原本的 Helper 更好用
其實內建的 View Helper 很好用,但偶爾會另外再加一些料,像是想要在 link_to
超連結的前面加上一個小 icon,你可能會這樣寫:
<h1>候選人列表</h1>
<%= link_to "<span class='glyphicon glyphicon-knight'></span> 新增候選人", new_candidate_path %>
結果你會得到這樣的畫面:
link_to
方法把 HTML 標籤「完整」的呈現在畫面上了,這應該不是你想要的!這是因為 link_to
後面接的那個字串會自動被逸出(escape),除非你使用 .html_safe
方法,主動告訴它說「這個字串是安全的」:
<h1>候選人列表</h1>
<%= link_to "<span class='glyphicon glyphicon-knight'></span> 新增候選人".html_safe, new_candidate_path %>
這樣就會正常了:
但這樣的寫法看起來有點囉嗦,所以如果這樣的寫法常用的話,我會建議把它包成一個 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 的連結也都可以使用,相當便利。
Comments