让 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>
然后,看完这篇文章的话你懂的。
http://DiryBoy.cnblogs.com