65.9K
CodeProject 正在变化。 阅读更多。
Home

JavaScript 前端 Web 应用教程 第二部分:添加约束验证

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (17投票s)

2014 年 4 月 11 日

CPOL

26分钟阅读

viewsIcon

48189

downloadIcon

725

学习如何使用纯 JavaScript 开发具有响应式 (HTML5) 约束验证的前端 Web 应用。不要使用任何框架/库(如 jQuery 或 Angular),它们只会创建黑盒依赖和开销,并阻止你学习如何自己完成。

引言

本文摘自一个关于使用纯 JavaScript 工程前端 Web 应用程序的五部分教程,该教程作为开放访问书籍《使用纯 JavaScript 构建前端 Web 应用》提供。它展示了如何使用纯 JavaScript(无框架或库)构建具有响应式 (HTML5) 约束验证的前端 Web 应用。如果你想了解它是如何工作的,可以运行本文讨论的验证应用

前端 Web 应用可以由任何 Web 服务器提供,但它是在用户的计算机设备(智能手机、平板电脑或笔记本电脑)上执行的,而不是在远程 Web 服务器上执行的。通常,但并非必须,前端 Web 应用程序是单用户应用程序,不与其他用户共享。

本教程中讨论的数据管理应用仅包含完整应用所需总体功能中的验证部分。它只处理一种对象类型(“书籍”)并支持四种标准数据管理操作(Create/Read/Update/Delete),但需要通过添加应用总体功能的其他重要部分来增强

  • 第 3 部分:处理枚举

  • 第 4 部分:管理单向关联,将作者和出版商分配给书籍

  • 第 5 部分:管理双向关联,也将书籍分配给作者和出版商

  • 第 6 部分:处理子类型(继承)关系在类层次结构中。

本教程的第 1 部分可在 CodeProject 文章《JavaScript 前端 Web 应用教程第 1 部分:七步构建最小应用》中找到。我们在第 1 部分中介绍的最小应用仅限于支持数据管理应用的最小功能。例如,它不关心阻止用户向应用的数据库输入无效数据。在本教程的第二部分中,我们将展示如何在 JavaScript 模型类中表达完整性约束,以及如何在应用的模型部分和 HTML5 用户界面中执行约束验证。

背景

如果你还没读过,可能首先想阅读本教程的第 1 部分:《七步构建最小应用》。

为了更好地理解最重要的完整性约束类型,请阅读我的 CodeProject 文章《完整性约束和数据验证》。

编写应用程序

我们再次考虑本教程第 1 部分中讨论的单类数据管理问题。因此,我们应用的目的仍然是管理书籍信息。但现在我们还要考虑管理书籍数据的数据完整性规则,或完整性约束。它们可以在 UML 类模型中表达,如下图所示

图 1. 单类应用的信息设计模型

在这个简单模型中,表达了以下约束

  1. 由于 `isbn` 属性被声明为标准标识符,因此它是强制的唯一的

  2. `isbn` 属性具有模式约束,要求其值匹配 ISBN-10 格式,该格式仅允许 10 位字符串或 9 位字符串后跟“X”。

  3. `title` 属性是强制的,如其多重性表达式 [1] 所示,并具有字符串长度约束,要求其值最多 50 个字符。

  4. `year` 属性是强制的,并且具有区间约束,但形式特殊,因为最大值不固定,而是由日历函数 `nextYear()` 提供,我们将其实现为实用函数。

请注意,`edition` 属性不是强制的,而是可选的,如其多重性表达式 [0..1] 所示。除了这些约束之外,还有通过将数据类型 `NonEmptyString` 分配给 `isbn` 和 `title`,`Integer` 分配给 `year`,以及 `PositiveInteger` 分配给 `edition` 所定义的隐式范围约束。在我们的纯 JavaScript 方法中,所有这些属性约束都编码在模型类中,位于属性特定的检查函数中。

使用 HTML5 表单验证 API

我们只使用 HTML5 表单验证 API 的两个方法来验证应用程序基于 HTML 表单的用户界面中的约束。其中第一个是 `setCustomValidity`,它允许通过为其分配一个空字符串或一个非空消息来将表单输入字段标记为有效或无效。第二个方法 `checkValidity` 在表单上调用,并测试所有输入字段是否具有有效值。
请注意,在我们的方法中,不需要使用新的 HTML5 验证属性,例如 `required`,因为我们借助 `setCustomValidity` 和我们的属性检查函数执行所有验证,如下所述。

有关 HTML5 表单验证 API 的更多信息,请参阅此 Mozilla 教程或此 HTML5Rocks 教程

新问题

与第 1 部分中讨论的最小应用相比,我们必须处理许多新问题

  1. 在模型代码中,我们必须处理

    1. 为每个属性添加一个检查函数,用于验证为该属性定义的约束,以及一个设置器方法,用于调用该检查函数并用于设置属性的值,
    2. 在保存任何新数据之前执行约束验证。
  2. 在用户界面(“视图”)代码中,我们必须处理

    1. 使用 CSS 规则样式化用户界面,
    2. 对用户输入进行响应式验证,以便向用户提供即时反馈,
    3. 在表单提交时进行验证,以防止向模型层提交有缺陷的数据。
    为了改进视图代码的分解,我们引入了一个实用方法(在 `lib/util.js` 中),该方法用 `option` 元素填充 `select` 表单控件,其内容从关联数组(如 `Book.instances`)中检索。该方法在 `updateBook` 和 `deleteBook` 用例的 `setupUserInterface` 方法中使用。

