让 Razor 视图使用 WebForms Master page 的方法

原有一个 WebForms 项目,想保持兼容,但是新的内容想使用 MVC 框架开发,于是就有了想共享 Master page 的想法。

Scott Hanselman 在他的一篇文章中提到这是可以实现的,并给出了他的参考来源,但是当我照着做的时候遇到了些问题,而且我觉得用他们用 this.RazorView 扩展方法等于在 Controller 里面就把视图引擎写死了,这并不好。

他们也没有解决另外一个问题:Master page 里面可以有很多 ContentPlaceholder,如何在一个 cshtml 文件里面都定义到?换句话说,我们知道用原生的 cshtml + layout 的话,在 cshtml 文件里面可以通过 @section 块来定义,layout 就用 RenderSection 来渲染,那使用 WebForms 的 Master page 该怎么办?

OK,现在我们开始。

在 VS 2013 可以用新建项目向导,弄一个 WebForms + MVC 的项目。

不用为 Controller 添加 RazorView 扩展方法的方法

_ViewStart

对于所有从 Controller Action 返回说要渲染的 Razor 视图,视图引擎会搜索并先运行一系列文件夹中名为 _ViewStart 的视图,于是 ~/Views/Shared/_ViewStart.cshtml 便是我消除在 controller 中执行 this.RazorView 方法的切入点:

@{     
// If using aspx mater page then don't use Layout cshtml.
     //Layout = "~/Views/Shared/_Layout.cshtml";

     this.RenderShim(); }

在这个文件中,我把原先的 Layout = … 注释掉了,改为运行一个扩展方法。这个方法的大部分内容来自跟我前面提到的参考来源,我会在后面把代码列出,它主要调用 RenderPartial 去渲染一个特殊的页面,而这个特殊的页面进而使用 Html.Partial 把实际的 View 渲染出来,代码也在后面给出。

Index.cshtml

首先来个简单的。

@{
     ViewBag.Title = "Home Page";

     if ( this.IsRenderedInAspx() )
     {
         return;
     } } <h1>This is the main content!</h1>

可以看到,其实就加了三行代码,也运行了一个扩展方法。这个过程就是,在 _ViewStart 里面抢先把内容在一个特殊的视图里面渲染出来,于是在引擎运行完 _ViewStart “正式” 进入这个视图的时候,已经没必要再渲染一遍了。

那两个神秘的扩展方法

就那么几行而已。

~/Extensions/RazorInAspx/RazorPageExtensions

public static class RazorPageExtensions
{
    private const string ShimViewName = "RazorInAspxShim";
    private const string KeyForViewAlreadyRendered = "_ViewAlreadyRenderedInApsxAsPartialView";

    /// <summary>
    /// Render the Razor page in Aspx master page as PartialView.
    /// </summary>
    public static void RenderShim( this ViewStartPage page )
    {
        var view = (RazorView)page.ViewContext.View;
        page.ViewContext.ViewBag._ViewName = Path.GetFileNameWithoutExtension(view.ViewPath);
        page.Html.RenderPartial(ShimViewName, page.ViewContext.ViewData);

        page.Context.Items[KeyForViewAlreadyRendered] = true;
    }

    /// <summary>
    /// Check if the Razor page is already rendered in Aspx master page as PartialView.
    /// </summary>
    public static bool IsRenderedInAspx( this WebViewPage page )
    {
        return page.Context.Items.Contains(KeyForViewAlreadyRendered);
    }
}

那个特殊的视图 RazorInAspxShim

从上面代码可以看到 RenderShim 把要渲染的视图名字存在 ViewBag 以后,用 RenderPartial 渲染了一个叫做 RazorInAspxShim 的视图,现在我们来看看它的简单版。

<%@ Page Language="C#" EnableViewState="false" MasterPageFile="~/Site.Master" Inherits="System.Web.Mvc.VewPage<dynamic>" %>

<asp:Content ContentPlaceHolderID="Main" runat="server">
     <%: Html.Partial((string)ViewBag._ViewName) %> </asp:Content>

有了以上的 code,随便撸一个 Site.Master,里面加个 ID 叫 Main 的 ContentPlaceHolder,运行就能看到效果啦!

但是呢,你立马就会发现一个问题:糟糕,Razor 视图怎么知道它自己上面是个 aspx,怎么设置 <title> 的内容?!ViewBag.Title 的数据在部分视图里面传不到上头的 aspx 啊!

支持多个 ContentPlaceHolder

有 Layout 的 Razor 视图的执行顺序

