☰
Razor 组件化:在 ASP.NET Core MVC 中实现可复用 UI 模块的三种姿势
2026/10/9 3:28:45 网站建设 项目流程

做后端的人常遇到这种纠结:页面里有一堆重复的模块,比如分页条、筛选面板、数据卡片,每个页面都复制粘贴一遍 Razor 代码,改个样式要全局替换;想抽成前端组件吧,又觉得为了几个局部交互引入一套前端工程化体系太重了。我在几个 ASP.NET Core MVC 项目里摸索下来,发现 Razor 本身就能把"前端组件"这件事做得很好——不是去和 Vue、React 抢饭碗,而是在服务端渲染的体系内,把可复用的 UI 模块以强类型、可维护的方式组织起来。这篇总结就是我在实际项目里用 Razor 做前端组件的完整记录,包含 Partial、ViewComponent、TagHelper 三种姿势的选型逻辑、一个分页组件的完整落地过程,以及组件库化的目录约定和性能缓存方面的实战心得,希望对正在用 ASP.NET Core MVC 做后台管理类系统的朋友有参考价值。

1. 为什么我最终放弃了 JavaScript 组件库,转而用 Razor 组织前端

1.1 很多项目其实不需要"前后端分离"

前几年前后端分离的风气吹得很猛,我也跟风把一个内部订单管理系统的前端用 Vue 重写过一轮。结果发现,这种以"查询条件 + 表格 + 分页 + 表单提交"为主的后台页面,前端做的事并不复杂,反而是接口联调、权限控制、字段校验这些工作在前后端之间来回拉扯,效率明显下降。后来我接手另一个 ASP.NET Core MVC 项目时,决定换个思路:交互复杂的部分用少量 JavaScript 增强,页面主体和可复用模块全部用 Razor 在服务端渲染。

这不是倒退,而是理性选择。Razor 最大的优势是和 C# 类型系统直接打通,@Model就是强类型的,循环、条件、字符串处理都能直接写,页面生成在服务端完成,浏览器拿到的就是完整 HTML。对于不需要 SEO、也不追求极致交互体验的内部系统,这个模式开发速度最快,问题最好定位。我见过太多团队为了"技术先进"硬上前后端分离,结果一个小需求要后端写接口、前端调接口、联调环境出问题还得两边一起排查,成本全耗在沟通上。

1.2 什么场景适合用 Razor 组件,什么场景别硬上

我自己的判断标准很简单:看这个模块的"交互密度"。如果页面是"点击链接 → 跳转 → 刷新 → 填表单 → 提交 → 跳转"这种模式,Razor 组件完全够用;如果页面需要高频局部刷新、复杂拖拽、实时协作编辑器,那 Razor 就不合适了,老老实实引入前端框架或者用 HTMX 这类库做增强。

举几个我觉得特别适合 Razor 组件的场景:

  • 服务端数据强绑定的模块,比如"当前登录用户的待办列表"、"最新公告",这些数据从数据库查出来渲染成 HTML,天然适合 ViewComponent。
  • 需要权限控制的部分片段,比如管理员才显示的操作按钮组,Razor 里可以直接用User.IsInRole判断,比前端拿着一堆权限标识判断要安全得多。
  • SEO 敏感的内容页,比如官网的新闻列表、产品详情,服务端渲染直接输出 HTML,搜索引擎友好。
  • 标准化 UI 模块,比如分页条、面包屑导航、消息提示框,这些在每个页面都长一个样,抽成组件后修改样式只动一个文件。

反过来,如果页面有大量拖拽交互、图表实时刷新、富文本编辑,我建议该用 Vue/React 就果断用,Razor 组件硬扛只会让代码变得极其痛苦。我通常的做法是:一个项目里,90% 的页面走 MVC + Razor 渲染,少数交互密集的页面独立引入前端框架,避免一刀切。

2. Razor 做前端组件的三种核心姿势:Partial、ViewComponent 与 TagHelper

2.1 Partial:最轻量的片段复用,但边界要清楚