在用户输入时检查用户界面中的约束对于向用户提供即时反馈非常重要。但仅在用户界面中执行约束验证是不够安全的,因为在分布式 Web 应用程序中,用户界面在前端设备的 Web 浏览器中运行,而应用程序的数据由远程 Web 服务器上的后端组件管理,这可能会被规避。因此,我们需要两层约束验证,首先在用户界面中,然后是模型代码中负责数据存储的部分。

我们解决这个问题的方法是将约束验证代码保存在模型类中的特殊检查函数中,并在用户输入和表单提交时在用户界面中调用这些函数,以及通过调用设置器在模型类的“添加”和“更新”数据管理方法中调用它们。请注意,某些关系(如参照完整性)约束也可能通过删除操作被违反,但在我们的单类示例中,我们无需考虑这一点。

创建 JavaScript 数据模型

以上图 1 所示的信息设计模型为起点,我们通过以下步骤创建 JavaScript 数据模型

  1. 为每个非派生属性创建检查操作,以便集中实现设计模型中为属性定义的所有约束。对于标准标识符(或主键)属性,例如 `Book::isbn`,需要两个检查操作

    1. 一个检查操作,例如 `checkIsbn`,用于检查标识符属性的所有基本约束,除了强制值和唯一性约束。

    2. 一个检查操作,例如 `checkIsbnAsId`,除了基本约束之外,还用于检查标识符属性所需的强制值和唯一性约束。

    `checkIsbnAsId` 函数在创建书籍表单中 `isbn` 表单字段的用户输入时调用,并在 `setIsbn` 方法中调用,而 `checkIsbn` 函数可用于测试值是否满足为 ISBN 定义的语法约束。

  2. 为每个非派生单值属性创建一个设置器操作。在设置器中,调用相应的检查操作,并且仅当检查未检测到任何约束违反时才设置属性。

这导致了下图映射箭头右侧所示的 JavaScript 数据模型

图 2. 从信息设计模型派生 JavaScript 数据模型

JavaScript 数据模型通过为每个属性添加检查和设置器来扩展设计模型。请注意,检查函数的名称带有下划线,因为这是 UML 中类级(“静态”)方法的约定。

设置文件夹结构并创建四个初始文件

我们简单应用的 MVC 文件夹结构通过添加两个文件夹来扩展最小应用的结构,`css` 用于添加 CSS 文件 `main.css`,`lib` 用于添加通用函数库 `browserShims.js` 和 `util.js`。因此,我们得到了包含四个初始文件的以下文件夹结构

publicLibrary
  css
    main.css
  lib
    browserShims.js
    errorTypes.js
    util.js
  src
    ctrl
    model
    view
  index.html

我们将在以下各节讨论这五个初始文件的内容。

1. 使用 CSS 样式化用户界面

我们借助 Yahoo 提供的 CSS 库 Pure 来样式化 UI。我们只使用 Pure 的基本样式,其中包括 normalize.css 的浏览器样式规范化,以及其表单样式。此外,我们在 `main.css` 中为 `table` 和 `menu` 元素定义了自己的样式规则。

2. 在库文件中提供通用的与应用程序无关的代码

通用的与应用程序无关的代码包括实用函数和 JavaScript 修复。我们将三个文件添加到 `lib` 文件夹中

  1. `util.js` 包含一些实用函数的定义,例如 `isNonEmptyString(x)` 用于测试 `x` 是否为非空字符串。

  2. `browserShims.js` 包含针对不支持字符串 trim 函数的旧浏览器的定义(该函数仅在 2009 年定义的 ECMAScript Edition 5 中添加到 JavaScript)。其他最近定义的函数(例如 `querySelector` 和 `classList`)的更多浏览器 shim 也可以添加到 `browserShims.js` 中。

  3. `errorTypes.js` 定义了错误(或异常)类型的通用类:NoConstraintViolation、MandatoryValueConstraintViolation、RangeConstraintViolation、IntervalConstraintViolation、PatternConstraintViolation、UniquenessConstraintViolation、OtherConstraintViolation。

3. 创建一个起始页

应用的起始页首先通过加载 Pure CSS 基础文件(来自 Yahoo 网站)和我们的 `main.css` 文件(通过第 6 和第 7 行的两个 `link` 元素)来处理样式,然后加载以下 JavaScript 文件(第 8-12 行)

  1. 来自 `lib` 文件夹的 `browserShims.js` 和 `util.js`,在上一节中讨论过,

  2. 来自 `src/ctrl` 文件夹的 `initialize.js`,定义了应用程序的 MVC 命名空间,如第 1 部分(我们的最小应用程序教程)中所述。

  3. 来自 `lib` 文件夹的 `errorTypes.js`,定义异常类。

  4. 来自 `src/model` 文件夹的 `Book.js`,一个提供数据管理和其他功能的模型类文件。

