使用 Knockout 实现多对多矩阵网格





5.00/5 (2投票s)
如何构建一个矩阵显示来表示多对多实体关系。
在当今不断发展的 Web 框架和 JavaScript 库时代,很容易觉得深入学习它们毫无意义,因为它们很快就会过时。结果就是,你有时会错过一个真正伟大的工具,只是因为下一个“杀手级”的库脚本刚刚出现。
我多年来发现最大的挫败感之一就是,在度过光鲜亮丽的“Hello World”入门教程后,却花费大量时间寻找完成更复杂任务的正确技术。更糟糕的是,当你试图学习一个成熟框架的新版本时,你常常会发现针对旧版本的解决方案,而这些解决方案已经完全不适用了。
这正是我之前在使用 Knockout JS 时遇到的情况。Knockout 适合我,因为它不规定我必须如何在 MVVM 世界中与后端*模型*进行通信。对我来说,这种独立性至关重要,因为我必须依赖一个遗留框架。我想为我数据模型中一个复杂的多对多关系的*链接*数据构建一个直观的用户体验。
基本上,我需要一个网格!现在,你可能会说网格的示例成千上万,而且你说得对。然而,你发现的大部分都是关于字段的表格布局。我想要的是一个视图模型,将多对多关系表示为一个*矩阵*,其中两个记录之间的链接成为显示表格视图中的单元格。我不会用我正在处理的特定数据模型的细节来烦扰你,而是使用一个稍微人为构建的多对多结构,每个人都能理解。
问题域
我的示例将围绕一组 `Students`(学生)和他们所上的 `Courses`(课程)展开。在一个学期内,`Students` 应该注册一门或多门 `Courses`(我是英国人,所以我们不学期,我们用“term”!)。
从上面的描述可以看出,我需要三个表
Student
课程
Student_Course
这些给我们经典的多个对多模型,如下所示
这是对实体的极大简化,但足以用于本文的目的。
附录 A 中的 SQL 将在 Microsoft SQL Server 中创建这些表和关系,并创建一些示例记录。
这三个表和关系代表了 MVVM 解决方案的第一个“`m`”(模型)。
View
任何看过 http://learn.knockoutjs.com/ 上的精彩文档和教程的人,都会对 Knockout 如何融入现代 Web 应用程序的“`VM`”部分有所了解,所以我将从展示我的学生和课程示例的“`V`”(视图)开始。
此视图显示 `Student` 实体的记录作为行标题,`Course` 实体表中的记录作为列标题。关键在于,我们在设计视图时,不知道将处理多少学生或多少课程。我们只知道它必须是灵活的!
我们可以确定的事情是,左上角的单元格与 `Student` 或 `Course` 实体都无关,所以它是放置仅与视图本身相关的控件的理想位置。
矩阵的单元格是我们希望显示链接表中数据的可编辑字段的地方。您会注意到有 3 个字段保存着关于特定课程中*注册*的 `students` 的相关信息。在单个小单元格中显示每个链接记录的 3 条信息可能很困难,所以左上角的单元格包含一个选择项,允许用户选择他们想要处理的具体信息。
这里还有第四个信息,不那么明显,并且隐藏在显示之外。在上例中,您可以看到有 4 个 `students` 和 9 个 `courses`。因此,有 36 个*可能的*链接记录,但如果您使用了附录 A 中的脚本,您一开始只有 12 条记录,并且仍然会得到此显示。难以捉摸的第四条信息是链接记录是否存在!如果存在,我们将有一个“`student_course_id`”值,我们将将其放在视图模型中以表示链接的存在。后面的 `VIewModel` 将会处理这个问题。我稍后会回来谈论它!
HTML 标记
此时,我假设您已经了解 Knockout 如何使用 HTML 中的标记来声明要显示*的 JavaScript `ViewModel`* 的哪个部分。您可以查看本文源代码文件中的完整标记,但由于我假设您知道如何定义表格,所以我将专注于有趣的部分!
我们将视图分解为 4 部分
第一部分 - 网格控件单元格
无论需要显示多少条记录,总需要一个与任一链接实体都无关的左上角单元格。这使得它成为生成一些简单视图控件选择的完美位置。该单元格将包含以下标记。
<div id="divControl">
<h3>Display Mode:</h3
<p><select
data-bind="options: $root.displayModes,
value: $root.displayMode,
optionsText: function (item) { return item}"
size="1"></select>
</p>
</div>
在这里,我使用一个下拉列表框来选择链接记录的哪个属性将在后面描述的区域 4 中显示。您可以看到 `ViewModel` 将拥有一个字符串的可观察数组 `displayModes`,用于填充 `SELECT` 选项,以及一个可观察属性 `displayMode`(注意单数名称!)。
第二部分 - 列实体标题
构建矩阵时,应选择最适合列标题的实体。如果这是一个真实世界的例子,`student` 条目将远多于 `courses`,因此将 `courses` 放在第二部分将是明智的。
我们需要一个标题单元格数组,用于每个标题实体记录,再加上 1 个单元格来标记实体的*类型*。所以,在我们的第一个表格行中,我有以下标记
<th class="columnTitle" style="height:12em" >
<span data-bind="text: columnEntityTypeName" >Columns</span>
</th>
<!-- ko foreach: cols -->
<th class="columnTitle"
style="height:12em" >
<span data-bind="text: Name">?</span>
</th>
<!-- /ko -->
同样,Knockout 标记向您展示了 `ViewModel` 需要包含的内容。将有一个可观察属性用于列标题记录的名称 `columnEntityTypeName`。在这种情况下,我们将有一个字面名称“`Classes`”。
紧接着是让这个矩阵生效的*魔法*所在。我使用了注释伪元素表示法来生成一个 `foreach` 循环,遍历名为 `cols` 的可观察数组。数组中包含的对象需要一个用于实体*名称*的字段。例如 `Art`、`Math` 等。
第三部分 - 行条目
不出所料,行是使用绑定到容器元素的标准 `foreach` 循环方法构建的。
<tbody data-bind="foreach: rows">
<tr>
<th class="rowTitle">
<div data-bind="text: Name">Row ???</div>
</th>
<td class="rowSubTitle"> </td>
请注意,有必要生成一个空单元格,因为第一行有一个包含列实体类型名称的单元格,需要在视图中进行平衡。我本可以简单地在第一个单元格上放置一个 `colspan` 属性,但我希望保持标记简单明了!同时请注意,我将附加单元格的 CSS 类声明为 `rowSubTitle`,您可以在此处放置任何您喜欢的内容,也许它是一个添加关于该行相关集合或链接记录摘要信息的不错地方。
第四部分 - 链接单元格
就像我在主标题行生成列标题时所做的那样,我使用了相同的伪注释元素技术
<!-- ko foreach: cells -->
<td class="cell" >
<input data-bind="value: targetGrade,
visible: $root.displayMode() == 'Target'" />
<input data-bind="value: currentGrade,
visible: $root.displayMode() == 'Current'"/>
<input data-bind="value: finalGrade,
visible: $root.displayMode() == 'Final'"/>
</td>
<!-- /ko -->
您可以看到,每个行对象都有一个单元格数组,该数组的构造与列的数量相匹配。我本可以使用类似 `` 的标记,但我更喜欢构造我的 `ViewModel`,使行拥有与列集合大小相同的单元格集合。
我在这里声明了 3 个输入控件,每个控件都绑定到一个不同的可观察属性,并且我选择基于上面第 1 部分讨论的 `displayMode` 属性来显示它们。
在实践中,创建一个充满输入控件的网格可能不是一个好主意,因为浏览器在维护如此多的控件时会浪费资源。您最好是生成一个带有点击处理程序的 `SPAN`,以便在需要时将单个控件定位到单元格上,并将其绑定到视图模型上的某个 `currentCell` 属性。但是,我将其留给读者作为练习,因为要完整地描述它会偏离本文的重点,即一个矩阵网格!
视图模型和其他代码
在描述了视图标记和描述我们模型的表结构之后,我们就涵盖了 M-V-VM 解决方案的“`M`”和“`V`”部分。剩下的只有“`VM`”视图模型本身了。
我假设您已经知道什么是视图模型以及如何在网页中使用它,因此我将从列出示例中加载的脚本开始。
这些 `script` 标签放在页面的 `HEAD` 部分。
<script src="Scripts/jquery-3.1.1.min.js"></script>
<script src="Scripts/knockout-3.4.0.js"></script>
<script src="Scripts/model.js"></script>
显然,前两个只是我正在使用的库。在生产环境中,我倾向于使用 CDN 源。第三个,*model.js*,包含描述我的视图模型本质的代码。下面是代码的编辑摘要。
矩阵网格视图模型构造函数
上面的 *model.js* 脚本包含一个*构造函数*,用于创建视图模型对象。
function MatrixGrid(options) {
//overide defaults object with the options using the jQuery facility
var _defaults = {
... more code
}
jQuery.extend(_defaults, options);//merge the options (if any) over the defaults
var self = this; //standard technique to hold a reference to the constructed VM object
self.displayMode = ko.observable(_defaults.displayMode);
self.displayModes = ko.observableArray(_defaults.displayModes);
... more code
self.cols = ko.observableArray(_defaults.colData);
self.rows = ko.observableArray(_defaults.rowData);
self.columnEntityTypeName = ko.observable(_defaults.columnEntityTypeName)
self.rowEntityTypeName = ko.observable(_defaults.rowEntityTypeName)
self.load = _defaults.load;
self.save = _defaults.save;
self.clear = function () {
self.cols([]);
self.rows([]);
};
... more code
}
这里我使用的是一个标准的约定,即一个构造函数,用于从中创建视图模型对象。名称以大写字母开头,因为我打算将其与 **new** 运算符一起使用来构造一个视图模型对象。请注意,该函数声明了一个参数 `options`。我借用了 jQuery 插件的技术,即将选项作为对象传递,用于覆盖一些默认属性和方法。
jQuery.extend(_defaults, options); //merge the options (if any) over the defaults
在覆盖默认值后,我将 `this` 的引用存储在 `self` 属性中。这也是一项常见技术,您可以在其他地方阅读。
接下来,我声明了一堆 Knockout 可观察属性。
self.displayModes = ko.observableArray(_defaults.displayModes);
self.displayMode = ko.observable(_defaults.displayMode);
self.cols = ko.observableArray(_defaults.colData);
self.rows = ko.observableArray(_defaults.rowData);
self.columnEntityTypeName = ko.observable(_defaults.columnEntityTypeName)
self.rowEntityTypeName = ko.observable(_defaults.rowEntityTypeName)
您可以通过视图的 HTML 标记识别出这些属性。有用于控件单元格下拉列表的 `displayModes` 数组以及相关的显示模式。有两个用于 `cols` 和 `rows` 的数组,以及几个用于列和行实体名称的可观察属性。所有这些的初始值已在 `_defaults` 对象中定义,传入的选项应该覆盖这些值。
此视图模型是从客户端 HTML 页面本身定义的脚本构建的,位于 jQuery DOM `ready` 方法的回调函数中。使用的代码是:
var vm = null;
jQuery(document).ready(function () {
vm = new MatrixGrid({
displayMode: "Current",
displayModes: ["Target", "Current", "Final"],
columnEntityTypeName: "Classes",
rowEntityTypeName : "Students",
load: loadGrid
});
ko.applyBindings(vm, document.getElementById("mainContent"));
vm.load(vm);
});
您可以在这里看到,我在调用 `MatrixGrid` 构造函数时,将显示模式列表和初始设置传递给了普通的选项对象。
此时,您可能想知道关于学生和课程的更*具体*数据从何而来?上面的列表忽略了一个关键点:用于构造视图模型对象的选项有一个 `load` 属性,它实际上是一个函数。此函数已在主页面文件中定义,并在 `applyBindings` 调用后立即调用。其定义如下:
function loadGrid(model){
var url = "data/initialgrid.xml";
jQuery.ajax({
url: url,
cache: false,
dataType: "xml",
success: function(data,status,jqXHR){
var $dom = jQuery(data);
model.clear();
//load columns
$dom.find("col").each(function(idx,tagCol){
var $col = jQuery(tagCol);
model.cols.push({
ID: $col.attr("ID"),
Name: jQuery("Name",tagCol).text(),
subText:""})
}
);
//load rows
$dom.find("row").each(function(idx,tagRow){
var $row = jQuery(tagRow);
var rowID = $row.attr("ID");
var aCells= ko.observableArray();
//find cells for this row
$dom.find("cell[rowID = " + rowID + "]").each(
function(idxCell, tagCell){
var $cell = jQuery(tagCell);
var colID = $cell.attr("colID");
aCells.push({
rowID:rowID,
colID: colID,
currentGrade: $cell.attr("currentGrade"),
targetGrade: $cell.attr("targetGrade"),
finalGrade: $cell.attr("finalGrade") }
);
});
model.rows.push({
ID:rowID,
Name: jQuery("Name",tagRow).text(),
cells: aCells}
)
});
},
error: function(jqXHR,status,errorThrown){
alert(status + "\n-----------------\n" + errorThrown)
}
});
}
让我们快速浏览一下这个函数。它首先发起一个 AJAX 调用(使用 jQuery)来获取一个描述链接结构数据的 XML 文档。最近,应用程序开发倾向于使用 JSON 作为首选数据传输策略。然而,我包含了适用于 MS SQL Server 的 SQL,可以直接生成所需的 XML 数据。SQL Server 2016 之前的 JSON 支持仅通过用户定义函数或其他 CLR 类型支持,但自 2008 年以来,XML 支持一直很出色。
我选择生成 XML 格式的传输数据,这可以使用 MS SQL Server XML 扩展轻松生成。对于本文,我将“For XML”查询的结果直接保存到一个文件中,并将其用作数据源 URL。在生产环境中,您将目标指向某个 ASHX 处理程序或其他资源,以动态地从数据库获取数据。
假设从服务器获取数据没有问题,`success` 回调函数将被调用。
function(data, status, jqXHR) {
var $dom = jQuery(data);
model.clear();
//load columns
$dom.find("col").each(function(idx, tagCol) {
var $col = jQuery(tagCol);
model.cols.push({
ID: $col.attr("ID"),
Name: jQuery("Name", tagCol).text()
})
});
//load rows
$dom.find("row").each(function(idx, tagRow) {
var $row = jQuery(tagRow);
var rowID = $row.attr("ID");
var aCells = ko.observableArray();
//find cells for this row
$dom.find("cell[rowID = " + rowID +
"]").each(function(idxCell, tagCell) {
var $cell = jQuery(tagCell);
var colID = $cell.attr("colID");
aCells.push({
rowID: rowID,
colID: colID,
currentGrade: $cell.attr("currentGrade"),
targetGrade: $cell.attr("targetGrade"),
finalGrade: $cell.attr("finalGrade")
});
});
model.rows.push({
ID: rowID,
Name: jQuery("Name", tagRow).text(),
cells: aCells
})
});
jQuery 在使用一些简单熟悉的语法导航 XML 数据文档方面非常出色。因此,我创建了一个 DOM 文档的 jQuery 对象(`$dom`),然后我使用 jQuery 结果的 `find` 方法来构建一个“`each`”函数。数据结构非常简单
<grid>
<row ID=”1”><Name>Student Name</Name></row>
...
<col ID=”9”><Name>Course</Name></col>
...
<cell rowID=”1” colID=”9” attributeValue1=”?” />
...
</grid>
可能不那么明显的是,我们数据模型中任何*缺失*的单元格都是在服务器代码中构建的,以确保我们拥有一整套单元格。换句话说,如果我们有 4 行和 6 列,我们将始终有 24 个单元格。找到“空白”单元格的地方,`ID` 属性将是 `NULL`,因此不会出现在 XML 输出中。
如您所见,首先确定了列集
$dom.find("col").each(function(idx, tagCol) {
var $col = jQuery(tagCol);
model.cols.push({
ID: $col.attr("ID"),
Name: jQuery("Name", tagCol).text()
})
});
每个 `<col>` 元素用于创建一个简单对象,该对象使用 `push` 方法添加到 `model.cols` 属性的可观察数组中。
同样,行也被处理。行的区别在于执行了一个内部循环,使用选择器谓词语法来仅获取与当前行 ID 链接的行。
$dom.find("cell[rowID = " + rowID + "]")
找到的每个单元格元素都用于创建一个简单对象,并追加到该行的单元格数组中。
这就是全部了!
要在实际中使用,您需要决定如何收集屏幕上的更改并更新服务器。您可以响应 UI 中的每一次更改并将消息发送回服务器,或者您可以将更改数据保留在客户端应用程序中,直到用户单击某种提交按钮,然后一次性发送所有更改。选择取决于更改发送后服务器上需要发生什么。
无论您使用哪种策略,都应注意考虑多用户更新。我倾向于在链接表上放置一个时间戳字段,并在单元格标签中将此字段作为附加属性发送到客户端。数据显示为 Base 64 编码的*字符串*,可以在提交更新之前进行检查。但是,关于此的进一步讨论远远超出了本文的范围!
希望您能在此示例的基础上,制作出真正实用的矩阵 UI 屏幕。它们确实为我的用户简化了生活!
附录 A - 生成 SQL
IF not OBJECT_ID('Student_Course') IS NULL DROP TABLE Student_Course
IF not OBJECT_ID('Student') IS NULL DROP TABLE Student
IF not OBJECT_ID('Course') IS NULL DROP TABLE Course
GO
CREATE TABLE dbo.Student(
Student_ID INT NOT NULL IDENTITY(1,1)
CONSTRAINT Student_PK PRIMARY KEY
, FirstName NVARCHAR(100) NOT NULL
, LastName NVARCHAR(100) NOT NULL
, Notes NVARCHAR(MAX) NULL
, FullName AS FirstName + ' ' + LastName
)
CREATE TABLE dbo.Course (
Course_ID INT NOT NULL IDENTITY(1,1)
CONSTRAINT Course_PK PRIMARY KEY NONCLUSTERED
, Name NVARCHAR(100) NOT NULL
CONSTRAINT Course_Unq_Name UNIQUE CLUSTERED
, IsCompulsory BIT NOT NULL
CONSTRAINT Course_Def_IsComp DEFAULT(0)
, Notes NVARCHAR(MAX) NULL
)
CREATE TABLE dbo.Student_Course (
Student_Course_ID INT NOT NULL IDENTITY(1,1)
CONSTRAINT Student_Course_PK PRIMARY KEY
, Student_ID INT NOT NULL
CONSTRAINT Student_Course_FK_Student REFERENCES Student(Student_ID) ON DELETE CASCADE
, Course_ID INT NOT NULL
CONSTRAINT Student_Course_FK_Course REFERENCES Course (Course_ID) ON DELETE CASCADE
, Current_Grade CHAR(1) NULL
, Target_Grade CHAR(1) NULL
, Final_Grade CHAR(1) NULL
)
GO
INSERT INTO Student(FirstName,LastName)
VALUES
('Alan','Adder')
, ('Belinda','Blair')
, ('Colin','Carpenter')
, ('Daniel','Diamond')
INSERT INTO Course(Name,IsCompulsory)
VALUES
('Maths',1)
, ('English Lit.',1)
, ('English Lang.',1)
, ('Biology',0)
, ('Chemistry',0)
, ('Physics',0)
, ('Art',0)
, ('Geography',0)
, ('History',0)
--Prefill the compulsory links!
INSERT INTO Student_Course( Student_ID,Course_ID)
SELECT
S.Student_id
, C.Course_ID
FROM Student S
CROSS JOIN Course C
WHERE C.IsCompulsory = 1