浏览器中的Javascript执行机制🚀

121 阅读31分钟

前言

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 1 天,点击查看活动详情

这篇主要讲解浏览器中的Javascript执行机制的相关内容,将从变量提升、调用栈、作用域和闭包以及this等出发。让我们从 JavaScript 的顺序执行讲起,一步步了解 JavaScript 是怎么运行的吧。

变量提升

JavaScript 是按顺序执行的,可是呢,有些时候实际代码执行结果却不符合这种逻辑。为什么呢?先看一段代码。

showName()
console.log(name)
var name = '冰冰'
function showName() {
    console.log('函数showName被执行');
}

最后的结果:

第 1 行输出“函数 showName 被执行”,第 2 行输出“undefined”。

如果删除var name = '冰冰',再次执行这段代码,JavaScript 引擎就会报错:

因此,我们可以得到结论: (前提:变量用var声明)

  1. 执行时使用了未声明的变量,JavaScript 引擎就会报错;

  2. 在一个变量定义之前使用它不会出错,且值为undefined;

  3. 在一个函数定义之前使用它不会出错且函数能正确执行;

对于第2,3的话,我们得归咎到变量提升。

什么是变量提升

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

从概念的字面意义上来看,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面。但实际上变量和函数声明在代码里的位置是不会改变的,而且是在编译阶段被 JavaScript 引擎放入内存中。

一段 JavaScript 代码在执行之前需要被 JavaScript 引擎编译,编译完成之后,才会进入执行阶段。大致流程你可以参考下图:

对于变量:

对于函数:

  • 自定义函数方式(命名函数)

  • 函数表达式(匿名函数)

为什么会存在变量提升?

为什么 JavaScript 中会存在变量提升这个特性,而其他语言似乎都没有这个特性呢?要讲清楚这个问题,我们就得先从作用域讲起。

作用域

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

  1. 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。

  2. 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

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

//常见的块级作用域
//if块
if(1){}

//while块
while(1){}

//函数块
function foo(){}
 
//for循环块
for(let i = 0; i<100; i++){}

//单独一个块
{}

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

为什么需要变量提升

之所以需要实现变量提升,是因为 JavaScript 代码在执行之前需要先编译,创建执行的上下文,初始化一些代码在执行时所需要使用的对象。

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

执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。在执行上下文中存在一个变量环境的对象(Viriable Environment),该对象中保存了变量提升的内容,比如上面代码中的变量 myname 和函数 showName,都保存在该对象中。

在访问一个变量时,会在当前的执行上下文中的作用域中去查找,然而作用域的首端指向的是当前执行的上下文中的变量,这个变量是当前执行上下文的一个属性,也就是变量环境,所有的函数和变量的声明都会存在这里,且变量的默认值会被设置为 undefined;

因此,在代码执行阶段,JavaScript 引擎会从变量环境中去查找自定义的变量和函数。

有了变量提升,因为在代码执行会进行一次代码检查和预编译(只进行一次),这样就不需要在函数变量每次执行的时候都再进行一次代码检查和重新解析。而且这样可以提升容错率,例如这样的一段代码,正是因为变量的提示在执行时就不会进行报错。

a=2;
var a;
console.log(a)
//2

代码中出现相同的变量或者函数怎么办?

在执行一段 JavaScript 代码之前,会编译代码,并将代码中的函数和变量保存到执行上下文的变量环境中,那么如果代码中出现了重名的函数或者变量,JavaScript 引擎会如何处理?

如果在编译阶段,存在两个相同的函数,那么最终存放在变量环境中的是最后定义的那个,这是因为后定义的会覆盖掉之前定义的。


function showName() {
    console.log('冰冰');
}
showName();
function showName() {
    console.log('冰冰');
}
showName(); 

//冰冰
//冰冰

变量提升带来的问题

虽然变量提升带来了一定的优点,但同时也会造成一些其他的问题。类似于

  1. 本应销毁的变量没有被销毁
 var useString = 'xiaokonglong';

