八、设计模式:构建型

我们在过去三章中看到的许多创造、结构和行为设计模式可以结合在一起,形成架构模式,帮助解决更大代码库中的特定问题。在这一章中,我们将看看三种最常见的适用于 JavaScript 应用的架构模式,以及每种模式的例子。

模型-视图-控制器(MVC)模式

模型-视图-控制器(MVC)模式允许将 JavaScript 应用中的代码分成三个不同的部分:模型,它将与代码中底层数据结构相关的代码组合在一起,包括数据的存储和检索;视图,它将与存储在模型中的数据在屏幕上的显示相关的代码组合在一起——本质上是处理 DOM 元素;以及控制器,它处理系统中的任何业务逻辑,并在必要时更新模型和/或视图——它确保模型和视图不需要直接相互对话,使它们彼此松散耦合。这种关注点的分离使代码更容易理解和使用,更容易测试,并允许从事同一项目的多个开发人员能够在应用的模型、视图和控制器层之间划分任务。

MVC 架构模式实际上是我们之前在第 6 章和第 7 章中看到的三种特定设计模式的组合:观察者、复合和策略。当模型中的数据发生变化时,观察者模式被用来触发一个事件,该事件传递更新后的数据以供系统的其他部分使用。类似地,视图使用相同的模式来监听模型数据的变化,并用这些新数据更新用户界面。视图只能直接从模型中读取数据,而不能设置它;那是管制员的角色。视图还可以包含子视图,以处理更大 UI 的可重用部分,复合模式用于确保控制器不需要知道其逻辑需要影响的视图数量。最后,控制器利用策略模式将一个特定的视图应用于自身,允许一个更大的系统中的多个视图共享相同的控制器逻辑,前提是它们都公开一个类似的方法,我选择将其命名为render(),该方法从模型传递数据,并将视图放在当前页面上,将其连接到系统其余部分广播的事件,以备使用。值得注意的是,在 JavaScript 应用中,该模型通常通过 Ajax 连接到一个后端服务,作为存储数据的数据库。

清单 8-1 展示了我们如何创建一个“类”来处理一个简单系统中模型数据的表示,这个系统采用 MVC 模式,允许管理屏幕上的电子邮件地址列表。

清单 8-1。模型

// The Model represents the data in the system. In this system, we wish to manage a list of

// email addresses on screen, allowing them to be added and removed from the displayed list. The

// Model here, therefore, represents the stored email addresses themselves. When addresses are

// added or removed, the Model broadcasts this fact using the observer pattern methods from

// Listing 7-6

//

// Define the Model as a "class" such that multiple object instances can be created if desired

function EmailModel(data) {

// Create a storage array for email addresses, defaulting to an empty array if no addresses

// are provided at instantiation

this.emailAddresses = data || [];

}

EmailModel.prototype = {

// Define a method which will add a new email address to the list of stored addresses

add: function(email) {

// Add the new email to the start of the array

this.emailAddresses.unshift(email);

// Broadcast an event to the system, indicating that a new email address has been

// added, and passing across that new email address to any code module listening for

// this event

observer.publish("model.email-address.added", email);

},

// Define a method to remove an email address from the list of stored addresses

remove: function(email) {

var index = 0,

length = this.emailAddresses.length;

// Loop through the list of stored addresses, locating the provided email address

for (; index < length; index++) {

if (this.emailAddresses[index] === email) {

// Once the email address is located, remove it from the list of stored email

// addresses

this.emailAddresses.splice(index, 1);

// Broadcast an event to the system, indicating that an email address has been

// removed from the list of stored addresses, passing across the email address

// that was removed

observer.publish("model.email-address.removed", email);

// Break out of the for loop so as not to waste processor cycles now we've

// found what we were looking for

break;

}

}

},

// Define a method to return the entire list of stored email addresses

getAll: function() {

return this.emailAddresses;

}

};

清单 8-2 中的代码显示了我们如何定义用户界面的视图代码。它将由一个包含两个子视图的面板组成,一个包含一个用于添加新电子邮件地址的简单输入表单,另一个显示存储的电子邮件地址的列表,每个列表旁边有一个“删除”按钮,允许用户从列表中删除单个电子邮件地址。图 8-1 中的截图显示了我们正在构建的视图的一个例子。

A978-1-4302-6269-5_8_Fig1_HTML.jpg

图 8-1。

A system for managing email addresses, built using the MVC architectural pattern

清单 8-2。查看

// We will be building a page consisting of two parts: a text input field and associated

// button, for adding new email addresses to our list of stored addresses, and a list displaying

// the stored email addresses with a "Remove" button beside each to allow us to remove email

// addresses from the list of stored addresses. We will also define a generic View which acts

// as a holder for multiple child views, and we'll use this as a way of linking the two views

// together in Listing 8-3\. As with the Model in Listing 8-1, we will be taking advantage of

