JavaScript 现代 Web 开发框架教程(三)
六、Browserify
少即是多。—路德维希·密斯·凡·德罗
Browserify 是一个 JavaScript 模块加载器,它通过充当代码的“预处理程序”来解决该语言当前缺乏对浏览器中导入模块的支持的问题。与 CSS 扩展(如 SASS 和 LESS)为样式表带来了增强的语法支持一样,Browserify 通过递归扫描源代码来增强客户端 JavaScript 应用对全局require()函数的调用。当 Browserify 找到这样的调用时,它会立即加载引用的模块(使用 Node.js 中可用的相同的require()函数)并将它们组合成一个单一的、缩小的文件——一个“包”——然后可以在浏览器中加载。
这种简单而优雅的方法为浏览器带来了 CommonJS(在 Node.js 中加载模块的方法)的强大和便利,同时也消除了异步模块定义(AMD)加载器(如 RequireJS)所需的额外复杂性和样板代码(在第五章中描述)。
在本章中,您将学习如何
- 区分 AMD 和 CommonJS 模块加载器
- 创建模块化前端 JavaScript 应用,这些应用遵循由 Node.js 等工具普及的简单模块管理模式
- 可视化项目的依赖关系树
- 使用 Browserify 的姊妹应用 Watchify,在发生变化时尽快编译您的应用
- 使用第三方浏览器插件(“转换”)来扩展工具的核心功能
Note
本章的部分内容讨论了本书前几章已经涉及的概念,包括 Bower(第一章)和咕噜声(第二章)。如果您不熟悉这些工具,建议您在继续之前先了解这些材料。
AMD API 与 CommonJS
异步模块定义 API,在第五章的中有所介绍,是 JavaScript 当前缺乏对内联加载外部模块支持的一个巧妙的解决方案。通常被称为“浏览器优先”的方法,AMD API 通过要求开发人员将他们的每个模块包装在回调函数中,然后可以根据需要异步加载(即“延迟加载”)来实现其将模块带到浏览器的目标。清单 6-1 中所示的模块演示了这一过程。
Listing 6-1. Defining and Requiring an AMD Module
// requirejs-example/public/app/weather.js
define([], function() {
return {
'getForecast': function() {
document.getElementById('forecast').innerHTML = 'Partly cloudy.';
}
};
});
// requirejs-example/public/app/index.js
define(['weather'], function(weather) {
weather.getForecast();
});
AMD API 既聪明又有效,但是许多开发人员也发现它有点笨拙和冗长。理想情况下,JavaScript 应用应该能够引用外部模块,而不会像 AMD API 那样增加复杂性和样板代码。幸运的是,有一种流行的替代方法 CommonJS 可以解决这个问题。
虽然大多数人倾向于将 JavaScript 与 web 浏览器联系在一起,但事实是,JavaScript 已经在许多其他环境中广泛使用了一段时间——远在 Node.js 出现之前。这种环境的例子包括 Rhino,一个由 Mozilla 创建的服务器端运行时环境,以及 ActionScript,一个由 Adobe 曾经流行的 Flash 平台使用的衍生品,近年来已经失宠。这些平台都通过创建自己的方法来解决 JavaScript 缺乏内置模块支持的问题。
意识到这个问题需要一个标准的解决方案,一组开发人员聚集在一起,提出了 CommonJS,一种定义和使用 JavaScript 模块的标准化方法。Node.js 遵循类似的方法,JavaScript 的下一个重大更新也是如此(ECMAScript 6,也就是 ES6 Harmony)。这种方法也可以用来编写模块化的 JavaScript 应用,这些程序可以在今天使用的所有 web 浏览器中运行,尽管还需要一些其他工具的帮助,比如本章的主题 Browserify。
安装浏览器
在进一步操作之前,您应该确保已经安装了 Browserify 的命令行工具。作为一个 npm 包,安装过程如清单 6-2 所示。
Listing 6-2. Installing the browserify Command-Line Utility via npm
$ npm install -g browserify
$ browserify --version
10.2.4
Note
Node 的软件包管理器(npm)允许用户在两种环境中安装软件包:本地或全局。在本例中,browserify安装在全局上下文中,该上下文通常是为命令行工具保留的。
创建您的第一个包
Browserify 的吸引力在于它的简单性;熟悉 CommonJS 和 Node 的 JavaScript 开发人员会发现自己很快就能如鱼得水。举例来说,考虑清单 6-3 ,它显示了我们在清单 6-1 中看到的简单的基于 RequireJS 的应用的基于 CommonJS 的等价物。
Listing 6-3. Front-End Application That Requires Modules via CommonJS
// simple/public/app/index.js
var weather = require('./weather');
weather.getForecast();
// simple/public/app/weather.js
module.exports = {
'getForecast': function() {
document.getElementById('forecast').innerHTML = 'Partly cloudy.';
}
};
与我们基于 RequireJS 的例子不同,这个应用不能直接在浏览器中运行,因为浏览器缺少通过require()加载模块的内置机制。在浏览器能够理解这个应用之前,我们必须首先借助于browserify命令行工具或者通过 Browserify 的 API 将它编译成一个包。
使用 Browserify 的命令行工具编译该应用的命令如下:
$ browserify app/index.js -o public/dist/app.js
在这里,我们将应用主文件 public/ app/index.js的路径传递给browserify工具,并指定编译后的输出应该保存到public/dist/app.js,这个脚本在项目的 HTML 中引用(参见清单 6-4 )。
Listing 6-4. HTML File Referencing Our Compiled Browserify Bundle
// simple/public/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Browserify - Simple Example</title>
</head>
<body>
<div id="forecast"></div>
<script src="/dist/app.js"></script>
</body>
</html>
除了使用 Browserify 的命令行工具,我们还可以选择通过 Browserify 的 API 以编程方式编译这个应用。这样做将允许我们轻松地将这个步骤合并到一个更大的构建过程中(使用 Grunt 等工具开发)。清单 6-5 显示了这个项目的browserify繁重任务。
Listing 6-5. Grunt Task That Compiles the Application via Browserify’s API
// simple/tasks/browserify.js
module.exports = function(grunt) {
grunt.registerTask('browserify', function() {
var done = this.async();
var path = require('path');
var fs = require('fs');
var src = path.join('public', 'app', 'index.js');
var target = path.join('public', 'dist', 'app.js');
var browserify = require('browserify')([src]);
browserify.bundle(function(err, data) {
if (err) return grunt.fail.fatal(err);
grunt.file.mkdir(path.join('public', 'dist'));
fs.writeFileSync(target, data);
done();
});
});
};
可视化依赖关系树
如果你碰巧更喜欢视觉学习,图 6-1 所示的图表可能会对传达 Browserify 编译过程中发生的事情大有帮助。这里我们看到 Browserify 在编译本章的advanced项目时遇到的各种依赖关系的可视化。
图 6-1。
Visualizing the advanced project’s dependency tree
将此图表视为页面上的静态呈现并不公平。为了获得完整的效果,您应该编译项目,并通过在项目的文件夹中运行npm start在浏览器中查看图表。这样做将允许您将鼠标悬停在图表的各个部分上,每个部分都代表 Browserify 在编译过程中遇到的一个依赖项。虽然在图 6-1 中并不明显,但对图表的深入分析表明,我们应用的定制代码只占 Browserify 生成的包总大小的很小一部分(9.7kB)。这个项目将近 2MB 的代码中的绝大部分由第三方依赖项组成(例如 Angular、jQuery、Lodash 等。),这是一个重要的事实,将在本章后面再次引用。
Note
您可能还对研究browserify-graph和colony命令行工具(也可以通过 npm 获得)感兴趣,您可以使用它们来生成项目依赖树的额外可视化。
发生变化时创建新的包
利用 Browserify 的项目不能直接在浏览器中运行,它们必须首先被编译。为了最有效地使用该工具,重要的是项目的设置方式要能够在源代码发生变化时自动触发这一步骤。让我们来看看实现这一点的两种方法。
用 Grunt 监视文件变化
在 Grunt 的第二章中,您发现了像grunt-contrib-watch这样的插件是如何让开发者在应用源代码发生变化时触发构建步骤的。很容易理解如何将这些工具应用到使用 Browserify 的项目中,并在检测到变更时触发新包的创建。通过运行本章的simple项目的默认 Grunt 任务,可以看到这个过程的一个例子,如清单 6-6 所示。
Listing 6-6. Triggering the Creation of New Browserify Builds with Grunt
$ grunt
Running "browserify" task
Running "concurrent:serve" (concurrent) task
Running "watch" task
Waiting...
Running "server" task
App is now available at: http://localhost:7000
>> File "app/index.js" changed.
Running "browserify" task
Done, without errors.
Completed in 0.615s at Fri Jun 26 2015 08:31:25 GMT-0500 (CDT) - Waiting...
在这个例子中,运行默认的 Grunt 任务触发了三个步骤:
- 立即创建了 Browserify 包。
- 启动了一个 web 服务器来托管该项目。
- 执行一个监视脚本,当检测到源代码更改时,该脚本触发新 Browserify 包的创建。
这种简单的方法通常可以很好地服务于大多数小型项目;然而,随着小项目逐渐演变成大项目,开发人员通常会对随之而来的不断增长的构建时间感到沮丧,这是可以理解的。在你尝试每一个更新之前必须等待几秒钟,这可能会很快破坏你可能希望达到的任何“流畅”感。幸运的是,Browserify 的姐妹应用 Watchify 可以在这些情况下帮助我们。
使用 Watchify 监视文件更改
如果说 Browserify(完整编译应用)可以被认为是切肉刀,那么 Watchify 可以被认为是削皮刀。当被调用时,Watchify 最初完整地编译指定的应用;然而,这个过程完成后,Watchify 并没有退出,而是继续运行,观察项目源代码的变化。当检测到更改时,Watchify 只重新编译那些已更改的文件,从而大大加快构建时间。Watchify 通过在每次构建中维护自己的内部缓存机制来实现这一点。
与 Browserify 一样,Watchify 可以通过命令行或提供的 API 调用。在清单 6-7 中,本章的simple项目是在 Watchify 命令行工具的帮助下编译的。在本例中,传递了参数-v来指定 Watchify 应该以详细模式运行。因此,Watchify 会在检测到更改时通知我们。
Listing 6-7. Installing Watchify via npm and Running It Against This Chapter’s simple Project
$ npm install -g watchify
$ watchify public/app/index.js -o public/dist/app.js -v
778 bytes written to public/dist/app.js (0.03 seconds)
786 bytes written to public/dist/app.js (0.01 seconds)
与 Browserify 一样,Watchify 提供了一个方便的 API,允许我们将其集成到一个更大的构建过程中(参见清单 6-8 )。我们只需对先前在清单 6-7 中显示的 Browserify 任务做一些小小的调整就可以做到。
Listing 6-8. Grunt Task Demonstrating the Use of Watchify’s API
// simple/tasks/watchify.js
module.exports = function(grunt) {
grunt.registerTask('watchify', function() {
var done = this.async();
var browserify = require('browserify');
var watchify = require('watchify');
var fs = require('fs');
var path = require('path');
var src = path.join('public', 'app', 'index.js');
var target = path.join('public', 'dist', 'app.js');
var targetDir = path.join('public', 'dist');
var browserify = browserify({
'cache': {},
'packageCache': {}
});
browserify = watchify(browserify);
browserify.add(src);
var compile = function(err, data) {
if (err) return grunt.log.error(err);
if (!data) return grunt.log.error('No data');
grunt.file.mkdir(targetDir);
fs.writeFileSync(target, data);
};
browserify.bundle(compile);
browserify.on('update', function() {
browserify.bundle(compile);
});
browserify.on('log', function(msg) {
grunt.log.oklns(msg);
});
});
};
在这个例子中,我们用watchify包装我们的browserify实例。之后,我们根据需要通过订阅由我们包装的实例发出的update事件来重新编译项目。
使用多个包
在前面的“可视化依赖关系树”一节中,我们看了一个交互式图表,它允许我们可视化 Browserify 在编译本章的advanced项目时遇到的各种依赖关系(见图 6-1 )。我们可以从这个图表中得到的最重要的事实之一是,项目的定制代码(在/app中找到)只占捆绑包总大小 1.8MB 的很小一部分(9.7kB),换句话说,这个项目的绝大部分代码由第三方库(例如 Angular、jQuery、Lodash 等)组成。)不太可能经常改变。让我们来看看如何利用这些知识。
本章的extracted项目在各方面都与advanced项目相同,除了一个例外:extracted项目的构建过程创建了两个独立的包,而不是编译一个单独的 Browserify 包:
/dist/vendor.js:第三方依赖关系/dist/app.js:自定义应用代码
通过采用这种方法,浏览器可以更有效地访问发布的项目更新。换句话说,当项目的定制代码发生变化时,浏览器只需要重新下载/dist/app.js。将这种方法与advanced项目的方法进行对比,在该项目中,每次更新(无论多小)都迫使客户重新下载该项目的近 2MB 包。
清单 6-9 显示了extracted项目的 HTML 文件。如您所见,这里我们引用了两个独立的包,/dist/vendor.js和/dist/app.js。
Listing 6-9. HTML for This Chapter’s extracted Project
// extracted/public/index.html
<!DOCTYPE html>
<html ng-app="app">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Browserify - Advanced Example</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="container">
<navbar ng-if="user_id"></navbar>
<div ng-view></div>
<footer><a href="/disc.html">View this project’s dependency tree</a></footer>
<script src="/dist/vendor.js"></script>
<script src="/dist/app.js"></script>
</body>
</html>
清单 6-10 显示了extracted项目的 Gruntfile。注意正在设置的特殊配置值(browserify.vendor_modules)。
Listing 6-10. Gruntfile for This Chapter’s extracted Project
// extracted/Gruntfile.js
module.exports = function(grunt) {
grunt.initConfig({
'browserify': {
'vendor_modules': [
'angular',
'bootstrap-sass',
'jquery',
'angular-route',
'angular-sanitize',
'restangular',
'jquery.cookie',
'lodash',
'underscore.string',
'lodash-deep'
]
}
});
grunt.loadTasks('tasks');
grunt.registerTask('default', ['compass', 'browserify', 'browserify-vendor', 'init-db', 'concurrent']);
};
清单 6-11 显示了extracted项目的browserify任务的内容。这个任务很大程度上模仿了advanced项目中的相应任务,只有一个主要的例外。在这个任务中,我们遍历在项目的 Gruntfile 中定义的第三方模块,对于每个条目,我们指示 Browserify 从编译的包中排除引用的模块。
Listing 6-11. The extracted Project’s browserify Grunt Task
// extracted/tasks/browserify.js
module.exports = function(grunt) {
grunt.registerTask('browserify', function() {
var done = this.async();
var path = require('path');
var fs = require('fs');
var target = path.join('public', 'dist', 'app.js');
var vendorModules = grunt.config.get('browserify.vendor_modules') || [];
var browserify = require('browserify')([
path.join('app', 'index.js')
], {
'paths': ['app'],
'fullPaths': true,
'bundleExternal': true
});
vendorModules.forEach(function(vm) {
grunt.log.writelns('Excluding module from application bundle: %s', vm);
browserify.exclude(vm);
});
browserify.bundle(function(err, data) {
if (err) return grunt.fail.fatal(err);
grunt.file.mkdir(path.join('public', 'dist'));
fs.writeFileSync(target, data);
grunt.task.run('disc');
done();
});
});
};
最后,清单 6-12 显示了extracted项目的browserify-vendor繁重任务的内容。当运行时,这个任务将创建一个单独的 Browserify 包,只包含我们在清单 6-10 中定义的第三方模块。
Listing 6-12. The extracted Project’s browserify-vendor Grunt Task
// extracted/tasks/browserify-vendor.js
module.exports = function(grunt) {
grunt.registerTask('browserify-vendor', function() {
var done = this.async();
var path = require('path');
var fs = require('fs');
var target = path.join('public', 'dist', 'vendor.js');
var vendorModules = grunt.config.get('browserify.vendor_modules') || [];
var browserify = require('browserify')({
'paths': [
'app'
],
'fullPaths': true
});
vendorModules.forEach(function(vm) {
browserify.require(vm);
});
browserify.bundle(function(err, data) {
if (err) return grunt.fail.fatal(err);
grunt.file.mkdir(path.join('public', 'dist'));
fs.writeFileSync(target, data);
done();
});
});
};
要查看这个过程的运行情况,在您的终端中导航到extracted项目并运行$ npm start。将安装任何缺少的 npm 模块,并运行项目的默认 Grunt 任务。随着这一过程的进行,将会创建两个独立的包。包含项目定制代码/dist/app.js的包只有 14kB 大小。
节点方式
正如本章介绍中提到的,Browserify 通过递归扫描源代码来编译项目,以搜索对全局require()函数的调用。当找到这些调用时,Browserify 通过 Node 使用的同一个require()函数加载它们引用的模块。之后,Browserify 将它们合并成一个浏览器能够理解的包。
在这方面,使用 Browserify 的项目最好被认为是客户端节点应用。当这个概念——以及它所包含的一切——被牢记在心时,浏览器功能的许多方面往往会让新来者感到困惑,从而变得更容易理解。现在让我们来看两个这样的方面:模块解析和依赖关系管理。
模块解析和 NODE_PATH 环境变量
节点应用能够以多种方式引用模块。例如,这里我们看到一个简单的节点应用,它需要一个模块,方法是提供其位置的相对路径:
var animals = require('./lib/animals');
以类似的方式,这个示例也可以提供这个模块的完整的绝对路径。无论哪种方式,期望节点找到该模块的位置都是相当明显的。现在考虑下面的例子,其中模块仅由名称引用:
var animals = require('animals');
在这种情况下,Node 将首先尝试在其核心库中定位被引用的模块。这个过程可以在加载模块时看到,比如节点的文件系统模块fs。如果没有找到匹配,Node 将继续搜索名为node_modules的文件夹,从调用require()的模块的位置开始,沿着文件系统向上搜索。当遇到这些文件夹时,Node 将检查它们是否包含与所请求的相匹配的模块(或包)。这个过程将一直持续到找到匹配为止,如果没有找到匹配,就会抛出异常。
这种简单而强大的方法几乎完全围绕着node_modules文件夹,通过这种方法可以在 Node 中进行模块解析。然而,Node 提供了一个经常被忽略的方法,该方法允许开发人员通过定义额外的文件夹来增强这种行为,如果前面的步骤一无所获,则应该允许 Node 在这些文件夹中搜索模块。让我们看看本章的path-env项目,它演示了如何实现这一点。
清单 6-13 显示了该项目的package.json文件的摘录。特别重要的是已经定义的start脚本。基于此处显示的设置,当$ npm start在这个项目中运行时,在应用运行之前,NODE_PATH环境变量将被更新,以包含对这个项目的/lib文件夹的引用。因此,Node 会将此文件夹添加到它用来解析命名模块位置的文件夹中。
Listing 6-13. This Project’s npm start Script Updates the NODE_PATH Environment Variable
// path-env/package.json
{
"name": "path-env",
"version": "1.0.0",
"main": "./bin/index.js",
"scripts": {
"start": "export NODE_PATH=$NODE_PATH:./lib && node ./bin/index.js"
}
}
Note
在 OS X 和 Linux 上,通过运行export ENVIRONMENT_VARIABLE=value从终端设置环境变量。在 Windows 命令行中使用的命令是set ENVIRONMENT_VARIABLE=value。
设置NODE_PATH环境变量的意义乍一看可能并不明显;然而,这样做可以对复杂项目的整洁性和可维护性产生显著的积极影响。为什么呢?因为当使用这种方法时,它基本上允许开发人员创建一个名称空间,通过该名称空间,应用的模块(那些不是作为独立 npm 包存在的模块)可以按名称引用,而不是按冗长的相对路径引用。清单 6-14 展示了一个简单的例子,展示了它在实践中的样子。
Listing 6-14. Several of the Modules Contained Within the path-env Project
// path-env/bin/index.js
var api = require('app/api');
// path-env/lib/app/api/index.js
var express = require('express');
var path = require('path');
var app = express();
var animals = require('app/models/animal');
app.use('/', express.static(path.join(__dirname, '..', '..', '..', 'public')));
app.get('/animals', function(req, res, next) {
res.send(animals);
});
app.listen(7000, function() {
console.log('App is now available at:``http://localhost:7000
});
module.exports = app;
// path-env/lib/app/models/animal/index.js
module.exports = [
'Aardvarks', 'Cats', 'Dogs', 'Lemurs', 'Three-Toed Sloths', 'Zebras'
];
注意这个例子缺少相对的模块引用。例如,注意这个项目的主脚本bin/index.js如何能够通过require('app/api');加载一个负责初始化 Express 的定制模块。另一种方法是使用相对路径:require('../lib/app/api');。任何在复杂的节点应用中工作过并且遇到过类似于require('../../../../models/animal');的模块引用的人都会很快体会到这种方法带来的代码清晰度的提高。
Note
重要的是要记住,NODE_PATH环境变量的使用只在节点(或浏览器)应用的上下文中有意义——而不是包。当创建旨在与其他人共享的可重用包时,您应该只依赖于 Node 的默认模块解析行为。
利用 Browserify 中的 NODE_PATH
到目前为止,我们已经关注了NODE_PATH环境变量如何对服务器端节点应用产生积极的影响。既然我们已经打下了基础,那么让我们看看如何在用 Browserify 编译的基于浏览器的客户端应用的上下文中应用这个概念。
清单 6-15 显示了本章advanced项目的browserify Grunt 任务,它负责通过 Browserify 的 API 编译应用。特别重要的是使用了paths选项,它允许我们为 Browserify 提供一组路径,这些路径应该在编译开始前附加到NODE_PATH环境变量中。正是这种设置使我们能够轻松利用本节前面的示例中展示的相同优势。
Listing 6-15. The browserify Grunt Task for This Chapter’s advanced Project
// advanced/tasks/browserify.js
module.exports = function(grunt) {
grunt.registerTask('browserify', function() {
var done = this.async();
var path = require('path');
var fs = require('fs');
var target = path.join('public', 'dist', 'app.js');
var browserify = require('browserify')([
path.join('app', 'index.js')
], {
'paths': [
'app'
],
'fullPaths': true
});
browserify.bundle(function(err, data) {
if (err) return grunt.fail.fatal(err);
grunt.file.mkdir(path.join('public', 'dist'));
fs.writeFileSync(target, data);
grunt.task.run('disc');
done();
});
});
};
为了简单地展示这种方法如何对这个项目产生积极的影响,请考虑清单 6-16 。这里我们看到一个小模块,它负责加载lodash和集成两个第三方工具underscore.string和lodash-deep。最终导出的值是包含所有三个模块的组合功能的单个对象。
Listing 6-16. Module Responsible for Loading Lodash and Integrating Various Third-Party Plugins
// advanced/app/utils/index.js
var _ = require('lodash');
_.mixin(require('underscore.string'));
_.mixin(require('lodash-deep'));
module.exports = _;
作为提供给 Browserify 的paths值的结果,我们的应用现在可以通过简单地调用require('app/utils');从任何位置引用这个模块。
依赖性管理
直到最近,“依赖管理”的概念(在很大程度上)在基于浏览器的客户端项目环境中还是一个陌生的概念。然而,这种趋势已经迅速转变,这在很大程度上要归功于 Node 的迅速普及,以及在它之上构建的其他工具——本书已经介绍了其中的一些(例如,Bower、Grunt 和 Yeoman)。这些工具有助于将急需的工具和指导带到曾经是(现在仍然是)客户端开发的蛮荒的“西部”。
关于依赖性管理,Bower 通过为客户端开发人员提供一种易于使用的机制来管理应用所依赖的各种第三方库,从而帮助解决了这一需求。对于不熟悉这个概念并且没有使用 Browserify 等客户端编译器的开发人员来说,Bower 一直是并且将继续是管理项目依赖项的一个可行选项;然而,随着开发人员开始看到 Browserify 等工具提供的优势,Bower 已经开始显示出年龄的迹象。
在本节的开始,我们提到使用 Browserify 的项目最好被认为是客户端节点应用。关于依赖性管理,这种说法尤其重要。回想一下,在 Browserify 的编译过程中,项目的源代码被扫描以寻找对全局require()函数的调用。找到后,这些调用在 Node 中执行,随后返回值可供客户端应用使用。这里的重要含义是,当使用 Browserify 时,当开发人员只依赖 npm(节点的包管理器)时,依赖性管理会大大简化。虽然从技术上来说,是的,可以指导 Browserify 如何加载由 Bower 安装的包,但通常情况下,这只是比它的价值更麻烦。
定义浏览器特定的模块
考虑一个场景,您想要创建一个新模块,并打算通过 npm 发布和共享它。您希望这个模块在节点和浏览器中都可以工作(通过 Browserify)。为此,Browserify 支持在项目的package.json文件中使用browser配置设置。定义后,该设置允许开发人员覆盖用于定位特定模块的位置。为了更好地理解这是如何工作的,让我们看两个简单的例子。
清单 6-17 显示了一个简单包的内容。在这个包中,有两个模块,lib/node.js和lib/browser.js。根据这个包的package.json文件,这个包的main模块是lib/node.js。换句话说,当这个包在一个节点应用中被名字引用时,这就是节点将要加载的模块。但是,请注意,已经定义了一个额外的配置设置:"browser": "./lib/browser.js"。该设置的结果是,Browserify 将加载该模块,而不是由main指定的模块。
Listing 6-17. Module Exposing Two Distinct Entry Points: One for Node, the Other for Browserify
// browser1/package. json
{
"name": "browser1",
"version": "1.0.0",
"main": "./lib/node.js",
"browser": "./lib/browser.js"
}
// browser1/lib/browser.js
module.exports = {
'run': function() {
console.log('I am running within a browser.');
}
};
// browser1/ lib/node.js
module.exports = {
'run': function() {
console.log('I am running within Node.');
}
};
正如您马上会看到的,Browserify 的browser配置设置不需要局限于简单地覆盖一个包的main模块的位置。它还可以用来覆盖一个包中多个模块的位置。举例来说,考虑列出 6-18 。在这个例子中,我们没有为我们的package.json文件的browser设置提供一个字符串,而是提供了一个对象,允许我们指定多个特定于浏览器的覆盖。
Listing 6-18. Module Exposing Multiple, Distinct Modules for Node and Browserify
// browser2/package.json
{
"name": "browser2",
"version": "1.0.0",
"main": "./lib/node.js",
"browser": {
"./lib/node.js": "./lib/browser.js",
"./lib/extra.js": "./lib/extra-browser.js"
}
}
如清单 6-17 所示,实现该模式的模块将向自身公开不同的入口点:一个用于节点,另一个用于通过 Browserify 编译的应用。然而,这个例子将这个概念向前推进了一步。当这个模块被编译时,如果它试图加载位于lib/extra.js的模块,位于lib/extra-browser的模块将被替换。通过这种方式,browser设置允许我们创建具有不同行为的模块,这取决于这些模块是在节点中运行还是在浏览器中运行。
使用转换扩展浏览器功能
开发人员可以在 Browserify 的核心功能基础上创建插件,称为转换,在创建新的包时利用编译过程。此类转换通过 npm 安装,一旦它们的名称包含在应用的package.json文件中的browserify.transform数组中,它们就被启用。让我们看几个有用的例子。
短消息
brfs转换简化了内联加载文件内容的过程。它扩展了 Browserify 的编译过程来搜索对fs. readFileSync()方法的调用。找到后,引用文件的内容会立即加载并返回。
清单 6-19 显示了本章transforms-brfs项目的package.json文件的摘录。在本例中,brfs模块已经安装并包含在browserify.transform配置设置中。
Listing 6-19. Excerpt from the package.json File for This Chapter’s transforms-brfs Project
// transforms-brfs/package.json
{
"name": "transforms-brfs",
"dependencies": {
"browserify": "¹⁰.2.4",
"brfs": "¹.4.0"
},
"browserify": {
"transform": [
"brfs"
]
}
}
清单 6-20 显示了该项目的/app/index.js模块的内容。在这个例子中,brfs转换将加载/app/templates/lorem.html的内容,该内容随后被分配给tpl变量。
Listing 6-20. Loading a Template via fs.readFileSync()
// transforms-brfs/app/index.js
var fs = require('fs');
var $ = require('jquery');
var tpl = fs.readFileSync(__dirname + '/templates/lorem.html', 'utf8');
$('#container').html(tpl);
文件夹化
与brfs转换非常相似,folderify转换允许您以内联方式加载文件的内容。然而,folderify允许你快速加载多个文件的内容,而不是一次只操作一个文件。举例来说,考虑清单 6-21 ,它显示了本章的transforms-folderify应用的内容。
Listing 6-21. Loading the Contents of Multiple Files with folderify
// transforms-folderify/app/index.js
var $ = require('jquery');
var includeFolder = require('include-folder');
var folder = includeFolder(__dirname + '/templates');
for (var k in folder) {
$('#container').append('<p>' + k + ': ' + folder[k] + '</p>');
}
和前面的例子一样,这个项目的package.json文件已经被修改,在它的browserify.transform数组中包含了folderify。编译时,Browserify 将搜索对include-folder模块的引用。当调用它返回的函数时,Browserify 将加载它在指定文件夹中找到的每个文件的内容,并以对象的形式返回它们。
使膨胀
使用bulkify转换,开发人员可以通过一次调用导入多个模块。为了更好地理解这是如何工作的,请看清单 6-22 ,它显示了本章transforms-bulkify项目的主应用文件内容的摘录。
Listing 6-22. Main Application File for This Chapter’s transforms-bulkify Project
// transforms-bulkify/app/index.js
var bulk = require('bulk-require');
var app = angular.module('app', [
'ngRoute'
]);
var routes = bulk(__dirname, [
'routes/**/route.js'
]).routes;
app.config(function($routeProvider) {
var defaultRoute = 'dashboard';
_.each(routes, function(route, route_name) {
route = route.route;
route.config.resolve = route.config.resolve || {};
$routeProvider.when(route.route, route.config);
});
$routeProvider.otherwise({
'redirectTo': defaultRoute
});
});
这个特殊的例子演示了 Browserify 在 Angular 应用的上下文中的使用。如果你不熟悉 Angular(在第八章的中有所涉及),不要担心——这个例子的重要方面是bulk()方法允许我们require()多个模块匹配一个或多个指定的模式(在这个例子中是routes/**/route.js)。
图 6-2 显示了该项目的文件结构。如您所见,app/routes模块包含三个文件夹,每个文件夹代表我们的 Angular 应用中的一条路线。bulkify转换允许我们通过对bulk()的一次调用来快速require()每个模块。之后,我们能够迭代生成的对象,并将每条路线传递给 Angular。
图 6-2。
File structure for this chapter’s transforms-bulkify project
浏览器填充垫片
使用 Browserify 的开发人员偶尔会发现他们需要导入不符合一般做事方式的模块。考虑一个第三方的Foo库,一旦被加载,它就把自己分配给全局window.Foo变量(参见清单 6-23 )。这样的库可以在browserify-shim转换的帮助下导入。
Listing 6-23. Third-Party Foo Library That Assigns Itself to the Global Foo Variable
// transforms-shim/app/vendor/foo.js
function Foo() {
console.log('Bar');
}
在通过 npm 本地安装了browserify- shim模块之后,通过将它的名称添加到项目的package.json文件中的已启用转换列表中来启用它,如清单 6-19 所示。接下来,在应用的package.json文件的根级别创建一个browserify-shim对象,它将作为这个转换的配置对象(参见清单 6-24 )。在这个例子中,这个对象中的每个键都代表一个不正确暴露的模块的路径,而相应的值指定了该模块为自己分配的全局变量。
Listing 6-24. Configuring browserify-shim Within a Project’s package.json File
// transforms-shim/package.json
{
"name": "transforms-shim",
"version": "1.0.0",
"main": "server.js",
"browserify": {
"transform": [
"browserify-shim"
]
},
"browserify-shim": {
"./app/vendor/foo.js": "Foo"
}
}
随着browserify-shim转换的安装和配置,位于app/vendor/foo.js的模块现在可以通过require()正确导入。
摘要
Browserify 是一个强大的工具,它扩展了在节点内创建模块并将其导入浏览器的直观过程。在它的帮助下,基于浏览器的 JavaScript 应用可以被组织成一系列小的、易于理解的、紧密集中的模块,这些模块一起工作形成一个更大、更复杂的整体。更重要的是,目前没有模块管理系统的应用可以立即使用 Browserify。将一个完整的应用重构为更小的组件的过程不是一蹴而就的,最好一步一步来。在 Browserify 的帮助下,只要时间和资源允许,您就可以做到这一点。
相关资源
- 浏览:
http://browserify.org - 浏览变换:
https://github.com/substack/node-browserify/wiki/list-of-transforms - brfs:
https://github.com/substack/brfs - Watchify:
https://github.com/substack/watchify
七、Knockout
复杂系统的特征是简单的元素,按照本地规则作用于本地知识,产生复杂的、模式化的行为。—大卫·韦斯特
Knockout 是一个 JavaScript 库,负责将 HTML 标记绑定到 JavaScript 对象。它不是一个完整的框架。它没有状态路由器、HTTP AJAX 功能、内部消息总线或模块加载器。相反,它侧重于 JavaScript 对象和 DOM 之间的双向数据绑定。当 JavaScript 应用中的数据发生变化时,绑定到挖空视图的 HTML 元素会收到自动更新。同样,当 DOM 输入发生时——例如通过表单域操作——Knockout 捕获输入变化并相应地更新应用状态。
Knockout 使用称为 observables 的专用对象和自定义绑定语法来表达应用数据与标记的关系,而不是低级的命令式 HTML 元素操作。内部机制是完全可定制的,因此开发人员可以使用定制的绑定语法和行为来扩展 Knockout 的功能。
作为一个独立的 JavaScript 库,Knockout 没有依赖关系。要实现 Knockout 不能执行的应用功能,通常需要其他库的存在,因此它可以很好地与许多其他常用库一起使用,如 jQuery、下划线、Q 等。与严格的 DOM 操作相比,Knockout API 在更高的层次上表示数据绑定操作,因此从抽象的角度来看,Knockout 更接近主干或角度,但其纤细的、面向视图的特性集意味着它的占用空间要小得多。
Knockout 将在所有现代浏览器中完全发挥作用,并且在撰写本文时,还将扩展到 Firefox 3+、Internet Explorer 6+和 Safari 6+。鉴于它的最新特性,它的向后兼容性尤其令人印象深刻,即带有自定义标记标签的 HTML5 兼容组件。淘汰赛团队煞费苦心地让淘汰赛开发体验在各种浏览器环境中无缝衔接。
本章通过一个管理厨房食谱的示例应用来探索 Knockout 的特性和 API。所有章节代码示例都将带有注释前缀,以表明示例代码实际驻留在哪个文件中。例如,在清单 7-1 中,index.js文件可以在本书源代码分发的knockout/example-000目录中找到。
Listing 7-1. Not a Real Example
// example-000/index.js
console.log('this is not a real example');
要运行示例,首先安装 Node.js(参考您系统的 Node.js 文档),然后运行knockout目录中的npm install来安装所有示例代码依赖项。每个示例目录将包含一个运行简单 Node.js web 服务器的index.js文件。要运行每个示例,需要启动该服务器,然后在 web 浏览器中导航到指定的 URL。例如,要运行清单 7-1 中的index.js文件,在终端提示符下导航到knockout/example-000目录并运行node index.js。
所有示例页面都在一个<script>标签引用中包含核心剔除脚本。您可以从 http://knockoutjs.com 或一些著名的内容交付网络下载该脚本。Knockout 也可以作为 Bower 包或 npm 模块安装,并且与 AMD 和 CommonJS 兼容。敲除文档包含所有这些安装方法的详细说明。
视图、模型和视图模型
Knockout 区分应用用户界面中的两种信息来源:数据模型,表示应用的状态;视图模型,表示该状态如何显示或传达给用户。这两种模型都是作为 JavaScript 对象在应用中创建的。Knockout 为视图模型提供了一种以视图(HTML)友好方式表示数据模型的方法,同时在视图和数据模型之间建立了双向通信,以便输入影响应用状态,而应用状态影响视图表示数据的方式。
因为 HTML 是在 web 浏览器中表示数据的技术,所以挖空视图模型可以直接绑定到预先存在的 HTML 文档元素,也可以使用 HTML 模板创建新元素。Knockout 甚至可以创建完整的可重用 HTML 组件(带有自己的属性和行为的自定义 HTML 标签)。
本章包含的示例应用 Omnom Recipes 在可浏览的主/详细用户界面中显示配方数据(“数据模型”)。该界面的两个部分——配方列表和每个配方的详细信息——都是逻辑组件,非常适合挖空视图模型。每一个都有自己的视图模型,应用将协调它们之间的交互。最终,用户会想要添加或编辑食谱,因此将为此引入额外的 HTML 标记和视图模型。
清单 7-2 显示了作为tree命令输出的example-001目录中的示例应用结构。
Listing 7-2. Example Application Structure
example-001$ tree --dirsfirst
.
■??]
◆θ★★★★★★★★★★★★★★★★★★★★★★
──μ──??∮
──ζ──??∮
──★★★★★★★★★★★★★★★★★★★★★★★★★
──μ──??∮
──μ──??∮
──★??∮
◆θ★★★★★★★★★★★★★★★★★★★★★★
──★??∮
◆θ★★★★★★★★★★★★★★★★★★★★★★
■??]
ε──??″
index.js文件负责启动一个 web 服务器,该服务器将服务于对public目录中文件的请求。当应用的 web 页面向 AJAX 请求食谱数据时,web 服务器将在recipes.json中序列化数据并将其返回给客户端。
在public目录中,当用户访问http://localhost:8080时,默认情况下会提供index.html文件。该文件包含用挖空属性增强的应用标记。index.html文件还引用了public/styles中的app.css样式表、public/scripts/vendor中的两个供应商脚本和public/scripts中的三个应用脚本。
挖空视图模型可以应用于整个页面,也可以应用于页面上的特定元素。对于重要的应用,建议使用多视图模型来保持模块化。在 Omnom Recipes 应用中,用户界面作为两个逻辑“组件”存在:配方列表和所选配方的详细视图。应用没有对整个页面使用单一的视图模型,而是将剔除逻辑分成两个 JavaScript 模块,分别位于public/scripts : recipe-list.js和recipe-details.js。app.js模块使用这两种视图模型,并协调它们在页面上的活动。
图 7-1 显示了应用的屏幕截图,配方列表清晰可见,配方详情在左侧。
图 7-1。
Omnom Recipes screenshot Note
为了避免混淆,示例应用使用简单的 JavaScript 闭包,而不是客户端框架或面向模块的构建工具来组织模块。这些闭包通常将单个对象分配给全局window对象的一个属性,该属性将被其他脚本使用。例如,recipe-list.js文件创建了一个全局对象window.RecipeList,用于app.js文件。虽然完全有效,但是应该根据示例应用的简单需求来看待这个架构决策。
食谱清单
包含整页标记和挖空模板的index.html文件分为三个关键的顶级元素:
<header>元素,它包含静态 HTML 内容,不会被 Knockout 操作<nav id="recipe-list">元素,包含一个无序的食谱列表,将由 Knockout 操作<section id="recipe-details">元素,显示配方信息,也将被剔除操作
虽然配方列表元素很小,但它包含了许多不同的特定于淘汰的绑定。这部分 HTML 的视图模型将被绑定到<nav>元素。记住这一点,通过检查清单 7-3 中的标记,可以推断出许多关于淘汰绑定如何工作的事情。
Listing 7-3. Recipe List Markup and Bindings
<!-- example-001/public/index.html -->
<nav id="recipe-list">
<ul data-bind="foreach: recipes">
<li data-bind="text: title,
click: $parent.selectRecipe.bind($parent),
css: {selected: $parent.isSelected($data)}"></li>
</ul>
</nav>
首先,很明显,淘汰绑定应用于带有data-bind属性的 HTML 元素。这不是唯一的装订方法,但却是最常见的。元素<ul>和<li>都有形式为binding-name: binding-value的绑定。
第二,多个绑定可以作为逗号分隔的列表应用于一个元素,如<li>元素所示,它有对text、click和css的绑定。
第三,具有更复杂值的绑定,比如<li>元素上的css绑定,使用键/值散列({key: value, ... })来定义特定的绑定选项。
最后,绑定值可以引用 JavaScript 原语、视图模型属性、视图模型方法或任何有效的 JavaScript 表达式。
配方列表剔除绑定揭示了将被绑定到<nav>元素的剔除视图模型的某些事情。开发人员将立即识别出foreach流控制语句,并正确推断出recipes将是由视图模型公开的某个集合,foreach将在其上循环。
无序列表中的<li>元素没有自己的 HTML 内容,因此也可以推断出该元素是一种模板元素,将为recipes集合中的每一项进行绑定和呈现。与大多数foreach循环一样,期望循环中的对象(循环的“上下文”)是集合的一个元素是合理的。列表项的text绑定引用了当前迭代的 recipe 对象的title属性,并将在呈现时作为<li>元素的文本内容注入。
click和css绑定都引用了特殊的$parent对象,这告诉 Knockout 绑定值应该针对与foreach绑定的视图模型,而不是当前的配方对象。(视图模型是“父”上下文,配方是它的“子”上下文。)
每当列表项的click事件被触发时,click绑定就调用视图模型上的selectRecipe()方法。它通过将$parent引用传递给方法的bind()函数,将方法绑定到视图模型。这确保了selectRecipe()方法中this的值不会引用处理程序执行时附加的 DOM 元素(DOM 的默认行为)。
相比之下,$parent(视图模型)对象上的isSelected()方法由css绑定调用,但是 Knockout 而不是 DOM 管理调用,确保方法中的this值引用视图模型而不是 DOM 元素。
css绑定指示 Knockout 在满足特定条件时将特定的 CSS 类应用于 DOM 元素。css绑定值是选择器/函数对的散列,每当呈现 DOM 元素时,Knockout 都会对其进行评估。如果isSelected()方法返回true,那么selected CSS 类将被添加到列表项元素中。另一个特殊变量$data被传递给isSelected()。$data变量总是指当前对象上下文,在此例中是一个单独的配方对象。一些敲除绑定,如text,默认在当前对象上下文上操作;其他的,像foreach,作为副作用会引起上下文切换。
在清单 7-4 中,每个特殊变量的上下文对象和值都显示在 HTML 注释中。为清楚起见,绑定已被缩写。
Listing 7-4. Changing Contexts with Knockout Bindings
<!-- example-001/public/index.html -->
<nav id="recipe-list">
<!-- context: viewmodel -->
<!-- $parent === undefined -->
<!-- $data === viewmodel -->
<ul data-bind="foreach: ...">
<! -- context: recipe -->
<!-- $parent === viewmodel -->
<!-- $data === recipe -->
<li data-bind="text: ..."></li>
</ul>
</nav>
清单 7-5 中的配方列表模块创建视图模型对象,当页面被渲染时,挖空将绑定到配方列表标记。该模块的create()方法接受一系列配方对象——从服务器加载的 JSON 数据——并返回一个带有数据属性和方法的视图模型对象。几乎所有的挖空视图模型都需要访问全局window.ko对象上的辅助函数,所以它被作为参数传递给模块的闭包函数。
Listing 7-5. Recipe List View Model
// example-001/public/scripts/recipe-list.js
'use strict';
window.RecipeList = (function (ko) {
return {
create: function (recipes) {
var viewmodel = {};
// properties
viewmodel.recipes = recipes;
viewmodel.selectedRecipe = ko.observable(recipes[0]);
// methods
viewmodel.selectRecipe = function (recipe) {
this.selectedRecipe(recipe);
};
viewmodel.isSelected = function (recipe) {
return this.selectedRecipe() === recipe;
};
return viewmodel;
}
};
}(window.ko));
Note
视图模型对象本身可以以开发者选择的任何方式来创建。在示例代码中,每个视图模型都是由工厂方法创建的简单对象文字。经常可以看到 JavaScript 构造函数模式被用来创建视图模型,但是视图模型仅仅是对象,可以按照开发人员认为合适的方式来构造。
除了selectedRecipe属性,食谱列表视图模型完全不起眼。模板的foreach绑定被应用到recipes属性(一个普通 JavaScript 对象的数组),每个列表项上的click绑定调用selectRecipe()方法(传递给它一个特定的配方),当每个列表项被呈现时,调用isSelected()方法来确定被评估的配方是否已经被分配给selectedRecipe属性。事实上,这并不完全正确。selectedRecipe的值实际上不是一个配方对象,而是一个函数——一个引人注目的可观察对象。
可观察值是一种特殊类型的函数,它保存一个值,并且可以在该值发生变化时通知潜在的订阅者。HTML 元素和 observables 之间的绑定会自动创建由 Knockout 在后台管理的订阅。观察值是用全局ko对象上的特殊工厂函数创建的。清单 7-5 中的selectedRecipe是在调用ko.observable(recipes[0])时创建的。它的初始值是recipes数组中的第一个元素。当不带参数调用selectedRecipe()时,它返回它包含的值(在本例中,是recipes[0]中的对象)。传递给selectedRecipe()的任何值都将成为它的新值。虽然selectedRecipe()属性没有绑定到配方列表模板中的任何元素,但是当用户通过视图模型的方法与配方列表交互时,它会被操纵。这个元素的变化值将被用作下一个页面组件的输入:recipe details。
食谱详情
当点击配方列表中的配方时,配方详情显示在右窗格中(参见图 7-1 )。清单 7-6 中的标记显示了用于在 DOM 中呈现菜谱细节视图模型的 HTML 元素和剔除绑定。
Listing 7-6. Recipe Details Markup and Bindings
<!-- example-001/public/index.html -->
<section id="recipe-details">
<h1 data-bind="text: title"></h1>
<h2>Details</h2>
<p>Servings: <span data-bind="text: servings"></span></p>
<p>Approximate Cook Time: <span data-bind="text: cookingTime"></span></p>
<h2>Ingredients</h2>
<ul data-bind="foreach: ingredients">
<li data-bind="text: $data"></li>
</ul>
<h2>Instructions</h2>
<ol data-bind="foreach: instructions">
<li data-bind="text: $data"></li>
</ol>
<a data-bind="visible: hasCitation,
attr: {href: citation, title: title}"
target="_blank">Source</a>
</section>
有些绑定,如<h1> text绑定,从视图模型属性中读取一个值,并将其字符串值注入 HTML 元素。
因为“细节”标题下的段落具有静态内容(文本“服务:”和“大约烹饪时间:”等),所以在每个段落的末尾使用<span>标签来锚定servings和cookingTimes属性的挖空绑定。
配料列表使用foreach绑定遍历字符串集合,因此每个循环中的上下文对象是由$data变量表示的字符串。每个字符串都成为列表项的文本内容。
底部的<a>标签作为引用链接到食谱的来源网站。如果配方没有引用,锚将不会显示。元素的visible绑定检查视图模型的hasCitation可观察值,如果值为空,隐藏锚元素。像菜谱列表中使用的css绑定一样,attr绑定将一个键/值散列作为它的绑定值。散列键(href和title)是要在锚点上设置的元素属性,值是视图模型上将被绑定到每个属性的属性。
配方细节视图模型比配方列表视图模型有更多的成员。清单 7-7 显示了配方细节视图模型是以类似的方式创建的,通过调用带有特定配方对象的RecipeDetails.create()函数,该配方对象将用于向视图模型添加数据。这个模块在全局ko对象上使用了几个函数,因此,像食谱列表一样,它作为参数传递给模块闭包。
Listing 7-7. Recipe Details View Model
// example-001/public/scripts/recipe-details.js
'use strict';
window.RecipeDetails = (function (ko) {
return {
create: function (recipe) {
var viewmodel = {};
// add properties and methods...
return viewmodel;
}
};
}(window.ko));
对于 recipe 对象上的每个属性,recipe details 视图模型都有相应的可观察属性,如清单 7-8 所示。只有当它们包含的值预计会改变时,可观测量才真正有用。如果值应该是静态的,那么可以使用普通的 JavaScript 属性和值。在 recipe details 视图模型中使用 Observables,因为只有一个视图模型实例绑定到页面。当在配方列表中选择一个新配方时,配方细节视图模型将用新配方的值进行更新。因为它的属性是可观察的,所以页面的标记会立即改变。
Listing 7-8. Recipe Details View Model Properties
// example-001/public/scripts/recipe-details.js
// properties
viewmodel.title = ko.observable(recipe.title);
viewmodel.servings = ko.observable(recipe.servings);
viewmodel.hours = ko.observable(recipe.cookingTime.hours);
viewmodel.minutes = ko.observable(recipe.cookingTime.minutes);
viewmodel.ingredients = ko.observableArray(recipe.ingredients);
viewmodel.instructions = ko.observableArray(recipe.instructions);
viewmodel.citation = ko.observable(recipe.citation);
viewmodel.cookingTime = ko.computed(function () {
return '$1 hours, $2 minutes'
.replace('$1', this.hours())
.replace('$2', this.minutes());
}, viewmodel);
清单 7-8 显示了两种新的可观测量:ko.observableArray()和ko.computed()。
可观察数组监视它们的值(普通的 JavaScript 数组)的添加、删除和索引变化,因此如果数组发生变化,可观察数组的任何订户都会得到通知。虽然本例中的成分和指令没有改变,但稍后将引入代码来操作集合,并显示可观察数组的自动绑定更新。
计算的可观察值基于视图模型上可观察值公开的其他值生成或计算一个值。ko.computed()函数接受回调,该回调将被调用来生成计算出的可观察值,并且可选地接受一个上下文对象,该对象在回调中充当this的值。当被模板绑定引用时,计算出的可观察值将是其回调返回的值。清单 7-8 中的cookingTime属性创建一个格式化的字符串,其中插入了来自hours和minutes观察值的值。如果hours或minutes发生变化,cookingTime计算出的可观测值也将更新其订户。
Note
因为hours和minutes实际上是函数(尽管它们在剔除绑定表达式中被视为属性),所以必须在计算出的可观察对象的主体中调用每一个函数,以便检索其值。
清单 7-9 中的菜谱细节视图模型方法相当简单。hasCitation()方法测试citation属性的非空值,而update()方法接受配方并用新值更新视图模型上的可观察属性。该方法不绑定到视图,但是当选择配方列表视图模型中的配方时,将使用该方法。
Listing 7-9. Recipe Details View Model Methods
// example-001/public/scripts/recipe-details.js
// methods
viewmodel.hasCitation = function () {
return this.citation() !== '';
};
viewmodel.update = function (recipe) {
this.title(recipe.title);
this.servings(recipe.servings);
this.hours(recipe.cookingTime.hours);
this.minutes(recipe.cookingTime.minutes);
this.ingredients(recipe.ingredients);
this.instructions(recipe.instructions);
this.citation(recipe.citation);
};
将视图模型绑定到 DOM
这两个视图模型工厂都被附加到全局window对象上,并且可以用来创建将被绑定到页面上的单独的视图模型实例。清单 7-10 中所示的app.js文件是将两个配方视图模型联系在一起的主脚本。
Listing 7-10. Binding View Models to the DOM
// example-001/public/scripts/app.js
(function app ($, ko, RecipeList, RecipeDetails) {
// #1
var getRecipes = $.get('/recipes');
// #2
$(function () {
// #3
getRecipes.then(function (recipes) {
// #4
var list = RecipeList.create(recipes);
// #5
var details = RecipeDetails.create(list.selectedRecipe());
// #6
list.selectedRecipe.subscribe(function (recipe) {
details.update(recipe);
});
// #7
ko.applyBindings(list, document.querySelector('#recipe-list'));
ko.applyBindings(details, document.querySelector('#recipe-details'));
}).fail(function () {
alert('No recipes for you!');
});
});
}(window.jQuery, window.ko, window.RecipeList, window.RecipeDetails));
app模块负责从服务器加载一组初始配方数据,等待 DOM 进入就绪状态,然后实例化视图模型实例并将每个实例绑定到适当的元素。下表描述了清单 7-10 中显示的每个步骤注释(例如// #1)。
A jQuery promise is created that will resolve at some point in the future, when the data obtained from the GET /recipes request becomes available. The function passed to $() will be triggered when the DOM has been completely initialized to ensure that all Knockout template elements will be present before any binding attempts. When the jQuery promise resolves, it passes the list of recipes to its resolution handler. If the promise fails, an alert is shown to the user indicating that a problem occurred. Once the recipe data has been loaded, the list view model is created. The recipe array is passed as an argument to RecipeList.create(). The return value is the actual recipe list view model object. The recipe details view model is created in a similar fashion. Its factory function accepts a single recipe, and so the selectedRecipe property on the recipe list is queried for a value. (The recipe list view model chooses the very first recipe in its data array for this value, by default.) After the recipe details view model has been created, it subscribes to change notifications on the recipe list’s selectedRecipe observable. This is the manual equivalent of a DOM subscription created by Knockout when an observable is bound to an HTML element. The function provided to the subscribe() method will be invoked whenever selectedRecipe changes, receiving the new value as an argument. When the callback fires the recipe details view model uses any newly selected recipe to update itself, thereby changing the values of its own observable properties. Finally, view models are bound to the DOM when the global ko.applyBindings() function is invoked. In Listing 7-10 this function receives two arguments: the view model to be bound, and the DOM element to which the view model will be bound. Any binding attribute Knockout encounters on this element or its descendants will be applied to the specified view model. If no DOM element is specified, Knockout assumes that the view model applies to the entire page. For simplistic pages this might be appropriate, but for more complex scenarios, using multiple view models that encapsulate their own data and behavior is the better option.
查看模型和表单
挖空视图模型属性可以绑定到表单控件。许多控件,如<input>元素,共享类似value的标准绑定;但是其他的像<select>有特定于元素的绑定。例如,options绑定控制着<select>标签中<option>元素的创建。一般来说,到目前为止,表单字段绑定的行为很像示例代码中看到的绑定,但是复杂的表单可能很棘手,有时需要更有创意的绑定策略。
本节中的示例建立在 recipe details 模板和视图模型上。具体来说,引入了“编辑”模式,由此查看特定配方的用户可以选择通过表单字段来改变其细节。使用了相同的视图模型,但是在 recipe details 模板中添加了新的表单域元素,增加了两者的复杂性。
切换到“编辑”模式
配方详细信息标记的顶部和底部添加了三个按钮。图 7-2 和 7-3 显示了按钮呈现时的外观。
图 7-3。
In “edit” mode, the Save and Cancel buttons are visible
图 7-2。
In “view” mode, the Edit button is visible
编辑按钮将页面从查看模式切换到编辑模式(并为正在查看的配方的每个部分显示适当的表单字段)。在编辑模式下,“编辑”按钮本身是隐藏的,但另外两个按钮,“保存”和“取消”是可见的。如果用户单击保存按钮,对配方所做的任何更改都将被保存;相反,如果用户点击取消按钮,编辑会话将被中止,配方细节将恢复到其原始状态。
清单 7-11 中显示的每个按钮的敲除绑定与目前讨论的绑定略有不同。
Listing 7-11. Editing Button Markup
<!-- example-002/public/index.html -->
<div>
<!-- in read-only view -->
<button data-bind="click: edit, visible: !isEditing()">Edit</button>
<!-- in edit view -->
<button data-bind="click: save, visible: isEditing">Save</button>
<button data-bind="click: cancelEdit, visible: isEditing">Cancel</button>
</div>
首先,每个按钮都有一个 click 事件处理程序,它调用视图模型上的一个方法:edit()、save()和cancelEdit()。但是与前面的例子不同,这些方法不使用bind()函数来确保视图模型中this的值。相反,视图模型中所有出现的关键字this都被替换为对对象文字viewmodel的引用,如清单 7-12 所示。这些按钮的新属性和方法也被添加到 recipe details 视图模型中。为了简洁起见,清单 7-12 省略了recipe-list.js中没有改变的部分。
Listing 7-12. Methods reference the viewmodel object, not this
// example-002/public/scripts/recipe-details.js
// properties
viewmodel.previousState = null;
viewmodel.isEditing = ko.observable(false);
// methods
viewmodel.edit = function () {
viewmodel .previousState = ko.mapping.toJS(viewmodel);
viewmodel .isEditing(true);
};
viewmodel.save = function () {
// TODO save recipe
viewmodel .isEditing(false);
};
viewmodel.cancelEdit = function () {
viewmodel .isEditing(false);
ko.mapping.fromJS(viewmodel.previousState, {}, viewmodel);
};
因为视图模型本身被赋给了RecipeDetails.create()闭包中的一个变量,所以它的方法可以通过名字来引用它。通过完全避免this,事件绑定被简化,潜在的错误被避免。
其次,每个按钮都有一个附加到视图模型的isEditing observable 的visible绑定,但是只有 Edit 按钮作为函数直接调用该方法。它还拥有唯一一个使用否定(!)运算符的绑定,该运算符将绑定值转换为表达式。表达式中计算的任何可观察值都必须作为函数调用,以检索其值。如果一个可观察对象本身被用作绑定值,就像保存和取消按钮的visible绑定一样,当剔除评估绑定时,它将被自动调用。
所有这三种方法,edit()、save()和cancelEdit(),都操纵isEditing可观察值的值,该值决定了在表单上显示哪个或哪些按钮(以及,稍后将演示的,显示哪些表单字段)。当调用edit()方法时,编辑开始,当用户保存配方或取消编辑会话时,编辑结束。
为了确保当用户取消编辑会话时对配方的改变被丢弃,当编辑会话开始时,视图模型序列化其状态,以预期可能的回复。如果编辑会话被取消,则先前的状态被反序列化,并且每个可观察属性的值被有效地重置。
挖空映射插件用于在edit()和cancelEdit()方法中序列化和反序列化视图模型的状态:
// serializing the view model
viewmodel.previousState = ko.mapping.toJS(viewmodel);
// deserializing the view model
ko.mapping.fromJS(viewmodel.previousState, {}, viewmodel);
Tip
Knockout 的贴图插件与核心的 Knockout 库分开发布。当前版本可从 http://knockoutjs.com/documentation/plugins-mapping.html 下载。要安装插件,只需在 HTML 页面的核心剔除标签<script>之后添加一个<script>标签引用到插件脚本。它将自动在全局ko对象上创建ko.mapping名称空间属性。
映射插件序列化/反序列化拥有可观察属性的对象,在序列化过程中读取它们的值,在反序列化过程中设置它们的值。当edit()方法调用ko.mapping.toJS(viewmodel)时,它接收一个普通的 JavaScript 对象文字,其属性名称与视图模型的属性名称相同,但是包含普通的 JavaScript 数据,而不是可观察的函数。当编辑会话被取消时,为了将这些值推回到视图模型自己的可观察值中,cancelEdit()方法使用三个参数调用ko.mapping.fromJS():
- 包含要写入视图模型的可观察属性的数据的普通 JavaScript 对象文字
- 一个对象文字,将普通 JavaScript 状态对象上的属性映射到视图模型上的可观察属性(如果该对象为空,则假定两者的属性共享相同的名称)
- 将接收对象文字数据的视图模型
Note
Knockout mapper 插件可以通过其toJS()和fromJS()函数将视图模型序列化/反序列化为普通的 JavaScript 对象文字,或者通过其toJSON()和fromJSON()函数将其序列化/反序列化为 JSON 字符串。这些函数对于将 JSON 数据绑定到简单表单的 CRUD(创建+读取+更新+删除)视图模型特别有用。
虽然表单上有 Save 按钮,但是它的方法只在视图模型中被存根化。它的功能将在后面的示例中添加。
更改配方标题
无论配方详细信息视图处于编辑模式还是只读模式,配方标题均可见。当用户点击编辑按钮时,标签和输入字段在<h1>标签下变得可见,因此用户可以在必要时更新配方标题。包含<div>元素控件上的visible绑定通过订阅视图模型上的isEditing可观察对象来显示和隐藏该字段。输入字段的值通过value绑定绑定到视图模型的title可观察值。默认情况下,value绑定只会在可观察对象绑定的字段失去焦点时刷新可观察对象中的数据。当清单 7-13 中的标题输入失去焦点时,<h1>标签的内容将立即用新的标题值更新,因为两者都绑定到了title可观察对象。渲染后的场景如图 7-4 所示。
图 7-4。
Editing the recipe title Listing 7-13. Recipe Title Markup
<!-- example-002/public/index.html -->
<h1 data-bind="text: title"></h1>
<!-- in edit view -->
<div data-bind="visible: isEditing" class="edit-field">
<label for="recipe-title">Title:</label>
<input data-bind="value: title" name="title" id="recipe-title" type="text" />
</div>
更新食谱和烹饪时间
在清单 7-14 中,当表单进入编辑模式时,食谱的只读食用量<p>元素被隐藏。在它的位置上显示了一个<select>元素,其中有许多可供用户选择的份量选项。再一次,isEditing被用来决定显示哪些元素。
Listing 7-14. Serving Size Markup
<!-- example-002/public/index.html -->
<h2>Details</h2>
<!-- in read-only view -->
<p data-bind="visible: !isEditing()">
Servings: <span data-bind="text: servings"></span>
</p>
<!-- in edit view -->
<div data-bind="visible: isEditing" class="edit-field">
<label for="recipe-servings">Servings:</label>
<select data-bind="options: servingSizes,
optionsText: 'text',
optionsValue: 'numeral',
value: servings,
optionsCaption: 'Choose...'"
name="recipeServings"
id="recipe-servings">
</select>
</div>
清单 7-14 中的<select>标签声明了新的特定于元素的剔除绑定,以控制它使用视图模型数据的方式。options绑定告诉 Knockout 视图模型上的哪个属性持有将用于在标签内创建<option>元素的数据集。绑定值是属性的名称(在本例中是servingSizes),一个只读引用数据的简单数组。
对于原始值,比如字符串或数字,options绑定假设每个原始值都应该是其<option>元素的文本和值。对于复杂的对象,optionsText和optionsValue绑定告诉 Knockout 数组中每个对象的哪些属性将用于生成每个<option>元素的文本和值。清单 7-15 中定义了服务量对象。请注意,文本值是每个数字的名称,而数字值是相应的数字。当用户选择一份食物时,数字值将被分配给viewmodel.servings()。
Listing 7-15. Recipe Serving Size Data in the View Model
// example-002/public/scripts/recipe-details.js
// properties
viewmodel.servings = ko.observable(recipe.servings);
viewmodel.servingSizes = [
{text: 'one', numeral: 1},
{text: 'two', numeral: 2},
{text: 'three', numeral: 3},
{text: 'four', numeral: 4},
{text: 'five', numeral: 5},
{text: 'six', numeral: 6},
{text: 'seven', numeral: 7},
{text: 'eight', numeral: 8},
{text: 'nine', numeral: 9},
{text: 'ten', numeral: 10}
];
<select>标签的value绑定将下拉列表的选定值与视图模型上的可观察值联系起来。当<select>标签被渲染时,这个值会在 DOM 中自动为用户选择;当用户选择一个新值时,有界可观测值将被更新。
最后,optionsCaption绑定在 DOM 中创建一个特殊的<option>元素,它出现在下拉选项列表的顶部,但是永远不会被设置为视图模型上的选定值。这仅仅是一个装饰性的增强,给用户一些关于如何使用下拉菜单的指导。
图 7-5 和 7-6 显示了折叠和展开的食用量下拉菜单。
图 7-6。
Choosing a new value from the Servings drop-down
图 7-5。
Servings drop-down with a pre-selected value
烹饪时间字段也如图 7-5 所示,不包含特殊绑定。清单 7-16 中显示的两个输入字段(小时和分钟)都是数字字段,它们使用简单的value绑定来更新视图模型上的可观察值。它们通过前面讨论的相同的可见性机制来显示和隐藏。
Listing 7-16. Cooking Time Markup
<!-- example-002/public/index.html -->
<!-- in read-only view -->
<p data-bind="visible: !isEditing()">
Approximate Cook Time: <span data-bind="text: cookingTime"></span>
</p>
<!-- in edit view -->
<div data-bind="visible: isEditing" class="edit-field">
<label for="recipe-hours">Approximate Cook Time:</label>
<input data-bind="value: hours"
name="hours"
id="recipe-hours"
type="number" />
<input data-bind="value: minutes"
name="minutes"
id="recipe-minutes"
type="number" />
</div>
回想一下,当烹饪时间以只读模式显示给用户时,使用清单 7-17 中的cookingTime计算可观测值,而不是hours和minutes可观测值。当这些可观测量的值基于清单 7-16 中的输入绑定发生变化时,计算出的可观测量会为视图重新生成格式化字符串。还要注意,计算的可观察对象不再有上下文参数,因为在可观察对象内部,视图模型变量是通过名称引用的,而不是通过关键字this来解析。
Listing 7-17. View Model Hours, Minutes, and Computed Cooking Time
// example-002/public/scripts/recipe-details.js
// properties
viewmodel.hours = ko.observable(recipe.cookingTime.hours);
viewmodel.minutes = ko.observable(recipe.cookingTime.minutes);
viewmodel.cookingTime = ko.computed(function () {
return '$1 hours, $2 minutes'
.replace('$1',``viewmodel
.replace('$2',``viewmodel
});
添加和移除配料
在只读模式下,配方成分呈现为无序列表。为了维护表单,当 recipe details 视图进入编辑模式时,为列表中的每个项目生成一个输入,如图 7-7 所示。每个配料旁边的减号按钮允许用户删除任何或所有配料,而输入列表下方的空输入字段和加号按钮可用于添加新配料。任何成分输入中的文本更改都会更新视图模型的ingredients数组中的值。
图 7-7。
Creating and editing recipe ingredients
添加新的配料比就地编辑现有的配料更直接。清单 7-18 中的标记显示了对表单配料部分的部分更改。出现了一个只读无序列表,在它下面是一个包含所有新表单字段的<div>元素。一个注释块指出了现有成分的<input>元素将会放在哪里(稍后讨论),但是新的成分字段显示在它的下面。
Listing 7-18. New Ingredients Markup
<!-- example-002/public/index.html -->
<h2>Ingredients</h2>
<!-- in read-only view -->
<ul data-bind="foreach: ingredients, visible: !isEditing()">
<li data-bind="text: $data"></li>
</ul>
<!-- in edit view -->
<div data-bind="visible: isEditing" class="edit-field">
<!-- ingredient list inputs here... -->
<input data-bind="value: newIngredient"
type="text"
name="new-ingredient"
id="recipe-new-ingredient"/>
<button data-bind="click: commitNewIngredient"
class="fa fa-plus"></button>
</div>
为了添加新的配料,用户在新的配料<input>字段中输入文本,然后点击它旁边的加号按钮。<input>被绑定到视图模型上可观察的newIngredient,加号按钮的click事件调用commitNewIngredient()方法,如清单 7-19 所示。
Listing 7-19. Creating a New Ingredient in the View Model
// example-002/public/scripts/recipe-details.js
// properties
viewmodel.ingredients = ko.observableArray(recipe.ingredients);
viewmodel.newIngredient = ko.observable('');
// methods
viewmodel.commitNewIngredient = function () {
var ingredient = viewmodel.newIngredient();
if (ingredient === '') return;
viewmodel.ingredients.push(ingredient);
viewmodel.newIngredient('');
};
commitNewIngredient()方法评估newIngredient可观察值的内容,以确定它是否为空。如果是,用户没有在<input>中输入任何文本,因此该方法过早返回。如果不是,则将newIngredient的值推入ingredients可观测值数组,并清除newIngredient可观测值。
Tip
可观察数组与普通 JavaScript 数组共享一个几乎相同的 API。大多数数组操作,比如push()、pop()、slice()、splice()等等,都可以在可观察数组上进行,并且在被调用时会触发更新通知给可观察数组的订阅者。
当新的成分被添加到ingredients后,Knockout 会更新 DOM 以反映这一变化。在编辑模式下隐藏的只读列表会自动获取一个新的列表项元素,现有的<input>元素的可编辑列表,如清单 7-20 所示,也会获得一个新条目。
Listing 7-20. Ingredients Markup
<!-- example-002/public/index.html -->
<h2>Ingredients</h2>
<!-- in read-only view -->
<ul data-bind="foreach: ingredients, visible: !isEditing()">
<li data-bind="text: $data"></li>
</ul>
<!-- in edit view -->
<div data-bind="visible: isEditing" class="edit-field">
<ul data-bind="foreach: ingredients" class="listless">
<li>
<input data-bind="value: $data,
valueUpdate: 'input',
attr: {name: 'ingredient-' + $index()},
event: {input: $parent.changeIngredient.bind($parent, $index())}"
type="text" />
<button data-bind="click: $parent.removeIngredient.bind($parent, $index())"
class="fa fa-minus"></button>
</li>
</ul>
<!-- new ingredient input here... -->
</div>
对于ingredients可观察数组中的每个成分,在新的成分字段上方呈现一个输入。这些输入嵌套在一个无序列表中,它们的值都绑定到数组中的特定成分,由foreach循环中的$data变量表示。通过将字符串“ingredient-”与特殊的$index observable 公开的循环的当前索引连接起来,attr绑定用于为每个<input>元素命名。像绑定表达式中使用的任何可观察对象一样,必须调用$index来检索它的值。
由可观察数组公开的绑定只适用于数组本身,而不适用于它们包含的元素,这一点怎么强调都不为过。当每个成分被绑定到一个 DOM <input>元素时,它被包装在$data可观察对象中,但是在这个可观察对象和包含它的可观察数组之间没有通信。如果$data中的值因为输入而改变,数组将会被忽略,并且仍然包含它自己的未改变数据的副本。这是一个令人惊恐的来源,但有几个应对策略可以让它变得可以忍受。
首先,observable ingredients数组可以填充对象,每个对象将成分文本公开为可观察属性(类似于{ ingredient: ko.observable('20 mushrooms') })。每个<input>的value绑定将使用每个对象的$data.ingredient属性来建立一个双向绑定。可观察数组仍然不知道其成员的变化,但因为每个元素都是一个通过可观察对象跟踪其自身数据的对象,所以这成为一个争论点。
清单 7-20 中采用的第二种方法是通过valueUpdate和event绑定监听每个<input>元素上的变化事件,然后告诉视图模型在ingredients可观察数组中的特定成分值发生变化时替换它们。两种方式都不“正确”——两者都有各自的优点和缺点。
每次 DOM input事件在每个<input>元素上触发时,valueUpdate绑定首先指示 Knockout 更改$data的值。(记住:一旦一个元素失去焦点,Knockout 通常会更新$data,而不是当它收到输入时。)其次,添加了一个 Knockout event绑定,它在每次 DOM input事件触发时调用视图模型上的changeIngredient()方法。默认情况下,Knockout 将$data的当前值提交给changeIngredient(),但是由于新值将替换旧值,视图模型必须知道ingredients数组中的哪个索引是目标。使用bind(),$index的值作为第一个参数绑定到方法,确保$data的值是第二个。
清单 7-21 中的代码显示了changeIngredient()方法访问ingredients可观察数组中的实际底层数组,以替换给定索引处的值。
Listing 7-21. Changing a Recipe Ingredient in the View Model
// example-002/public/scripts/recipe-details.js
// properties
viewmodel.ingredients = ko.observableArray(recipe.ingredients);
// methods
viewmodel.changeIngredient = function (index, newValue) {
viewmodel.ingredients()[index] = newValue;
};
不幸的是,当可观察数组的底层数组结构发生变化时,可观察数组不会自动通知任何订阅者,这意味着其他 DOM 元素,比如显示成分的只读无序列表,将保持不变。为了减轻这一点,视图模型监听它自己的isEditing可观察值,如清单 7-22 所示。当传递给可观察对象的值是false(意味着用户或者保存了对配方的更改,或者取消了编辑会话)时,视图模型通过调用其valueHasMutated()方法,强制通知ingredients可观察对象数组的任何订阅者。这确保了在“查看”模式下显示的只读无序列表将准确地反映出ingredients数组中任何改变的值。
Listing 7-22. Forcing Observable Arrays to Notify Their Subscribers of Underlying Changes
// example-002/public/scripts/recipe-details.js
// properties
viewmodel.isEditing = ko.observable(false);
viewmodel.isEditing.subscribe(function (isEditing) {
if (isEditing) return;
// force refresh
//
viewmodel.ingredients.valueHasMutated();
});
每个配方<input>旁边是一个减号按钮,用于从ingredients可观察数组中删除给定的配料。它的 click 事件被绑定到removeIngredient()方法,像changeIngredient()一样,该方法也必须接收$index的值,以便视图模型知道要删除哪个元素。可观察数组公开了一个splice()方法,如清单 7-23 所示,该方法可用于移除特定索引处的元素。使用这种方法而不是直接操作底层数组,可以确保ingredients可观察数组的订阅者立即得到变化的通知。
Listing 7-23. Removing a Recipe Ingredient
// example-002/public/scripts/recipe-details.js
// properties
viewmodel.ingredients = ko.observableArray(recipe.ingredients);
// methods
viewmodel.removeIngredient = function (index) {
viewmodel.ingredients.splice(index, 1);
};
说明
配方说明与配方成分非常相似,但有两个显著的不同。首先,指令以有序列表的形式呈现,因为指令必须一步一步地执行。第二,指令可以在列表中升级或降级。图 7-8 显示了订购说明字段和与之相关的按钮的屏幕截图。
图 7-8。
Creating and editing recipe instructions
将不讨论与配料用例(创建指令、删除指令、更新现有指令)重叠的配方指令用例,因为两者的标记、剔除绑定和视图模型结构本质上是相同的,但操作的是instructions可观察数组。然而,数组内的指令降级和升级是新特性,在清单 7-24 中增加了 up 和 down <button>标记。
Listing 7-24. Instructions Markup
<!-- example-002/public/index.html -->
<h2>Instructions</h2>
<!-- in read-only view -->
<ol data-bind="foreach: instructions, visible: !isEditing()">
<li data-bind="text: $data"></li>
</ol>
<!-- in edit view -->
<div data-bind="visible: isEditing" class="edit-field">
<!-- existing instructions -->
<ul data-bind="foreach: instructions" class="listless">
<li>
<input data-bind="value: $data,
valueUpdate: 'input',
attr: {name: 'instruction-' + $index()},
event: {input: $parent.changeInstruction.bind($parent, $index())}"
type="text" />
<button data-bind="click: $parent.demoteInstruction.bind($parent, $index())"
class="fa fa-caret-down"></button>
<button data-bind="click: $parent.promoteInstruction.bind($parent, $index())"
class="fa fa-caret-up"></button>
<button data-bind="click: $parent.removeInstruction.bind($parent, $index())"
class="fa fa-minus"></button>
</li>
</ul>
<!-- new instruction input here... -->
</div>
像减号按钮一样,up 和 down 按钮都使用 Knockout click绑定来调用视图模型上的方法,将相关的项目索引作为参数传递给每个按钮。
清单 7-25 展示了这两种方法是如何操作instructions可观察数组的。promoteInstruction()方法计算索引,如果索引为零,则提前退出(第一条指令不能被提升)。然后,它使用其splice()方法从可观察数组中选取给定索引处的指令,通过减 1 计算该指令的新索引(例如,从索引 2 到 1 将是列表中的提升),然后将该指令拼接回新索引处的可观察数组。demoteInstruction()方法正好相反。它防止在列表“末端”的指令被进一步降级;否则,它会通过重新拼接可观察数组来将指令在列表中下移。在这两种情况下,任何绑定到instructions属性的 DOM 元素都会自动得到变更通知。
Listing 7-25. Promoting and Demoting Recipe Instructions in the View Model
// example-002/public/scripts/recipe-details.js
// properties
viewmodel.instructions = ko.observableArray(recipe.instructions);
viewmodel.promoteInstruction = function (index) {
if (index === 0) return;
var instruction = viewmodel.instructions.splice(index, 1);
var newIndex = index - 1;
viewmodel.instructions.splice(newIndex, 0, instruction);
};
viewmodel.demoteInstruction = function (index) {
var lastIndex = (viewmodel.instructions.length - 1);
if (index === lastIndex) return;
var instruction = viewmodel.instructions.splice(index, 1);
var newIndex = index + 1;
viewmodel.instructions.splice(newIndex, 0, instruction);
};
引用
考虑到指令和成分的复杂性,添加引用字段是一件相当普通的事情。单个文本<input>使用value绑定来更新视图模型的citation可观察值。渲染后的场景如图 7-9 所示。
图 7-9。
Updating a Recipe’s Citation
引文超链接上的visible绑定已被更改为复合表达式。现在,清单 7-26 中的超链接只有在配方详情视图处于只读模式(!isEditing())且配方实际上有引用时才会显示。
Listing 7-26. Citation Field Markup
<!-- example-002/public/index.html -->
<a data-bind="visible: hasCitation() && !isEditing(),
attr: {href: citation, title: title}"
target="_blank">Source</a>
<div data-bind="visible: isEditing" class="edit-field">
<label>Citation:</label>
<input name="citation" type="text" data-bind="value: citation" />
</div>
定制组件
受流行的 web components . js poly fill(http://webcomponents.org)的启发,Knockout 提供了一个定制的组件系统,该组件系统使用定制的标签名称、标记和行为来生成可重用的 HTML 元素。
在 Omnom Recipes 应用中,recipe details 视图包含两个可编辑的列表,即配料和说明,它们在标记和视图模型属性和方法方面有许多相似的特征。只需一点努力,自定义组件就可以在应用中替换这两个列表。目标是将 DOM 中复杂的标记和绑定表达式简化为新的定制元素,如清单 7-27 中所设想的。
Listing 7-27. Input List Element
<!-- example-003/public/index.html -->
<!-- editable ingredients list -->
<input-list params="items: ingredients,
isOrdered: false"></input-list>
<!-- ... -->
<!-- editable instructions list -->
<input-list params="items: instructions,
isOrdered: true"></input-list>
淘汰组件是几个东西的交集:
- 为页面上自定义组件的每个实例创建视图模型的工厂函数
- 一个 HTML 模板,它有自己的挖空绑定,将在使用组件的任何地方注入
- 一个定制的标签注册,告诉 Knockout 在哪里可以找到模板,以及当它在页面上遇到组件标签时如何实例化它的视图模型
输入列表视图模型
recipe details 视图模型已经拥有了用于操作其ingredients和instructions数组的属性和方法,但是有必要将这段代码抽象出来,并将其移动到自己的模块input-list.js中,以便 Knockout 可以将它专门用于新的输入列表组件。
清单 7-28 显示了输入列表模块的简化版本。它的结构与其他视图模型工厂模块相同,在全局InputList对象上公开了一个create()方法。这个工厂方法接受一个params参数,该参数将用于向输入列表组件传递一个对可观察数组(params.items)的引用,以及一系列可选设置,这些设置将决定输入列表在绑定到呈现模板时的行为:params.isOrdered、params.enableAdd、params.enableUpdate和params.enableRemove。
defaultTo()函数是一个简单的实用函数,它返回params对象上缺失属性的默认值。
Listing 7-28. Input List View Model
// example-003/public/scripts/input-list.js
'use strict';
window.InputList = (function (ko) {
function defaultTo(object, property, defaultValue) {/*...*/}
return {
create: function (params) {
var viewmodel = {};
// properties
viewmodel.items = params.items; // the collection
viewmodel.newItem = ko.observable('');
viewmodel.isOrdered = defaultTo(params, 'isOrdered', false);
viewmodel.enableAdd = defaultTo(params, 'enableAdd', true);
viewmodel.enableUpdate = defaultTo(params, 'enableUpdate', true);
viewmodel.enableRemove = defaultTo(params, 'enableRemove', true);
// methods
viewmodel.commitNewItem = function () {/*...*/};
viewmodel.changeItem = function (index, newValue) {/*...*/};
viewmodel.removeItem = function (index) {/*...*/};
viewmodel.promoteItem = function (index) {/*...*/};
viewmodel.demoteItem = function (index) {/*...*/};
return viewmodel;
}
};
}(window.ko));
params.items和params.isOrdered属性对应于清单 7-27 中的绑定属性。当在页面上使用一个组件时,它的绑定属性的值通过params对象被引用传递给组件的视图模型。在这个场景中,输入列表组件将被赋予访问配方细节视图模型上的ingredients和instructions可观察数组的权限。
清单 7-28 中的输入列表方法已经被修订,因为它们与清单 7-25 中的方法几乎相同。然而,这些方法不是引用成分或指令,而是引用抽象的items可观察数组。组件用从params.items接收的数据填充这个数组。与recipe-details.js模块中的newIngredient和newInstruction可观察对象的行为方式完全相同,newItem可观察对象保存新项目输入的值。然而,它并不与 recipe details 视图模型共享,因为它只与输入列表相关。
因为输入列表组件现在将处理页面上的配料和说明列表的操作,所以之前执行这些操作的 recipe details 视图模型中的属性和方法已经被移除。
输入列表模板
一个可重用的组件需要一个抽象的、可重用的模板,所以与编辑指令和成分相关的标记也被收集到一个 HTML 模板中。每次在页面上创建输入列表组件的实例时,Knockout 都会将模板注入 DOM,然后将输入列表视图模型的新实例绑定到它。
由于输入列表组件可以容纳有序列表和无序列表,因此模板必须使用淘汰绑定来智能地决定显示哪种列表。只有有序列表才会有升级和降级按钮,而项目可以从这两种列表中添加和删除。由于输入列表视图模型公开了从其params对象接收的布尔属性,模板可以根据这些属性的值改变其行为。例如,如果视图模型属性isOrdered是true,模板将显示一个有序列表;否则它将显示一个无序列表。同样,与添加新项目或删除现有项目相关的字段和按钮分别由enableAdd和enableRemove属性切换。
模板标记通常被添加到 DOM 的非解析元素中,如<template>或<script type="text/html">元素。在清单 7-29 中,完整的组件标记和所有绑定都显示在一个<template>标签中。当组件注册到框架时,Knockout 将使用元素的id在 DOM 中查找模板内容。
Listing 7-29. Input List Component Template
<!-- example-003/public/index.html -->
<template id="item-list-template">
<!-- ko if: isOrdered -->
<!-- #1 THE ORDERED LIST -->
<ol data-bind="foreach: items" class="listless">
<li>
<input data-bind="value: $data,
valueUpdate: 'input',
attr: {name: 'item-' + $index()},
event: {input: $parent.changeItem.bind($parent, $index())}"
type="text" />
<button data-bind="click: $parent.demoteItem.bind($parent, $index())"
class="fa fa-caret-down"></button>
<button data-bind="click: $parent.promoteItem.bind($parent, $index())"
class="fa fa-caret-up"></button>
<button data-bind="click: $parent.removeItem.bind($parent, $index()),
visible: $parent.enableRemove"
class="fa fa-minus"></button>
</li>
</ol>
<!-- /ko -->
<!-- ko ifnot: isOrdered -->
<!-- #2 THE UN-ORDERED LIST -->
<ul data-bind="foreach: items" class="listless">
<li>
<input data-bind="value: $data,
valueUpdate: 'input',
attr: {name: 'item-' + $index()},
event: {input: $parent.changeItem.bind($parent, $index())}"
type="text" />
<button data-bind="click: $parent.removeItem.bind($parent, $index()),
visible: $parent.enableRemove"
class="fa fa-minus"></button>
</li>
</ul>
<!-- /ko -->
<!-- ko if: enableAdd -->
<!-- #3 THE NEW ITEM FIELD -->
<input data-bind="value: newItem"
type="text"
name="new-item" />
<button data-bind="click: commitNewItem"
class="fa fa-plus"></button>
<!-- /ko -->
</template>
在输入列表模板中有许多标记需要消化,但它实际上只是无序成分列表和有序说明列表的组合,带有一个共享的新项目字段。
特殊绑定注释—ko if和ko ifnot注释块——包装模板的一部分,以确定注释块中的元素是否应该添加到页面中。这些注释块评估视图模型的属性,并相应地改变模板处理控制流。这不同于visible元素绑定,后者仅仅隐藏已经存在于 DOM 中的元素。
Tip
在ko注释块绑定中使用的语法被称为无容器控制流语法。
输入列表模板中的所有字段和按钮都绑定到输入列表视图模型上的属性和方法。例如,如果点击一个降级按钮,输入列表视图模型将操作其内部的items集合,该集合实际上是对配方细节视图模型中的instructions可观察数组的引用,通过items绑定共享。该模板基于isOrdered属性确定显示哪种类型的列表,而添加和移除控件基于enableAdd和enableRemove属性进行切换。因为这些属性是从视图模型中的params对象读取的,所以它们中的任何一个都可以作为绑定属性添加到<input-list>组件标签中。通过这种方式,组件抽象并封装了针对任何集合的所有操作,这些操作可以表示为输入列表。
注册输入列表标签
一旦定义了组件视图模型和模板,组件本身必须用挖空注册。这告诉 Knockout 在 DOM 中遇到组件的自定义标记时如何解析组件实例,以及在呈现组件内容时使用什么模板和视图模型。
清单 7-30 中的app.js脚本已经更新,可以在 DOM 准备好之后,但在任何剔除绑定应用到页面之前(使用ko.applyBindings())立即注册输入列表组件。这确保了 Knockout 有时间在 DOM 中呈现组件的标记,所以在任何视图模型被绑定到它之前。
Listing 7-30. Registering the Input List Component
// example-003/public/scripts/app.js
(function app ($, ko, InputList /*...*/) {
// ...
$(function () {
// register the custom component tag before
// Knockout bindings are applied to the page
ko.components.register('input-list', {
template: {
element: 'item-list-template'
},
viewModel: InputList.create
});
// ...
});
}(window.jQuery, window.ko, window.InputList /*...*/));
在清单 7-30 中,ko.components.register()函数接收两个参数:新组件定制标签的名称,input-list和一个选项散列,该散列为 Knockout 提供构建组件所需的信息。
Knockout 使用自定义标记名来标识 DOM 中的<input-list>元素,并用 options hash 中指定的模板内容替换它。
因为输入列表元素的标记已经在一个<template>元素中定义,所以淘汰组件系统只需要知道它应该使用什么元素 ID 来在 DOM 中查找该元素。选项散列中的template对象在其element属性中包含这个 ID。对于较小的组件,整个 HTML 模板可以作为一个字符串直接分配给template属性。
为了构建组件的视图模型,一个工厂函数被分配给选项散列的viewModel属性。该属性还可以引用常规的构造函数,但是使用工厂函数可以避免当事件绑定在视图模型中重新分配关键字this时出现的潜在问题。不管采用哪种方法,视图模型函数都将接收一个params对象,其中填充了来自模板绑定声明的值。
Tip
Knockout 可以通过 RequireJS 自动加载组件模板和查看模型功能。有关更多详细信息,请参考脱模组件文档。RequireJS 模块加载器包含在第五章的中。
既然输入列表组件已经用 Knockout 注册了,那么可编辑成分和说明列表的复杂标记可以用简单的<input-list>实例代替。清单 7-31 展示了产生的更轻、更干净的页面标记。
Listing 7-31. Editing Instructions and Ingredients with the Input List Component
<!-- example-003/public/index.html -->
<h2>Ingredients</h2>
<!-- in read-only view -->
<ul data-bind="foreach: ingredients, visible: !isEditing()">
<li data-bind="text: $data"></li>
</ul>
<!-- in edit view -->
<div data-bind="visible: isEditing" class="edit-field">
<input-list params="items: ingredients,
isOrdered: false"></input-list>
</div>
<h2>Instructions</h2>
<!-- in read-only view -->
<ol data-bind="foreach: instructions, visible: !isEditing()">
<li data-bind="text: $data"></li>
</ol>
<!-- in edit view -->
<div data-bind="visible: isEditing" class="edit-field">
<input-list params="items: instructions,
isOrdered: true"></input-list>
</div>
不仅输入列表的复杂性被隐藏在新的<input-list>标签后面,而且列表的某些方面,比如添加和删除项目的能力,都是通过绑定属性来控制的。这提高了灵活性和可维护性,因为常见的行为被捆绑到单个元素中。
订阅:廉价信息
此时,recipe details 视图模型操作配方数据,但不保存更改。它也无法将配方更改传递给配方列表,因此,即使用户修改了配方的标题,配方列表也会继续显示配方的原始标题。从用例的角度来看,只有当菜谱细节被发送到服务器并成功持久化时,菜谱列表才应该被更新。需要一种更复杂的机制来促进这一工作流程。
淘汰可观察对象实现了淘汰可订阅对象的行为,这是一个更抽象的对象,它不保存值,但充当一种其他对象可能订阅的事件机制。Observables 利用了 subscribable 接口,通过 subscribable 发布自己的更改,DOM 绑定(甚至其他视图模型)会监听 subscribable。
可订阅性可以直接作为属性附加到视图模型上,或者通过引用对其事件感兴趣的任何对象来传递。在清单 7-32 中,一个 subscribable 在app.js文件中构造,并作为参数传递给配方列表和配方细节模块。注意,与可观察对象不同,可订阅对象必须用关键字new进行实例化。
Listing 7-32. Knockout Subscribable Acting As a Primitive Message Bus
// example-004/public/scripts/app.js
var bus = new ko.subscribable();
var list = RecipeList.create(recipes, bus);
var details = RecipeDetails.create(list.selectedRecipe(), bus);
为了有效地将更新的配方发布到订户,配方细节视图模型已经以多种方式进行了修改。
首先,subscribable 作为一个名为bus的参数传递给 recipe details 工厂函数。当配方细节改变时,配方细节模块将使用这个可订阅的来引发事件。
其次,视图模型现在跟踪配方的 ID,因为这个值将用于更新服务器上的配方数据。保存更改后,配方列表也将使用 ID 来替换过时的配方数据。
最后,save()方法已经更新,可以触发bus订户的recipe.saved事件,将修改后的配方数据作为参数传递给任何订户。修改后的save()方法如清单 7-33 所示。
Listing 7-33. Recipe Details View Model Saving a Modified Recipe
// example-004/public/scripts/recipe-details.js
viewmodel.save = function () {
var savedRecipe = {
id: viewmodel.id,
title: viewmodel.title(),
ingredients: viewmodel.ingredients(),
instructions: viewmodel.instructions(),
cookingTime: {
hours: viewmodel.hours(),
minutes: viewmodel.minutes()
},
servings: viewmodel.servings(),
citation: viewmodel.citation()
};
bus.notifySubscribers(savedRecipe, 'recipe.saved');
viewmodel.isEditing(false);
};
subscribers 上的notifySubscribers()方法接受两个参数——订户将收到的数据对象和引发的事件的名称。app.js模块订阅可订阅的bus上的recipe.saved事件,如清单 7-34 所示,并发起一个 AJAX 请求将修改后的食谱数据发送到服务器。因为配方细节视图模型和app.js模块共享对bus对象的引用,所以配方细节视图模型触发的任何事件都可以在app.js模块中处理。
Listing 7-34. Saved Recipe Is Persisted to the Server
// example-004/public/scripts/app.js
var bus = new ko.subscribable();
bus.subscribe(function (updatedRecipe) {
$.ajax({
method: 'PUT',
url: '/recipes/' + updatedRecipe.id,
data: updatedRecipe
}).then(function () {
bus.notifySubscribers(updatedRecipe, 'recipe.persisted');
})
}, null, 'recipe.saved');
subscribable 的subscribe()方法接受三个参数:
- 当指定的事件在 subscribable 上被触发时要执行的回调函数
- 将被绑定到回调函数中的
this关键字的上下文对象(或者null,如果this关键字在回调函数中从未被使用) - 订阅回调的事件的名称(例如,
recipe.saved)
如果 AJAX 更新成功,app.js 模块会在 subscribable 上触发一个recipe.persisted事件来通知侦听器。对bussubscribe 的引用也被传递给了 recipe list 视图模型,它主动监听recipe.persisted事件。当事件触发时,配方列表接收保存在列表 7-35 中的数据,并根据持久化的接收者 ID 更新其内部配方集合和选定的配方。
Listing 7-35. Updating the Recipe List with a Persisted Recipe
// example-004/public/scripts/recipe-list.js
window.RecipeList = (function (ko) {
return {
create: function (recipes, bus) {
var viewmodel = {};
// properties
viewmodel.recipes = ko.observableArray(recipes);
viewmodel.selectedRecipe = ko.observable(recipes[0]);
// ...
bus.subscribe(function (updatedRecipe) {
var recipes = viewmodel.recipes();
var i = 0,
count = recipes.length;
while (i < count) {
if (recipes[i].id !== updatedRecipe.id) {
i += 1;
continue;
}
recipes[i] = updatedRecipe;
viewmodel.recipes(recipes);
viewmodel.selectRecipe(recipes[i]);
break;
}
}, null, 'recipe.persisted');
// ...
}
};
}(window.ko));
尽管可订阅性并不是在应用中引发事件的唯一方式,但是它们对于简单的用例来说是有效的,可以在模块之间创建一个解耦的通信链。
摘要
许多前端框架提供了引人注目的特性和插件套件,但 Knockout 真正关注的是应用中 HTML 视图和数据模型之间的交互。Knockout 的可观察性减轻了从 HTML DOM 元素手动提取数据和将数据推入 HTML DOM 元素的痛苦。开发人员可以向页面上的任何元素添加data-bind属性,通过双向绑定将标记粘合到一个或多个视图模型。
虽然表单数据可以直接绑定到视图模型属性,但是 DOM 事件绑定也可以调用挖空视图模型上的方法。这些方法对查看模型可观察属性所做的任何更改都会立即反映在 DOM 中。像visible和css这样的绑定决定元素如何显示给用户,而像text和value这样的绑定决定元素的内容。
可观察对象是保存视图模型数据值的特殊对象。当它们的值改变时,observables 会通知任何感兴趣的订阅者,包括绑定的 DOM 元素。原始可观察值保存单个值,而可观察数组保存集合。发生在可观察数组上的突变可以被绑定到集合的 HTML 元素跟踪和镜像。当迭代一个可观察数组的元素时,foreach绑定特别有用,尽管如果一个可观察数组的单个成员被改变或替换,必须要特别考虑。
敲除模板和视图模型可以抽象成具有独特 HTML 标签的可重用组件。可以将这些组件添加到页面中,并绑定到其他视图模型属性,就像绑定任何标准 HTML 元素一样。将状态和行为封装在组件中减少了页面上的总标记,还保证了应用的类似部分(例如,绑定到集合的输入列表)无论在哪里使用都具有相同的行为。
最后,可订阅对象 observables 背后的基本构件——可以用作原始消息总线,通知订阅者已发布的事件,并可能在需要的地方传递有效数据。
相关资源
- 淘汰赛网站:
http://knockoutjs.com/ - KnockMeOut.net:
http://www.knockmeout.net/