分类 基础知识 下的文章

问题描述

输入一个字符串,输出去重的全排列(如果有重复的)。这意味着,您必须按所有可能的顺序对输入中的所有字母进行排列。

说明范例

permutations('a'); // ['a']
permutations('ab'); // ['ab', 'ba']
permutations('aabb'); // ['aabb', 'abab', 'abba', 'baab', 'baba', 'bbaa']

分析

所谓排列,就是指从给定个数的元素中取出指定个数的元素进行排序,本题目相当于全排列。两个元素的全排列$$A_2^2$$=$$2!$$=2,即[i,j]的全排列为[ij,ji]。那么如果是$$A_3^3$$=$$3!$$=6是怎么样的过程,假设有[a,b,c],先选择a作为第一个元素,剩下[b,c]相当于一个$$A_2^2$$,接下来选b作为第一个元素,剩下[a,c]相当于一个$$A_2^2$$,以此类推。

求无重复元素集合的排列数量

假设没有重复元素的情况,等价子问题应当是:对子串中每个元素做首元素的情况,进行其他元素的全排列。因此就有以下的实现:

/**
 * idea1 先处理没有重复字母的情况
 * 注意:这里为了方便处理,str其实是str.split("")
 * 如果str不包含重复字母,数量关系上应该是:permutations(str) = str.length * permutations(str*)
 * 理论上递归的终止条件是str.length === 1,此时应当返回1
 * @param str
 */
function permutations(str) {
  if (str.length === 1) {
    return 1;
  }
  let result = 0;
  // 理论上求str组合数,要讨论str每一个元素做起始元素的情况,所以循环str是一定的
  for (let i = 0; i < str.length; i++) {
    let newArr = [...str];
    newArr.splice(i, 1);
    result += permutations(newArr);
  }

  return result;
}

其实“每个元素做首元素”的方式有很多,上述代码中通过创建元素组拷贝,从中splice掉作为首元素的元素,剩下的数列进行递归。这个过程或产生临时数组,试图先对这个问题进行一些优化。

不产生临时变量就要求我们在原数组上进行操作,将str[i]提到str[0],原来的str[0]到str[i-1]的元素分别后移,这种情况需要一个临时变量来进行移动。但是实际上我们并不需要str[0]到str[i-1]的元素都移动,因为我们是求全排列,输入的集合顺序并不产生影响。那么我们只需要将str[0]和str[i]进行对换,这时也只需要一个临时变量。

解决了做首元素的问题,那么如何解决对剩余元素进行递归呢?我们不适用临时数组后,递归调用总是使用原数组,那么当前调用总是针对数组的“剩余部分”--str[step]-str[str.length-1],因为str[0]-str[step-1]总是我们已经确定的序列,所以我们做一个标记,来让当前调用知道递归到了什么程度。

因此我们有以下实现:

function swap(str, a, b) {
  let temp = str[a];
  str[a] = str[b];
  str[b] = temp;
}

/**
 * idea2 在idea1的前提下,优化临时数组的问题
 * @param str
 * @param step 标记递归到了哪一步,默认是0
 */
function permutations2(str, step = 0) {
  if (str.length === step + 1) {
    // console.log(str.join(""))
    return 1;
  }
  let result = 0;
  // 本次调用针对是的str[step]到str[str.length-1]的子串
  for (let i = step; i < str.length; i++) {
    // 对换来得到除了首字母以外的剩余部分
    swap(str, step, i );
    // 针对剩下的部分
    result += permutations2(str, step + 1);
  }

  return result;
}

由于我们还没有解决存在重复字母的问题,permutations2(['a','b','c'])=6,permutations2(['a','b','c','d'])=24,permutations2(['a','b','c','d','e'])=120。看起求全排列的数量似乎并没有问题,但是我们如果在递归结束条件时,输出此时的排列序列,就会发现我们的全排列是有重复的。

我们来一步一步的分析一下。输入str=['a','b','c','d']:

no-swap-again.jpg

求无重复元素集合的具体排列

其实很容易发现问题,step=1的第一阶段递归,第一轮循环针对的是[b,c,d]这个子序列,而第二轮循环变成了[d,b,c],虽然元素没有变(无序集合中元素是相同的),但是顺序改变了。如果把每次递归针对的子序列看做无序集合,那么排列数是不会错的;如果把每次递归针对的子序列看做是有序集合,那么排序就会有问题。如果不需要输出具体的排列情况,理论上这个算法就没问题,但是如果需要输出具体的排列情况,我们需要把swap这个过程,在递归完成后逆转回来,保证每一轮循环起始的有序集合不发生变化。

每一次递归都会对换一次元素,当递归达到终止条件时,正好得到一个排列,我们将其保存在一个数组中,这个数组随着递归收集所有的结果,最终返回。既能查看所有的排列,数组的长度表达排列数量,于是有了下面的代码:

function swap(str, a, b) {
  let temp = str[a];
  str[a] = str[b];
  str[b] = temp;
}

/**
 * idea2 在idea1的前提下,优化临时数组的问题
 * @param str
 * @param step 标记递归到了哪一步,默认是0
 * @param result 保存结果的数组
 */
function permutations2(str, step = 0, result = []) {
  if (str.length === step + 1) {
    result.push(str.join(""));
    return;
  }
  // 本次调用针对是的str[step]到str[str.length-1]的子串
  for (let i = step; i < str.length; i++) {
    // 对换来得到除了首字母以外的剩余部分
    swap(str, step, i );
    // 针对剩下的部分
    permutations2(str, step + 1, result);
    swap(str, step, i );
  }
  return result
}

求任意集合的具体排列

最后我们来解决存在重复元素的情况。我们假设重复的元素是$$str[m]$$和$$str[n]$$,显然$$m \neq n$$,令$$m<n$$。在这个前提下,我们进行到p阶段,判断swap(p,i)是否在将来会遇到等价的情况,如果遇到则跳过。

当p===i时,原始序列肯定不能跳过;

当p<i时,

​ 如果str[p]===str[i]应当跳过,因为至少是和swap(p,p)是等价的。

​ 如果str[p]!==str[i],则需要从i+1开始寻找q使str[q]===str[i],如果有则跳过。

那么可以得到以下判断函数:

function isEqual(arr, step, i) {
  // 自我对换不处理
  if (step === i) {
    return false
  }
  // 至少和str[step]的自我对换是等价的
  if (arr[step] === arr[i]) {
    return true;
  }
  // 从i+1开始寻找,如果有和str[i],则把交换过程放到swap(step,k)
  for (let k = i + 1; k < arr.length; k++) {
    if (arr[k] === arr[i]) {
      return true;
    }
  }
  return false;
}

除了这种判断方式,在众多的代码中还可以看到这样的判断方式:

function isEqual2(arr, step, i) {
  for (let p = step; p < i; p++) {
    if (arr[p] === arr[i]) {
      return true;
    }
  }
  return false;
}

核心是从step遍历到i-1,看看是否存在和str[i]相同的元素。step和i相等时,无法构成循环,所以始终为不相同;step和i不相同时,在step到i-1的范围内查找是否有与str[i]相同的元素,有的话就认为这个对换已经被使用过了。其实swap(step,i)的根本含义是用str[i]做step阶段子序列新的首元素,那么只用判断str[i]是否已经在当前子序列靠前的位置中出现过。

对比来看,isEqual考量的是str[i]如果还有机会当首元素,就等下一次机会吧。当step和i相等时,后面有重复的元素,导致认为还有机会,实际上自对换只有一次。如果step和i不相等,str[step]和str[i]相等,但之后并没有重复的元素,所以认为这样的对换之后不会发生,之后是不会发生,但是其本身就是无效对换。因此我们补充了这两种特殊情况完成判断。

总结

