如何优雅地写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>