根据需要执行或跳过 _ViewStart 后,视图引擎会先执行视图主体里面的内容,包括 C# 代码,执行完就存成一个 HelperResult,然后执行 Layout 视图,最后 Layout 视图里面会调用 RenderBody 把这个 HelperResult 渲染出来,而其他 @section 则按照 Layout 视图里面的 RenderSection 顺序在 Layout 执行时执行并渲染。

带 Master page 的 Aspx 页面的执行顺序

如果一个 aspx 页面设置了 master page,忽略后台代码,那么这个页面其实是先把 master page 的结构替换过来,然后将这个页面本身的 <asp:Content> 的内容加进去再按照 master page 定义的顺序再渲染的,并不像 Razor 视图那样有“主体”那样的说法,也就是说,使用 master page 的 aspx 的内容一定是 <asp:Content> 的列表,<asp:Content> 外面是不能写其他东西的。

RazorViewInit 容器

为了模仿 Razor 视图的顺序,只能在 Master page 里面定义一个能最早有机会执行的 ContentPlaceHolder 了

<head runat="server">
    <asp:ContentPlaceHolder ID="RazorViewInit" runat="server" />

而 RazorInAspxShim.aspx 则修改成:

<%@ Page Language="C#" EnableViewState="false" MasterPageFile="../../Site.Master" Inherits="System.Web.Mvc.ViewPage<dynamic>" %>

<asp:Content ContentPlaceHolderID="RazorViewInit" runat="server">
    <%
        AspxSections.SetMainContent(Html.Partial((string)ViewBag._ViewName));
    %>
</asp:Content>

<asp:Content ContentPlaceHolderID="Main" runat="server">
    <%: AspxSections.GetMainContent() %>
</asp:Content>

那这个 AspxSections 是何方神圣呢?

/// <summary>Sets the content for the Main section.</summary>
public static void SetMainContent( IHtmlString content )
{
    HttpContext.Current.Items[SectionKeyMain] = content;
}

/// <summary>Gets the content for the Main section.</summary>
public static IHtmlString GetMainContent()
{
    var content = HttpContext.Current.Items[SectionKeyMain];
    return (content as IHtmlString) ?? new HtmlString((content as string) ?? "");
}

SetXXContent 方法把内容存到 HttpContext 里面,GetXXXContent 则是把内容从 HttpContext 里面取回来。

于是其他 Section 可以同理了,例如,在 Site.Master 里面还定义了另外一个 ContentPlaceHolder:

<asp:ContentPlaceHolder ID="Header" runat="server" />

那么在 Index.cshtml 里面就写成:

@{
    if ( this.IsRenderedInAspx() )
    {
        return;
    }
   
    AspxSections.SetHeaderContent(HeaderContent());
}

<h1>Main content</h1>

@helper HeaderContent() {
    <style>
        p {
            color: red;
        }
    </style>
}

没想到怎么用 @section 块,所以我的方案是改成用 @helper 块,@helper 生成的 HelperResult 是懒惰的,所以尽管传给 SetHeaderContent 的参数 HeaderContent() 看起来它所定义的块好像是执行了,但其实并没有,真正执行的地方在 Site.Master,有兴趣的可以在 Helper 里面弄一段 Response.Write 试一下。

使用 T4 生成代码

从上面的描述可以看出,AspxSections.cs 和 RazorInAspxShim.aspx 其实可以通过 Site.Master 生成。而它们都需要读进 Site.Master 找出所有 ContentPlaceHolder,所以我把这个逻辑写到一个 .ttinc 文件里面,让这两个 .tt 文件共享:

~/Extensions/RazorInAspx/AspxSectionsResolver.ttinc

<#@ import namespace="System" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ import namespace="System.Text.RegularExpressions" #>
<#+

static class AspxSectionsResolver
{
    public static IEnumerable<string> Enumerate( string masterPagePath )
    {
        var content = File.ReadAllText(masterPagePath);
        var matches = Regex.Matches(content, "<asp:ContentPlaceHolder\\s+[^>]*ID=\"([^\"]+)\"", RegexOptions.IgnoreCase);
        return from Match match in matches
               let id = match.Groups[1].Value
               where id != "RazorViewInit"
               select id;
    }
}

#>

~/Extensions/RazorInAspx/AspxSections.tt

<#@ template debug="false" hostspecific="true" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System" #>
<#@ import namespace="System.IO" #>
<#@ include file="AspxSectionsResolver.ttinc" #>
<#@ output extension=".cs" #>

<#
    var masterPagePath = Host.ResolvePath("../../Site.Master");
    var sections = AspxSectionsResolver.Enumerate(masterPagePath);
#>