Partial(分部视图)是 ASP.NET Core MVC 里最简单粗暴的复用方式,就是Views/Shared目录下一个.cshtml文件,可以被任意视图引入。用法有两种:

@await Html.PartialAsync("_UserCard", Model.User)

或者在 Razor 页面里用 Partial TagHelper 的方式:

<partial name="_UserCard" model="Model.User" />

Partial 适合的是"纯展示"型片段——数据已经由当前页面的 Model 准备好了,只需要把它渲染成 HTML。比如用户信息卡片、系统公告条、页脚版权声明,这些都是 Partial 的好场景。

但 Partial 的边界在于它没有自己的逻辑。如果这个片段需要在每次渲染时去数据库拉数据、做权限过滤,那放在 Partial 里就要求每个页面都自己去准备这些数据,这就破坏了封装性——十个页面用这个 Partial,就得有十处重复的数据准备代码。我见过不少人把 Partial 当万能组件用,结果业务逻辑散落在各个 Controller 里,比不用组件还难维护。所以我的经验是:纯展示用 Partial,带逻辑用 ViewComponent,想改造成 HTML 标签风格用 TagHelper。

2.2 ViewComponent:带业务逻辑的独立组件,我实际用得最多

ViewComponent 可以理解成"一个轻量级的迷你 Controller + View",它有独立的类、独立的方法、独立的视图文件,可以自己完成数据获取和渲染。从 ASP.NET Core 里它替代了旧版 MVC 的ChildAction,但设计上更规范。

一个基础的 ViewComponent 长这样:

public class TodoListViewComponent : ViewComponent { private readonly IRepository<TodoItem> _repository; public TodoListViewComponent(IRepository<TodoItem> repository) { _repository = repository; } public async Task<IViewComponentResult> InvokeAsync(int count) { var items = await _repository.Query() .Where(x => !x.IsDone) .OrderBy(x => x.DueDate) .Take(count) .ToListAsync(); return View(items); } }

视图文件放在Views/Shared/Components/TodoList/Default.cshtml,然后页面里这样调用:

@await Component.InvokeAsync("TodoList", new { count = 5 })

ViewComponent 有几个好处很打动我:

  • 强类型 + 依赖注入:构造函数可以直接注入仓储、服务,数据获取逻辑全部封装在组件内部,页面和 Controller 完全不用操心。
  • 自带视图隔离:每个组件的视图在自己目录下,别人改组件时不会误动主页面。
  • 支持异步:InvokeAsync天然适配异步数据访问,不会阻塞线程。

如果你的组件需要读取数据库、调用外部服务、根据用户权限动态生成内容,首选 ViewComponent。它在概念上最贴近"独立组件",也是我项目里出现频率最高的组件形式。

2.3 TagHelper:让组件用起来像写 HTML 标签

TagHelper 是 Razor 里最有"前端组件库"气质的东西。它允许你定义一个新的 HTML 标签,或者在现有标签上扩展自定义属性,然后 Razor 引擎会在渲染时调用你的 C# 逻辑生成内容。

比如我想封装一个带图标的按钮:

<icon-button name="primary" icon="plus">新增用户</icon-button>

对应的 TagHelper 类:

[HtmlTargetElement("icon-button")] public class IconButtonTagHelper : TagHelper { public string Name { get; set; } public string Icon { get; set; } public override void Process(TagHelperContext context, TagHelperOutput output) { output.TagName = "button"; output.Attributes.SetAttribute("class", $"btn btn-{Name}"); output.Content.SetHtmlContent($"<i class='bi bi-{Icon}'></i> {output.GetChildContentAsync().GetAwaiter().GetResult()}"); } }

使用时需要在_ViewImports.cshtml里注册程序集:

@addTagHelper *, MyWebApp

TagHelper 的优势是"声明式体验",写页面的人不用关心背后逻辑,就像在用组件库的标签。遇到那种"结构固定、属性很多、到处要用"的 UI 模块,比如分页器、图片懒加载、日期选择框,TagHelper 是非常优雅的方案。但它的缺点也很明显:调试起来不如 ViewComponent 直观,C# 逻辑和 HTML 生成混在一个类里,写复杂了很难维护。所以我的建议是:简单结构用 TagHelper,复杂逻辑用 ViewComponent,两者不冲突,甚至可以互相配合。

