JavaScript 中一些概念的底层原理


注:本篇博客的内容来源自极客时间李兵老师的“浏览器工作原理与实践”课程的笔记总结

JavaScript中的变量提升与块级作用域

变量提升

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


showName()
console.log(myname)
var myname = '极客时间'
function showName() {
    console.log('函数showName被执行');
}

输出结果如图所示:
在这里插入图片描述
为什么会出现undefined这个结果,正式由于有变量提升。
上述代码等同于如下代码:


/*
* 变量提升部分
*/
// 把变量 myname提升到开头,
// 同时给myname赋值为undefined
var myname = undefined
// 把函数showName提升到开头
function showName() {
    console.log('showName被调用');
}

/*
* 可执行代码部分
*/
showName()
console.log(myname)
// 去掉var声明部分,保留赋值语句
myname = '极客时间'

变量提升这种机制的底层是由于JavaScript 代码在执行之前需要先编译。在编译阶段,变量和函数会被存放到变量环境中,变量的默认值会被设置为 undefined;在代码执行阶段,JavaScript 引擎会从变量环境中去查找自定义的变量和函数。
在 ES6 之前,JavaScript 只支持全局作用域和函数作用域,而其它编程语言基本都支持块级作用域。

变量提升所带来的问题

1. 变量容易在不被察觉的情况下被覆盖掉
看如下代码示例


var myname = "极客时间"
function showName(){
  console.log(myname);
  if(0){
   var myname = "极客邦"
  }
  console.log(myname);
}
showName()

结果如下:
在这里插入图片描述
由于变量提升以及作用域的问题导致原本已赋值的变量输出为undefined。
2. 本应销毁的变量没有被销毁
下面这段示例代码可能对新人来说更amazing更经典:


function foo(){
  for (var i = 0; i < 7; i++) {
  }
  console.log(i); 
}
foo()

由于var定义的变量i不存在块级作用域而无法及时销毁,所以导致最终输出的结果为7。

块级作用域的原理

作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
首先,我们知道在ES6中引入了constlet两个关键字,使得JavaScript能够支持块级作用域。
我们知道JavaScript之前只有全局作用域和函数作用域,而每个作用域其实都是一个执行上下文
在执行上下文里面有var所产生的变量环境,再不破坏原有的基本环境结构下之所以const和let能够支持块级作用域,是因为它俩在执行上下文中引入了一块叫做词法环境的区域。
接下来通过一段具体的代码示例来看看词法环境是如何造出块级作用域。


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

第一步是编译并创建执行上下文
下图就是foo函数的执行上下文:
在这里插入图片描述
通过上图,我们可以得出以下结论:

  • 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。
  • 通过 let 声明的变量,在编译阶段会被存放到词法环境中。
  • 在函数的作用域块内部,通过 let 声明的变量并没有被存放到词法环境中。

第二步继续执行代码
当刚执行到代码块里面时,foo函数的执行上下文:
在这里插入图片描述
从图中可以看出,当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在。并且在词法环境内部,维护了一个小型栈结构。
JavaScript引擎在执行上下文中的查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。
当执行到console.log(a)时,其查找过程如下图所示:
在这里插入图片描述
并且当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出。
最终的执行上下文如图所示:
在这里插入图片描述

小结

块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。
有关变量提升还有一个扩展的知识要点:
var的创建和初始化被提升,赋值不会被提升。
let的创建被提升,初始化和赋值不会被提升。
function的创建、初始化和赋值均会被提升。

作用域链和闭包

作用域链

先看看下面这段代码:


function bar() {
    console.log(myName)
}
function foo() {
    var myName = "极客邦"
    bar()
}
var myName = "极客时间"
foo()

最终的结果应该是foo函数作用中的极客邦还是全局作用域中的极客时间,这时候这和作用域链有关了,因为作用域链就是这条查找执行顺序的链条。
其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer
在这里插入图片描述
由图中不难发现bar函数的外部引用是全局上下文,所以结果应该是极客时间,但是为什么是全局上下文呢?
因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。
词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。并且词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。
所以根据词法作用域,bar函数的上级作用域就是全局作用域。
所以,综合上面的所有内容:
作用域链的查找与执行上下文(作用域)密切相关,而执行上下文里有变量环境和词法环境。
在变量环境中有var声明的变量和由词法作用域所决定的outer,
在词法环境中有const和let声明的变量,并且词法环境内部是一个小型栈结构。

下面再给出一个更完整全面的例子:


function bar() {
    var myName = "极客世界"
    let test1 = 100
    if (1) {
        let myName = "Chrome浏览器"
        console.log(test)
    }
}
function foo() {
    var myName = "极客邦"
    let test = 2
    {
        let test = 3
        bar()
    }
}
var myName = "极客时间"
let myAge = 10
let test = 1
foo()

它的查找调用链:
在这里插入图片描述

闭包

明白了作用域链相关的概念后,再看传说中的闭包就显得简单多了。
先看下面一段代码:


function foo() {
    var myName = "极客时间"
    let test1 = 1
    const test2 = 2
    var innerBar = {
        getName:function(){
            console.log(test1)
            return myName
        },
        setName:function(newName){
            myName = newName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName("极客邦")
bar.getName()
console.log(bar.getName())

下图为当执行到 foo 函数内部的return innerBar这行代码时调用栈的情况:
在这里插入图片描述
根据词法作用域的规则,内部函数 getName 和 setName 总是可以访问它们的外部函数 foo 中的变量,所以当 innerBar 对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 getName 和 setName 函数依然可以使用 foo 函数中的变量 myName 和 test1。所以当 foo 函数执行完成之后,其整个调用栈的状态如下图所示:
在这里插入图片描述
从图中不难发现除了 setName 和 getName 函数之外,其他任何地方都是无法访问该foo(closure)这个背包的,我们就可以把这个背包称为 foo 函数的闭包。
闭包的一个正式定义:在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。
闭包的一个精简理解:当函数嵌套时,内层函数引用了外层函数作用域下的变量,并且内层函数在全局作用域下可访问时,就形成了闭包。
顺便说一句,正是由于闭包的这种特性导致其很容易因为没能回收掉而产生内存泄漏,所以在实际应用中还是尽量避免使用闭包。

JavaScript中的this

this是什么

this与作用域链是两套不同系统,但是this与执行上下文有关。
在此,执行上下文中可以引入第三个环境,也就是this。
在控制台中输入console.log(this)来打印出来全局执行上下文中的 this,最终输出的是 window 对象。
我们知道在JavaScript中有全局执行上下文和函数执行上下文,但是默认情况下都是表示window对象。特别是在函数执行上下文中,这种行为是很不正常的,但是有三种方式可以设置函数执行上下文中的 this 值。

设置函数执行上下文中的 this 值

1. 通过函数的 call 方法设置
可以通过函数的 call 方法来设置函数执行上下文的 this 指向。
比如下面这段代码:


let bar = {
  myName : "极客邦",
  test1 : 1
}
function foo(){
  this.myName = "极客时间"
}
foo.call(bar)
console.log(bar)
console.log(myName)

其实除了 call 方法,你还可以使用 bindapply 方法来设置函数执行上下文中的 this。
这也就引入经典问题call,bind,apply三者之间的区别与联系,这里我不作讲解,有兴趣可以自行搜索。
2. 通过对象调用方法设置
比如下面这段代码:


var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)
  }
}
myObj.showThis()

使用对象来调用其内部的一个方法,该方法的 this 是指向对象本身的。
其实在执行myObject.showThis()时,等价于myObj.showThis.call(myObj)。
只不过是JavaScript引擎替我们隐式执行了方法1.
3. 通过构造函数中设置
比如下面这段代码:


function CreateObj(){
  this.name = "极客时间"
}
var myObj = new CreateObj()

当执行 new CreateObj() 的时候,JavaScript 引擎做了如下四件事:

  • 首先创建了一个空对象 tempObj;
  • 接着调用 CreateObj.call 方法,并将 tempObj 作为 call 方法的参数,这样当 CreateObj 的执行上下文创建时,它的 this 就指向了 tempObj 对象;
  • 然后执行 CreateObj 函数,此时的 CreateObj 函数执行上下文中的 this 指向了 tempObj 对象;
  • 最后返回 tempObj 对象。

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

看如下一段示例代码:


var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)
    function bar(){console.log(this)}
    bar()
  }
}
myObj.showThis()

结果如下:
在这里插入图片描述
不难发现,函数 bar 中的 this 指向的是全局 window 对象,而函数 showThis 中的 this 指向的是 myObj 对象。
即嵌套函数中的 this 不会从父函数中继承, 这里也是和其它语言都不一样的地方。
下面有两种方式可以解决这个问题:
第一种方式:把外层函数的this使用一个变量保存下来,然后将此变量当做内层函数的this。
改进后的代码如下:


var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)
    var self = this
    function bar(){
      self.name = "极客邦"
    }
    bar()
  }
}
myObj.showThis()

其实,这个方法的的本质是把 this 体系转换为了作用域的体系。
第二种方式:使用 ES6 中的箭头函数。
原理很简单,箭头函数没有自己的执行上下文,所以它会继承调用函数中的 this。
改进后的代码如下:


var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)
    var bar = ()=>{
      this.name = "极客邦"
      console.log(this)
    }
    bar()
  }
}
myObj.showThis()

小结

此章有关this的核心有以下四点:

  1. 当函数作为对象的方法调用时,函数中的 this 就是该对象;
  2. 当函数被正常调用时,在严格模式下,this 值是 undefined,非严格模式下 this 指向的是全局对象 window;
  3. 嵌套函数中的 this 不会继承外层函数的 this 值;
  4. 箭头函数没有自己的执行上下文,所以它会继承调用函数中的 this。

结语

这篇文章重点介绍了以执行上下文为基本环境的三大区域,分别为变量环境、词法环境和this,在此之中还介绍了var原生具备的全局作用域和函数作用域以及const和let所新增的块级作用域,进一步也讲述了作用域链以及闭包的原理。
到此,此篇博客的内容就告一段落,如果还有疑问的朋友欢迎评论区留言。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值