[ASP.NET MVC][1]使用 ChildAction 與 ViewModel 呈現 View 的資料

在網站開發的過程中,一開始一般會有下列幾個步驟:

  1. 拿到 designer 的 layout
  2. Developer 進行 view 的切版
  3. 定義 ViewModel 並接上假資料

這篇文章將針對一開始這幾個步驟,介紹如何透過 ASP.NET MVC 的 View, ViewModel, ChildAction 來進行開發。

前言

今年四月參加了 Skilltree 由 demo 所講授的 ASP.NET MVC5 實戰訓練營課程, 每一天課程之後都會有 homework 來讓學員練習,並且講師會針對學員各個 commit 進行 code review 跟給 comment (我的 TDD 課程也都是這樣進行,雖然很花講師時間,但我個人覺得這對學員是最有價值的一種方式)。因為 homework 設計相當實務且循序漸進,讓我獲益良多,所以打算針對 homework 需求,透過幾篇文章來紹上課以及自己上網查的技巧做一些簡介。

背景

當開發人員瞭解需求之後,通常開發過程如下:

  1. 先由 designer 設計出一版靜態 html 與 css 的版面
  2. 交由 developer 針對 View 需要什麼樣的邏輯、資料與呈現來進行切版套版
  3. Developer 依據 View 需要的資料意義,定義出 ViewModel ,並與後端 controller 進行互動,回傳假的 ViewModel 資料,確認 View 的呈現與需求單位期望一致

Homework 的需求是實作一個簡單的記事本功能,第一天的 homework 規範如下:

  1. 請使用「MoneyTemplate.html」作為樣版(就是你家設計提供的版型)
  2. 必須使用 Layout
  3. 下方列表必須有假資料(禁止寫死在HTML)可使用 ViewModel
  4. 上方表單部分如果有時間可以依據需求調整,如果沒時間可以將「MoneyTemplate.html」的部分直接複製過來。

靜態版面

Designer 所設計的 MoneyTemplate.html 版面,如下圖所示:

其 HTML 內容如下:

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>我的記帳本</title>
    <!--@Styles.Render("~/Content/css")
    @Scripts.Render("~/bundles/modernizr")-->
    <link href="http://ajax.aspnetcdn.com/ajax/bootstrap/3.3.6/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body>
    <div class="navbar navbar-inverse navbar-fixed-top">
        <div class="container">
            <div class="navbar-header">
                <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                </button>
                <a href="/" class="navbar-brand">我的記帳本</a>
            </div>
            <div class="navbar-collapse collapse">
                <ul class="nav navbar-nav">
                    <li><a href="#">首頁</a>
                    <li><a href="#">關於</a>
                    <li><a href="#">連絡方式</a>
                </ul>
            </div>
        </div>
    </div>
    <div class="container body-content">
        
<div class="well">
    <form class="form-horizontal">
        <div class="form-group">
            <label for="category" class="col-sm-2 control-label">類別</label>
            <div class="col-sm-10">
                <select id="category" class="form-control">
                    <option value="" selected>請選擇</option>
                    <option>支出</option>
                    <option>收入</option>
                </select>
            </div>
        </div>
        <div class="form-group">
            <label for="money" class="col-sm-2 control-label">金額</label>
            <div class="col-sm-10">
                <input type="text" class="form-control" id="money" placeholder="金額">
            </div>
        </div>
        <div class="form-group">
            <label for="date" class="col-sm-2 control-label">日期</label>
            <div class="col-sm-10">
                <input type="text" class="form-control" id="date" placeholder="日期">
            </div>
        </div>
        <div class="form-group">
            <label for="description" class="col-sm-2 control-label">備註</label>
            <div class="col-sm-10">
                <textarea class="form-control" id="description">
                </textarea>
            </div>
        </div>
        <div class="form-group">
            <div class="col-sm-offset-8 col-sm-4">
                <button type="submit" class="btn btn-default">送出</button>
            </div>
        </div>
    </form>
</div>

<div class="row">
    <div class="col-md-12">
        <table class="table table-bordered table-hover">
            <tr>
                <th>#</th>
                <th>類別</th>
                <th>日期</th>
                <th>金額</th>
            </tr>
            <tr>
                <td>1</td>
                <td>支出</td>
                <td>2016-01-01</td>
                <td>300</td>
            </tr>
            <tr>
                <td>2</td>
                <td>支出</td>
                <td>2016-01-02</td>
                <td>1,600</td>
            </tr>
            <tr>
                <td>3</td>
                <td>支出</td>
                <td>2016-01-03</td>
                <td>8,00</td>
            </tr>
        </table>
    </div>
</div>
        <hr />
        <footer>
            <p>&copy; 2016 - <a href="#">SkillTree</a></p>
        </footer>
    </div>
    <!--@Scripts.Render("~/bundles/jquery")
    @Scripts.Render("~/bundles/bootstrap")-->
    <script src="http://ajax.aspnetcdn.com/ajax/jQuery/jquery-2.2.1.min.js"></script>
    <script src="http://ajax.aspnetcdn.com/ajax/bootstrap/3.3.6/bootstrap.min.js"></script>