排列问题应该是算法的基础题,很容易发现重复子问题,但是去重的思想有多种,且要考虑的情况比较多,也就有了复杂的判断isEqual和简单一些的判断isEqual2。求排列数是一道典型的递归型算法,使用递归就要想好递归的终止条件,且这个问题能够分切成等价的一个个子问题(求子序列的全排列数)。本文从求排列数开始,到求排列结果,到求去重的排列结果,每一步我都有我的理解方式,也许笨重,也许绕了弯子,但是既然掌握的不扎实,就从最笨的方法一点点掌握,希望我能慢慢熟练掌握这样的思考。

前言

有啥前言啊,突如其来的两个电面,打出了我最真实的水平。废话不多说了。。。

整体内容偏向于前端基础,广度感人,面试官非常nice,并没有到了中途感觉自己是个废物,然后直接挂掉电话去角落哭。整个过程三十分钟,我把我记下来的问题,和我当时的心路历程和回答抛出来,希望能够记录当时的自己有多傻。当然了,总结经验和教训才是这篇博客的主旨,问题驱动的学习看起来功利,多少也是收获。

Q1:聊聊你对于Redux和Vuex的使用经验及看法

我的回答:redux和vuex的功能都是提供统一的状态管理,用法上都是针对组件进行需要的数据注入,vuex的mapState,redux的connect。vuex的状态树核心还是一个vue实例,state对应到data,getter对应到computed。redux的话没有再深入了解。

写在这里:我是一个主要使用vue的同学,vuex的源码也看过一些,反正知道的都说了。react算是自己想了解,redux也只是停留在了使用的层面。所以说用不用在生产上可能不重要,有没有自己写出来的项目也不那么重要,重要的是了解一些原理,谁知道啥时候就用上了。

A1:对比分析法

先允许我摘抄一段尤大在知乎上的回答:

Vuex 其实是一个针对 Vue 特化的 Flux,主要是为了配合 Vue 本身的响应式机制。当然吸取了一些 Redux 的特点,比如单状态树和便于测试和热重载的 API,但是也选择性的放弃了一些在 Vue 的场景下并不契合的特性,比如强制的 immutability(在保证了每一次状态变化都能追踪的情况下强制的 immutability 带来的收益就很有限了)、为了同构而设计得较为繁琐的 API、必须依赖第三方库才能相对高效率地获得状态树的局部状态等等(相比之下 Vuex 直接用 Vue 本身的计算属性就可以)

所以 Vue + Vuex 会更简洁,也不需要考虑性能问题,代价就是 Vuex 只能和 Vue 配合。Vue + Redux 也不是不可以,但是 Redux 作为一个泛用的实现和 Vue 的契合度肯定不如 Vuex。

从这一段我们可以抽取出一些比较高级别的观点:

  • Vuex相对于Redux的共性:单一状态树,便于测试和热重载的API。
  • Vuex相对于Redux的差异:没有强制的immutability,没有复杂的API,不依赖第三方库就可以获取局部状态(这里应该说的是Redux只提供了一个getState方法获取整个状态树,而Vuex通过内涵的Vue实例的computed就可以实现)。
  • Vuex只能配合Vue,而“Redux is a predictable state container for JavaScript apps.”

从两者的工作流程上来对比:

  • Vuex:视图层dispatch-actions或commit-mutations,mutate-state,state-render
  • Redux:视图层dispatch-actions,reducer消费actions并返回新的store-render

后续的任务肯定是再深入的了解Redux乃至Flux的工作原理。Vuex的源码倒是读过一些,整理好了也会发出来。

Q2:Vue是如何实现响应式的,收集依赖是怎么做的,computed是怎么实现

我的回答:Vue的响应式核心是Object.defineProperty,通过对响应式数据设置getter和setter,并在编译模板和处理computed属性时收集依赖,作为数据的订阅者,setter方法被触发时,能够主动通知到这些依赖使其产生响应。编译模板时,对于v-model指令指明的数据以来关系,则会产生一个数据与虚拟DOM之间的依赖,数据变更时,会通过patch的过程,将响应产生变化的虚拟DOM渲染到页面上。computed答得烂七八杂我也不记得。

A2:先熟悉官方文档中“深入响应式原理”一章

首先,我们在编写组件时,统一导出的是一个普通的对象,解析.vue文件时,编译模板,基于这个对象创建Vue实例。对于data属性,通常是个函数,返回实例所需的状态集合,遍历这个集合使用Object.defineProperty把这些状态转化为getter/setter。官方文档也提示了,Object.defineProperty不可以被转化到ES5,所以IE8无法支持。

对组件内的状态,也就是data赋值会触发setter方法,而读取则会触发getter方法,这个过程是隐含的。而响应式则通过watcher实例实现,组件渲染过程中肯定会访问data,这就会触发getter,从而触发watcher收集到一个依赖关系,而后当被依赖的数据被重新赋值,触发setter,就会通知watcher,按照之前收集的依赖关系进行通知,触发组件的render函数。我们编写的模板部分,就相当于render函数。

由于创建getter/setter是在创建Vue实例的阶段执行的,如果对实例追加属性,无法使其正确的产生getter/setter,也不会让watcher感知到它与其他数据的关联,可以使用Vue.set,或者Vue.prototype.$set来实现。

异步更新队列可能也会被问到,同一个EventLoop,或者说某一段不包含一部代码的逻辑,可能会有多个数据更新,多次触发watcher,这些更改会被推出一个队列,这个队列中的更改会进行去重,并尝试使用Promise.then、MutationObserver、setImmediate甚至setTimeout(fn,0)来代替。注意这些方法有些是宏任务,有些是微任务,但是都是为了实现Vue渲染的一个“tick”。所以Vue提供了nextTick函数来将修改推入下一个更新队列,防止更新被合并。

注意:不可被监听的变化包括1.数组中原始类型元素的值的变化。2.数组的length变化。这里解释一下为什么,defineProperty这个API,看名字就知道是给某个对象定义一个属性,而属性其实是包含元属性的,就是描述这个属性的属性。就用数组的length属性,这个属性具有什么特性呢--writable:true可写、enumerable:false不可被枚举、configurable:false不可被配置。对于[[Configurable]]的描述如下:

If false, the property can't be deleted, can't be changed to an accessor property and attributes other than [[Value]] and [[Writable]] can't be changed.

不能被delete操作符删除,不能被变更为访问器属性,[[Value]]和[[Writable]]的值也不能修改。所以length不能转换成getter/setter,也就没法收集依赖和通知watcher。

至于computed属性,通常我们只会用到getter,其实也可以设置它的setter,比如:

{
  data: {
    firstName: 'Foo',
    lastName: 'Bar'
  },
  computed: {
    fullName: {
      get(){
         return this.firstName + ' ' + this.lastName
      },
      set(newValue): {
        const [firstName, lastName] = newValue.split(" ");
        this.firstName = firstName;
        this.lastName = lastName;
      }
    }
  }
}

这样看起来更像是一个看得到getter/setter的可响应的一个属性,而相比data中的属性,computed的初始值往往不是确定的,而是依赖关系得到的,而这个初始值,或者说变化后的值,都是延迟求值,用到时才会调用get来更新一下自己。且具有缓存性。

这里先简单聊一下computed的创建与工作方式,稍后我们再来通过源码来详细聊聊。

Vue的响应式离不开Watcher、Observe和Dep。初始化computed时,会相应的对每一个computed字段创建一个内部watcher,即_computedWatchers集合。然后通过defineComputed函数,将computed字段转换成访问器属性挂载到Vue实例上。而defineComputed定义的get访问器,会从_computedWatchers中拿出对应的watcher,如果有依赖则调用evaluate方法进行懒求值,然后返回计算结果。更多的细节,稍后会更新一篇Vue响应式原理源码分析的文章,我相信会在Watcher的部分看到端倪。