// the observer pattern methods from Listing 7-6

//

// Define a View representing a simple form for adding new email addresses to the displayed

// list. We define this as a "class" so that we can create and display as many instances of

// this form as we wish within our user interface

function EmailFormView() {

// Create new DOM elements to represent the form we are creating (you may wish to store

// the HTML tags you need directly within your page rather than create them here)

this.form = document.createElement("form");

this.input = document.createElement("input");

this.button = document.createElement("button");

// Ensure we are creating a <input type="text"> field with appropriate placeholder text

this.input.setAttribute("type", "text");

this.input.setAttribute("placeholder", "New email address");

// Ensure we are creating a <button type="submit">Add</button> tag

this.button.setAttribute("type", "submit");

this.button.innerHTML = "Add";

}

EmailFormView.prototype = {

// All Views should have a render() method, which is called by the Controller at some point

// after its instantiation by the Controller. It would typically be passed the data from

// the Model also, though in this particular case, we do not need that data

render: function() {

// Nest the <input> field and <button> tag within the <form> tag

this.form.appendChild(this.input);

this.form.appendChild(this.button);

// Add the <form> to the end of the current HTML page

document.body.appendChild(this.form);

// Connect up any events to the DOM elements represented in this View

this.bindEvents();

},

// Define a method for connecting this View to system-wide events

bindEvents: function() {

var that = this;

// When the form represented by this View is submitted, publish a system-wide event

// indicating that a new email address has been added via the UI, passing across this

// new email address value

this.form.addEventListener("submit", function(evt) {

// Prevent the default behavior of the submit action on the form (to prevent the

// page refreshing)

evt.preventDefault();

// Broadcast a system-wide event indicating that a new email address has been

// added via the form represented by this View. The Controller will be listening

// for this event and will interact with the Model on behalf of the View to

// add the data to the list of stored addresses

observer.publish("view.email-view.add", that.input.value);

}, false);

// Hook into the event triggered by the Model that tells us that a new email address

// has been added in the system, clearing the text in the <input> field when this

// occurs

observer.subscribe("model.email-address.added", function() {

that.clearInputField();

});

},

// Define a method for emptying the text value in the <input> field, called whenever an

// email address is added to the Model

clearInputField: function() {

this.input.value = "";

}

};

// Define a second View, representing a list of email addresses in the system. Each item in

// the list is displayed with a "Remove" button beside it to allow its associated address to

// be removed from the list of stored addresses

function EmailListView() {

// Create DOM elements for <ul>, <li>, <span> and <button> tags

this.list = document.createElement("ul");

this.listItem = document.createElement("li");

this.listItemText = document.createElement("span");

this.listItemRemoveButton = document.createElement("button");

// Give the <button> tag the display text "Remove"

this.listItemRemoveButton.innerHTML = "Remove";

}

EmailListView .prototype = {

// Define the render() method for this View, which takes the provided Model data and

// renders a list, with a list item for each email address stored in the Model

render: function(modelData) {

var index = 0,

length = modelData.length,

email;

// Loop through the array of Model data containing the list of stored email addresses

// and create a list item for each, appending it to the list

for (; index < length; index++) {

email = modelData[index];

this.list.appendChild(this.createListItem(email));

}

// Append the list to the end of the current HTML page

document.body.appendChild(this.list);

// Connect this View up to the system-wide events

this.bindEvents();

},

// Define a method which, given an email address, creates and returns a populated list

// item <li> tag representing that email

createListItem: function(email) {

// Cloning the existing, configured DOM elements is more efficient than creating new

// ones from scratch each time

var listItem = this.listItem.cloneNode(false),

listItemText = this.listItemText.cloneNode(false),

listItemRemoveButton = this.listItemRemoveButton.cloneNode(true);

// Assign a "data-email" attribute to the <li> element, populated with the email

// address it represents - this simplifies the attempt to locate the list item

// associated with a particular email address in the removeEmail() method later

listItem.setAttribute("data-email", email);

listItemRemoveButton.setAttribute("data-email", email);

// Display the email address within the <span> element, and append this, together with

// the "Remove" button, to the list item element

listItemText.innerHTML = email;

listItem.appendChild(listItemText).appendChild(listItemRemoveButton);

// Return the new list item to the calling function

return listItem;

},

// Define a method for connecting this View to system-wide events

bindEvents: function() {

var that = this;

// Create an event delegate on the list itself to handle clicks of the <button> within

this.list.addEventListener("click", function(evt) {

if (evt.target && evt.target.tagName === "BUTTON") {

// When the <button> is clicked, broadcast a system-wide event which will be

// picked up by the Controller. Pass the email address associated with the

// <button> to the event

observer.publish("view.email-view.remove", evt.target.getAttribute("data-email"));

}

}, false);

// Listen for the event fired by the Model indicating that a new email address has

// been added, and execute the addEmail() method

observer.subscribe("model.email-address.added", function(email) {

that.addEmail(email);

});

// Listen for the event fired by the Model indicating that an email address has been

// removed, and execute the removeEmail() method

observer.subscribe("model.email-address.removed", function(email) {

that.removeEmail(email);

});

},

// Define a method , called when an email address is added to the Model, which inserts a

// new list item to the top of the list represented by this View

addEmail: function(email) {

this.list.insertBefore(this.createListItem(email), this.list.firstChild);

},

// Define a method, called when an email address is removed from the Model, which removes

// the associated list item from the list represented by this View

removeEmail: function(email) {

var listItems = this.list.getElementsByTagName("li"),

index = 0,

length = listItems.length;

// Loop through all the list items, locating the one representing the provided email

// address, and removing it once found

for (; index < length; index++) {

if (listItems[index].getAttribute("data-email") === email) {

this.list.removeChild(listItems[index]);

// Once we've removed the email address, stop the for loop from executing

break;

}

}

}

};

