以编程方式将Razor Page呈现为HTML字符串

7

目标

  • 我正在尝试在后端生成 HTML 字符串,因为我想使用 HtmlToPDF 库将其转换为 PDF。
  • 我还想能够轻松地在浏览器中查看生成的 HTML,以进行调试/微调。页面仅在 IsDevelopment() 时公开。
  • 我希望它尽可能简单。

我正在使用 ASP.NET Core 3.1

方法

Razor 页面

我认为我会尝试新的Razor 页面,因为它们被宣传为非常简单易用。

@page
@using MyProject.Pages.Pdf
@model IndexModel

<h2>Test</h2>
<p>
    @Model.Message
</p>

namespace MyProject.Pages.Pdf
{
    public class IndexModel : PageModel
    {
        private readonly MyDbContext _context;

        public IndexModel(MyDbContext context)
        {
            _context = context;
        }

        public string Message { get; private set; } = "PageModel in C#";

        public async Task<IActionResult> OnGetAsync()
        {
            var count = await _context.Foos.CountAsync();

            Message += $" Server time is { DateTime.Now } and the Foo count is { count }";

            return Page();
        }
    }
}

这个在浏览器中可以正常工作 - 万岁!

渲染并获取 HTML 字符串

我找到了一个看起来可以完成我的需求的方法 Render a Razor Page to string