Q3:Vue3相比Vue2做出了哪些改变,Proxy有什么优势

我的回答:根据我看的新闻和技术分享,Vue3中最主要的变化就是使用Proxy取代Object.defineProperty作为响应式的底层技术实现。其他的我也不熟悉,怕说了被深究,就说了这一点。Proxy的优势能够监听更多的变化,具体有哪些我记不得了。真是拉胯,知道Proxy至少证明我还是看过技术新闻的。

A3:回顾尤大的发布会PPT

Vue3就目前公开的消息来看,最明显的还是Proxy取代Object.defineProperty。除此之外,通过回顾发布会的ppt,我们还应当知道以下特性:

  • 全局API,内置组件和功能支持tree-shaking,也就是说可以部分引用,而不用担心将整个Vue引入
  • 基于Proxy的变动侦测,性能更优
  • 代码采用monorepo,简单来说就是像乐高积木的一样的项目
  • TypeScript
  • 开放更多底层功能,如自定义渲染函数
  • 模板静态分析,生成更优化的VDOM树,静态节点和动态节点分离,更新时直接便利动态节点
  • Function-based API,类似React的函数组件,逻辑复用更容易,相比mixin更明了,更好的类型支持,更好的tree-shaking。mixin使用的属性来源不清晰,被mixin的组件也容易知道如何配合;高阶组件的props来源不明确;composition functions就是简单的函数,让组件以功能层面得到拆解,而不是生命周期。

除了上述内容,更多详情请看:github.com/vuejs/rfcs

下面再来说说Proxy。Proxy是一个完全不支持IE的特性,作用是基于target目标对象,使用handler作为代理行为,创建一个代理实例。使用代理实例访问相应的属性,行为将被handler截获并处理。关于Proxy的详细内容稍后也可以写一篇文章,这里先简单讨论一下。

Proxy的handler可以设置以下属性:

  • handler.getPrototypeOf():拦截Object.getPrototypeOf操作
  • handler.setPrototypeOf():拦截Object.setPrototypeOf操作
  • handler.isExtensible():拦截Object.isExtensible操作
  • handler.preventExtensions():拦截Object.preventExtensions操作
  • handler.getOwnPropertyDescriptor():拦截Object.getOwnPropertyDescriptor操作
  • handler.defineProperty():拦截Object.defineProperty操作
  • handler.has():拦截in操作符
  • handler.get():拦截访问操作
  • handler.set():拦截赋值操作,这里可以参见Reflect.set,对于数组中的元素赋值也可以被拦截,这里是普通setter做不到的。而且数组的length属性,也在set范围内。
  • handler.deleteProperty():拦截delete操作符
  • handler.ownKeys():拦截Object.getOwnPropertyNames和Object.getOwnPropertySymbols
  • handler.apply():拦截函数的调用
  • handler.construct():拦截new操作符

小结

现总结了前三个问题,可以发现对于Vue的响应式原理仍然只是一知半解,没有对于源码进行深入的观察和分析,只能得到诸如Object.defineProperty设置访问器属性、收集依赖、通知变化等零零散散的内容。事实上这里还应该注重一些概念的细化,比如观察者模式和订阅发布模式的区别,Vue的watcher用的是哪一种;Proxy元编程能力到底如何,具体哪些算作set操作,借助其他的拦截功能是不是可以实现更多功能。

除了这些细节和具体的知识点要更进一步的深入了解外,应用层的功能和工作方式的对比,也是学习的一种基本技能。Vuex和Redux都提供状态管理功能,使用上能够根据经验说个一二三点,但是差异性往往蕴含更多内容。Vuex只能结合Vue使用,因为核心的响应式状态树是依赖Vue实例实现的;Redux虽然知道action和reducer,但是对局部状态树的获取,与组件的链接关系还不甚明确。

这些问题都是和两大主流视图渲染库息息相关的问题,同时也生发出了一些基础知识,如设计模式和Proxy等知识点。在使用工具的过程中,多去探求3W--是什么(Proxy是什么)&为什么(为什么要使用状态管理)&如何(视图如何根据data进行变化),除了广度也要有深度,这是我在面试和学习中得到的一条真理。同时面试官也对我提出了很中肯的建议,不管是内建还是外显知识体系,都要有一个清晰的思路,每个问题说明白,有条理,这样才不会错过什么细节。

本系列题目源自于:Github

参考其解析和相关知识点记录自己的思考学习过程

题目

以下代码输出什么:

function sayHi() {
  console.log(name)
  console.log(age)
  var name = 'Lydia'
  let age = 21
}

sayHi()
  • A: Lydiaundefined
  • B: LydiaReferenceError
  • C: ReferenceError21
  • D: undefinedReferenceError

答案

D

主要知识点是let声明变量存在的暂时性死区问题。var声明的变量的作用域是整个封闭函数,在同一个封闭函数内,重复使用var声明同名的变量,对应的是同一个。var声明的变量会被提升,即一开始就会占好内存,只是并没有定义类型和值,也就是undefined,直到程序执行到为其赋值的地方。let和const声明的变量则为了保证变量得到正确的使用,即使是在非严格模式下,也通过暂时性死区来保证变量初始化之前无法被使用。

解析

let、const和块级作用域

let 语句声明一个块级作用域的本地变量,并且可选的将其初始化为一个值。

在ES6之前,JavaScript的变量作用域只有全局作用域和函数作用域,所以也只需要var进行变量声明。然而块级作用域基本上是其他语言必备的特性之一,全局作用域和函数作用域显然是不够的。在没有块级作用域的情况下,任何除了函数体的大括号界定都没有新的作用域,和外层函数共享作用域,使得内部用var声明的变量会被提升到函数体的开头,尽管这个变量并不会在其他的地方使用。如果函数体结构复杂,很有可能会造成变量重复声明,导致逻辑上错误。

块语句是JavaScript的一个基础特性,也是大部分变成语言用于区分逻辑分区的有力特征,比如if(){ Statement }for(){ Statement }等,以大括号界定的零个或多个语句的组合。通过var声明的变量不存在块级作用域,即块语句中的var声明仍然不受约束的提升到外层去。

ES6提出了letconst关键字,声明具有块级作用域的变量,这里先只谈let(把const理解成必须赋值且不能更改的let就行)。let和var最直接的对比:

// example_1.js
var x = 1;
{
  var x = 2;
}
console.log(x); // 输出 2

// example_2.js
let x = 1;
{
  let x = 2;
}
console.log(x); // 输出 1

在相同作用域下使用let或者const,声明同名变量,是会报错的。除了明显的大括号产生的作用域之外,if的条件语句,for的循环设置,都被算在下属的作用域内。尤其是使用for时,使用var声明的迭代标记,很有可能干扰外部的变量,或者出现延迟求值的情况。比如官网的例子:

var a = [];
for (var i = 0; i < 10; i++) {
      a[i] = function () {console.log(i);};
}
a[0]();                // 10
a[1]();                // 10
a[6]();                // 10

/********************/

var a = [];
for (let i = 0; i < 10; i++) {
      a[i] = function () {console.log(i);};
}
a[0]();                // 0
a[1]();                // 1
a[6]();                // 6

由于var声明的变量被提升,循环体内创建的闭包并没有办法保存i的瞬间值。使用let声明的变量在块级作用域内能强制执行更新变量

暂时性死区

有了作用域规则后,大概就能理解var和let的提升情况。首先要知道的就是,var和let(以及const)声明的变量都会提升,主要区别就是提升的位置不同。另外一点就是新的概念暂时性死区(TDZ)。作用域内使用let或const声明的变量,在被初始化之前,都在暂时性死区中不可用。

使用let声明的变量受到块级作用域的约束,可以用于创建私有的成员变量。比如官网的例子:

var Thing;