// Define a generic View which can contain child Views. When its render() method is called, it

// calls the render() methods of its child Views in turn, passing along any Model data

// provided upon instantiation

function EmailView(views) {

this.views = views || [];

}

EmailView.prototype = {

// All Views need to have a render() method - in the case of this generic View, it simply

// executes the render() method of each of its child Views

render: function(modelData) {

var index = 0,

length = this.views.length;

// Loop through the child views, executing their render() methods, passing along any

// Model data provided upon instantiation

for (; index < length; index++) {

this.views[index].render(modelData);

}

}

};

通过使用观察者模式,模型中所做的更改可以立即反映在视图中。但是,在视图中所做的更改不会立即传递到模型中;它们将由控制器处理,如清单 8-3 所示。

清单 8-3。控制器

// The Controller connects a Model to a View, defining the logic of the system. This allows

// alternative Models and Views to be provided whilst still enabling a similar system behavior,

// provided the Model provides add(), remove() and getAll() methods for accessing its data, and

// the View provides a render() method - this is the strategy pattern in action. We will also

// use the observer pattern methods from Listing 7-6.

//

// Define a "class" to represent the Controller for connecting the Model and Views in our email

// address system. The Controller is instantiated after the Model and View, and their object

// instances provided as inputs

function EmailController(model, view) {

// Store the provided Model and View objects

this.model = model;

this.view = view;

}

EmailController.prototype = {

// Define a method to use to initialize the system, which gets the data from the Model using

// its getAll() method and passes it to the associated View by executing that View's

// render() method

initialize: function() {

// Get the list of email addresses from the associated Model

var modelData = this.model.getAll();

// Pass that data to the render() method of the associated View

this.view.render(modelData);

// Connect Controller logic to system-wide events

this.bindEvents();

},

// Define a method for connecting Controller logic to system-wide events

bindEvents: function() {

var that = this;

// When the View indicates that a new email address has been added via the user

// interface, call the addEmail() method

observer.subscribe("view.email-view.add", function(email) {

that.addEmail(email);

});

// When the View indicates that an email address has been remove via the user

// interface, call the removeEmail() method

observer.subscribe("view.email-view.remove", function(email) {

that.removeEmail(email);

});

},

// Define a method for adding an email address to the Model, called when an email address

// has been added via the View's user interface

addEmail: function(email) {

// Call the add() method on the Model directly, passing the email address added via

// the View. The Model will then broadcast an event indicating a new email address has

// been added, and the View will respond to this event directly, updating the UI

this.model.add(email);

},

// Define a method for removing an email address from the Model, called when an email

// address has been removed via the View's user interface

removeEmail: function(email) {

// Call the remove() method on the Model directly, passing the email address added via

// the View. The Model will then broadcast an event indicating an email address has

// been removed, and the View will respond to this event directly, updating the UI

this.model.remove(email);

}

};

清单 8-4 展示了我们如何根据 MVC 架构模式,使用清单 8-1 到 8-3 中创建的“类”来构建如图 8-1 所示的简单页面。

清单 8-4。正在使用的模型-视图-控制器模式

// Create an instance of our email Model "class", populating it with a few email addresses to

// get started with

var emailModel = new EmailModel([

"``denodell@me.comT2】

"``denodell@gmail.comT2】

"``den.odell@akqa.comT2】

]),

// Create instances of our form View and list View "classes"

emailFormView = new EmailFormView(),

emailListView = new EmailListView(),

// Combine together the form and list Views as children of a single View object

emailView = new EmailView([emailFormView, emailListView]),

// Create an instance of our email system Controller, passing it the Model instance and

// the View to use. Note that the Controller does not need to be aware whether the View

// contains a single View or multiple, combined Views, as it does here - this is an example

// of the composite pattern in action

emailController = new EmailController(emailModel, emailView);