for (var i = 0; i < useString.length; i++) {
        console.log(useString[i]);
}

console.log(i); // 12

正是因为变量的提升,导致i变成了全局变量,在结束后并不会销毁,所以会打印出来useString.length-1。 在ES6中letconst将会解决这个问题,同样的代码,使用let const 时结果就会不一致

 var useString = 'xiaokonglong';

for (let i = 0; i < useString.length; i++) {
        console.log(useString[i]);
}

console.log(i); // i is not defined
  1. 变量容易在不被察觉的情况下被覆盖掉。
var myname = "极客时间"
function showName(){
  console.log(myname);
  if(0){
   var myname = "极客邦"
  }
  console.log(myname);
}
showName()
//undefined

这里的原因我们先提出来,具体的理解在看了下面的调用栈之后会更加清楚。

当刚执行到 showName 函数调用时,最终的调用栈状态如下图所示:

howName 函数的执行上下文创建后,JavaScript 引擎便开始执行 showName 函数内部的代码了。首先执行的是:console.log(myname);

执行这段代码需要使用变量 myname,结合上面的调用栈状态图,你可以看到这里有两个 myname 变量:一个在全局执行上下文中,其值是“极客时间”;另外一个在 showName 函数的执行上下文中,其值是 undefined。

那么到底该使用哪个呢?

当然是先使用函数执行上下文里面的变量啦!

在函数执行过程中,JavaScript 会优先从当前的执行上下文中查找变量,由于变量提升,当前的执行上下文中就包含了变量 myname,而值是 undefined,所以获取到的 myname 的值就是 undefined。

这输出的结果和其他大部分支持块级作用域的语言都不一样,比如上面 C 语言输出的就是全局变量,所以这会很容易造成误解。

思考题

按照 JavaScript 的执行流程,来分析最终输出结果


showName()
var showName = function() {
    console.log(2)
}
function showName() {
    console.log(1)
}

//1

分析

//编译阶段: 

var showName function showName(){console.log(1)} 

//执行阶段: 
showName()//输出1 
showName=function(){console.log(2)} //如果后面再有showName执行的话,就输出2,因为这时候函数引用已经变了

下面是关于同名变量和函数的三点处理原则:

  1. 如果是同名的函数,JavaScript编译阶段会选择最后声明的那个。

  2. 函数提升要比变量提升的优先级要高一些,且不会被变量声明覆盖,但是会被变量赋值之后覆盖。

  3. 如果变量和函数同名,那么在编译阶段,变量的声明会被忽略.

调用栈

什么是合规范的代码

在讲变量提升的时候,我们已经对执行上下文有了一定的了解。当一段代码被执行时,JavaScript 引擎先会对其进行编译,并创建执行上下文。

但是并没有明确说明到底什么样的代码才算符合规范。哪些情况下代码才算是“一段”代码,才会在执行之前就进行编译并创建执行上下文?

一般说来,有这么三种情况:

  • 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
  • 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
  • 当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文。

好了,又进一步理解了执行上下文,那继续深入,一起聊聊调用栈。

为什么要学习调用栈?

  • 可以帮助你了解 JavaScript 引擎背后的工作原理;
  • 让你有调试 JavaScript 代码的能力;
  • 帮助你搞定面试,因为面试过程中,调用栈也是出境率非常高的题目

有些时候我们会用递归来解决问题,当递归入口没设置好的话,可能会出现栈溢出。其他语言是这样,写 JavaScript 代码的时候也有可能会这样。那么什么是调用栈呢?

什么是调用栈?

调用栈就是用来管理函数调用关系的一种数据结构。JavaScript 引擎利用栈的这种结构来管理执行上下文的,在执行上下文创建好后,JavaScript 引擎会将执行上下文压入栈中,通常把这种用来管理执行上下文的栈称为执行上下文栈,又称调用栈。

