一文带你读懂作用域、作用链以及this的原理

463 阅读14分钟

变量提升的原理:JavaScript的执行顺序

变量提升:JavaScript代码执行过程中 JavaScript引擎变量的声明部分和函数的声明部分提升到代码开头的行为 (变量提升后以undefined设为默认值)

callName();
function callName() {
	console.log('callName Done!');
}
console.log(personName);
var personName = 'james';
//变量提升后 类似以下代码
function callName() {
	console.log('callName Done!');
};
var personName = undefined;
callName();//callName已声明 所以正常输出calName Done!
console.log(personName);//undefined
personName = 'james';
//代码所作改变:
1.将声明的变量和函数移到了代码顶部
2.去除变量的var 声明

JavaScript代码的执行流程:有些人认为 变量提升就是将声明部分提升到了最前面的位置 其实这种说法是错的 因为变量和函数声明在代码中的位置是不会变的 之所以会变量提升是因为在编译阶段被JavaScript引擎放入内存中(换句话来说 js代码在执行前会先被JavaScript引擎编译 然后才会进入执行阶段)流程大致如下图

image-20220517150046749

那么编译阶段究竟是如何做到变量提升的呢 接下来我们一起来看看 我们还是以上面的那段代码作为例子

第一部分:变量提升部分的代码
function callName() {
	console.log('callName Done!')
}
var personName = undefined;
第二部分:代码执行部分
callName();
console.log(personName);
personName = 'james'

执行图如下

image-20220517151048844

可以看到 结果编译后 会在生成执行上下文可执行代码两部分内容

执行上下文:JavaScript代码执行时的运行环境(比如调用一个函数 就会进入这个函数的执行上下文 确定函数执行期间的this变量对象等)在执行上下文中包含着变量环境(Viriable Environment)以及词法环境(Lexicol Environment) 变量环境保存着变量提升的内容 例如上面的myName 以及callName

那既然变量环境保存着这些变量提升 那变量环境对象时怎么生成的呢 我们还是用上面的代码来举例子

callName();
function callName() {
	console.log('callName Done!');
}
console.log(personName);
var personName = 'james';
  • 第一、三行不是变量声明 JavaScript引擎不做任何处理
  • 第二行 发现了function定义的函数 将函数定义储存在堆中 并在变量环境中创建一个callName的属性 然后将该属性指向中函数的位置
  • 第四行 发现var定义 于是在变量环境中创建一个personName的属性 并使用undefined初始化

经过上面的步骤后 变量环境对象就生成了 现在已经有了执行上下文和可执行代码了 接下来就是代码执行阶段了

代码执行阶段

总所周知 js执行代码是按照顺序一行一行从上往下执行的 接下来还是使用上面的例子来分析

  • 执行到callName()是 JavaScript引擎便在变量环境中寻找该函数 由于变量环境中存在该函数的引用 于是引擎变开始执行该函数 并输出"callName Done!"
  • 接下来执行到console.log(personName); 引擎在变量环境中找到personName变量 但是这时候它的值是undefined 于是输出undefined
  • 接下来执行到了var personName = 'james'这一行 在变量环境中找到personName 并将其值改成james

以上便是一段代码的编译和执行流程了 相信看到这里你对JavaScript引擎是如何执行代码的应该有了更深的了解

Q:如果代码中出现了相同的变量或者函数怎么办?

A:首先是编译阶段 如果遇到同名变量或者函数 在变量环境中后面的同名变量或者函数会将之前的覆盖掉 所以最后只会剩下一个定义

function func() {
	console.log('我是第一个定义的')
}
func();
function func() {
	console.log('我是将你覆盖掉的')
}
func();
//输出两次"我是将你覆盖掉的"

调用栈:栈溢出的原理

你在日常开发中有没有遇到过这样的报错

image-20220517152840833

根据报错我们可以知道是出现了栈溢出的问题 那什么是栈溢出呢?为什么会栈溢出呢?

Q1:什么是栈呢?

A1:一种后进先出的数据结构队列

Q2:什么是调用栈?

A2:代码中通常会有很多函数 也有函数中调用另一个函数的情况 调用栈就是用来管理调用关系的一种数据结构

当我们在函数中调用另一个函数(如调用自身的递归)然后处理不当的话 就很容易产生栈溢出 比如下面这段代码

function stackOverflow(n) {
	if(n == 1) return 1;
	return stackOverflow(n - 2);
}
stackOverflow(10000);//栈溢出