应用的起始页 `index.html`。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>JS front-end Validation App Example</title>
    <link rel="stylesheet" type="text/css" 
        href="http://yui.yahooapis.com/combo?pure/0.3.0/base-min.css" />
    <link rel="stylesheet" type="text/css" href="css/main.css" /> 
    <script src="lib/browserShims.js"></script>
    <script src="lib/util.js"></script>
    <script src="lib/errorTypes.js"></script>
    <script src="src/ctrl/initialize.js"></script>
    <script src="src/model/Book.js"></script>
  </head>
  <body>
    <h1>Public Library</h1>
    <h2>Validation Example App</h2>
    <p>This app supports the following operations:</p>
    <menu>
      <li><a href="listBooks.html"><button type="button">List all books</button></a></li>
      <li><a href="createBook.html"><button type="button">Add a new book</button></a></li>
      <li><a href="updateBook.html"><button type="button">Update a book</button></a></li>
      <li><a href="deleteBook.html"><button type="button">Delete a book</button></a></li>
      <li><button type="button" onclick="Book.clearData()">Clear database</button></li>
      <li><button type="button" onclick="Book.createTestData()">Create test data</button></li>
    </menu>
</body>
</html> 

编写模型代码

如何编码 JavaScript 数据模型

图 2 右侧所示的 JavaScript 数据模型可以逐步编码,以获取我们的 JavaScript 前端应用程序的模型层代码。这些步骤总结在下一节中。

1. 总结

  1. 将模型类编码为 JavaScript 构造函数。

  2. 将检查函数编码为类级别(“静态”)方法,例如 `checkIsbn` 或 `checkTitle`。确保属性的所有约束(如 JavaScript 数据模型中指定)都在检查函数中正确编码。

  3. 将设置器操作编码为(实例级别)方法,例如 `setIsbn` 或 `setTitle`。在设置器中,调用相应的检查操作,并且只有当检查未检测到任何约束违反时才设置属性。

  4. 编码添加和删除操作(如果有的话)。

  5. 编码任何其他操作。

这些步骤将在以下章节中更详细地讨论。

2. 将模型类编码为构造函数

类 `Book` 通过同名 `Book` 的相应 JavaScript 构造函数进行编码,以便其所有(非派生)属性都从构造函数参数 `slots` 的相应键值槽中获取值。

function Book( slots) {
  // assign default values
  this.isbn = "";   // string
  this.title = "";  // string
  this.year = 0;    // number (int)
  // assign properties only if the constructor is invoked with an argument
  if (arguments.length > 0) {
    this.setIsbn( slots.isbn); 
    this.setTitle( slots.title); 
    this.setYear( slots.year);
    if (slots.edition) this.setEdition( slots.edition);  // optional attribute
  }
};

在构造函数体中,我们首先为类属性分配默认值。当构造函数作为默认构造函数(无参数)调用,或者只带部分参数调用时,将使用这些值。在注释中指示属性的范围很有帮助。这需要根据下表将信息设计模型的平台独立数据类型映射到相应的隐式 JavaScript 数据类型。

平台独立数据类型 JavaScript 数据类型
字符串 字符串
整数 数字 (int)
十进制 数字 (float)
布尔值 布尔值
日期 日期

由于设置器可能会抛出约束违反错误,因此构造函数和任何设置器都应该在 try-catch 块中调用,其中 catch 子句负责处理错误(至少记录适当的错误消息)。

与最小应用一样,我们添加了一个类级别属性 `Book.instances`,它以关联数组的形式表示应用程序管理的所有 Book 实例的集合。

Book.instances = {}; 

3. 编码属性检查

以类级别(“静态”)方法的形式编码属性检查函数。在 JavaScript 中,这意味着将它们定义为构造函数的函数槽,例如 `Book.checkIsbn`。请确保 JavaScript 数据模型中指定的所有属性约束都在其检查函数中正确编码。这尤其涉及到标准标识符声明(带有 `«stdid»`)所隐含的强制值和唯一性约束,以及所有多重性为 1 的属性的强制值约束(如果没有显示多重性,则为默认值)。如果任何约束被违反,则返回实例化上述错误类之一并在 `lib/errorTypes.js` 文件中定义的错误对象。

例如,对于 `checkIsbn` 操作,我们得到以下代码

Book.checkIsbn = function (id) {
  if (!id) {
    return new NoConstraintViolation();
  } else if (typeof(id) !== "string" || id.trim() === "") {
    return new RangeConstraintViolation("The ISBN must be a non-empty string!");
  } else if (!/\b\d{9}(\d|X)\b/.test( id)) {
    return new PatternConstraintViolation(
        'The ISBN must be a 10-digit string or a 9-digit string followed by "X"!');
  } else {
    return new NoConstraintViolation();
  }
};

请注意,由于 `isbn` 是 `Book` 的标准标识符属性,我们只在 `checkIsbn` 中检查语法约束,但在 `checkIsbnAsId` 中检查强制值和唯一性约束,后者本身首先调用 `checkIsbn`。

Book.checkIsbnAsId = function (id) {
  var constraintViolation = Book.checkIsbn( id);
  if ((constraintViolation instanceof NoConstraintViolation)) {
    if (!id) {
      constraintViolation = new MandatoryValueConstraintViolation(
          "A value for the ISBN must be provided!");
    } else if (Book.instances[id]) {  
      constraintViolation = new UniquenessConstraintViolation(
          "There is already a book record with this ISBN!");
    } else {
      constraintViolation = new NoConstraintViolation();
    } 
  }
  return constraintViolation;
};