</body>
</html>

定義 Layout 與 Controller

先建立一個 AccountingLayout 與 designer 所給的 HTML 一模一樣,接著將 <div class="container body-content">中,<footer>之前的內容切出來,由 @RenderBody() 取代。

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>我的記帳本</title>
    @Styles.Render("~/Content/css")
    @Scripts.Render("~/bundles/modernizr")
    <link href="http://ajax.aspnetcdn.com/ajax/bootstrap/3.3.6/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body>
    <div class="navbar navbar-inverse navbar-fixed-top">
        <div class="container">
            <div class="navbar-header">
                <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                </button>
                <a href="/" class="navbar-brand">我的記帳本</a>
            </div>
            <div class="navbar-collapse collapse">
                <ul class="nav navbar-nav">
                    <li><a href="#">首頁</a>
                    <li><a href="#">關於</a>
                    <li><a href="#">連絡方式</a>
                </ul>
            </div>
        </div>
    </div>
    <div class="container body-content">
        @RenderBody()        
        <hr />
        <footer>
            <p>&copy; 2016 - <a href="#">SkillTree</a></p>
        </footer>
    </div>
    @*@Scripts.Render("~/bundles/jquery")
    @Scripts.Render("~/bundles/bootstrap")*@
    <script src="http://ajax.aspnetcdn.com/ajax/jQuery/jquery-2.2.1.min.js"></script>
    <script src="http://ajax.aspnetcdn.com/ajax/bootstrap/3.3.6/bootstrap.min.js"></script>
</body>
</html>

依據記帳本的意義,定義出一個 AccountingController 如下:

    public class AccountingController : Controller
    {
        // GET: Accounting
        public ActionResult Index()
        {
            return View();
        }
    }

設計 Index.cshtml

Index() 中,點選 Add View ,自動產生 Index.cshtml 出來

選擇 Layout,產生 Index.cshtml

將 ViewBag.Title 的部分,改為「我的記帳本」,套用剛剛的 AccoutingLayout.cshtml,並將 Layout Title 的部分改為 ViewBag.Title。Index.cshtml 內容如下:

@{
    ViewBag.Title = "我的記帳本";
    Layout = "~/Views/Shared/_AccountingLayout.cshtml";
}

<div class="well">
    <form class="form-horizontal">
        <div class="form-group">
            <label for="category" class="col-sm-2 control-label">類別</label>
            <div class="col-sm-10">
                <select id="category" class="form-control">
                    <option value="" selected>請選擇</option>
                    <option>支出</option>
                    <option>收入</option>
                </select>
            </div>
        </div>
        <div class="form-group">
            <label for="money" class="col-sm-2 control-label">金額</label>
            <div class="col-sm-10">
                <input type="text" class="form-control" id="money" placeholder="金額">
            </div>
        </div>
        <div class="form-group">
            <label for="date" class="col-sm-2 control-label">日期</label>
            <div class="col-sm-10">
                <input type="text" class="form-control" id="date" placeholder="日期">
            </div>
        </div>
        <div class="form-group">
            <label for="description" class="col-sm-2 control-label">備註</label>
            <div class="col-sm-10">
                <textarea class="form-control" id="description"></textarea>
            </div>
        </div>
        <div class="form-group">
            <div class="col-sm-offset-8 col-sm-4">
                <button type="submit" class="btn btn-default">送出</button>
            </div>
        </div>
    </form>
</div>

<div class="row">
    <div class="col-md-12">
        <table class="table table-bordered table-hover">
            <tr>
                <th>#</th>
                <th>類別</th>
                <th>日期</th>
                <th>金額</th>
            </tr>
            <tr>
                <td>1</td>
                <td>支出</td>
                <td>2016-01-01</td>
                <td>300</td>
            </tr>
            <tr>
                <td>2</td>
                <td>支出</td>
                <td>2016-01-02</td>
                <td>1,600</td>
            </tr>
            <tr>
                <td>3</td>
                <td>支出</td>
                <td>2016-01-03</td>
                <td>8,00</td>
            </tr>
        </table>
    </div>
</div>
這時候清單的內容還是 html hard-code 的。

定義 ViewModel 以及 Child Action 用以呈現歷史清單內容

ViewModel 如下所示:

    public class AccountingViewModel
    {
        public int Amount { get; set; }
        public DateTime Date { get; set; }
        public string Remark { get; set; }
        public AccountingType Type { get; set; }
    }

    public enum AccountingType
    {
        收入,
        支出
    }