既然知道了什么是调用栈和栈溢出 那代码执行过程中调用栈又是如何工作的呢?我们用下面这个例子来举例

var personName = 'james';
function findName(name, address) {
	return name + address;
}
function findOneDetail (name, adress) {
	var tel = '110';
	detail = findName(name, address);
	return personName + detail + tel
};
findOneDetail('james', 'Lakers')

可以看到 我们在findOneDetail中调用了findName函数 那么调用栈是怎么变化的

第一步:创建全局上下文 并将其压入栈底

image-20220517154455415

接下来开始执行personName = 'james'的操作 将变量环境中的personName设置为james

第二步:执行findOneDetail函数 这个时候JavaScript会为其创建一个执行上下文 最后将其函数的执行上下文压入栈中

image-20220517155137696

接下来执行完tel = ‘110'后 将变量环境中的tel设置为110

第三步:当执行detail = findName()时 会为findName创建执行上下文并压入栈中

image-20220517155425992

接下来执行完findName函数后 将其执行上下文弹出调用栈 接下来再弹出findOneDetail的执行上下文以及全局执行上下文 至此整个JavaScript的执行流程结束

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

如何利用调用栈

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

点击source并打上断点刷新后就可以再Call Stack查到调用栈的信息(也可以通过代码中输入console.track()查看)

image-20220517155835891

2.小心栈溢出

当我们在写递归的时候 很容易发生栈溢出 可以通过尾调用优化来避免栈溢出

块级作用域:var、let以及const

作用域

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

我们都知道 使用var会产生变量提升 而变量提升会引发很多问题 比如变量覆盖 本应被销毁的变量依旧存在等等问题 而ES6引入了let 和const两种声明方式 让js有了块级作用域 那let和const时如何实现块级作用域的呢 其实很简单 原来还是从理解执行上下文开始

我们都知道 JavaScript引擎在编译阶段 会将使用var定义的变量以及function定义的函数声明在对应的执行上下文中的变量环境中创建对应的属性 当时我们发现执行上下文中还有一个词法环境对象没有用到 其实 词法环境对象便是关键之处 我们还是通过举例子来说明一下

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()
  • 第一步:执行并创建上下文

image-20220517161310897

  • 函数内部通过var声明的变量 在编译阶段全都被存放到变量环境里面了
  • 通过let声明的变量 在编译阶段会被存放到词法环境(Lexical Environment)中
  • 在函数的作用域内部 通过let声明的变量并没有被存放到词法环境中
  • 接下来 第二步继续执行代码 当执行到代码块里面时 变量环境中a的值已经被设置成了1 词法环境中b的值已经被设置成了2

这时候函数的执行上下文就如下图所示:

image-20220517161335971

