情境如下, 在 ASP.NET MVC 用一小段程式顯示部門下拉清單,資料來自資料庫,因欄位較多且命名不直覺,我將由資料庫取得的集合轉成匿名型別 Select(o => new { DeptId = o.DI, DeptName = o.DN },再以 Razor 語法 @foreach (var dept in ViewBag.Depts) { <option value="@dept.DeptId">@dept.DeptName</option> } 轉成下拉選單選項。程式碼範例如下:(2017-01-11補充:若只是要產生下拉選單,使用 @Html.DropDownList() 更省事,詳見文末補充)
HomeController.cs

    public class HomeController : Controller
    {
        public ActionResult Index()
        {
            ViewBag.Depts = DataHelper.GetDepts()
                .Select(o => new { DeptId = o.DI, DeptName = o.DN })
                .ToList();
            return View();
        }
    }

Index.cshtml

@{
    Layout = null;
}
 
<!DOCTYPE html>
 
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>MVC Test</title>
</head>
<body>
    <div> 
        <select>
            @foreach (var d in ViewBag.Depts)
            {
                <option value="@d.DeptId">@d.DeptName</option>
            }
        </select>
        
    </div>
</body>
</html>

看似正常,執行時卻遇到錯誤,資料繫結(Data Binding)抱怨不認得匿名型別的 DeptId 屬性。

這才想到,我踩了匿名型別的紅線:參考

您無法將欄位、屬性、事件或方法的傳回類型,宣告為具有匿名類型。 同樣地,您無法將方法、屬性、建構函式或索引子的型式參數宣告為具有匿名類型。 若要以方法引數的形式來傳遞匿名類型或含有匿名類型的集合,您可以將參數宣告為物件(object)。 但是這樣做將失去強式類型的目的。 如果您必須在方法界限外儲存或傳遞查詢結果,請考慮使用一般具名結構或類別來取代匿名類型。

匿名型別在 CSHTML 被當成 object 型別,無法滿足資料繫結的強型別需求。改用 @Newtonsoft.Json.JsonConvert.SerializeObject(ViewBag.Depts) 測試,驗證資料已正確傳到前端,其中差別在於 Json.NET 靠 Reflection 解析欄位可以正確讀取屬性,而資料繫結需要強型別。

有幾種解法:第一種是從 JavaScript 下手,既然資料能正確轉為 JSON,便可再轉為 JavaScript 物件陣列,用 Angular、Knockout 等 MVVM 框架可輕易繫結成下拉選單。[參考]

若想在伺服器端處理,第二種做法是放棄匿名型別,乖乖宣告一個 DeptInfo 之類的具名型別,其中定義 DeptId、DeptName 屬性做為 CSHTML 與 Controller 間的共通規格,這是最守規矩的正統解法。

如果你像我一樣崇尚簡潔勝過嚴謹,討厭為了一丁點限制搞出一堆只用一次的雞肋型別,可以參考看看我找到的第三種做法。(如果你也愛用 Dapper、Tuple,那我們應該是一國的)

在 Stackoverflow 找到一則相關討論,提到以前介紹過的既然要動態就動個痛快 - ExpandoObject,可以兼顧動態及強型別繫結要求。關鍵在於將匿名型別轉成 ExpandoObject,為求簡便,轉換程序可寫成擴充方法,再透過 .Select(o => new { DeptId = o.DI, DeptName = o.DN }.ToExpando()) 將匿名型別物件轉成 ExpandoObject 即可。ToExpando() 有個值得偷學的技巧:RouteValueDictionary 可將任何物件轉成 IDictionary<string, object>,再用 foreach + Add 方式將屬性複製到 ExpandObject 上,比 Reflection 寫法簡潔很多,但要記住 ExpandoObject 與 dynamic 背後仍是走 Reflection,留意可能的效能代價。

    public class HomeController : Controller
    {
        public ActionResult Index()
        {
            ViewBag.Depts = DataHelper.GetDepts()
                .Select(o => new { DeptId = o.DI, DeptName = o.DN }.ToExpando())
                .ToList();
            return View();
        }
    }
 
    public static class ExpandoExtensions
    {
        //http://stackoverflow.com/a/5670899/4335757
        public static ExpandoObject ToExpando(this object anonymousObject)
        {
            IDictionary<string, object> anonymousDictionary = 
                new RouteValueDictionary(anonymousObject);
            IDictionary<string, object> expando = new ExpandoObject();
            foreach (var item in anonymousDictionary)
                expando.Add(item);
            return (ExpandoObject)expando;
        }
    }

就這樣,匿名型別也可以在 CSHTML foreach 做資料繫結囉~

最後補充一點,匿名型別真的不能在不同方法間傳遞嗎?倒也未必,如下例,善用 dynamic 就可以克服:

不過,以上範例並不能解決本次案例遇到的狀況。CSHTML 雖然支援 dynamic 資料繫結(例如 ViewBag 本身就是 dynamic) ,但 foreach 時不適用,猜想與 foreach 情境的資料繫結實作方式有關,這部分就交給 ExpandoObject 搞定囉~

2017-01-11 補充

感謝 Dino 大大補充更簡便的做法,如果 foreach 的目的是要展開成 Text/Value 性質選項,有個 SelectList,可以透過 dataValueField、dataTextField 參數指定屬性名稱,轉成具有 Text、Value 屬性物件的集合:

@foreach (var dept in new SelectList(ViewBag.Depts, "DeptId", "DeptName"))
{
   <option value="@dept.Value">@dept.Text</option>
}

順便補上 mrkt 針對 MVC 下拉選單處理的一系列深入探討,如果只是要做 DropDownList,用 foreach 其實有點繞路,正統的最精簡寫法是-@Html.DropDownList("field", new SelectList(ViewBag.Depts, "DeptId", "DeptName");。


Comments

# by Dino

如果是 ASP.NET MVC, 那小弟推薦使用 SelectList 處理即可 @foreach (var dept in new SelectList(ViewBag.Depts, "DeptId", "DeptName")) { <option value="@dept.Value">@dept.Text</option> }

# by Jeffrey

to Dino, AJAX寫多了,對這塊很生疏。感謝補充,已加入本文。

Post a comment