注意到一点,forEach 允许我们指明我们想要使用数组中的元素做些什么, 但是隐藏了数组是怎么样被遍历的细节.
投影数组
对某个值执行一个函数,以得到新的值,这种操作叫做投影。为了把一个数组投影成另一个数组,我们对数组中的每个元素都执行一个投影函数,然后把所有的结果收集起来,组成新的数组。
这是一套附带练习的交互式教程,你可以直接在浏览器中完成。如果你只是想看到教程内容,点击下面的按钮:
这是一套用于学习使用微软 Reactive Extensions(Rx) Javascript 库的交互式学习教程。那为什么标题要叫做“Javascript 函数式编程”呢?因为学习 Rx 的关键,就是要训练自己去使用函数式的编程方法,进行各种集合操作。函数式编程可以让开发者把常见的集合操作,抽象成一个个可重用的,可组合的代码块。你会惊讶的发现,绝大部分集合操作都可以用下面这五个简单的函数来实现(有些函数是 Javascript 原生自带的,有些包含在 RxJS 库 里面):
我向你打包票,如果你掌握了这五个函数,你的代码会变得更精简,更有表达能力,而且更易于维护。而且,现在看起来可能不是很明显,但是实际上这五个函数式简化异步编程的关键。当你完成了这个教程之后,你就也掌握了必要的方法,可以做到避免竞态,传播和处理异步错误,处理串行的事件和 AJAX 请求等等。一句话,这五个函数可能是你这辈子学到的所有函数中最强大,最灵活,最有用的。
这个教程不仅仅是一个教程,它包含了一系列的交互式练习,你只需要浏览器就可以完成。完成练习的步骤很简单,只需要编辑代码,然后点击“运行”,如果代码可以工作,新的练习就会出现在下面,反之则会弹出错误提示。
提示:使用 "F4" 键可以打开编辑器的全屏模式
这个教程可能存在 bug,如果你碰到了什么诡异的情况,或者你觉得已经输入了正确答案但是还是不能继续向下进行,就刷新一下浏览器试试。如果你使用的是现代浏览器(你都来到这里了,我相信你肯定不是用的 IE6),练习的状态会被保存到本地。如果你需要的话,可以重启这个教程。
这个教程托管在 Github 上, 处于一个正在逐渐被完善的阶段,如果你想添加新的练习,完善已有练习的问题描述,或者解决某个 bug,都可以 fork 这个项目,然后发送 pull request。我们会试着把用户贡献的问题融合到已有的教程当中。
你的答案会被存储到 local storage 当中。 如果你想把答案转移到另外的位置,使用下面的按钮进行操作:
在 Javascript 中,数组是唯一的集合类型,几乎写任何程序都需要用到数组。接下来我们要把上面提到的五个函数,加入到数组类型,让数组类型变得更加强大易用。实际上,Javascript 的数组类型已经自带了 map,filter 和 reduce 这三个函数了!不过我们还是要把他们重新实现一遍,作为练习的一部分。
下面教程的过程是这样的,首先我们使用你在学校学过的方法(或者在别人代码中看到的方法)解决问题,也就是使用循环和赋值语句,把一个集合转换为另一个集合。然后我们实现上面提到的五个函数中的某一个,接下来使用这个函数在不使用循环的情况下解决相同的问题。当你把五个函数全部掌握之后,你会学习如何把他们结合起来,在不需要太多的代码的情况下,解决更加复杂的问题。
// Traverse array with for loop function(str) { preVerifierHook(); var fun = eval("(" + str + ")"); var items = []; var got; var expected = '["Ben","Brian","Jafar","Matt","Priya"]'; fun({ log:function(name) { items.push(name); console.log(name); } }); got = JSON.stringify(items.sort()); if(got === expected) { return "成功!" } else { showLessonErrorMessage(expected, got, '注意:输出顺序没有关系'); } }
function(console) { var names = ["Ben", "Jafar", "Matt", "Priya", "Brian"], counter; for(counter = 0; counter < names.length; counter++) { console.log(names[counter]); } }
思考这个问题: 我们需要确定数组打印的顺序吗?如果不需要,那么为什么需要循环呢?
我们使用 forEach 函数重做上面的练习。
// Traverse array with foreach function(str) { preVerifierHook(); if (str.indexOf(".forEach") === -1) { return "You have to use forEach!" } var fun = eval("(" + str + ")"); var items =[]; fun({ log:function(name) { items.push(name); console.log(name); } }); if(JSON.stringify(items.sort()) === '["Ben","Brian","Jafar","Matt","Priya"]') { return "Success!" } else { throw 'console.log 没有输出下面这些值: "Ben","Brian","Jafar","Matt","Priya" (注意:顺序没有关系)' } }
function(console) { var names = ["Ben", "Jafar", "Matt", "Priya", "Brian"]; names.forEach(function(name) { console.log(name); }); }
注意到一点,forEach 允许我们指明我们想要使用数组中的元素做些什么, 但是隐藏了数组是怎么样被遍历的细节.
对某个值执行一个函数,以得到新的值,这种操作叫做投影。为了把一个数组投影成另一个数组,我们对数组中的每个元素都执行一个投影函数,然后把所有的结果收集起来,组成新的数组。
对于每一个 video, 添加一个 {id, title} 对到 videoAndTitlePairs 数组。
// Projection with with forEach function(str) { preVerifierHook(); var fun = eval("(" + str + ")"), videoAndTitlePairs = fun(), expected = '[{\"id\":675465,\"title\":\"Fracture\"},{\"id\":65432445,\"title\":\"The Chamber\"},{\"id\":70111470,\"title\":\"Die Hard\"},{\"id\":654356453,\"title\":\"Bad Boys\"}]'; // Sorting by video id videoAndTitlePairs = videoAndTitlePairs.sortBy(function(video) { return video.id }); if (JSON.stringify(videoAndTitlePairs) === expected) { return true; } else { showLessonErrorMessage(expected, JSON.stringify(videoAndTitlePairs)); } }
function() { var newReleases = [ { "id": 70111470, "title": "Die Hard", "boxart": "http://cdn-0.nflximg.com/images/2891/DieHard.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": [4.0], "bookmark": [] }, { "id": 654356453, "title": "Bad Boys", "boxart": "http://cdn-0.nflximg.com/images/2891/BadBoys.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": [5.0], "bookmark": [{ id:432534, time:65876586 }] }, { "id": 65432445, "title": "The Chamber", "boxart": "http://cdn-0.nflximg.com/images/2891/TheChamber.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": [4.0], "bookmark": [] }, { "id": 675465, "title": "Fracture", "boxart": "http://cdn-0.nflximg.com/images/2891/Fracture.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": [5.0], "bookmark": [{ id:432534, time:65876586 }] } ], videoAndTitlePairs = []; newReleases.forEach(function(video) { videoAndTitlePairs.push({id:video.id, title: video.title}); }); return videoAndTitlePairs; }
所有的数组投影都需要完成下面两个操作
既然如此,我们为什么不直接把怎么样执行这些操作的细节隐藏起来呢?
为了让投影操作变得更简单,我们给数组类型添加一个map()方法。Map 函数接受一个投影函数作为参数,对源数组的每个对象都应用这个函数,然后返回投影后的数组。
// Implement map() function(str) { preVerifierHook(); var fun = eval(str), arr = [1,2,3], result; result = arr.map(function(x) { return x + 1}); if (JSON.stringify(arr) !== "[1,2,3]") { throw "喔! 你改变了源数组的值。Map 不应该改变输入数组的值,它应该对输入数组的每个元素执行投影函数之后,返回一个新的数组" } else if(JSON.stringify(result) !== '[2,3,4]') { throw '[1,2,3].map(function(x) { return x + 1}) 结果应该为 [2,3,4].' } }
Array.prototype.map = function(projectionFunction) { var results = []; this.forEach(function(itemInArray) { results.push(projectionFunction(itemInArray)); }); return results; }; // JSON.stringify([1,2,3].map(function(x) { return x + 1; })) === '[2,3,4]'
我们来重复上面的收集 {id, title} 对得到一个新数组的练习,这次使用 map 函数来完成。
// Projection with map function(str) { preVerifierHook(); var fun = eval("(" + str + ")"), videoAndTitlePairs = fun(), expected = '[{\"id\":675465,\"title\":\"Fracture\"},{\"id\":65432445,\"title\":\"The Chamber\"},{\"id\":70111470,\"title\":\"Die Hard\"},{\"id\":654356453,\"title\":\"Bad Boys\"}]'; // Sorting by video id videoAndTitlePairs = videoAndTitlePairs.sortBy(function(video) { return video.id }); if (JSON.stringify(videoAndTitlePairs) === expected) { return true; } else { throw 'Expected: ' + expected; } }
function() { var newReleases = [ { "id": 70111470, "title": "Die Hard", "boxart": "http://cdn-0.nflximg.com/images/2891/DieHard.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": [4.0], "bookmark": [] }, { "id": 654356453, "title": "Bad Boys", "boxart": "http://cdn-0.nflximg.com/images/2891/BadBoys.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": [5.0], "bookmark": [{ id:432534, time:65876586 }] }, { "id": 65432445, "title": "The Chamber", "boxart": "http://cdn-0.nflximg.com/images/2891/TheChamber.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": [4.0], "bookmark": [] }, { "id": 675465, "title": "Fracture", "boxart": "http://cdn-0.nflximg.com/images/2891/Fracture.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": [5.0], "bookmark": [{ id:432534, time:65876586 }] } ]; return newReleases.map(function(video) { return {id: video.id, title: video.title}; }); }
注意到,map 允许我们指明我们想要对数组施加什么投影操作, 但是隐藏了这个操作是 怎么样被执行的细节。
和投影类似,过滤(filter)数组也是很常见的操作。过滤一个数组是这样的过程,我们对数组中的每个元素都执行一个检查,然后把所有通过这个检查的元素收集成一个新的数组。
使用 forEach() 遍历 newReleases 数组, 如果一个 video 评分为 5.0, 就把它添加到 videos 数组.
// Filter with forEach function(str) { preVerifierHook(); var fun = eval("(" + str + ")"), videos = fun(), expected = '[{"id":675465,"title":"Fracture","boxart":"http://cdn-0.nflximg.com/images/2891/Fracture.jpg","uri":"http://api.netflix.com/catalog/titles/movies/70111470","rating":5,"bookmark":[{"id":432534,"time":65876586}]},{"id":654356453,"title":"Bad Boys","boxart":"http://cdn-0.nflximg.com/images/2891/BadBoys.jpg","uri":"http://api.netflix.com/catalog/titles/movies/70111470","rating":5,"bookmark":[{"id":432534,"time":65876586}]}]'; // Sorting by video id videos = videos.sortBy(function(v) { return v.id; }); if (JSON.stringify(videos) === expected) { return true; } else { throw 'Expected: ' + expected; } }
function() { var newReleases = [ { "id": 70111470, "title": "Die Hard", "boxart": "http://cdn-0.nflximg.com/images/2891/DieHard.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, "bookmark": [] }, { "id": 654356453, "title": "Bad Boys", "boxart": "http://cdn-0.nflximg.com/images/2891/BadBoys.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, "bookmark": [{ id:432534, time:65876586 }] }, { "id": 65432445, "title": "The Chamber", "boxart": "http://cdn-0.nflximg.com/images/2891/TheChamber.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, "bookmark": [] }, { "id": 675465, "title": "Fracture", "boxart": "http://cdn-0.nflximg.com/images/2891/Fracture.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, "bookmark": [{ id:432534, time:65876586 }] } ], videos = []; newReleases.forEach(function(video) { if (video.rating === 5.0) { videos.push(video); } }); return videos; }
注意,和 map() 类似, 每个 filter() 操作也都要完成下面的操作:
为什么我们不把这些实现细节也隐藏起来呢?
为了让过滤操作更简单, 我们给数组类型添加一个 filter() 函数。 filter() 接受一个断言作为参数。 断言是这样一个函数: 它以数组中的一个元素作为参数,返回一个布尔值,表明这个元素应不应该被添加到新数组中。
// Implement filter() function(str) { preVerifierHook(); var fun = eval(str), arr = [1,2,3], result; result = arr.filter(function(x) { return x > 2}); if (JSON.stringify(arr) !== "[1,2,3]") { throw "喔! 你改变了源数组的值。Filter 不应该改变输入数组的值,它应该对输入数组的每个元素执行投影函数之后,返回一个新的数组" } else if(JSON.stringify(result) !== '[3]') { throw '[1,2,3].filter(function(x) { return x > 2}) 结果应该为 [3].' } }
Array.prototype.filter = function(predicateFunction) { var results = []; this.forEach(function(itemInArray) { if (predicateFunction(itemInArray)) { results.push(itemInArray); } }); return results; }; // JSON.stringify([1,2,3].filter(function(x) { return x > 2})) === "[3]"
和 map() 类似, filter() 让我们选择想要什么数据,但是不要求我们指明怎么样去收集这些数据。
// Filter with filter() function(str) { preVerifierHook(); var fun = eval("(" + str + ")"), videoids = fun(), expected = '[675465,654356453]'; // Sorting by video id videoids = videoids.sortBy(function(v) { return v; }); if (JSON.stringify(videoids) === expected) { return true; } else { throw 'Expected: ' + expected; } }
function() { var newReleases = [ { "id": 70111470, "title": "Die Hard", "boxart": "http://cdn-0.nflximg.com/images/2891/DieHard.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, "bookmark": [] }, { "id": 654356453, "title": "Bad Boys", "boxart": "http://cdn-0.nflximg.com/images/2891/BadBoys.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, "bookmark": [{ id:432534, time:65876586 }] }, { "id": 65432445, "title": "The Chamber", "boxart": "http://cdn-0.nflximg.com/images/2891/TheChamber.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, "bookmark": [] }, { "id": 675465, "title": "Fracture", "boxart": "http://cdn-0.nflximg.com/images/2891/Fracture.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, "bookmark": [{ id:432534, time:65876586 }] } ]; // ------------ 在这里写下答案 ----------------------------------- // 结合 filter 和 map 方法,拿到所有 rating 为 5.0 的 video 的 id。 return newReleases. filter(function(video) { return video.rating === 5.0; }). map(function(video) { return video.id; }); // ------------ 在这里写下答案 ----------------------------------- }
把 map() 和 filter() 结合在一起调用,让我们的代码变得富有表现力。 这些高层函数让我们表达我们想要 什么数据, 使得下层库在处理怎么样执行查询操作时可以有很大的灵活性。
有时候,除了数组之外,我们还需要对树状数据结构进行查询。树带来了一个新的问题,因为我们需要首先把数展开成数组,然后才能使用 filter() 和 map() 操作。在这个部分的练习中,我们定义了 concatAll() 函数,结合它我们就可以使用 map() 和 filter 对树进行查询了。
首先,我们使用两层嵌套的 forEach 循环,把二维数组 movieLists 中所有 video 的 id 收集起来。
// Use filter and map to collect video ids with rating of 5.0 function(str) { var fun = eval("(" + str + ")"), videos = fun(), expected = '[675465,65432445,70111470,654356453]'; videos = videos.sortBy(function(v) { return v }); if (JSON.stringify(videos) !== expected) { throw "Expected " + expected; } }
function() { var movieLists = [ { name: "New Releases", videos: [ { "id": 70111470, "title": "Die Hard", "boxart": "http://cdn-0.nflximg.com/images/2891/DieHard.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, "bookmark": [] }, { "id": 654356453, "title": "Bad Boys", "boxart": "http://cdn-0.nflximg.com/images/2891/BadBoys.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, "bookmark": [{ id:432534, time:65876586 }] } ] }, { name: "Dramas", videos: [ { "id": 65432445, "title": "The Chamber", "boxart": "http://cdn-0.nflximg.com/images/2891/TheChamber.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, "bookmark": [] }, { "id": 675465, "title": "Fracture", "boxart": "http://cdn-0.nflximg.com/images/2891/Fracture.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, "bookmark": [{ id:432534, time:65876586 }] } ] } ], allVideoIdsInMovieLists = []; movieLists.forEach(function(movieList) { movieList.videos.forEach(function(video) { allVideoIdsInMovieLists.push(video.id); }); }); return allVideoIdsInMovieLists; }
用 forEach 来展开数组很简单,因为我们可以显式地把元素添加到新数组中。可惜,这恰恰就是我们想通过类似 map(),filter() 这种函数来封装起来的底层操作。我们能不能定义一个方法,使它足够抽象,允许我们指明想要展开一个树的意图,但是不需要指明怎么样完成这个操作?
我们给数组类型增加一个 concatAll() 方法。 concatAll() 会遍历数组中的每一个子数组,把它们的结果收集到一个新的,展开的数组当中。 注意 concatAll() 方法需要数组中的每一个元素也都是一个数组。
// Flatten movieLists into an array of video ids function(str) { preVerifierHook(); var fun = eval(str), arr = [[1,2,3],[4,5,6],[7,8,9]], result, expected = "[1,2,3,4,5,6,7,8,9]"; result = arr.concatAll(); result = result.sortBy(function(x) { return x; }); if (JSON.stringify(result) !== expected) { throw 'Expected that [[1,2,3],[4,5,6],[7,8,9]].concatAll() would equal [1,2,3,4,5,6,7,8,9].' } }
Array.prototype.concatAll = function() { var results = []; this.forEach(function(subArray) { results.push.apply(results, subArray); }); return results; }; // JSON.stringify([ [1,2,3], [4,5,6], [7,8,9] ].concatAll()) === "[1,2,3,4,5,6,7,8,9]" // [1,2,3].concatAll(); // throws an error because this is a one-dimensional array
concatAll 函数非常简单,简单到你可能都不太会注意到它如何与 map() 结合起来,进行树的查询,我们来举一个例子。
提示: 使用两个嵌套的 map() 和一个 concatAll()。
function(str) { preVerifierHook(); var fun = eval("(" + str + ")"), videos = fun(), expected = '[675465,65432445,70111470,654356453]'; videos = videos.sortBy(function(v) { return v }); if (JSON.stringify(videos) !== expected) { throw "Expected " + expected + "\n\nReceived " + JSON.stringify(videos); } }
function() { var movieLists = [ { name: "New Releases", videos: [ { "id": 70111470, "title": "Die Hard", "boxart": "http://cdn-0.nflximg.com/images/2891/DieHard.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, "bookmark": [] }, { "id": 654356453, "title": "Bad Boys", "boxart": "http://cdn-0.nflximg.com/images/2891/BadBoys.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, "bookmark": [{ id:432534, time:65876586 }] } ] }, { name: "Dramas", videos: [ { "id": 65432445, "title": "The Chamber", "boxart": "http://cdn-0.nflximg.com/images/2891/TheChamber.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, "bookmark": [] }, { "id": 675465, "title": "Fracture", "boxart": "http://cdn-0.nflximg.com/images/2891/Fracture.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, "bookmark": [{ id:432534, time:65876586 }] } ] } ]; // ------------ 在这里写下答案 ----------------------------------- // 使用 map 和 concatAll 把 movieLists 打平成 video id 的数组。 return movieLists. map(function(movieList) { return movieList.videos.map(function(video) { return video.id; }); }). concatAll(); // ------------ 在这里写下答案 ----------------------------------- }
干得漂亮! 掌握 map() 和 concatAll() 的结合使用对于高效率的函数式编程而言十分重要。你已经迈出了坚实的一步!我们来尝试一个更加复杂的例子。
你已经成功地展开过一个两层深的树了,我们来试一下三层的。对于每个 video,我们不是只有一个 boxart url,而是有一组 boxart 对象,每个对象里包含了不同的大小和 url。你需要做这样一个查询,从 movieLists 中把每个 video 的 {id, title, boxart} 筛选出来,而且这次结果中的 boxart url 需要来自具有 150x200px 尺寸的 boxart 对象。试着使用 map(),concatAll() 和 filter() 解决这个问题。
还有一件事:你不能使用数组下标。也就是说这样的写法是 非法的:
var itemInArray = movieLists[0];
更进一步的,在接下来的所有练习中你都不能使用下标,除非你是在实现开始提到的那五个函数中的某一个。这样做是有很大的好处的,具体的好处我们会在后面解释清楚。现在你只需要遵守这个规定,并且相信这样做的目的是好的就可以了 :-)
function(str) { preVerifierHook(); var fun = eval("(" + str + ")"), videos = fun(), got, expected = JSON.stringify([ {"id": 675465,"title": "Fracture","boxart":"http://cdn-0.nflximg.com/images/2891/Fracture150.jpg" }, {"id": 65432445,"title": "The Chamber","boxart":"http://cdn-0.nflximg.com/images/2891/TheChamber150.jpg" }, {"id": 654356453,"title": "Bad Boys","boxart":"http://cdn-0.nflximg.com/images/2891/BadBoys150.jpg" }, {"id": 70111470,"title": "Die Hard","boxart":"http://cdn-0.nflximg.com/images/2891/DieHard150.jpg" } ].sortBy(function(v) { return v.id })); if (str.indexOf('[0]') !== -1) { throw "你不能使用数组下标。你可能过早地创建了对象。与其使用数组下标来从数组中获取 boxart,试着使用 map() 方法在投影函数中创建新的对象"; } videos = videos.sortBy(function(v) { return v.id }); got = JSON.stringify(videos); if (got !== expected) { showLessonErrorMessage(expected, got); } }
function() { var movieLists = [ { name: "Instant Queue", videos : [ { "id": 70111470, "title": "Die Hard", "boxarts": [ { width: 150, height:200, url:"http://cdn-0.nflximg.com/images/2891/DieHard150.jpg" }, { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/DieHard200.jpg" } ], "url": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, "bookmark": [] }, { "id": 654356453, "title": "Bad Boys", "boxarts": [ { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/BadBoys200.jpg" }, { width: 150, height:200, url:"http://cdn-0.nflximg.com/images/2891/BadBoys150.jpg" } ], "url": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, "bookmark": [{ id:432534, time:65876586 }] } ] }, { name: "New Releases", videos: [ { "id": 65432445, "title": "The Chamber", "boxarts": [ { width: 150, height:200, url:"http://cdn-0.nflximg.com/images/2891/TheChamber150.jpg" }, { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/TheChamber200.jpg" } ], "url": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, "bookmark": [] }, { "id": 675465, "title": "Fracture", "boxarts": [ { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture200.jpg" }, { width: 150, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture150.jpg" }, { width: 300, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture300.jpg" } ], "url": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, "bookmark": [{ id:432534, time:65876586 }] } ] } ]; // 使用一个或者多个 map,concatAll,filter 方法拿到一个有下列元素的数组 // [ // {"id": 675465,"title": "Fracture","boxart":"http://cdn-0.nflximg.com/images/2891/Fracture150.jpg" }, // {"id": 65432445,"title": "The Chamber","boxart":"http://cdn-0.nflximg.com/images/2891/TheChamber150.jpg" }, // {"id": 654356453,"title": "Bad Boys","boxart":"http://cdn-0.nflximg.com/images/2891/BadBoys150.jpg" }, // {"id": 70111470,"title": "Die Hard","boxart":"http://cdn-0.nflximg.com/images/2891/DieHard150.jpg" } // ]; return movieLists. map(function(movieList) { return movieList.videos. map(function(video) { return video.boxarts. filter(function(boxart) { return boxart.width === 150; }). map(function(boxart) { return {id: video.id, title: video.title, boxart: boxart.url}; }); }). concatAll(); }). concatAll(); }
精彩!现在你已经学会了使用 concatAll(), map() 以及 filter() 进行树的查询。 注意到map() 和 concatAll() 经常会一起使用。我们来加入一个小的助手函数来处理这种场景。
几乎每次我们需要展开一个树的时候,都会链接使用 map() 和 concatAll()。有些时候,如果我们需要处理的树有好几层深,我们就需要在代码中重复好几次这种写法。为了让代码更简单,我们加入 concatMap 方法,它就是一个 map 操作, 然后紧接一个 concatAll。
// Implement concatAll function(str) { preVerifierHook(); var fun = eval(str), spanishFrenchEnglishWords = [ ["cero","rien","zero"], ["uno","un","one"], ["dos","deux","two"] ], allWords = [0,1,2], result, expected = '["cero","rien","zero","uno","un","one","dos","deux","two"]'; var allWords = [0,1,2]. concatMap(function(index) { return spanishFrenchEnglishWords[index]; }); if (JSON.stringify(allWords) !== expected) { throw "Expected " + expected; } }
Array.prototype.concatMap = function(projectionFunctionThatReturnsArray) { return this. map(function(item) { return projectionFunctionThatReturnsArray(item); }). // apply the concatAll function to flatten the two-dimensional array concatAll(); }; /* var spanishFrenchEnglishWords = [ ["cero","rien","zero"], ["uno","un","one"], ["dos","deux","two"] ]; // collect all the words for each number, in every language, in a single, flat list var allWords = [0,1,2]. concatMap(function(index) { return spanishFrenchEnglishWords[index]; }); return JSON.stringify(allWords) === '["cero","rien","zero","uno","un","one","dos","deux","two"]'; */
从现在起,在展开树的时候不需要再使用 map().concatAll(),我们可以直接使用 concatMap 函数。
让我们再来做一下刚刚做过的那个练习。这一次我们将用 concatMap() 来替换 map().concatAll() 来简化代码。
function(str) { preVerifierHook(); var fun = eval("(" + str + ")"), videos = fun(), got, expected = JSON.stringify([ {"id": 675465,"title": "Fracture","boxart":"http://cdn-0.nflximg.com/images/2891/Fracture150.jpg" }, {"id": 65432445,"title": "The Chamber","boxart":"http://cdn-0.nflximg.com/images/2891/TheChamber150.jpg" }, {"id": 654356453,"title": "Bad Boys","boxart":"http://cdn-0.nflximg.com/images/2891/BadBoys150.jpg" }, {"id": 70111470,"title": "Die Hard","boxart":"http://cdn-0.nflximg.com/images/2891/DieHard150.jpg" } ].sortBy(function(v) { return v.id })); videos = videos.sortBy(function(v) { return v.id }); got = JSON.stringify(videos); if (got !== expected) { showLessonErrorMessage(expected, got); } }
function() { var movieLists = [ { name: "Instant Queue", videos : [ { "id": 70111470, "title": "Die Hard", "boxarts": [ { width: 150, height:200, url:"http://cdn-0.nflximg.com/images/2891/DieHard150.jpg" }, { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/DieHard200.jpg" } ], "url": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, "bookmark": [] }, { "id": 654356453, "title": "Bad Boys", "boxarts": [ { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/BadBoys200.jpg" }, { width: 150, height:200, url:"http://cdn-0.nflximg.com/images/2891/BadBoys150.jpg" } ], "url": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, "bookmark": [{ id:432534, time:65876586 }] } ] }, { name: "New Releases", videos: [ { "id": 65432445, "title": "The Chamber", "boxarts": [ { width: 150, height:200, url:"http://cdn-0.nflximg.com/images/2891/TheChamber150.jpg" }, { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/TheChamber200.jpg" } ], "url": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, "bookmark": [] }, { "id": 675465, "title": "Fracture", "boxarts": [ { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture200.jpg" }, { width: 150, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture150.jpg" }, { width: 300, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture300.jpg" } ], "url": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, "bookmark": [{ id:432534, time:65876586 }] } ] } ]; // 使用一个或者多个 concatMap, map,filter 方法拿到一个有下列元素的数组 // [ // {"id": 675465,"title": "Fracture","boxart":"http://cdn-0.nflximg.com/images/2891/Fracture150.jpg" }, // {"id": 65432445,"title": "The Chamber","boxart":"http://cdn-0.nflximg.com/images/2891/TheChamber150.jpg" }, // {"id": 654356453,"title": "Bad Boys","boxart":"http://cdn-0.nflximg.com/images/2891/BadBoys150.jpg" }, // {"id": 70111470,"title": "Die Hard","boxart":"http://cdn-0.nflximg.com/images/2891/DieHard150.jpg" } // ]; return movieLists.concatMap(function(movieList) { return movieList.videos.concatMap(function(video) { return video.boxarts. filter(function(boxart) { return boxart.width === 150; }). map(function(boxart) { return {id: video.id, title: video.title, boxart: boxart.url}; }); }); }); }
将最后一个运算变为 map,是一种访问若干嵌套的 concatMap 运算的很常见的方式。你可以把这种方式当做一个嵌套的 forEach 的函数式版本。
有时我们需要同时对数组中很多项进行操作。举例,我们需要找到数组中的最大整型值。我们不能使用 filter(),因为 filter() 每次只对一项进行检查。为了找到这个最大整型值,我们需要互相比较数组中的每一项。
先假设某一项是最大值(可能是第一项),然后再把它和数组中其余所有项进行对比。每一次我们找到一个比假想值大的数时,就把这个假想值替换为我们找到的这个值,直到我们遍历完整个数组。
如果我们用闭包来替换特定大小的比较,我们需要写一个函数来遍历数组。每一步我们的函数将在当前的值和最后的值运用闭包 ,下一次就将结果作为最后的值。最后我们只剩下一个值。这个过程被称为缩减,因为我们将很多的值变为了一个值。
在这个例子中我们将使用 forEach 找到最大的 box art。我们每次检查一个新的 boxart 都会更新一下目前找到的最大值 maximumSize。 如果当前 boxart 的 size 小于这个值,就丢弃它,如果大于,就保留它。最后我们会得到一个拥有最大尺寸的 boxart。
// Find largest box art function(str){ preVerifierHook(); var fun = eval("(" + str + ")"), boxart = fun(), got = JSON.stringify(boxart), expected = JSON.stringify({ width: 425, height:150, url:"http://cdn-0.nflximg.com/images/2891/Fracture425.jpg" }); if (got !== expected) { showLessonErrorMessage(expected, got); } }
function() { var boxarts = [ { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture200.jpg" }, { width: 150, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture150.jpg" }, { width: 300, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture300.jpg" }, { width: 425, height:150, url:"http://cdn-0.nflximg.com/images/2891/Fracture425.jpg" } ], currentSize, maxSize = -1, largestBoxart; boxarts.forEach(function(boxart) { currentSize = boxart.width * boxart.height; if (currentSize > maxSize) { largestBoxart = boxart; maxSize = currentSize; } }); return largestBoxart; }
这个过程叫做缩减(reduction)。我们每次使用上次计算的结果,来计算当前的值。在这个例子中我们 还是需要手动进行遍历操作。如果我们只需要声明我们想要做的操作,不就更好了吗?下面我们就来写这样一个助手函数用来对数组进行缩减操作。
和 map 类似,我们给 Array 类型增加一个 reduce 方法。
// Implement reduce function(str) { preVerifierHook(); var fun = eval(str), numbers = [1,2,3], sum = numbers.reduce(function(acc,curr) { return acc + curr }), expected = JSON.stringify([6]), sum2 = numbers.reduce(function(acc,curr) { return acc + curr },10), expected2 = JSON.stringify([16]); if (JSON.stringify(sum) !== expected) { throw "Expected that [1,2,3].reduce(function(accumulated,current) { return accumulated + current; }) === [6]. Instead got " + JSON.stringify(sum); } if (JSON.stringify(sum2) !== expected2) { throw "Expected that [1,2,3].reduce(function(accumulated,current) { return accumulated + current; }, 10) === [16]. Instead got " + JSON.stringify(sum2); } }
// [1,2,3].reduce(function(accumulatedValue, currentValue) { return accumulatedValue + currentValue; }); === [6]; // [1,2,3].reduce(function(accumulatedValue, currentValue) { return accumulatedValue + currentValue; }, 10); === [16]; Array.prototype.reduce = function(combiner, initialValue) { var counter, accumulatedValue; // 如果数组是空,直接返回 if (this.length === 0) { return this; } else { // 如果用户没有提供初始值,使用数组当中的第一个元素。 if (arguments.length === 1) { counter = 1; accumulatedValue = this[0]; } else if (arguments.length >= 2) { counter = 0; accumulatedValue = initialValue; } else { throw "Invalid arguments."; } // 遍历整个数组,把当前元素和上一次计算的结果,传给 combiner 函数,直到遍历完整个 // 数组,只剩下一个值。 while(counter < this.length) { accumulatedValue = combiner(accumulatedValue, this[counter]) counter++; } return [accumulatedValue]; } };
下面我们使用 reduce 方法找到一个 rating 数组当中的最大值。
// Find largest rating function(str){ preVerifierHook(); var fun = eval("(" + str + ")"), boxarts = fun(), got = JSON.stringify(boxarts), expected = JSON.stringify([5]); if (got !== expected) { showLessonErrorMessage(expected, got); } }
function() { var ratings = [2,3,1,4,5]; // 返回一个只包含最大 rating 的数组。注意 reduce 总是返回一个只包含一个元素的数组。 return ratings. reduce(function(acc, curr) { if(acc > curr) { return acc; } else { return curr; } }); }
干得漂亮。下面我们将试着把 reduce() 和其他函数结合起来构建更复杂的查询操作。
我们试一下结合使用 reduce() 和 map(),将多个 boxart 对象缩减成一个值:最大 boxart 的 url。
// Find largest box art with reduce function(str){ preVerifierHook(); var fun = eval("(" + str + ")"), boxarts = fun(), got = JSON.stringify(boxarts), expected = JSON.stringify(["http://cdn-0.nflximg.com/images/2891/Fracture425.jpg"]); if (got !== expected) { showLessonErrorMessage(expected, got); } }
function() { var boxarts = [ { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture200.jpg" }, { width: 150, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture150.jpg" }, { width: 300, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture300.jpg" }, { width: 425, height:150, url:"http://cdn-0.nflximg.com/images/2891/Fracture425.jpg" } ]; // 返回一个只包含最大 boxart 的 URL 的数组。注意 reduce 总是返回一个只包含一个元素的数组。 return boxarts. reduce(function(acc,curr) { if (acc.width * acc.height > curr.width * curr.height) { return acc; } else { return curr; } }). map(function(boxart) { return boxart.url; }); }
有时候,我们想缩减一个数组,同时希望缩减后的值类型,不同于数组当中的元素值类型。 举个例子,我们有一个 video 的数组,我们想通过缩减它来得到一个键是 video id,值是 video title 的 map。
// Reducing with an initial value function(str){ preVerifierHook(); var fun = eval("(" + str + ")"), videoMap = fun()[0], expected = [ { "65432445": "The Chamber", "675465": "Fracture", "70111470": "Die Hard", "654356453": "Bad Boys" } ]; if (!(videoMap["65432445"] === "The Chamber" && videoMap["675465"] === "Fracture" && videoMap["70111470"] === "Die Hard" && videoMap["654356453"] === "Bad Boys")) { throw "Expected " + JSON.stringify(expected); } }
function() { var videos = [ { "id": 65432445, "title": "The Chamber" }, { "id": 675465, "title": "Fracture" }, { "id": 70111470, "title": "Die Hard" }, { "id": 654356453, "title": "Bad Boys" } ]; // Expecting this output... // [ // { // "65432445": "The Chamber", // "675465": "Fracture", // "70111470": "Die Hard", // "654356453": "Bad Boys" // } // ] return videos. reduce(function(accumulatedMap, video) { // Object.create() makes a fast copy of the accumulatedMap by // creating a new object and setting the accumulatedMap to be the // new object's prototype. // Initially the new object is empty and has no members of its own, // except a pointer to the object on which it was based. If an // attempt to find a member on the new object fails, the new object // silently attempts to find the member on its prototype. This // process continues recursively, with each object checking its // prototype until the member is found or we reach the first object // we created. // If we set a member value on the new object, it is stored // directly on that object, leaving the prototype unchanged. // Object.create() is perfect for functional programming because it // makes creating a new object with a different member value almost // as cheap as changing the member on the original object! var copyOfAccumulatedMap = Object.create(accumulatedMap); copyOfAccumulatedMap[video.id] = video.title; return copyOfAccumulatedMap; }, // Use an empty map as the initial value instead of the first item in // the list. {}); }
做的好。现在我们试着把 reduce 和其他函数结合起来构建更复杂的查询操作。
这个练习是我们之前解决过的问题的一个变体。在之前的问题中,我们取出了 width 为 150px 的 boxart 的 url。 这次我们使用 reduce() 替代 filter,取出最小的 box art。
// Find the id, title, and smallest box art. function(str) { preVerifierHook(); var fun = eval("(" + str + ")"), videos = fun(), got, expected = JSON.stringify([ {"id": 675465,"title": "Fracture","boxart":"http://cdn-0.nflximg.com/images/2891/Fracture120.jpg" }, {"id": 65432445,"title": "The Chamber","boxart":"http://cdn-0.nflximg.com/images/2891/TheChamber130.jpg" }, {"id": 654356453,"title": "Bad Boys","boxart":"http://cdn-0.nflximg.com/images/2891/BadBoys140.jpg" }, {"id": 70111470,"title": "Die Hard","boxart":"http://cdn-0.nflximg.com/images/2891/DieHard150.jpg" } ].sortBy(function(v) { return v.id })); if (str.indexOf('[0]') !== -1){ throw "You're not allowed to index into the array. You might be creating the object too early. Instead of using an indexer to get the boxart out of the array, try adding a call to map() and creating the object inside the projection function."; } videos = videos.sortBy(function(v) { return v.id }); got = JSON.stringify(videos); if (got !== expected) { showLessonErrorMessage(expected, got); } }
function() { var movieLists = [ { name: "New Releases", videos: [ { "id": 70111470, "title": "Die Hard", "boxarts": [ { width: 150, height:200, url:"http://cdn-0.nflximg.com/images/2891/DieHard150.jpg" }, { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/DieHard200.jpg" } ], "url": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, "bookmark": [] }, { "id": 654356453, "title": "Bad Boys", "boxarts": [ { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/BadBoys200.jpg" }, { width: 140, height:200, url:"http://cdn-0.nflximg.com/images/2891/BadBoys140.jpg" } ], "url": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, "bookmark": [{ id:432534, time:65876586 }] } ] }, { name: "Thrillers", videos: [ { "id": 65432445, "title": "The Chamber", "boxarts": [ { width: 130, height:200, url:"http://cdn-0.nflximg.com/images/2891/TheChamber130.jpg" }, { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/TheChamber200.jpg" } ], "url": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, "bookmark": [] }, { "id": 675465, "title": "Fracture", "boxarts": [ { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture200.jpg" }, { width: 120, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture120.jpg" }, { width: 300, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture300.jpg" } ], "url": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, "bookmark": [{ id:432534, time:65876586 }] } ] } ]; // 使用一个或多个 concatMap, map,和 reduce 操作得到下面的数组(顺序无关)。 // [ // {"id": 675465,"title": "Fracture","boxart":"http://cdn-0.nflximg.com/images/2891/Fracture120.jpg" }, // {"id": 65432445,"title": "The Chamber","boxart":"http://cdn-0.nflximg.com/images/2891/TheChamber130.jpg" }, // {"id": 654356453,"title": "Bad Boys","boxart":"http://cdn-0.nflximg.com/images/2891/BadBoys140.jpg" }, // {"id": 70111470,"title": "Die Hard","boxart":"http://cdn-0.nflximg.com/images/2891/DieHard150.jpg" } // ]; return movieLists.concatMap(function(movieList) { return movieList.videos.concatMap(function(video) { return video.boxarts. reduce(function(acc,curr) { if (acc.width * acc.height < curr.width * curr.height) { return acc; } else { return curr; } }). map(function(boxart) { return {id: video.id, title: video.title, boxart: boxart.url}; }); }); }); }
有时候我们需要把两个数组合并(zip),分别从两个数组当中拿出一个元素,组成一个元素对的数组。把数组想象成拉链,每个数组是拉链的一边, 元素是拉链的牙,这样你可以更好地理解合并数组是什么样的操作。
使用一个 for 循环,同时遍历 videos 和 bookmarks 数组。对每一个 video 和 bookmark 创建一个 {videoId, bookmarkId} 对, 并把它加入 videoIdAndBookmarkIdPairs 数组中。
// Zip imperatively function(str) { preVerifierHook(); var fun = eval("(" + str + ")"), pairs = fun(), got, expected = '[{"videoId":65432445,"bookmarkId":445},{"videoId":70111470,"bookmarkId":470},{"videoId":654356453,"bookmarkId":453}]'; pairs = pairs.sortBy(function(v) { return v.videoId }); got = JSON.stringify(pairs); if (got !== expected) { showLessonErrorMessage(expected, got); } }
function() { var videos = [ { "id": 70111470, "title": "Die Hard", "boxart": "http://cdn-0.nflximg.com/images/2891/DieHard.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, }, { "id": 654356453, "title": "Bad Boys", "boxart": "http://cdn-0.nflximg.com/images/2891/BadBoys.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, }, { "id": 65432445, "title": "The Chamber", "boxart": "http://cdn-0.nflximg.com/images/2891/TheChamber.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, }, { "id": 675465, "title": "Fracture", "boxart": "http://cdn-0.nflximg.com/images/2891/Fracture.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, } ], bookmarks = [ {id: 470, time: 23432}, {id: 453, time: 234324}, {id: 445, time: 987834} ], counter, videoIdAndBookmarkIdPairs = []; for(var counter = 0; counter < Math.min(videos.length, bookmarks.length); counter++) { videoIdAndBookmarkIdPairs.push({videoId: videos[counter].id, bookmarkId: bookmarks[counter].id}); } return videoIdAndBookmarkIdPairs; }
我们来给 Array 类型添加一个静态的 zip() 方法。zip 方法接受一个 combiner 函数作为参数,同时遍历两个数组,并且在两边数组的对应元素执行 combiner 函数。zip 方法需要元素才能调用 combiner,因此 zip 返回的数组长度是两个数组当中最小的那个的长度。
// Implement zip function(str) { preVerifierHook(); var fun = eval(str), left = [1,2,3], right = [4,5,6], sum = Array.zip(left, right, function(left, right){ return left + right; }), expected = '[5,7,9]'; if (JSON.stringify(sum) !== expected) { showLessonErrorMessage(expected, JSON.stringify(sum)); } }
// JSON.stringify(Array.zip([1,2,3],[4,5,6], function(left, right) { return left + right })) === '[5,7,9]' Array.zip = function(left, right, combinerFunction) { var counter, results = []; for(counter = 0; counter < Math.min(left.length, right.length); counter++) { results.push(combinerFunction(left[counter],right[counter])); } return results; };
我们来重复练习 21 的内容,不过这次使用新的 zip() 方法。对每一个 video 和 bookmark 创建一个 {videoId, bookmarkId} 对。
// Combine videos and bookmarks function(str) { preVerifierHook(); var fun = eval("(" + str + ")"), pairs = fun(), got, expected = '[{"videoId":65432445,"bookmarkId":445},{"videoId":70111470,"bookmarkId":470},{"videoId":654356453,"bookmarkId":453}]'; pairs = pairs.sortBy(function(v) { return v.videoId }); got = JSON.stringify(pairs); if (got !== expected) { showLessonErrorMessage(expected, got); } }
function() { var videos = [ { "id": 70111470, "title": "Die Hard", "boxart": "http://cdn-0.nflximg.com/images/2891/DieHard.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, }, { "id": 654356453, "title": "Bad Boys", "boxart": "http://cdn-0.nflximg.com/images/2891/BadBoys.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, }, { "id": 65432445, "title": "The Chamber", "boxart": "http://cdn-0.nflximg.com/images/2891/TheChamber.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, }, { "id": 675465, "title": "Fracture", "boxart": "http://cdn-0.nflximg.com/images/2891/Fracture.jpg", "uri": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, } ], bookmarks = [ {id: 470, time: 23432}, {id: 453, time: 234324}, {id: 445, time: 987834} ]; return Array. zip( videos, bookmarks, function(video, bookmark) { return {videoId: video.id, bookmarkId: bookmark.id}; }); }
这是我们之前解决过问题的一个变种。这次我们的每个 video 包含了一个关键时刻的集合,关键时刻代表这个时间的画面是视频当中具有代表性的或者最有趣的。 注意 boxart 和 interestingMoments 数组处于树的同一深度。使用 zip() 同时 取出 middle 类型的 interesting moment 的 time 和 最小的 boxart 的 url。对每一个 video,返回一个 {id, title, time, url } 对象。
// Find id, title, smallest box art, and bookmark id function(str) { preVerifierHook(); var fun = eval("(" + str + ")"), videos = fun(), got, expected = '[{"id":675465,"title":"Fracture","time":3453434,"url":"http://cdn-0.nflximg.com/images/2891/Fracture120.jpg"},{"id":65432445,"title":"The Chamber","time":3452343,"url":"http://cdn-0.nflximg.com/images/2891/TheChamber130.jpg"},{"id":70111470,"title":"Die Hard","time":323133,"url":"http://cdn-0.nflximg.com/images/2891/DieHard150.jpg"},{"id":654356453,"title":"Bad Boys","time":6575665,"url":"http://cdn-0.nflximg.com/images/2891/BadBoys140.jpg"}]'; videos = videos.sortBy(function(v) { return v.id }); got = JSON.stringify(videos); if (got !== expected) { showLessonErrorMessage(expected, got); } }
function() { var movieLists = [ { name: "New Releases", videos: [ { "id": 70111470, "title": "Die Hard", "boxarts": [ { width: 150, height:200, url:"http://cdn-0.nflximg.com/images/2891/DieHard150.jpg" }, { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/DieHard200.jpg" } ], "url": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, "interestingMoments": [ { type: "End", time:213432 }, { type: "Start", time: 64534 }, { type: "Middle", time: 323133} ] }, { "id": 654356453, "title": "Bad Boys", "boxarts": [ { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/BadBoys200.jpg" }, { width: 140, height:200, url:"http://cdn-0.nflximg.com/images/2891/BadBoys140.jpg" } ], "url": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, "interestingMoments": [ { type: "End", time:54654754 }, { type: "Start", time: 43524243 }, { type: "Middle", time: 6575665} ] } ] }, { name: "Instant Queue", videos: [ { "id": 65432445, "title": "The Chamber", "boxarts": [ { width: 130, height:200, url:"http://cdn-0.nflximg.com/images/2891/TheChamber130.jpg" }, { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/TheChamber200.jpg" } ], "url": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 4.0, "interestingMoments": [ { type: "End", time:132423 }, { type: "Start", time: 54637425 }, { type: "Middle", time: 3452343} ] }, { "id": 675465, "title": "Fracture", "boxarts": [ { width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture200.jpg" }, { width: 120, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture120.jpg" }, { width: 300, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture300.jpg" } ], "url": "http://api.netflix.com/catalog/titles/movies/70111470", "rating": 5.0, "interestingMoments": [ { type: "End", time:45632456 }, { type: "Start", time: 234534 }, { type: "Middle", time: 3453434} ] } ] } ]; //------------ 完成这个表达式 -------------- return movieLists.concatMap(function(movieList) { return movieList.videos.concatMap(function(video) { return Array.zip( video.boxarts.reduce(function(acc,curr) { if (acc.width * acc.height < curr.width * curr.height) { return acc; } else { return curr; } }), video.interestingMoments.filter(function(interestingMoment) { return interestingMoment.type === "Middle"; }), function(boxart, interestingMoment) { return {id: video.id, title: video.title, time: interestingMoment.time, url: boxart.url}; }); }); }); }
现在我们已经学习了 5 个操作符。让我们通过实战练习一下如何书写复杂查询。
当信息使用像 JSON 一样的树状结构存储时,数据之间的关系是从父亲节点指向孩子节点。在数据库这样的关系型系统里面,数据之前的关系是由孩子节点指向 父亲节点。两种组织信息的方式在表达能力上是一致。根据使用场景不同,我们可能会希望数据采用特定的方式进行组织。 这可能会吓你一跳,不过通过之前学的 5 个函数,你已经能够很容易地将数据在这两种组织方式之间转换了。换句话说 你不仅仅能够从树中查询出数组,你也可以从数组当中查询出树。
现在我们有两个数组,分别包含 list 和 video 信息。每个 video 有一个 listId 字段,记录着它的父亲 list。我们想得到一个 list 对象的数组, 其中的对象包含一个 name 和一个 video 的数组。这个 video 数组会包含 video 的 id 和 title。也就是说,我们想要构造出下面的结构:
[ { "name": "New Releases", "videos": [ { "id": 65432445, "title": "The Chamber" }, { "id": 675465, "title": "Fracture" } ] }, { "name": "Thrillers", "videos": [ { "id": 70111470, "title": "Die Hard" }, { "id": 654356453, "title": "Bad Boys" } ] } ]
注意:在创建对象时,确保对象(list 和 video)是按照上面的顺序添加的。这一点不影响你代码的正确性,但是检查答案时期望的顺序就是上面的顺序。
// Combine videos and bookmarks function(str) { preVerifierHook(); var fun = eval("(" + str + ")"), pairs = fun(), got, expected = '[{"name":"New Releases","videos":[{"id":65432445,"title":"The Chamber"},{"id":675465,"title":"Fracture"}]},{"name":"Thrillers","videos":[{"id":70111470,"title":"Die Hard"},{"id":654356453,"title":"Bad Boys"}]}]'; got = JSON.stringify(pairs); if (got !== expected) { showLessonErrorMessage(expected, got); } }
function() { var lists = [ { "id": 5434364, "name": "New Releases" }, { "id": 65456475, name: "Thrillers" } ], videos = [ { "listId": 5434364, "id": 65432445, "title": "The Chamber" }, { "listId": 5434364, "id": 675465, "title": "Fracture" }, { "listId": 65456475, "id": 70111470, "title": "Die Hard" }, { "listId": 65456475, "id": 654356453, "title": "Bad Boys" } ]; return lists.map(function(list) { return { name: list.name, videos: videos. filter(function(video) { return video.listId === list.id; }). map(function(video) { return {id: video.id, title: video.title}; }) }; }); }
看样子你已经学会了如何使用 map 和 filter 来通过 key 关联 两个不同的数组。让我们来看一个更加复杂的例子...
我们试着创建一个更深层的树形结构。这次我们有 4 个不同的数组,分别包含 list,video ,boxarts 和 bookmarks。 每个对象都包含一个 parent 的 id,标明自己的父亲节点。我们想要构造一个 list 对象的数组,其中每个对象包含 name 和 video 的数组。这个 video 数组包含 video 的 id,title,bookmark 的 time,和最小的 boxart url。也就是我们想要构造 下面的结构:
[ { "name": "New Releases", "videos": [ { "id": 65432445, "title": "The Chamber", "time": 32432, "boxart": "http://cdn-0.nflximg.com/images/2891/TheChamber130.jpg" }, { "id": 675465, "title": "Fracture", "time": 3534543, "boxart": "http://cdn-0.nflximg.com/images/2891/Fracture120.jpg" } ] }, { "name": "Thrillers", "videos": [ { "id": 70111470, "title": "Die Hard", "time": 645243, "boxart": "http://cdn-0.nflximg.com/images/2891/DieHard150.jpg" }, { "id": 654356453, "title": "Bad Boys", "time": 984934, "boxart": "http://cdn-0.nflximg.com/images/2891/BadBoys140.jpg" } ] } ]
注意:在创建对象时,确保对象(list 和 video)是按照上面的顺序添加的。这一点不影响你代码的正确性,但是检查答案时期望的顺序就是上面的顺序。
// Combine videos and bookmarks function(str) { preVerifierHook(); var fun = eval("(" + str + ")"), pairs = fun(), got, expected = '[{"name":"New Releases","videos":[{"id":65432445,"title":"The Chamber","time":32432,"boxart":"http://cdn-0.nflximg.com/images/2891/TheChamber130.jpg"},{"id":675465,"title":"Fracture","time":3534543,"boxart":"http://cdn-0.nflximg.com/images/2891/Fracture120.jpg"}]},{"name":"Thrillers","videos":[{"id":70111470,"title":"Die Hard","time":645243,"boxart":"http://cdn-0.nflximg.com/images/2891/DieHard150.jpg"},{"id":654356453,"title":"Bad Boys","time":984934,"boxart":"http://cdn-0.nflximg.com/images/2891/BadBoys140.jpg"}]}]'; got = JSON.stringify(pairs); if (got !== expected) { showLessonErrorMessage(expected, got); } }
function() { var lists = [ { "id": 5434364, "name": "New Releases" }, { "id": 65456475, name: "Thrillers" } ], videos = [ { "listId": 5434364, "id": 65432445, "title": "The Chamber" }, { "listId": 5434364, "id": 675465, "title": "Fracture" }, { "listId": 65456475, "id": 70111470, "title": "Die Hard" }, { "listId": 65456475, "id": 654356453, "title": "Bad Boys" } ], boxarts = [ { videoId: 65432445, width: 130, height:200, url:"http://cdn-0.nflximg.com/images/2891/TheChamber130.jpg" }, { videoId: 65432445, width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/TheChamber200.jpg" }, { videoId: 675465, width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture200.jpg" }, { videoId: 675465, width: 120, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture120.jpg" }, { videoId: 675465, width: 300, height:200, url:"http://cdn-0.nflximg.com/images/2891/Fracture300.jpg" }, { videoId: 70111470, width: 150, height:200, url:"http://cdn-0.nflximg.com/images/2891/DieHard150.jpg" }, { videoId: 70111470, width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/DieHard200.jpg" }, { videoId: 654356453, width: 200, height:200, url:"http://cdn-0.nflximg.com/images/2891/BadBoys200.jpg" }, { videoId: 654356453, width: 140, height:200, url:"http://cdn-0.nflximg.com/images/2891/BadBoys140.jpg" } ], bookmarks = [ { videoId: 65432445, time: 32432 }, { videoId: 675465, time: 3534543 }, { videoId: 70111470, time: 645243 }, { videoId: 654356453, time: 984934 } ]; return lists.map(function(list) { return { name: list.name, videos: videos. filter(function(video) { return video.listId === list.id; }). concatMap(function(video) { return Array.zip( bookmarks.filter(function(bookmark) { return bookmark.videoId === video.id; }), boxarts.filter(function(boxart) { return boxart.videoId === video.id; }). reduce(function(acc,curr) { return acc.width * acc.height < curr.width * curr.height ? acc : curr; }), function(bookmark, boxart) { return { id: video.id, title: video.title, time: bookmark.time, boxart: boxart.url }; }); }) }; }); }
喔!这是个很长的查询,但是代码相对于它做的事情来说,并不算很长。如果我们使用传统的循环操作,代码的可读性 会大大下降。循环不能告诉读者所进行的操作是什么。当你看到一个循环的时候,你需要仔细阅读循环体中的代码,才能知道它是在 做什么。它是在投影?还是在过滤?还是在缩减?通过例子的展示,相信你已经发现,当我们能够通过前面学的 5 个函数拿到几乎所有 想要的结果时,为什么还要使用循环来查询数据呢?
我们来看一个简单一些的问题。现在我们有一些 NASDAQ 股票不同时间的价格数据。每当股票价格变化时,NASDAQ 股票报价机就 会在集合中加入一条数据。例如,我们在 10 天前买入了微软的股票,现在你想知道从那时候起,所有 MSFT 股票的价格情况。 过滤集合,找到从 10 天前算起,所有 MSFT 的数据,然后使用 print() 函数打印出每个价格记录(包括时间戳)。 注意:这不是一个有陷阱的题目,它就是像看起来那么简单。
// The pricesNASDAQ collection looks something like this... var pricesNASDAQ = [ // ... from the NASDAQ's opening day {name: "ANGI", price: 31.22, timeStamp: new Date(2011,11,15) }, {name: "MSFT", price: 32.32, timeStamp: new Date(2011,11,15) }, {name: "GOOG", price: 150.43, timeStamp: new Date(2011,11,15)}, {name: "ANGI", price: 28.44, timeStamp: new Date(2011,11,16)}, {name: "GOOG", price: 199.33, timeStamp: new Date(2011,11,16)}, // ...and up to the present. ];
命令行输出
// Combine videos and bookmarks function(str, lesson) { preVerifierHook(); var output = $(".output", lesson)[0], fun = eval("(" + str + ")"), stockSymbols = ["MSFT", "GOOG","NFLX","OSTK"], input = [{name: "MSFT", price: 32.32, timeStamp: new Date() }, {name: "GOOG", price: 150.43, timeStamp: new Date(2011,11,15)}], expected = [input[0]], items = [], counter = 0; confirmPrint = function(item) { items.push(item); }, print = function(item) { output.innerHTML += "MSFT " + item.price + " at " + item.timeStamp.toString() + "<" + "br" + ">"; output.scrollTop = output.scrollHeight; counter++; if (counter % 100 === 0) { output.innerHTML = ""; } }, stocks = Rx.Observable. interval(250). map(function() { var symbol = stockSymbols[Math.floor(Math.random() * stockSymbols.length)]; return {name: symbol, price: 30 + ((Math.floor(Math.random() * 100))/200), timeStamp: new Date()}; }); fun(input, confirmPrint); if (JSON.stringify(items) !== JSON.stringify(expected)) { throw "Got " + JSON.stringify(items, null, 4) + ", expected " + JSON.stringify(expected, null, 4); } fun(stocks, print); }
function(pricesNASDAQ, printRecord) { var microsoftPrices, now = new Date(), tenDaysAgo = new Date( now.getFullYear(), now.getMonth(), now.getDate() - 10); // use filter() to filter the trades for MSFT prices recorded any time after 10 days ago microsoftPrices = pricesNASDAQ. filter(function(priceRecord) { return priceRecord.name === 'MSFT' && priceRecord.timeStamp > tenDaysAgo; }); // Print the trades to the output console microsoftPrices. forEach(function(priceRecord) { printRecord(priceRecord); }); }
注意命令行输出在随着时间变化。 现在看一下股票价格的时间戳,我们展示的股票价格,是在我们运行程序 之后 才拿到的!我们的数组怎么能够包括来自未来的股票数据呢? 难道我们一不小心破坏了 空间时序统一性?
这个谜题的答案在于,pricesNASDAQ 不是 一个数组。数组只能够存储股票价格的一个快照,而这种新类型 可以对于变化进行响应,随着时间更新自己。
在下一节内容当中,我会向你展示这个神奇类型的内部原理。你将学习到如何将它用于建模,从鼠标事件到异步 JSON 请求,都可以用到它。 最终我将展示如何使用你已经知道的 5 个查询函数来查询这个类型的数据。是时候给这个类型起一个名字了...
微软的开源库 Reactive Extensions 在 Javascript 当中 引入了一个新的集合类型:Observable。Observable 和事件(Event)很类似。和 Event 一样, Observable 是一个由数据产生者推送给数据消费者的值的序列。 和 Event 不同的是, Observable 可以通知监听者,它已经完成,并且不再发送任何数据。
Observables 可以异步地像消费者发送数据。和数组不同,Javascript 当中没有创建 Observable 的字面值语法。不过我们可以通过构造一个助手方法来描述序列的值和每个值到达的时间。 seq 函数可以通过数组来构造 Observable,当碰到一个空值时,添加一个时间上的延迟。 每个 ,,, 会增加 1 秒的延迟。
// 一个数字数组 1,2,3 var numbers123Array = [1,2,3]; // 一个序列,先返回 1, 4 秒钟之后返回 2, // 再等待 1 秒返回 3,然后结束。 var numbers123Observable = seq([1,,,,,,,,,,,,2,,,3]); // 和数组一样,Observables 可以包含任意对象,甚至数组本身。 var observableOfArrays = seq([ [1,2,3],,,,,,[4,5,6],,,,,,,,,,,[1,2] ]);
Observables 是一系列值组成的序列,一个接一个的发送出去。因此 一个 Observable 有可能会一直像监听者发送数据,例如鼠标的移动事件。 要创建一个不会完成的序列,你可以在 seq() 的参数的末尾添加 ,,, 。
// 结尾的 ,,, 保证了这个序列 不会 终止。 var mouseMovesObservable = seq([ {x: 23, y: 55},,,,,,,{x: 44, y: 99},,,{x:55,y:99},,,{x: 54, y:543},,, ]); // 没有结尾的 ,,, 意味着这个序列 将会 结束。 var numbers123Observable = seq([1,,,2,,,3]);
对数组的查询只能给我们提供一个快照。与其对比,对 Observables 的查询允许我们创建一个能够随着时间响应变化而且更新自己的数据集。 这个特性带来了一个非常强大的编程模型,被称为 响应式(Reactive)编程。
让我们先从 Observable 和 Events 的对比说起...
过去你可以把事件看成是存储在一个对象里的一系列 handler 的 list。在这个例子里,我们订阅一个按钮点击的事件,然后在按钮被点击一次之后,就取消订阅。
// Combine videos and bookmarks 2 function(str, lesson) { preVerifierHook(); var fun = eval("(" + str + ")"), button = $('.button', lesson)[0]; fun(button); }
问你自己这样一个问题:订阅一个事件和遍历一个数组有什么不同? 这两个操作都是通过重复调用一个函数,给监听者发送一系列的数据。为什么我们不能使用相同的方式 遍历数组和事件呢?
订阅一个事件(Event)和遍历一个数组,本质上是相同的操作。唯一的区别在于, 数组遍历是同步的,而且一定会结束,而事件的遍历是异步的,同时永远不会结束。 如果我们把按钮点击事件转换为一个 Observable 对象,我们就可以使用 forEach() 来遍历这个事件。
// Combine videos and bookmarks 2 function(str, lesson) { preVerifierHook(); var fun = eval("(" + str + ")"), button = $('.button', lesson)[0]; fun(button); }
注意 Observable 的 forEach() 方法返回了一个 Subscription 对象。 销毁(Dispose)这个 Subscription 对象会取消对于事件的订阅,防止内存泄露。 销毁 subscription 相当于是异步版本的在循环过程中 break 出去。
销毁 Subscription 对象基本上和调用 removeEventListener() 是一样的。表面上看来,这两种事件处理的方式并没有那么不同。 这样看来,为什么我们还要费力去把事件转换成 Observables 呢?原因是,如果我们把事件转换成 Observable,我们就能够 使用强大的函数操作来对它进行变换。 在下一个练习中,我们将学习如何使用这样的函数,在很多情况下避免对于 Subscriptions 的处理...
你有没有希望你可以监听一个事件的下一次出现,然后立马取消订阅? 例如,开发者们经常想订阅 window.onload 事件,同时期望他们的事件处理器只被调用一次。
window.addEventListener( "load", function() // do some work here, but expect this function will only be called once. })
类似这样的例子当中,当事件发生之后立马取消订阅,是一个很好的习惯。没有正确取消订阅,会导致内存泄露。 根据实际情况不同,内存泄露有可能严重地影响你的应用的可靠性,而且有可能难以追踪到问题根源。 不幸的是,取消订阅事件的操作,有时候并不简单:
var handler = function() { // do some work here, then unsubscribe from the event window.removeEventListener("load", handler) }; window.addEventListener("load", handler);
如果能有更简单的方式做到这一点,岂不是会更好?这就是为什么 Observable 有一个 take() 方法。 take() 方法的用法是这样的...
seq([1,,,2,,,3,,,4,,,5,,,6,,,]).take(3) === seq([1,,,2,,,3]);
基于事件的 Observable 永远不会自己结束。 take() 方法会创建一个新的序列,这个序列,在固定数量的元素到达之后,就会结束。 这是一个很重要的点,因为和事件不同,当一个 Observable 序列结束时,它会自动取消所有的监听者的订阅。 这就意味着, 如果我们使用 take() 来结束事件序列,我们不再需要手动取消订阅了!
让我们重复前面的练习,在上个练习里,我们监听了一个按钮点击的事件,然后取消订阅。 这次我们使用 take() 在按钮被点击的时候,结束这个序列。
// Combine videos and bookmarks 2 function(str, lesson) { preVerifierHook(); var fun = eval("(" + str + ")"), button = $('.button', lesson)[0]; fun(button); }
function(button) { var buttonClicks = Observable.fromEvent(button, "click"); // Use take() to listen for only one button click // and unsubscribe. buttonClicks. take(1). forEach(function(priceRecord) { alert("Button was clicked once. Stopping Traversal."); }); }
take() 对于监听固定次数的事件然后取消订阅来说,是一个非常好用的方法, 然而 Observable 还有 更加 灵活的方法可以用来结束序列...
你有没有这种时候,需要在一个 Event 触发的时候,结束订阅当前的 Event ? Observable 的 takeUntil() 方法可以用于 可以在另一个 Event 触发的时候,结束掉一个序列。 takeUntil() 用法是这样的:
var numbersUntilStopButtonPressed = seq( [ 1,,,2,,,3,,,4,,,5,,,6,,,7,,,8,,,9,,, ]). takeUntil(seq([ ,,, {eventType: "click"},,, ]) ) === seq([ 1,,,2 ])
前面的练习中我们(在不了解内情的情况下)使用 Observable 构造了一个微软股票的报价机。这个报价机的一个问题是, 它会一直报价,不会停止。。如果不处理这种情况,在日志中的所有输出有可能用光页面的全部内存。 在下面的练习中,过滤出 Observable 中 NASDAQ 所有 MSFT 股票价格,使用 fromEvent() 创建基于事件的 Observable。
<-按下这个按钮,结束微软股票价格的序列。命令行输出
// Combine videos and bookmarks 2 function(str, lesson) { preVerifierHook(); var fun = eval("(" + str + ")"), output = $(".output", lesson)[0], stopButton = $('.stop', lesson)[0], stockSymbols = ["MSFT", "GOOG","NFLX","OSTK"], input = [{name: "MSFT", price: 32.32, timeStamp: new Date() }, {name: "GOOG", price: 150.43, timeStamp: new Date(2011,11,15)}], expected = [input[0]], items = [], counter = 0, print = function(item) { output.innerHTML += "MSFT " + item.price + " at " + item.timeStamp.toString() + "<" + "br" + ">"; output.scrollTop = output.scrollHeight; counter++; if (counter % 100 === 0) { output.innerHTML = ""; } }, stocks = Rx.Observable. interval(250). map(function() { var symbol = stockSymbols[Math.floor(Math.random() * stockSymbols.length)]; return {name: symbol, price: 30 + ((Math.floor(Math.random() * 100))/200), timeStamp: new Date()}; }); fun(stocks, print, stopButton); }
function(pricesNASDAQ, printRecord, stopButton) { var stopButtonClicks = Observable.fromEvent(stopButton,"click"), microsoftPrices = pricesNASDAQ. filter(function(priceRecord) { return priceRecord.name === "MSFT"; }). takeUntil(stopButtonClicks); microsoftPrices. forEach(function(priceRecord) { printRecord(priceRecord); }); }
我们现在了解到了,Observable 序列比原始的 Event 更加强大,因为它们可以自己结束。 take() 和 takeUntil() 两个方法如此强大,我们再也不需要手动去掉订阅事件了! 这减少了内存泄露的风险,也提高了代码的可读性。
我们在这节内容中学到了下面的内容:
在下面的内容中我们将学习如果将若干 Event 结合起来,构造更加复杂的 Event。你会发现解决复杂的异步问题,竟然变得如此简单!
下面这两个任务,有什么区别?
你可能觉得它们没什么共同点,写出来的代码也有很大差别。但是实际上 这些任务本质上是相同的。 它们都是 查询, 它们都可以使用前面的练习中学过的函数解决。
遍历数组和遍历 Observable 的区别在于数据移动的方向。 遍历数组时,遍历者从数据源 拉取 数据,在拿到结果之前,会一直阻塞。 遍历 Observables 时,数据源在数据到达时,将数据 推送 给遍历者。
数据移动的方向,和查询数据的方式,两者的关系是正交的。换句话说 对于查询来说,拉取数据,或者数据被推送给我们,两者是没有实际差别的。 不管使用哪种方式,查询函数所进行的变换操作是一致的。 唯一的区别是输入和输出的类型。如果我们过滤一个数组,我们得到的就是数组。如果我们过滤一个 Observable,就会得到一个 Observable,以此类推。
我们看一下查询函数是如何对 Observables 和数组进行变换的:
// map() [1,2,3].map(function(x) { return x + 1 }) === [2,3,4] seq([1,,,2,,,3]).map(function(x) { return x + 1}) === seq([2,,,3,,,4]) seq([1,,,2,,,3,,,]).map(function(x) { return x + 1 }) === seq([2,,,3,,,4,,,]) // filter() [1,2,3].filter(function(x) { return x > 1; }) === [2,3] seq([1,,,2,,,3]).filter(function(x) { return x > 1; }) === seq([2,,,3]) seq([1,,,2,,,3,,,]).filter(function(x) { return x > 1; }) === seq([2,,,3,,,]) // concatAll() [ [1, 2, 3], [4, 5, 6] ].concatAll() === [1,2,3,4,5,6] seq([ seq([1,,,2,,,3]),,,seq([4,,,5,,,6]) ]).concatAll() === seq([1,,,2,,,3,,,4,,,5,,,6]) // 如果新序列中的元素在前面的序列所有元素到达之前,提前到达, // 直到前面序列结束之后,才会试图去取新序列中的元素。 // 这样序列当中元素的顺序得以保留。 seq([ seq([1,,,, ,2, ,3]) ,,,seq([,,4, ,5, ,,6]) ]). concatAll() === seq([1,,,,,2,,3,,4,,5,,,6]) // 注意只要有一个序列是不会结束的,那么组合起来的序列也是不会结束的 seq([ seq([1,, ,,, ,,,2,,,3]) ,,,seq([4,,,5,,, ,,, ,,6,,,]) ]). concatAll() === seq([1,,,,,,,,2,,,3,4,,,5,,,,,,,,6,,,]) // reduce() [ 1, 2, 3 ].reduce(sumFunction) === [ 6 ] seq([ 1,,,2,,,3 ]).reduce(sumFunction) === seq([,,,,,,6]) // 如果被缩减的序列不结束,那么缩减的序列 不会 结束。 seq([ 1,,,2,,,3,,, ]).reduce(sumFunction) === seq([ ,,,,,,,,,]) // zip() // 对于数组和 Observables,合并的序列会在左边或者右边序列 // 当中有一个结束时,自己结束。 Array.zip([1,2],[3,4,5], sumFunction) === [4,6] Observable.zip(seq([1,,,2]),seq([3,,,4,,,5]), sumFunction) === seq([4,,,6]) // take() [1,2,3].take(2) === [1, 2] seq([ 1,,,2,,,3 ]).take(2) === seq([ 1,,,2 ]) seq([ 1,,,2,,,3,,, ]).take(2) === seq([ 1,,,2 ]) // takeUntil() // takeUntil 可以用于数组,不过实际用处并不大。 // 因为它的结果永远是一个空数组 [1,2,3].takeUntil([1]) === [] seq([1,,,2,,,3,,, ]).takeUntil( seq([ ,,, ,,4 , })) === seq([ 1,,,2 ])
还记得我们之前禁止使用数组下标吗?这样做的原因,现在你应该清楚了。 这 5 个方法可以用于 任意 集合类型,但是数组下标只能用于支持随机访问(例如数组)的集合。 如果你一直不使用数组下标,你会掌握一个统一的编程模型,这个模型适用于 任意 集合类型。 统一的编程模型让同步代码到异步代码的转换过程变得简单得多。反之如果没有统一模型的帮助,这样的转换会非常困难。 我们也已经展示过了,哪怕是进行很复杂的集合变换操作,你也完全不需要使用下标。
现在我们已经可以使用同一个编程模型查询异步和同步的数据源了。 下面我们试着用 Observable 和查询函数,创建新的事件。
记得前面的练习中我们解决过的一个问题吗?我们从一个 movie list 的数组中,取出了所有 rating 为 5.0 的数据。 如果使用伪代码来表达,看起来类似下面这样...
“对每个 movie list,取出那些 rating 为 5.0 的 video”
var moviesWithHighRatings = movieLists. concatMap(function(movieList) { return movieList.videos. filter(function(video) { return video.rating === 5.0; }); });
现在,我们要从 DOM 对象中创建一个 mouseDrag 鼠标拖动事件。如果我们也想用伪代码表达 这个 问题, 看起来可能类似下面这样...
“对每个
movie list
mouse down 事件, 取出那些
rating 为 5.0 的 video
在下一个 mouse up 事件触发之前的 mouse move 事件”
// Combine videos and bookmarks 2 function(str, lesson) { preVerifierHook(); var fun = eval("(" + str + ")"), output = $(".output", lesson)[0], container = $(".container", lesson)[0], sprite = $(".sprite",lesson)[0], moveSprite = function(point) { sprite.style.left = point.pageX + "px"; sprite.style.top = point.pageY + "px"; } fun(sprite, container, moveSprite); }
function(sprite, spriteContainer) { var spriteMouseDowns = Observable.fromEvent(sprite, "mousedown"), spriteContainerMouseMoves = Observable.fromEvent(spriteContainer, "mousemove"), spriteContainerMouseUps = Observable.fromEvent(spriteContainer, "mouseup"), spriteMouseDrags = // For every mouse down event on the sprite... spriteMouseDowns. concatMap(function(contactPoint) { // ...retrieve all the mouse move events on the sprite container... return spriteContainerMouseMoves. // ...until a mouse up event occurs. takeUntil(spriteContainerMouseUps); }); // For each mouse drag event, move the sprite to the absolute page position. spriteMouseDrags.forEach(function(dragPoint) { sprite.style.left = dragPoint.pageX + "px"; sprite.style.top = dragPoint.pageY + "px"; }); }
现在我们才算出师了。我们刚刚只用几行代码就创建一个复杂的事件。 我们甚至都没有处理任何 subscriptions 对象,也没有写任何状态处理的代码。 下面我们再来试试更难的挑战。
我们的鼠标拖动事件,有一些 过于 简单了。注意看,当我们拖动 sprite 方框时,它总是把鼠标置于自己的左上角。 理想情况中,我们希望拖动事件根据鼠标的 mouse down 事件的坐标,对偏移进行处理。这样我们的鼠标拖动事件就更加接近于 使用手指拖动一个真实世界中的物品了。
下面我们看一下如何在鼠标拖动事件中,根据 mouse down 的位置,对坐标进行调整。 鼠标事件是这样的序列:
spriteContainerMouseMoves = seq([ {x: 200, y: 400, layerX: 10, layerY: 15},,,{x: 210, y: 410, layerX: 20, layerY: 26},,, ])
鼠标事件序列中的每个元素都包含一个 x,y,表示鼠标事件在页面上的绝对位置。 moveSprite() 方法使用这些坐标来确定位置。同时每个元素又包含一对 layerX 和 layerY 属性,标示鼠标事件相对于 event target 的 位置。
function(str, lesson) { preVerifierHook(); var fun = eval("(" + str + ")"), output = $(".output", lesson)[0], container = $(".container", lesson)[0], sprite = $(".sprite",lesson)[0], moveSprite = function(point) { sprite.style.left = point.pageX + "px"; sprite.style.top = point.pageY + "px"; } fun(sprite, container, moveSprite); }
function(sprite, spriteContainer) { // All of the mouse event sequences look like this: // seq([ {pageX: 22, pageY: 3423, layerX: 14, layerY: 22} ,,, ]) var spriteMouseDowns = Observable.fromEvent(sprite, "mousedown"), spriteContainerMouseMoves = Observable.fromEvent(spriteContainer, "mousemove"), spriteContainerMouseUps = Observable.fromEvent(spriteContainer, "mouseup"), // Create a sequence that looks like this: // seq([ {pageX: 22, pageY:4080 },,,{pageX: 24, pageY: 4082},,, ]) spriteMouseDrags = // For every mouse down event on the sprite... spriteMouseDowns. concatMap(function(contactPoint) { // ...retrieve all the mouse move events on the sprite container... return spriteContainerMouseMoves. // ...until a mouse up event occurs. takeUntil(spriteContainerMouseUps). map(function(movePoint) { return { pageX: movePoint.pageX - contactPoint.layerX, pageY: movePoint.pageY - contactPoint.layerY }; }); }); // For each mouse drag event, move the sprite to the absolute page position. spriteMouseDrags.forEach(function(dragPoint) { sprite.style.left = dragPoint.pageX + "px"; sprite.style.top = dragPoint.pageY + "px"; }); }
事件并不是应用中唯一的异步数据源。还有 HTTP 请求 大部分 HTTP 请求是使用 基于回调的 API 暴露给我们使用的。 为了从回调中异步地接收数据,调用者一般会给函数传递成功和失败的 handler。当异步操作完成的时候, 适当的 handler 就会被调用。在这个练习中,我们使用 jQuery 的 getJSON 来异步地获取数据。
// Combine videos and bookmarks 2 function(str, lesson) { preVerifierHook(); var fun = eval("(" + str + ")"); fun(jQueryMock); }
现在我们如下给一个 web 应用写一个启动流程。在启动的时候,这个应用需要执行下面的操作:
// Combine videos and bookmarks 2 function(str, lesson) { preVerifierHook(); var fun = eval("(" + str + ")"), NOOP = function() {}; fun( { addEventListener: function(event, handler) { window.setTimeout(handler, 200) }, removeEventListener: NOOP }, jQueryMock, function(output) { alert(output) }, function(output) { alert(output) }); }
可以说,串联基于回调的 HTTP 请求 非常 困难。 为了并行执行两个任务,我们需要引入一个变量来追踪它们的状态。当其中一个任务完成的时候,需要检查另外一个兄弟任务有没有 完成。当两个任务全部完成之后,才能继续执行下一步操作。在上面的例子中,每次任务完成之后,都要调用 tryToDisplayOutput() 检查当前程序是不是已经准备好显示输出了。这个函数检查所有任务的状态,并且在可能的情况下,展示所需要的输出结果。
使用基于回调的 API,异步错误处理也非常困难。在同步程序中,当有异常抛出事,一组工作就被取消了。与之相对比。在我们的 程序中,我们只能手动追踪是不是在并行过程中有错误发生,避免无用的对于 instant queue 的获取操作。Javascript 对于同步错误处理 提供了使用 try/catch/throw 的错误处理支持。不幸的是,在异步程序中没有这种东西。
Observable 接口在处理异步 API 上,要比回调强大的多。下面我们将看到,就像 Observables 让我们可以不必操心对于 Event 的订阅 Observables 也可以让我们不在需要对并行任务状态进行追踪。 Observable 也能让我们像对同步程序进行错误处理那样,在异步程序中 使用相同的错误传导语义。最后我们将学习到,通过把基于回调的 API 转换成 Observables,我们可以联合 Event 一起,对它们进行查询, 构造更富表达力的应用程序。
如果回调 API 是一个序列,它会是什么样子的序列? 我们已经看到过,UI 事件的序列可以保护从 0 到无限多个元素,但是它永远不会自己结束。
mouseMoves === seq([ {x: 23, y: 55},,,,,,,{x: 44, y: 99},,,{x:55,y:99},,,{x: 54, y:543},,, ]);
与之对比,如果我们把 $.getJSON() 的返回转换成序列,它总会返回一个发送完单个元素就自己结束的序列。
getJSONAsObservable("http://api-global.netflix.com/abTestInformation") === seq([ { urlPrefix: "billboardTest" } ])
创建一个仅包含一个元素的序列,听起来可能会有点奇怪。我们 可以 专门为标量值引入一个类似 Observable 的类型, 但是那样就会导致基于回调的 API 相比 Event 更加难以查询。幸运的是,Observable 足够灵活,它能够支持这两种模型。
所以我们如何把一个基于回调的 API 转换成 Observable 序列呢? 比较不幸运的是, 因为基于回调的 API 在接口上各有不同, 我们没办法像 fromEvent() 那样创建一个统一的转换函数。不过没关系,我们有更灵活的方法来创建 Observable 序列...
Observable.create() 是一个可以把任意异步 API 转换成 Observable 的工具。 Observable.create() 依赖于这样的事实:所有异步 API 都有下面的语义:
在下面的例子里,我们使用 Observable.create() 创建一个,它会在自己被遍历的时候,发送一个 getJSON 请求。
// Combine videos and bookmarks 2 function(str, lesson) { preVerifierHook(); var fun = eval("(" + str + ")"), NOOP = function() {}; fun( { addEventListener: function(event, handler) { window.setTimeout(handler, 200) }, removeEventListener: NOOP }, jQueryMock, function(output) { alert(output) }, function(output) { alert(output) }); }
传递给 Observable.create() 的参数叫做 subscribe 函数。对于创建出的 Observable 可能产生的数据感兴趣的对象(例如一个 Observer) 可以通过订阅这个 Observable 来实现对于数据的观察。它们必须实现 Observer 的接口,这样才能保证从 Observable 中获取的数据可以被 正确地发送过去。之后,Observers 被当成参数,传递给 Observable 的 subscribe 函数。
需要注意的是,subscribe 函数代表一个惰性赋值操作,它仅当 Observer 进行订阅操作时才会执行。 当一个 Observer 对 Observable 的数据不再感兴趣时,它应当取消自己的订阅。在 Observable 上调用订阅所返回的值, 是一个 Subscription 对象,它代表着一个可销毁的资源。调用 Subscription 的 unsubscribe 会终止当前 Observer 对应 的 Observable 的执行。
可以看到 Observer 定义了下面三个方法:
Observers 可以不实现全部的方法(也就是说,可以只实现其中的一部分)。 对于没有提供的回调,Observable 依然能够正确执行,只不过某些类型的通知会被忽略掉。
在 RxJS 4 和 5 两个版本中,有一些 API 变化,在这里不再赘述。 查看 迁移指南 可以获得更多信息。
现在我们已经创造了一个返回 Observable 序列的 getJSON 方法,下面我们用它来改善上一个练习的代码...
我们使用返回 Observables 的 getJSON 方法,以及 Observable.fromEvent() 完成之前做过的练习。
// Combine videos and bookmarks 2 function(str, lesson) { preVerifierHook(); var fun = eval("(" + str + ")"), getJSON = function(url) { return Observable.create(function(observer) { var subscribed = true; jQueryMock.getJSON(url, { success: function(data) { // If client is still interested in the results, send them. if (subscribed) { // Send data to the client observer.onNext(data); // Immediately complete the sequence observer.onCompleted(); } }, error: function(ex) { // If client is still interested in the results, send them. if (subscribed) { // Inform the client that an error occurred. observer.onError(ex); } } }); // Definition of the Subscription objects dispose() method. return function() { subscribed = false; } }) }, NOOP = function() {}; fun( { addEventListener: function(event, handler) { window.setTimeout(handler, 200) }, removeEventListener: NOOP }, getJSON, function(output) { alert(JSON.stringify(output)) }, function(output) { alert(output) }); }
几乎所有 web 应用的工作流程都是从事件开始,然后发送 HTTP 请求,最后导致状态变化。 现在我们知道如何优雅地完成前面两个任务了。
当处理用户输入的时候,会碰到一些情况,用户的输入太密集,有可能会导致无意义的请求阻塞掉服务器。 我们希望有可能对用户的输入进行 throttle,这样我们就能够实现,如果他们在 1 秒钟没有操作,然后我们再拿用户的输入。 例如,当用户点击一个保存按钮很多次的时候,我们只在他们不操作 1 秒钟之后,才执行一次保存操作。
seq([1,2,3,,,,,,,4,5,6,,,]).throttleTime(1000 /* ms */) === seq([,,,,,,,3,,,,,,,,,,6,,,]);<<-- 点这里来保存你的数据
function(str, lesson) { preVerifierHook(); var $inputName = $('.inputName', lesson), $savedValue = $('.savedValue', lesson); var counter = 0; var data = null; var clicks = Observable.fromEvent($('.submitInputName', lesson)[0], 'click'); var code = eval("(" + str + ")") var code = code(clicks, function(name) { return Rx.Observable.of(name.val()); }, $inputName); code .subscribe(function (data) { $savedValue.text('Name Saved: ' + data); }); }
function (clicks, saveData, name) { return clicks. throttleTime(1000). concatMap(function () { return saveData(name); }) }
现在我们知道怎么对输入进行 throttle 了,我们来看一个问题,这个问题当中对数据的 throttle 是很重要的...
在 web 开发中最常见的问题之一,就是自动补全输入框。这个问题看起来应该很简单,然而实际上却相当有挑战性。 例如如何 throttle 用户的输入?怎么保证请求返回的顺序不是错乱的?例如如果我打出了 "react" 然后打 "reactive",我希望 我得到的结果是 "reactive",不管服务端返回的结果是什么。
In the example below, you will be receiving a sequence of key presses, a textbox, and a function when called returns an array of search results.
getSearchResultSet('react') === seq[,,,["reactive", "reaction","reactor"]] keyPresses === seq['r',,,,,'e',,,,,,'a',,,,'c',,,,'t',,,,,]
function(str, lesson) { preVerifierHook(); var wordlist = window.wordlist; wordlist.sort(); var searchText = function (text) { var matched = wordlist.filter(function (x) { return x.indexOf(text) === 0; }); return Rx.Observable.of( matched.slice(0, 10) ); }; var $inputName = $('.inputName', lesson)[0], $searchResults = $('.searchResultsForAutoComplete', lesson); var clicks = Rx.Observable.fromEvent($inputName, 'keyup'); var code = eval("(" + str + ")") var code = code(searchText, clicks, $inputName); code .subscribe(function (results) { var s = results.map(function (r) { return '<li>' + r + '</li>'; }); $searchResults.html(s.join('')); }); }
function (getSearchResultSet, keyPresses, textBox) { var getSearchResultSets = keyPresses. map(function () { return textBox.value; }). throttleTime(1000). concatMap(function (text) { return getSearchResultSet(text).takeUntil(keyPresses); }); return getSearchResultSets; }
现在我们可以使用 throttle 之后的输入查询了,你可能还注意到了一个小问题。如果你按下键盘上的方向键或者其他非字母的键, 请求依然会被触发,如何避免这个问题呢?
你可能注意到,在前面的练习中,如果你在输入框中按下方向键,不管文本本身有没有变化,都会触发一次查询。 怎么样避免这个问题呢?Rx 提供了 distinctUntilChanged 用于过滤掉连续而且重复的输入值。
seq([1,,,1,,,3,,,3,,,5,,,1,,,]).distinctUntilChanged() === seq([1,,,,,,,3,,,,,,,5,,,1,,,]);
function(str, lesson) { preVerifierHook(); var $inputName = $('.inputName', lesson), $filtered = $('.filteredKeysByDistinct', lesson); var keyups = Rx.Observable.fromEvent($inputName[0], 'keypress'); var isAlpha = function (x) { return 'abcdefghijklmnopqrstuvwxyz'.indexOf(x.toLowerCase()) !== -1; }; var code = eval("(" + str + ")") var code = code(keyups, isAlpha); code .subscribe(function (text) { $filtered.text(text); }); }
function (keyPresses, isAlpha) { return keyPresses. map(function (e) { return String.fromCharCode(e.charCode); }). filter(function (character) { return isAlpha(character); }). distinctUntilChanged(). scan(function (stringSoFar, character) { return stringSoFar + character; }); }
现在我们知道了如何只在输入发生变化时才得到输入,下面我们看下如何将它应用在自动完成输入框的例子里...
在前一个自动补全框中,有下面两个 bug
下面的例子和之前是一样的,不过这次把 bug 修复好!
getSearchResultSet('react') === seq[,,,["reactive", "reaction","reactor"]] keyPresses === seq['r',,,,,'e',,,,,,'a',,,,'c',,,,'t',,,,,]
function(str, lesson) { preVerifierHook(); var wordlist = window.wordlist; wordlist.sort(); var searchText = function (text) { var matched = wordlist.filter(function (x) { return x.indexOf(text) === 0; }); return Rx.Observable.of( matched.slice(0, 10) ); }; var $inputName = $('.inputName', lesson), $searchResults = $('.searchResultsForAutoComplete', lesson); var clicks = Rx.Observable.fromEvent($inputName[0], 'keyup'); var code = eval("(" + str + ")") var code = code(searchText, clicks, $inputName[0]); code .subscribe(function (results) { var s = results.map(function (r) { return '<li>' + r + '</li>'; }); $searchResults.html(s.join('')); }); }
function (getSearchResultSet, keyPresses, textBox) { var getSearchResultSets = keyPresses. map(function () { return textBox.value; }). throttleTime(1000). distinctUntilChanged(). filter(function (s) { return s.length > 0; }). concatMap(function (text) { return getSearchResultSet(text).takeUntil(keyPresses); }); return getSearchResultSets; }
只需要这么几行代码,我们就构建出了一个完全可用的自动补全功能。但是还有其他明显的问题我们没有处理,例如错误处理。 我们如何处理错误并且在必要的情况下进行重试呢?
Congratulations! You've made it this far, but you're not done. Learning is an on-going process. Go out and start using the functions you've learned in your day-to-day coding. Over time, I'll be adding more exercises to this tutorial. If you have suggestions for more exercises, send me a pull request!