使用 AngularJS、trNgGrid、ngTable 和 Web API 实现服务器端数据过滤、排序和分页
一个完整的示例应用程序,使用 AngularJS、trNgGrid、ngTable 和 ASP.NET Web API 来访问和显示具有服务器端数据过滤、排序和分页功能的数据。
引言
服务器端数据过滤、排序和分页并不是一个新话题,即使在 AngularJS 世界中也是如此。现在有许多 AngularJS 网格工具可用,但很少能找到使用网格工具处理服务器端分页数据集的优秀示例,例如在 JQuery 中使用 jqGrid。本文展示了一个纯 HTML 5 和 AngularJS SPA 网站的示例,该网站实现了一个用于数据过滤的搜索模块和两个 AngularJS 网格(trNgGrid
和 ngTable
),用于请求和显示数据。随附了一个完整的 ASP.NET Web API 应用程序,用于演示如何从服务器提供过滤、排序和分页的数据项。
构建和运行示例应用程序
在运行下载的示例应用程序之前,请确保您的本地计算机上已安装以下先决条件。
- Visual Studio 2012 或 2013,并默认安装了 IIS Express。
- SQL Server 2012 或 2014 LocalDB。
- Internet 连接,用于从 NuGet 自动下载 Web API 所需的所有必要库。
Visual Studio 解决方案中的项目结构如下所示。通常,Web API 项目组和客户端 UI 网站应在单独的解决方案中。为了便于设置和运行,所有项目和网站都放在同一个解决方案中。
在 Visual Studio 中打开解决方案后,您需要通过 Solution Explorer 执行以下步骤。
-
右键单击
SM.Store.Api.Web
项目,选择属性,然后转到属性页面上的Web部分。IIS Express在服务器部分下配置。您只需单击一次此处的创建虚拟目录按钮。这将把本地项目位置链接到 IIS Express 站点。 -
右键单击
SM.Store.Api.Web
项目下的 index.html,然后选择在浏览器中查看菜单命令。这将启动 IIS Express 和 Web API 主机站点,自动在您的 LocalDB 实例中创建数据库,并用所有需要的示例数据填充表。关闭 index.html 测试页面不会影响 Web API 站点的运行。 -
如果 Web API 站点已停止,并且您需要在数据库初始化后的任何时间重新启动它,您可以简单地运行以下命令行:
"C:\Program Files (x86)\IIS Express\iisexpress.exe" /site:SM.Store.Api.Web
-
当 Web API 站点运行时,您可以通过打开
SM.Store.Client.Web
项目下的 index.html 来启动客户端网站。示例应用程序使用专有的搜索模块进行数据过滤,而不是大多数 AngularJS 网格工具中每个字段的内置过滤选项。输入任何搜索条件值并单击Go按钮,将加载带有分页器和字段排序标题的数据网格,如下所示:
AngularJS 下拉列表的占位符
在 JQuery 中,可以将占位符“请选择...”添加到源数据列表中,并且可以在没有任何时间问题的情况下设置显示样式。在 AngularJS 中,使用本地数据源填充下拉列表时也没有时间问题。示例应用程序中从本地数据服务提供程序获取的数据项示例如下。
angular.module('smApp.AppServices').service('LocalData', [function () {
//Local data for product search types.
this.getProductSearchTypes = function () {
return [
{ id: "0", name: "Please select..." },
{ id: "CategoryId", name: "Category ID" },
{ id: "CategoryName", name: "Category Name" },
{ id: "ProductId", name: "Product ID" },
{ id: "ProductName", name: "Product Name" }
];
}
}]);
然后,可以将用于将数据绑定到指令和切换 CSS 类的代码编写为 ng-options
和自定义 options-class
指令。
<select id="ddlSearchType" class="form-control placeholder-color"
ng-model="model.pSearchType.selected"
ng-options="item.id as item.name for item in model.productSearchTypes"
options-class="{'placeholder-color':'placeholder', 'control-color':'data'}"
ng-change="changeDdlClass('ddlSearchType')"
</select>
对于从数据库检索的任何列表数据源,自定义指令 options-class
仅在添加监视周期以等待 AJAX 调用返回的数据时才有效。使用监视周期解决时间问题是 AngularJS 指令的本质,但过多的监视周期会影响应用程序性能。为了避免此自定义指令中的监视周期,下拉列表最初使用带有 ng-repeat
指令的 option
标签加载。在这种情况下,需要将占位符项作为第一个 option
元素添加,并应用默认的 CSS 类。
<select id="ddlProductStatusType" class="form-control placeholder-color"
ng-model="model.pStatusType.selected"
ng-change="changeDdlClass('ddlProductStatusType')"
<option value="0" class="placeholder-color">Please select...</option>
<option ng-selected="{{item.StatusCode == model.pStatusType}}"
ng-repeat="item in model.productStatusTypes"
value="{{item.StatusCode}}"
class="control-color">
{{item.Description}}
</option>
</select>
UI Bootstrap 日期选择器
UI Bootstrap Datepicker 是一个纯 AngularJS 组件,没有 JQuery 引用。示例应用程序使用此组件作为搜索产品面板上的日期从和日期到字段输入。大多数配置可以通过在 HTML input
元素中设置指令值来直接完成。
<input type="text" ng-model="search.pAvailableFrom"
class="form-control"
id="txtFirstAvailFrom"
placeholder="Date from"
datepicker-popup="{{format}}"
is-open="openedFrom"
min-date="'2000-01-01'"
max-date="'2020-12-31'"
datepicker-options="dateOptions"
show-button-bar="false"
ng-required="true" >
为了互斥地打开日期选择器,通过单击日期选择器按钮调用的控制器中的函数设置了一对标志。
$scope.openFrom = function ($event) {
$event.preventDefault();
$event.stopPropagation();
$scope.openedFrom = true;
$scope.openedTo = false;
};
$scope.openTo = function ($event) {
$event.preventDefault();
$event.stopPropagation();
$scope.openedTo = true;
$scope.openedFrom = false;
};
请求带过滤、排序和分页参数的数据
过滤、排序和分页数据的请求通过 ngResource
数据服务从 AngularJS 控制器发送到 Web API。
var webApiBaseUrl = "https://:10611/api/";
angular.module('smApp.AppServices', ['ngResource'])
//Web API call for product list.
.factory('ProductList', function ($resource) {
return $resource(webApiBaseUrl + 'getproductlist_p', {}, {
post: {
method: 'POST', isArray: false,
headers: { 'Content-Type': 'application/json' }
}
});
})
控制器中调用 post
方法,直接传递 filterJson.json,这是一个包含输入参数的 JSON 格式 string
。
ProductList.post(filterJson.json, function (data) {...}, function (error) {...});
参数主要包含两部分
- 数据搜索条件。这些是 Web API 方法或数据库存储过程用于检索数据结果集所依据的参数。这些参数的数量可能根据数据过滤要求而或多或少。
- 分页和排序参数。这些项对于分页数据结果集来说是相当标准的,主要是起始记录索引、每页记录数、排序字段名和排序方向(升序或降序)。
下面是示例应用程序中使用的过滤器参数树的对象结构。
函数 getFilterJson
根据上述对象生成格式化的 JSON string
。构建 JSON string
也很容易验证多个相互依赖的数据输入,例如日期从和日期到,或价格低和价格高。读者可以在 controller.js 中查看代码以了解详细信息。
在 Web API 中处理数据请求
JSON string
将附加到 HTTP 表单正文并发送到服务器。它将由 Web API 控制器中的 Post_GetProductList
方法自动解析并转换回对象。GetProductsBySearchRequest
对象的一个实例将所有参数作为其属性,然后将其传递给业务逻辑和数据层以检索匹配结果。
[Route("~/api/getproductlist_p")]
public ProductListResponse Post_GetProductList([FromBody] GetProductsBySearchRequest request)
{
//Parse the request object and call for data...
}
Web API 项目中的进一步处理使用 Entity Framework 和 LINQ to SQL,而不是调用存储过程,来检索过滤、排序和分页的数据结果集。使用 PredicateBuilder
类简化了带有过滤条件的 LINQ 查询的构建,而排序和分页处理逻辑主要通过使用 GenericSorterPager
类完成。这两个类文件都在 SM.Store.Api.Common
项目中。
用于服务器端分页数据的 AngularJS 网格
如今有许多高质量的 AngularJS 网格工具可用。其中,ngGrid、ngTable、SmartTable 和 trNgGrid 是最受欢迎的具有服务器端分页功能的工具。根据我的研究结果,ngGrid
尽管由 Angular UI 团队维护,但它对 JQuery 库有主题依赖。SmartTable
需要比其他网格工具更多的自定义编码工作才能使服务器端分页工作。结果表明,trNgGrid
和 ngTable 是具有服务器端排序和分页功能的数据集的更好选择,因为我们需要功能丰富且易于使用但没有任何外部 JQuery 依赖的网格工具。
示例应用程序提供了使用 trNgGrid
和 ngTable
工具的代码示例。这两种工具都有优缺点。基本上,trNgGrid
更易于使用,但 ngTable
更灵活。以下部分描述了这两种网格工具的集成细节。
使用 trNgGrid
trNgGrid
为服务器端排序和分页功能提供了完整的指令集。我们只需在 table
元素中添加一个空值的 tr-ng-grid
指令,然后配置其他指令以获得适当的值。与分页相关的指令有:
current-page
:从零开始的分页号。该值可以从作用域变量设置。page-items
:分页大小,即每页的记录数。total-items
:总记录数。该值将在从服务器获取数据后进行后期分配。on-data-required
:事件处理程序,用于通过调用控制器中的函数向服务器发送请求。它可以由current-page
、page-items
、total-items
的任何更改或单击列标题中的任何排序按钮触发。
示例应用程序在 table
元素中设置这些指令值。请注意,内置的列过滤选项已禁用。
<table id="tblProductList"
tr-ng-grid=""
items="model.productList"
selected-items="mySelectedItems"
selection-mode="SingleRow"
enable-filtering="false"
on-data-required-delay="1000"
current-page="setCurrentPage"
page-items="model.pPageSizeObj.selected"
total-items="model.totalProductCount"
on-data-required="onServerSideItemsRequested
(currentPage, pageItems, filterBy, filterByFields, orderBy, orderByReverse)">
控制器中的 onServerSideItemsRequested
函数接收所有必要的参数,然后将请求发送到 Web API。
//Called from on-data-required directive.
$scope.onServerSideItemsRequested =
function (currentPage, pageItems, filterBy, filterByFields, orderBy, orderByReverse) {
loadProductList(currentPage, pageItems, orderBy, orderByReverse);
}
//Ajax call for list data.
var loadProductList = function (currentPage, pageItems, orderBy, orderByReverse) {
//Get JSON string for parameters.
var filterJson = getFilterJson();
//Call data service.
ProductList.post(filterJson.json,
function (data) {
$scope.model.productList = data.Products;
$scope.model.totalProductCount = data.TotalCount;
},
function (error) {
alert("Error getting product list data.");
}
);
$scope.showProductList = true;
}
当当前分页号不是 1
时,重新选择分页大小或排序参数应将分页号重置回 1
以加载任何新数据列表。此效果可以通过使用作用域变量 setCurrentPage
将 current-page 指令重置为 0 来实现。由于重置分页号将再次调用 onServerSideItemsRequested
函数,因此需要跳过任何已与服务器调用相关的过程。
//Called from search Go button.
$scope.clickGo = function () {
if ($scope.setCurrentPage != 0) {
//Directive current-page value change will auto call onServerSideItemsRequested().
$scope.setCurrentPage = 0;
}
else {
loadProductList(pCurrentPage, pPageItems, pOrderBy, pOrderByReverse);
}
}
//When page size is changed from dropdown in Pager.
$scope.changePageSize = function () {
//If page size changed from ddl, set back to first page.
//This will auto call onServerSideItemsRequested().
if (!resetSearchFlag) {
$scope.setCurrentPage = 0;
}
else {
resetSearchFlag = false;
}
}
使用 trNgGrid
的缺点是 table
元素在库文件内部迭代模型记录,并且在 HTML 标记中没有常规的 <tr>
和 <td>
标签可用。因此,我们无法使用正常的 ng-repeat
结构进行数据编程。例如,如果我们不想将超链接文本添加到控制器中的结果数据中,就不可能将文本设为 HTML 标记中的超链接。要执行与数据行相关的命令,我们可以让监视周期检测新选择的项,而不区分选择了行中的哪一列。
//Action of clicking grid row.
$scope.$watch("mySelectedItems[0]", function (newVal, oldVal) {
var val;
if (newVal != oldVal) {
if (newVal == undefined && oldVal) val = oldVal;
else val = newVal;
alert("You selected product ID: " + val.ProductID);
}
});
使用 ngTable
ngTable
不将所有分页参数和任何可访问的服务器数据请求事件处理程序公开为指令。相反,它创建一个 JavaScript 对象 tableParams
,其中包含所有排序和分页选项的参数,以及用于其他所需数据项和操作的 settings
对象。然后将 tableParams
对象传递给 table
元素中的顶级指令 ng-table
。开发人员可以在 JavaScript 中操作这些参数,并在 getData
函数中编写代码以请求排序和分页数据。ngTable
的 HTML 标记非常简单:
<table ng-table="tableParams" template-pagination="/Templates/ngTablePager.html" >
然而,控制器中的代码看起来要复杂得多。通过点击搜索Go按钮调用 loadProductList
函数进行初始数据加载。tableParamter
对象的属性的任何后续更改将重新触发 getData
函数并调用 Web API 以刷新网格中的数据。由于 getData
函数的定义在库文件内部定义,并且该函数会自动由 tableParams
成员的任何更改调用,因此任何由非参数更改(例如,再次点击搜索Go按钮)发送的数据请求都需要模拟参数值中的更改。还需要设置标志变量以绕过由模拟参数更改触发的调用。有关详细说明,请参阅代码中的注释行。
//Called from search Go button.
$scope.clickGo = function () {
searchFlag = true;
loadProductList();
}
//Called from clicking search Go button. The getData will be called from any change of params.
var loadProductList = function () {
//Set default values.
pageIndex = 0;
pageSize = pageSizeSelectedDef;
//Subsequent clicking search Go button.
if ($scope.tableParams != undefined) {
//Leave same pageSize when called after changing search filter items.
pageSize = $scope.tableParams.count();
//Set param count differently from the current to trigger getData but bypass it.
//The actual process still use pageSize value not this changed count.
reSearchFlag = true;
$scope.tableParams.count($scope.tableParams.count() + 1);
}
//Set ng-table parameters initially.
$scope.tableParams = new ngTableParams({
page: pageIndex + 1, // Page number
count: pageSize, // Count per page
sorting: {}
}, {
defaultSort: 'asc',
total: 0,
countOptions: pageSizeList,
countSelected: pageSize,
//getData will also be called from ng-table.js whenever params changed
getData: function ($defer, params) {
if (!reSearchFlag) {
if (!searchFlag) {
//Retrieve changed params from pager and sorter for AJAX call input
pageIndex = params.page() - 1;
//Go to page #1 if change page size.
if (pageSize != params.count()) {
pageSize = params.count();
params.page(1);
}
sortBy = Object.getOwnPropertyNames(params.sorting())[0]
//Go to page #1 if change sorting on any column.
if (sortBy != undefined && sortBy != "") {
if (sorting !== params.sorting()) {
sorting = params.sorting();
sortDirection = sorting[sortBy] == "asc" ? 0 : 1;
params.page(1);
}
}
else {
sortBy = "";
sortDirection = 0;
}
}
else {
searchFlag = false;
}
var filterJson = getFilterJson();
ProductList.post(filterJson.json, function (data) {
//$scope.model.productList = data.Products;
$timeout(function () {
//Update table params.
params.total(data.TotalCount);
//Set start and end page numbers.
if (pageIndex == 0) {
params.settings().startItemNumber = 1;
}
else {
params.settings().startItemNumber =
pageIndex * params.settings().countSelected + 1;
}
params.settings().endItemNumber =
params.settings().startItemNumber + data.Products.length - 1;
//Set new data.
$defer.resolve(data.Products);
//Show grid.
$scope.showProductList = true;
}, 500);
}, function (error) {
alert("Error getting product list data.");
});
}
else
{
//Reset re-search flag.
reSearchFlag = false;
}
}
});
}
与 trNgGrid
不同,在 trNgGrid
中分页器可以在 HTML tfoot
元素中设置,与主要的 tr-ng-grid
指令分离,而 ngTable
的分页器使用 template-pagination
指令,该指令与 ng-table
指令和 settings
对象耦合。在 ngTable
库代码之外定义的范围变量无法轻易作用于分页器。因此,需要修改 ngTable.js 库文件,添加 settings
对象的一些属性,用于分页号选择 UI(例如下拉列表)和分页器中交互式总数显示。在示例应用程序中,以下粗体显示的这些成员已添加到原始 ngTable.js 文件中的 settings
对象变量中:
var settings = {
$scope: null, // set by ngTable controller
$loading: false,
data: null, //allows data to be set when table is initialized
total: 0,
defaultSort: 'desc',
filterDelay: 750,
counts: [10, 25, 50, 100],
countOptions: {},
countSelected: 0,
startItemNumber: 0,
endItemNumber: 0,
getGroups: this.getGroups,
getData: this.getData
};
然后,分页器模板可以使用 object
数据来实现正常功能。
<div class="ng-cloak ng-table-pager">
<!--Paging size dropdown list-->
<div class="pull-left">
<select id="ddlPageSize" class="form-control form-ddl-adj"
ng-model="params.settings().countSelected"
ng-options="item.value as item.text for item in params.settings().countOptions"
ng-change="params.count(params.settings().countSelected)"></select>
<span><span class="pager-label
page-label-down">  items per page</span></span>
</div>
<ul class="pagination ng-table-pagination pull-right">
<!--Page number buttons-->
<li ng-class="{'disabled': !page.active &&
!page.current, 'active': page.current}"
ng-repeat="page in pages" ng-switch="page.type">
<a ng-switch-when="prev" ng-click="params.page(page.number)"
href="">«</a>
<a ng-switch-when="first" ng-click="params.page(page.number)"
href=""><span ng-bind="page.number"></span></a>
<a ng-switch-when="page" ng-click="params.page(page.number)"
href=""><span ng-bind="page.number"></span></a>
<a ng-switch-when="more" ng-click="params.page(page.number)"
href="">…</a>
<a ng-switch-when="last" ng-click="params.page(page.number)"
href=""><span ng-bind="page.number"></span></a>
<a ng-switch-when="next" ng-click="params.page(page.number)"
href="">»</a>
</li>
<!--Page status messages-->
<li>
<span ng-class="{show: params.total() < 1,
hidden: params.total() > 0}" >No items to display</span>
<span ng-class="{show: params.total() > 0,
hidden: params.total() < 1}" class="pager-label"
ng-attr-title="{{'Display Info'}}">
{{params.settings().startItemNumber}} -
{{params.settings().endItemNumber}} {{'displayed'}}
<span>, {{params.total()}} {{'in total'}}</span>
</span>
</li>
</ul>
</div>
ngTable
使用原生与表相关的 HTML 标签来构建网格结构,这为列样式、文本格式和超链接提供了灵活的方法。例如,网格中的产品名称可以设置为超链接,以启动后续请求,例如打开产品详细信息页面或对话框。请注意,这里定义了范围对象 paging
,用于通过原型继承从子范围访问函数 openProductForm
。
在 HTML 标记中
<tr ng-repeat="item in $data">
<td data-title="'Product Name'" sortable="'ProductName'" width="120px">
<a ng-click="paging.openProductForm(item.ProductID)"
class="cursor-pointer">{{item.ProductName}}</a>
</td>
- - -
</tr>
在控制器中
//For communicating with ng-table scope through prototype inheritance.
$scope.paging = {};
//Action of clicking product name link.
$scope.paging.openProductForm = function (id) {
alert("You selected product ID: " + id);
}
摘要
由于指令导向的特性,在 AngularJS 中实现数据列表的服务器端过滤、排序和分页与使用 JQuery 实现相同结果大相径庭。本文提供了 AngularJS 客户端的完整示例,包括搜索模块、流行的网格工具和作为过滤、排序和分页数据提供者的 Web API。本文还描述了在使用 AngularJS 数据网格工具时 trNgGrid
和 ngTable
之间的优缺点。