三种方式我列了个对照表,方便快速选型:

方式复杂度是否带逻辑推荐场景缺点
Partial低否纯展示片段复用逻辑需外部准备
ViewComponent中是有数据获取、权限判断的独立模块视图文件多一个目录层级
TagHelper中高是结构固定、属性多的自定义标签复杂逻辑下难调试

3. 以分页器为例,完整走一遍从需求到落地

3.1 为什么选分页器当例子

分页器是我见过"每个页面各自实现一遍"最多的组件。列表页要分页、报表页要分页、日志页要分页,每个页面都写着上一页、下一页、页码循环,然后各自处理查询参数拼接,样式还经常不统一。把它抽成组件,收益立竿见影。下面我会分别用 ViewComponent 和 TagHelper 各实现一版,并说明在真实项目里我最终选择了哪种。

分页器要处理的细节其实不少:当前页高亮、上一页/下一页在边界时禁用、页码窗口(比如总共 50 页,不可能把 50 个页码全显示出来)、保留当前查询条件(否则点第二页就丢了搜索关键字)。我们按这个需求来设计。

3.2 先用 ViewComponent 实现,数据逻辑更清晰

先定义一个分页数据的模型:

public class PagedResult<T> { public IReadOnlyList<T> Items { get; set; } public int TotalCount { get; set; } public int PageSize { get; set; } public int CurrentPage { get; set; } public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); }

然后写 ViewComponent 类:

public class PagerViewComponent : ViewComponent { public IViewComponentResult Invoke(PagedResult<object> result, string queryString = null) { ViewBag.QueryString = queryString; return View(result); } }

对应的默认视图Views/Shared/Components/Pager/Default.cshtml:

@model PagedResult<object> @{ var totalPages = Model.TotalPages; var currentPage = Model.CurrentPage; var startPage = Math.Max(1, currentPage - 2); var endPage = Math.Min(totalPages, currentPage + 2); var queryString = ViewBag.QueryString as string ?? ""; } @if (totalPages > 1) { <nav aria-label="分页导航"> <ul class="pagination"> <li class="page-item @(currentPage <= 1 ? "disabled" : "")"> <a class="page-link" href="?page=@(currentPage - 1)&@queryString">上一页</a> </li> @for (var i = startPage; i <= endPage; i++) { <li class="page-item @(i == currentPage ? "active" : "")"> <a class="page-link" href="?page=@i&@queryString">@i</a> </li> } <li class="page-item @(currentPage >= totalPages ? "disabled" : "")"> <a class="page-link" href="?page=@(currentPage + 1)&@queryString">下一页</a> </li> </ul> </nav> }

页面里的调用方式:

@await Component.InvokeAsync("Pager", new { result = Model.PagedResult, queryString = Context.Request.QueryString.Value })

注意我在设计时留了一个queryString参数,把当前请求的所有查询参数原样带过去,这样用户点第 2 页时搜索条件不会丢。这一步非常关键,很多新手写分页组件时只拼page参数,导致翻页后筛选条件全部丢失。

3.3 再用 TagHelper 实现,页面代码更简洁

如果想让页面代码更接近"组件库"的感觉,可以用 TagHelper 实现一个<pager>标签:

[HtmlTargetElement("pager")] public class PagerTagHelper : TagHelper { public int CurrentPage { get; set; } public int TotalCount { get; set; } public int PageSize { get; set; } = 10; public string QueryString { get; set; } public override void Process(TagHelperContext context, TagHelperOutput output) { var totalPages = (int)Math.Ceiling((double)TotalCount / PageSize); if (totalPages <= 1) { output.SuppressOutput(); return; } var startPage = Math.Max(1, CurrentPage - 2); var endPage = Math.Min(totalPages, CurrentPage + 2); output.TagName = "nav"; output.Attributes.SetAttribute("aria-label", "分页导航"); var sb = new StringBuilder(); sb.Append("<ul class=\"pagination\">"); if (CurrentPage > 1) { sb.Append($"<li class=\"page-item\"><a class=\"page-link\" href=\"?page={CurrentPage - 1}&{QueryString}\">上一页</a></li>"); } else { sb.Append("<li class=\"page-item disabled\"><span class=\"page-link\">上一页</span></li>"); } for (var i = startPage; i <= endPage; i++) { var active = i == CurrentPage ? "active" : ""; sb.Append($"<li class=\"page-item {active}\"><a class=\"page-link\" href=\"?page={i}&{QueryString}\">{i}</a></li>"); } if (CurrentPage < totalPages) { sb.Append($"<li class=\"page-item\"><a class=\"page-link\" href=\"?page={CurrentPage + 1}&{QueryString}\">下一页</a></li>"); } else { sb.Append("<li class=\"page-item disabled\"><span class=\"page-link\">下一页</span></li>"); } sb.Append("</ul>"); output.Content.SetHtmlContent(sb.ToString()); } }

页面里用起来非常清爽:

<pager current-page="model.CurrentPage" total-count="model.TotalCount" page-size="10" query-string="@Context.Request.QueryString.Value" />

两个版本我都实际跑过,最终项目里我保留的是 ViewComponent 版本。原因很简单:TagHelper 把 HTML 生成逻辑塞进 C# 字符串拼接里,一旦遇到更复杂的 UI(比如带省略号、带跳页输入框),代码会变得非常难读;而 ViewComponent 天生把数据计算和 HTML 模板分离,改样式只需改 cshtml。

3.4 表单页面的分页:不得不说的一个坑

你说查询条件都用 GET 参数就没事了?真实项目里还有一种常见情况:查询页用的是表单 POST 提交,比如复杂筛选条件字段很多,页面设计成点查询按钮时 POST 到后端,然后展示结果列表。这种情况下,列表下方的分页链接如果直接用?page=2,就会丢条件——因为 POST 提交的数据不会出现在 URL 里。

我遇到这个坑时的处理方案是:在接收 POST 的 Action 里,把查询条件转成一个object,再序列化成查询字符串传给分页组件。这里关键是不要自己去拼 URL,而是用 ASP.NET Core 的QueryHelpers:

using Microsoft.AspNetCore.WebUtilities; var query = new Dictionary<string, string> { ["keyword"] = keyword, ["status"] = status, ["page"] = "2" }; var queryString = QueryHelpers.AddQueryString("/Search/Result", query);

然后把生成的完整 URL 交给分页组件,点击页码时就能完整保留条件。这个坑网上很少有人提,但我相信踩过的人不少,写在这里给大家省点时间。

4. 用 Razor 搭建一个小型组件库的目录结构与复用约定

4.1 目录怎么摆才不混乱

组件一多,目录结构就要提前规划,不然过两个月就乱成一团。我的习惯是,所有全局共享的组件放在Views/Shared/Components下面,按组件名建子目录,组件视图统一命名Default.cshtml。如果模块有大量业务组件只属于某个功能区,就用 Areas 把组件放进对应 Area 的Views/.../Components下,避免全局目录爆炸。

一个项目里我常用的结构:

Views/ ├── Shared/ │ ├── Components/ │ │ ├── Pager/ │ │ │ └── Default.cshtml │ │ ├── AlertBox/ │ │ │ └── Default.cshtml │ │ └── UserCard/ │ │ └── Default.cshtml │ └── _ViewImports.cshtml ├── Home/ │ ├── Index.cshtml │ └── Detail.cshtml └── _ViewStart.cshtml

_ViewImports.cshtml是全局组件的注册中心,在这里统一引入命名空间和 TagHelper:

@using MyWebApp @using MyWebApp.Models @using MyWebApp.Components @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @addTagHelper *, MyWebApp

这样每个页面都能直接用@await Component.InvokeAsync(...),或者使用自定义 TagHelper,不需要每个视图单独写using。

