JavaScript如何实现let/const

调用栈、执行上下文、作用域、作用域链、闭包、this…

JavaScript为什么要引入let/const

  • 解决现有缺陷/问题
  • 提供新功能/特性

缺陷-变量提升(Hoisting)

  1. showName()
  2. console.log(myname)
  3. var myname = '极客时间'
  4. function showName() {
  5. console.log('函数 showName 被执行');
  6. }

什么是变量提升

先简单理解什么是声明、赋值

  1. var myname = '极客时间'
  1. var myname // 声明部分
  2. myname = '极客时间' // 赋值部分
  1. // 直接申明一个函数
  2. function foo(){
  3. console.log('foo')
  4. }
  5. // 先申明变量bar,再将函数赋值给bar
  6. var bar = function(){
  7. console.log('bar')
  8. }

所谓的变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的 undefined。

  1. var myname = undefined
  2. function showName() {
  3. console.log('showName 被调用');
  4. }
  5. showName()
  6. console.log(myname)
  7. myname = '极客时间'

笼统概括一下在定义之前使用变量或者函数的原因:函数和变量在执行之前都提升到了代码开头

变量提升时机、为什么?

JavaScript代码执行流程

实际上变量和函数声明在代码里的位置是不会改变的,而且是在编译阶段被 JavaScript 引擎放入内存中

JavaScript为什么要引入let/const? - 图1

编译阶段和变量提升有何关系?

JavaScript为什么要引入let/const? - 图2

输入一段代码,经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码

执行上下文是 JavaScript 执行一段代码时的运行环境,执行期间用到的诸如变量、对象、this以及函数都来自执行上下文

小结:

  1. 一段JavaScript代码的执行机制:先编译,再执行
  2. 变量提升就在编译阶段,变量和函数会放到执行上下文的变量环境中,后续执行时,js引擎就会从变量环境中去查找自定义的变量和函数
  1. showName()
  2. var showName = function() {
  3. console.log(2)
  4. }
  5. function showName() {
  6. console.log(1)
  7. }

调用栈

为什么会出现栈溢出?

  1. function showName() {
  2. console.log(1)
  3. showName()
  4. }
  5. showName()

调用栈就是用来管理函数调用关系的一种数据结构

先弄明白函数调用栈结构

函数调用
  1. function showName() {
  2. var a = 1
  3. console.log(a)
  4. }
  5. showName()

在执行到函数 showName() 之前,JavaScript 引擎会为上面这段代码创建全局执行上下文

JavaScript为什么要引入let/const? - 图3

执行到showName()时,js引擎判断这是一个函数调用,进行以下操作:

  1. 从全局执行上下文中取出showName函数代码
  2. 对这段代码进行编译,并创建该函数的执行上下文可执行代码
    JavaScript为什么要引入let/const? - 图4
  3. 执行

那么就有了两个执行上下文,如何管理?

调用栈

栈溢出:调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,我们把这种错误叫做栈溢出

小结:

  1. 每调用一个函数,JavaScript 引擎会为其创建执行上下文,并把该执行上下文压入调用栈,然后 JavaScript 引擎开始执行函数代码。
  2. 如果在一个函数 A 中调用了另外一个函数 B,那么 JavaScript 引擎会为 B 函数创建执行上下文,并将 B 函数的执行上下文压入栈顶。
  3. 当前函数执行完毕后,JavaScript 引擎会将该函数的执行上下文弹出栈。
  4. 当分配的调用栈空间被占满时,会引发“栈溢出”问题。

特性-块级作用域

作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

在 ES6 之前,ES 的作用域只有两种:全局作用域和函数作用域。

  • 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
  • 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

作用域与执行上下文

什么是块级作用域?

块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。

  1. function showName() {
  2. if(1) {
  3. var name = 'a'
  4. }
  5. console.log(name)
  6. }
  7. showName()
  1. char* myname = " 极客时间 ";
  2. void showName() {
  3. printf("%s \n",myname);
  4. if(0){
  5. char* myname = " 极客邦 ";
  6. }
  7. }
  8. void main(){
  9. showName();
  10. }

