eightHundreds / web-clipper-store

网页采集工具存储区域
5 stars 0 forks source link

漫话JavaScript与异步·第三话——Generator:化异步为同步 - 大唐西域都护 - 博客园 #44

Open eightHundreds opened 3 years ago

eightHundreds commented 3 years ago

一、Promise 并非完美

我在上一话中介绍了Promise,这种模式增强了事件订阅机制,很好地解决了控制反转带来的信任问题、硬编码回调执行顺序造成的 “回调金字塔” 问题,无疑大大提高了前端开发体验。但有了 Promise 就能完美地解决异步问题了吗?并没有。

首先,Promise 仍然需要通过 then 方法注册回调,虽然只有一层,但沿着 Promise 链一长串写下来,还是有些让人头晕。

更大的问题在于 Promise 的错误处理比较麻烦,因为 Promise 链中抛出的错误会一直传到链尾,但在链尾捕获的错误却不一定清楚来源。而且,链中抛出的错误会 fail 掉后面的整个 Promise 链,如果要在链中及时捕获并处理错误,就需要给每个 Promise 注册一个错误处理回调。噢,又是一堆回调!

那么最理想的异步写法是怎样的呢?像同步语句那样直观地按顺序执行,却又不会阻塞主线程,最好还能用 try-catch 直接捕捉抛出的错误。也就是说,“化异步为同步”!

痴心妄想?

我在第一话里提到,异步和同步之间的鸿沟在于:同步语句的执行时机是 “现在”,而异步语句的执行时机在 “未来”。为了填平鸿沟,如果一个异步操作要写成同步的形式,那么同步代码就必须有 “等待”的能力,等到 “未来” 变成 “现在” 的那一刻,再继续执行后面的语句。

在不阻塞主线程的前提下,这可能吗?

听起来不太可能。幸好,Generator(生成器)为 JS 带来了这种超能力!

二、“暂停 / 继续” 魔法

ES6 引入的新特性中,Generator 可能是其中最强大也最难理解的之一,即使看了阮一峰老师列举的大量示例代码,知道了它的全部 API,也仍是不得要领,这是因为 Generator 的行为方式突破了我们所熟知的 JS 运行规则。可一旦掌握了它,它就能赋予我们巨大的能量,极大地提升代码质量、开发效率,以及 FEer 的幸福指数。

我们先来简单回顾一下,ES6 之前的 JS 运行规则是怎样的呢?

  1. JS 是单线程执行,只有一个主线程

2. 宿主环境提供了一个事件队列,随着事件被触发,相应的回调函数被放入队列,排队等待执行 

3. 函数内的代码从上到下顺序执行;如果遇到函数调用,就先进入被调用的函数执行,待其返回后,用返回值替代函数调用语句,然后继续顺序执行

对于一个 FEer 来说,日常开发中理解到这个程度已经够用了,直到他尝试使用 Generator……

function* gen() {let count \= 0; while(true) { let msg \= yield ++count; console.log(msg); } }

let iter \= gen(); console.log(iter.next().value); // 1 console.log(iter.next('magic').value); // 'magic' // 2

等等,gen 明明是个 function,执行它时却不执行里面的代码,而是返回一个 Iterator 对象?代码执行到 yield 处竟然可以暂停?暂停以后,竟然可以恢复继续执行?说好的单线程呢?另外,暂停 / 恢复执行时,还可以传出 / 传入数据?怎么肥四?难道 ES6 对 JS 做了什么魔改?

其实 Generator 并没有改变 JS 运行的基本规则,不过套用上面的 naive JS 观已经不足以解释其实现逻辑了,是时候掏出长年在书架上吃灰的计算机基础,重温那些考完试就忘掉的知识。

三、法力的秘密——栈与堆

(注:这个部分包含了大量的个人理解,未必准确,欢迎指教)

理解 Generator 的关键点在于理解函数执行时,内存里发生了什么

一个 JS 程序的内存分为代码区、栈区、堆区和队列区,从MDN借图一张以说明(图中没有画出代码区):

队列(Queue)就是 FEer 所熟知的事件循环队列。

代码区保存着全部 JS 源代码被引擎编译成的机器码(以 V8 为例)。

栈(stack)保存着每个函数执行所需的上下文,一个栈元素被称为一个栈帧,一个栈帧对应一个函数。

对于引用类型的数据,在栈帧里只保存引用,而真正的数据存放在堆(Heap)里。堆与栈不同的是,栈内存由 JS 引擎自动管理,入栈时分配空间,出栈时回收,非常清楚明了;而堆是程序员通过 new 操作符手动向操作系统申请的内存空间(当然,用字面量语法创建对象也算),何时该回收没那么明晰,所以需要一套垃圾收集(GC)算法来专门做这件事。

扯了一堆预备知识,终于可以回到 Generator 的正题了:

普通函数在被调用时,JS 引擎会创建一个栈帧,在里面准备好局部变量函数参数临时值代码执行的位置(也就是说这个函数的第一行对应到代码区里的第几行机器码),在当前栈帧里设置好返回位置,然后将新帧压入栈顶。待函数执行结束后,这个栈帧将被弹出栈然后销毁,返回值会被传给上一个栈帧。

当执行到 yield 语句时,Generator 的栈帧同样会被弹出栈外,但 Generator 在这里耍了个花招——它在堆里保存了栈帧的引用(或拷贝)!这样当 iter.next 方法被调用时,JS 引擎便不会重新创建一个栈帧,而是把堆里的栈帧直接入栈。因为栈帧里保存了函数执行所需的全部上下文以及当前执行的位置,所以当这一切都被恢复如初之时,就好像程序从原本暂停的地方继续向前执行了。

而因为每次 yield 和 iter.next 都对应一次出栈和入栈,所以可以直接利用已有的栈机制,实现值的传出和传入

这就是 Generator 魔法背后的秘密!

四、终极方案:Promise+Generator

Generator 的这种特性对于异步来说,意味着什么呢?

意味着,我们终于获得了一种在不阻塞主线程的前提下实现 “同步等待” 的方法!

为便于说明,先上一段直接使用回调的代码:

let it = gen(); // 获得迭代器 function request() { ajax({ url: 'www.someurl.com', onSuccess(res){ it.next(res); // 恢复 Generator 运行,同时向其中塞入异步返回的结果 } }); } function* gen() {let response \= yield request(); console.log(response.text); }

it.next(); // 启动 Generator

注意 let response = yield request() 这行代码,是不是很有同步的感觉?就是这个 Feel!

我们来仔细分析下这段代码是如何运行的。首先,最后一行 it.next() 使得 Generator 内部的代码从头开始执行,执行到 yield 语句时,暂停,此时可以把 yield 想象成 return,Generator 的栈帧需要被弹出,会先计算 yield 右边的表达式,即执行 request 函数调用,以获得用于返回给上一级栈帧的值。当然 request 函数没有返回值,但它发送了一个异步 ajax 请求,并注册了一个 onSuccess 回调,表示在请求返回结果时,恢复 Generator 的栈帧并继续运行代码,并把结果作为参数塞给 Generator,准确地说是塞到 yield 所在的地方,这样 response 变量就获得了 ajax 的返回值。

可以看出,这里 yield 的功能设计得非常巧妙,好像它可以 “赋值” 给 response。

更妙的是,迭代器不但可以. next,还可以. throw,即把错误也抛入 Generator,让后者来处理。也就是说,在 Generator 里使用 try-catch 语句捕获异步错误,不再是梦!

先别急着激动,上面的代码还是 too young too simple,要真正发挥 Generator 处理异步的威力,还得结合他的好兄弟——Promise 一起上阵。代码如下:

function request() { // 此处的 request 返回的是一个 Promise return new Promise((resolve, reject) => { ajax({ url: 'www.someurl.com', onSuccess(res) { resolve(res); }, onFail(err) { reject(err); } }); }); }

let it \= gen(); let p \= it.next().value; // p 是 yield 返回的 Promise p.then(res => it.next(res), err \=> it.throw(err) // 发生错误时,将错误抛入生成器 ); function* gen() { try {let response \= yield request(); console.log(response.text); } catch (error) { console.log('Ooops,', error.message); // 可以捕获 Promise 抛进来的错误! } }

这种写法完美结合了 Promise 和 Generator 的优点,可以说是 FEer 们梦寐以求的超级武器。

但聪明的你一定看得出来,这种写法套路非常固定,当 Promise 对象一多时,就需要写许多类似于 p.then(res => ...., err => ...) 这样的重复语句,所以人们为了偷懒,就把这种套路给提炼成了一个更加精简的语法,那就是传说中的async/await

async funtion fetch() { try {let response \= await request(); // request 定义同上一端段示例代码 console.log(response.text); } catch (error) { console.log('Ooops,', error.message); } }

fetch();

这这这。。。就靠拢同步风格的程度而言,我觉得 async/await 已经到了登峰造极的地步~

顺便说一句,著名 Node.js 框架 Koa2 正是要求中间件使用这种写法,足见其强大和可爱。

前端们,擦亮手中的新锐武器,准备迎接来自异步的高难度挑战吧!

写在最后

距离发表第二话(Promise)已经过去大半年了,原本设想的终章——第三话(Generator),却迟迟未能动笔,因为笔者一直没能弄懂 Generator 这个行为怪异的家伙究竟是如何存在于 JS 世界的,又如何成为 “回调地狱” 的终极解决方案?直到回头弥补了一些计算机基础知识,才最终突破了理解上的障碍,把 Generator 的来龙去脉想清楚,从而敢应用到实际工作中。所以说,基础是很重要的,这是永不过时的真理。前端发展非常迅速,框架、工具日新月异,只有基础扎实,才能从容应对,任他风起云涌,我自稳坐钓鱼台。 https://www.cnblogs.com/leegent/p/8207246.html