4.2 约定比配置重要:给组件定"规矩"

组件库最怕的不是代码难写,而是没有约定导致的"每个组件长得都不一样"。我在团队里推进组件化时,定了几条硬性约定:

  • 命名全部用 PascalCase:组件类和视图目录必须同名,比如PagerViewComponent对应Components/Pager,否则框架识别会有歧义。
  • 所有组件必须有注释块:说明这个组件是干什么的、参数含义是什么、有没有前置依赖。我给 ViewComponent 和 TagHelper 类都加标准 XML 注释,相当于组件的文档。
  • 组件对外只暴露必要参数:能用默认值解决的绝不设为必填。比如分页组件我默认PageSize = 10,调用方不传也能工作。
  • CSS 类名加组件前缀:比如分页组件里的样式都用pager-前缀,避免和页面的全局样式冲突。

这里额外说一句,很多人把"前端组件库"简单地理解为引入一套现成的 UI 库(比如 Bootstrap、Element Plus),但在 MVC 项目里,"组件库"完全可以理解为"属于自己项目的、用 Razor 沉淀出来的一套可复用 UI 模块"。它没有版本更新的负担,和项目代码天然耦合,修改起来也没有外部依赖的牵绊。这正是 Razor 组件库最舒服的地方。

4.3 组件和静态资源怎么配合

Razor 组件渲染的是 HTML,但样式和脚本最终还是落在wwwroot里。我的约定是:每个组件如果有独立的 CSS/JS,就放到/wwwroot/组件名/目录下,比如/wwwroot/pager/pager.css,然后在组件的视图文件里用<link>引用。

但这里有个问题:如果组件是动态渲染在页面上的,直接用<link>引用会导致同一个 CSS 文件被多个页面重复引入。我的做法是,在需要引入组件样式的页面使用 Razor 的@section机制,把样式和脚本放入页面底部的 section 里,确保只加载一次。没有放置在布局页的原因,是很多组件并不是全站使用,全部加载反而是浪费。

还有一个小技巧:有时候组件需要一段初始化脚本,比如一个表格组件需要给分页按钮绑定点击事件。这时候不要直接在组件视图里内联<script>,因为组件可能被多次引用,内联脚本会被执行多次。正确做法是把脚本抽到独立 JS 文件,并传入一个唯一实例标识。我在项目里通过给组件容器加一个 Guid 作为 id 来解决多次渲染冲突的问题:

var uid = Guid.NewGuid().ToString("N");

在视图里给根元素加上id="@uid",脚本里直接用这个 id 锚定操作范围。

5. 性能、缓存与调试:Razor 组件在真实项目里的几个坎

5.1 组件的性能:缓存策略要谨慎

ViewComponent 每次渲染都会执行一次InvokeAsync,如果在里面查数据库,页面有几个组件就多几次数据库查询。对于查询频繁的组件,我建议在数据层做缓存,不要直接缓存渲染结果。

为什么?因为组件渲染的结果里通常包含了当前用户的信息(比如用户昵称、角色标识),如果按页面级别缓存渲染结果,不同用户看到的可能是一样的,这在权限场景下就出大事了。更安全的做法是,在 ViewComponent 内部只缓存"数据集",比如"所有部门的列表"、"公告列表",然后在渲染时再用当前用户信息过滤。我经历了两次权限泄露的险情之后,把组件的缓存原则定成了:只缓存数据,不缓存 HTML 输出。

具体实现可以用IMemoryCache,但要特别注意缓存 key 的设计:

public class TodoListViewComponent : ViewComponent { private readonly IMemoryCache _cache; public TodoListViewComponent(IMemoryCache cache) { _cache = cache; } public async Task<IViewComponentResult> InvokeAsync() { var cacheKey = $"todos_{User.Identity?.Name}"; var items = await _cache.GetOrCreateAsync(cacheKey, async entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); // 查数据库逻辑 }); return View(items); } }

缓存 key 里包含用户名,既保证了用户级隔离,也能让数据在用户维度有效。如果组件还依赖 URL 中的查询参数,记得也要拼进 key,否则不同筛选条件下的数据互相污染。

5.2 调试 Razor 组件的常用手法

查组件问题的时候,我最常用的工具就是断点。ViewComponent 的InvokeAsync方法就是一个普通方法,可以直接下断点看传入参数和返回结果,这比 Debug JavaScript 舒服多了。另一个常见问题是 cshtml 视图编译报错,ASP.NET Core 默认在 Development 环境下会显示详细错误,但有时候组件视图路径不对,报错说找不到视图,这时候先检查Views/Shared/Components/组件名/Default.cshtml路径是否异常,特别是组件名大小写。Windows 下不区分大小写可能没事,但在 Linux 容器里部署时,路径大小写不一致就会报视图不存在的错误。

还有一个老生常谈但很多人犯了又犯的错:InvokeAsync里用了异步方法,但忘了await,典型表现是组件渲染出来是空白的或者数据是默认值,调试发现是 Task 没有执行完。我自己也在这个问题上栽过两次,所以现在写异步方法的时候都会强制自己检查有没有await。

5.3 我踩过的三个典型坑

除了上面的问题,再分享三个我真实遇到且排查了很久的坑,希望你能绕开。

第一个坑:ViewComponent 里用HttpContext.Request.Query取不到查询参数。因为组件是在页面渲染阶段被调用的,某些情况下Request.Query在组件里读取到的内容不一定是你想象的那样。更稳妥的做法是通过调用方把参数传进去,比如在页面上先取好查询字符串,再传给组件,组件不要自己依赖 HttpContext。

第二个坑:TagHelper 属性名绑定对不上。比如你在类里定义了public string CurrentPage { get; set; },在标签里写current-page,结果始终收不到传入的值。这是因为 TagHelper 默认属性名转成 kebab-case 是current-page,没问题——出现问题,一般是 C# 类里属性名是缩写(比如ISBN),转换规则会搞乱。遇到这类绑不上值的问题,直接用[HtmlAttributeName("current-page")]显式指定属性名,比猜规则靠谱得多。

第三个坑:组件里用了@Html.AntiForgeryToken(),但页面没有相应的 Cookie/Token 机制时,表单提交总报校验失败。这个不是组件本身的问题,但一旦把表单片段抽进组件,很容易忽略防伪验证的上下文。解决方案是确保整个页面渲染链路中都启用了防伪服务,或者配合[ValidateAntiForgeryToken]时通过services.AddAntiforgery()统一配置好,不要在某一个页面单独处理。抽组件时这种"依赖上下文"的功能尤其要警惕。

6. 组件化和团队协作:怎么让团队成员都愿意用组件

6.1 好用才是硬道理

组件化做得再好,如果团队成员用起来觉得别扭,最后还是各写各的。我在项目里推动组件落地的过程中发现,大家愿意用组件的前提是:组件真的省事、文档真的清楚、出了问题真的能快速排查。所以我每次写组件都会单独建一个_ComponentDocs.md,放在组件的目录下,里面包含用法示例、参数说明、注意事项,甚至一句"不要在这里加样式,应该去 xxx 改"之类的提醒。这套做法虽然土,但比口口相传强得多。

6.2 先沉淀,再造轮子

我从来不主张一开始就规划一个庞大的组件库,那是过度设计。更务实的路径是:写页面的时候,只要发现同一段 Razor 逻辑出现第二次,就停下来考虑抽组件;出现第三次,就直接抽。组件库不是设计出来的,是长出来的,从项目实际需求里长出来的组件库,每个组件都有真实的存活理由。

最后再分享一点个人感受:技术选型这件事,没有绝对的好与坏。Razor 组件解决的是服务端渲染项目中的模块复用问题,它没法替代前端框架,但对内部管理系统、CMS、报表类项目来说,它确实是投入产出比极高的方案。你在项目里只需要记住一句话:**组件是给写页面的人减负的,不是给架构图增加装饰的。**当你觉得一个组件让使用方变麻烦了,那就该重新审视设计了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询