变量提升的原理:JavaScript的执行顺序
变量提升:JavaScript
代码执行过程中 JavaScript引擎把变量的声明部分和函数的声明部分提升到代码开头的行为 (变量提升后以undefined
设为默认值)
1 | callName(); |
JavaScript代码的执行流程:有些人认为 变量提升就是将声明部分提升到了最前面的位置 其实这种说法是错的 因为变量和函数声明在代码中的位置是不会变的 之所以会变量提升是因为在编译阶段被JavaScript引擎放入内存中(换句话来说 js代码在执行前会先被JavaScript引擎编译 然后才会进入执行阶段)流程大致如下图
那么编译阶段究竟是如何做到变量提升的呢 接下来我们一起来看看 我们还是以上面的那段代码作为例子
第一部分:变量提升部分的代码
1 | function callName() { |
第二部分:代码执行部分
1 | callName(); |
执行图如下
可以看到 结果编译后 会在生成执行上下文和可执行代码两部分内容
执行上下文:JavaScript代码执行时的运行环境(比如调用一个函数 就会进入这个函数的执行上下文 确定函数执行期间的this
、变量、对象等)在执行上下文中包含着变量环境(Viriable Environment)以及词法环境(Lexicol Environment) 变量环境保存着变量提升的内容 例如上面的myName
以及callName
那既然变量环境保存着这些变量提升 那变量环境对象时怎么生成的呢 我们还是用上面的代码来举例子
1 | callName(); |
- 第一、三行不是变量声明 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:首先是编译阶段 如果遇到同名变量或者函数 在变量环境中后面的同名变量或者函数会将之前的覆盖掉 所以最后只会剩下一个定义
1 | function func() { |
调用栈:栈溢出的原理
你在日常开发中有没有遇到过这样的报错
根据报错我们可以知道是出现了栈溢出的问题 那什么是栈溢出呢?为什么会栈溢出呢?
Q1:什么是栈呢?
A1:一种后进先出的数据结构队列
Q2:什么是调用栈?
A2:代码中通常会有很多函数 也有函数中调用另一个函数的情况 调用栈就是用来管理调用关系的一种数据结构
当我们在函数中调用另一个函数(如调用自身的递归)然后处理不当的话 就很容易产生栈溢出 比如下面这段代码
1 | function stackOverflow(n) { |
既然知道了什么是调用栈和栈溢出 那代码执行过程中调用栈又是如何工作的呢?我们用下面这个例子来举例
1 | var personName = 'james'; |
可以看到 我们在findOneDetail
中调用了findName
函数 那么调用栈是怎么变化的
第一步:创建全局上下文 并将其压入栈底
接下来开始执行personName = 'james'
的操作 将变量环境中的personName
设置为james
第二步:执行findOneDetail
函数 这个时候JavaScript
会为其创建一个执行上下文 最后将其函数的执行上下文压入栈中
接下来执行完tel = ‘110'
后 将变量环境中的tel
设置为110
第三步:当执行detail = findName()
时 会为findName
创建执行上下文并压入栈中
接下来执行完findName
函数后 将其执行上下文弹出调用栈 接下来再弹出findOneDetail
的执行上下文以及全局执行上下文 至此整个JavaScript
的执行流程结束
所以调用栈是JavaScript引擎追踪函数执行的一个机制 当一次有多个函数被调用时 通过调用栈就能追踪到哪个函数正在被执行以及各函数之间的调用关系
如何利用调用栈
1.使用浏览器查看调用栈的信息
点击source并打上断点刷新后就可以再Call Stack查到调用栈的信息(也可以通过代码中输入console.track()
查看)
2.小心栈溢出
当我们在写递归的时候 很容易发生栈溢出 可以通过尾调用优化来避免栈溢出
块级作用域:var、let以及const
作用域
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期
我们都知道 使用var会产生变量提升 而变量提升会引发很多问题 比如变量覆盖 本应被销毁的变量依旧存在等等问题 而ES6引入了let 和const两种声明方式 让js有了块级作用域 那let和const时如何实现块级作用域的呢 其实很简单 原来还是从理解执行上下文开始
我们都知道 JavaScript引擎在编译阶段 会将使用var定义的变量以及function定义的函数声明在对应的执行上下文中的变量环境中创建对应的属性 当时我们发现执行上下文中还有一个词法环境对象没有用到 其实 词法环境对象便是关键之处 我们还是通过举例子来说明一下
1 | function foo(){ |
- 第一步:执行并创建上下文
- 函数内部通过var声明的变量 在编译阶段全都被存放到变量环境里面了
- 通过let声明的变量 在编译阶段会被存放到词法环境(Lexical Environment)中
- 在函数的作用域内部 通过let声明的变量并没有被存放到词法环境中
- 接下来 第二步继续执行代码 当执行到代码块里面时 变量环境中a的值已经被设置成了1 词法环境中b的值已经被设置成了2
这时候函数的执行上下文就如下图所示:
可以看到 当进入函数的作用域块是 作用域块中通过let声明的变量 会被放到词法环境中的一个单独的区域中 这个区域并不邮箱作用域块外面的变量 (比如声明了b = undefined
但是不影响外面的b = 2
)
其实 在词法作用域内部 维护了一个小型的栈结构 栈底是函数最外层的变量 进入一个作用域块后 便会将过海作用域内部耳朵变量压到栈顶 当作用域执行完之后 就会弹出(通过let
和const
声明的变量)
当作用域块执行完之后 其内部定义的变量就会从词法作用域的栈顶弹出
总结
块级作用域就是通过词法环境的栈结构来实现的 而变量提升是通过变量环境来实现 通过这两者的结合 JavaScript引擎也就同时支持了变量提升和块级作用域了。
作用域链和闭包
在开始作用域链和闭包的学习之前 我们先来看下这部分代码
1 | function callName() { |
在每个执行上下文的变量环境中 都包含了一个外部引用 用来执行外部的执行上下文 称之为outer
当代码使用一个变量时 会先从当前执行上下文中寻找该变量 如果找不到 就会向outer指向的执行上下文查找
可以看到callName
和findName
的outer都是指向全局上下文的 所以当在callName
中找不到personName
的时候 会去全局找 而不是调用callName
的findName
中找 所以输出的是curry
而不是james
作用域链是由词法作用域决定的
词法作用域就是指作用域是由代码中函数声明的位置来决定的 所以词法作用域是静态的作用域 通过它就能够预测代码在执行过程中如何查找表示符
所以词法作用域是代码阶段就决定好的 和函数怎么调用的没有关系
块级作用域中的变量查找
我们来看下下面这个例子
1 | function bar() { |
我们知道 如果是let
或者const
定义的 就会储存在词法环境中 所以寻找也是从该执行上下文的词法环境找 如果找不到 就去变量环境 还是找不到则去outer指向的执行上下文寻找 如下图
闭包
JavaScript 中 根据词法作用域的规则 内部函数总是可以访问其外部函数中声明的变量 当通过调用一个外部函数返回一个内部函数后 即使该外部函数已经执行结束了 但是内部函数引用外部函数的变量依然保存在内存中 我们就把这些变量的集合称为闭包
举个例子
1 | function foo() { |
首先我们看看当执行到 foo
函数内部的return innerBar
这行代码时调用栈的情况 你可以参考下图:
从上面的代码可以看出 innerBar
是一个对象 包含了 getName
和setName
的两个方法 这两个方法都是内部定义的 且都引用了函数内部的变量
根据词法作用域的规则 getName
和setName
总是可以访问到外部函数foo
中的变量 所以当foo
执行结束时 getName
和setName
依然可以以后使用变量myName
和test
如下图所示
可以看出 虽然foo
从栈顶弹出 但是变量依然存在内存中 这个时候 除了setName
和getName
其他任何地方都不能访问到这两个变量 所以形成了闭包
那如何使用这些闭包呢 可以通过bar来使用 当调用了bar.seyName
时 如下图
可以使用chrome的Clourse查看闭包情况
闭包怎么回收
通常 如果引用闭包的函数是一个全局变量 那么闭包会一直存在直到页面关闭 但如果这个闭包以后不再使用的话 就会造成内存泄漏
如果引用闭包的函数是各局部变量 等函数销毁后 在下次JavaScript引擎执行垃圾回收的时候 判断闭包这块内容不再被使用了 就会回收
所以在使用闭包的时候 请记住一个原则:如果该闭包一直使用 可以作为全局变量而存在 如果使用频率不高且占内存 考虑改成局部变量
小练
1 | var per = { |
this:从执行上下文分析this
相信大家都有被this
折磨的时候 而this确实也是比较难理解和令人头疼的问题 接下来我将从执行上下文的角度来分析JavaScript中的this
这里先抛出结论:this是和执行上下文绑定的 每个执行上下文都有一个this
接下来 我将带大家一起理清全局执行上下文的this
和函数执行上下文的this
、
全局执行上下文的this
全局执行上下文的this
和作用域链的最底端一样 都是指向window
对象
函数执行上下文的this
我们通过一个例子来看一下
1 | function func() { |
默认情况下调用一个函数 其执行上下文的this也是指向window对象
那如何改变执行上下文的this
值呢 可以通过apply call 和bind
实现 这里讲下如何使用call
来改变
1.通过call
1 | let per = { |
可以看到这里this
的指向已经改变了
2.通过对象调用
1 | var person = { |
使用对象来调用其内部方法 该方法的this指向对象本身的
1 | person.callName() === person.callName.call(person) |
这个时候我们如果讲对象赋给另一个全局变量 this
又会怎样变化呢
1 | var person = { |
- 在全局环境中调用一个函数 函数内部的
this
指向全局变量window
- 通过一个对象调用内部的方法 该方法的
this
指向对象本身
3.通过构造函数设置
当使用new
关键字构建好了一个新的对象 构造函数的this
其实就是对象本身
this的缺陷以及应对方案
1.嵌套函数的this不会从外层函数中继承
1 | var person = { |
2.普通函数中的this指向全局对象window
在默认情况下调用一个函数 其指向上下文的this
默认就是指向全局对象window
总结:相信看到这里 大家对于作用域 作用域链 执行上下文和this都有了更深的理解 笔者后期还会更新更多关于浏览器的原理和实践 感兴趣的小伙伴可以点波关注一起学习 文中错误之处请在评论区指出!