先看一段代码:先是创建了一个 add 函数,接着在代码的最下面又调用了该函数。

var a = 2
function add(){
var b = 10
return  a+b
}
add()

我们模拟一下这段代码的函数调用过程。

  • 在执行到函数 add() 之前,JavaScript 引擎会为上面这段代码创建全局执行上下文,包含了声明的函数和变量,你可以参考下图:

第一步,代码中全局变量和函数都保存在全局上下文的变量环境中。

执行上下文准备好之后,便开始执行全局代码。

当执行到 add 这儿时,JavaScript 判断这是一个函数调用,那么将执行以下操作:

首先,从全局执行上下文中,取出 add 函数代码。

其次,对 add 函数的这段代码进行编译,并创建该函数的执行上下文和可执行代码。

最后,执行代码,输出结果。

完整流程参考下图:

就这样,当执行到 add 函数的时候,我们就有了两个执行上下文了——全局执行上下文和 add 函数的执行上下文

在这过程中,创建全局上下文后就将其压入栈底,接着执行全局代码a=2给全局上下文变量中的a的值设置为2.

接着调用add函数,JavaScript 引擎会编译该函数,并为其创建一个执行上下文,最后还将该函数的执行上下文压入栈中,之后进入函数代码的执行阶段了,这里先执行的是b=10的赋值操作,执行语句会将 add函数执行上下文中的 b 由undefined变成了10

后面当add函数返回时,add函数的执行上下文就会从栈顶弹出,只剩下全局上下文。

总结

调用栈是 JavaScript 引擎追踪函数执行的一个机制,当一次有多个函数被调用时,通过调用栈就能够追踪到哪个函数正在被执行以及各函数之间的调用关系。

在开发中,如何利用好调用栈

  1. 利用浏览器查看调用栈的信息

当你执行一段复杂的代码时,你可能很难从代码文件中分析其调用关系,这时候你可以在你想要查看的函数中加入断点,然后当执行到该函数时,就可以查看该函数的调用栈了。

你可以打开“开发者工具”,点击“Source”标签,选择 JavaScript 代码的页面,然后在第 3 行加上断点,并刷新页面。你可以看到执行到 add 函数时,执行流程就暂停了,这时可以通过右边“call stack”来查看当前的调用栈的情况,如下图:

从图中可以看出,右边的“call stack”下面显示出来了函数的调用关系:栈的最底部是 匿名(anonymous),也就是全局的函数入口;中间是 addAll 函数;顶部是add函数。这就清晰地反映了函数的调用关系,所以在分析复杂结构代码,或者检查 Bug 时,调用栈都是非常有用的。

除了通过断点来查看调用栈,你还可以使用 console.trace() 来输出当前的函数调用关系,比如在示例代码中的 add 函数里面加上了console.trace(),你就可以看到控制台输出的结果,如下图:

  1. 栈溢出(Stack Overflow)

调用栈是有大小的,当分配的调用栈空间被占满时,JavaScript 引擎就会报错,会引发“堆栈溢出”问题,们把这种错误叫做栈溢出。

报错类似于这种

理解了栈溢出原因后,你就可以使用一些方法来避免或者解决栈溢出的问题,比如把递归调用的形式改造成其他形式,或者使用加入定时器的方法来把当前任务拆分为其他很多小任务。

堆栈溢出后怎么解决?首先看一个实例:

function isEven (num) {
    if (num === 0) {
        return true;
    }
 
    if (num === 1) {
        return false;
    }
 
    return isEven(Math.abs(num) - 2);
}
 
//Outputs: true
console.log(isEven(10));
 
//Outputs: false
console.log(isEven(9));

当我们把参数改成10000000时,运行下例会发生堆栈溢出:

原因是每次执行代码时,都会分配一定尺寸的栈空间(Windows系统中为1M),每次方法调用时都会在栈里储存一定信息(如参数、局部变量、返回值等等),这些信息再少也会占用一定空间,成千上万个此类空间累积起来,自然就超过线程的栈空间了。