可以看到 当进入函数的作用域块是 作用域块中通过let声明的变量 会被放到词法环境中的一个单独的区域中 这个区域并不邮箱作用域块外面的变量 (比如声明了b = undefined 但是不影响外面的b = 2

其实 在词法作用域内部 维护了一个小型的栈结构 栈底是函数最外层的变量 进入一个作用域块后 便会将过海作用域内部耳朵变量压到栈顶 当作用域执行完之后 就会弹出(通过letconst声明的变量)

image-20220517161748842

当作用域块执行完之后 其内部定义的变量就会从词法作用域的栈顶弹出

image-20220517161843353

总结

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

作用域链和闭包

在开始作用域链和闭包的学习之前 我们先来看下这部分代码

function callName() {
	console.log(personName);
}
function findName() {
	var personName = 'james';
	callName();
}
var personName = 'curry';
findName();//curry
//你是否以为输出james 猜想callName不是在findName中调用的吗 那findName中已经定义了personName = 'james' 那为什么是输出外面的curry呢 这其实是和作用域链有关的

在每个执行上下文的变量环境中 都包含了一个外部引用 用来执行外部的执行上下文 称之为outer

当代码使用一个变量时 会先从当前执行上下文中寻找该变量 如果找不到 就会向outer指向的执行上下文查找

image-20220517163149167

可以看到callNamefindName的outer都是指向全局上下文的 所以当在callName中找不到personName的时候 会去全局找 而不是调用callNamefindName中找 所以输出的是curry而不是james

作用域链是由词法作用域决定的

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

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

块级作用域中的变量查找

我们来看下下面这个例子

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

我们知道 如果是let或者const定义的 就会储存在词法环境中 所以寻找也是从该执行上下文的词法环境找 如果找不到 就去变量环境 还是找不到则去outer指向的执行上下文寻找 如下图

image-20220517163831591

闭包

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

首先我们看看当执行到 foo 函数内部的return innerBar这行代码时调用栈的情况 你可以参考下图:

image-20220517164204469

从上面的代码可以看出 innerBar 是一个对象 包含了 getNamesetName的两个方法 这两个方法都是内部定义的 且都引用了函数内部的变量

根据词法作用域的规则 getNamesetName总是可以访问到外部函数foo中的变量 所以当foo执行结束时 getNamesetName依然可以以后使用变量myNametest 如下图所示

image-20220517164450416

可以看出 虽然foo从栈顶弹出 但是变量依然存在内存中 这个时候 除了setNamegetName 其他任何地方都不能访问到这两个变量 所以形成了闭包

那如何使用这些闭包呢 可以通过bar来使用 当调用了bar.seyName时 如下图

image-20220517164641086

可以使用chrome的Clourse查看闭包情况

闭包怎么回收

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

如果引用闭包的函数是各局部变量 等函数销毁后 在下次JavaScript引擎执行垃圾回收的时候 判断闭包这块内容不再被使用了 就会回收

所以在使用闭包的时候 请记住一个原则:如果该闭包一直使用 可以作为全局变量而存在 如果使用频率不高且占内存 考虑改成局部变量

小练

var per = {
	name: 'curry';
	callName: function() {
		console.log(name);
	}
}
function askName(){
	let name = 'davic';
	return per.callName
}
let name = 'james';
let _callName = askName()
_callName();
per.callName();
//打印两次james
//只需要确定好调用栈就好 调用了askName()后 返回的是per.callName 后续就和askName没关系了(出栈) 所以结果就是调用了两次per.callName 根据词法作用域规则 结果都是james 也不会形成闭包

this:从执行上下文分析this

相信大家都有被this折磨的时候 而this确实也是比较难理解和令人头疼的问题 接下来我将从执行上下文的角度来分析JavaScript中的this 这里先抛出结论:this是和执行上下文绑定的 每个执行上下文都有一个this

接下来 我将带大家一起理清全局执行上下文的this和函数执行上下文的this

全局执行上下文的this

全局执行上下文的this和作用域链的最底端一样 都是指向window对象

函数执行上下文的this

我们通过一个例子来看一下

function func() {
	console.log(this)//window对象
}
func();

默认情况下调用一个函数 其执行上下文的this也是指向window对象

那如何改变执行上下文的this值呢 可以通过apply call 和bind实现 这里讲下如何使用call来改变

1.通过call

let per = {
	name: 'james',
	address: 'Lakers'
}
function callName() {
	this.name = 'curry'
}
callName.call(per);
console.log(per)//name: 'curry', address: 'Lakers'

可以看到这里this的指向已经改变了

2.通过对象调用

var person = {
	name: 'james';
	callName: function() {
		console.log(this.name)
	}
}
person.callName();//james 

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

person.callName() === person.callName.call(person)

这个时候我们如果讲对象赋给另一个全局变量 this又会怎样变化呢

var person = {
	name: 'james';
	callName: function() {
		this.name = 'curry';
		console.log(this.name);
	}
}
var per1 = person;//this又指向window
  • 在全局环境中调用一个函数 函数内部的this指向全局变量window
  • 通过一个对象调用内部的方法 该方法的this指向对象本身

3.通过构造函数设置

当使用new关键字构建好了一个新的对象 构造函数的this其实就是对象本身

this的缺陷以及应对方案

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

var person = {
	name: 'james',
	callName: function() {
		console.log(this);//指向person
		function innerFunc() {
			console.log(this)//指向window
		}
		innerFunc()
	}
}
person.callName();
//如何解决
1.使用一个变量保存
let _this = this //保存指向person的this
2.使用箭头函数
() => {
    console.log(this)//箭头函数不会创建其自身的执行上下文 所以箭头函数中的this指向外部函数
}

2.普通函数中的this指向全局对象window

在默认情况下调用一个函数 其指向上下文的this默认就是指向全局对象window

**总结:**相信看到这里 大家对于作用域 作用域链 执行上下文和this都有了更深的理解 笔者后期还会更新更多关于浏览器的原理和实践 感兴趣的小伙伴可以点波关注一起学习 文中错误之处请在评论区指出!