using System;
using System.Web;
using System.Web.Mvc;
using System.Web.WebPages;

namespace WebformsInMVC.Extensions.RazorInAspx
{
    public static class AspxSections
    {
        private const string KeyRoot = "Mvc.Extensions.RazorInAspx.Sections.";

        <# foreach ( var section in sections ) { #>

            private const string SectionKey<#= section #> = KeyRoot + "<#= section #>";

            /// <summary>Sets the content for the <#= section #> section.</summary>
            public static void Set<#= section #>Content( string content )
            {
                HttpContext.Current.Items[SectionKey<#= section #>] = content;
            }

            /// <summary>Sets the content for the <#= section #> section.</summary>
            public static void Set<#= section #>Content( IHtmlString content )
            {
                HttpContext.Current.Items[SectionKey<#= section #>] = content;
            }

            /// <summary>Sets the content for the <#= section #> section.</summary>
            public static void Set<#= section #>Content( Func<string, IHtmlString> inlineTemplate )
            {
                HttpContext.Current.Items[SectionKey<#= section #>] = inlineTemplate("<#= section #>");
            }

            /// <summary>Gets the content for the <#= section #> section.</summary>
            public static IHtmlString Get<#= section #>Content()
            {
                var content = HttpContext.Current.Items[SectionKey<#= section #>];
                return (content as IHtmlString) ?? new HtmlString((content as string) ?? "");
            }

        <# } #>

    }
}

~/Views/Shared/RazorInAspxShim.tt

<#@ template debug="false" hostspecific="true" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System" #>
<#@ include file="../../Extensions/RazorInAspx/AspxSectionsResolver.ttinc" #>
<#@ output extension=".aspx" #>

<#
    var masterPagePath = "../../Site.Master";
    var sections = AspxSectionsResolver.Enumerate(Host.ResolvePath(masterPagePath));
#>

<%@ Page Language="C#" EnableViewState="false" MasterPageFile="<#= masterPagePath #>" Inherits="System.Web.Mvc.ViewPage<dynamic>" %>

<asp:Content ContentPlaceHolderID="RazorViewInit" runat="server">
    <%
        AspxSections.SetMainContent(Html.Partial((string)ViewBag._ViewName));
    %>
</asp:Content>

<# foreach ( var section in sections ) { #>

<asp:Content ContentPlaceHolderID="<#= section #>" runat="server">
    <%: AspxSections.Get<#= section #>Content() %>
</asp:Content>

<# } #>

~/Views/Web.config

最后,其实在一开始需要修改一下 ~/Views/Web.config,让 Asp.Net 认为这个文件夹里面的 aspx 是 ViewPage,也让 Razor 和 aspx 都能找到新加入的扩展方法:

razor 部分

  <system.web.webPages.razor>
    <pages pageBaseType="System.Web.Mvc.WebViewPage">
      <namespaces>
<!-- 省略 -->

        <add namespace="WebformsInMVC" />
        <add namespace="WebformsInMVC.Extensions.RazorInAspx"/>
      </namespaces>
    </pages>
  </system.web.webPages.razor>

aspx 部分

  <system.web>
    <httpHandlers>
      <add path="*" verb="*" type="System.Web.HttpNotFoundHandler"/>
    </httpHandlers>

    <!--
        Enabling request validation in view pages would cause validation to occur
        after the input has already been processed by the controller. By default
        MVC performs request validation before a controller processes the input.
        To change this behavior apply the ValidateInputAttribute to a
        controller or action.
    -->
    <pages
        validateRequest="false"
        pageParserFilterType="System.Web.Mvc.ViewTypeParserFilter, System.Web.Mvc, Version=5.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35"
        pageBaseType="System.Web.Mvc.ViewPage, System.Web.Mvc, Version=5.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35"
        userControlBaseType="System.Web.Mvc.ViewUserControl, System.Web.Mvc, Version=5.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35">
      <controls>
        <add assembly="System.Web.Mvc, Version=5.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" namespace="System.Web.Mvc" tagPrefix="mvc" />
      </controls>
      <namespaces>
        <add namespace="System.Web.Mvc.Html"/>
        <add namespace="WebformsInMVC.Extensions.RazorInAspx"/>
      </namespaces>
    </pages>
  </system.web>

就这么多了。

整个过程都没有修改过 Controller 的代码。至于中间提到的怎么设置页面 title 的问题,在 Site.Master 加入

<title>
    <asp:ContentPlaceHolder ID="Title" runat="server" />
</title>

然后,看完这篇文章的话你懂的。

posted @ 2015-07-20 17:35 DiryBoy 阅读(...) 评论(...)  编辑 收藏