// Finally, initialize the Controller which gets the data from the Model and passes it to the

// render() method of the View, which, in turn, connects up the user interface to the

// system-wide events, bringing the whole application together

emailController.initialize();

通过将清单 7-6(观察者模式)中的代码与清单 8-1 到 8-4 中的代码按顺序组合,这个 MVC 应用示例可以在任何简单的 HTML 页面的上下文中运行。例如:

<!DOCTYPE html>

<html>

<head>

<title>MVC Example</title>

</head>

<body>

<script src="Listing7-6.js"></script>

<script src="Listing8-1.js"></script>

<script src="Listing8-2.js"></script>

<script src="Listing8-3.js"></script>

<script src="Listing8-4.js"></script>

</body>

</html>

当用户使用<input>字段输入新的电子邮件地址并提交表单时,新的电子邮件将出现在下方列表的顶部(消息在系统中从视图传递到控制器再到模型,然后模型广播一个事件,更新视图)。当用户单击任何电子邮件地址旁边的Remove按钮时,该电子邮件将从显示中删除,也将从模型中的底层数据存储中删除。

MVC 模式在包含一组数据的大型应用中非常有用,这些数据需要在用户界面中显示、交互和更新,而不会使代码库过于复杂。代码分为负责存储和操作数据的代码、负责数据显示的代码以及负责数据和显示之间的业务逻辑和连接的代码。要了解有关模型-视图-控制器模式的更多信息,请查看以下在线资源:

模型-视图-演示者(MVP)模式

模型-视图-展示者(MVP)架构模式是 MVC 模式的衍生物,它试图阐明模型、视图和连接它们的代码之间的界限(在 MVC 中,这是控制器,在 MVP 中,这被称为展示者)。它基于相同的底层设计模式,但是在 MVC 模式中,视图可以直接基于模型中的更改进行更新,而在 MVP 中,模型和视图之间的所有通信都必须通过表示层——这是一个微妙但重要的区别。此外,MVP 模式中的视图不应该直接包含事件处理程序代码——这应该从 Presenter 传递到视图中,这意味着视图代码只呈现用户界面,而 Presenter 执行事件处理。

让我们以图 8-1 所示的电子邮件地址列表为例,它是我们之前使用 MVC 模式构建的,现在使用 MVP 模式构建它。我们将保持与清单 8-1 中相同的模型,但是我们需要构建一个新的视图,并用一个演示者替换之前的控制器。清单 8-5 中的代码显示了如何编写演示者;与清单 8-3 中的控制器有相似之处,但是请注意模型和视图之间的所有通信在这里是如何处理的,而不是在演示者和视图之间分开。

清单 8-5。电子邮件地址列表应用的演示者

// The Presenter "class" is created in much the same was as the Controller in the MVC pattern.

// Uses the observer pattern methods from Listing 7-6.

function EmailPresenter(model, view) {

this.model = model;

this.view = view;

}

EmailPresenter.prototype = {

// The initialize() method is the same as it was for the Controller in the MVC pattern

initialize: function() {

var modelData = this.model.getAll();

this.view.render(modelData);

this.bindEvents();

},

// The difference is in the bindEvents() method, where we connect the events triggered from

// the Model through to the View, and vice versa - no longer can the Model directly update

// the View without intervention. This clarifies the distinction between the Model and View,

// making the separation clearer, and giving developers a better idea where to look should

// problems occur connecting the data to the user interface

bindEvents: function() {

var that = this;

// When the View triggers the "add" event, execute the add() method of the Model

observer.subscribe("view.email-view.add", function(email) {

that.model.add(email);

});

// When the View triggers the "remove" event, execute the remove() method of the Model

observer.subscribe("view.email-view.remove", function(email) {

that.model.remove(email);

});

// When the Model triggers the "added" event, execute the addEmail() method of the View

observer.subscribe("model.email-address.added", function(email) {

// Tell the View that the email address has changed. We will need to ensure this

// method is available on any View passed to the Presenter on instantiation, which

// includes generic Views that contain child Views

that.view.addEmail(email);

});

// When the Model triggers the "removed" event, execute the removeEmail() method of

// the View

observer.subscribe("model.email-address.removed", function(email) {

that.view.removeEmail(email);

});

}

};

视图现在会比以前更短,因为我们已经将它的一些事件处理代码提取到了 Presenter 中,如清单 8-6 所示。注意每个视图上添加的addEmail()removeEmail()方法,包括通用视图,它将包含子视图。

清单 8-6。MVP 模式的视图

// Define the EmailFormView "class" constructor as before to initialize the View's DOM elements.

// Uses the observer pattern methods from Listing 7-6.

function EmailFormView() {

this.form = document.createElement("form");

this.input = document.createElement("input");

this.button = document.createElement("button");

this.input.setAttribute("type", "text");

this.input.setAttribute("placeholder", "New email address");

this.button.setAttribute("type", "submit");

this.button.innerHTML = "Add";

}