但是这就开始有问题了 :(

问题

首先,我发现当你通过 _razorViewEngine.FindPage 找到页面时,它不知道如何填充 ViewContextModel,我认为 IndexModel 的工作应该是填充这些内容的。我希望能够请求 ASP.NET 页面的 IndexModel 并完成任务。

无论如何... 下一个问题。为了呈现该页面,必须手动创建 ViewContext 并提供一个 Model。但是页面就是模型,而且由于它是一个页面,它不是一个简单的 ViewModel。它依赖于 DI,并且期望执行 OnGetAsync() 以填充 Model。这基本上是个死结。

我还尝试通过 _razorViewEngine.FindView 获取视图而不是页面,但这也需要一个模型,所以我们又回到了死结。

另一个问题。用于调试/调整页面的目的是轻松地查看生成的内容。但如果我必须在 IndexModel 之外创建一个 Model,那么它就不再代表在某个服务中实际生成的东西了。

所有这些让我想知道自己是否走错了路。或者我是否漏掉了什么?


请查看此链接:https://github.com/Tyrrrz/MiniRazor,它可以用于构建电子邮件模板以及任何HTML页面,非常有趣。我还需要创建一个HTML转PDF的工具,所以很快就会遇到您的问题,需要找到一个好的解决方案。 - Johan Herstad
2个回答

10

请按照以下步骤将局部视图渲染为字符串:

  1. Add an interface to the Services folder named IRazorPartialToStringRenderer.cs.

     public interface IRazorPartialToStringRenderer
     {
         Task<string> RenderPartialToStringAsync<TModel>(string partialName, TModel model);
     }
    
  2. Add a C# class file to the Services folder named RazorPartialToStringRenderer.cs with the following code:

     using System;
     using System.IO;
     using System.Linq;
     using System.Threading.Tasks;
     using Microsoft.AspNetCore.Http;
     using Microsoft.AspNetCore.Mvc;
     using Microsoft.AspNetCore.Mvc.Abstractions;
     using Microsoft.AspNetCore.Mvc.ModelBinding;
     using Microsoft.AspNetCore.Mvc.Razor;
     using Microsoft.AspNetCore.Mvc.Rendering;
     using Microsoft.AspNetCore.Mvc.ViewEngines;
     using Microsoft.AspNetCore.Mvc.ViewFeatures;
     using Microsoft.AspNetCore.Routing;
    
     namespace RazorPageSample.Services
     {
         public class RazorPartialToStringRenderer : IRazorPartialToStringRenderer
         {
             private IRazorViewEngine _viewEngine;
             private ITempDataProvider _tempDataProvider;
             private IServiceProvider _serviceProvider;
             public RazorPartialToStringRenderer(
                 IRazorViewEngine viewEngine,
                 ITempDataProvider tempDataProvider,
                 IServiceProvider serviceProvider)
             {
                 _viewEngine = viewEngine;
                 _tempDataProvider = tempDataProvider;
                 _serviceProvider = serviceProvider;
             }
             public async Task<string> RenderPartialToStringAsync<TModel>(string partialName, TModel model)
             {
                 var actionContext = GetActionContext();
                 var partial = FindView(actionContext, partialName);
                 using (var output = new StringWriter())
                 {
                     var viewContext = new ViewContext(
                         actionContext,
                         partial,
                         new ViewDataDictionary<TModel>(
                             metadataProvider: new EmptyModelMetadataProvider(),
                             modelState: new ModelStateDictionary())
                         {
                             Model = model
                         },
                         new TempDataDictionary(
                             actionContext.HttpContext,
                             _tempDataProvider),
                         output,
                         new HtmlHelperOptions()
                     );
                     await partial.RenderAsync(viewContext);
                     return output.ToString();
                 }
             }
             private IView FindView(ActionContext actionContext, string partialName)
             {
                 var getPartialResult = _viewEngine.GetView(null, partialName, false);
                 if (getPartialResult.Success)
                 {
                     return getPartialResult.View;
                 }
                 var findPartialResult = _viewEngine.FindView(actionContext, partialName, false);
                 if (findPartialResult.Success)
                 {
                     return findPartialResult.View;
                 }
                 var searchedLocations = getPartialResult.SearchedLocations.Concat(findPartialResult.SearchedLocations);
                 var errorMessage = string.Join(
                     Environment.NewLine,
                     new[] { $"Unable to find partial '{partialName}'. The following locations were searched:" }.Concat(searchedLocations)); ;
                 throw new InvalidOperationException(errorMessage);
             }
             private ActionContext GetActionContext()
             {
                 var httpContext = new DefaultHttpContext
                 {
                     RequestServices = _serviceProvider
                 };
                 return new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
             }
         }
     }
    
  3. Register the services in the ConfigureServices method in the Startup class:

     public void ConfigureServices(IServiceCollection services)
     {
         services.AddRazorPages(); 
         services.AddTransient<IRazorPartialToStringRenderer, RazorPartialToStringRenderer>();
     }
    
  4. Using the RenderPartialToStringAsync() method to render Razor Page as HTML string:

     public class ContactModel : PageModel
     {
         private readonly IRazorPartialToStringRenderer _renderer;
         public ContactModel(IRazorPartialToStringRenderer renderer)
         {
             _renderer = renderer; 
         }
         public void OnGet()
         { 
         }
         [BindProperty]
         public ContactForm ContactForm { get; set; }
         [TempData]
         public string PostResult { get; set; }
    
         public async Task<IActionResult> OnPostAsync()
         {
             var body = await _renderer.RenderPartialToStringAsync("_ContactEmailPartial", ContactForm);  //transfer model to the partial view, and then render the Partial view to string.
             PostResult = "Check your specified pickup directory";
             return RedirectToPage();
         }
     }
     public class ContactForm
     {
         public string Email { get; set; }
         public string Message { get; set; }
         public string Name { get; set; }
         public string Subject { get; set; }
         public Priority Priority { get; set; }
     }
     public enum Priority
     {
         Low, Medium, High
     }
    
以下是翻译的结果:

调试截图如下:

在此输入图片描述

更详细的步骤,请参阅此博客文章(将局部视图呈现为字符串)


你如何包含布局以获取完整的页面HTML而不仅仅是部分内容?我正在做类似于这样的事情,但它只返回页面中的HTML而没有布局代码。 - Bradly Bennison

5
我终于破解了!我一直走错了方向......解决方法是使用ViewComponent。但还有些奇怪!
感谢:

解决方案

将PageModel转换为ViewComponent

namespace MyProject.ViewComponents
{
    public class MyViewComponent : ViewComponent
    {
        private readonly MyDbContext _context;

        public MyViewComponent(MyDbContext context)
        {
            _context = context;
        }

        public async Task<IViewComponentResult> InvokeAsync()
        {
            var count = await _context.Foos.CountAsync();

            var message = $"Server time is { DateTime.Now } and the Foo count is { count }";

            return View<string>(message);
        }
    }
}

视图位于 Pages/Shared/Components/My/Default.cshtml 中。
@model string

<h2>Test</h2>
<p>
    @Model
</p>

这项服务

using System;
using System.IO;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Routing;

public class RenderViewComponentService
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ITempDataProvider _tempDataProvider;
    private readonly IViewComponentHelper _viewComponentHelper;

    public RenderViewComponentService(
        IServiceProvider serviceProvider,
        ITempDataProvider tempDataProvider,
        IViewComponentHelper viewComponentHelper
    )
    {
        _serviceProvider = serviceProvider;
        _tempDataProvider = tempDataProvider;
        _viewComponentHelper = viewComponentHelper;
    }

    public async Task<string> RenderViewComponentToStringAsync<TViewComponent>(object args)
        where TViewComponent : ViewComponent
    {
        var viewContext = GetFakeViewContext();
        (_viewComponentHelper as IViewContextAware).Contextualize(viewContext);

        var htmlContent = await _viewComponentHelper.InvokeAsync<TViewComponent>(args);
        using var stringWriter = new StringWriter();
        htmlContent.WriteTo(stringWriter, HtmlEncoder.Default);
        var html = stringWriter.ToString();

        return html;
    }

    private ViewContext GetFakeViewContext(ActionContext actionContext = null, TextWriter writer = null)
    {
        actionContext ??= GetFakeActionContext();
        var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary());
        var tempData = new TempDataDictionary(actionContext.HttpContext, _tempDataProvider);

        var viewContext = new ViewContext(
            actionContext,
            NullView.Instance,
            viewData,
            tempData,
            writer ?? TextWriter.Null,
            new HtmlHelperOptions());

        return viewContext;
    }

    private ActionContext GetFakeActionContext()
    {
        var httpContext = new DefaultHttpContext
        {
            RequestServices = _serviceProvider,
        };

        var routeData = new RouteData();
        var actionDescriptor = new ActionDescriptor();

        return new ActionContext(httpContext, routeData, actionDescriptor);
    }

    private class NullView : IView
    {
        public static readonly NullView Instance = new NullView();
        public string Path => string.Empty;
        public Task RenderAsync(ViewContext context)
        {
            if (context == null) { throw new ArgumentNullException(nameof(context)); }
            return Task.CompletedTask;
        }
    }
}

