且听疯吟 如此生活三十年
一个 ASP.NET MVC HtmlHelper 的 tricks

问题

先看下面一个简单的 ASP.NET MVC 5 的 demo:

  • model

    public class TestModel
    {
        public List<int> Ints { get; set; }
    }
    
  • controller

    public ActionResult Index()
    {
        var testModel = new TestModel();
        return View(testModel);
    }
    
    [ActionName("Index"), HttpPost]
    public ActionResult Post(TestModel testModel)
    {
        return View(testModel);
    }
    
  • view

    @model Test.Controllers.TestModel
    
    <form action="@Url.Action("Index")" method="post">
    
        @for (var i = 0; i < 10; i++)
        {
            @Html.TextBoxFor(model => model.Ints[i])
        }
    
        <input type="submit" value="Submit" />
    </form>
    

有没有看出什么问题?

View 里面的

@for (var i = 0; i < 10; i++)
{
    Html.TextBoxFor(model => model.Ints[i])
}

Model.Ints 并没有初始化的情况下被使用了。

正常情况下可能会这么写:

@{
    if (Model.Ints == null)
    {
        Model.Ints = new List<int>();
    }
    for (var i = 0; i < Model.Ints.Count; i++)
    {
        @Html.TextBoxFor(model => model.Ints[i])
    }
}

如果我们需要 10 个 input,可能还得费心给 Model.Ints 初始化并添加 10 个 元素。

然而前面的写法真的会报错吗?

其实并不会,it works well.

为什么呢? @Html.TextBoxFor(model => model.Ints[i])Model.Ints 并未初始化的时候就使用了,那么应该会抛出异常才对?

原因

那么,我们来看看为什么没有报错。
这就要从源代码上找原因。

幸好,ASP.NET MVC 已经在 Github 上开源了,地址在这里

  • 我们很容易根据 namespace 找到 Html.TextBoxFor 的实现,参考 https://github.com/ASP-NET-MVC/aspnetwebstack/blob/master/src/System.Web.Mvc/Html/InputExtensions.cs#L425

  • 简略的说,根据方法签名追踪,可以找到 InputHelper 方法,即https://github.com/ASP-NET-MVC/aspnetwebstack/blob/master/src/System.Web.Mvc/Html/InputExtensions.cs#L483

  • 重点在这一段:

    string attemptedValue = (string)htmlHelper
        .GetModelStateValue(fullName, typeof(string));
    tagBuilder.MergeAttribute("value", attemptedValue ?? ((useViewData)
        ? htmlHelper.EvalString(fullName, format)
        : valueParameter), isExplicitValue);
    

    如果要报错,那么应该报错在 htmlHelper.GetModelStateValue,因为很明显这是获取 Model.Ints[i] 的值的地方

  • 继续找到 HtmlHelper.GetModelStateValue 方法,即 https://github.com/ASP-NET-MVC/aspnetwebstack/blob/master/src/System.Web.Mvc/HtmlHelper.cs#L391

    internal object GetModelStateValue(string key, Type destinationType)
    {
        ModelState modelState;
        if (ViewData.ModelState.TryGetValue(key, out modelState))
        {
            if (modelState.Value != null)
            {
                return modelState.Value.ConvertTo(destinationType, null /* culture */);
            }
        }
        return null;
    }
    

    重点就在于 ViewData.ModelState.TryGetValue 了,显然 ModelState 主结构是一个 Dictionary 来存储所有的值,这个想必大部分人都知道,所以我们绕了一圈最终找到了这里
    也就是说,实际上是通过 Dictionary.TryGetValue(key, out value) 这样的形式来获取对应的值
    具体到我们的问题,即 i == 0 时,在 ModelState 中寻找 key == "Ints[0]" 的值,当然,其值为 null 并且并不会报错

所以整个流程中并不会因为 Model.Ints 未初始化而报错,因为 Html.TextBoxFor(model => model.Ints[i]) 并不是通过直接访问而是从 expression 数据结构和 ModelState 数据绑定中取值。虽然这背后机制并不复杂,但是这个问题突然冒出来的时候,没有完整看过这部分实现,我也并没有想到这其中的关联。

最后,在使用之前初始化一定是一个好习惯!

附送

其实比起看源码,通过 Visual Studio 来 debug 可能更方便。

那么步骤如下:

  • 找到 Tool -> Options -> Debugging -> General
  • Uncheck Enable Just My Code
  • Check Enable Source Server Support
  • 转到 Tool -> Options -> Debugging -> Symbols
  • Check Microsoft Symbol Servers
  • Add http://referencesource.microsoft.com/symbols
  • Add http://msdl.microsoft.com/download/symbols
  • Add http://srv.symbolsource.org/pdb/Public
  • 我也不知道哪个 symbol server 对你有效,所以就都加上吧~
  • 如果你只需要一部分的 modules,可以选择 Only specified modules,比如添加 System.Web.Mvc.dll

接下来进入调试时,只要右键在当前断点上选择 Step Into Specific 就可以选择进入调试源码了~