和 Java、C/C++ 不同,ES6 之前是不支持块级作用域的,因为当初设计这门语言的时候,并没有想到 JavaScript 会火起来,所以只是按照最简单的方式来设计。没有了块级作用域,再把作用域内部的变量统一提升无疑是最快速、最简单的设计,不过这也直接导致了函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都是能被访问的,这也就是 JavaScript 中的变量提升。

变量提升和块级作用域缺失所带来的问题

  1. // 缺失块级作用域:本该销毁的变量没有销毁
  2. function foo(){
  3. for (var i = 0; i < 7; i++) {
  4. }
  5. console.log(i);
  6. }
  7. foo()
  1. // 变量提升+缺失块级作用域,造成不符合直觉的结果
  2. var myname = " 极客时间 "
  3. function showName(){
  4. console.log(myname);
  5. if(0){
  6. var myname = " 极客邦 "
  7. }
  8. console.log(myname);
  9. }
  10. showName()

为了解决这些问题,ES6 引入了 let 和 const 关键字

let/const是如何解决这些问题的

在同一段代码中,ES6 是如何做到既要支持变量提升的特性,又要支持块级作用域的呢?

JavaScript如何解决变量提升的问题

  1. function foo(){
  2. console.log(a)
  3. console.log(b)
  4. var a = 1
  5. let b = 2
  6. }
  7. foo()

支持块级作用域

作用块内声明的变量不影响块外面的变量

JavaScript是如何支持块级作用域的

  1. function foo(){
  2. var a = 1
  3. let b = 2
  4. {
  5. let b = 3
  6. var c = 4
  7. let d = 5
  8. console.log(a)
  9. console.log(b)
  10. }
  11. console.log(b)
  12. console.log(c)
  13. console.log(d)
  14. }
  15. foo()

站在执行上下文的角度来分析

  • 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。
  • 通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。

在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。

小结

由于 JavaScript 的变量提升存在着变量覆盖、变量污染等设计缺陷,所以 ES6 引入了块级作用域关键字来解决这些问题。之后我们还通过对变量环境和词法环境的介绍,分析了 JavaScript 引擎是如何同时支持变量提升和块级作用域的。

  1. let myname= '极客时间'
  2. {
  3. console.log(myname)
  4. let myname= '极客邦'
  5. }

代码中出现相同的变量,JavaScript引擎是如何选择的?

  1. function bar() {
  2. console.log(myName)
  3. }
  4. function foo() {
  5. var myName = " 极客邦 "
  6. bar()
  7. }
  8. var myName = " 极客时间 "
  9. foo()

作用域链

其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为outer

当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量,
比如上面那段代码在查找 myName 变量时,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。

这个查找的链条就称为作用域链

foo 函数调用的 bar 函数,那为什么 bar 函数的外部引用是全局执行上下文,而不是 foo 函数的执行上下文?

还需要知道什么是词法作用域。这是因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。

词法作用域

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

也就是说,词法作用域是代码阶段就决定好的,和函数是怎么调用的没有关系

块级作用域中的变量查找

  1. function bar() {
  2. var myName = " 极客世界 "
  3. let test1 = 100
  4. if (1) {
  5. let myName = "Chrome 浏览器 "
  6. console.log(test)
  7. }
  8. }
  9. function foo() {
  10. var myName = " 极客邦 "
  11. let test = 2
  12. {
  13. let test = 3
  14. bar()
  15. }
  16. }
  17. var myName = " 极客时间 "
  18. let myAge = 10
  19. let test = 1
  20. foo()

闭包

  1. function foo() {
  2. var myName = " 极客时间 "
  3. let test1 = 1
  4. const test2 = 2
  5. var innerBar = {
  6. getName:function(){
  7. console.log(test1)
  8. return myName
  9. },
  10. setName:function(newName){
  11. myName = newName
  12. }
  13. }
  14. return innerBar
  15. }
  16. var bar = foo()
  17. bar.setName(" 极客邦 ")
  18. bar.getName()
  19. console.log(bar.getName())

虽然 foo 函数已经执行结束,但是 getName 和 setName 函数依然可以使用 foo 函数中的变量 myName 和 test1。

