Event Loop、Scope 以及Callback(转)
看到一篇很好的文章,于是转过来收藏用!原文是繁体中文,做了简单转码,很多名词还是保留台湾翻译。
原文标题:JavaScript 與 NodeJS
原文地址:http://book.nodejs.tw/zh-tw/node_javascript.html
=============转载分割线============
其实使用JavaScript 在网页端与伺服器端的差距并不大, 但是为了使NodeJS 可以发挥他最强大的能力, 有一些知识还是必要的, 所以还是针对这些主题介绍一下。其中Event Loop、Scope 以及Callback 其实是比较需要了解的基本知识, cps、currying、flow control是更进阶的技巧与应用。
Event Loop
可能很多人在写Javascript时,并不知道他是怎么被执行的。这个时候可以参考一下jQuery作者John Resig一篇好文章,介绍事件及timer怎么在浏览器中执行:How JavaScript Timers Work。通常在网页中,所有的Javascript执行完毕后(这部份全部都在global scope跑,除非执行函数),接下来就是如John Resig解释的这样,所有的事件处理函数,以及timer执行的函数,会排在一个queue结构中,利用一个无穷回圈,不断从queue中取出函数来执行。这个就是event loop。
(除了John Resig的那篇文章,Nicholas C. Zakas的“Professional Javascript for Web Developer 2nd edition” 有一个试阅本:http://yuiblog.com/assets/pdf/zakas-projs-2ed-ch18.pdf ,598页刚好也有简短的说明)
所以在Javascript中,虽然有非同步,但是他并不是使用执行绪。所有的事件或是非同步执行的函数,都是在同一个执行绪中,利用event loop的方式在执行。至于一些比较慢的动作例如I/O、网页render, reflow等,实际动作会在其他执行绪跑,等到有结果时才利用事件来触发处理函数来处理。这样的模型有几个好处: 没有执行绪的额外成本,所以反应速度很快不会有任何程式同时用到同一个变数,不必考虑lock,也不会产生dead lock 所以程式撰写很简单但是也有一些潜在问题: 任一个函数执行时间较长,都会让其他函数更慢执行(因为一个跑完才会跑另一个) 在多核心硬体普遍的现在,无法用单一的应用程式instance发挥所有的硬体能力用NodeJS撰写伺服器程式,碰到的也是一样的状况。要让系统发挥event loop的效能,就要尽量利用事件的方式来组织程式架构。另外,对于一些有可能较为耗时的操作,可以考虑使用process.nextTick 函数来让他以非同步的方式执行,避免在同一个函数中执行太久,挡住所有函数的执行。
如果想要测试event loop怎样在「浏览器」中运行,可以在函数中呼叫alert(),这样会让所有Javascript的执行停下来,尤其会干扰所有使用timer的函数执行。有一个简单的例子,这是一个会依照设定的时间间隔严格执行动作的动画,如果时间过了就会跳过要执行的动作。点按图片以后,人物会快速旋转,但是在旋转执行完毕前按下「delay」按钮,让alert讯息等久一点,接下来的动画就完全不会出现了。
Scope 與 Closure
要快速理解JavaScript 的Scope(变数作用范围)原理,只要记住他是Lexical Scope就差不多了。简单地说,变数作用范围是依照程式定义时(或者叫做程式文本?)的上下文决定,而不是执行时的上下文决定。
为了维护程式执行时所依赖的变数,即使执行时程式运行在原本的scope之外,他的变数作用范围仍然维持不变。这时程式依赖的自由变数(定义时不是local的,而是在上一层scope定义的变数)一样可以使用,就好像被关闭起来,所以叫做Closure。用程式看比较好懂:
1 2 3 4 5 6 7 8 | function outter(arg1) { //arg1及free_variable1對inner函數來說,都是自由變數 var free_variable1 = 3; return function inner(arg2) { var local_variable1 =2; //arg2及local_variable1對inner函數來說,都是本地變數 return arg1 + arg2 + free_variable1 + local_variable1; }; } |
var a = outter(1);//变数a 就是outter函数执行后返回的inner函数var b = a(4);//执行inner函数,执行时上下文已经在outter函数之外,但是仍然能正常执行,而且可以使用定义在outter函数里面的arg1及free_variable1变数console.log(b);//结果10
在Javascript中,scope最主要的单位是函数(另外有global及eval),所以有可能制造出closure的状况,通常在形式上都是有巢状的函数定义,而且内侧的函数使用到定义在外侧函数里面的变数。
Closure有可能会造成记忆体泄漏,主要是因为被参考的变数无法被垃圾收集机制处理,造成占用的资源无法释放,所以使用上必须考虑清楚,不要造成意外的记忆体泄漏。 (在上面的例子中,如果a一直未执行,使用到的记忆体就不会被释放)
跟透过函数的参数把变数传给函数比较起来,Javascript Engine会比较难对Closure进行最佳化。如果有效能上的考量,这一点也需要注意。
Callback
要介绍Callback 之前, 要先提到JavaScript 的特色。
JavaScript 是一种函数式语言(functional language),所有Javascript语言内的函数,都是高阶函数(higher order function,这是数学名词,计算机用语好像是first class function,意指函数使用没有任何限制,与其他物件一样)。也就是说,函数可以作为函数的参数传给函数,也可以当作函数的返回值。这个特性,让Javascript的函数,使用上非常有弹性,而且功能强大。
callback在形式上,其实就是把函数传给函数,然后在适当的时机呼叫传入的函数。 Javascript使用的事件系统,通常就是使用这种形式。 NodeJS中,有一个物件叫做EventEmitter,这是NodeJS事件处理的核心物件,所有会使用事件处理的函数,都会「继承」这个物件。 (这里说的继承,实作上应该像是mixin)他的使用很简单: 可以使用物件.on(事件名称, callback函数) 或是物件.addListener(事件名称, callback函数) 把你想要处理事件的函数传入在物件中,可以使用物件.emit(事件名称, 参数...) 呼叫传入的callback函数这是Observer Pattern的简单实作,而且跟在网页中使用DOM的addEventListener使用上很类似,也很容易上手。不过NodeJS是大量使用非同步方式执行的应用,所以程式逻辑几乎都是写在callback函数中,当逻辑比较复杂时,大量的callback会让程式看起来很复杂,也比较难单元测试。举例来说:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | var p_client = new Db(‘integration_tests_20’, new Server(“127.0.0.1”, 27017, {}), {‘pk’:CustomPKFactory}); p_client.open( function (err, p_client) { p_client.dropDatabase( function (err, done) { p_client.createCollection(‘test_custom_key’, function (err, collection) { collection.insert({‘a’:1}, function (err, docs) { collection.find({‘_id’: new ObjectID(“aaaaaaaaaaaa”)}, function (err, cursor) { cursor.toArray( function (err, items) { test.assertEquals(1, items.length); p_client.close(); }); }); }); }); }); }); |
这是在网路上看到的一段操作mongodb的程式码,为了循序操作,所以必须在一个callback里面呼叫下一个动作要使用的函数,这个函数里面还是会使用callback,最后就形成一个非常深的巢状。
这样的程式码,会比较难进行单元测试。有一个简单的解决方式,是尽量不要使用匿名函数来当作callback或是event handler。透过这样的方式,就可以对各个handler做单元测试了。例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | var http = require( 'http' ); var tools = { cookieParser: function (request, response) { if (request.headers[ 'Cookie' ]) { //do parsing } } }; var server = http.createServer( function (request, response) { this .emit( 'init' , request, response); //... }); server.on( 'init' , tools.cookieParser); server.listen(8080, '127.0.0.1' ); |
更进一步,可以把tools改成外部module,例如叫做tools.js:
1 2 3 4 5 6 7 | module.exports = { cookieParser: function (request, response) { if (request.headers[‘Cookie’]) { //do parsing } } }; |
然後把程式改成:
1 2 3 4 5 6 7 8 | var http = require( 'http' ); var server = http.createServer( function (request, response) { this .emit( 'init' , request, response); //... }); server.on( 'init' , require( './tools' ).cookieParser); server.listen(8080, '127.0.0.1' ); |
這樣就可以單元測試cookieParser了。例如使用nodeunit時,可以這樣寫:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | var testCase = require( 'nodeunit' ).testCase; module.exports = testCase({ 'setUp' : function (cb) { this .request = { headers: { Cookie: 'name1:val1; name2:val2' } }; this .response = {}; this .result = { name1: 'val1' , name2: 'val2' }; cb(); }, 'tearDown' : function (cb) { cb(); }, 'normal_case' : function (test) { test.expect(1); var obj = require( './tools' ).cookieParser( this .request, this .response); test.deepEqual(obj, this .result); test.done(); } }); |
善於利用模組,可以讓程式更好維護與測試。
CPS(Continuation-Passing Style)
cps是callback使用上的特例,形式上就是在函数最后呼叫callback,这样就好像把函数执行后把结果交给callback继续运行,所以称作continuation-passing style。利用cps,可以在非同步执行的情况下,透过传给callback的这个cps callback来获知callback执行完毕,或是取得执行结果。例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | <html> <body> <div id= 'panel' style= 'visibility:hidden' ></div> </body> </html> <script> var request = new XMLHttpRequest(); request.open( 'GET' , 'test749.txt?timestamp=' + new Date().getTime(), true ); request.addEventListener( 'readystatechange' , function (next){ return function () { if ( this .readyState===4&& this .status===200){ next( this .responseText); //<==傳入的cps callback在動作完成時執行並取得結果進一步處理 } }; }( function (str){ //<==這個匿名函數就是cps callback document.getElementById( 'panel' ).innerHTML=str; document.getElementById( 'panel' ).style.visibility = 'visible' ; }), false ); request.send(); </script> |
進一步的應用,也可以參考2-6 流程控制。
函數返回函數與Currying
前面的cps范例里面,使用了函数返回函数,这是为了把cps callback传递给onreadystatechange事件处理函数的方法。 (因为这个事件处理函数并没有设计好会传送/接收这样的参数)实际会执行的事件处理函数其实是内层返回的那个函数,之外包覆的这个函数,主要是为了利用Closure,把next传给内层的事件处理函数。这个方法更常使用的地方,是为了解决一些scope问题。例如:
1 2 3 4 5 6 7 8 9 10 11 | var accu=0,count=10; for ( var i=0; i<count; i++) { setTimeout( function (){ count–; accu+=i; if (count<=0){ console.log(accu) } }, 50) } |
最后得出的结果会是100,而不是想像中的45,这是因为等到setTimeout指定的函数执行时,变数i已经变成10而离开回圈了。要解决这个问题,就需要透过Closure来保存变数i:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | var accu=0,count=10; for ( var i=0; i<count; i++) { setTimeout( function (i) { return function (){ count–; accu+=i; if (count<=0){ console.log(accu) } }; }(i), 50) } |
函數返回函數的另外一個用途,是可以暫緩函數執行。例如:
1 2 3 4 5 | function add(m, n) { return m+n; } var a = add(20, 10); console.log(a); |
add这个函数,必须同时输入两个参数,才有办法执行。如果我希望这个函数可以先给它一个参数,等一些处理过后再给一个参数,然后得到结果,就必须用函数返回函数的方式做修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | function add(m) { return function (n) { return m+n; }; } var wait_another_arg = add(20); //先給一個參數 var a = function (arr) { var ret=0; for ( var i=0;i<arr.length;i++) { ret+=arr[i]; } return ret; }([1,2,3,4]); //計算一下另一個參數 var b = wait_another_arg(a); //然後再繼續執行 console.log(b); |
像这样利用函数返回函数,使得原本接受多个参数的函数,可以一次接受一个参数,直到参数接收完成才执行得到结果的方式,有一个学名就叫做...Currying
综合以上许多奇技淫巧,就可以透过用函数来处理函数的方式,调整程式流程。接下来看看...
流程控制
(以sync方式使用async函数、避开巢状callback循序呼叫async callback等奇技淫巧)
建議參考:
- http://howtonode.org/control-flow
- http://howtonode.org/control-flow-part-ii
- http://howtonode.org/control-flow-part-iii
- http://blog.mixu.net/2011/02/02/essential-node-js-patterns-and-snippets
这几篇都是非常经典的NodeJS/Javascript流程控制好文章(阿,mixu是在介绍一些pattern时提到这方面的主题)。不过我还是用几个简单的程式介绍一下做法跟概念:
並發與等待
下面的程式參考了mixu文章中的做法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | var wait = function (callbacks, done) { console.log( 'wait start' ); var counter = callbacks.length; var results = []; var next = function (result) { //接收函數執行結果,並判斷是否結束執行 results.push(result); if (--counter == 0) { done(results); //如果結束執行,就把所有執行結果傳給指定的callback處理 } }; for ( var i = 0; i < callbacks.length; i++) { //依次呼叫所有要執行的函數 callbacks[i](next); } console.log( 'wait end' ); } wait([ function (next){ setTimeout( function (){ console.log( 'done a' ); var result = 500; next(result) },500);}, function (next){ setTimeout( function (){ console.log( 'done b' ); var result = 1000; next(result) },1000);}, function (next){ setTimeout( function (){ console.log( 'done c' ); var result = 1500; next(1500) },1500); }], function (results){ var ret = 0, i=0; for (; i<results.length; i++) { ret += results[i]; } console.log( 'done all. result: ' +ret); } ); |
執行結果:
wait start
wait end
done a
done b
done c
done all. result: 3000
可以看出来,其实wait并不是真的等到所有函数执行完才结束执行,而是在所有传给他的函数执行完毕后(不论同步、非同步),才执行处理结果的函数(也就是done( ))
不过这样的写法,还不够实用,因为没办法实际让函数可以等待执行完毕,又能当作事件处理函数来实际使用。上面参考到的Tim Caswell的文章,里面有一种解法,不过还需要额外包装(在他的例子中)NodeJS核心的fs物件,把一些函数(例如readFile)用Currying处理。类似像这样:
1 2 3 4 5 6 7 8 9 10 11 12 | var fs = require( 'fs' ); var readFile = function (path) { return function (callback, errback) { fs.readFile(path, function (err, data) { if (err) { errback(); } else { callback(data); } }); }; } |
其他部份可以参考Tim Caswell的文章,他的Do.parallel跟上面的wait差不多意思,这里只提示一下他没说到的地方。
另外一种做法是去修饰一下callback,当他作为事件处理函数执行后,再用cps的方式取得结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | function Wait(fns, done) { var count = 0; var results = []; this .getCallback = function (index) { count++; return ( function (waitback) { return function () { var i=0,args=[]; for (;i<arguments.length;i++) { args.push(arguments[i]); } args.push(waitback); fns[index].apply( this , args); }; })( function (result) { results.push(result); if (--count == 0) { done(results); } }); } } var a = new Wait([ function (waitback){ console.log( 'done a' ); var result = 500; waitback(result) }, function (waitback){ console.log( 'done b' ); var result = 1000; waitback(result) }, function (waitback){ console.log( 'done c' ); var result = 1500; waitback(result) }], function (results){ var ret = 0, i=0; for (; i<results.length; i++) { ret += results[i]; } console.log( 'done all. result: ' +ret); }); var callbacks = [a.getCallback(0),a.getCallback(1),a.getCallback(0),a.getCallback(2)]; //一次取出要使用的callbacks,避免結果提早送出 setTimeout(callbacks[0], 500); setTimeout(callbacks[1], 1000); setTimeout(callbacks[2], 1500); setTimeout(callbacks[3], 2000); //當所有取出的callbacks執行完畢,就呼叫done()來處理結果 |
執行結果:
done a
done b
done a
done c
done all. result: 3500
上面只是一些小實驗,更成熟的作品是Tim Caswell的step:https://github.com/creationix/step
如果希望真正使用同步的方式寫非同步,則需要使用Promise.js這一類的library來轉換非同步函數,不過他結構比較複雜XD(見仁見智,不過有些人認為Promise有點過頭了):http://blogs.msdn.com/b/rbuckton/archive/2011/08/15/promise-js-2-0-promise-framework-for-javascript.aspx
如果想不透過其他Library做轉換,又能直接用同步方式執行非同步函數,大概就要使用一些需要額外compile原始程式碼的方法了。例如Bruno Jouhier的streamline.js:https://github.com/Sage/streamlinejs
循序執行
循序執行可以協助把非常深的巢狀callback結構攤平,例如用這樣的簡單模組來做(serial.js):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | module.exports = function (funs) { var c = 0; if (!isArrayOfFunctions(funs)) { throw ( 'Argument type was not matched. Should be array of functions.' ); } return function () { var args = Array.prototype.slice.call(arguments, 0); if (!(c>=funs.length)) { c++; return funs[c-1].apply( this , args); } }; } function isArrayOfFunctions(f) { if ( typeof f !== 'object' ) return false ; if (!f.length) return false ; if (!f.concat) return false ; if (!f.splice) return false ; var i = 0; for (; i<f.length; i++) { if ( typeof f[i] !== 'function' ) return false ; } return true ; } |
简单的测试范例(testSerial.js),使用fs模组,确定某个path是档案,然后读取印出档案内容。这样会用到两层的callback,所以测试中有使用serial的版本与nested callbacks的版本做对照:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | var serial = require( './serial' ), fs = require( 'fs' ), path = './dclient.js' , cb = serial([ function (err, data) { if (!err) { if (data.isFile) { fs.readFile(path, cb); } } else { console.log(err); } }, function (err, data) { if (!err) { console.log( '[flattened by searial:]' ); console.log(data.toString( 'utf8' )); } else { console.log(err); } } ]); fs.stat(path, cb); fs.stat(path, function (err, data) { //第一層callback if (!err) { if (data.isFile) { fs.readFile(path, function (err, data) { //第二層callback if (!err) { console.log( '[nested callbacks:]' ); console.log(data.toString( 'utf8' )); } else { console.log(err); } }); } else { console.log(err); } } }); |
关键在于,这些callback的执行是有顺序性的,所以利用serial返回的一个函数cb来取代这些callback,然后在cb中控制每次会循序呼叫的函数,就可以把巢状的callback摊平成循序的function阵列(就是传给serial函数的参数)。
测试中的./dclient.js是一个简单的dnode测试程式,放在跟testSerial.js同一个目录:
1 2 3 4 5 6 7 8 | var dnode = require( 'dnode' ); dnode.connect(8000, 'localhost' , function (remote) { remote.restart( function (str) { console.log(str); process.exit(); }); }); |
執行測試程式後,出現結果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | [flattened by searial:] var dnode = require( 'dnode' ); dnode.connect(8000, 'localhost' , function (remote) { remote.restart( function (str) { console.log(str); process.exit(); }); }); [nested callbacks:] var dnode = require( 'dnode' ); dnode.connect(8000, 'localhost' , function (remote) { remote.restart( function (str) { console.log(str); process.exit(); }); }); |
对照起来看,两种写法的结果其实是一样的,但是利用serial.js,巢状的callback结构就会消失。
不过这样也只限于顺序单纯的状况,如果函数执行的顺序比较复杂(不只是一直线),还是需要用功能更完整的流程控制模组比较好,例如 https://github.com/caolan/async 。
組合
简单地说,组合就是把函数执行的结果作为参数传给另外一个函数,用这样的形式把好几个函数串接起来,然后一次执行。
例如:
1 2 3 | var a = function (a){ return a*3;}; var b = function (b){ return b+5;}; console.log(a(b(2))); //結果是21,a(b(2))這樣就是組合 |
有些时候,可能需要预先做好处理一些payload的函数,然后可以依照需求任意组合。当然也可以直接组合来执行,但是有时候系统需要有弹性依照不定的需求来弹性组合,这样就需要用一些方法来处理。
待续...
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· Ollama——大语言模型本地部署的极速利器
· DeepSeek如何颠覆传统软件测试?测试工程师会被淘汰吗?