到目前为止,我们完成了数据库设置,成功地取得了数据并显示在了应用页面上。接着,我们还完成了通用组件——分类菜单,并让它可以高亮显示当前分类。Web 应用的“前端”已经初具雏形了。这一章里,我们要做一个前端的业务逻辑中必不可少的组件——购物篮。
构建购物车
购物车中一般需要显示商品信息,所购数量,单价和总价;除了可以让用户点击继续付款外,还要有让用户继续浏览商店的出口。 因为购物车的页面不应该包含分类菜单,所以我们要给购物车页面重新构造一个布局。 Listing 8-1 在 Views/Shared 文件里新建一个 _CartLayout.cshtml 文件
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<title>逻辑的电子书店</title>
<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body>
<div class="container">
<div class="bg-secondary text-white p-2 mt-2">
<span class="navbar-brand ml-2 display-6">逻辑的电子书店</span>
</div>
<div class="row m-3 p-3">
<div class="col">
@RenderBody()
</div>
</div>
</div>
</body>
</html>
在Views 文件夹里新建 Cart 文件夹,在里面添加新文件 Index.cshtml 。 Listing 8-2 在 Views/Cart 文件夹里新建 Index.cshtml 文件
@{
ViewData["Title"] = "购物车";
Layout = "../Shared/_CartLayout";
}
<div>
<p>This is the Cart Page</p>
</div>
因为 Cart 的布局不同,所以这里要在开头定位布局文件。
我们暂时先像这样做好购物车的基本骨架,为了让接下来写代码的时候,让意识里有这个购物车的概念。在写接下来的代码时,提供大脑思考逻辑过程的锚点。
给应用加入 Ajax 技术
一般的购物车操作都是点击加入购物车按钮以后,网页会给出提示,并不需要马上跳转到购物车页面。这样做可以让用户购物过程不会中断,如丝顺滑地让用户多买。亚马逊就是像这样利用用户心理的,不管有用没用,用户不知不觉就入坑了。
我们先修改 Views/Home 的 Index.cshtml,给每本电子书都加上购物按钮。
Listing 8-3 修改 Views/Home 文件夹里的 Index.cshtml 文件
@model EBooksListViewModel
<div class="row">
@foreach (var item in Model.EBooks)
{
<div class="col-sm-6 col-md-4 p-2">
<div class="card p-2">
<div class="card-body">
<h5 class="card-title" style="height:3.2rem">@item.Title</h5>
<h6 class="card-subtitle mb-2 text-body-secondary overflow-y-hidden" style="height:1.2rem">作者:@item.Author</h6>
<p>类型:@item.Category</p>
<div class="overflow-y-auto" style="height: 160px;">
简介:
<br />
<span class="text-body-secondary">@item.Description</span>
</div>
<p class="text-end mt-3"><span class="badge text-bg-dark text-white p-2">价格:@item.Price</span></p>
</div>
<!-- 加入表格和按钮 -->
<form id="form-@item.Id" data-ebook-id="@item.Id" method="post">
<input type="hidden" name="eBookId" value="@item.Id" />
<button id="btn-@item.Id" type="submit" class="btn btn-outline-primary w-100 mb-2">加入购物车</button>
</form>
</div>
</div>
}
</div>
<div pagination="@Model.PagingViewModel" page-action="Index" class="d-flex justify-content-end my-2"
button-class="btn" button-active="btn-primary" button-normal="btn-outline-primary"></div>
我们给每本书都加一个表格和加入购物车按钮,同时再表格中加入的隐藏的 input 其实可以不用,但这是传统做法,用 JavaScript 代码取数据也比较方便。
我们要用 Ajax 技术把表格提交给 Web 应用后台,网页不会跳转,会一直停留在电子书的列表页面。
“AJAX(Asynchronous JavaScript And XML)是一种在 Web 应用中通过异步发送 HTTP 请求向服务器获取内容,并使用这些新内容更新页面中相关的部分,而无需重新加载整个页面的 Web 开发技术。这可以让网页更具有响应性,因为只请求了需要更新的部分。”
“一开始的时候 AJAX 通过使用 XMLHttpRequest 接口实现,但是 fetch() API 更适合用于开发现代 Web 应用” —— Mdn Web docs
一般做法是在应用服务端给 AJAX 请求新建一个 响应 HttpPost 的 方法。这个方法将处理请求,更新数据库,再返回响应信息给客户端。返回信息一般是 Json 数据。
我们给 HomeController 类添加一个 AddToCart 方法来处理加入购物车的任务。
Listing 8-4 在 HomeController.cs 里添加一个新方法
using EBooksStore.Models.Interfaces;
using EBooksStore.Models.ViewModels;
using Microsoft.AspNetCore.Mvc;
namespace EBooksStore.Controllers;
public class HomeController(IStoreRepository repository, int PageSize = 4) : Controller
{
private readonly IStoreRepository _repository = repository;
public int PageSize { get; set; } = PageSize;
public IActionResult Index(string category = "", int pageNumber = 1)
=> View(new EBooksListViewModel
{
EBooks = _repository.EBooks
.Where(e => string.IsNullOrEmpty(category) || e.Category == category)
.OrderBy(e => e.Id)
.Skip((pageNumber - 1) * PageSize)
.Take(PageSize),
PagingViewModel = new PagingViewModel
{
CurrentPage = pageNumber,
BooksPerPage = PageSize,
TotalEBooks = string.IsNullOrEmpty(category) ?
_repository.EBooks.Count() :
_repository.EBooks.Where(e => e.Category == category).Count()
},
CurrentCategory = category
});
// 响应 Ajax 的加入购物车
[HttpPost]
public JsonResult AddToCart(int eBookId)
{
// Logic to add the ebook to the cart
// For example: _cartService.AddToCart(eBookId);
return Json(new { success = true });
}
}
HttpGET 和 HttpPost 方法是超文本传输协议 HTTP 中的两种常用方法。
HttpGet 从指定资源请求数据,数据作为 url 的一部分提交,对用户可见,不安全,但快速。
HttpPost 将要处理的数据提交到指定资源,Submit (提交) 按钮启动 HttpPost 请求。数据在 http 请求正文中提交,数据在 URL 中不可见。
我们用 [HttpPost]
属性限定 AddToCart 方法只能是 HttpPost ,它接收一个整型的参数 eBookId 。
接着,我们要让视图在用户发起 HttpPost 请求时,发送 eBookId 给服务端。再回到 Home 的 Index.cshtml ,在代码末尾添加 JavaScript 处理 AJAX 的代码。
Listing 8-5 修改 Views/Home 文件夹里的 Index.cshtml
@model EBooksListViewModel
<div class="row">
@foreach (var item in Model.EBooks)
{
<div class="col-sm-6 col-md-4 p-2">
<div class="card p-2">
<div class="card-body">
<h5 class="card-title" style="height:3.2rem">@item.Title</h5>
<h6 class="card-subtitle mb-2 text-body-secondary overflow-y-hidden" style="height:1.2rem">作者:@item.Author</h6>
<p>类型:@item.Category</p>
<div class="overflow-y-auto" style="height: 160px;">
简介:
<br />
<span class="text-body-secondary">@item.Description</span>
</div>
<p class="text-end mt-3"><span class="badge text-bg-dark text-white p-2">价格:@item.Price</span></p>
</div>
<form id="form-@item.Id" data-ebook-id="@item.Id" method="post">
<input type="hidden" name="eBookId" value="@item.Id" />
<button id="btn-@item.Id" type="submit" class="btn btn-outline-primary w-100 mb-2">加入购物车</button>
</form>
</div>
</div>
}
</div>
<div pagination="@Model.PagingViewModel" page-action="Index" class="d-flex justify-content-end my-2"
button-class="btn" button-active="btn-primary" button-normal="btn-outline-primary"></div>
<script type="text/javascript">
window.onload = function () {
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', function (event) {
event.preventDefault();
const ebookId = this.getAttribute('data-ebook-id');
fetch('/Cart/AddToCart', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: `eBookId=${ebookId}`
}).then(response => {
if (response.ok) {
response.json().then(data => {
if(data.success){
document.getElementById(`btn-${ebookId}`).textContent = '已加入购物车';
document.getElementById(`btn-${ebookId}`).disabled = true;
}
});
}
});
});
});
};
</script>
我们用fetch() API 返回服务端的响应,如果成功返回(代码200)再检查 success 是否为 true,如果正确返回,那么修改按钮文字并设置按钮的 disabled 属性为 true,用户就知道已经把这本电子书加入到购物车了。另外,我们的 AddToCart 方法目前返回都是真值。如果运行应用,得到如下页面:
Figure 8-1
我们注意到已加入购物车的按钮已经被失效了。
注意: 在实际应用中,我们应该在 HTTP 请求的 headers 中设置 "Content-Type": "application/json"
,并添加 X-CSRF-TOKEN
,否则不能正常发起 POST 请求。
到这里,我们好像完成了这个任务。我们可以点击分页导航,浏览器会转到相应页面;我们可以点击单本电子书的加入购物车按钮,当服务器收到点击消息返回时,我们让这个按钮变灰色,再不能点击。但是,如果我们先点击某个加入购物车按钮,按钮变灰失效,再点击翻页,会转到目标页;接着如果我们点击翻页回到刚才点加入购物车那一页,那个按钮又恢复到正常状态了。怎么解决这个问题?
一般对这样需要保存某些网页状态的情形,我们有两种解决方式:一种是用浏览器的本地存储来保存状态,另一种是用服务端的会话存储状态。ASP.NET Core 是在服务端生成网页再由客户端访问来生成页面的,所以用会话来保存状态是一个符合逻辑的选择。首先,我们先让应用可以使用会话服务。
Listing 8-6 给 Program.cs 添加会话服务
using EBooksStore.Models;
using EBooksStore.Models.Interfaces;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddDbContext<StoreDbContext>(options => {
options.UseSqlServer(
builder.Configuration["ConnectionStrings:EBooksStoreConnection"]);
});
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
//Add session services
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession(options => {
options.Cookie.Name = ".EBooksStore.Session";
options.IdleTimeout = TimeSpan.FromHours(30);
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
});
var app = builder.Build();
// enable session middleware
app.UseSession();
app.UseStaticFiles();
app.MapDefaultControllerRoute();
// Seeds.InitPopulate(app);
app.Run();
在 HttpContext 里存在一个 ISession 接口类型,我们将给这个接口添加一个扩展方法让它可以存取我们要求的信息。先在项目文件夹下新建一个文件夹 Extensions。
Listing 8-7 在 Extensions 文件夹新建一个文件 SessionExtensions.cs
using System.Text.Json;
namespace EBooksStore.Extensions;
public static class SessionExtensions
{
public static void Set<T>(this ISession session, string key, T value)
{
session.SetString(key, JsonSerializer.Serialize(value));
}
public static T? Get<T>(this ISession session, string key)
{
var value = session.GetString(key);
return value == null ? default : JsonSerializer.Deserialize<T>(value);
}
}
会话 Set 方法是用JsonSerializer.Serialize 把泛型值序列化为字符串,然后用 SetString 方法把键值和序列化泛型值成对存在会话里;会话 Get 方法是取得对应键值的泛型值(当然要反序列化为原来的类型)。
接着,我们在 AddToCart 方法中把点击过的电子书 Id 保存到会话。
同时,我们还要让每一页取得数据库数据时,同时把保存的会话内容传给视图。这里我们用 ViewData 字典在 Controller 和 View 之间传递数据。
在 ASP.NET MVC 中,ViewData 是一个字典(ViewDataDictionary),用于从控制器向视图传递数据。它允许您在控制器和视图之间共享数据,而不需要强类型模型。ViewData 本质上是一个字典<字符串,对象> ,使用字符串键存储。数据仅在当前请求中可用。
Listing 8-8 在 HomeController.cs 里添加保存会话
using EBooksStore.Extensions; // add the using directive
using EBooksStore.Models.Interfaces;
using EBooksStore.Models.ViewModels;
using Microsoft.AspNetCore.Mvc;
namespace EBooksStore.Controllers;
public class HomeController(IStoreRepository repository, int PageSize = 4) : Controller
{
private readonly IStoreRepository _repository = repository;
public int PageSize { get; set; } = PageSize;
// pass the disabled cart items to the view
ViewData["DisabledCartItems"] = HttpContext.Session.Get<List<int>>("DisabledCartItems") ?? [];
return View(new EBooksListViewModel
{
EBooks = _repository.EBooks
.Where(e => string.IsNullOrEmpty(category) || e.Category == category)
.OrderBy(e => e.Id)
.Skip((pageNumber - 1) * PageSize)
.Take(PageSize),
PagingViewModel = new PagingViewModel
{
CurrentPage = pageNumber,
BooksPerPage = PageSize,
TotalEBooks = string.IsNullOrEmpty(category) ?
_repository.EBooks.Count() :
_repository.EBooks.Where(e => e.Category == category).Count()
},
CurrentCategory = category
});
[HttpPost]
[Route("Cart/AddToCart")]
public JsonResult AddToCart(int eBookId)
{
// Logic to add the ebook to the cart
// For example: _cartService.AddToCart(id);
// retrive existing disabled cart items fro session
var disabledCartItems = HttpContext.Session.Get<List<int>>("DisabledCartItems") ?? [];
// add the new item to the list
if (!disabledCartItems.Contains(eBookId))
{
disabledCartItems.Add(eBookId);
HttpContext.Session.Set("DisabledCartItems", disabledCartItems);
}
return Json(new { success = true });
}
}
更新视图,应用 ViewData 里的数据。我们已经能够在分类页面跳转或者分页导航跳转后,仍然保留电子书的是否加入购物车的状态。
Listing 8-8 修改 Views/Home 文件夹里的 Index.cshtml
@model EBooksListViewModel
<div class="row">
@{
var disabledCartItems = ViewData["DisabledCartItems"] as List<int> ?? [];
}
<!-- 取得ViewData 里的数据 -->
@foreach (var item in Model.EBooks)
{
var isDisabled = disabledCartItems.Contains(item.Id);
<div class="col-sm-6 col-md-4 p-2">
<div class="card p-2">
<div class="card-body">
<h5 class="card-title" style="height:3.2rem">@item.Title</h5>
<h6 class="card-subtitle mb-2 text-body-secondary overflow-y-hidden" style="height:1.2rem">作者:@item.Author</h6>
<p>类型:@item.Category</p>
<div class="overflow-y-auto" style="height: 160px;">
简介:
<br />
<span class="text-body-secondary">@item.Description</span>
</div>
<p class="text-end mt-3"><span class="badge text-bg-dark text-white p-2">价格:@item.Price</span></p>
</div>
<form id="form-@item.Id" data-ebook-id="@item.Id" method="post">
<input type="hidden" name="eBookId" value="@item.Id" />
@if(isDisabled)
{
<button id="btn-@item.Id" type="submit" class="btn btn-outline-primary w-100 mb-2" disabled>已加入购物车</button>
} else
{
<button id="btn-@item.Id" type="submit" class="btn btn-outline-primary w-100 mb-2">加入购物车</button>
}
<!-- 按条件生成正确的按钮 -->
</form>
</div>
</div>
}
</div>
<div pagination="@Model.PagingViewModel" page-action="Index" class="d-flex justify-content-end my-2"
button-class="btn" button-active="btn-primary" button-normal="btn-outline-primary"></div>
<script type="text/javascript">
window.onload = function () {
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', function (event) {
event.preventDefault();
const ebookId = this.getAttribute('data-ebook-id');
fetch('/Cart/AddToCart', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: `eBookId=${ebookId}`
}).then(response => {
if (response.ok) {
response.json().then(data => {
if(data.success){
document.getElementById(`btn-${ebookId}`).textContent = '已加入购物车';
document.getElementById(`btn-${ebookId}`).disabled = true;
}
});
}
});
});
});
};
</script>
使用会话比使用浏览器本地存储要安全;并且会话数据是和页面互相独立的。同时,我们点击分页导航跳转的页面是服务器生成的,用会话功能给性能带来影响微乎其微。我们现在可以检查验证一下,会话功能是否成功。
如果没有问题了,让我们继续完善应用。 接下来,我们还需要在电子书列表页面添加一个转到购物车页面的按钮,以便我们转到购物车进行后续操作。
发表回复