那么怎么解决此类问题呢?

  1. 使用闭包

但是每次tarmpoline(isEven, 1000)这种调用方式不是很好,我们可以使用bind来绑定:

function isEven(n) {
    /**
     * [isEvenInner 递归]
     * @param  {[type]}  num [description]
     * @return {Boolean}     [description]
     */
    function isEvenInner (n) {
        if (n === 0) {
            return true;
        }
 
        if (n === 1) {
            return false;
        }
 
        return function() {
            return isEvenInner(Math.abs(n) - 2);
        }
    }
    /**
     * [trampoline 迭代]
     * @param  {[type]} func [description]
     * @param  {[type]} arg  [description]
     * @return {[type]}      [description]
     */
    function trampoline (func, arg) {
        var value = func(arg);
 
        while(typeof value === "function") {
            value = value();
        }
 
        return value;
    }
 
    return trampoline.bind(null, isEvenInner)(n);
}
//Outputs: true
console.log(isEven(10000));
 
//Outputs: false
console.log(isEven(10001));

虽然上例实现了我们想要的效果,但是trampoline函数还是有一定的局限性:

  1. 假设你只传递一个参数给递归函数

value = func(arg); 修改为 value = func.apply(func, arg);

  1. 假设最后的返回值不是一个函数 关于更健壮性的实现,请看underscore-contrib中源码。

块级作用域

正是由于 JavaScript 存在变量提升这种特性,从而导致了很多与直觉不符的代码,这也是 JavaScript 的一个重要设计缺陷。且JavaScript 的变量提升存在着变量覆盖、变量污染等设计缺陷,所以 ES6 引入了let 和 const块级作用域关键字来解决这些问题,JavaScript 也能像其他语言一样拥有了块级作用域。

对于let和const的用法,可以参考这个文章:let 和 const 命令 - ECMAScript 6入门

通过块级作用域来解决变量提升带来的问题

function letTest() {
  let x = 1;
  if (true) {
   //var  x=2;//用var会覆盖,是同样的变量
    let x = 2;  // 用let是不同的变量
    console.log(x);  // 2
  }
  console.log(x);  // 1
}

如果xvar会覆盖,相当于是同样的变量,用let的话就可以解决这个问题。这是因为 let 关键字是支持块级作用域的,所以在编译阶段,JavaScript 引擎并不会把 if 块中通过 let 声明的变量存放到变量环境中,这也就意味着在 if 块通过 let 声明的关键字,并不会提升到全函数可见。所以在 if 块之内打印出来的值是 2,跳出语块之后,打印出来的值就是 1 了。

这种就非常符合我们的编程习惯了:作用域块内声明的变量不影响块外面的变量

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

ES6可以通过使用 let 或者 const 关键字来实现块级作用域。

不过你是否有过这样的疑问:“在同一段代码中,ES6 是如何做到既要支持变量提升的特性,又要支持块级作用域的呢?”

词法环境

我们用执行上下文来理解,前边在学习执行上下文中,我们的图中都会有一个词法环境。

  • 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。
  • 在函数的作用域块内部,通过 let 声明的变量并没有被存放到词法环境中。
  • 当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量。
  • 当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出。

在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。需要注意下,我这里所讲的变量是指通过 let 或者 const 声明的变量。

变量查找过程:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。

我们用一段代码块来解释一下

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()

从上图你可以清晰地看出变量查找流程,不过要完整理解查找变量或者查找函数的流程,就涉及到作用域链了。

块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。

作用域链和闭包

什么是作用域链?

作用域

作⽤域:即变量(变量作⽤域⼜称上下⽂)和函数⽣效(能被访问)的区域或集合,换句话说,作⽤域决定了代码区块中变量和其他资源的可⻅性。