{
  let privateScope = new WeakMap();
  let counter = 0;

  Thing = function() {
    this.someProperty = 'foo';
    
    privateScope.set(this, {
      hidden: ++counter,
    });
  };

  Thing.prototype.showPublic = function() {
    return this.someProperty;
  };

  Thing.prototype.showPrivate = function() {
    return privateScope.get(this).hidden;
  };
}

首先在外部privateScope和counter这两个变量时不可访问的。利用WeakMap的特性,对象可以作键,将需要隐藏的属性放在一个外部不可访问的容器中,提供一个对外的访问方法,在闭包中可以拿到这个容器,来避免外部直接访问想要隐藏的属性,同时隐藏了修改的途径。当然了,使用函数闭包的方式也可以用var来模拟私有变量,利用的是var逃不出函数作用域的特性。

var Thing;

(function () {
  var privateScope = new WeakMap();
  var counter = 0;

  Thing = function () {
    this.someProperty = "foo";

    privateScope.set(this, {
      hidden: ++counter
    });
  };

  Thing.prototype.showPublic = function () {
    return this.someProperty;
  };

  Thing.prototype.showPrivate = function () {
    return privateScope.get(this).hidden;
  }
})();
// 利用IIFE来初始化Thing,外部同样不可见私有属性

对于var来说,同一作用域内不存在重复声明的问题,但是let和const是不允许的,会抛出语法错误。并且要注意一下语法结构所隐含的作用域。

  • if条件结构:不同的分支有自己的作用域
  • switch条件结构:所有的case和default在不使用块语句时共用一个作用域
  • for循环结构:整个共用一个作用域,初始化条件声明的变量在循环体内可用

因为有了这些限制,是的let和const的提升行为和var有了明显的不同。var的提升可以认为是声明和赋值被分开了,但是声明确确实实一开始就做了,占用了该占用的内存。而let和const不一样,一定在定义被执行时才会初始化,初始化之前会导致引用错误。这样的变量在作用域顶端进入“暂时性死区”,处于不可访问的状态。这样听上去很奇怪,就跟没有被提升一样,但是用typeof你会发现并不是这样。又是官网的例子:

// prints out 'undefined'
console.log(typeof undeclaredVariable);
// results in a 'ReferenceError'
console.log(typeof i);
let i = 10;

变量undeclaredVariable确实没有被声明,使用typeof判断类型会得到undefined,而通过let声明的变量i,在初始化之前尝试探测其类型,一样会得到引用异常。

undefined是一个全局属性,即global.undefined或window.undefined。其值是原始值undefined原始值undefined会自动赋值给那些只是被声明的变量,或者是那些没有实际传参的形式参数(因为函数的形参相当于声明的临时变量)。typeof操作符用于返回操作数的类型,有三种类型的变量类型是undefined--全局属性undefined、声明但未赋值的变量和完全未声明的变量。全局属性undefined和声明但未赋值的变量,类型是undefined可以理解,因为他们相当于在内存中占了个坑,但是里面空空如也,完全不知道是什么。而对于没有声明过的变量,在内存中找了一遍都没有找到,就更不知道是什么了。

再来说ReferenceError,在严格模式下操作了一个未声明的变量,会抛出这个异常。根据上面的例子发现,非严格模式下尝试探测暂时性死区内变量的类型也会产生该异常。对于已声明和未声明的变量,主要差异在于:

  • 未声明变量总是在全局的,而已声明的变量总是约束在特定的上下文中
  • 已声明的变量在相关上下文开始之前就已经创建了(所以var和let声明的变量都是被提升的)
  • 已声明的变量时其执行上下文的不可配置属性

所以let和const声明的变量更加严格,非严格模式下,未声明的变量可以直接进行赋值等操作,间接产生了全局变量,这非常不安全。而使用var声明的变量直接被提升,检查其类型由于没有被初始化所以是undefined,初始化之前的赋值又会被覆盖,可能会产生迷惑。因此let和const在兼顾块级作用域的前提下,使用暂时性死区的特性,来将这些数据的提升保护起来。提升理论上应该只是程序执行的优化,而不应该在编程中影响应有的逻辑,所以暂时性死区更加严格。

暂时性死区真的超严格

当var和let相遇了会发生什么。来看官网的例子:

function test(){
   var foo = 33;
   if (true) {
      let foo = (foo + 55); // ReferenceError
   }
}
test();

test()函数作用域下用var声明并初始化了变量foo,在if的条件语句块儿中,用let声明了一个变量foo,并将其赋值为表达式(foo+55)的值。这时候要先考虑声明提升导致的暂时性死区,因为在上下文中寻找变量遵循就近原则。if条件语句块中有let声明,就应当考虑到会锁定就近foo变量,这时候在计算(foo+55)就会产生访问暂时性死区中变量的情况,会严格抛出异常,而不是用外部的foo进行计算并赋值。

另一种情况发生在for...of结构中,使用变量迭代时,也会出现暂时性死区问题。比如官网的例子:

function go(n) {
  // n here is defined!
  console.log(n); // Object {a: [1,2,3]}

  for (let n of n.a) { // ReferenceError
    console.log(n);
  }
}

go({a: [1, 2, 3]});

将for循环结构拆开,我们大概会得到以下的内容:

let values = n.a.values();
let iterator = values.next(); // 迭代器行为
while(!iterator.done) {
  let n = iterator.value;
  console.log(n);
  iterator = values.next();
}

实际上的上下文环境不会像上面代码一样,只是拆开方便讲解步骤,把上面的几行代码都看作一个上下文环境来分析。let n会将n提升,然后会访问n的a属性,这样就不难发现暂时性死区的存在了。

除此之外,还要注意的就是,var声明并赋值的变量不受块级作用域的约束,会影响到块级作用域以外的变量,以下这种情况会产生重复声明的异常:

let x = 1;

if (true) {
  var x = 2; // SyntaxError for re-declaration
}

条件语句块中的var声明和外部的let声明重复了,所以会抛出异常,如果外部也是使用var声明的,则不会产生异常,且条件语句块中的赋值会覆盖外面的赋值。

总结

本题目的分析思路,从变量是否可访问出发。不考虑变量提升(我始终认为变量提升不应当影响编程逻辑),var声明的变量要求比较宽松,你在初始化之前访问,认为就是一个不存在的变量,所以就是输出undefined。而let声明的变量对作用域有更高的要求,结合新的特性“暂时性死区”,相当于开启了严格模式,你不可以在变量被赋值前就访问。至于说要考虑提升,你可以认为程序在执行的时候,你声明的变量都要提前占坑,即使你不赋值,内存得先占住,直到你赋值的时候才会往内存中设置值。其实从“声明”两个字,就应该知道声明变量是一个提前准备的过程。

可迭代协议

这个协议约束对象定义自己的的迭代行为,for...of这种循环结构,就是在被循环对象上进行迭代访问,这就依赖其定制的迭代行为。类似Array和Map都有自己的迭代行为,普通的对象并没有提前定义迭代行为。可迭代对象要求实现@@iterator方法,即访问对象的[Symbol.iterator]属性必须是可行的,对象本身或者其原型链上必须有这个属性。

[Symbol.iterator]这个属性的值应当是一个函数,返回一个符合迭代器协议的对象。需要被迭代的时候,@@iterator方法被无参调用,返回一个“迭代器实例”,用这个实例来获取每次访问的值。

可迭代协议实际上是规定了可迭代与不可迭代对象之间的差异,由于JavaScript没有Interface(接口)的概念,必须要通过协议的方式,来约束具有特定行为的对象应当具备的特性。

迭代器协议

可迭代协议是要求对象在需要被迭代时,如何实现自己的迭代行为;迭代器协议则是约定了一种标准的方式,可以产生有限或无限序列的值,还要求在迭代的最终总会有一个默认的返回值。所以说迭代器协议,是定义了怎样才算是一个迭代器。可以参考下面的迭代器一节。