4. 编码属性设置器

将设置器操作编码为(实例级)方法。在设置器中,调用相应的检查函数,并且只有当检查未检测到任何约束违反时才设置属性。否则,抛出检查函数返回的约束违反错误对象。例如,`setIsbn` 操作的编码方式如下

Book.prototype.setIsbn = function (id) {
  var validationResult = Book.checkIsbnAsId( id);
  if (validationResult instanceof NoConstraintViolation) {
    this.isbn = id;
  } else {
    throw validationResult;
  }
};

其他属性(`title`、`year` 和 `edition`)也有类似的设置器。

5. 添加序列化函数

拥有一个针对类结构定制的序列化函数很有帮助,这样序列化对象的结果就是对象的易于阅读的字符串表示,显示其所有相关信息项。按照惯例,这些函数被称为 `toString()`。对于 `Book` 类,我们使用以下代码

Book.prototype.toString = function () {
  return "Book{ ISBN:" + this.isbn + ", title:" + 
      this.title + ", year:" + this.year +"}"; 
};

6. 数据管理操作

除了以带有属性定义、检查器和设置器以及 `toString()` 函数的构造函数形式定义模型类之外,我们还需要将以下数据管理操作定义为模型类的类级方法

  1. `Book.convertRow2Obj` 和 `Book.loadAll` 用于从持久数据存储中加载所有托管的 Book 实例。

  2. `Book.saveAll` 用于将所有托管的 Book 实例保存到持久数据存储中。

  3. `Book.add` 用于创建新的 Book 实例。

  4. `Book.update` 用于更新现有 Book 实例。

  5. `Book.destroy` 用于删除 Book 实例。

  6. `Book.createTestData` 用于创建一些示例书籍记录作为测试数据。

  7. `Book.clearData` 用于清除书籍数据存储。

所有这些方法本质上与我们在第 1 部分中讨论的最小应用中的代码相同,只是现在

  1. 我们可能需要在 `Book.convertRow2Obj`、`Book.add`、`Book.update` 和 `Book.createTestData` 过程中,在合适的 try-catch 块中捕获约束违反;并且

  2. 我们可以使用 `toString()` 函数在状态和错误消息中序列化对象。

请注意,对于更改操作 `add` 和 `update`,我们需要实现全有或全无策略:一旦某个属性存在约束违反,则不得创建新对象,也不得执行受影响对象的(部分)更新。

当在 `Book.add` 中调用 `new Book(...)` 时检测到某个设置器中存在约束违反时,对象创建尝试失败,并在第 6 行创建约束违反错误消息。否则,新的书籍对象将被添加到 `Book.instances` 中,并在第 10 和第 11 行创建状态消息,如以下程序清单所示

Book.add = function (slots) {
  var book = null;
  try {
    book = new Book( slots);
  } catch (e) {
    console.log( e.name +": "+ e.message);
    book = null;
  }
  if (book) {
    Book.instances[book.isbn] = book;
    console.log( book.toString() + " created!");
  }
};

同样,当在 `Book.update` 中调用的某个设置器中检测到约束违反时,将创建约束违反错误消息(在第 16 行),并恢复对象的先前状态(在第 19 行)。否则,将创建状态消息(在第 23 或第 25 行),如以下程序清单所示

Book.update = function (slots) {
  var book = Book.instances[slots.isbn],
      noConstraintViolated = true,
      updatedProperties = [],
      objectBeforeUpdate = util.cloneObject( book);
  try {
    if (book.title !== slots.title) {
      book.setTitle( slots.title);
      updatedProperties.push("title");
    }
    if (book.year !== parseInt( slots.year)) {
      book.setYear( slots.year);
      updatedProperties.push("year");
    }
    if (slots.edition && book.edition !== parseInt(slots.edition)) {
        book.setEdition( slots.edition);
        updatedProperties.push("edition");
    }
  } catch (e) {
    console.log( e.name +": "+ e.message);
    noConstraintViolated = false;
    // restore object to its state before updating
    Book.instances[slots.isbn] = objectBeforeUpdate;
  }
  if (noConstraintViolated) {
    if (updatedProperties.length > 0) {
      console.log("Properties " + updatedProperties.toString() +
          " modified for book " + slots.isbn);
    } else {
      console.log("No property value changed for book " + slots.isbn + " !");
    }
  }
};

视图和控制器层

用户界面 (UI) 由一个起始页 `index.html` 组成,该页面允许用户通过导航到应用程序文件夹中相应的 UI 页面(如 `listBooks.html` 或 `createBook.html`)来选择其中一个数据管理操作。起始页 `index.html` 已在上面讨论过。

加载 Pure 基础样式表和我们自己的 `main.css` 中的 CSS 设置后,我们首先加载一些浏览器 shim 和实用函数。然后我们在 `src/ctrl/initialize.js` 中初始化应用程序,并继续加载 `lib/errorTypes.js` 中定义的错误类和模型类 `Book`。

我们以按钮的形式渲染数据管理菜单项。为简单起见,我们直接从按钮的 `onclick` 事件处理程序属性调用 `Book.clearData()` 和 `Book.createTestData()` 方法。但请注意,通常最好使用 `addEventListener(...)` 注册此类事件处理函数,就像我们在所有其他情况下所做的那样。