使用方法

从 Razor 页面(调试/微调页面)

请注意没有代码后台文件。

@page
@using MyProject.ViewComponents

@await Component.InvokeAsync(typeof(MyViewComponent))

使用RouteData
@page "{id}"
@using MyProject.ViewComponents

@await Component.InvokeAsync(typeof(MyViewComponent), RouteData.Values["id"])

来自控制器

[HttpGet]
public async Task<IActionResult> Get()
{
    var html = await _renderViewComponentService
        .RenderViewComponentToStringAsync<MyViewComponent>();

    // do something with the html

    return Ok(new { html });
}

使用 FromRoute

[HttpGet("{id}")]
public async Task<IActionResult> Get([FromRoute] int id)
{
    var html = await _renderViewComponentService
        .RenderViewComponentToStringAsync<MyViewComponent>(id);

    // do something with the html

    return Ok(new { html });
}

奇怪的现象

很不幸,注入的 IViewComponentHelper 无法直接使用。

因此,我们需要做一些非常不直观的 事情 才能使它正常工作。

(_viewComponentHelper as IViewContextAware).Contextualize(viewContext);

这会引起一系列奇怪的事情,比如假的ActionContextViewContext,需要一个TextWriter,但它没有被用于任何事情!实际上,整个ViewContext根本没有被使用。它只是需要存在 :(

此外,NullView...由于某种原因,Microsoft.AspNetCore.Mvc.ViewFeatures.NullViewInternal,所以我们基本上必须将其复制/粘贴到我们自己的代码中。

也许未来会有所改进。

总之:在我看来,这比使用IRazorViewEngine更简单,后者几乎出现在每个网络搜索中 :)


2
这种方法更加繁琐。之前的答案为您提供了广泛使用的无限机会。假设您想要在调用引擎的get方法时直接传递一个模型,您该如何做呢?因为您肯定需要显示与当前请求相关的值,而不仅仅是存储在数据库中的一些数据。 - Tavershima
@Tavershima InvokeAsync() 可以接受任意数量的参数。所以你只需要调用 RenderViewComponentToStringAsync<MyViewComponent>(int arg1, string arg2, etc...); 这很简单。 - Snæbjørn
这是使用参数调用InvokeAsync的示例 https://learn.microsoft.com/en-us/aspnet/core/mvc/views/view-components?view=aspnetcore-3.1#invoking-a-view-component - Snæbjørn
我添加了从RazorPage和Controller传递路由参数的示例。 - Snæbjørn
1
您正在调用一个页面,该页面被称为字符串渲染器。您正在调用的页面只能使用存储在数据库中的预先存在的数据。假设有一个路由参数是从请求中获取的,并且您需要对其进行操作,那么如何将这些数据传递到该页面中,以便调用可以使用它?我想告诉您的是,对于这种简单情况来说,这是太多的工程了。 - Tavershima
这是不正确的。我会收到异常“InvalidOperationException:找不到与ActionContext关联的IRouter。如果您的应用程序正在使用端点路由,则可以使用依赖注入获取IUrlHelperFactory并使用它创建UrlHelper,或者使用Microsoft.AspNetCore.Routing.LinkGenerator。”,而在视图中尝试使用@Url.PageLink引用另一个端点。我使用此端点在Pdf上动态呈现图像。 - Creative

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接