迭代器可以显示的调用next()来逐个获取值,并通过done属性来判断是否完成,这个动作就是一个迭代行为。所以一般来说可迭代协议和迭代器协议是同时实现的。用迭代器协议的实现,作为可迭代的默认行为。最简单的迭代器:

var myIterator = {
    next: function() {
        // 自己定义如何返回下一个值,相当于决定访问序列的顺序和限度
    },
    [Symbol.iterator]: function() { return this } // 自身就是符合迭代器协议的对象
}

由于[Symbol.iterator]这个属性通常是不会显示的访问,且next()方法也有可能不会暴露出来,比如Array的实例,可以for...of进行迭代,但是next()并不是直接访问的,所以提供了一些方法来将使用的迭代器暴露出来。

let values = [1,2,3,4,5].values(); // 这里values就是数组内涵的迭代器
// 这里还可以使用.entries()来返回一个迭代器,这个迭代器的值是[index, ele]这种格式的
let n = values.next();
while(!n.done) {
  console.log(n.value); // 依次打印序列中的每一个值,打印出最后一个元素后,n.done === true,结束遍历
  n = values.next();
}

另外,for...in也是一种循环结构,一般针对对象的属性进行遍历(只会遍历那些“可遍历”的属性,具体可以参见defineProperty),但实际上一个普通的对象并不是可迭代的。for...in可以用作数组的遍历,但是顺序可能无法保证。

一些例子

可迭代协议里要求了@@iterator的实现,所以可以用这一点来判断一个对象是否可迭代:

let ss = "hello";
typeof ss[Symbol.iterator]; // 这里应当返回"function"

let iterator = ss[Symbol.iterator](); // 获取到字符串的迭代器实例
// console.log(iterator.toString()) 这里会返回[object String Iterator],表示它确实是一个迭代器

iterator.next(); // {value: 'h', done: false} 
// 显示的调用next(),可以发现字符串实际上是字符的序列
// 可迭代的对象一般都可以使用展开语法
[...ss]; // 你会得到['h','e','l','l','o'] 

为了让用户自己实现可迭代协议,[Symbol.iterator]属性是可以修改的,因此也可以覆盖已经实现的迭代行为:

let ss = new String("hello"); // 这里必须使用显示构造String实例,避免自动装箱
ss[Symbol.iterator] = function() {
  // 返回一个实现迭代器协议的对象
  return {
    next() {
      // 必须返回{value: any, done: boolean}
      return {value: "+" + ss[this._index++], done: this._index >= ss.length}
    },
    _index: 0
  }
};
console.log(iterator.next().value); // "+h"
console.log([...ss]); // [ '+h', '+e', '+l', '+l' ] 

对于string,number和boolean这三种基础数据类型,js有对应的三种包装类型String,Number和Boolean,但你以字面量的形式声明了变量,如let ss = "hello",并执行了ss.substring(2),String.prototype.substring实际上是String封装类实例的方法,所以JavaScript会创建对应的包装类型,然后调用指定的方法,然后会销毁掉的。所以如果不显示的创建包装类型,你是无法覆盖[Symbol.iterator]的,因为都覆盖到临时的那个装箱变量上。

迭代器

原文

In JavaScript an iterator is an object which defines a sequence and potentially a return value upon its termination

在JavaScript中,迭代器是这样一个对象:定义了一个序列,在终止时可能会返回一个值。更确切的说,迭代器是实现了迭代协议,要求是拥有一个next()方法,这个方法返回一个对象,包含value属性--序列中下一个值,和done属性--true表示序列中最后一个值已经被访问过。如果valuedone都有,其被称为迭代器的返回值。

迭代器一旦被创建,可以通过重复的调用next属性来显式的迭代。迭代器的迭代过程可以被称为是“消耗迭代器”,因为通常这个过程只执行一次。(原文比较拗口,其实就是表达迭代一个集合,相当于单程的地铁,一次经过一个站)。当最终值已经产生,额外再调用next()应当只是返回{done:true}(只有done属性没有value属性,已经不能算作迭代器的返回值了)。

JavaScript中最常见的迭代器就是数组迭代器,单纯的按照顺序返回关联数组中的每一个值。很容易去想象,所有的迭代器都可以被描述成数组,但实时并非如此。数组必须按照体量完整分配空间,但是迭代器只在需要的时候才被消耗,因此可能描述成一个不限大小的序列,比如0到无穷之间所有的整数形成的集合(即特定情况下,迭代器的next()是可以无限调用下去,永远有下一个值)。

下面用一个例子来说明。这个例子可以创建一个简单的范围迭代器,根据提供的起始点(start和end,左闭右开区间)来创建一个整数步长序列。最终返回值是创建序列的大小(iterationCount)。

// start 包含左端点 end 不包含右端点 step 整数间隔步长
function makeRangeIterator(start = 0, end = Infinity, step = 1) {
    let nextIndex = start;
      // 闭包内的用于计数的变量
    let iterationCount = 0;
        
      // 具备迭代器的定义特性,有一个next方法,next返回的对象有value属性和done属性
    const rangeIterator = {
       next: function() {
           let result;
           if (nextIndex < end) {
               result = { value: nextIndex, done: false }
               nextIndex += step;
               iterationCount++;
               return result;
           }
           return { value: iterationCount, done: true }
       }
    };
    return rangeIterator;
}

// 使用方法
let it = makeRangeIterator(1, 10, 2);

let result = it.next();
while (!result.done) {
 console.log(result.value); // 1 3 5 7 9
 result = it.next();
}

console.log("Iterated over sequence of size: ", result.value); // [5 numbers returned, that took interval in between: 0 to 10]

注:无法通过反射判断对象是否可迭代

生成器函数

迭代器next方法的实现决定了如何访问集合中的元素,对于集合的操作需要非常谨慎,不能因为一个迭代的行为而导致集合产生了不可预期的影响。除此之外,next方法调用的时机不确定,两次next的调用期间,要维护集合的稳定。所以迭代器对于内部状态的维护非常重要。生成器函数是迭代器的一个可选替代方式,通过定义一个不连续执行的函数,定义迭代算法。

生成器函数的语法

function* funcName(...args): Generator {}

生成器函数的行为

调用生成器函数并不会执行函数体的任何代码,而是返回一个特殊的迭代器Generator--生成器。生成器调用next方法想要消耗一个值时,生成器函数体才会执行,并且在遇到yield时停下,将yield的操作数作为value返回,如果将所有的yield都消耗完了,将返回{value: undefined, done: true}作为迭代的终止。

多次调用生成器函数会得到全新的生成器,相互之间不会相互影响,但是都只能迭代一遍。

之所以叫Generator生成器,就是因为它能在调用next()时,按照既定的规则生成一个值,而不一定需要既定集合去迭代。yield单词本身有“产生,产出”的含义,作为关键字其操作数会作为迭代的结果被“产生”。

来自官网的例子:

function* makeRangeIterator(start = 0, end = 100, step = 1) {
    for (let i = start; i < end; i += step) {
        yield i;
    }
}

用工厂方法的形式也可以实现类似的功能:

function rangeIteratorFactory(start = 0, end = 100, step = 1) {
  return {
    next() {
      let done = this._currentValue >= end,
        result = {
          value: done ? undefined : this._currentValue,
          done
        };

      this._currentValue += step;

      return result;
    },
    _currentValue: start
  }
}

但是返回的终究是个普通的对象,只是实现了迭代器协议,正确使用也可以得到预期的结果。

高级生成器

生成器返回一个迭代器,按函数体中yield来完成迭代。每次调用next都会回到函数体上次结束的位置继续执行,直到下个yield语句。对于外部来说,调用next来获取,或者说消费下一个值,特定次数的调用,理论上结果应该是一样的。比如一个斐波那契数列生成器,使用一个无限循环来求数列的下一项:

function* fibonacci() {
  var fn1 = 0;
  var fn2 = 1;
  while (true) {  
    var current = fn1;
    fn1 = fn2;
    fn2 = current + fn1;
    yield current;
  }
}

只要通过这个生成器获得的迭代器,第三次调用next()都会返回value为1的结果。而生成器的工作流程可以发现有一个回到上次执行结尾的特性,相当于我们调用next()时,会从生成器函数上一次yield的位置之后继续执行。所以有的时候用一个“执行权”的概念来描述生成器函数的执行。借官网的一段代码:

let sequence = fibonacci();
console.log(sequence.next().value);     // 0
console.log(sequence.next().value);     // 1
console.log(sequence.next().value);     // 1

首先获取了生成器对应的迭代器,第一次调用next(),“执行权”从生成器函数的首行开始,初始化fn1和fn2,进入循环,创建变量current,赋值为fn1的值,操作fn1和fn2(分别为1和1),遇到yield将current的值组装到迭代器返回的结果中,将“执行权”交回到第一次调用next()的调用处,访问value属性并打印。“执行权”交出去了,但是执行环境还是保留着的,所以到第二次调用next()时,“执行权”回到生成器函数中上次停下的地方,即fn1和fn2分别为1和1的时候继续执行,回到循环头,再声明一个current并赋值,操作fn1和fn2(分别为1和2),遇到yield将current返回,再次交出“执行权”,外部得到结果1。后面的就依次类推。

不难发现“执行权”交回的时候,外部并无法影响其内部逻辑,对于斐波那契数列来说,将无限迭代下去。除非我们重新获取一个迭代器,否则我们无法重新开始。所以迭代器的next()函数,可以传入一个参数,用于执行权交回的时候,给定一个信号。这个信号在执行权交回的时候,作为yield语句的值,可能参与到后续的逻辑,所以我们再从官网拿个例子:

function* fibonacci() {
  var fn1 = 0;
  var fn2 = 1;
  while (true) {  
    var current = fn1;
    fn1 = fn2;
    fn2 = current + fn1;
    var reset = yield current;
    if (reset) {
        fn1 = 0;
        fn2 = 1;
    }
  }
}

当我们调用next(true)时,变量reset将被赋值为true,后续就可以重启序列了。生成器函数中yield将其操作数求值作为迭代的value返回,而next()传入的参数则作为执行权交回生成器函数式,整个yield语句的值,参与到后续的逻辑中。

除此之外,生成器函数返回的迭代器,通过调用throw()来制造一个异常,在生成器函数内部被捕捉,产生异常和异常的处理并不在一起。

yield*

yield* [[expression]];

这个表达式可以认为是迭代器的嵌套,用另一个迭代器的迭代情况来补充自身的迭代。拿一个官网的例子:

function* g1() {
  yield 2;
  yield 3;
  yield 4;
}

function* g2() {
  yield 1;
  yield* g1();
  yield 5;
}

在遇到yield*时,将从其操作数中进行迭代。也可以认为是将执行权进行一次传递。可以将“产生”这一动作委托给其他的可迭代对象,你可以yield* [1,2],则从数组中依次产生值。

生成器函数与异步

前面有提到生成器函数的行为,可以用执行权的交换来理解,这已经有异步的意味在里面。异步与同步相对,不阻塞线程来完成任务,宏观上看,一个任务的执行,往往不是在一段连续的时间内执行并完成的。而生成器函数的函数体,在调用next()时,可以从上次停下的地方继续执行,也就是说,整个函数体就是一个异步任务,在特定的时候停下交出执行权,又在特定的时候得到执行权继续完成。

JavaScript引擎是在单线程上执行的,为了实现异步编程,通常采用回调函数、事件监听、Pub/Sub和Promise等方式实现。协程的概念在很多语言中,也是异步编程的方案之一。协程也被当做轻量的线程,简单来看就像是函数间的切换执行。Generator函数就类似协程,通过yield来交出执行权,暂停自身的执行,等到特定的时候继续,所以说生成器函数体就是一个异步任务。生成器函数返回的迭代器,就相当于这个异步任务执行的控制器,调用next()来让生成器函数逐步执行,并且返回阶段性的信息,value分段执行的结果,done任务是否完成。

生成器函数可以停止可以恢复,是封装异步任务的基础,除此之外还为他设计了数据交换的方式(next().value和向next()传入参数),异常的产生与捕捉,都为生成器函数模式的异步编程提供支持。

用生成器函数封装异步任务

生成器函数利用执行权交换的特性,可以改变异步任务的宏观流程,偷了一个例子过来:

function* gen () {
  var url = 'https://api.github.com/users/github'
  var result = yield fetch(url)
  console.log(result.bio)
}

var g = gen()
var result = g.next()

result
  .value
  .then(function (data) {
    return data.json()
  })
  .then(function (data) {
    g.next(data)
  })

可以看到gen函数体,不看yield基本上就是一个同步的处理流程,g.next()启动,拿到fetch返回的promise实例,在then方法中对数据进行json化,再通过next(data)将执行权交回,所以让gen的函数体看上去符合一般的逻辑流程。但是这样的写法会有一定的混淆,因为真正异步任务完成和结果被处理并不在一起。

Thunk函数

Thunk在专业释义中表达为“形(式)实(在)转换程序”。JavaScript中Thunk函数是高阶函数的一种,通常是用来将已有的函数,结合新的参数,形成新的函数。总的来说Thunk的作用是“替换”,偷一个例子来看:

fs.readFile(fileName, callback);

var Thunk = function (fileName) {
  return function (callback) {
    return fs.readFile(fileName, callback);
  };
};

var readFileThunk = Thunk(fileName);
readFileThunk(callback);

readFile本来是一个多参数函数,Thunk将fileName作为替换内容,生成一个只接受callback的函数。所以你在调用readFileThunk时,其实都是提前将fileName参数替换掉的过程,或者说将readFile的调用替换掉了。具体可以参见Thunk的详细说明,简单理解成“传名调用”也可以,是一种替换表达式的实现方式。

其实Thunk函数的最内层return一定是要被替换的最原始多参数函数,然后用嵌套return function()来创造上下文接受部分参数,作为上层的替换结果。由于每一层return function()将多参数打散,是的在调用的时候会有Thunk(fileName)(callback)这样的情况,直到最后一个调用才真正执行,而前面的调用,相当于将参数进行缓存,有一个延迟求值的作用。

生成器函数的执行控制

从生成器函数的行为特性可以看到,整个执行流程实际上非常依赖外部对于next的调用,如果外部不知道生成器内部需要多次完成,可能还需要外部通过永真循环来让生成器函数彻底执行完。并且生成器函数包含多个异步操作时,后一阶段要依赖上一阶段,外部可能就需要进行then的嵌套。所以这个嵌套可以用递归的方式使其自动完成,而不需要知道实际有几个阶段。偷了一个例子:

function run(fn) {
  var gen = fn();

  function next(err, data) {
    var result = gen.next(data);
    if (result.done) return;
    result.value(next);
  }

  next();
}

function* g() {
  // ...
}

run(g);

run()方法接受一个生成器函数,函数体内先得到生成器对应的迭代器gen,定义递归函数next(),这个函数的作用就是控制迭代器消耗。不难发现这个next()其实就是一个指令,告诉生成器函数生成异步操作完成后继续向后就是了。控制生成器函数的执行,就是控制好执行权的转移,上面的例子是通过回调函数递归实现自动的执行权转移,换成Promise也可以通过then进行递归实现。

生成器函数实际上就是异步操作的容器,遇到异步操作将执行权交出,完成后应当重新得到执行权进行下一个异步此操作。所以生成器函数的自动执行的关键,就是在异步操作完成时能够主动的交还执行权。上面的例子中,就是通过next()方法的递归来实现自动的交还。总结起来,1. 回调函数型异步操作,包装成Thunk函数,回调中交还执行权。2. Promise型异步操作,then方法注册的回调进行交还。