EmailFormView.prototype = {

// The render() method is the same as it was in the MVC pattern

render: function() {

this.form.appendChild(this.input);

this.form.appendChild(this.button);

document.body.appendChild(this.form);

this.bindEvents();

},

// Note how the bindEvents() method differs from that in the MVC pattern - we no longer

// subscribe to events broadcast from the Model, we only trigger View-based events and the

// Presenter handles the communication between Model and View

bindEvents: function() {

var that = this;

this.form.addEventListener("submit", function(evt) {

evt.preventDefault();

observer.publish("view.email-view.add", that.input.value);

}, false);

},

// We make an addEmail() method available to each View, which the Presenter calls when

// the Model indicates that a new email address has been added

addEmail: function() {

this.input.value = "";

},

// We make an removeEmail() method available to each View, which the Presenter calls when

// the Model indicates that an email address has been removed. Here we do not need to do

// anything with that information so we leave the method empty

removeEmail: function() {

}

};

// Define the EmailListView "class" constructor as before to initialize the View's DOM elements.

function EmailListView() {

this.list = document.createElement("ul");

this.listItem = document.createElement("li");

this.listItemText = document.createElement("span");

this.listItemRemoveButton = document.createElement("button");

this.listItemRemoveButton.innerHTML = "Remove";

}

EmailListView.prototype = {

render: function(modelData) {

var index = 0,

length = modelData.length,

email;

for (; index < length; index++) {

email = modelData[index];

this.list.appendChild(this.createListItem(email));

}

document.body.appendChild(this.list);

this.bindEvents();

},

createListItem: function(email) {

var listItem = this.listItem.cloneNode(false),

listItemText = this.listItemText.cloneNode(false),

listItemRemoveButton = this.listItemRemoveButton.cloneNode(true);

listItem.setAttribute("data-email", email);

listItemRemoveButton.setAttribute("data-email", email);

listItemText.innerHTML = email;

listItem.appendChild(listItemText).appendChild(listItemRemoveButton);

return listItem;

},

// The bindEvents() method only publishes View events, it no longer subscribes to Model

// events - these are handled in the Presenter

bindEvents: function() {

this.list.addEventListener("click", function(evt) {

if (evt.target && evt.target.tagName === "BUTTON") {

observer.publish("view.email-view.remove", evt.target.getAttribute("data-email"));

}

}, false );

},

// Create this View's addEmail() method, called by the Presenter when the Model indicates

// that an email address has been added

addEmail: function(email) {

this.list.insertBefore(this.createListItem(email), this.list.firstChild);

},

// Create this View's removeEmail() method, called by the Presenter when the Model indicates

// that an email address has been removed

removeEmail: function(email) {

var listItems = this.list.getElementsByTagName("li"),

index = 0,

length = listItems.length;

for (; index < length; index++) {

if (listItems[index].getAttribute("data-email") === email) {

this.list.removeChild(listItems[index]);

break;

}

}

}

};

// Create the generic View which can contain child Views

function EmailView(views) {

this.views = views || [];

}

EmailView.prototype = {

// The render() method is as it was in the MVC pattern

render: function(modelData) {

var index = 0,

length = this.views.length;

for (; index < length; index++) {

this.views[index].render(modelData);

}

},

// Even the generic View needs the addEmail() and removeEmail() methods. When these are

// called, they must execute the methods of the same name on any child Views, passing

// along the email address provided

addEmail: function(email) {

var index = 0,

length = this.views.length;

for (; index < length ; index++) {

this.views[index].addEmail(email);

}

},

removeEmail: function(email) {

var index = 0,

length = this.views.length;

for (; index < length; index++) {

this.views[index].removeEmail(email);

}

}

};

最后,清单 8-7 显示了如何将 MVP 系统和我们在本章前面看到的 MVC 模式结合起来。

清单 8-7。正在使用的模型-视图-演示者模式

// Use EmailModel from Listing 8-1

var emailModel = new EmailModel([

"``denodell@me.comT2】

"``denodell@gmail.comT2】

"``den.odell@akqa.comT2】

]),

emailFormView = new EmailFormView(),

emailListView = new EmailListView(),

emailView = new EmailView([emailFormView, emailListView]),

// Create the Presenter as you would the Controller in the MVC pattern

emailPresenter = new EmailPresenter(emailModel, emailView);

emailPresenter.initialize();

通过将清单 7-6(观察者模式)中的代码与清单 8-1(我们最初的 MVC 应用的共享模型)、8-5、8-6 和 8-7 中的代码按顺序组合,这个 MVP 应用示例可以在任何简单的 HTML 页面的上下文中运行。例如:

<!DOCTYPE html>

<html>

<head>

<title>MVP Example</title>

</head>

<body>

<script src="Listing7-6.js"></script>

