如何优雅地写 JS 异步代码

5,151 阅读5分钟
原文链接: iammapping.com

不优雅的代码是什么样的?

1、 回调地狱

/**
 * 读取当前目录的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');
                    }
                });
            }
        });
    }
});

2、 匿名调试

取消上面代码中抛出异常的注释再执行

wtf,这个unexpected错误从哪个方法抛出来的?

神马?你觉的这个代码写得很好,优雅得无可挑剔?那么你现在可以忽略下文直接去最后的评论写:楼主敏感词

怎样写才能让js回调看上去优雅?

1、 消除回调嵌套

2、 命名方法

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');
    }
}

我们现在可以快速定位抛出异常的方法

他山之石 可以攻玉

借助第三方库,优化异步代码

browser js

  • jQuery Deferred
    • ajax
    • animate

NodeJs

jQuery Deferred

在jQuery-1.5中引进,被应用在ajax、animate等异步方法上

一个简单的例子:

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');
});

一个复杂的例子:

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('http://www.baidu.com/favicon.ico').then(  
    function(img) {
        $('body').prepend(img);
    }, function() {
        alert('load error');
    }
)

那么问题来了,我想要过5s后把百度Logo显示出来?

普通写法:

sleep(5000).done(function() {  
     loadImg('http://www.baidu.com/favicon.ico').done(function(img) {
        $('body').prepend(img);
    });
});

二逼写法:

setTimeout(function() {  
    loadImg('http://www.baidu.com/favicon.ico').done(function(img) {
        $('body').prepend(img);
    });
}, 5000);

文艺写法(睡5s和加载图片同步执行):

$.when(sleep(5000), loadImg('http://www.baidu.com/favicon.ico')).done(function(ignore, img) {
    $('body').prepend(img);
});

Async

使用方法参考:github.com/caolan/asyn…

优点:

  1. 简单、易于理解
  2. 函数丰富,几乎可以满足任何回调需求
  3. 流行

缺点:

  1. 额外引入第三方库
  2. 虽然简单,但还是难以掌握所有api

ECMAScript 6

ES6的目标,是使得JavaScript语言可以用来编写大型的复杂的应用程序,成为企业级开发语言。

接下来介绍ES6的新特性:Promise对象和Generator函数,是如何让代码看起来更优雅。

更多ES6的特性参考:ECMAScript 6 入门

Promise

Promise对象的初始化以及使用:

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);
});

JavaScript Promise 的 API 会把任何包含有 then 方法的对象当作“类 Promise”(或者用术语来说就是 thenable)

与上面介绍的jQuery Deferred对象类似,但api方法和错误捕捉等不完全一样。
可以使用以下方法转换:

var promise = Promise.resolve($.Deferred());  

那怎么使用Promise改写回调地狱那个例子?

// 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);
});

这么简单?

看看readPackageFilecheckBackupDirbackupPackageFile的定义:

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();
        });
    });
};

是不是感觉到满满的欺骗,说好的简单呢,先别打,至少调用起来还是很简单的XD。个人觉得使用Promise最大的好处就是让调用方爽。

流程优化,使用js的无阻塞特性,我们发现第一步和第二步可以同步执行:

Promise.all([readPackageFile, checkBackupDir]).then(function(res) {  
    return backupPackageFile(res[0]);
}).then(function() {
    console.log('backup successed');
}).catch(function(err) {
    console.error(err);
});

在ES5环境下可以使用的库:

Generator

NodeJs默认不支持Generator的写法,但在v0.12后可以添加--harmony参数使其支持:

> node --harmony generator.js

允许函数在特定地方像return一样退出,但是稍后又能恢复到这个位置和状态上继续执行

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 }  

如果觉得比较难理解,就把yield看成return语句,把整个函数拆分成许多小块,每次调用generatornext方法就按顺序执行一小块,执行到yield就退出。

告诉你一个惊人的秘密,我们现在可以“同步”写js的sleep了:

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  

合体,使用Promise和Generator重写回调地狱的例子

合体前的准备工作,参考Q.async

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();
}

readPackageFilecheckBackupDirbackupPackageFile直接使用上面Promise中的定义,是不是很爽。

合体后的执行:

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);
    }
});

是不是感觉跟写同步代码一样了。

总结

看完本文,如果你感慨:“靠,js还能这样写”,那么我的目的就达到了。本文的写作初衷不是介绍AsyncDeferredPromiseGenerator的用法,如果对于这几个概念不是很熟悉的话,建议查阅其他资料学习。写js就像说英语,不是write in js,而是think in js。不管使用那种方式,都是为了增强代码的可读性和可维护性;如果是在已有的项目中修改,还要考虑对现有代码的侵略性。

续集:如何优雅地写js异步代码(2)

参考地址

题图引自:forwardjs.com/img/worksho…