作用域链:当在 Javascript 中使⽤⼀个变量的时候,⾸先 Javascript 引擎会尝试在当前作⽤域下去寻找该变 量,如果没找到,再到它的上层作⽤域寻找,以此类推直到找到该变量或是已经到了全局作⽤域,如果在全局作⽤域⾥仍然找不到该变量,它就会在全局范围内隐式声明该变量(⾮严格模式下)或是直接报错,宣布放弃。这种一层层的关系,就是作用域链。

我们看一下面这一段代码:

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

我们可以知道,当这段代码执行到 bar 函数内部时,其调用栈的状态图如下所示:

一开始我们可能会觉得是按照调用栈的顺序来查找变量,即先查找栈顶是否存在 myName 变量,但是这里没有,所以接着往下查找 foo 函数中的变量。在 foo 函数中查找到了 myName 变量,这时候就使用 foo 函数中的 myName。但是你会发现,正确结果是“极客世界”,也就说明不是按照调用栈的顺序来查找变量的。

那是什么样的查找顺序呢?

其实每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer。当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。

比如上一段代码

函数foobarouter都指向全局执行上下文。

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

这是因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的,而词法作用域就是根据代码的位置来决定的。foobar的上级作用域都是全局作用域,所以如果 foo 或者 bar 函数使用了一个它们没有定义的变量,那么它们会到全局作用域去查找。也就是说,词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。

块级作用域中的变量查找

前面我们通过全局作用域和函数级作用域来分析了作用域链,那接下来我们再来看看块级作用域中变量是如何查找的?

我们得先确定,ES6 是支持块级作用域的,当执行到代码块时,如果代码块中有 let 或者 const 声明的变量,那么变量就会存放到该函数的词法环境中。

我们再看一段代码


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()

当执行到bar函数的if语块之内,需要打印test的值,其查找过程在图中使用序号12345标记出来了。

首先是在 bar 函数的执行上下文中查找,但因为 bar 函数的执行上下文中没有定义 test 变量,所以根据词法作用域的规则,下一步就在 bar 函数的外部作用域中查找,也就是全局作用域。

闭包

什么是闭包

因为作用域链的存在,函数内部可以直接读取全局变量。而函数内部无法读取函数内部的局部变量。

那如果我们想读取函数内部的变量呢,怎么办?闭包就出现了

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

或者也可以这么理解。

当函数嵌套时,内层函数引用了外层函数作用域下的变量,并且内层函数在全局作用域下可访问时,就形成了闭包。

但是得注意,闭包只提供从内部到外部作用域的访问,而不能提供从外部到内部作用域的访问。因此,如果在几个嵌套函数中声明并定义一个变量,却在父函数的外部作用域中使用,编译器将返回一个未定义的错误。


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("happy")
bar.getName()
console.log(bar.getName())

从上面的代码可以看出,innerBar 是一个对象,包含了 getNamesetName的两个方法。根据词法作用域的规则,内部函数 getNamesetName 总是可以访问它们的外部函数 foo 中的变量,所以当 innerBar 对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 getName 和 setName 函数依然可以使用 foo 函数中的变量 myNametest1。所以当 foo 函数执行完成之后,其整个调用栈的状态如下图所示:

foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setNamegetName 方法中使用了 foo 函数内部的变量 myNametest1,所以这两个变量依然保存在内存中。而且除了 setName 和 getName 函数之外,其他任何地方都是无法访问的,类似于专属背包,我们就可以把这个背包称为 foo 函数的闭包。

所以访问有闭包的代码时,JavaScript 引擎会沿着“当前执行上下文—>函数闭包—> ...—>全局执行上下文”的顺序来查找

闭包是怎么回收的?

如果闭包使用不正确,会很容易造成内存泄漏的,关注闭包是如何回收的能让你正确地使用闭包。

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

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

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

思考题