<script src="Listing8-1.js"></script>

<script src="Listing8-5.js"></script>

<script src="Listing8-6.js"></script>

<script src="Listing8-7.js"></script>

</body>

</html>

与模型-视图-控制器模式相比,模型-视图-展示者模式在层之间提供了更明确的分隔,这在代码库变得更大时非常有用。它被视为简化 MVC 模式的一种方式,因为在大型应用中跟踪事件变得不那么容易了。要在线阅读有关模型-视图-演示者模式的更多信息,请参考以下资源:

模型-视图-视图模型(MVVM)模式

模型-视图-视图模型(MVVM)模式是 MVC 模式的一个较新的衍生模式,和 MVP 模式一样,它的目标是将模型和视图完全分开,避免它们之间的直接通信。然而,这两者被 ViewModel 分开,而不是 Presenter,ViewModel 实现了类似的角色,但包含了视图中存在的所有代码。因此,视图本身可以用更简单的东西代替,并通过 HTML5 data-属性连接(或绑定)到视图模型。事实上,ViewModel 和 View 之间的分离非常明显,以至于 View 实际上可以作为一个静态 HTML 文件提供,该文件可以用作一个模板,通过绑定到这些data-属性中包含的 ViewModel 来直接构建用户界面。

让我们回到图 8-1 所示的同一个邮件列表应用示例,并对其应用 MVVM 模式。我们可以重用与清单 8-1 中相同的模型代码,但是我们将创建一个新的视图,并用一个视图模型替换之前的控制器或表示器。清单 8-8 中的代码显示了一个 HTML 页面,在相关的标签上有特定的 HTML5 data-属性,向视图模型指示它应该根据其内部业务逻辑对这个视图做什么。

清单 8-8。定义为简单 HTML 页面的视图

<!--

The View is now a simple HTML document - it could be created through DOM elements in JavaScript

but it does not need to be any more. The View is connected to the ViewModel via HTML5 data

attributes on certain HTML tags which are then bound to specific behaviors in the ViewModel as

required

-->

<!DOCTYPE html>

<html>

<head>

<meta charset="utf-8">

<title>Model-View-ViewModel (MVVM) Example</title>

</head>

<body>

<!--

The <form> tag has a specific HTML5 data attribute indicating that when submitted it should

be bound to an addEmail() method in the ViewModel

-->

<form data-submit="addEmail">

<input type="text" placeholder="New email address">

<button type="submit">Add</button>

</form>

<!--

The <ul> list has a specific HTML5 data attribute indicating that the tags within should be

looped through for each item in the stored Model data. So if the Model contained three email

addresses, three <li> tags would be produced and rendered in place of the one templated <li>

tag that currently exists

-->

<ul data-loop>

<li>

<!--

We will use the data-text attribute to indicate that the ViewModel should replace

the tag contents with the individual email address represented as we loop through

the stored Model data

-->

<span data-text></span>

<!--

We use the data-click attribute as we used data-submit previously on the <form>

tag, i.e. to execute a specific method exposed by the ViewModel when the button is

clicked

-->

<button data-click="removeEmail">Remove</button>

</li>

</ul>

<!--

We will add <script> tags to the end of this page in future to load the observer pattern,

the Model, the ViewModel, and the initialization code

-->

</body>

</html>

我在<ul>标签上选择了一个data-loop属性,以指示下面显示的<li>标签应该为存储在模型中的每个电子邮件地址重复出现。这个循环中特定标签上的data-text属性表示它的内容应该被电子邮件地址本身替换。一个data-submit属性,以及作为其值的特定方法名,表明一个submit事件处理程序应该连接到该元素,在事件发生时执行存储在 ViewModel 中的给定方法名。类似地,data-click属性的值表示当用户点击该元素时将被执行的来自 ViewModel 的方法名。这些属性名称是任意选择的;除了我在这里定义的以外,它们在 HTML 或 JavaScript 中没有特定的含义。请注意文件末尾的注释,它表明我们将在代码清单的末尾添加<script>标记来加载和初始化代码,这是我们在查看了视图模型和初始化代码之后添加的。

清单 8-9 中的代码显示了特定的视图模型,用于将相关的数据和行为绑定到清单 8-8 中的视图来表示应用。

清单 8-9。视图模型

// Define a ViewModel for the email system which connects up a static View to the data stored in

// a Model. It parses the View for specific HTML5 data attributes and uses these as instructions

// to affect the behavior of the system. Provided the ViewModel is expecting the specific data

// attributes included in the View, the system will work as expected. The more generic the

// ViewModel, therefore, the more variation is possible in the View without needing to update

// thes code here. Uses the observer pattern methods from Listing 7-6.