根据词法作用域的规则,内部函数 getName 和 setName 总是可以访问它们的外部函数 foo 中的变量

foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setName 和 getName 方法中使用了 foo 函数内部的变量 myName 和 test1,所以这两个变量依然保存在内存中。这像极了 setName 和 getName 方法背的一个专属背包,无论在哪里调用了 setName 和 getName 方法,它们都会背着这个 foo 函数的专属背包。

之所以是专属背包,是因为除了 setName 和 getName 函数之外,其他任何地方都是无法访问该背包的,我们就可以把这个背包称为 foo 函数的闭包

给闭包一个正式的定义了。在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包

闭包是怎么回收的

聊聊闭包是什么时候销毁的。因为如果闭包使用不正确,会很容易造成内存泄漏的:

通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。

如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。

使用闭包的时候,要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量

小结:

  • 首先,介绍了什么是作用域链,我们把通过作用域查找变量的链条称为作用域链;作用域链是通过词法作用域来确定的,而词法作用域反映了代码的结构。
  • 其次,介绍了在块级作用域中是如何通过作用域链来查找变量的。
  • 最后,又基于作用域链和词法环境介绍了到底什么是闭包。

关于词法作用域和闭包

  1. var bar = {
  2. myName:"time.geekbang.com",
  3. printName: function () {
  4. console.log(myName)
  5. }
  6. }
  7. let myName = " 极客邦 "
  8. bar.printName()
  9. //function foo() {
  10. // let myName = " 极客时间 "
  11. // return bar.printName
  12. //}
  13. // let _printName = foo()
  14. // _printName()

foo函数中的myName会成为闭包吗?

this

为什么有this?

bar.myName

在对象内部的方法中使用对象内部的属性是一个非常普遍的需求。但是 JavaScript 的作用域机制并不支持这一点,基于这个需求,JavaScript 又搞出来另外一套this 机制

区分清楚作用域链this是两套不同的系统,它们之间基本没太多联系。明确这点,可以避免在学习 this 的过程中,和作用域产生一些不必要的关联。

全局执行上下文中的 this函数执行上下文中的 this

全局执行上下文中的 this

console.log(this)来打印出来全局执行上下文中的 this,最终输出的是 window 对象。所以可以得出这样一个结论:全局执行上下文中的 this 是指向 window 对象的。

函数执行上下文中的 this

  1. function foo(){
  2. console.log(this)
  3. }
  4. foo()

执行这段代码,打印出来的也是 window 对象,这说明在默认情况下调用一个函数,其执行上下文中的 this 也是指向 window 对象的。

能不能设置执行上下文中的 this 来指向其他对象呢?

有下面三种方式来设置函数执行上下文中的 this 值。

  1. call/apply/bind
  2. 通过对象来调用方法

    使用对象来调用其内部的一个方法,该方法的 this 是指向对象本身的

  1. 通过构造函数来设置this

this的设计缺陷

嵌套函数中的 this 不会从外层函数中继承

  1. var myObj = {
  2. name : " 极客时间 ",
  3. showThis: function(){
  4. console.log(this)
  5. function bar(){console.log(this)}
  6. bar()
  7. }
  8. }
  9. myObj.showThis()

可以通过一个小技巧来解决这个问题,比如在 showThis 函数中声明一个变量 self 用来保存 this,然后在 bar 函数中使用 self

这个方法的的本质是把 this 体系转换为了作用域的体系

也可以使用 ES6 中的箭头函数来解决这个问题

这是因为 ES6 中的箭头函数并不会创建其自身的执行上下文,所以箭头函数中的 this 取决于它的外部函数。

发现将近一半的时间都是在谈 JavaScript 的各种缺陷,比如变量提升带来的问题、this 带来问题等。作者认为了解一门语言的缺陷并不是为了否定它,相反是为了能更加深入地了解它。我们在谈论缺陷的过程中,还结合 JavaScript 的工作流程分析了出现这些缺陷的原因,以及避开这些缺陷的方法。掌握了这些,相信今后在使用 JavaScript 的过程中会更加得心应手。