JavaScript-开发高级教程-五-

56 阅读20分钟

JavaScript 开发高级教程(五)

原文:Pro JavaScript Development

协议:CC BY-NC-SA 4.0

八、设计模式:构建型

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

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

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

MVC 架构模式实际上是我们之前在第六章和第七章中看到的三种特定设计模式的组合:观察者、复合和策略。当模型中的数据发生变化时,观察者模式被用来触发一个事件,该事件传递更新后的数据以供系统的其他部分使用。类似地,视图使用相同的模式来监听模型数据的变化,并用这些新数据更新用户界面。视图只能直接从模型中读取数据,而不能设置它;那是管制员的角色。视图还可以包含子视图,以处理更大 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.com

"``denodell@gmail.com

"``den.odell@akqa.com

]),

// 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.com

"``denodell@gmail.com

"``den.odell@akqa.com

]),

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 .methodsformSubmitMethod;

}

});

};

// 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.methodsmethodName;

}

};

}

// 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.com

"``denodell@gmail.com

"``den.odell@akqa.com

]),

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

摘要

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

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

九、管理代码文件依赖项

随着时间一年一年地过去,我们开发人员进一步踏入了一个充满 JavaScript 的网站和应用的勇敢新世界。使用 jQuery 之类的代码库、AngularJS ( http://angularjs.org )、Backbone ( http://backbonejs.org )或 Ember ( http://emberjs.com )之类的框架,以及许多其他高质量、可重用的插件,可以简化 JavaScript 开发的核心方面,使我们能够构建更丰富的用户体验,既实用又有趣。

我们添加到解决方案中的每个额外的 JavaScript 文件都会带来额外的复杂性,特别是我们如何管理该文件与代码库中其余 JavaScript 文件的关系。我们可以在一个文件中编写一些代码,使用一个单独文件中的 jQuery 插件与我们的页面进行交互,这反过来依赖于 jQuery 的存在和从另一个文件中加载。随着解决方案规模的增长,文件之间可能的连接数也在增长。我们说,任何需要另一个文件才能正常运行的 JavaScript 文件都依赖于该文件。

大多数情况下,我们以线性和手动的方式管理我们的依赖关系。在 HTML 文件的末尾,在结束的</body>标记之前,我们通常按顺序列出 JavaScript 文件,从最普通的库和框架文件开始,一直到最特定于应用的文件,确保每个文件都列在它的依赖项之后,这样在试图访问其他尚未加载的脚本文件中定义的变量时就不会出现错误。随着我们的解决方案中文件数量的增长,这种依赖关系管理的方法变得越来越难以维护,特别是如果您希望在不影响依赖它的任何其他代码的情况下删除一个文件。

使用 RequireJS 管理代码文件依赖项

我们显然需要一种比这更健壮的方法来管理大型网站和应用中的依赖关系。在这一章中,我将解释如何使用 RequireJS 更好地管理您的代码文件依赖性,这是一个 JavaScript 模块加载器,旨在解决这个问题,它具有按需异步脚本文件加载的额外优势,这是我们在第四章中提到的一种提高网站性能的方法。

RequireJS 库基于异步模块定义(AMD) API ( http://bit.ly/amd_api ),这是一种定义代码块及其依赖关系的跨语言统一方式,在行业中获得了很大的吸引力,并在 BBC、Hallmark、Etsy 和 Instagram 等网站上实现。

为了演示如何将 RequireJS 合并到一个应用中,让我们从清单 9-1 所示的简单的索引 HTML 页面开始,它包含一个非常基本的表单,当提交时,会将一个电子邮件地址发布到一个单独的感谢页面,如清单 9-2 所示。清单 9-3 中的代码显示了应用于这个演示页面的 CSS 样式,我们将把它存储在一个名为main.css的文件中。我们使用了谷歌字体( http://bit.ly/g_fonts )中的字体龙虾和亚伯。

清单 9-1。主演示页面的 HTML 代码,包含一个向邮件列表添加电子邮件的表单

<!doctype html>

<html>

<head>

<meta charset="utf-8">

<title>Mailing list</title>

<link href="http://fonts.googleapis.com/css?family=Lobster|Abel

<link rel="stylesheet" href="Listing9-3.css">

</head>

<body>

<form action="Listing9-2.html" id="form" method="post">

<h1>Join our mailing list</h1>

<label for="email">Enter your email address</label>

<input type="text" name="email" id="email" placeholder="e.g. me@mysite.com">

<input type="submit" value="Sign up">

</form>

</body>

</html>

清单 9-2。提交电子邮件地址后,HTML 感谢页面将用户导向

<!doctype html>

<html>

<head>

<meta charset="utf-8">

<title>Thank you</title>

<link href="http://fonts.googleapis.com/css?family=Lobster|Abel

<link rel="stylesheet" href="Listing9-3.css">

</head>

<body>

<div class="card">

<h1>Thank you</h1>

<p>Thank you for joining our mailing list.</p>

</div>

</body>

</html>

清单 9-3。应用于清单 9-1 和清单 9-2 中 HTML 页面的 CSS 样式规则

html,

body {

height: 100%;

}

body {

font-size: 62.5%;

margin: 0;

background: #32534D;

background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, from(#1a82f7), to(#2F2727));

background-image: -webkit-linear-gradient(top, #1a82f7, #2F2727);

background-image: -moz-linear-gradient(top, #1a82f7, #2F2727);

background-image: -ms-linear-gradient(top, #1a82f7, #2F2727);

background-image: -o-linear-gradient(top, #1a82f7, #2F2727);

}

body,

input {

font-family: "Lobster", sans-serif;

}

h1 {

font-size: 4.4em;

letter-spacing: -1px;

padding-bottom: 0.25em;

}

form,

.card {

position: absolute;

top: 100px;

bottom: 100px;

min-height: 250px;

left: 50%;

margin-left: -280px;

width: 400px;

padding: 20px 80px 80px;

border: 2px solid #333;

border-radius: 5px;

box-shadow: 5px 5px 15px #000;

background: #fff;

background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, from(#eee), to(#fff));

background-image: -webkit-linear-gradient(top, #eee, #fff);

background-image: -moz-linear-gradient(top, #eee, #fff);

background-image: -ms-linear-gradient(top, #eee, #fff);

background-image: -o-linear-gradient(top, #eee, #fff);

}

label,

input,

p {

display: block;

font-size: 1.8em;

width: 100%;

}

label,

input[type=email],

p {

font-family: "Abel", cursive;

}

input {

margin-bottom: 1em;

border: 1px solid #42261B;

border-radius: 5px;

padding: 0.25em;

}

input[type=submit] {

background: #dda;

color: #000;

font-weight: bold;

width: 103%;

font-size: 3em;

margin: 0;

box-shadow: 1px 1px 2px #000;

}

.error {

border: 1px solid #f99;

background: #fff5f5;

}

运行到目前为止我们已经拥有的代码,产生如图 9-1 所示的页面布局。

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

图 9-1。

The final page we’re building represents a newsletter sign-up form, which will only submit if the e-mail address provided is in a valid format

在我们编写任何 JavaScript 代码之前,我们将从项目主页的 http://bit.ly/require_dl 下载一份 RequireJS。在撰写本文时,该库的当前版本是 2.1.9,它在所有主流 web 浏览器中都受到支持,包括 Internet Explorer 6、Firefox 2、Safari 3.2、Chrome 3 和 Opera 10。

现在,在我们将 RequireJS 添加到页面之前,我们需要回顾一下我们的应用需要哪些 JavaScript 文件,并将它们组织到适当的文件夹结构中。我们将从主应用脚本文件开始,该文件使用 jQuery 监听 HTML 表单上的提交事件,并在发生时执行表单验证,只有在没有错误的情况下才允许表单继续提交。因此,除了 RequireJS 库之外,我们还有三个 JavaScript 文件,如表 9-1 所示。

表 9-1。

The three JavaScript files used in our project, in addition to RequireJS

| 文件名 | 描述 | | --- | --- | | `jquery-1.10.2.js` | jQuery 库的最新版本,用于访问和操作页面上的 DOM 元素 | | `validation-plugin.js` | 作为 jQuery 插件的表单验证脚本 | | `main.js` | 主应用脚本文件 |

让我们将这些文件与 RequireJS 和项目的其余文件一起整理到一个合理的文件夹结构中,如图 9-3 所示,第三方脚本和插件一起分组到scripts文件夹中一个名为lib的子文件夹中。

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

图 9-3。

Folder structure for our RequireJS-based project

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

图 9-2。

The RequireJS homepage at requirejs.org contains the library files plus plenty of documentation

加载和初始化要求

让我们借此机会在我们的 HTML 页面上加载并设置 RequireJS,方法是在清单 9-1 中的 HTML 页面的末尾添加一个<script>标记,就在</body>标记的末尾之前,指向库文件的位置。尽管我们可以在这一点之后添加多个<script>标签来包含我们剩余的代码,但是我们可以依靠 RequireJS 的异步文件加载特性来加载这些标签。通过向我们的<script>标签添加一个data-main属性,我们可以为我们的项目指定主应用脚本文件的位置。当 RequireJS 初始化时,它将自动和异步地加载该属性值中引用的任何文件。我们只需要在页面上有一个<script>标签:

<script src="scripts/require.js" data-main="scripts/main"></script>

注意,在我们的data-main属性中,当引用任何带有 RequireJS 的文件时,可以排除.js文件扩展名,因为默认情况下它采用这个扩展名。

具有特定用途或行为的可重用代码块(称为模块)由 RequireJS 使用其内置的define()函数定义,该函数遵循此处所示的模式,具有三个输入参数,分别命名模块、定义其依赖项和包含模块代码本身:

define(

moduleName,   // optional, defaults to name of file if parameter is not present

dependencies, // optional array listing this file's dependencies

function(parameters) {

// Function to execute once dependencies have been loaded

// parameters contain return values from the dependencies

}

);

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

图 9-4。

The BBC is a proponent of RequireJS and has its own documentation site for their developers to refer to when building JavaScript modules

在对define()的调用中所需要的只是一个包含要执行的模块代码的函数。通常,我们会为代码库中的每个模块创建一个单独的文件,默认情况下,模块名称会在 RequireJS 中通过其文件名来标识。如果您的模块依赖于其他代码文件才能正常运行(例如,一个 jQuery 插件需要 jQuery),您应该在传递给define()的数组中列出这些所谓的依赖关系,放在包含模块代码的函数参数之前。在这个数组中使用的名称通常对应于依赖项的文件名,这些依赖项相对于 RequireJS 库文件本身的位置。在我们的项目中,如果我们想将 jQuery 库作为另一个模块的依赖项列出,我们可以将它包含在依赖项数组中,如清单 9-4 所示。

清单 9-4。定义依赖于 jQuery 的模块

define(["lib/jquery-1.10.2"], function($) {

// Module code to execute once jQuery is loaded goes here. The jQuery library

// is manifest through the first parameter to this function, here named $

});

回想一下,我们不需要指定文件扩展名.js,所以在数组参数中列出依赖项时,我们不考虑这个。顺便提一下,jQuery 的最新版本包含使用define()函数将自己注册为模块的代码,如果这个函数出现在页面上,那么我们不需要编写任何特殊的代码来将 jQuery 库转换为我们需要的格式,以便与 RequireJS 一起使用。其他库可能需要一些初始设置才能与 RequireJS 一起使用。通过 http://bit.ly/require_shim 阅读关于创建在这种情况下使用的垫片的文档部分。

依赖代码文件提供的任何返回值都通过输入参数传递给模块的函数,如清单 9-4 所示。只有传递给该函数的这些参数才应该在该模块中使用,以便正确地封装代码及其依赖项。这也有轻微的性能优势,因为 JavaScript 访问函数范围内的局部变量比提升到周围的范围以解析变量名和值要快。我们现在有了一种方法来整理我们的模块代码和它所依赖的代码之间的关系,正是这种关系告诉 RequireJS 在执行模块功能之前加载对我们的模块功能至关重要的所有代码。

对模块名称使用别名

jQuery 开发团队有一个惯例,用它所代表的发布版本号来命名它的库文件;这里我们使用的是版本1.10.2。如果我们在多个文件中大量引用 jQuery 作为依赖项,那么如果我们希望在以后更新我们站点中使用的 jQuery 版本,我们就会给自己制造一个维护问题。我们必须使用 jQuery 作为依赖项对所有这些文件进行修改,以匹配包含更新版本号的新文件名。幸运的是,RequireJS 允许我们通过为某些模块定义替代别名来解决这个问题;我们能够创建一个映射到我们的版本化文件名的单个模块名别名,这样我们就可以在我们的文件中使用该名称来代替直接文件名。这是在 RequireJS 配置对象中设置的。让我们从清单 9-5 所示的代码开始我们的主应用脚本文件(main.js),为 jQuery 创建这个模块别名到文件名的映射。

清单 9-5。通过创建到 jQuery 的别名映射开始主应用脚本文件

requirejs.config({

paths: {

"jquery": "lib/jquery-1.10.2"

}

});

我们现在可以在模块的依赖数组中使用模块名jquery而不是它的文件名,这将映射到 jQuery 的指定版本。

内容交付网络和回退

许多开发人员更喜欢参考来自 web 上众多全球内容交付网络(CDN)之一的 jQuery 或其他流行库的副本。在适当的条件下,这将减少下载文件所需的时间,并增加文件可能已经缓存在用户机器上的可能性,如果他们以前访问过从同一 CDN 加载相同版本的 jQuery 的另一个网站。

RequireJS 允许您通过使用依赖关系数组中的 URL 来链接到托管在其他域上的模块,但是我们可以使用前面配置 jQuery 时使用的配置设置来简化外部文件依赖关系的管理。我们将用一个新的代码片段替换最后一个代码片段,以引用来自 Google CDN 的 jQuery,同时仍然允许它在外部文件加载失败时回退到文件的本地版本。我们可以在配置对象中使用一个数组来链接回退脚本列表,如清单 9-6 所示。

清单 9-6。一个有两个可能位置的模块,一个在第一个没有加载的情况下用作后备

requirejs.config({

paths: {

"jquery": [

"https://ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min

// If the CDN fails, load from this local file instead

"lib/jquery-1.10.2"

]

}

});

创建模块

现在我们已经有了文件结构,并需要在页面上加载和配置,是时候考虑应用中的文件是如何相互依赖的了。我们已经确定我们的主应用脚本依赖于 jQuery,以便设置表单提交处理程序和验证脚本来验证表单。因为验证脚本将作为 jQuery 插件构建,所以它也依赖于 jQuery。我们可以用表 9-2 中的表格来描述这些依赖关系。

表 9-2。

The code file dependencies for each script in our project

| 脚本文件 | 属国 | | --- | --- | | 框架 | 没有依赖关系 | | 验证外挂程式 | 仅 jQuery | | 主应用脚本 | jQuery 和验证插件 |

我们现在可以在validation-plugin.js文件中为我们的验证 jQuery 插件模块编写代码,指定 jQuery 作为它唯一的依赖项。该模块检查给定字段的值是否是电子邮件地址的典型格式,如果是有效的电子邮件地址,则返回 true,否则返回 false,如清单 9-7 所示。

清单 9-7。作为 jQuery 验证插件的 RequireJS 模块

define(["jquery"], function($) {

$.fn.isValidEmail = function() {

var isValid = true,

// Regular expression that matches if one or more non-whitespace characters are

// followed by an @ symbol, followed by one or more non-whitespace characters,

// followed by a dot (.) character, and finally followed by one or more non-

// whitespace characters

regEx = /\S+@\S+\.\S+/;

this.each(function() {

if (!regEx.test(this.value)) {

isValid = false;

}

});

return isValid;

};

});

我们在对define()函数的调用中省略了可选的模块名参数,所以模块将用相对于 RequireJS 位置的文件名注册。它可以在其他依赖关系中被模块名lib/validation-plugin引用。

现在我们已经建立了依赖关系,是时候完成主应用脚本文件中的代码了。这里我们不打算使用define()函数;相反,我们将使用 RequireJS 的require()函数和 AMD API。这两种方法具有相同的模式,但是它们的使用方式不同。前者用于声明模块以备后用,而后者用于加载依赖项以便立即执行,而无需从中创建可重用的模块。后一种情况适合我们的主应用脚本,它将只执行一次。

在我们的main.js文件中的配置代码下面,我们需要声明附加到 HTML 表单提交事件的代码,如清单 9-8 所示,使用我们新的 jQuery 插件脚本执行验证,如果提供的电子邮件地址有效,允许表单提交。

清单 9-8。添加到我们项目的主应用脚本中,将页面连接到我们的模块

require(["jquery", "lib/validation-plugin"], function($) {

var $form = $("#form"),

$email = $("#email");

$form.on("submit", function(e) {

e.preventDefault();

if ($email.isValidEmail()) {

$form.get(0).submit();

} else {

$email.addClass("error").focus();

}

});

$email.on("keyup", function() {

$email.removeClass("error");

});

});

当清单 9-8 中的代码在清单 9-1 中登录页面的上下文中的浏览器中执行时,jQuery 库将首先被加载,然后是我们的验证插件模块。您还记得,当我们定义验证模块时,我们指定它也依赖于 jQuery。RequireJS 的一个很棒的特性是,如果它遇到一个已经被引用的依赖项,它将使用内存中存储的值,而不是再次下载它,这允许我们正确地定义我们的代码文件依赖项,而不会影响下载的数据量。

一旦加载了依赖项,就执行该函数,并将依赖项的任何返回值作为参数传递。因为我们将验证器模块定义为一个 jQuery 插件,所以我们没有指定返回值;和其他插件一样,它将被添加到 jQuery $变量中。

按需加载附加脚本

我们可以扩展我们的代码,以利用 RequireJS 可以在您的应用中需要 JavaScript 依赖项的时候按需加载这些依赖项。我们不需要在页面加载时立即加载验证插件(就像我们现在做的),我们只需要在用户提交表单时加载插件并使其可用。通过减少最初请求页面时下载的数据量和执行的代码量,转移到更高效的按需脚本模型,卸载该脚本的负载将提高页面加载性能。

RequireJS 允许我们在希望下载额外的依赖项时简单地调用require()函数。我们可以重写我们的主应用脚本,在初始页面加载时删除对验证插件的依赖,并在用户试图提交表单时将其添加到页面中。如果用户从未提交表单,则不会加载该文件。我在清单 9-9 所示的主应用脚本的更新代码中突出显示了对require()函数的额外调用。

清单 9-9。更新了主应用脚本,以便在需要时按需加载验证插件

require(["jquery"], function($) {

var $form = $("#form"),

$email = $("#email");

$form.on("submit", function(e) {

e.preventDefault();

require(["lib/validation-plugin"], function() {

if ($email.isValidEmail()) {

$form.get(0).submit();

} else {

$email.addClass("error").focus();

}

});

});

$email.on("keyup", function() {

$email.removeClass("error");

});

});

当用户试图使用这个更新的脚本提交表单时,会加载验证器插件并尝试验证。如果用户第二次尝试验证,那么验证器插件已经被加载,所以不会再被下载。图 9-5 显示了加载到页面上的脚本的瀑布时间表。第二条垂直线表示页面已经完全加载,而最右边的小圆点表示验证插件脚本加载的时间,此时用户与表单进行交互,从而减少了页面准备使用之前加载的数据量。

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

图 9-5。

RequireJS supports the loading of JavaScript files dynamically on demand as needed by your application, reducing the number of HTTP requests on page load

当然,表单提交可能要等到文件下载后才会发生,所以如果插件脚本是一个大文件,这可能会影响页面的响应。如果您愿意,您可以通过在用户第一次关注表单中的文本字段时下载验证器插件来抵消这种影响。这应该给浏览器足够的时间来下载插件文件,以便它为用户提交表单做好准备。

RequireJS 代码优化工具

如果您在开发设置中运行构建工具,或者在编码后打包代码用于部署,您可以利用 http://requirejs.org 提供的 RequireJS 优化工具。这结合了相关的脚本,并使用 UglifyJS 或 Google Closure 编译器对它们进行了精简,我们在第三章的中提到过。

优化器被构建为在 Java 或 NodeJS 上运行(首选,因为它运行得更快),因此它可以从命令行或通过自动化工具运行。它研究应用中列出的、与 JavaScript 文件中每个模块相关联的依赖项,并将总是一起使用的依赖项文件合并到一个文件中,动态更新文件中的依赖项列表以进行匹配。这使得代码执行时的 HTTP 请求更少,从而在不改变开发中使用的原始代码的情况下改善了最终用户的体验。

如果您想了解更多关于 RequireJS 优化工具的信息,请通过 http://bit.ly/require_opt 查看文档和示例。

所需的附加插件

RequireJS 通过将插件脚本与 RequireJS 一起放在 scripts 文件夹中来支持其自身功能的扩展。表 9-3 显示了我遇到的一组优秀插件,你可能希望考虑在你的应用中使用它们。

表 9-3。

Plugins for RequireJS

| 插件 | 描述 | | --- | --- | | 詹姆斯·伯克的 i18n | 用于应用中文本本地化的插件。通过创建以 ISO 语言环境命名的文件,并且每个文件都包含一个表示 web 应用中与该特定语言和国家相关的文本字符串的相似对象,您可以配置 RequireJS 只加载与用户当前查看的站点的语言环境版本相关的文件。可通过 [`http://bit.ly/req_i18n`](http://bit.ly/req_i18n) 在线获得 | | 詹姆斯·伯克的文本 | 允许将任何基于文本的文件作为依赖项加载进来(默认情况下,只加载脚本文件)。任何列出的带有模块名前缀`text!`的依赖项将使用 XmlHttpRequest (XHR)加载,并作为代表文件全部内容的字符串传递给模块。这对于从外部文件加载固定的 HTML 标记块以便在您自己的模块中呈现或处理非常方便。可通过 [`http://bit.ly/req_text`](http://bit.ly/req_text) 在线获得 | | 米勒·梅德罗斯字体 | 允许您通过 Google 的 WebFont Loader API 加载字体,将您需要的字体指定为前缀为字符串`font!`的依赖项。可通过 [`http://bit.ly/req_font`](http://bit.ly/req_font) 在线获得 | | 亚历克斯·塞顿设计的车把 | 插件加载到`handlebars.js`模板文件中作为模块中的依赖项。返回的模板参数是一个函数,您可以将数据传递给它,也可以将它作为一个依赖项加载进来。执行该函数的结果是一个 HTML 字符串,然后将它注入到页面中。访问 [`http://handlebarsjs.com`](http://handlebarsjs.com/) 了解更多关于车把模板库的信息。可通过 [`http://bit.ly/req_handle`](http://bit.ly/req_handle) 在线获得 | | 由 Jens Arps 缓存 | 默认情况下,RequireJS 会在加载后将模块存储在内存中,如果发生页面刷新,会再次下载模块。有了这个插件,任何加载的模块都将存储在浏览器的`localStorage`中,并在随后的页面刷新时从那里加载,以减少页面刷新时的 HTTP 请求数量。可通过 [`http://bit.ly/req_cache`](http://bit.ly/req_cache) 在线获得 |

如果你看到你觉得缺少的功能或者没有达到标准(或者你只是想冒险),你可以通过使用 RequireJS 插件 API 编写你自己的插件,详情通过 http://bit.ly/req_plugin

要求的替代方案 j

尽管 RequireJS 是浏览器中管理代码依赖关系最常用的库,但它不是唯一可用的选项。因为每一个都是基于 AMD 规范的,所以表 9-4 中显示的每一个备选方案都以相似的方式工作,所以,只要稍加协调,它们就可以相互替换使用。

表 9-4。

Browser-based module loaders

| 图书馆 | 统一资源定位器 | | --- | --- | | BDLoad | [`http://bdframework.org/bdLoad/`](http://bdframework.org/bdLoad/) | | 狭谷 | [`https://github.com/requirejs/cajon`](https://github.com/requirejs/cajon) | | 卷发 | [`https://github.com/cujojs/curl`](https://github.com/cujojs/curl) | | LoaderJS | [`https://github.com/pinf/loader-js`](https://github.com/pinf/loader-js) | | 要求的 | [`http://requirejs.org`](http://requirejs.org/) | | UMD | [`https://github.com/umdjs/umd`](https://github.com/umdjs/umd) | | Yabble | [`https://github.com/jbrantly/yabble`](https://github.com/jbrantly/yabble) |

摘要

在这一章中,我已经向你介绍了如何构建一个简单的页面,这个页面使用 RequireJS 来简化代码文件依赖关系的管理,并允许将脚本文件的加载延迟到需要的时候。这种方法不仅使管理不断增长的代码变得更加容易,还允许您通过只在需要的时候加载所需的代码来提高 web 应用的性能。

RequireJS 的能力甚至超过了我在这一章中提到的。我鼓励您通读库主页上的文档:了解如何使用许多有用的配置选项,如何为模块提供替代名称,如何直接从 JSONP web 服务加载和存储数据,以及如何同时加载和管理同一模块的多个版本(以及许多其他功能)。

这种代码依赖管理和脚本文件加载的方法是当今世界中新兴的行业最佳实践,在这个世界中,网站和应用的 JavaScript 代码库不断增长。我全心全意地鼓励你更多地了解这种方法,并在你自己的网站中采用它,为你自己获得好处。

在下一章中,我们将介绍与移动设备相关的 JavaScript 开发,包括以最小的内存占用充分利用您的代码的技术,以及学习如何处理来自当今市场上许多智能手机和平板设备上的传感器的输入。