async/await 函数

AsyncFunction

async function(){} 就是一个AsyncFunction的实例。Object.getPrototypeOf(async function(){}).constructor可以获取构造方法(虽然这样做并没有意义)。

let AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;

// 使用语法,用不用new都可以,且不创建闭包,上下文始终是全局
AsyncFunction(...args:any[], functionBody:string): function;

这个并不重要,只是提一下。

异步函数是通过事件循环异步执行的函数(因为一般的函数都作为单独的调用栈同步执行),隐式返回Promise代表其执行结果。异步函数语法和结构上更像是同步执行流程,比如官网的例子:

function resolveAfter2Seconds() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('resolved');
    }, 2000);
  });
}

async function asyncCall() {
  console.log('calling');
  var result = await resolveAfter2Seconds();
  console.log(result);
  // expected output: 'resolved'
}

asyncCall();

asyncCall的逻辑理解顺序,是等到resolveAfter2Seconds()函数对应的Promise解决了之后得到结果result在继续执行。已经看到了生成器函数的影子,await实际将执行权交出去了,promise解决了以后将执行权交回并继续。所以从流程上看更像是同步的。

语法

async function name(...args) { statements }
async (...args) => { statements }
async function(...args) { statements }

let ob = {
  async func(...args) { statements }
}

可用于匿名函数和箭头函数。

隐式返回一个Promise实例,函数体执行完成则对应resolve,函数体异常则对应reject。

描述

异步函数才可以包含await指令,该指令实际上会暂停异步函数的执行,交出执行权,待await指定的操作完成(可以参见Promise的解决)继续执行。所以只关注异步函数的流程,看上去变成了同步的过程,实际上通过执行权的转移,仍是异步的完成。实际上async/await只是展开了Promise.then嵌套回调的格式,不改变具体的执行顺序。

await原意就是“等待,期待”,在异步执行函数中,表达“当等待的东西得到结果了,我在继续执行”。

异步执行函数的return会隐式的传递给Promise.resolve,所以return await promiseValue和return promiseValue都是可以的,但是逻辑上是完全不同的。

和生成器函数的关系

一说async/await是生成器函数的语法糖。偷一下阮一峰的例子:

const readFile = function (fileName) {
  return new Promise(function (resolve, reject) {
    fs.readFile(fileName, function(error, data) {
      if (error) return reject(error);
      resolve(data);
    });
  });
};