1. 数据管理UI页面

每个数据管理 UI 页面都加载与上面讨论的起始页 `index.html` 相同的基本 CSS 和 JavaScript 文件。此外,它还加载两个特定于用例的视图和控制器文件 `src/view/`useCase`.js` 和 `src/ctrl/`useCase`.js`,然后添加一个用例初始化函数(例如 `pl.ctrl.listBooks.initialize`)作为页面加载事件的事件监听器,该函数负责在 UI 页面加载后初始化用例。

对于“列出书籍”用例,我们在 `listBooks.html` 中得到以下代码

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
  <meta charset="UTF-8" />
  <title>JS front-end Validation App Example</title>
  <link rel="stylesheet" href="http://yui.yahooapis.com/pure/0.3.0/pure-min.css" />
  <link rel="stylesheet" href="css/main.css" /> 
  <script src="lib/browserShims.js"></script>
  <script src="lib/util.js"></script>
  <script src="lib/errorTypes.js"></script>
  <script src="src/ctrl/initialize.js"></script>
  <script src="src/model/Book.js"></script>
  <script src="src/view/listBooks.js"></script>
  <script src="src/ctrl/listBooks.js"></script>
  <script>
    window.addEventListener("load", pl.ctrl.listBooks.initialize);
  </script>
</head>
<body>
  <h1>Public Library: List all books</h1>
  <table id="books">
    <thead>
      <tr><th>ISBN</th><th>Title</th><th>Year</th><th>Edition</th></tr>
    </thead>
    <tbody></tbody>
  </table>
  <nav><a href="index.html">Back to main menu</a></nav>
</body>
</html>

对于“创建图书”用例,我们在 `createBook.html` 中获得以下代码

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
  <meta charset="UTF-8" />
  <title>JS front-end Validation App Example</title>
  <link rel="stylesheet" href="http://yui.yahooapis.com/combo?pure/0.3.0/base-
       min.css&pure/0.3.0/forms-min.css" />
  <link rel="stylesheet" href="css/main.css" /> 
  <script src="lib/browserShims.js"></script>
  <script src="lib/util.js"></script>
  <script src="lib/errorTypes.js"></script>
  <script src="src/ctrl/initialize.js"></script>
  <script src="src/model/Book.js"></script>
  <script src="src/view/createBook.js"></script>
  <script src="src/ctrl/createBook.js"></script>
  <script>
    window.addEventListener("load", pl.ctrl.createBook.initialize);
  </script>
  </head>
  <body>
  <h1>Public Library: Create a new book record</h1>
  <form id="Book" class="pure-form pure-form-aligned">
    <div class="pure-control-group">
      <label for="isbn">ISBN</label>
      <input id="isbn" name="isbn" />
    </div>
    <div class="pure-control-group">
      <label for="title">Title</label>
      <input id="title" name="title" />
    </div>
    <div class="pure-control-group">
      <label for="year">Year</label>
      <input id="year" name="year" />
    </div>
    <div class="pure-control-group">
      <label for="edition">Edition</label>
      <input id="edition" name="edition" />
    </div>
    <div class="pure-controls">
      <p><button type="submit" name="commit">Save</button></p>
      <nav><a href="index.html">Back to main menu</a></nav>
    </div>
  </form>
</body>
</html>

请注意,为了样式化 `createBook.html` 以及 `updateBook.html` 和 `deleteBook.html` 中的表单元素,我们使用了 Pure CSS 表单样式。这需要为包含表单控件的表单 `div` 元素的 `class` 属性分配特定值,例如“pure-control-group”。我们必须使用显式标签(`label` 元素的 `for` 属性引用 `input` 元素的 `id`),因为 Pure 不支持 `label` 元素包含 `input` 元素的隐式标签。

2. 初始化应用

要初始化应用程序,必须定义其命名空间和 MVC 子命名空间。对于我们的示例应用程序,主命名空间定义为 `pl`,表示“公共图书馆”,其三个子命名空间 `model`、`view` 和 `ctrl` 最初是空对象

var pl = { model:{}, view:{}, ctrl:{} }; 

我们将此代码放在 `ctrl` 文件夹中的 `initialize.js` 文件中。

3. 初始化数据管理用例

要初始化数据管理用例,必须从持久存储中加载所需数据并设置 UI。这是在控制器文件 `ctrl/createBook.js` 中使用以下代码定义的控制器程序 `pl.ctrl.createBook.initialize` 和 `pl.ctrl.createBook.loadData` 完成的

pl.ctrl.createBook = {
  initialize: function () {
    pl.ctrl.createBook.loadData();
    pl.view.createBook.setupUserInterface();
  },
  loadData: function () {
    Book.loadAll();
  }
};

所有其他数据管理用例(读取/列表、更新、删除)都以相同的方式处理。

4. 设置用户界面

为了设置数据管理用例的用户界面,我们必须将“列出书籍”的情况与其他用例(创建、更新、删除)区分开来。后两者需要使用 HTML 表单并将事件处理程序附加到表单控件,而“列出书籍”的情况我们只需要渲染一个显示所有书籍的表格,如 `view/listBooks.js` 的以下程序清单所示

