如何优雅地写js异步代码
hubuke
9年前
<p><img alt="如何优雅地写js异步代码" src="https://simg.open-open.com/show/3e74552a76c94b85efe327087fabfd3e.jpg"> 本文通过一个简单的需求:读取文件并备份到指定目录(详见第一段代码的注释),以不同的js代码实现,来演示代码是如何变优雅的。对比才能分清好坏,想知道什么是优雅的代码,先看看糟糕的代码。</p> <h2>不优雅的代码是什么样的?</h2> <h3>1、 回调地狱</h3> <pre> <code class="language-javascript">/** * 读取当前目录的package.json,并将其备份到backup目录 * * 1. 读取当前目录的package.json * 2. 检查backup目录是否存在,如果不存在就创建backup目录 * 3. 将文件内容写到备份文件 */ fs.readFile('./package.json', function(err, data) { if (err) { console.error(err); } else { fs.exists('./backup', function(exists) { if (!exists) { fs.mkdir('./backup', function(err) { if (err) { console.error(err); } else { // throw new Error('unexpected'); fs.writeFile('./backup/package.json', data, function(err) { if (err) { console.error(err); } else { console.log('backup successed'); } }); } }); } else { fs.writeFile('./backup/package.json', data, function(err) { if (err) { console.error(err); } else { console.log('backup successed'); } }); } }); } }); </code></pre> <h3>2、 匿名调试</h3> <p>取消上面代码中抛出异常的注释再执行</p> <p><img alt="如何优雅地写js异步代码" src="https://simg.open-open.com/show/9485b81e7ce1ec2d0559c24eb8d08065.png"></p> <p>wtf,这个<code class="language-markup">unexpected</code>错误从哪个方法抛出来的?</p> <p>神马?你觉的这个代码写得很好,优雅得无可挑剔?那么你现在可以忽略下文直接去最后的评论写:楼主敏感词</p> <h2>怎样写才能让js回调看上去优雅?</h2> <ol> <li>消除回调嵌套</li> <li>命名方法</li> </ol> <pre> <code class="language-javascript">fs.readFile('./package.json', function(err, data) { if (err) { console.error(err); } else { writeFileContentToBackup(data); } }); function writeFileContentToBackup(fileContent) { checkBackupDir(function(err) { if (err) { console.error(err); } else { backup(fileContent, log); } }); } function checkBackupDir(cb) { fs.exists('./backup', function(exists) { if (!exists) { mkBackupDir(cb); } else { cb(null); } }); } function mkBackupDir(cb) { // throw new Error('unexpected'); fs.mkdir('./backup', cb); } function backup(data, cb) { fs.writeFile('./backup/package.json', data, cb); } function log(err) { if (err) { console.error(err); } else { console.log('backup successed'); } } </code></pre> <p>我们现在可以快速定位抛出异常的方法</p> <p><img alt="如何优雅地写js异步代码" src="https://simg.open-open.com/show/5a5334282cafae4dc83616b034446d46.png"></p> <h2>他山之石 可以攻玉</h2> <p>借助第三方库,优化异步代码</p> <h3>browser js</h3> <ul> <li>jQuery Deferred <ul> <li>ajax</li> <li>animate</li> </ul> </li> </ul> <h3>NodeJs</h3> <ul> <li> <p><a href="/misc/goto?guid=4958824849782534716">Async</a></p> <ul> <li>async.each</li> <li>async.map</li> <li>async.waterfall</li> </ul> </li> <li> <p>ECMAScript 6</p> <ul> <li><a href="/misc/goto?guid=4958967308707590699">Promise</a></li> <li><a href="/misc/goto?guid=4959671365697941558">Generator</a></li> </ul> </li> </ul> <h2>jQuery Deferred</h2> <blockquote> <p>在jQuery-1.5中引进,被应用在ajax、animate等异步方法上</p> </blockquote> <p>一个简单的例子:</p> <pre> <code class="language-javascript">function sleep(timeout) { var dtd = $.Deferred(); setTimeout(dtd.resolve, timeout); return dtd; } // 等同于上面的写法 function sleep(timeout) { return $.Deferred(function(dtd) { setTimeout(dtd.resolve, timeout); }); } console.time('sleep'); sleep(2000).done(function() { console.timeEnd('sleep'); }); </code></pre> <p>一个复杂的例子:</p> <pre> <code class="language-javascript">function loadImg(src) { var dtd = $.Deferred(), img = new Image; img.onload = function() { dtd.resolve(img); } img.onerror = function(e) { dtd.reject(e); } img.src = src; return dtd; } loadImg('<a class="token url-link" href="/misc/goto?guid=4959671365797252090">http://www.baidu.com/favicon.ico</a>').then( function(img) { $('body').prepend(img); }, function() { alert('load error'); } ) </code></pre> <p>那么问题来了,我想要过5s后把百度Logo显示出来?</p> <p>普通写法:</p> <pre> <code class="language-javascript">sleep(5000).done(function() { loadImg('<a class="token url-link" href="/misc/goto?guid=4959671365797252090">http://www.baidu.com/favicon.ico</a>').done(function(img) { $('body').prepend(img); }); }); </code></pre> <p>二逼写法:</p> <pre> <code class="language-javascript">setTimeout(function() { loadImg('<a class="token url-link" href="/misc/goto?guid=4959671365797252090">http://www.baidu.com/favicon.ico</a>').done(function(img) { $('body').prepend(img); }); }, 5000); </code></pre> <p>文艺写法(睡5s和加载图片同步执行):</p> <pre> <code class="language-javascript">$.when(sleep(5000), loadImg('<a class="token url-link" href="/misc/goto?guid=4959671365797252090">http://www.baidu.com/favicon.ico</a>')).done(function(ignore, img) { $('body').prepend(img); }); </code></pre> <h2>Async</h2> <p>使用方法参考:<a href="/misc/goto?guid=4958824849782534716">https://github.com/caolan/async</a></p> <p>优点:</p> <ol> <li>简单、易于理解</li> <li>函数丰富,几乎可以满足任何回调需求</li> <li>流行</li> </ol> <p>缺点:</p> <ol> <li>额外引入第三方库</li> <li>虽然简单,但还是难以掌握所有api</li> </ol> <h2>ECMAScript 6</h2> <blockquote> <p>ES6的目标,是使得JavaScript语言可以用来编写大型的复杂的应用程序,成为企业级开发语言。</p> </blockquote> <p>接下来介绍ES6的新特性:Promise对象和Generator函数,是如何让代码看起来更优雅。</p> <p>更多ES6的特性参考:<a href="/misc/goto?guid=4958850434079143726">ECMAScript 6 入门</a></p> <h3>Promise</h3> <p>Promise对象的初始化以及使用:</p> <pre> <code class="language-javascript">var promise = new Promise(function(resolve, reject) { setTimeout(function() { if (true) { resolve('ok'); } else { reject(new Error('unexpected error')); } }, 2000); }); promise.then(function(msg) { // throw new Error('unexpected resolve error'); console.log(msg); }).catch(function(err) { console.error(err); }); </code></pre> <blockquote> <p>JavaScript Promise 的 API 会把任何包含有 then 方法的对象当作“类 Promise”(或者用术语来说就是 thenable)</p> </blockquote> <p>与上面介绍的jQuery Deferred对象类似,但api方法和错误捕捉等不完全一样。<br> 可以使用以下方法转换:</p> <pre> <code class="language-javascript">var promise = Promise.resolve($.Deferred()); </code></pre> <p>那怎么使用Promise改写回调地狱那个例子?</p> <pre> <code class="language-javascript">// 1. 读取当前目录的package.json readPackageFile.then(function(data) { // 2. 检查backup目录是否存在,如果不存在就创建backup目录 return checkBackupDir.then(function() { // 3. 将文件内容写到备份文件 return backupPackageFile(data); }); }).then(function() { console.log('backup successed'); }).catch(function(err) { console.error(err); }); </code></pre> <p>这么简单?</p> <p>看看<code class="language-markup">readPackageFile</code>、<code class="language-markup">checkBackupDir</code>和<code class="language-markup">backupPackageFile</code>的定义:</p> <pre> <code class="language-javascript">var readPackageFile = new Promise(function(resolve, reject) { fs.readFile('./package.json', function(err, data) { if (err) { reject(err); } resolve(data); }); }); var checkBackupDir = new Promise(function(resolve, reject) { fs.exists('./backup', function(exists) { if (!exists) { resolve(mkBackupDir); } else { resolve(); } }); }); var mkBackupDir = new Promise(function(resolve, reject) { // throw new Error('unexpected error'); fs.mkdir('./backup', function(err) { if (err) { return reject(err); } resolve(); }); }); function backupPackageFile(data) { return new Promise(function(resolve, reject) { fs.writeFile('./backup/package.json', data, function(err) { if (err) { return reject(err); } resolve(); }); }); }; </code></pre> <p>是不是感觉到满满的欺骗,说好的简单呢,先别打,至少调用起来还是很简单的XD。个人觉得使用<strong>Promise</strong>最大的好处就是让调用方爽。</p> <p>流程优化,使用js的无阻塞特性,我们发现第一步和第二步可以同步执行:</p> <pre> <code class="language-javascript">Promise.all([readPackageFile, checkBackupDir]).then(function(res) { return backupPackageFile(res[0]); }).then(function() { console.log('backup successed'); }).catch(function(err) { console.error(err); }); </code></pre> <p>在ES5环境下可以使用的库:</p> <ul> <li><a href="/misc/goto?guid=4959615062234167351">bluebird</a></li> <li><a href="/misc/goto?guid=4958534319681535099">Q</a></li> <li><a href="/misc/goto?guid=4959544328503707886">when</a></li> <li><a href="/misc/goto?guid=4959554004866267121">WinJS</a></li> <li><a href="/misc/goto?guid=4959546318874509941">RSVP.js</a></li> </ul> <h3>Generator</h3> <p>NodeJs默认不支持Generator的写法,但在v0.12后可以添加<code class="language-markup">--harmony</code>参数使其支持:</p> <pre> <code class="language-javascript">> node --harmony generator.js </code></pre> <blockquote> <p>允许函数在特定地方像<code class="language-markup">return</code>一样退出,但是稍后又能恢复到这个位置和状态上继续执行</p> </blockquote> <pre> <code class="language-javascript">function * foo(input) { console.log('这里会在第一次调用next方法时执行'); yield input; console.log('这里不会被执行,除非再调一次next方法'); } var g = foo(10); console.log(Object.prototype.toString.call(g)); // [object Generator] console.log(g.next()); // { value: 10, done: false } console.log(g.next()); // { value: undefined, done: true } </code></pre> <p>如果觉得比较难理解,就把<code class="language-markup">yield</code>看成<code class="language-markup">return</code>语句,把整个函数拆分成许多小块,每次调用<code class="language-markup">generator</code>的<code class="language-markup">next</code>方法就按顺序执行一小块,执行到<code class="language-markup">yield</code>就退出。</p> <p>告诉你一个惊人的秘密,我们现在可以“同步”写js的<code class="language-markup">sleep</code>了:</p> <pre> <code class="language-javascript">var sleepGenerator; function sleep(time) { setTimeout(function() { sleepGenerator.next(); // step 5 }, time); } var sleepGenerator = (function * () { console.log('wait...'); // step 2 console.time('how long did I sleep'); // step 3 yield sleep(2000); // step 4 console.log('weakup'); // step 6 console.timeEnd('how long did I sleep'); // step 7 }()); sleepGenerator.next(); // step 1 </code></pre> <p>合体,使用Promise和Generator重写回调地狱的例子</p> <p>合体前的准备工作,参考<a href="/misc/goto?guid=4959671366120579525">Q.async</a>:</p> <pre> <code class="language-javascript">function run(makeGenerator) { function continuer(verb, arg) { var result; try { result = generator[verb](arg); } catch (err) { return Promise.reject(err); } if (result.done) { return result.value; } else { return Promise.resolve(result.value).then(callback, errback); } } var generator = makeGenerator.apply(this, arguments); var callback = continuer.bind(continuer, "next"); var errback = continuer.bind(continuer, "throw"); return callback(); } </code></pre> <p><code class="language-markup">readPackageFile</code>、<code class="language-markup">checkBackupDir</code>和<code class="language-markup">backupPackageFile</code>直接使用上面Promise中的定义,是不是很爽。</p> <p>合体后的执行:</p> <pre> <code class="language-javascript">run(function *() { try { // 1. 读取当前目录的package.json var data = yield readPackageFile; // 2. 检查backup目录是否存在,如果不存在就创建backup目录 yield checkBackupDir; // 3. 将文件内容写到备份文件 yield backupPackageFile(data); console.log('backup successed'); } catch (err) { console.error(err); } }); </code></pre> <p>是不是感觉跟写同步代码一样了。</p> <h2>总结</h2> <p>看完本文,如果你感慨:“靠,js还能这样写”,那么我的目的就达到了。本文的写作初衷不是介绍<code class="language-markup">Async</code>、<code class="language-markup">Deferred</code>、<code class="language-markup">Promise</code>、<code class="language-markup">Generator</code>的用法,如果对于这几个概念不是很熟悉的话,建议查阅其他资料学习。写js就像说英语,不是<em>write in js</em>,而是<em>think in js</em>。不管使用那种方式,都是为了增强代码的可读性和可维护性;如果是在已有的项目中修改,还要考虑对现有代码的侵略性。</p> <blockquote> <p>续集:<a href="/misc/goto?guid=4959671366215572600">如何优雅地写js异步代码(2)</a></p> </blockquote> <h2>参考地址</h2> <ul> <li><a href="/misc/goto?guid=4958964046517860617">回调地狱</a></li> <li><a href="/misc/goto?guid=4959652469983305116">JavaScript Promise启示录</a></li> <li><a href="/misc/goto?guid=4958866170093509700">Promises/A+</a></li> <li><a href="/misc/goto?guid=4958850434079143726">ECMAScript 6入门</a></li> <li><a href="/misc/goto?guid=4959621647701252125">JavaScript Promises</a></li> <li><a href="/misc/goto?guid=4959671366432761724">使用 (Generator) 生成器解决 JavaScript 回调嵌套问题</a></li> <li><a href="/misc/goto?guid=4959671366524545884">拥抱Generator,告别回调</a></li> </ul> <p>题图引自:<a href="/misc/goto?guid=4959671366613648757">http://forwardjs.com/img/workshops/advancedjs-async.jpg</a></p> <p>来自:<a href="/misc/goto?guid=4959671366715340425">http://iammapping.com/write-js-async-gracefully/</a></p>