const gen = function* () {
  const f1 = yield readFile('/etc/fstab');
  const f2 = yield readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

const asyncReadFile = async function () {
  const f1 = await readFile('/etc/fstab');
  const f2 = await readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

function*和async function对应,await和yield对应。不过生成器函数返回的是迭代器,且自动执行需要显示操作,而异步执行函数隐式返回promise是自动完成的。异步执行函数像普通函数一样直接调用,就会自动执行,不需要生成器函数那样的自己实现。且async/await有更好的理解,流程上更加清晰。同时也可以将async function理解成将函数体包装成promise,而await则是将then绑定的回调进行展开。如果把await之后的语句当做当前promise.then绑定的回调,就可以发现其实多个异步操作包含在异步执行函数中,是嵌套的promise。

异常处理

普通promise异步操作用then处理结果,用catch处理异常,多个异步操作也可以用Promise.all进行包装处理。如果异步执行函数中使用await移交了执行权,解决异常就应当类似生成器函数中处理异常,使用try...catch结构来捕捉。

注意

虽然async/await并没有改变异步执行的特性,但是执行顺序是被确定的,所以连续的await如果不存在依赖关系(使用Promise形式不存在嵌套时),最好不要都使用await,因为异步操作的启动相当于是阻塞的。后面的操作启动的要更晚,这明显不利于异步处理效率。这种情况用await Promise.all()更有效率。

再循环中使用await也要注意,在借用阮一峰的几个例子:

function dbFuc(db) {
  let docs = [{}, {}, {}];

  docs.forEach(async function (doc) {
    await db.post(doc);
  });
}

async function dbFuc(db) {
  let docs = [{}, {}, {}];

  for (let doc of docs) {
    await db.post(doc);
  }
}

第一个函数可能得到错误的结果,因为forEach是并发的,针对docs的每个元素执行异步函数。但第二个函数中,显示的等待post的结果,会阻塞for循环,从而确保结果的正确。

另外,由于async/await具有生成器函数的执行特性,遇到await会将执行权交出去,并保留自己的执行上下文,使得特定情况下函数的上下文得以保留,比如阮一峰的例子:

const a1 = () => {
  b().then(() => c());
};

const a2 = async () => {
  await b();
  c();
};

调用a1以后,b()执行一个异步操作,绑定回调()=>c(),此时就不再等待,继续完成a1的执行。绑定的回调也是等事件循环来调度。而a2调用后,将执行权交到b()的异步操作,a2也并没有执行完,上下文也保留着。当然a1中绑定的回调由于创建了闭包,也能保留a1中必要的变量等。

async的生成器函数实现

不难看出异步执行函数就是自动执行的生成器函数,所以可以用一下方式来实现:

async function fn(args) {
  // ...
}

// 等同于

function fn(args) {
  return spawn(function* () {
    // ...
  });
}

function spawn(genF) {
  // 隐式返回一个promise
  return new Promise(function(resolve, reject) {
    const gen = genF();
    // 定义递归函数自动执行
    function step(nextF) {
      let next;
      // 异常处理
      try {
        next = nextF();
      } catch(e) {
        return reject(e);
      }
      if(next.done) {
        return resolve(next.value);
      }
      Promise.resolve(next.value).then(function(v) {
        step(function() { return gen.next(v); });
      }, function(e) {
        step(function() { return gen.throw(e); });
      });
    }
    step(function() { return gen.next(undefined); });
  });
}

实现了可迭代协议的对象就是可迭代对象,在for...of这种结构中,使用其迭代行为来访问集合中的值。比如Array和Map的实例都是拥有内置迭代行为的对象。Array的实例,调用[Symbol.iterator]()和values()都会得到遍历元素的迭代器,entries()可以参见文档。根据可迭代协议,@@iterator必须实现,对象本身或者其原型链上必须带有符合协议的[Symbol.iterator]属性。生成器函数返回迭代器实例,所以也可以用在[Symbol.iterator]属性上。

An open standard for sound, interoperable JavaScript promises—by implementers, for implementers.

先给定个性:Promises/A+是一个开放的标准。按此标准实现可靠的、可互操作的JavaScript promise。所以Promises/A+相当于一个约定的规则,只要按照这个规则实现的promise实际上是通用的。(毕竟与JS相关的差异点已经数不胜数了


首先

promise表示异步操作的最终结果。then方法是与Promise交互的主要方式,注册回调来接受Promise的最终值或失败的原因。

给Promise一个大体上的表述:代表异步操作的最终结果(未来的值),有一个then方法,then方法要接受回调,回调的签名大概是data => voidreason => void。既然promise是一个异步操作的最终结果,那么同步过程中应该是拿不到promise的状态,也改变不了Promise的进程。

其他内容大概就是说:then是promise可操作的基础,所以实现规范的Promise必须提供then,A+规范是A规范的更进一步的版本,规范不会牵扯创建、完成和拒绝promise,主要关注then。

技术术语

  • promise:是一个带有行为符合本规范的then方法的object或function。
  • thenable:是一个object或function,定义了一个then方法。
  • value:(值)是任何JavaScript的合法值,包含undefined,thenable或promise。
  • exception:是一个通过throw语句抛出的值。
  • reason:是一个代表promise被拒绝原因的值。

主要是value(值)可以是所有JS合法值,包括undefined,对应resolve()。reason的定义也是一个值,所以reject也可以传递任何值。而thenable是一个很奇怪的定义,拥有then方法的对象或方法,主要是因为这样的对象可能可以被转换为promise。

要求

状态的要求

promise必须处于pending(进行中),fulfilled(已完成)和rejected(已拒绝)三种状态之一。

  1. 当promise处于pending状态时:

    • 可以转换成fulfilledrejected状态。
  2. 当promise处于fulfilled状态时:

    • 不能再转换状态。
    • 必须有一个不可变的值,即promise最终代表的值。
  3. 当promise处于rejected状态时:

    • 不能再转换状态。
    • 必须有一个不可变的值,代表被拒绝的原因。

不可变的值由“===”确定,所以不要求深层次不可变。白话文就是最终值如果是值类型就值不变,引用类型就引用不变。但是不要求引用类型内部字段的不变。这个要求主要是因为异步操作的结果是多少就是多少,比如网络请求得到什么值就是什么值,读取文件读到什么就是什么。也就是说如果promise的task如果包含两个异步操作,结果返回时会调用resolve,如果没有这个要求,那么promise得到一个返回时进入fulfilled状态,另一个再返回将promise的最终值修改了,这对于then注册的函数来说是不可思议的。所以resolve是唯一且屏蔽的(reject也类似)

then方法的要求

promise必须提供then方法访问其当前/最终的值/原因。promise的then方法要接受两个参数:

promise.then(onFulfilled, onRejected)
  1. onFulfilled和onRejected都是可选的,代表着你不想处理promise的最终结果或被拒绝的原因。如果不是function将会被忽略,代表着你要处理就好好处理。
  2. 如果onFulfilled是一个function:

    • 必须在promise完成后调用,传入promise的值作为第一参数。
    • 不能在promise完成之前调用。
    • 只能调用一次
  3. 如果onRejected是一个function:

    • 必须要promise拒绝后调用,传入被拒绝的原因作为第一参数。
    • 不能再promise被拒绝之前调用。
    • 只能调用一次
  4. 在执行上下文堆栈仅包含平台代码之前,不得调用onFulfilled或onRejected【1】。先简单理解为:onFulfilled和onRejected会被加入MicroTask队列
  5. onFulfilled和onRejected作为函数执行【2】,一般this指向global,严格模式为undefined。
  6. then方法可以在同一个promise上重复调用:

    • 如果/当promise完成时,所有相应的onFulfilled必须按照多个then注册的顺序执行。
    • 如果/当promise拒绝时,所有相应的onRejected必须按照多个then注册的顺序执行。
  7. then方法必须返回一个promise【3】:

    promise2 = promise1.then(onFulfilled, onRejected);
    • 如果onFulfilled或onRejected返回一个值x,要执行Promise处理流程[[Resolve]](promise2, x)
    • 如果onFulfilled或onRejected抛出一个异常e,promise2必须拒绝,且原因为e。
    • 如果onFulfilled不是一个function,且promise1完成了,promise2也将完成,且和promise1拥有相同的值。
    • 如果onRejected不是一个function,且promise1拒绝了,promise2也将拒绝,且和promise1拥有相同的原因。

关键点一个是onFulfilled和onRejected的调用顺序及调用次数,一个是then要返回promise。promise的状态转移的确定性,要求onFulfilled或onRejected回调在特定的时机,只执行一次。多次执行代表着状态转移的不确定性。

Promise Resolution Procedure

我称其为Promise解析流程。这是一个抽象的操作,将promise和value作为输入,表达为[[Resolve]](promise, x)。如果x是一个thenable,假设x的行为与promise有一定程度上的相似,它会尝试去使用x的状态创建一个promise。否则的话它将用x完成promise。

只要thenable暴露一个符合Promise/A+规范的then方法,就可以将thenable在一定程度上当做promise对待,让promise的具体实现具备互操作性。

[[Resolve]](promise, x)执行以下步骤(用伪代码说明):

let then;
if(promise === x) {
    reject promise with TypeError;
}

if(x is Promise) {
    // 如果x是一个promise,接受x的状态[4]
    switch(x.state) {
        case 'pending':
            promise.state = 'pending'; // 直到x的状态发生变化
            break;
    case 'fulfilled':
        promise.state = 'fulfilled';
        promise.value = x.value;
        break;
    case 'rejected':
        promise.state = 'rejected';
        promise.reason = x.reason;
        break;
    }
} if(x is Object || x is Function) {
    // 如果x是一个对象或者函数,这里主要针对thenable
  try {
      then = x.then // [5]
  } catch(e) {
      // 如果x没有then方法,或者获取x.then的时候报错
      reject promise with e;
  }
  
  if(then is Function) {
      try{
      then.call(x, resolvePromise, rejectPromise);
      // 如果then方法执行过程中,调用了resolvePromise(y),则执行[[Resolve]](promise, y)
      // 如果then方法执行过程中,调用了rejectPromise(e),则reject promise with e
      // 如果resolvePromise和rejectPromise都被调用,多单个方法被多次调用,以第一个调用为主,之后都被忽略
      } catch(e) {
          // 如果已经调用过resolveP或rejectPromise,忽略e
          reject promise with e;
      }
  } else {
      // 如果then不是一个方法,即x.then不是一个方法
      fulfill promise with x;
  }
} else {
    // 如果x不是function
    fulfill promise with x;
}

如果参与循环thenable链的thenable解决了promise,那么[[Resolve]](promise, thenable)会产生无限递归,这种情况下的拒绝可以自行实现,但是不是必须的【6】。如果x是这样的object:

let x = {
  then(resolve, reject) {
    resolve(x); // 会产生[[Resolve]](promise, x),从而产生无限递归
  }
}
new Promise(resolve => resolve(x));
Promise.resovle(x) // 真的会引起死循环

备注

  1. 解释一下“平台代码”:引擎、环境和promise实现的代码。实际上,因为promise代表一个异步操作的最终值,所以onFulfilled和onRejected都是异步执行的,在当前执行栈的末尾,下次事件循环的开始,也就是MicroTask微任务级别。

This can be implemented with either a “macro-task” mechanism such as setTimeout or setImmediate, or with a “micro-task” mechanism such as MutationObserver or process.nextTick.

注意:这里列举了setTimeout和setImmediate(仅node)为宏任务,除此之外setInterval,requestAnimationFrame(仅浏览器)和I/O事件触发回调,都算作宏任务。同时还列举了MutationObserver(仅浏览器)和process.nextTick(仅node)为微任务,Promise.then/catch/finally也加入微任务大军。(finally不要求一定实现)

  1. 什么叫called as functions,即以函数的身份执行。我们说“函数”是不依赖调用对象的function,而“方法”应当是依赖调用对象的,如浏览器的全局函数atob,Math类的静态方法random。onFulfilled和onRejected执行时,this一般指向global,严格模式下为undefined。
  2. then方法返回的promise在一定条件下可以和原promise相同,即promise2 === promise1,只是要指明条件。
  3. 一般来说,“if x is a promise”是指x是当前实现下的promise实例,所以“adopt the state”也是在特定于实现的前提下采用状态。
  4. “let then = x.then”是指先保存x.then的引用,然后测试该引用,再调用该引用,主要是为了防止多次访问x.then属性。

Such precautions are important for ensuring consistency in the face of an accessor property, whose value could change between retrievals.

这句话的含义并不明确,大概是说保存一个引用,可以防止访问者属性在检索(也就是取用)过程中的变化,暂时没有想到这种变化是怎样的。

  1. 对于x.then会产生无限递归的情况,promise实现不能对thenable链设定随意的深度限制,并假设超过此限制的递归是无线递归。只有在promise和x为同一个引用的时候,才能认为TypeError(即真正的死循环)。如果是由不同的thenable组成的,那无穷递归是正确的行为。