JavaScript 前端 Web 应用教程 第四部分:管理单向关联






4.53/5 (9投票s)
了解如何开发前端 Web 应用,其中模型类之间存在单向关联,例如将作者和出版商分配给图书的关联。虽然在许多其他教程中,关联如果被讨论,也只是肤浅地提及,但本文提供了深入的解释。
引言
本教程前几部分讨论的两个示例应用程序,即最小应用和验证应用,都仅限于管理一种对象类型的数据。然而,一个真正的应用程序必须管理几种对象类型的数据,这些对象类型通常以各种方式相互关联。特别是,对象类型之间可能存在关联和子类型(继承)关系。处理关联和子类型关系是软件应用程序工程中的高级问题。它们在软件开发教科书中通常讨论不足,并且应用程序开发框架对其支持也不好。在本教程的这一部分中,我们将展示如何处理单向关联,而双向关联和子类型关系将在第 5 部分和第 6 部分中介绍。
本文摘自关于使用纯 JavaScript 构建前端 Web 应用程序的六部分教程,该教程以开放获取书籍使用纯 JavaScript 构建前端 Web 应用的形式提供。它展示了如何构建一个 Web 应用,该应用处理三种对象类型:Book
、Publisher
和 Author
,以及将出版商和(一个或多个)作者分配给图书的两个单向关联。如果您想查看并了解其工作原理,可以从我们的服务器运行本文讨论的单向关联应用。
前端 Web 应用可以由任何 Web 服务器提供,但它在用户的计算机设备(智能手机、平板电脑或笔记本电脑)上执行,而不是在远程 Web 服务器上执行。通常,但并非必须,前端 Web 应用程序是单用户应用程序,不与其他用户共享。
该应用程序支持四种标准数据管理操作:创建/读取/更新/删除。它通过添加代码来处理 Book
和 Publisher
之间的单向功能(多对一)关联,以及 Book
和 Author
之间的单向非功能(多对多)关联,从而扩展了第 2 部分的示例应用程序,但它需要通过添加应用程序整体功能的其他重要部分来增强。
背景
如果您尚未阅读本教程的第 1-3 部分,建议您先阅读它们:1. 七步构建最小应用、2. 添加约束验证 和 3. 处理枚举。
我们采用基于模型开发的方法,它提供了一种通用的方法来工程化各种工件,包括数据管理应用程序。为了能够理解本教程,您需要理解其基础概念和理论。您可以先阅读关于引用属性和关联的理论章节,然后再继续阅读本教程,或者您可以开始阅读本教程,并仅在需要时查阅理论章节,例如,当您遇到不熟悉的术语时。
作为关联概念的简短介绍,您可以阅读我的博客文章为什么关联如此难以理解,不仅仅对于开发人员而言?不幸的是,在许多文章和教程中,关联如果没有被处理,也只是没有足够深入地处理。例如,在这篇 CP 文章中,它们只是肤浅地讨论(没有提及非功能和双向关联)。在另一篇 CP 文章中,关联甚至被令人困惑地定义为对象相互使用但拥有自己的对象生命周期,这听起来像是一个专家声明,但实际上是错误的。
编写应用程序
实现单向功能关联
单向功能性关联要么是一对一,要么是多对一。在这两种情况下,这种关联都通过单值引用属性来表示或实现。
在本节中,我们将展示
-
如何从包含表示单向功能关联的单值引用属性的信息设计模型中推导出一个 JavaScript 数据模型,
-
如何将 JavaScript 数据模型编码成 JavaScript 模型类的形式,
-
如何基于模型代码编写视图和控制器代码(使用模型-视图-控制器架构)。
单值引用属性,例如对象类型 Book
的属性 publisher
,允许存储指向另一种类型对象(如 Publisher
)的内部引用。创建新对象时,构造函数需要有一个参数,以便为引用属性分配合适的值。在像 Java 这样的类型化编程语言中,我们必须决定这个值是内部对象引用还是 ID 引用。然而,在 JavaScript 中,我们可以采用更灵活的方法,允许使用其中任何一个,如以下示例所示。
function Book( slots) {
// set the default values for the parameter-free default constructor
...
this.publisher = null; // optional reference property
...
// constructor invocation with a slots argument
if (arguments.length > 0) {
...
if (slots.publisher) this.setPublisher( slots.publisher);
else if (slots.publisherIdRef) this.setPublisher( slots.publisherIdRef);
...
}
}
请注意,为了灵活性,构造函数参数 slots
可能包含表示(内部)JavaScript 对象引用的 publisher
槽,或者表示(外部)ID 引用(或外键)的 publisherIdRef
槽。我们通过检查参数类型来处理属性设置器中产生的歧义,如以下代码片段所示。
Book.prototype.setPublisher = function (p) {
...
var publisherIdRef = "";
if (typeof(p) !== "object") { // ID reference
publisherIdRef = p;
} else { // object reference
publisherIdRef = p.name;
}
}
请注意,出版商的 name
用作 ID 引用(或外键),因为这是 Publisher
类的标准标识符(或主键)。
构建 JavaScript 数据模型
创建 JavaScript 数据模型的起点是一个信息设计模型,其中显式关联已转换为引用属性,如下所示
我们现在将展示如何分三步从这个设计模型中派生出 JavaScript 数据模型。
-
为每个非派生属性创建检查操作,以便集中实现属性约束。对于标准标识符属性(例如
Book::isbn
),需要三个检查操作:-
一个基本的检查操作,例如
checkIsbn
,用于检查所有语法约束,但不包括强制值和唯一性约束。 -
一个标准 ID 检查操作,例如
checkIsbnAsId
,用于检查标识符(或主键)属性所需的强制值和唯一性约束。 -
一个ID 引用检查操作,例如
checkIsbnAsIdRef
,用于检查ID 引用 (IdRef)(或外键)属性所需的引用完整性约束。
对于引用属性,例如
Book::publisher
,检查操作Book.checkPublisher
必须检查相应的参照完整性约束,如果属性是强制性的,可能还需要检查强制值约束。 -
-
为每个非派生单值属性创建一个设置器操作。在设置器中,调用相应的检查操作,并且只有当检查未检测到任何约束违反时才设置该属性。
-
为每个非派生多值属性创建添加和移除操作。
这导致了以下 JavaScript 数据模型类 Book
,其中类级别(“静态”)方法用下划线表示。
![]() ![]() |
![]() |
我们也必须对 Publisher
类执行类似的转换。这为我们提供了从上述无关联模型派生出的完整 JavaScript 数据模型,如下图所示。
新问题
与例如在第 2 部分(验证教程)中讨论的单类应用程序相比,我们必须处理一些新的技术问题:
-
在模型代码中,我们现在必须处理需要以下内容的引用属性:
-
参照完整性约束的验证(ID 引用)
-
在序列化和反序列化过程中,(内部)对象引用和(外部)ID 引用之间的转换。
-
-
在用户界面(“视图”)代码中,我们现在必须注意:
-
在“列出对象”用例中显示有关关联对象的信息;
-
在“创建对象”和“更新对象”用例中,允许从目标类的所有现有实例列表中选择一个关联对象。
-
最后一个问题,即允许从某个类的所有现有实例列表中选择一个关联对象,可以通过 HTML select
表单元素来解决。
编写模型代码
JavaScript 数据模型可以直接编码,以获取我们的 JavaScript 前端应用程序模型层的代码。
-
将每个模型类编码为一个 JavaScript 构造函数。
-
将属性检查编码为类级别(“静态”)方法。确保 JavaScript 数据模型中指定的所有属性约束都正确编码在属性检查中。
-
将属性设置器编码为(实例级别)方法。在每个设置器中,调用相应的属性检查,并且只有当检查未检测到任何约束违反时才设置属性。
-
实施删除策略。
-
序列化和反序列化。
这些步骤将在以下部分中更详细地讨论。
1. 将 JavaScript 数据模型中的每个类编码为一个构造函数
数据模型中的每个类 C
都通过一个同名 C
的相应 JavaScript 构造函数进行编码,该函数带有一个参数 slots
,其中包含一个键值槽,为类的每个非派生属性提供值。这些属性的范围应在注释中指明。
在构造函数体中,我们首先为所有属性分配默认值。当构造函数作为默认构造函数被调用时(即不带任何参数),将使用这些值。如果构造函数带参数被调用,则可以通过调用所有属性的setter方法来覆盖默认值。
例如,JavaScript 数据模型中的 Publisher
类以下列方式编码:
function Publisher( slots) {
// set the default values for the parameter-free default constructor
this.name = ""; // String
this.address = ""; // String
// constructor invocation with arguments
if (arguments.length > 0) {
this.setName( slots.name);
this.setAddress( slots.address);
}
};
由于 setter 可能会抛出约束违反异常,因此构造函数和任何 setter 都应在 try-catch 块中调用,其中 catch 子句负责记录适当的错误消息。
对于每个模型类 C
,我们定义一个类级属性 C.instances
,它以 JSON 表(记录映射)的形式表示应用程序管理的所有 C
实例的集合:此属性最初设置为 {}
。例如,对于模型类 Publisher
,我们定义:
Publisher.instances = {};
我们以类似的方式编码 Book
类
function Book( slots) {
// set the default values for the parameter-free default constructor
this.isbn = ""; // string
this.title = ""; // string
this.year = 0; // number(int)
this.publisher = null; // Publisher
// constructor invocation with a slots argument
if (arguments.length > 0) {
this.setIsbn( slots.isbn);
this.setTitle( slots.title);
this.setYear( slots.year);
if (slots.publisher) this.setPublisher( slots.publisher);
else if (slots.publisherIdRef) this.setPublisher( slots.publisherIdRef); }
}
请注意,Book
构造函数可以以对象引用 slots.publisher
或 ID 引用 slots.publisherIdRef
调用。
2. 编码属性检查
确保 JavaScript 数据模型中指定的所有属性约束都正确编码在其检查函数中,如第 2 部分(验证教程)中所述。错误类在文件 lib/errorTypes.js
中定义。
例如,对于 checkName
操作,我们得到以下代码:
Publisher.checkName = function (n) {
if (!n) {
return new NoConstraintViolation();
} else if (typeof(n) !== "string" || n.trim() === "") {
return new TypeConstraintViolation(
"The name must be a non-empty string!");
} else {
return new NoConstraintViolation();
}
};
请注意,由于 name
属性是 Publisher
的标准 ID 属性,我们只在 checkName
中检查语法约束,并在 checkNameAsId
中检查强制值和唯一性约束,后者调用 checkName
。
Publisher.checkNameAsId = function (n) {
var constraintViolation = Publisher.checkName( n);
if ((constraintViolation instanceof NoConstraintViolation)) {
if (!n) {
return new MandatoryValueConstraintViolation(
"A value for the name must be provided!");
} else if (Publisher.instances[n]) {
constraintViolation = new UniquenessConstraintViolation(
"There is already a publisher record with this name!");
} else {
constraintViolation = new NoConstraintViolation();
}
}
return constraintViolation;
};
由于对于任何标准 ID 属性,我们可能需要在其他类中处理 ID 引用(外键),因此我们需要提供另一个检查函数,名为 checkNameAsIdRef
,用于检查参照完整性约束,如下例所示。
Publisher.checkNameAsIdRef = function (n) { var constraintViolation = Publisher.checkName( n); if ((constraintViolation instanceof NoConstraintViolation)) { if (!Publisher.instances[n]) { constraintViolation = new ReferentialIntegrityConstraintViolation( "There is no publisher record with this name!"); } } return constraintViolation; };
条件 (!Publisher.instances[n])
检查是否存在名称为 n
的出版商对象,然后创建一个 constraintViolation
对象。此参照完整性约束检查由以下 Book.checkPublisher
函数使用。
Book.checkPublisher = function (publisherIdRef) {
var constraintViolation = null;
if (!publisherIdRef) {
constraintViolation = new NoConstraintViolation(); // optional
} else {
// invoke foreign key constraint check
constraintViolation = Publisher.checkNameAsIdRef( publisherIdRef);
}
return constraintViolation;
};
3. 编码属性设置器
将设置器操作编码为(实例级别)方法。在设置器中,调用相应的检查操作,并且只有当检查未检测到任何约束违反时才设置属性。对于引用属性,我们允许使用内部对象引用或 ID 引用调用设置器。通过测试设置器调用中提供的参数是否为对象来解决由此产生的歧义。例如,设置器操作 setPublisher
按以下方式编码。
Book.prototype.setPublisher = function (p) {
var constraintViolation = null;
var publisherIdRef = "";
// a publisher can be given as ...
if (typeof(p) !== "object") { // an ID reference or
publisherIdRef = p;
} else { // an object reference
publisherIdRef = p.name;
}
constraintViolation = Book.checkPublisher( publisherIdRef);
if (constraintViolation instanceof NoConstraintViolation) {
// create the new publisher reference
this.publisher = Publisher.instances[ publisherIdRef];
} else {
throw constraintViolation;
}
};
4. 实施删除策略
对于任何引用属性,我们必须选择并实现在2中讨论的两种可能的删除策略之一,以管理属性范围类的destroy
方法中相应的对象销毁依赖关系。在我们的例子中,我们必须在以下两者之间选择:
-
删除被删除出版商出版的所有书籍;
-
从被删除出版商出版的所有书籍中删除对被删除出版商的引用。
我们选择第二种方案。这在 Publisher.destroy
方法的以下代码中展示,其中所有相关书籍对象 book
的属性 book.publisher
都被清除。
Publisher.destroy = function (name) {
var publisher = Publisher.instances[name];
var book=null, keys=[];
// delete all references to this publisher in book objects
keys = Object.keys( Book.instances);
for (var i=0; i < keys.length; i++) {
book = Book.instances[keys[i]];
if (book.publisher === publisher) delete book.publisher;
}
// delete the publisher record
delete Publisher.instances[name];
console.log("Publisher " + name + " deleted.");
};
5. 序列化和反序列化
序列化方法 convertObj2Row
将带有内部对象引用的类型化对象转换为带有 ID 引用的相应(无类型)记录对象。
Book.prototype.convertObj2Row = function () {
var bookRow = util.cloneObject(this), keys=[];
if (this.publisher) {
// create publisher ID reference
bookRow.publisherIdRef = this.publisher.name;
}
return bookRow;
};
反序列化方法 convertRow2Obj
将带有 ID 引用的(无类型)记录对象转换为带有内部对象引用的相应类型化对象。
Book.convertRow2Obj = function (bookRow) {
var book={}, persKey="";
var publisher = Publisher.instances[bookRow.publisherIdRef];
// replace the publisher ID reference with object reference
delete bookRow.publisherIdRef;
bookRow.publisher = publisher;
try {
book = new Book( bookRow);
} catch (e) {
console.log( e.name + " while deserializing a book row: " + e.message);
}
return book;
};
视图和控制器层
用户界面 (UI) 由一个起始页组成,用于导航到数据管理 UI 页面,每个对象类型对应一个(在我们的示例中为 books.html
和 publishers.html
)。每个数据管理 UI 页面包含 5 个部分,例如管理书籍、列出书籍、创建书籍、更新书籍和删除书籍,并且在任何给定时间只显示其中一个(通过为所有其他部分设置 CSS 属性 display:none
)。
为了初始化数据管理用例,所需数据(所有出版商和书籍记录)从持久存储中加载。这在控制器过程(例如 ctrl/books.js
中的 pl.ctrl.books.manage.initialize
)中执行,代码如下:
pl.ctrl.books.manage = {
initialize: function () {
Publisher.loadAll();
Book.loadAll();
pl.view.books.manage.setUpUserInterface();
}
};
用于管理书籍数据的 initialize
方法加载出版商表和书籍表,因为书籍数据管理 UI 需要为两种对象类型提供选择列表。然后通过 setUpUserInterface
方法设置书籍数据管理选项的菜单。
在“列出对象”用例中显示有关关联对象的信息
在我们的示例中,我们只有一个引用属性 Book::publisher
,它是功能性的。为了在列出书籍用例中显示有关可选出版商的信息,如果存在出版商,则 HTML 表格中相应的单元格将填充出版商的名称。
pl.view.books.list = {
setupUserInterface: function () {
var tableBodyEl = document.querySelector(
"section#Book-R>table>tbody");
var keys = Object.keys( Book.instances);
var row=null, listEl=null, book=null;
tableBodyEl.innerHTML = "";
for (var i=0; i < keys.length; i++) {
book = Book.instances[keys[i]];
row = tableBodyEl.insertRow(-1);
row.insertCell(-1).textContent = book.isbn;
row.insertCell(-1).textContent = book.title;
row.insertCell(-1).textContent = book.year;
row.insertCell(-1).textContent =
book.publisher ? book.publisher.name : "";
}
document.getElementById("Book-M").style.display = "none";
document.getElementById("Book-R").style.display = "block";
}
};
对于多值引用属性,表格单元格将需要填充所有由该属性引用的关联对象的列表。
允许在创建和更新用例中选择关联对象
为了在创建和更新用例中,允许从列表中选择要与当前编辑对象关联的对象,HTML 选择列表(一个 select
元素)通过辅助方法 fillSelectWithOptions
填充关联对象类型的实例。在创建书籍用例中,UI 通过以下过程设置:
pl.view.books.create = {
setupUserInterface: function () {
var formEl = document.querySelector("section#Book-C > form"),
publisherSelectEl = formEl.selectPublisher,
submitButton = formEl.commit;
// define event handlers for responsive validation
formEl.isbn.addEventListener("input", function () {
formEl.isbn.setCustomValidity(
Book.checkIsbnAsId( formEl.isbn.value).message);
});
// set up the publisher selection list
util.fillSelectWithOptions( publisherSelectEl, Publisher.instances, "name");
// define event handler for submitButton click events
submitButton.addEventListener("click", this.handleSubmitButtonClickEvent);
// define event handler for neutralizing the submit event
formEl.addEventListener( 'submit', function (e) {
e.preventDefault();
formEl.reset();
});
// replace the manageBooks form with the Book-C form
document.getElementById("Book-M").style.display = "none";
document.getElementById("Book-C").style.display = "block";
formEl.reset();
},
handleSubmitButtonClickEvent: function () {
...
}
};
当用户点击提交按钮时,所有表单控件值,包括 select
控件的值,都会复制到一个 slots
列表中,该列表在所有表单字段都被检查有效性后用作调用 add
方法的参数,如以下程序清单所示。
handleSubmitButtonClickEvent: function () {
var formEl = document.querySelector("section#Book-C > form");
var slots = {
isbn: formEl.isbn.value,
title: formEl.title.value,
year: formEl.year.value,
publisherIdRef: formEl.selectPublisher.value
};
// check input fields and show constraint violation error messages
formEl.isbn.setCustomValidity( Book.checkIsbnAsId( slots.isbn).message);
/* ... (do the same with title and year) */
// save the input data only if all of the form fields are valid
if (formEl.checkValidity()) {
Book.add( slots);
}
}
“更新书籍”用例的 setupUserInterface
代码类似。
实现单向非功能关联
单向非功能性关联要么是一对多,要么是多对多。在这两种情况下,这种关联都通过多值引用属性来表示或实现。
在本章中,我们将展示
-
如何从包含表示单向非功能关联的多值引用属性的无关联信息设计模型中派生出 JavaScript 数据模型,
-
如何将 JavaScript 数据模型编码成 JavaScript 模型类的形式,
-
如何基于模型代码编写视图和控制器代码。
多值引用属性,例如对象类型 Book
的 authors
属性,允许存储一组对某种类型对象(例如 Author
)的引用。创建类型为 Book
的新对象时,构造函数需要有一个参数,以便为该引用属性提供合适的值。在 JavaScript 中,我们可以允许该值为一组(或列表)内部对象引用或 ID 引用,如以下示例所示。
function Book( slots) {
// set the default values for the parameter-free default constructor
...
this.authors = {}; // map of Author object referencess
...
// constructor invocation with a slots argument
if (arguments.length > 0) {
...
this.setAuthors( slots.authors || slots.authorsIdRef);
...
}
}
请注意,构造函数参数 slots
预计包含 authors
槽或 authorsIdRef
槽。JavaScript 表达式 slots.authors || slots.authorsIdRef
使用析取运算符 ||
,如果 slots
包含对象引用槽 authors
,则求值为 authors
,否则求值为 authorsIdRef
。我们通过检查参数类型来处理属性设置器中产生的歧义,如以下代码片段所示。
Book.prototype.setAuthors = function (a) {
var keys=[], i=0;
this.authors = {};
if (Array.isArray(a)) { // array of ID references
for (i= 0; i < a.length; i++) {
this.addAuthor( a[i]);
}
} else { // map of object references
keys = Object.keys( a);
for (i=0; i < keys.length; i++) {
this.addAuthor( a[keys[i]]);
}
}
};
多值引用属性可以实现为属性,其值为对象引用数组或映射。我们更喜欢使用映射来实现多值引用属性,因为它们保证每个元素都是唯一的,而使用数组则必须防止重复元素。此外,映射的元素可以轻松删除(借助 delete
运算符),而对于数组则需要更多的努力。但要实现列表值引用属性,我们需要使用数组。
我们使用引用对象的标准标识符作为键。如果标准标识符是整数,则必须特别注意将 ID 值转换为字符串以用作键。
构建 JavaScript 数据模型
创建 JavaScript 数据模型的起点是一个信息设计模型,其中显式关联已被引用属性替换。以下模型用于我们的示例应用程序,其中包含多值引用属性 Book::authors
,它表示单向多对多关联 Book-has-Author。
我们现在将展示如何分三步从这个设计模型中派生出 JavaScript 数据模型。
-
为每个非派生属性创建检查操作,以便集中实现属性约束。对于任何引用属性,无论它是单值(如
Book::publisher
)还是多值(如Book::authors
),检查操作(checkPublisher
或checkAuthor
)都必须检查相应的参照完整性约束,这要求所有引用都引用一个现有对象,并且如果属性是强制性的,可能还需要检查强制值约束。 -
为每个非派生单值属性创建一个设置操作。在设置器中,调用相应的检查操作,并且只有当检查未检测到任何约束违反时才设置该属性。
-
为每个非派生多值属性创建添加、移除和设置操作。对于
Book::authors
属性,我们将在Book
类矩形中创建addAuthor
、removeAuthor
和setAuthors
操作。
这导致了以下 JavaScript 数据模型,其中我们只展示了 Book
和 Author
类,而缺失的 Publisher
类与之前相同。
请注意,为简化起见,我们未将数据模型中显示的所有验证检查代码包含在示例应用程序的代码中。
新问题
与处理前面章节中讨论的单向功能关联相比,我们必须处理以下新的技术问题:
-
在模型代码中,我们现在必须处理需要以下内容的多值引用属性:
-
实现一个add和remove操作,以及一个用于借助add操作分配一组引用的setter。
-
在序列化函数
convertObj2Row
中将内部对象引用映射转换为 ID 引用数组,并在反序列化函数convertRow2Obj
中将此类数组转换回内部对象引用映射。
-
-
在用户界面(“视图”)代码中,我们现在必须注意:
-
在“列出对象”用例中显示有关一组关联对象的信息;
-
在“创建对象”和“更新对象”用例中,允许从目标类的所有现有实例列表中选择一组关联对象。
-
最后一个问题,即允许从某个类的所有现有实例列表中选择一组关联对象,通常不能借助 HTML select multiple
表单元素来解决,因为存在可用性问题。每当可选选项集大于某个阈值(由无需滚动即可在屏幕上看到的选项数量定义)时,HTML select multiple
元素就无法再使用,必须使用替代的多选小部件。
编写模型代码
1. 编码添加和删除操作
对于多值引用属性 Book::authors
,我们需要编码操作 addAuthor
和 removeAuthor
。这两个操作都接受一个参数,该参数通过 ID 引用(作者 ID 作为整数或字符串)或内部对象引用来表示作者。addAuthor
的代码如下:
Book.prototype.addAuthor = function (a) {
var constraintViolation=null,
authorIdRef=0, authorIdRefStr="";
// an author can be given as ...
if (typeof( a) !== "object") { // an ID reference or
authorIdRef = parseInt( a);
} else { // an object reference
authorIdRef = a.authorId;
}
constraintViolation = Book.checkAuthor( authorIdRef);
if (authorIdRef &&
constraintViolation instanceof NoConstraintViolation) {
// add the new author reference
authorIdRefStr = String( authorIdRef);
this.authors[ authorIdRefStr] =
Author.instances[ authorIdRefStr];
}
};
removeAuthor
的代码与 addAuthor
类似。
Book.prototype.removeAuthor = function (a) {
var constraintViolation = null;
var authorIdRef = "";
// an author can be given as ID reference or object reference
if (typeof(a) !== "object") authorIdRef = parseInt( a);
else authorIdRef = a.authorId;
constraintViolation = Book.checkAuthor( authorIdRef);
if (constraintViolation instanceof NoConstraintViolation) {
// delete the author reference
delete this.authors[ authorIdRef];
}
};
为了将 ID 引用数组或对象引用映射分配给属性 Book::authors
,setAuthors
方法借助 addAuthor
逐个添加它们。
Book.prototype.setAuthors = function (a) {
var keys=[];
this.authors = {};
if (Array.isArray(a)) { // array of IdRefs
for (i= 0; i < a.length; i++) {
this.addAuthor( a[i]);
}
} else { // map of object references
keys = Object.keys( a);
for (i=0; i < keys.length; i++) {
this.addAuthor( a[keys[i]]);
}
}
};
2. 实施删除策略
对于引用属性 Book::authors
,我们必须在 Author
类的 destroy
方法中实现删除策略。我们必须在以下两者之间做出选择:
-
删除所有由被删除作者(共同)创作的书籍;
-
从所有由被删除作者(共同)创作的书籍中删除对被删除作者的引用。
我们选择第二种方案。这在 Author.destroy
方法的以下代码中展示,其中所有相关书籍对象 book
的作者引用 book.authors[authorKey]
都被删除。
Author.destroy = function (id) {
var authorKey = id.toString(),
author = Author.instances[authorKey],
key="", keys=[], book=null;
// delete all dependent book records
keys = Object.keys( Book.instances);
for (i=0; i < keys.length; i++) {
key = keys[i];
book = Book.instances[key];
if (book.authors[authorKey]) delete book.authors[authorKey];
}
// delete the author record
delete Author.instances[authorKey];
console.log("Author " + author.name + " deleted.");
};
3. 序列化和反序列化
序列化方法 convertObj2Row
将带有内部对象引用的类型化对象转换为带有 ID 引用的相应(无类型)记录对象。
Book.prototype.convertObj2Row = function () {
var bookRow = util.cloneObject(this), keys=[];
// create authors ID references
bookRow.authorsIdRef = [];
keys = Object.keys( this.authors);
for (i=0; i < keys.length; i++) {
bookRow.authorsIdRef.push( parseInt( keys[i]));
}
if (this.publisher) {
// create publisher ID reference
bookRow.publisherIdRef = this.publisher.name;
}
return bookRow;
};
反序列化方法 convertRow2Obj
将带有 ID 引用的(无类型)记录对象转换为带有内部对象引用的相应类型化对象。
Book.convertRow2Obj = function (bookRow) {
var book=null, authorKey="",
publisher = Publisher.instances[bookRow.publisherIdRef];
// replace the "authorsIdRef" array of ID references
// with a map "authors" of object references
bookRow.authors = {};
for (i=0; i < bookRow.authorsIdRef.length; i++) {
authorKey = bookRow.authorsIdRef[i].toString();
bookRow.authors[authorKey] = Author.instances[authorKey];
}
delete bookRow.authorsIdRef;
// replace publisher ID reference with object reference
delete bookRow.publisherIdRef;
bookRow.publisher = publisher;
try {
book = new Book( bookRow);
} catch (e) {
console.log( e.constructor.name +
" while deserializing a book row: " + e.message);
}
return book;
};
编写用户界面代码
在“列出对象”用例中显示有关关联对象的信息
为了在“列出书籍”用例中显示有关书籍作者的信息,HTML 表格中相应的单元格通过辅助函数 util.createListFromMap
填充所有作者姓名的列表。
pl.view.books.list = {
setupUserInterface: function () {
var tableBodyEl = document.querySelector(
"section#Book-R>table>tbody");
var row=null, book=null, listEl=null,
keys = Object.keys( Book.instances);
tableBodyEl.innerHTML = ""; // drop old contents
for (i=0; i < keys.length; i++) {
book = Book.instances[keys[i]];
row = tableBodyEl.insertRow(-1);
row.insertCell(-1).textContent = book.isbn;
row.insertCell(-1).textContent = book.title;
row.insertCell(-1).textContent = book.year;
// create list of authors
listEl = util.createListFromMap(
book.authors, "name");
row.insertCell(-1).appendChild( listEl);
row.insertCell(-1).textContent =
book.publisher ? book.publisher.name : "";
}
document.getElementById("Book-M").style.display = "none";
document.getElementById("Book-R").style.display = "block";
}
};
辅助函数 util.createListFromMap
的代码如下:
createListFromMap: function (aa, displayProp) {
var listEl = document.createElement("ul");
util.fillListFromMap( listEl, aa, displayProp);
return listEl;
},
fillListFromMap: function (listEl, aa, displayProp) {
var keys=[], listItemEl=null;
// drop old contents
listEl.innerHTML = "";
// create list items from object property values
keys = Object.keys( aa);
for (var j=0; j < keys.length; j++) {
listItemEl = document.createElement("li");
listItemEl.textContent = aa[keys[j]][displayProp];
listEl.appendChild( listItemEl);
}
}
允许在创建用例中选择关联对象
为了在“创建书籍”用例中,允许选择多个作者与当前编辑的书籍关联,一个多选列表(带有 multiple="multiple"
的 select
元素),如以下 HTML 代码所示,将填充关联对象类型的实例。
<section id="Book-C" class="UI-Page">
<h1>Public Library: Create a new book record</h1>
<form>
<div class="field">
<label>ISBN: <input type="text" name="isbn" /></label>
</div>
<div class="field">
<label>Title: <input type="text" name="title" /></label>
</div>
<div class="field">
<label>Year: <input type="text" name="year" /></label>
</div>
<div class="select-one">
<label>Publisher: <select name="selectPublisher"></select></label>
</div>
<div class="select-many">
<label>Authors:
<select name="selectAuthors" multiple="multiple"></select>
</label>
</div>
<div class="button-group">
<button type="submit" name="commit">Save</button>
<button type="button" onclick="pl.view.books.manage.refreshUI()">
Back to menu</button>
</div>
</form>
</section>
“创建书籍”用户界面通过辅助方法 fillSelectWithOptions
填充用于选择作者和出版商的选择列表来设置,如以下程序清单所示。
pl.view.books.create = {
setupUserInterface: function () {
var formEl = document.querySelector("section#Book-C > form"),
publisherSelectEl = formEl.selectPublisher,
submitButton = formEl.commit;
// define event handlers for form field input events
...
// set up the (multiple) authors selection list
util.fillSelectWithOptions( authorsSelectEl,
Author.instances, "authorId", {displayProp:"name"});
// set up the publisher selection list
util.fillSelectWithOptions( publisherSelectEl,
Publisher.instances, "name");
...
},
handleSubmitButtonClickEvent: function () {
...
}
};
当用户点击提交按钮时,所有表单控件值,包括任何单选 select
控件的值,都会复制到相应的 slots
记录变量中,该变量在所有表单字段都已检查有效性后用作调用 add
方法的参数。在调用 add
之前,我们必须首先从多作者选择列表的选定选项中创建(在 authorsIdRef
槽中)一个作者 ID 引用列表,如以下程序清单所示。
handleSubmitButtonClickEvent: function () {
var i=0,
formEl = document.querySelector("section#Book-C > form"),
selectedAuthorsOptions = formEl.selectAuthors.selectedOptions;
var slots = {
isbn: formEl.isbn.value,
title: formEl.title.value,
year: formEl.year.value,
authorsIdRef: [],
publisherIdRef: formEl.selectPublisher.value
};
// check all input fields
...
// save the input data only if all of the form fields are valid
if (formEl.checkValidity()) {
// construct the list of author ID references
for (i=0; i < selectedAuthorsOptions.length; i++) {
slots.authorsIdRef.push( selectedAuthorsOptions[i].value);
}
Book.add( slots);
}
}
“更新书籍”用例将在下一节中讨论。
允许在更新用例中选择关联对象
不幸的是,在实际用例中,多重选择控件并不真正适用于显示和允许维护关联作者集合,因为当我们有数百甚至数千名作者时,它渲染选择的方式在视觉上过于分散。因此,我们最好使用一种特殊的关联列表小部件,该小部件允许向关联对象列表添加(和删除)对象,如8中所述。为了展示此小部件如何取代上一节中讨论的多重选择列表,我们现在在更新书籍用例中使用它。
为了在更新书籍用例中允许维护与当前编辑书籍关联的作者集合,一个如下 HTML 代码所示的关联列表小部件将填充 Author
类的实例。
<section id="Book-U" class="UI-Page">
<h1>Public Library: Update a book record</h1>
<form>
<div class="select-one">
<label>Select book: <select name="selectBook"></select></label>
</div>
<div class="field">
<label>ISBN: <output name="isbn"></output></label>
</div>
<div class="field">
<label>Title: <input type="text" name="title" /></label>
</div>
<div class="field">
<label>Year: <input type="text" name="year" /></label>
</div>
<div class="select-one">
<label>Publisher: <select name="selectPublisher"></select></label>
</div>
<div class="widget">
<label for="updBookSelectAuthors">Authors: </label>
<div class="MultiSelectionWidget" id="updBookSelectAuthors"></div>
</div>
<div class="button-group">
<button type="submit" name="commit">Save</button>
<button type="button"
onclick="pl.view.books.manage.refreshUI()">Back to menu</button>
</div>
</form>
</section>
“更新书籍”用户界面(在以下所示的 setupUserInterface
过程中)通过填充以下内容进行设置:
-
用于选择要更新的书籍的选择列表,借助辅助方法
fillSelectWithOptions
,以及 -
用于更新出版商的选择列表,借助辅助方法
fillSelectWithOptions
,
而用于更新书籍关联作者的关联列表小部件仅在选择要更新的书籍时才被填充(在 handleSubmitButtonClickEvent
中)。
pl.view.books.update = {
setupUserInterface: function () {
var formEl = document.querySelector("section#Book-U > form"),
bookSelectEl = formEl.selectBook,
publisherSelectEl = formEl.selectPublisher,
submitButton = formEl.commit;
// set up the book selection list
util.fillSelectWithOptions( bookSelectEl, Book.instances,
"isbn", {displayProp:"title"});
bookSelectEl.addEventListener("change", this.handleBookSelectChangeEvent);
... // define event handlers for title and year input events
// set up the associated publisher selection list
util.fillSelectWithOptions( publisherSelectEl, Publisher.instances, "name");
// define event handler for submitButton click events
submitButton.addEventListener("click", this.handleSubmitButtonClickEvent);
// define event handler for neutralizing the submit event and reseting the form
formEl.addEventListener( 'submit', function (e) {
var authorsSelWidget = document.querySelector(
"section#Book-U > form .MultiSelectionWidget");
e.preventDefault();
authorsSelWidget.innerHTML = "";
formEl.reset();
});
document.getElementById("Book-M").style.display = "none";
document.getElementById("Book-U").style.display = "block";
formEl.reset();
},
当选择了一本要更新的书籍时,表单输入字段 isbn
、title
和 year
,以及用于更新出版商的 select
控件,都将被分配来自所选书籍的相应值,并设置关联作者选择小部件。
handleBookSelectChangeEvent: function () {
var formEl = document.querySelector("section#Book-U > form"),
authorsSelWidget = formEl.querySelector(
".MultiSelectionWidget"),
key = formEl.selectBook.value,
book=null;
if (key !== "") {
book = Book.instances[key];
formEl.isbn.value = book.isbn;
formEl.title.value = book.title;
formEl.year.value = book.year;
// set up the associated authors selection widget
util.createMultiSelectionWidget( authorsSelWidget,
book.authors, Author.instances, "authorId", "name");
// assign associated publisher to index of select element
formEl.selectPublisher.selectedIndex =
(book.publisher) ? book.publisher.index : 0;
} else {
formEl.reset();
formEl.selectPublisher.selectedIndex = 0;
}
},
当用户更新一些值后,最终点击提交按钮时,所有表单控件值,包括用于分配出版商的单选 select
控件的值,都会复制到 slots
记录变量中相应的槽位,该变量在所有值都已检查有效性后用作调用 update
方法的参数。在调用 update
之前,一个要添加的作者 ID 引用列表和另一个要删除的作者 ID 引用列表(在 authorsIdRefToAdd
和 authorsIdRefToRemove
槽位中)将从关联作者选择小部件中记录的更新中创建,并借助 classList
值,如以下程序清单所示。
handleSubmitButtonClickEvent: function () {
var i=0, assocAuthorListItemEl=null,
authorsIdRefToAdd=[], authorsIdRefToRemove=[],
formEl = document.querySelector("section#Book-U > form"),
authorsSelWidget =
formEl.querySelector(".MultiSelectionWidget"),
authorsAssocListEl = authorsSelWidget.firstElementChild;
var slots = { isbn: formEl.isbn.value,
title: formEl.title.value,
year: formEl.year.value,
publisherIdRef: formEl.selectPublisher.value
};
// commit the update only if all of the form fields values are valid
if (formEl.checkValidity()) {
// construct authorsIdRefToAdd and authorsIdRefToRemove
for (i=0; i < authorsAssocListEl.children.length; i++) {
assocAuthorListItemEl = authorsAssocListEl.children[i];
if (assocAuthorListItemEl.classList.contains("removed")) {
authorsIdRefToRemove.push(
assocAuthorListItemEl.getAttribute("data-value"));
}
if (assocAuthorListItemEl.classList.contains("added")) {
authorsIdRefToAdd.push(
assocAuthorListItemEl.getAttribute("data-value"));
}
}
// if the add/remove list is non-empty create a corresponding slot
if (authorsIdRefToRemove.length > 0) {
slots.authorsIdRefToRemove = authorsIdRefToRemove;
}
if (authorsIdRefToAdd.length > 0) {
slots.authorsIdRefToAdd = authorsIdRefToAdd;
}
Book.update( slots);
}
}
运行应用
您可以从我们的服务器运行示例应用程序。
可能的变体和扩展
部分-整体关联
部分-整体关联是一种表示部分类型和整体类型之间关系的关联。它的实例是两个对象之间的部分-整体关系,其中一个对象是另一个对象的一部分。部分-整体关联有不同的类型,例如在 UML 中定义的聚合和组合。聚合和组合在stackoverflow 回答中进行了解释,其中指出,一种普遍的误解是,组合意味着整体与其部分之间存在生命周期依赖关系,使得部分不能脱离整体而存在。
我们计划撰写一篇后续教程,涵盖部分-整体关联。
注意事项
请注意,在本教程中,我们假设所有应用程序数据都可以加载到主内存中(例如所有书籍数据都加载到映射 Book.instances
中)。这种方法仅适用于较小数据库的本地数据存储,例如,数据量不超过 2 MB,大致对应于 10 个表,平均每个表有 1000 行,每行平均大小为 200 字节。当需要管理更大的数据库,或者当数据远程存储时,就不再可能将所有表的整个内容加载到主内存中,但我们必须使用一种只加载表部分内容的技术。
我们仍然在模型层中包含了每个类和每个属性的重复代码结构(称为样板代码),用于约束验证(检查和设置器),以及每个类的数据存储管理方法add
、update
和destroy
。虽然为了学习应用程序开发而编写几次这些代码是很好的,但当您在实际项目中工作时,您不会想一遍又一遍地编写它们。在本教程系列的第 6 部分中,我们将介绍一种方法,如何将这些方法以通用形式放入一个名为mODELcLASS
的元类中,以便它们可以在应用程序的所有模型类中重用。
历史
- 2015年2月25日,文档创建。