究竟该如何学习与时俱进的 ES
Last updated
Last updated
JavaScript 语言规范始终在与时俱进,除了过于激进的 ES4 被“废除”之外,ES Next 始终茁壮发展。到如今,TC39(Technical Committee 39,JavaScript 委员会)已经明确表示每年更新一个版本,因此使用 ES Next 表示那些“正在演进、正在发展”的新特性集。
作为前端开发者,我们该如何看待每年一版的 ES Next,又该如何去保持学习呢?这一讲,我们就来谈谈 ES Next。我认为列举新特性没有价值,这些东西随处可见,更重要的是分析新特性的由来,剖析如何学习新特性,分析如何利用新特性。
相关知识点如下:
有很多人不幸患上了“JavaScript fatigue”,表示我“再也学不动了”,求“不要再更新”了。到底要不要更新?我们来从一处细节看看 ES Next 发展的必要性。
ES7 规范中定义了一个新的数组 API,签名如下:
它用起来就像这样:
从命名上就不难理解,这是判断数组中是否含有一个元素的方法,该方法最终返回一个布尔值。有的开发者可能会问,判断数组中是否含有一个元素,不是有很多现成的方法可以使用吗?我能列举出来很多:
难道这还不够吗?我们甚至完全可以实现一个“一模一样” 的 API:
对于任何 ES Next 的新特性,开发者若有疑问,都可以在 TC39 提议的 GitHub 中找到,这个也不例外。我们就来分析这一新特性的意义:首先,在语义上它直观明朗,这是 indexof 所无法取代的。当然还有更深层次的必要性和不可替代性。
我们认真审视 Array.prototype.includes 这个 API,它用来判断数组是否包含某一元素,那么“是否包含”必然有判断“是否相等”的逻辑。那么这个“相等”,又是如何定义的呢?最简单的,是 == 还是 ===?
这里可以说明的是:Array.prototype.indexOf 采用的是 === 比较,而Array.prototype.includes 不同,它采用了 SameValueZero() 比较。
SameValueZero() 是什么呢?这个是引擎内置的比较方式,并没有对外接口,其实现采用了 Map 和 Set。采用这种比较,最直接的收益就是可以判断 NaN:
因为:
而 SameValueZero() 却不受干扰,可以准确地判断 NaN === NaN。
这就是新特性区别于老传统的不同,很多都体现在细节上,需要开发者用心体会,这也是学习 ES Next 的“正确姿势”之一。
当然,新特性除了体现在这些细节上,也体现在更多更有意义的方面,比如异步处理,相信学习过前面课程的读者已经能有所体会了。想想异步处理从回调到 Promise,再到 generator 和 async/await,也许你就会明白语言发展的必要性。
前面我们已经通过剖析一个细节,为大家介绍了学习的“正确姿势”。除了认真、事无巨细以外,有的时候还需要一些“刨根问底”、“吹毛求疵”的态度。我们来进入场景。
Object Spread 和 Object.assign 在很多情况下做的事情是一致的,它们都属于 ES Next 的新特性,当然 Object Spread 更新。事实上,规范说明中,也告诉我们 “object spread”:{... obj} 和 Object.assign({},obj) 是等价的。
但是一定还具有区别。实际上,Object.assign() 将会修改它的第一个参数对象,这个修改可以触发其第一个参数对象的 setter。熟悉函数式编程,了解 React/Redux 技术栈的读者,可能会听说过“不可变性”的概念。从这个层面上讲,Object spread 操作符会创建一个对象副本,而不会修改任何值,这也许是更好的选择。
当然,喜欢“抬杠”的读者可以说,如果使用 Object.assign(),我们始终保证一个空对象作为第一个参数,也能实现同样的“不可变性”。话虽是如此,但是既然你“抬杠”,那我也“抬杠”,我就告诉你这么做的话,性能比 Object Spread 就差的比较多了。
采用 object-assign-vs-object-spread 提供的 benchmark:
得出结果:
使用 Object spread 性能要明显领先于 Object.assign。
我们再来分析一道思考题, “哪些场景下不适合使用 ES6 箭头函数”?
这个问题不是死板地考察队 ES Next 箭头函数的理解,而是反其道行之,考察其不适用的场景。 回答这个问题,我们思考:开发者习惯使用箭头函数来对 this 指向进行干预,那么反过来说,“不需要进行 this 指向干预的情况下,我们就不适合使用箭头函数”。总结下来,有:
构造函数的原型方法上
构造函数的原型方法需要通过 this 获得实例,因此箭头函数不可以出现在构造函数的原型方法上:
这样的做法是错误的。
需要获得 arguments 时
箭头函数不具有 arguments,因此在其函数体内无法访问这一特殊的伪数组,那么相关场景下也不适合使用箭头函数。
使用对象方法时
上述代码中,getName 函数体内的 this 指向 window,显然不符合其用意。
使用动态回调时
同理,类似下面这种对回调函数的 this 有特殊场景需求的用法,箭头函数的 this 无法满足要求:
当点击 id 为 btn 的按钮时,将会输出:true,事件绑定函数的 this 指向了 window,而无法获取事件对象。
“箭头函数”不适用的场景社区上也有相关文章分析,我个人认为这是一个很好的切入点。思考“哪些场景不适用”,不仅能够全面了解学习新特性,也能够和老知识融会贯通,可谓学习 ES Next 的正确姿势之一了。
可能有开发者有这样的体会:“ES Next” 那么多新特性,但是我使用的来来回回都是那么几项,很多感觉并用不上啊?
同时,讲了这么多细节,我们可以用新特性实现哪些很 cool 的操作呢?其实除了日常用到的新特性以外,一些不为大家所熟知的特性往往在框架开发,或者实现更深层次行为操作的场景中,应用比比皆是。比如 Proxy,它可以用来定义对象各种基本操作的自定义行为,比如 Vue 双向绑定的实现,就可以借助 Proxy 完成。
我们先来看一些简单的场景,借用上节课的例子:
我们对 Person 构造函数进行了代理,这样就可以防止非构造函数实例化的调用:
同样道理,也可以静默处理非构造函数实例化的调用,将其强制转换为 new 调用:
这样即便在不使用 new 关键字时,仍然可以得到 new 调用的实例:
另外一个场景:熟悉前端测试的读者,可能对断言 assert 并不陌生,一种常用的使用方式是:
我们看 assert 赋值语句右侧表达式结果为一个布尔值,当表达式成立时,断言不会抛出;如果 assert 赋值语句右侧表达式不成立时,也就是断言失败时,断言抛出错误。
乍看上去这是不是很神奇?如果面试过程中,面试官要求你实现一个 assert,该怎么做呢?这样一个断言库本质上还是拦截 assert 对象的赋值(set)操作:
这样我们只需要判读对 assert 的赋值值是否为 true,如果不为 true,则打印错误。
是不是很简单?这样我们就可以随意进行断言:
这些只是 Proxy 实现的一些很简单的例子,这里抛砖引玉,大家可以充分发挥想象力,创造更多的玩法。
除此之外,介绍给大家的就是 ES7 中的装饰器 Decorator。
装饰器(Decorators)让你可以在设计时对类和类的属性进行“注解”和修改。
说直白一些,Decorator 就是给类添加或者修改类的属性与方法的。这么听上去似乎跟我们刚刚介绍的 proxy 似乎有异曲同工之秒。一些开发者可能已经在使用 Decorator 了,这里我借助 autobind 这个类库的实现,介绍一下 Decorator 的玩法。
我们知道:
这里在执行 fn() 时,this 已经指向了 window,使用 autobind 可以完成对 this 的绑定:
那么 autobind 怎么实现呢?伪代码如下:
autobind 这个 decorator 接受以下三个参数。
target:目标对象,这里是作用于 Person 中的函数、属性的
key:属性名称
descriptor:属性原本的描述符
autobind decorator 函数最终返回描述符,这个描述符运行时相当于调用 Object.defineProperty() 修改原有属性,我们看最终修改的结果为:
这样在使用 get 赋值时(const fn = person.getPersonName),赋值结果通过 const boundFn = fn.bind(this) 进行对 this 绑定,并返回绑定 this 后的结果,因此达到了我们对 getPersonName 属性方法绑定 this 的目的。
这就是 decorator 在 autobind 这个库中的应用,这个库大家接触的不多,也许有 React 开发者使用 autobind 来对事件处理函数进行 this 绑定。总之,autobind 源码实现很好地利用了 decorator 特性。
为了能够使用到新鲜出炉的 ES Next 新特性,必不可少的一环就是 Babel,相信每个前端开发者都听说过它的大名。虽然 Babel 目前已经是个丰富的生态社区了,但是它刚出道时的目标,以及目前最核心的能力就是:编译 ES Next 代码,进行降级处理,进而规避了兼容性问题。
那么 Babel 编译到底是施展了什么魔法呢?它的核心原理是使用 AST(抽象语法树)将源码进行分析并转为目标代码,这中间的细节部分我们会在工程化章节中有所涉及。在上一讲中,我们已经对 ES6 class 的编译产出进行了分析,这里再分析一些比较典型的编译结果。
简单来说,const、let 一律转成 var。为了保证 const 的不可变性:Babel 如果在编译过程中发现对 const 声明的变量进行了二次赋值,将会直接报错,这样就在编译阶段进行了处理。至于 let 的块级概念,ES5 中,我们一般通过 IIFE 实现块级作用域,但是 Babel 处理非常取巧,那就是在块内给变量换一个名字,块外自然就无法访问到。
在之前的课程中我们介绍使用 let 或者 const 声明的变量,存在暂时性死区(TDZ)现象。简单回顾下:代码声明变量所在的区块中,会形成一个封闭区域。在这个区域中,只要是在声明变量前使用这些变量,就会报错。
将会报错:Uncaught ReferenceError: Cannot access 'foo' before initialization。
那么 Babel 怎么编译模拟这种行为呢?其实我们提到 Babel 编译会将 let、const 变量重新命名,同时在 JavaScript 严格模式(strict mode)不允许使用未声明的变量 ,这样在声明前使用这个变量,也会报错。如下代码:
我们加上严格模式的标记,自然就可以实现了 TDZ 的效果。
对于经典的 for 循环问题,Babel 的处理并不让我们感到意外:
为了保存每一个循环变量 i 的值,Babel 也使用了闭包:
细心的同学可能还会想到:使用 const 声明的变量一旦声明,其变量(内存地址)是不可改变的。
对此 Babel 的处理有比较有意思:
我们看编译结果,Babel 检测到 const 声明的变量被改变赋值,就会主动插入了一个 _readOnlyError
函数,并执行此函数。这个函数的执行内容就是报错,因此代码执行时就会直接抛出异常。
对于箭头函数的转换,也不难理解,看代码:
转换为:
通过 var _this2 = this;
保存当前环境的 this 为 _this2
,在调用 innerFunc 时,用新储存的 _this2
进行替换函数体内的 this 即可。
上面的内容中,我们介绍了 decorators 新特性,那么 Babel 又是怎么编译 decorators 的呢?
使用方式:
我们有一个名为 log 的 decorators,Babel 编译:
我们看这里主要依赖了 _applyDecoratedDescriptor 方法。这个方法将返回描述符 desc,具体执行逻辑为:先把所有 decorators 包装成一个数组,作为 _applyDecoratedDescriptor 方法的第三个参数传入,对于 decorators 这个数组,我们将 target、property、desc 作为参数,依次遍历执行数组中的每一个 decorator 函数。执行后返回每一个 decorator 产生的属性描述符。上述代码样例就是:decorators 这个数组只有一项:log。[log],遍历数组时,我们将 target、property、desc 作为参数传给 log 函数并执行:log(target, property, desc),返回结果即是新的属性描述符。
如果读者对于 decorators 特性能够熟练掌握,上述源码的理解并不困难。
再加上上一节课对 class 编译结果的分析,我们可以知道:Babel 并没有什么“深不可测”的魔法,感兴趣的读者可以翻看各种 ES Next 的编译结果,通过对编译结果的学习,对于基础的提高,具有帮助作用。
本小节对 Babel 编译结果的进行分析,“抛砖引玉”,希望感兴趣的读者可以自行研究更多内容。值得提醒大家的一个细节是:Babel 编译产出结果主要分为两种模式,normal 模式的转换更贴近 ES Next 的写法,力求编译转换的更少,更“激进”。而另一种模式,loose 模式则更贴近 ES5 或者现有 ES 老规范的写法,也就是说在兼容性上更加有保障,因此转换代码结果也可能会更加的复杂。
前两天本课程的核心群里,有读者问了一个关于 ES6 尾递归调用的问题。我解释了什么样的行为算是尾递归调用优化,什么行为不能算尾递归调用优化。
简而言之:递归非常耗费内存,也很容易发生“栈溢出”错误。但是对于尾递归来说,之所以可能形成优化,是因为全部执行过程中不会在调用栈上增加新的堆栈帧,而是直接更新调用栈,进而永远不会发生“栈溢出”错误。因此真正实现尾递归调用优化,最关键的是改写递归函数,确保最后只调用自身。
我们来看 fibonacci 数列求和的例子:
fibonacci 数列求和非常耗费内存,如果用尾递归进行优化:
我们看,每次调用 fibonacciTail 函数后,会继续递归调用 fibonacciTail,函数的 n 会依次递减 1,它实际上是用来记录递归剩余求和的次数。而 a 和 b 两个参数在每次递归时也会在计算后再次传入 fibonacciTail 函数,最终返回值为 a,a 是上一次 a + b 的结果。这样每次递归都不会增加调用栈的长度,只是更新当前的堆栈帧而已。也就避免了内存的浪费和爆栈的危险。
然而可惜的是,据我所知,很多浏览器引擎并没有支持尾递归调用优化,即便支持,也要求代码运行环境在 strict mode 下。
那么,对于不支持尾递归调用优化的场景,我们可以做些什么实现类似的优化呢?答案一般有两个:蹦床函数和改为循环。改为循环:
这样一来就不存在函数的多次调用。因此,将递归改为循环,是防止递归暴栈的重要优化点之一。
另外一个优化手段是使用蹦床函数,我们来看蹦床函数:
蹦床函数其实并没有实现真正的尾递归,它只是将整个执行过程拆散,还是类似循环的效果:每次产生一个结果,该结果将会对下一次执行产生影响,就像蹦床一样,越蹦越高。我们看蹦床函数接受一个函数作为参数,在蹦床函数内部执行这个函数,如果执行结果,也就是该函数的返回值还是一个函数,那么就继续执行。一直到返回值不再是一个函数时,我们返回最终的结果。
在使用蹦床函数时,我们的 fibonacci 函数需要进行一定的改动:
在使用时:
就能带到良好的优化效果。
这是一种比较“取巧”的方式,并不是实现了真正的尾递归调用优化。那么有没有真正实现尾递归调用优化的手段呢?答案也是有的:
同样,我们改动相应的 fibonacci 函数为:
我们观察整个实现过程,结合修改后的 fibonacciTailOpt 函数尝试理解:tailCallOpt 接受一个待优化的函数 func,返回一个新的 accumulator 函数。执行 fibonacciTailOpt(5) 就是第一步执行 accumulator。
第一次执行 accumulator 时,先将参数推入 accumulated 数组当中,started 标记为 true。然后进入 while 循环,循环中执行待优化的 func 函数,func 这个函数执行过程中需要保证调用 tailCallOpt 函数的返回值,这里为 fibonacciTailOpt;第二次执行 accumulator,将新的参数加入 accumulated 数组;这样 accumulated 数组长度始终不为零,循环继续进行。
整个过程就是 accumulated 数组放进去一个参数,执行一次,得到结果,accumulated 清空;再放进去新的参数,执行得到结果,accumulated 再清空,以此类推。直到 func 返回了基本类型值(非函数值),这时候 accumulated 数组不会再有新的参数进来,因此返回最终结果。
这是一个通用的尾递归调用优化的轮子实现。核心原理就是不增加调用栈,拆成调用单元去分布执行。理解起来相对晦涩。不过这只是一点延伸,和 ES Next 并不太强相关,读者简单了解一下即可。
JavaScript 语言、ES 规范总是在不断进步、发展,那么每个开发者都要做到时刻学习、跟进。在这个过程中,除了了解新特性之外,新老知识相结合,融会贯通,不断去思考“是什么”、“为什么”非常重要。这节课程挑选了几个典型的特性、分析了 Babel 编译结果、最后从尾调用优化展开,内容并不算太深,但却是一个很好的的切入角度。
希望大家能够掌握学习的正确“姿势”,保持好的心态,这也是进阶路上至关重要的一点。
阅读文章过程中有任何疑问随时可以跟其他小伙伴讨论,或者直接向作者 LucasHC 提问(作者看到后抽空回复)。 你的分享不仅帮助他人,更会提升自己。
你也可以说说自己最想了解的主题,课程内容会根据部分读者的意见和建议迭代和完善。
此外,我们为本课程付费读者创建了《前端开发核心知识进阶》微信交流群,以方便更有针对性地讨论课程相关问题(入群请到第1-2课末尾添加 GitChat 小助手伽利略的微信,并注明「前端核心」,谢谢~)