JavaScript如何实现let/const
调用栈、执行上下文、作用域、作用域链、闭包、this…
JavaScript为什么要引入let/const
- 解决现有缺陷/问题
- 提供新功能/特性
缺陷-变量提升(Hoisting)
showName()
console.log(myname)
var myname = '极客时间'
function showName() {
console.log('函数 showName 被执行');
}
什么是变量提升
先简单理解什么是声明、赋值
var myname = '极客时间'
var myname // 声明部分
myname = '极客时间' // 赋值部分
// 直接申明一个函数
function foo(){
console.log('foo')
}
// 先申明变量bar,再将函数赋值给bar
var bar = function(){
console.log('bar')
}
所谓的变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的 undefined。
var myname = undefined
function showName() {
console.log('showName 被调用');
}
showName()
console.log(myname)
myname = '极客时间'
笼统概括一下在定义之前使用变量或者函数的原因:函数和变量在执行之前都提升到了代码开头
变量提升时机、为什么?
JavaScript代码执行流程
实际上变量和函数声明在代码里的位置是不会改变的,而且是在编译阶段被 JavaScript 引擎放入内存中
编译阶段和变量提升有何关系?
输入一段代码,经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码。
执行上下文是 JavaScript 执行一段代码时的运行环境,执行期间用到的诸如变量、对象、this以及函数都来自执行上下文
小结:
- 一段JavaScript代码的执行机制:先编译,再执行
- 变量提升就在编译阶段,变量和函数会放到执行上下文的变量环境中,后续执行时,js引擎就会从变量环境中去查找自定义的变量和函数
showName()
var showName = function() {
console.log(2)
}
function showName() {
console.log(1)
}
调用栈
为什么会出现栈溢出?
function showName() {
console.log(1)
showName()
}
showName()
调用栈就是用来管理函数调用关系的一种数据结构
先弄明白函数调用和栈结构
函数调用
function showName() {
var a = 1
console.log(a)
}
showName()
在执行到函数 showName()
之前,JavaScript 引擎会为上面这段代码创建全局执行上下文
执行到showName()
时,js引擎判断这是一个函数调用,进行以下操作:
- 从全局执行上下文中取出showName函数代码
- 对这段代码进行编译,并创建该函数的执行上下文和可执行代码
- 执行
那么就有了两个执行上下文,如何管理?
栈
调用栈
栈溢出:调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,我们把这种错误叫做栈溢出。
小结:
- 每调用一个函数,JavaScript 引擎会为其创建执行上下文,并把该执行上下文压入调用栈,然后 JavaScript 引擎开始执行函数代码。
- 如果在一个函数 A 中调用了另外一个函数 B,那么 JavaScript 引擎会为 B 函数创建执行上下文,并将 B 函数的执行上下文压入栈顶。
- 当前函数执行完毕后,JavaScript 引擎会将该函数的执行上下文弹出栈。
- 当分配的调用栈空间被占满时,会引发“栈溢出”问题。
特性-块级作用域
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
在 ES6 之前,ES 的作用域只有两种:全局作用域和函数作用域。
- 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
- 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。
作用域与执行上下文
什么是块级作用域?
块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。
function showName() {
if(1) {
var name = 'a'
}
console.log(name)
}
showName()
char* myname = " 极客时间 ";
void showName() {
printf("%s \n",myname);
if(0){
char* myname = " 极客邦 ";
}
}
void main(){
showName();
}
和 Java、C/C++ 不同,ES6 之前是不支持块级作用域的,因为当初设计这门语言的时候,并没有想到 JavaScript 会火起来,所以只是按照最简单的方式来设计。没有了块级作用域,再把作用域内部的变量统一提升无疑是最快速、最简单的设计,不过这也直接导致了函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都是能被访问的,这也就是 JavaScript 中的变量提升。
变量提升和块级作用域缺失所带来的问题
// 缺失块级作用域:本该销毁的变量没有销毁
function foo(){
for (var i = 0; i < 7; i++) {
}
console.log(i);
}
foo()
// 变量提升+缺失块级作用域,造成不符合直觉的结果
var myname = " 极客时间 "
function showName(){
console.log(myname);
if(0){
var myname = " 极客邦 "
}
console.log(myname);
}
showName()
为了解决这些问题,ES6 引入了 let 和 const 关键字
let/const是如何解决这些问题的
在同一段代码中,ES6 是如何做到既要支持变量提升的特性,又要支持块级作用域的呢?
JavaScript如何解决变量提升的问题
function foo(){
console.log(a)
console.log(b)
var a = 1
let b = 2
}
foo()
支持块级作用域
作用块内声明的变量不影响块外面的变量。
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()
要站在执行上下文的角度来分析
- 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。
- 通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。
在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。
小结
由于 JavaScript 的变量提升存在着变量覆盖、变量污染等设计缺陷,所以 ES6 引入了块级作用域关键字来解决这些问题。之后我们还通过对变量环境和词法环境的介绍,分析了 JavaScript 引擎是如何同时支持变量提升和块级作用域的。
let myname= '极客时间'
{
console.log(myname)
let myname= '极客邦'
}
代码中出现相同的变量,JavaScript引擎是如何选择的?
function bar() {
console.log(myName)
}
function foo() {
var myName = " 极客邦 "
bar()
}
var myName = " 极客时间 "
foo()
作用域链
其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为outer。
当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量,
比如上面那段代码在查找 myName 变量时,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。
这个查找的链条就称为作用域链
foo 函数调用的 bar 函数,那为什么 bar 函数的外部引用是全局执行上下文,而不是 foo 函数的执行上下文?
还需要知道什么是词法作用域。这是因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。
词法作用域
词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。
也就是说,词法作用域是代码阶段就决定好的,和函数是怎么调用的没有关系。
块级作用域中的变量查找
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 函数已经执行结束,但是 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 引擎的垃圾回收器就会回收这块内存。
使用闭包的时候,要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。
小结:
- 首先,介绍了什么是作用域链,我们把通过作用域查找变量的链条称为作用域链;作用域链是通过词法作用域来确定的,而词法作用域反映了代码的结构。
- 其次,介绍了在块级作用域中是如何通过作用域链来查找变量的。
- 最后,又基于作用域链和词法环境介绍了到底什么是闭包。
关于词法作用域和闭包
var bar = {
myName:"time.geekbang.com",
printName: function () {
console.log(myName)
}
}
let myName = " 极客邦 "
bar.printName()
//function foo() {
// let myName = " 极客时间 "
// return bar.printName
//}
// let _printName = foo()
// _printName()
foo函数中的myName会成为闭包吗?
this
为什么有this?
bar.myName
在对象内部的方法中使用对象内部的属性是一个非常普遍的需求。但是 JavaScript 的作用域机制并不支持这一点,基于这个需求,JavaScript 又搞出来另外一套this 机制。
区分清楚作用域链和this是两套不同的系统,它们之间基本没太多联系。明确这点,可以避免在学习 this 的过程中,和作用域产生一些不必要的关联。
全局执行上下文中的 this和函数执行上下文中的 this。
全局执行上下文中的 this
console.log(this)
来打印出来全局执行上下文中的 this,最终输出的是 window 对象。所以可以得出这样一个结论:全局执行上下文中的 this 是指向 window 对象的。
函数执行上下文中的 this
function foo(){
console.log(this)
}
foo()
执行这段代码,打印出来的也是 window 对象,这说明在默认情况下调用一个函数,其执行上下文中的 this 也是指向 window 对象的。
能不能设置执行上下文中的 this 来指向其他对象呢?
有下面三种方式来设置函数执行上下文中的 this 值。
- call/apply/bind
- 通过对象来调用方法
使用对象来调用其内部的一个方法,该方法的 this 是指向对象本身的。
- 通过构造函数来设置this
this的设计缺陷
嵌套函数中的 this 不会从外层函数中继承
var myObj = {
name : " 极客时间 ",
showThis: function(){
console.log(this)
function bar(){console.log(this)}
bar()
}
}
myObj.showThis()
可以通过一个小技巧来解决这个问题,比如在 showThis 函数中声明一个变量 self 用来保存 this,然后在 bar 函数中使用 self
这个方法的的本质是把 this 体系转换为了作用域的体系。
也可以使用 ES6 中的箭头函数来解决这个问题
这是因为 ES6 中的箭头函数并不会创建其自身的执行上下文,所以箭头函数中的 this 取决于它的外部函数。
发现将近一半的时间都是在谈 JavaScript 的各种缺陷,比如变量提升带来的问题、this 带来问题等。作者认为了解一门语言的缺陷并不是为了否定它,相反是为了能更加深入地了解它。我们在谈论缺陷的过程中,还结合 JavaScript 的工作流程分析了出现这些缺陷的原因,以及避开这些缺陷的方法。掌握了这些,相信今后在使用 JavaScript 的过程中会更加得心应手。