日常开发过程中, 时不时会遇到要同时预加载几张图片, 并且等都加载完再干活的情况, 结合 Promise 和 async/await 代码会优雅很多, 但也容易遇到坑, 今天就来简单聊聊.
ES5
先从最基本的 ES5 说起, 基本思路就是做一个计数器, 每次 image 触发 onload 就加一, 达到次数后触发回调函数.
- var count = 0,
- imgs = [];
- function loadImgs(imgList, cb) {
- imgList.forEach(function(url, i) {
- imgs[i] = new Image();
- imgs[i].onload = function() {
- if( ++count === imgList.length) {
- cb && cb()
- }
- }
- imgs[i].src = url;
- })
- }
调用方法:
- loadImgs(["xxx/a.png","xxx/b.png"],function() {
- console.log("开始干活");
- })
这样做基本功能是能满足的, 但是这种回调的方式跳来跳去, 代码显得比较混乱.
俗话说, 异步编程的最高境界, 就是根本不用关心它是不是异步. 能用同步的方式写出异步的代码, 才是好的编码体验. 于是乎, 到 Promise 和 async/await 出场了.
ES6
让我们用 Promise 和 async/await 来改写一下.(注意, 这个例子有个很大的问题)
- async function loadImgs(imgList, cb) {
- console.log("start")
- for( var i =0; i<imgList.length; i++) {
- await imgLoader(imgList[i], i);
- console.log("finish"+i)
- }
- cb();
- }
- async function imgLoader(url, num){
- return new Promise((resolve, reject) => {
- console.log("request"+num)
- setTimeout(resolve, 1000);
- // let img = new Image();
- // img.onload = () => resolve(img);
- // img.onerror = reject;
- console.log("return"+num)
- })
- }
- loadImgs(["xxx/a.png","xxx/b.png"],function() {
- console.log("开始干活");
- })
为了方便在 node 环境中运行代码, 这里我用 setTimeout 代替了真正的图片加载.
运行的结果是:
- start
- request0
- return0
- finish0
- request1
- return1
- finish1
开始干活
有没有发现问题, 虽然我们期望的是用同步代码的形式写出异步的效果, 虽然我们用了 async/await Promise 等吊炸天的东西, 但是实际运行的结果却是同步的. request0 finish 之后, request1 才发出.
这样的代码虽然语义清晰, 通俗易懂, 但等图片一张一张顺序加载是我们不能接受的, 同时发出几个请求异步加载是我们的目标.
产生这种错误的原因是 async/await 其实只是语法糖并不是说加了就异步了, 其本质上是为了解决回调嵌套过多的问题.
回调函数
N 年前, 通过分发 jQuery 武器, 大家卷起袖子加入了前端大潮, 然而他们遇到的一个大问题就是 "回调地狱".
比如下面这个例子, 发完三个 ajax 请求之后才能开始干活.
- $.ajax({
- url: "xxx/xxx",
- data: 123,
- success: function () {
- $.ajax({
- url: "xxx/xxx2",
- data:456,
- success: function () {
- $.ajax({
- url: "xxx/xxx3",
- data:789,
- success: function () {
- // 终于完了可以开始干事情了
- }
- })
- }
- })
- }
- })
这个还只是把简单的代码结构写出来, 括号就多到眼花, 如果再加上业务逻辑, 错误处理等, 那就是实实在在的 "地狱".
救世主 Promise ?
Promise 的出现大大改善了回调地狱, 写法也更加接近同步.
简单来说, Promise 就是一个容器, 里面保存着某个已经发生未来才会结束的事件, 当事件结束时, 会自动调用一个统一的接口告诉你.
- var promise = new Promise(function(resolve, reject) {
- $.ajax({
- url: "xxx/xxx3",
- success: function () {
- resolve(rs)
- },
- })
- }
- // 调用的时候
- promise.then(function(rs){
- // 返回另一个 Promise
- return new Promise(...)
- })
- .then(function(rs){
- // 又返回另一个 Promise
- return new Promise(...)
- })
- .then(function(rs){
- // 开始干活
- })
- .catch(function(err){
- // 出错了
- });
Promise 的构造函数有两个参数, 都是 javascript 引擎提供的, 不用自己实现, 分别是 resolve 和 reject.
resolve 的作用是将 Promise 的状态从 "未完成" 变成 "解决了", 即异步操作完成, 可以将结果作为参数传递给下一步.
reject 的作用是将 Promise 的状态从 "未完成" 变成 "失败", 即异步操作失败, 并将错误传递出去.
then 方法可以接受两个函数作为参数, 分别对应 resolve 和 reject 时的处理, 其中 reject 是可选的.
- promise.then(function(value) {
- // success
- }, function(error) {
- // failure
- });
Promise 至少把广大开发者从回调地狱中拯救出来, 把回调变为链式调用.
注意这里只是拿 ajax 做例子, 实际上 jQuery 的 ajax 已经 Promise 化, 可以直接类似 Promise 的用法.
- $.ajax({
- url: "test.html",
- context: document.body
- }).done(function() {
- $( this ).addClass( "done" );
- });
这种写法已经比回调函数的写法要直观多了, 但是还是有一些嵌套, 不够直观.
async/await 降临
Promise 和 async/await 之间其实还有一个 Generator, 用的也不多, 简单说下, 形式是这样的:
- function* gen(x){
- var y = yield x + 2;
- return y;
- }
- var g = gen(1);
- g.next() // { value: 3, done: false }
- g.next(2) // { value: 2, done: true }
Generator 函数要用 * 来标识, 用 yield 表示暂停, 通过 yield 把函数分割出好多个部分, 每调用一次 next 会返回一个对象, 表示当前阶段的信息 (value 属性和 done 属性).value 属性是 yield 语句后面表达式的值, 表示当前阶段的值; done 属性是一个布尔值, 表示 Generator 函数是否执行完毕, 即是否还有下一个阶段.
关于 Generator 的详细信息可以参考 http://www.ruanyifeng.com/blog/2015/04/generator.html
async/await 其实 Generator 的语法糖, 用 async 这种更明确的标识代替 *, 用 await 代替 yield.
说了这么多, 我们终于明白 async/await 是为了能用同步的方式写出异步的代码, 同时解决回调地狱.
所以在多图片异步加载这个场景下, 我们期望的应该是多个异步操作都完成之后再告诉我们.
- async function loadImgs(imgList){
- let proList = [];
- for(var i=0; i<imgList.length; i++ ){
- let pro = new Promise((resolve, reject) => {
- console.log("request"+i)
- setTimeout(resolve, 2000);
- console.log("return"+i)
- })
- proList.push(pro)
- }
- return Promise.all(proList)
- .then( ()=>{
- console.log("finish all");
- return Promise.resolve();
- })
- }
- async function entry(imgList, cb) {
- await loadImgs(imgList);
- cb();
- }
- entry(["xxx/a.png","xxx/b.png"], function(){
- console.log("开始干活")
- })
运行结果是:
- request0
- return0
- request1
- return1
- finish all
开始干活
会看到一开始就立马打印出
- request0
- return0
- request1
- return1
过了两秒之后, 才打印出 finish all .
完整例子
上面我们都是在 node 命令行里面运行的, 在理解整个过程之后, 让我们在浏览器里面实际试试, 由于兼容性问题, 我们要借助 webpack 转换一下.
上代码:
- function loadImgs(imgList){
- let proList = [];
- for(var i=0; i<imgList.length; i++ ){
- let pro = new Promise((resolve, reject) => {
- let img = new Image();
- img.onload = function(){
- resolve(img)
- }
- img.src = imgList[i];
- })
- proList.push(pro)
- }
- return Promise.all(proList)
- .then( (rs)=>{
- console.log("finish all");
- return Promise.resolve(rs);
- })
- }
- async function entry(imgList, cb) {
- try {
- let rs = await loadImgs(imgList);
- cb(rs);
- } catch(err) {
- console.log(err)
- cb([])
- }
- }
- var imgUrlList = [
- "http://111.231.236.41/vipstyle/cartoon/v4/release/pic/index/recomment-single-s3.png",
- "http://111.231.236.41/vipstyle/cartoon/v4/release/pic/index/recomment-single-s2.png"
- ]
- entry(imgUrlList, function(rs){
- console.log("开始干活")
- console.log(rs)
- })
注意, await 命令后的 Promise 对象是有可能 rejected 的, 所以最好放到 try...catch 块中执行.
需要用 webpack 转换下, 可以参考我们 webpack.config.js:
- module.exports = {
- entry: ['./index.js'],
- output: {
- filename: 'bundle.js'
- },
- devtool: 'sourcemap',
- watch: true,
- module: {
- loaders: [{
- test: /index.js/,
- exclude: /(node_modules)/,
- loader: 'babel',
- query: {
- presets: ['es2015', 'stage-3'],
- plugins: [
- ["transform-runtime", {
- "polyfill":false,
- "regenerator":true
- }]
- ]
- }
- }]
- }
- }
跑完之后写个页面在浏览器运行一下, 打开 console, 可以看到
返回的结果有两个图片对象, 是我们期望的.
再看看 network, 检查下是否是并发的:
ok, 搞定.
one more thing
其实到上面那一步关于 async/await 异步加载图片的相关东西已经讲完了, 这里我们回过头来看下生成的文件, 会发现特别的大, 就那么几行代码生成的文件居然有 80k.
把 webpack 具体打了哪些包打印出来看看:
其中, 我们本来的 index.js 只有 4.08k , 但是 webpack 为了支持 async/await 打包了一个 24k 的 runtime.js 文件, 除此之外为了支持 es6 语法还打包了一大堆别的文件进去.
如果你在打包的时候使用了 babel-polyfill 最后出来的文件可以达到可怕的 200k.
于是我想起了 TypeScript.
TypeScript 具有优秀的自编译能力, 不需要额外引入 babel, 而且比 babel 做的更好. 以我上面的代码为例, 安装 TypeScript 之后, 不需要任何修改, 只要把后缀名改成 ts, 直接就可以开始编译.
来感受一下:
bundle-ts.js 就是用 TypeScript 编译出来的, 只有 5.5k.
看一下编译出来的文件中 async/await 的实现, 不到 40 行, 干净利落.
TypeScript 编译出的文件跟你使用了多少特性有关系, 而 bable 可能一开始就会给你打包一堆进去, 即使你现在还没用到, 而且一些实现上 TypeScript 也要比 bable 更好.
当然, 这里并不是说用 TypeScript 就一定比 bable 好, 还是要根据项目实际情况来, 但 TypeScript 绝对值得你去花时间了解一下.
总结
有时候我们不能单从表面看问题, 而要从一个事情的演化来看, 比如 async/await 咋一看异步, 就认为加了就异步, 这样很容易走入误区. 有空多想想背后的故事, 会有更深刻的认识, 你我共勉.
相关代码
https://github.com/bob-chen/demos/tree/master/async-await-loadimgs
碎碎念
记录一些所思所想, 写写科技与人文, 写写生活状态, 写写读书心得, 主要是扯淡和感悟. 欢迎关注, 交流.
参考文章
Generator 函数的含义与用法 http://www.ruanyifeng.com/blog/2015/04/generator.html
async 函数的含义和用法 http://www.ruanyifeng.com/blog/2015/05/async.html
来源: https://juejin.im/post/5ae92c1b518825670b33e2ab