pl.view.listBooks = {
  setupUserInterface: function () {
    var tableBodyEl = document.querySelector("table#books>tbody");
    var i=0, book=null, row={}, key="", keys = Object.keys( Book.instances);
    for (i=0; i < keys.length; i++) {
      key = keys[i];
      book = Book.instances[key];
      row = tableBodyEl.insertRow(-1);
      row.insertCell(-1).textContent = book.isbn;
      row.insertCell(-1).textContent = book.title;
      row.insertCell(-1).textContent = book.year;
      if (book.edition) row.insertCell(-1).textContent = book.edition;
    }
  }
};

对于创建、更新和删除用例,我们需要将以下事件处理程序附加到表单控件

  1. 一个函数,例如 `handleSubmitButtonClickEvent`,用于处理用户单击保存/提交按钮时发生的事件,

  2. 用于验证用户在表单字段中输入的数据的函数(如果有的话)。

此外,在下面的 `view/createBook.js` 代码中,我们为 `beforeunload` 事件(例如,当浏览器(或浏览器标签页)关闭时发生)添加了一个事件处理程序,用于保存应用程序数据。

pl.view.createBook = {
  setupUserInterface: function () {
    var formEl = document.forms['Book'],
        submitButton = formEl.commit;
    submitButton.addEventListener("click", this.handleSubmitButtonClickEvent);
    formEl.isbn.addEventListener("input", function () { 
        formEl.isbn.setCustomValidity( Book.checkIsbnAsId( formEl.isbn.value).message);
    });
    formEl.title.addEventListener("input", function () { 
        formEl.title.setCustomValidity( Book.checkTitle( formEl.title.value).message);
    });
    formEl.year.addEventListener("input", function () { 
        formEl.year.setCustomValidity( Book.checkYear( formEl.year.value).message);
    });
    formEl.edition.addEventListener("input", function () {
        formEl.edition.setCustomValidity(
            Book.checkEdition( formEl.edition.value).message);
    });
    // neutralize the submit event
    formEl.addEventListener( 'submit', function (e) { 
        e.preventDefault();;
        formEl.reset();
    });
    window.addEventListener("beforeunload", function () {
        Book.saveAll(); 
    });
  },
  handleSubmitButtonClickEvent: function () {
    ...
  }
}; 

请注意,对于每个表单输入字段,我们都添加了一个 `input` 事件监听器,以便在任何用户输入时执行验证检查,因为 `input` 事件是由打字等用户输入操作创建的。我们使用 HTML5 表单验证 API 中预定义的 `setCustomValidity` 函数,以便在表单字段的当前值上调用我们的属性检查函数,并在违反约束的情况下返回错误消息。因此,当 `Book.checkIsbn(``formEl.isbn``.value).message` 表达式表示的字符串为空时,一切正常。否则,如果它表示错误消息,浏览器将通过为相关表单字段渲染红色轮廓(由于我们为 `:invalid` 伪类定义的 CSS 规则)向用户指示约束违反。

虽然用户输入验证通过向用户提供即时反馈增强了 UI 的可用性,但表单数据提交验证对于捕获无效数据更为重要。因此,事件处理程序 `handleSaveButtonClickEvent()` 再次借助 `setCustomValidity` 执行属性检查,如以下程序清单所示

handleSubmitButtonClickEvent: function () {
  var formEl = document.forms['Book'];
  var slots = { isbn: formEl.isbn.value,
          title: formEl.title.value,
          year: formEl.year.value,
          edition: formEl.edition.value
  };
  // set error messages in case of constraint violations
  formEl.isbn.setCustomValidity( Book.checkIsbnAsId( slots.isbn).message);
  formEl.title.setCustomValidity( Book.checkTitle( slots.title).message);
  formEl.year.setCustomValidity( Book.checkYear( slots.year).message);
  formEl.edition.setCustomValidity(
      Book.checkEdition( formEl.edition.value).message);
  // save the input data only if all of the form fields are valid
  if (formEl.checkValidity()) {
    Book.create( slots);
  }
}

通过调用 `checkValidity()`,我们确保只有在没有约束违反的情况下(通过 `Book.create`)才保存表单数据。在此 `handleSubmitButtonClickEvent` 处理程序在无效表单上执行后,浏览器会接管并测试预定义的属性 `validity` 是否有任何表单字段的错误标志。在我们的方法中,由于我们使用了 `setCustomValidity`,`validity.customError` 将为 true。如果是这种情况,将显示自定义约束违反消息(在气泡中),并且将抑制 `submit` 事件。

对于更新书籍用例,它在 `view/updateBook.js` 中处理,我们提供了一个书籍选择列表,因此用户无需输入书籍的标识符(ISBN),而必须选择要更新的书籍。这意味着无需验证 ISBN 表单字段,只需验证标题和年份字段。我们得到以下代码