AccoutingController 中增加一個 Child Action 方法,叫做 ShowHistory(),將寫死的 ViewModel 集合傳入 View() 後回傳,內容如下:

        [ChildActionOnly]
        public ActionResult ShowHistory()
        {
            var history = new List<AccountingViewModel>
            {
                new AccountingViewModel {Type=AccountingType.收入, Amount=100, Date = new DateTime(2016,4,10), Remark="發傳單" },
                new AccountingViewModel {Type=AccountingType.支出, Amount=40, Date = new DateTime(2016,4,11), Remark="咖啡" },
            };

            return View(history);
        }
標記[ChildAction] 代表這個 action 只能給 Child Action 使用

產生 Child Action 的 View

ShowHistory() 上,再透過 Add View 來產生 Child Action 的 View 。設定如下:

  1. Template 的部分,選擇 List ,因為我們要呈現多筆歷史資料。
  2. Model class 的部分,選擇剛剛新增的 ViewModel
  3. 不套用 Layout,  因為這是 ChildAction 的 View

ShowHistory.cshtml 的內容,移除 <body> 以外的內容,只保留 content 的部分,內容如下:

@model IEnumerable<MyMoney.Models.ViewModels.AccountingViewModel>
@{
    Layout = null;
}
<table class="table table-bordered table-hover">
    <tr>
        <th>#</th>
        <th>
            @Html.DisplayNameFor(model => model.Amount)
        </th>
        <th>
            @Html.DisplayNameFor(model => model.Date)
        </th>
        <th>
            @Html.DisplayNameFor(model => model.Remark)
        </th>
    </tr>

    @{ var index = 1;}
    @foreach (var item in Model)
    {
        <tr>
            <td>@(index)</td>
            <td>
                @Html.DisplayFor(modelItem => item.Amount)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Date)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Remark)
            </td>
        </tr>
        index++;
    }
</table>

接著把 Index.cshtml 中要呈現歷史清單的部分,改使用 HtmlHelper Action,關鍵程式碼如下所示:

<div class="row">
    <div class="col-md-12">
        @Html.Action("ShowHistory")
    </div>
</div>

瀏覽 Accounting/Index 時如下圖所示:

微調歷史清單呈現,貼近需求

清單的部分,金額要千分位(三位一撇),日期的部分希望只呈現日期,不需要呈現時間。這邊只需要在 ViewModel 上透過 DisplayFormat裡面的 DataFormatString 以類似 String Format 的方式設定即可。

    public class AccountingViewModel
    {
        [DisplayFormat(DataFormatString = "{0:N0}")]
        public int Amount { get; set; }

        [DisplayFormat(DataFormatString = "{0:yyyy/MM/dd}")]
        public DateTime Date { get; set; }
        public string Remark { get; set; }
        public AccountingType Type { get; set; }
    }

畫面如下所示:

這時歷史清單的欄位名稱還是 ViewModel 的 property name, 所以接著調整 ViewModel 的 Display attribute 裡面 Name 的設定,如下所示:

    public class AccountingViewModel
    {
        [DisplayFormat(DataFormatString = "{0:N0}")]
        [Display(Name = "金額")]
        public int Amount { get; set; }

        [DisplayFormat(DataFormatString = "{0:yyyy/MM/dd}")]
        [Display(Name ="日期")]
        public DateTime Date { get; set; }

        [Display(Name ="備註")]
        public string Remark { get; set; }

        [Display(Name ="類別")]
        public AccountingType Type { get; set; }
    }

在 ShowHistory.cshtml 中,也把類別的欄位與內容加進去,最後畫面如下所示:

結論

這大概就是第一天作業內容的範圍,也與網站開發實務的順序很貼近。摘要整理一下:

  1. 拿到 designer 給的 html
  2. 貼到 layout
  3. 決定 route 的 controller 與 action 名稱
  4. 產生 view
  5. 將 layout 中每一頁獨自內容的部分,搬到剛剛產生的 view 裡面,把 layout 抽出來的部分,改成 @RenderBody()
  6. 將查詢清單的職責定義為 Child Action (以這例子也可以使用 Partial View) ,可透過[ChildActionOnly] 標記,讓這個 action 只給 child action 使用。
  7. 定義查詢清單每一筆資料的 ViewModel 進而產生 Child Action 的 View
  8. 調整 Child Action 的 View,並調整原 View 中要呈現 Child Action 內容的部分,改成 @Html.Action()
  9. ViewModel 的 property 可透過 Display(Name="") 與 DisplayFormat(DataFormatString="") 來搭配 HtmlHelper 調整 View 的呈現方式。例如 @Html.DisplayNameFor() @Html.DisplayFor()

雖然這篇文章只是入門款,但開發 ASP.NET MVC 一開始起手式的核心概念,在這個 homework 表露無遺。當然目前完成的功能還不完整,在後續幾篇文章,將陸陸續續補上其他的概念。

對敏捷開發有興趣的朋友,可以參考我的粉絲專頁:91敏捷開發之路

若需要聯絡我,可以透過粉絲專頁私訊或是側欄的關於我。