在上面这段代码中有三个地方定义了 myName,分析这段代码,你觉得这段代码在执行过程中会产生闭包吗?最终打印的结果是什么?

var bar = {
    myName:"time.geekbang.com",
    printName: function () {
        console.log(myName)
    }    
}
function foo() {
    let myName = "冰冰"
    return bar.printName
}
let myName = "happy"
let _printName = foo()
_printName()
bar.printName()

思考题最后输出的都是 happy,这里不会产生函数闭包,解释如下:

  1. bar 不是一个函数,因此 bar 当中的 printName 其实是一个全局声明的函数,bar 当中的 myName 只是对象的一个属性,也和 printName 没有联系,如果要产生联系,需要使用 this 关键字,表示这里的 myName 是对象的一个属性,不然的话,printName 会通过词法作用域链去到其声明的环境,也就是全局,去找 myName
  2. foo 函数返回的 printName 是全局声明的函数,因此和 foo 当中定义的变量也没有任何联系,这个时候 foo 函数返回 printName 并不会产生闭包

this

在闭包中的最后一段代码中,其实按照常理来说,调用bar.printName方法时,该方法内部的变量 myName 应该使用 bar 对象中的,因为它们是一个整体。而且在对象内部的方法中使用对象内部的属性是一个非常普遍的需求。但是 JavaScript 的作用域机制并不支持这一点,基于这个需求,JavaScript 又搞出来另外一套 this 机制。

⚠作用域链和 this 是两套不同的系统,它们之间基本没太多联系。

我们提到过,执行上下文主要分为三种——全局执行上下文、函数执行上下文和 eval 执行上下文,所以对应的 this 也只有这三种——全局执行上下文中的 this、函数中的 this 和 eval 中的 this。

this的指向?

this的指向有六种!

this出现在全局函数中,永远指向window