pl.view.updateBook = {
  setupUserInterface: function () {
    var formEl = document.forms['Book'],
        submitButton = formEl.commit,
        selectBookEl = formEl.selectBook;
    // set up the book selection list
    util.fillWithOptionsFromAssocArray( Book.instances, selectBookEl, 
        "isbn", "title");
    // when a book is selected, populate the form with its data
    selectBookEl.addEventListener("change", function () {
      var bookKey = selectBookEl.value;
      if (bookKey) {
        book = Book.instances[bookKey];
        formEl.isbn.value = book.isbn;
        formEl.title.value = book.title;
        formEl.year.value = book.year;
        if (book.edition) formEl.edition.value = book.edition;
      } else {
        formEl.reset();
      }
    });
    formEl.title.addEventListener("input", function () { 
        formEl.title.setCustomValidity( 
            Book.checkTitle( formEl.title.value).message);
    });
    formEl.year.addEventListener("input", function () { 
        formEl.year.setCustomValidity( 
            Book.checkYear( formEl.year.value).message);
    });
    formEl.edition.addEventListener("input", function () {
        formEl.edition.setCustomValidity(
            Book.checkEdition( formEl.edition.value).message);
    });
    saveButton.addEventListener("click", this.handleSubmitButtonClickEvent);
    // neutralize the submit event
    formEl.addEventListener( 'submit', function (e) { 
        e.preventDefault();;
        formEl.reset();
    });
    window.addEventListener("beforeunload", function () {
        Book.saveAll(); 
    });
  },

当点击更新书籍表单上的保存按钮时,通过调用 `setCustomValidity` 验证 `title` 和 `year` 表单字段的值,然后如果可以通过 `checkValidity` 确定表单数据的有效性,则更新书籍记录。

  handleSubmitButtonClickEvent: function () {
    var formEl = document.forms['Book'];
    var slots = { isbn: formEl.isbn.value,
        title: formEl.title.value,
        year: formEl.year.value,
        edition: formEl.edition.value
    };
    // set error messages in case of constraint violations
    formEl.title.setCustomValidity( Book.checkTitle( slots.title).message);
    formEl.year.setCustomValidity( Book.checkYear( slots.year).message);
    formEl.edition.setCustomValidity(
            Book.checkEdition( formEl.edition.value).message);
    if (formEl.checkValidity()) {
      Book.update( slots);
    }
  } 

删除用例的 `setupUserInterface` 方法的逻辑类似。

运行应用

你可以从我们的服务器运行验证应用,并在 web-engineering.info 上找到更多关于 Web 工程的资源,包括开放获取书籍。

可能的变体和扩展

使用隐式标签简化表单

本教程中使用的表单字段的显式标签需要向 `input` 元素添加一个 `id` 值,并向其 `label` 元素添加一个 `for` 引用,如下例所示

<div class="pure-control-group">
  <label for="isbn">ISBN:</label>
  <input id="isbn" name="isbn" />
</div>

当页面上有许多表单字段时,这种将标签与表单字段关联起来的技术会变得非常不方便,因为我们必须创建大量的唯一 `id` 值,并且必须确保它们不会与同一页面上任何其他元素的 `id` 值冲突。因此,最好使用一种称为隐式标签的方法,这种方法不需要所有这些 `id` 引用。在这种方法中,我们将 `input` 元素作为其 `label` 元素的子元素,如下所示

<div>
  <label>ISBN: <input name="isbn" /></label>
</div>

让 `input` 成为其 `label` 的子元素似乎不太合乎逻辑(相反,人们会期望 `label` 成为 `input` 元素的子元素)。但 HTML5 中就是这样定义的。

使用隐式标签的一个小缺点是缺乏流行 CSS 库(如 Pure CSS)的支持。在本教程的以下部分中,我们将对隐式标签表单字段使用我们自己的 CSS 样式。

处理枚举属性

在所有应用领域中,都有定义枚举属性可能值的枚举数据类型。例如,当我们必须管理人员数据时,我们通常需要包含有关人员性别的信息。`gender` 属性的可能值仅限于以下之一:“男性”、“女性”或“未确定”。与其将这些字符串用作枚举属性 `gender` 的内部值,不如使用正整数 1、2 和 3,它们枚举了可能的值。然而,由于这些整数在程序代码中没有揭示其含义(它们所代表的枚举标签),为了可读性,我们更倾向于在程序语句中使用特殊常量,称为枚举字面量,例如 `GenderEL.MALE` 和 `GenderEL.FEMALE`,如 `this.gender = GenderEL.FEMALE`。请注意,根据惯例,枚举字面量全部大写。

我们可以使用 `Object.defineProperties` 方法以特殊的 JavaScript 对象定义形式实现枚举

var BookCategoryEL = null;
Object.defineProperties( BookCategoryEL, {
  NOVEL: {value: 1, writable: false},
  BIOGRAPHY: {value: 2, writable: false},
  TEXTBOOK: {value: 3, writable: false},
  OTHER: {value: 4, writable: false},
  MAX: {value: 4, writable: false},
  labels: {value:["novel","biography","textbook","other"], writable: false}
});

请注意,这种书籍类别枚举的定义如何考虑了枚举字面量(如 `BookCategoryEL.NOVEL`)是常量的要求,其值在程序执行期间不能更改。这是通过 `Object.defineProperties` 语句中的属性描述符 `writable: false` 实现的。

这个定义允许在程序语句中使用枚举字面量 `BookCategoryEL.NOVEL`、`BookCategoryEL.BIOGRAPHY` 等,它们代表枚举整数 1、2、3 和 4。请注意,我们使用约定,将枚举名称的后缀加上“EL”,表示“枚举字面量”。

有了像 `BookCategoryEL` 这样的枚举,我们就可以通过测试枚举属性(如 `category`)的值是否不小于 1 且不大于 `BookCategoryEL.MAX` 来检查其值是否可接受。

我们考虑以下带有枚举属性 `category` 的模型类 `Book`

function Book( slots) {
  this.isbn = "";     // string
  this.title = "";    // string
  this.year = 0;      // number (int)
  this.category = 0;  // number (enum)
  if (arguments.length > 0) {
    this.setIsbn( slots.isbn); 
    this.setTitle( slots.title); 
    this.setYear( slots.year);
    this.setCategory( slots.category);
  }
};

为了验证枚举属性 `category` 的输入值,我们可以使用以下检查函数

Book.checkCategory = function (c) {
  if (!c) {
    return new MandatoryValueConstraintViolation("A category must be provided!");
  } else if (!util.isPositiveInteger(c) || c > BookCategoryEL.MAX) {
    return new RangeConstraintViolation("The category must be a positive integer "+
                 "not greater than "+ BookCategoryEL.MAX +" !");
  } else {
    return new NoConstraintViolation();
  }
};

请注意,枚举 `BookCategoryEL` 定义的范围约束是如何检查的:它测试输入值 `c` 是否为正整数,以及它是否不大于 `BookCategoryEL.MAX`。

在用户界面中,枚举属性的输出字段将显示枚举标签,而不是枚举整数。标签可以通过以下方式检索

formEl.category.value = BookCategoryEL.labels[this.category];

对于单值枚举属性(如 `Book::category`)的用户输入,如果枚举字面量的数量足够少,可以使用单选按钮组,否则将使用单选列表。如果选择列表是通过 HTML `select` 元素实现的,枚举标签将用作选项元素的文本内容,而枚举整数将用作它们的值。

对于多值枚举属性的用户输入,如果枚举字面量的数量足够少,可以使用复选框组,否则将使用多选列表。为了可用性,如果枚举字面量的数量不超过某个阈值(这取决于用户无需滚动即可在屏幕上看到的选项数量),多选列表才能通过 HTML `select` 元素实现。

注意事项

读者可能已经注意到模型层中每个类和每个属性用于约束验证(检查和设置器)以及每个类用于数据存储管理方法 `add`、`update` 等所需的重复代码结构(称为样板代码)。虽然写几次这种代码对于学习应用程序开发很有好处,但你以后在实际项目中不会想一遍又一遍地编写它。在另一篇文章《使用 mODELcLASSjs 进行声明式和响应式约束验证》中,我们提出了一种方法,将这些方法以通用形式放在元类中,以便它们可以被应用程序的所有类重用。

实践项目

要构建的应用程序的目的是管理电影信息。与本教程中讨论的图书数据管理应用程序一样,您可以做出简化假设,即所有数据都可以保存在主内存中。持久数据存储通过 JavaScript 的本地存储 API 实现。

该应用程序只处理一种对象类型,`Movie`,如下图类图所示。

在此模型中,表达了以下约束

  1. 由于 `movieId` 属性被声明为 `Movie` 的标准标识符,因此它是强制的唯一的

  2. `title` 属性是强制的,如其多重性表达式 [1] 所示,并具有字符串长度约束,要求其值最多 120 个字符。

  3. `releaseDate` 属性具有区间约束:它必须大于或等于 1895-12-28。

请注意,`releaseDate` 属性不是强制性的,而是可选的,如其多重性表达式 [0..1] 所示。除了此列表中描述的约束之外,还有通过将数据类型 `PositiveInteger` 分配给 `movieId`,`NonEmptyString` 分配给 `title`,以及 `Date` 分配给 `releaseDate` 定义的隐式范围约束。在我们的纯 JavaScript 方法中,所有这些属性约束都编码在模型类中,位于属性特定的检查函数中。

按照本教程,你必须注意

  1. 为每个属性添加一个检查函数,用于验证为该属性定义的约束,以及一个设置器方法,用于调用该检查函数并用于设置属性的值,

  2. 在 `Movie.add` 和 `Movie.update` 方法中保存任何数据之前,执行验证

在应用程序的模型代码中,而在用户界面(“视图”)代码中,您必须注意

  1. 使用 CSS 规则(通过集成像 Yahoo 的 Pure CSS 这样的 CSS 库)来样式化用户界面,

  2. 用户输入验证,用于向用户提供即时反馈,

  3. 表单提交验证,以防止提交无效数据。

此外,您必须确保您的页面符合 HTML5 的 XML 语法,并且您的 JavaScript 代码符合我们的编码规范并使用 JSLint (http://www.jslint.com) 进行检查。

如果您对该项目有任何疑问,可以在下面的评论区发布。

历史

  • 2015 年 6 月 24 日,添加了“实践项目”部分
  • 2015 年 2 月 18 日,更正了 CodeProject“小节”
  • 2015 年 2 月 12 日,添加了新章节“可能的变体和扩展”
  • 2014 年 10 月 13 日,添加了字符串长度约束示例和可选属性,以说明强制属性和可选属性之间的区别。
  • 2014 年 4 月 15 日,更新了代码,现在使用提交按钮让浏览器在气泡中显示自定义约束违反消息,为 :invalid 伪类添加了 CSS 规则,在“新问题”部分添加了解释,由于使用实用方法填充带有选项的选择表单控件而简化了 updateBook 的程序清单,
  • 2014 年 4 月 11 日,创建了第一个版本。
© . All rights reserved.