function EmailViewModel(model, view) {

var that = this;

this.model = model;

this.view = view;

// Define the methods we wish to make available to the View for selection via HTML5 data

// attributes

this.methods = {

// The addEmail() method will add a supplied email address to the Model, which in turn

// will broadcast an event indicating that the Model has updated

addEmail: function(email) {

that.model.add(email);

},

// The removeEmail() method will remove a supplied email address from the Model, which

// in turn will broadcast an event indicating that the Model has updated

removeEmail: function(email) {

that.model.remove(email);

}

};

}

// Define the method to initialize the connection between the Model and the View

EmailViewModel.prototype.initialize = function() {

// Locate the <ul data-loop> element which will be used as the root element for looping

// through the email addresses stored in the Model and displaying each using a copy of the

// <li> tag located beneath it in the DOM tree

this.listElement = this.view.querySelectorAll("[data-loop]")[0];

// Store the <li> tag beneath the <ul data-loop> element

this.listItemElement = this.listElement.getElementsByTagName("li")[0];

// Connect the <form data-submit> in the View to the Model

this.bindForm();

// Connect the <ul data-loop> in the View to the Model

this.bindList();

// Connect the events broadcast by the Model to the View

this.bindEvents();

};

// Define a method to configure the <form data-submit> in the View

EmailViewModel.prototype.bindForm = function() {

var that = this,

// Locate the <form data-submit> tag

form = this.view.querySelectorAll("[data-submit]")[0],

// Get the method name stored in the "data-submit" HTML5 attribute value

formSubmitMethod = form.getAttribute("data-submit");

// Create an event listener to execute the method by the given name when the <form> is

// submitted

form.addEventListener("submit", function(evt) {

// Ensure the default <form> tag behavior does not run and the page does not refresh

evt.preventDefault();

// Grab the email address entered in the <input> field within the <form>

var email = form.getElementsByTagName("input")[0].value;

// Locate the given method in the ViewModel's "methods" property and execute it,

// passing in the email address entered in the <form>

if (that.methods[formSubmitMethod] && typeof that.methods[formSubmitMethod] === "function") {

that .methods[formSubmitMethod](email);

}

});

};

// Define a method to construct the list of email addresses from the data stored in the Model.

// This method is later connected to the events triggered by the Model such that the list is

// recreated each time the data in the Model changes

EmailViewModel.prototype.bindList = function() {

// Get the latest data from the Model

var data = this.model.getAll(),

index = 0,

length = data.length,

that = this;

// Define a function to create an event handler function based on a given email address,

// which executes the method name stored in the "data-click" HTML5 data attribute when the

// <button> tag containing that attribute is clicked, passing across the email address

function makeClickFunction(email) {

return function(evt) {

// Locate the method name stored in the HTML5 "data-click" attribute

var methodName = evt.target.getAttribute("data-click");

// Locate the given method in the ViewModel's "methods" property and execute it,

// passing in the email address provided

if (that.methods[methodName] && typeof that.methods[methodName] === "function") {

that.methods[methodName](email);

}

};

}

// Empty the contents of the <ul data-loop> element, removing all previously created <li>

// elements within it

this.listElement.innerHTML = "";

// Loop through the email addresses stored in the Model, creating <li> tags for each

// based on the structure from the original state of the View which we stored previously

for (; index < length; index++) {

email = data[index];

// Create a new <li> tag as a clone of the stored tag

newListItem = this.listItemElement.cloneNode(true);

// Locate the <span data-text> element and populate it with the email address

newListItem.querySelectorAll("[data-text]")[0].innerHTML = email;

// Locate the <button data-click> element and execute the makeClickFunction() function

// to create an event handler specific to the email address in this turn of the loop

newListItem.querySelectorAll("[data-click]")[0].addEventListener("click", makeClickFunction(email), false);

// Append the populated <li> tag to the <ul data-loop> element in the View

this.listElement.appendChild(newListItem);

}

};

// Define a method to clear the email address entered in the <input> field

EmailViewModel.prototype.clearInputField = function() {

var textField = this.view.querySelectorAll("input[type=text]")[0];

textField.value = "";

};

// The bindEvents() method connects the events broadcast by the Model to the View

EmailViewModel.prototype.bindEvents = function() {

var that = this;

// Define a function to execute whenever the data in the Model is updated

function updateView() {

// Recreate the list of email addresses from scratch

that.bindList();

// Clear any text entered in the <input> field

that.clearInputField();

}

// Connect the updateView() function to the two events triggered by the Model

observer.subscribe("model.email-address.added", updateView);

observer.subscribe("model.email-address.removed", updateView);

};

最后,我们可以将清单 8-1 中的模型、清单 8-8 中的普通 HTML 视图和清单 8-9 中的视图模型放在一起,使用清单 8-10 中的代码来初始化应用。要运行代码,在文件末尾指定的地方添加对清单 8-8 中视图的<script>标记引用,以加载这些代码清单和清单 7-6 中的 observer 模式。例如:

<script src="Listing7-6.js"></script>

<script src="Listing8-1.js"></script>

<script src="Listing8-9.js"></script>

<script src="Listing8-10.js"></script>

因为我们将这些<script>引用添加到视图 HTML 页面中,所以我们能够简单地使用document.body属性获得对页面的 DOM 表示的引用,如清单 8-10 所示,它初始化了应用。

清单 8-10。正在使用的 MVVM 模式

// Use EmailModel from Listing 8-1

var emailModel = new EmailModel([

"``denodell@me.comT2】

"``denodell@gmail.comT2】

"``den.odell@akqa.comT2】

]),

// Now our View is a HTML document, we can get a reference to the whole page and use that

emailView = document.body,

// Create an instance of our ViewModel as we would do with either Controller or Presenter in

// MVC and MVP patterns, respectively. Pass in the Model data and View (HTML document).

emailViewModel = new EmailViewModel(emailModel, emailView);

emailViewModel.initialize();

应该清楚的是,模型-视图-视图模型模式的好处是,与模型-视图-展示者和模型-视图-控制器模式相比,视图可以采用更简单的形式,这在应用中视图越多就越有用。将视图从连接它和模型的代码中分离出来,也意味着团队中的不同开发人员可以独立地在不同的层上工作,在适当的阶段将他们的工作合并在一起,降低了与彼此代码冲突的风险。在撰写本文时,MVVM 正迅速成为专业 JavaScript 开发人员最常用的架构模式。

要了解有关模型-视图-视图模型模式的更多信息,请查看以下在线资源:

架构模式框架

有许多预先构建的模型-视图-控制器(MVC)、模型-视图-演示者(MVP)和模型-视图-视图模型(MVVM) JavaScript 库可以在你自己的应用中实现我们在本章中讨论的架构模式。这些可以简化大型代码库的开发,因为它们将数据管理代码与呈现用户界面的代码分开。但是要小心,因为这些框架很多都很大,结果可能会降低应用的加载速度。一旦你的代码达到一定的规模,将一个框架应用到你的代码中,你将会意识到使用这些架构模式之一将会解决你正在经历的一个开发问题。请记住,设计模式是开发工具箱中的工具,必须小心使用,以满足代码中的特定需求。

如果您正在寻找一个在代码中采用架构模式的框架,请查看下面的流行备选方案列表:

| 结构 | 模式 | 笔记 | | --- | --- | --- | | 玛丽亚 | 手动音量调节 | 基于 20 世纪 70 年代最初的 MVC 框架,这是 MVC 框架最真实的表现。T2http://bit.ly/maria_mvc | | SpineJS | 手动音量调节 | Spine 力争拥有所有 JavaScript MVC 框架中最全面的文档。T2http://bit.ly/spinejs | | EmberJS | 手动音量调节 | Ember 依靠配置来简化开发,让开发人员快速熟悉框架。T2http://bit.ly/ember_js | | 毅力 | 最有价值球员 | Backbone 是一个流行的框架,它有一个大的 API,允许几乎任何类型的应用使用 MVP 模式蓬勃发展。T2http://bit.ly/backbone_mvp | | 敏捷性 JS | 最有价值球员 | Agility 引以为豪的是它是目前最轻的 MVP 框架,压缩了 4KB。T2http://bit.ly/agilityjs | | KnockoutJS | 视图模型 | Knockout 是为了支持 MVVM 模式及其与 HTML 用户界面的数据绑定而构建的。它有很好的、全面的文档,支持从 Internet Explorer 的旧版本到版本 6,并且有一个庞大的社区支持它。T2http://bit.ly/knockout_mvvm | | 安古斯 | 视图模型 | 谷歌的 Angular 正迅速成为最流行的架构框架,支持数据绑定的 MVVM 原则,将用户界面连接到底层数据模型。请注意,更高版本仅支持 Internet Explorer 的版本 9 及更高版本。T2http://bit.ly/angular_js |

摘要

在这一章中,我们研究了三种主要的架构模式,可以用来更好地构建和维护您的 JavaScript 应用,从而结束了关于设计模式的部分。这些是 JavaScript 开发的瑞士军刀中的工具,但是像所有工具一样,您需要知道何时何地最好地使用它们。熟悉本章中的模式,并确保在代码中意识到需要某个模式之前不要使用它。这是一个特别重要的建议,因为许多人犯了这样的错误,例如,在一个大的已存在的框架上构建他们的小应用,而没有意识到他们可以通过编写他们自己需要的精确代码来节省大量的开发时间和页面加载时间。不要落入这个陷阱——从您的应用需要的确切代码开始,然后在您意识到需要时应用设计模式,而不是相反。

在下一章中,我们将会看到我们在第 6 章中提到的对模块设计模式的现代改进,允许模块在大型 JavaScript 应用需要它们的时候异步加载,以及它们所需的任何依赖。