「Blade 裡直接寫 Str::limit($content, 100) 有什麼問題嗎?反正畫面上看起來是對的。」
這是很多人第一次寫「截取內容前幾個字當摘要」這種需求時的直覺寫法——在 Blade 檔案裡直接呼叫字串處理函式,畫面渲染出來確實沒問題。真正的問題不會馬上出現,而是等到同一段邏輯在第二個地方也需要用到的時候才浮現。
昨天介紹過,這個系列的素材是一個真實運作的大學官網入口網站,今天要看的正是一段真實發生過的重構:一段原本寫在 Blade View 裡的字串截取邏輯,後來為什麼、又是怎麼搬進 Model 的 Attribute accessor 裡。
Attribute::get() accessor 語法怎麼把邏輯收斂進 Model這個系統的首頁有好幾個區塊都要顯示新聞的「摘要」跟「短標題」——首頁跑馬燈要顯示短標題,焦點新聞區塊要顯示摘要。這兩個區塊分別在不同的 Blade 元件裡,最早的寫法很直覺:哪個畫面需要,就在那個畫面的 Blade 檔案裡直接處理一次。
問題是,「摘要」的定義不只是「截取前 100 字」這麼單純——這個系統的新聞內容存成 JSON(content.body 欄位存 HTML),要先把 HTML 標籤拿掉、把 HTML entity 解碼,才能截出乾淨的純文字摘要。這段邏輯如果在兩個 Blade 檔案裡各寫一次,維護起來的風險是:哪天摘要規則要改(例如字數上限從 100 改成 80),必須記得兩個地方都要改,漏改一處,兩個區塊顯示的摘要規則就會悄悄不一致。
{{-- headline.blade.php --}}
<p>{{ Str::limit(html_entity_decode(strip_tags($featured->content['body'] ?? '')), 100) }}</p>
{{-- marquee.blade.php(另一個檔案,重複同一段邏輯) --}}
<span>{{ Str::limit($announcement->title, 100) }}</span>
這樣寫,View 檔案要知道 Model 內部的資料結構(content['body'] 這個 array key),字串處理規則也散落在畫面層,沒有一個統一的地方可以查、可以測試。
// app/Models/News.php
protected function excerpt(): Attribute
{
return Attribute::get(function () {
$content = is_array($this->content)
? ($this->content['body'] ?? '')
: ($this->content ?? '');
return Str::limit(html_entity_decode(strip_tags($content)), 100);
});
}
protected function shortTitle(): Attribute
{
return Attribute::get(fn () => Str::limit($this->title, 100));
}
{{-- headline.blade.php --}}
<p>{{ $featured->excerpt }}</p>
{{-- marquee.blade.php --}}
<span>{{ $announcement->short_title }}</span>
Blade 檔案現在完全不需要知道「摘要怎麼算出來」這件事,只要用 $news->excerpt 就好。字數上限要改,只要改一個地方;要幫這個邏輯寫測試,也只要對著 Model 寫,不用渲染整個畫面才能驗證。
這次重構不只搬了 excerpt/short_title,同一批 commit 也把「取得新聞的精選圖片」這段邏輯搬進了 Model:
protected function featuredImage(): Attribute
{
return Attribute::get(fn () => $this->getMedia('images')->last());
}
protected function featuredImageUrl(): Attribute
{
return Attribute::get(fn () => $this->featured_image?->getUrl());
}
protected function featuredImageAlt(): Attribute
{
return Attribute::get(fn () => $this->featured_image?->getCustomProperty('alt') ?? $this->title);
}
原本 Blade 裡要呼叫 $news->getMedia('images')->last()->getUrl() 這種鏈式呼叫,現在只要 $news->featured_image_url。這種累加式重構值得注意的地方是:它不是一次規劃好的架構決策,是每次遇到「同一段邏輯又要在另一個地方用一次」時,才順手把它抽出來的結果。 一開始不會知道最終會累積出十幾個 accessor,是踩過重複的痛之後,一段一段搬出來的。
不是所有 View 裡的邏輯都該搬進 Model。判斷基準很單純:這段邏輯是不是「這個資料本身該有的樣子」,還是「這個畫面想怎麼呈現它」。摘要怎麼截、圖片網址怎麼組,屬於前者——不管哪個畫面用到這個新聞的資料,摘要的算法應該一致,這種邏輯適合收進 Model。至於「這個區塊要不要顯示邊框」「這個按鈕文字要顯示什麼顏色」,屬於畫面呈現本身的決定,留在 View 裡才對。
回想你的專案裡,有沒有一段資料處理邏輯,你在兩個以上的 View 檔案裡各寫了一次?如果現在要幫其中一份加一個新規則,你有把握兩個地方都會記得改嗎?
Attribute::get() 讓 Model 可以提供一個乾淨的唯讀衍生欄位,View 不用知道背後怎麼算出來的excerpt/short_title/featured_image_url 這幾個 accessor 都是「用到第二次才抽出來」的產物,不是一開始就規劃好的明天要看同一個系統裡,一次啟用 Model::shouldBeStrict() 之後,一口氣抓出來的真實 N+1 查詢問題跟一個藏了很久的 typo。