var Car = function() {
    console.log(this); // window
    console.log(this.Car==window.Car,Car==window.Car); // true true}
Car();

Car作为全局函数,直接调用时,函数的this指向window。

this出现在严格模式中 永远不会指向window

函数中使用es5的严格模式‘use strict’,this为undefined

var Car = function() {
    'use strict'
    console.log(this); // undefined
}
Car();

当某个函数为对象的一个属性时,在这个函数内部this指向这个对象

var car = {
    name:'丰田',
    run() {
        console.log(this); // {name: "丰田", run: ƒ}    }
}

在car这个对象的run方法中打印this为car本身。

this出现在构造函数中,指向构造函数新创建的对象

var Car = function(name) {
    this.name = name;
    console.log(this); // Car {name: "亚洲龙"}
                       // Car {name: "汉兰达"}
}
var myCar_1 = new Car('亚洲龙');
var myCar_2 = new Car('汉兰达');

上述代码中构造了两个实例对象myCar_1和myCar_2,构造函数里面的this分别指向创建的实例化对象myCar_1和myCar_2。

当一个元素被绑定事件处理函数时,this指向被点击的这个元素

var btn = document.querySelector('button');
btn.onclick = function() {
    console.log(this); // <button>this</button>
}

this出现在箭头函数中时,this和父级作用域的this指向相同

const obj = {
    Car() {
        setTimeout(function() {
            setTimeout(function() {
                console.log(this); // window            
            })
            setTimeout(()=>{
                console.log(this); // window            
            })
        })
        setTimeout(() => {
            setTimeout(function() {
                console.log(this); // window            
            })
            setTimeout(()=>{
                console.log(this); // obj            
            })
        })
    }
}
obj.Car();

首先遇到function(){}这种格式,如果是直接调用,浏览器会把Core传进去,所以①和③this指向为window。而箭头函数的this是指向父级作用域,➁的父级作用域是setTimeout(function(){})里面,前面已经说过function(){}这种格式如果直接调用Core传进去,this指向为window, 所以➁的this指向=父级作用域的this指向=window,至于④就比较简单了,这里不做赘述。

修改this的指向

使用call、apply、bind修改this指向

  • call
var Car=function(brand,color){
    console.log(this);
    this.brand=brand;
    this.color=color;
}
Car.call();

答案:window

其实这里我们用Car()直接调用的时候系统隐式的加了.call来执行的,所以Car()Car.call()结果是一样的。

那么call有什么用呢?

var Car=function(brand,color){
    this.brand=brand;
    this.color=color;
    console.log(this.brand,this.color);
}
var myCar={};
Car.call(myCar,'ToYOAO','black');
Car("ni",'red');
console.log(myCar)

原来是空对象的myCarCar.call之后具有了brand和color属性,说白了就是更改了this的指向,使得this指向了call的第一个参数。

那apply又有啥用呢?

其实与call相比只有一个区别:第二个参数为多个参数值合并的数组。

var Car=function(arr){
    this.brand=arr[0];
    this.color=arr[1];
    console.log(arr,arr[0],arr[1]);
}
var myCar={};
Car.apply(myCar,['ToYOAO','black']);
console.log(myCar)
Car.call(myCar,['ToYOAO','black']);
console.log(myCar)
Car.apply(myCar,['ToYOAO']);
console.log(myCar)
//如果不够赋值为undefined
Car.call(myCar,['ToYOAO'])
console.log(myCar)

再举一个例子

var Cat=function(){
    this.sleep=function(){
        cconsole.log('sleeping---');
    }
}
var Cat1=function(){
    Cat.apply(this);
    this.eat=function(){
        console.log('eating---');
    }
    console.log(this);
}
var myCat=new Cat1();

可见用了apply关键字实现Cat_1函数对Cat函数属性的继承,Cat_1同样有sleep方法。

那bind又怎么使用呢?

bind 方法后面多了个 () 外 ,结果返回都一致

总结

  1. call()apply()bind() 都是用来重定义 this 这个对象的!
var brand='丰田',color='黑色';
var obj={
    brand:this.brand,
    color:this.color,
    discribe:function(){
        console.log(`我的${this.brand}车是${this.color}的!`)
    }
}
obj.discribe();
var car={
    brand:"奔驰",
    color:"红色"
}
//改变this指向
obj.discribe.call(car);
obj.discribe.apply(car);
obj.discribe.bind(car)();

以上除了 bind 方法后面多了个 () 外 ,结果返回都一致!

由此得出结论,bind 返回的是一个新的函数,你必须调用它才会被执行。

  1. 对比call 、bind 、 apply 传参情况下

上述结果不难看出:

call 、bind 、apply 这三个函数的第一个参数都是 this 的指向对象,第二个参数差别就来了,

call 的参数是直接放进去的,apply 的所有参数都必须放在一个数组里面传进去 ,bind 除了返回是函数以外,它的参数和 call 一样。

  1. 使用new关键字改变this指向
var Car = function(name) {
    this.name = name;
    console.log(this); // Car {name: "亚洲龙"}// Car {name: "汉兰达"}}
var myCar_1 = new Car('亚洲龙');
var myCar_2 = new Car('汉兰达');

主要用来创建对象时初始化对象

new在执行时会做4件事:

  1. new会在内存中创建一个新的空对象;
  2. new会让this指向这个新对象;
  3. 执行构造函数目的是给这个新对象加属性和方法
  4. new会返回这个新对象

巧妙解this问题

var obj = {
  foo: function(){
    console.log(this)
  }
}

var bar = obj.foo
obj.foo() // 打印出的 this 是 obj
bar() // 打印出的 this 是 window

函数调用

JS(ES5)里面有三种函数调用形式:

func(p1, p2) 
obj.child.method(p1, p2)
func.call(context, p1, p2) // 先不讲 apply

一般,初学者都知道前两种形式,而且认为前两种形式「优于」第三种形式。

从看到这里开始,你一定要记住,第三种调用形式,才是正常调用形式:

func.call(context, p1, p2)

其他两种都是语法糖,可以等价地变为 call 形式:

func(p1, p2);
func.call(undefined,p1,p2);

obj.child.method(p1, p2);
obj.child.method.call(obj.child,p1,p2);

以上我们先称之为转换代码

至此,我们的函数调用可用一种形式:func.call(context,p1,p2)

那么this,就是上面代码的context ,this是你call一个函数时传的context。

注意:如果你传的 context 是 null 或 undefined,那么 window 对象就是默认的 context(严格模式下默认 context 是 undefined),其他的值,比如原始值,会转换成其包装类

例子

function func(){
  console.log(this)
}

func()

//转换后
func.call(undefined)
//可以简写为:func.call()

答案:window

如果你不想要this指向window,那可以这么写

func.call(obj)
//this就指向obj了

obj.child.method(p1, p2) 的 this 如何确定?

var obj = {
  foo: function(){
    console.log(this)
  }
}

obj.foo() 

//转换
obj.foo.call(obj);

答案:obj

回到开头的题目

var obj = {
  foo: function(){
    console.log(this)
  }
}

var bar = obj.foo
obj.foo() // 转换为 obj.foo.call(obj),this 就是 obj
bar() 
// 转换为 bar.call()
// 由于没有传 context
// 所以 this 就是 undefined
// 最后浏览器给你一个默认的 this —— window 对象

[ ]语法

function fn(){
    console.log(this)
}
var arr=[fn,fn1];
arr[0]();


//我们可以把arr[0]()看成arr.0()。虽然后面的语法错了,但是形式与转换代码里面的obj.child.method(p1,p2)对应上了
arr[0]()
arr.0()
arr.0.call(arr)

答案:arr

箭头函数

实际上箭头函数里并没有 this,如果你在箭头函数里看到 this,你直接把它当作箭头函数外面的 this 即可。外面的 this 是什么,箭头函数里面的 this 就还是什么,因为箭头函数本身不支持 this。

Event Handler 中的this

btn.addEventListener('click' ,function handler(){
  console.log(this) // 请问这里的 this 是什么
})

答案:btn

我们说过 this 都是由 call 或 apply 指定的,那么你只需要找到 handler 被调用时的代码就行了。

所以,你只能看文档了,MDN 这样说

通常来说this的值是触发事件的元素的引用,这种特性在多个相似的元素使用同一个通用事件监听器时非常让人满意。 当使用 addEventListener() 为一个元素注册事件的时候,句柄里的 this 值是该元素的引用。其与传递给句柄的 event 参数的 currentTarget 属性的值一样。

由于浏览器知道你不方便看源码里是怎么 call handler 的,所以直接在文档里告诉你了,你可以假想浏览器的源码是这样写的:

// 当事件被触发时
handler.call(event.currentTarget, event) 
// 那么 this 是什么不言而喻

jQuery Event Handler 中的 this

$ul.on('click', 'li' , function(){
  console.log(this)
})

同样,不要瞎猜,你要么看 jQuery 源码是怎么 call 这个函数的,要么看jQuery 文档

jQuery 文档是这样写的:

当jQuery的调用处理程序时,this关键字指向的是当前正在执行事件的元素。对于直接事件而言,this 代表绑定事件的元素。对于代理事件而言,this 则代表了与 selector 相匹配的元素。(注意,如果事件是从后代元素冒泡上来的话,那么 this 就有可能不等于 event.target。)若要使用 jQuery 的相关方法,可以根据当前元素创建一个 jQuery 对象,即使用 $(this)。

以上的说法其实都是不成文的,但是却是一种巧妙的记法,所以我们用上面的方法辅助我们对this进行学习!

到这文章就结束了,感觉还是有点乱,但是不知道怎么调整,希望大家可以给个意见呀!

参考

javaScript中为什么会有变量提升? - 走看看

Javascript中递归造成的堆栈溢出及解决方案