「本文已参与低调务实优秀中国好青年前端社群的写作活动」
变量提升
声明和赋值
var myname = 'cellinlab';
可以将上面代码看成两部分组成:
var myname; // 声明部分
myname = 'cellinlab'; // 赋值部分
对于函数来说:
function foo () {
console.log('foo');
}
var bar = function () {
console.log('bar');
}
函数 foo()
是一个完整的函数声明,没有涉及赋值操作;第二个函数,先声明了变量 bar
,再把 function () {}
赋值给 bar
。可以理解为:
变量提升
变量提升,是指在 JavaScript 代码执行的过程中,JavaScript 引擎将变量的声明部分和函数的声明部分提升到代码的顶部的“行为”。变量被提升后,会给变量设置一个默认值,默认值为 undefined
。
从字面上看,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的前面。但是,实际上,并不是这样的。实际上变量和函数的声明在代码中的位置是不变的,而是在编译阶段被 JavaScript 引擎放入内存中。
一段 JavaScript 代码在执行前需要被 JavaScript 引擎编译,编译完之后,才会进入执行阶段。
代码执行流程 之 编译阶段
showName();
console.log(myname);
var myname = 'cellinlab';
function showName() {
console.log('showName called');
}
上面代码可以被拆成两部分来看,声明部分:
var myname = undefined;
function showName() {
console.log('showName called');
}
执行部分的代码:
showName();
console.log(myname);
myname = 'cellinlab';
可以看出,输入一段代码,经过编译后,会生成两部分内容:执行上下文 和 可执行代码。
执行上下文是 JavaScript 执行一段代码时的运行环境,如调用一个函数,就会进入这个函数的执行上下文,以确定该函数在执行期间用到的诸如 this
、变量、对象以及函数等。
或者说,在执行上下文中存在一个变量环境的对象(Variable Environment),该对象中保存了变量提升的内容。
可以简单理解是这个样子的:
VariableEnvironment:
myname -> undefined
showName -> function () {
console.log('showName called');
}
变量环境对象生成的过程:
showName();
console.log(myname);
var myname = 'cellinlab';
function showName() {
console.log('showName called');
}
- 在 line 1 和 line 2 ,由于这两行代码不是声明操作,所以 JavaScript 引擎不会做任何处理;
- 在 line 3 中,使用了
var
声明,因此 JavaScript 引擎将在环境对象中创建一个名为myname
的属性,并将其初始化为undefined
; - 在 line 4 中,JavaScript 引擎发现一个通过
function
定义的函数,所以将函数定义存储到堆(Heap)中,并将函数的引用存储到环境对象中的showName
属性中;
接下来,JavasScript 引擎会把声明以外的代码编译为字节码:
showName();
console.log(myname);
myname = 'cellinlab';
代码执行流程 之 执行阶段
JavasScript 引擎按顺序逐行执行“可执行代码”:
- 当遇到
showName
函数时,JavaScript 引擎便开始在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以 JavaScript 引擎开始执行该函数,输出showName called
; - 接下来,输出
myname
的值,JavaScript 引擎在变量环境对象中查找该属性,找到myname
且其值为undefined
,所以 JavaScript 引擎输出undefined
; - 接下来,将
'cellinlab'
赋值给myname
。
VariableEnvironment:
myname -> 'cellinlab'
showName -> function () {
console.log('showName called');
}
同名变量和函数的处理
function showName() {
console.log('cell');
}
showName();
function showName() {
console.log('cellinlab');
}
showName();
- 编译阶段,遇到第一个
showName
函数,引擎会将该函数存到变量环境对象中。当遇到第二个showName
函数时,会继续存放,但是发现已经存在一个同名函数,此时,新来的函数会将之前的函数覆盖掉,变量环境对象中的showName
函数体的内容被更新为新的函数体。 - 执行阶段,从变量环境对象中查找函数,找到同名函数,执行新的函数体,输出
cellinlab
。
所以,如果一段代码中定义了两个同名函数,那么,最后生效的是晚点定义的函数。
调用栈
调用栈就是用来管理函数调用关系的一种数据结构。
函数调用
函数调用就是运行一个函数,具体方法就是使用函数名后加括号:
var a = 2;
function add () {
var b = 10;
return a + b;
}
add();
在执行到函数 add()
之前,JavaScript 引擎会为代码创建全局执行上下文,包含声明的函数和变量。
代码中的全局变量和函数都保存在全局上下文的变量环境中。
执行上下文准备好之后,便开始执行全局代码,当执行到 add
时,JavaScript 引擎识别出这是个函数调用,会进行:
- 从 全局执行上下文 中,取出
add
函数代码; - 对
add
函数代码进行编译,并创建该函数的执行上下文和可执行代码; - 执行代码,输出结果。
在执行 JavaScript 时,可能存在多个执行上下文,JavaScript 引擎通过栈来管理执行上下文。
JavaScript 的调用栈
在执行上下文创建好后,JavaScript 引擎会将执行上下文压入栈中,通常将用来管理执行上下文的栈称执行上下文栈,也叫调用栈。
var a = 2;
function add (b, c) {
return b + c;
}
function addAll(b, c) {
var d = 10;
result = add(b, c);
return a + result + d;
}
addAll(3, 6);
上面代码执行流程:
- 创建全局上下文,将其压入栈底;
- 变量
a
、函数add
和addAll
都保存到了全局上下文的变量环境对象中 - 全局上下文压入到调用栈后,JavaScript 引擎开始执行全局代码
- 变量
- 调用
addAll
函数,当调用该函数时,JavaScript 引擎会编译该函数,并为其创建一个执行上下文,最后还将该函数的执行上下文压入栈:addAll
函数的执行上下文创建好之后,便进入函数代码的执行阶段
- 当执行到
add
函数调用语句时,同样会为其创建执行上下文,并将其压入调用栈- 当
add
函数返回时,该函数的执行上下文就会从栈顶弹出,并将result
的值设置为add
函数的返回值,也就是 9 - 紧接着
addAll
执行最后一个相加操作后并返回,addAll 的执行上下文也会弹出 - 最后,调用栈中只剩下全局上下文,程序执行完毕
- 当
可以看出,调用栈是 JavaScript 引擎追踪函数执行的一个机制,当一次有多个函数被调用时,通过调用栈就能追踪到哪个函数正在被执行,以及各函数之间的关系。
块作用域
作用域
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
在 ES6 之前,作用域只有两种:
- 全局作用域:其中的内容在代码中的任何地方都能访问,其生命周期与页面的生命周期相同,只要页面存在,其内容就存在;
- 函数作用域:在函数内部定义的变量或函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束后,函数内部定义的变量也会被销毁。
块级作用域是一对大括号包裹的一段代码,如函数、判断语句、循环语句,甚至单独的一个 {}
都可以被看做是一个块级作用域。对于支持块作用域的语言,代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完之后,代码块中定义的变量会被销毁。
因为,在 ES6 之前,是不支持块级作用域的。没了块级作用域,再把作用域内部的变量统一提升无疑是最快速、最简单的设计,但是,这导致了函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以,这些变量在整个函数体内部的任何地方都是能被访问的。
变量提升带来的问题
-
变量容易在不被察觉的情况下被覆盖掉
var myname = 'cellinlab'; function showName () { console.log(myname); if (0) { var myname = 'cell'; } console.log(myname); } showName(); // undefined // undefined // undefined
- 执行上下文和调用栈的状态
showName
函数的执行上下文创建后,JavaScript 引擎便开始执行showName
函数内部的代码- 调用栈中有两个 myname 变量:一个在全局上下文中,其值是
cellinlab
;另一个在showName
函数的执行上下文中,其值是undefined
- 在函数执行过程中,JavaScript 会优先从当前的执行上下文中查找变量,由于变量提升,当前的执行上下文中就包含了变量
myname
值是undefined
,故 输出undefined
- 执行上下文和调用栈的状态
-
本应销毁的变量没有被销毁
function foo () { for (var i = 0; i < 7; i++) { } console.log(i); } foo(); // 7
- 在创建执行上下文是,
i
被提升,当for
循环结束时,i
并没有被销毁。
- 在创建执行上下文是,
ES6 如何解决变量提升带来的缺陷
ES6 引入了 let
和 const
关键字,从此 JavaScript 也有了 块作用域。
function varTest () {
var x = 1;
if (ture) {
var x = 2;
console.log(x); // 2
}
console.log(x); // 2
}
varTest();
使用 let
:
function letTest () {
let x = 1;
if (ture) {
let x = 2;
console.log(x); // 2
}
console.log(x); // 1
}
let
支持块级作用域,在编译阶段,JavaScript 引擎并不会把 if
块中的变量提升,使其在全函数可见范围内。
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();
// 1 3 2 undefined undefined
- 编译并创建执行上下文
- 函数内部通过
var
声明的变量,在编译阶段全都被存放到变量环境里面 - 通过
let
声明的变量,在编译阶段会被存放到 词法环境 中 - 在函数的作用域内部,通过
let
声明的变量并没有被存放到词法环境中
- 函数内部通过
- 继续执行代码,当执行到代码块里面时:
- 当进入函数的作用域块时,作用域块中通过
let
声明的变量,会被存放在词法环境的一个单独的区域中,该区域中的变量不会影响块作用域外的变量 - 实质上,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶,并在执行完该作用域后弹出
- 在读取值时,会现在词法环境找,找不到再去变量环境中找
- 等作用域块执行结束后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如下
- 当进入函数的作用域块时,作用域块中通过
作用域和闭包
function bar () {
console.log(myName);
}
function foo () {
var myName = 'cell';
bar();
}
var myName = 'cellinlab';
foo();
// cellinlab
作用域链
在每个执行上下文的变量环境中,都包含一个外部引用,用来指向外部的执行上下文,可以称为 outer。
当一段代码使用了一个变量时,JavaScript 引擎首先会在 “当前的执行上下文” 中查找该变量。如果当前的环境变量中没有找到,会继续在 outer 所指向的执行上下文中查找。
bar
函数 和 foo
函数的 outer 都是指向全局上下文的,也就意味着如果在 bar
函数或者 foo
函数中使用了外部变量,那么 JavaScript 引擎回去全局执行上下文中查找。把这个查找的路径称为 作用域链。
不过,有个疑问,foo
函数中调用的 bar
函数,为什么 bar
函数的外部引用是全局上下文,而不是 foo
函数的执行上下文?这需要了解词法作用域,JavaScript 执行过程中,其作用域链是由词法作用域决定的。
词法作用域
词法作用域指作用域由代码中函数声明的位置来决定的,所以词法作用域就是静态作用域,通过它能够预测代码在执行过程中如何查找标识符。
上图中的词法作用域链是:foo
函数 -> bar
函数 -> main
函数 -> 全局作用域。
词法作用域是代码阶段就决定好的,和函数怎么调用没有关系。
闭包
function foo () {
var myName = 'cell';
let test1 = 1;
const test2 = 2;
var innerBar = {
getName: function () {
console.log(test1);
return myName;
},
setName: function (name) {
myName = name;
}
};
return innerBar;
}
var bar = foo();
bar.setName('cellinlab');
console.log(bar.getName());
// 1
// cellinlab
根据词法作用域的规则,内部函数 getName
和 setName
总是可以访问它们的外部函数 foo
中的变量。当 innerBar
对象返回给全局变量 bar
时,虽然 foo
函数已经执行结束,但是 getName
和 setName
函数依然可以使用 foo
函数中的变量。在函数执行完后,其执行上下文弹出了,但是由于返回的方法中使用了 foo
中的变量 myName
和 test1
,所以这两个变量依然存在于内存中,这就是闭包。
在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个外部函数后,即使该外部函数已经执行结束,但是内部函数引用外部函数的变量依然保存在内存中,就把这些变量的集合称为闭包。如外部函数是 foo
,那这些变量的集合就称为 foo
函数的闭包。
闭包回收
通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;如果这个闭包以后不再使用的话,就会造成内存泄漏。
如果引用闭包的函数是局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。
需要注意:如果闭包会一直使用,那么它可以作为全局变量存在;如果使用频率不高,而且占用内存较大的话,就尽量让它成为一个局部变量。
this
JavaScript 中的 this 是什么
this 是和执行上下文绑定的,即每个执行上下文中都有一个 this
。执行上下文主要有三种:全局执行上下文、函数执行上下文和 eval
执行上下文,那对应的 this
就有三种。
全局执行上下文中的 this
全局执行上下文中的 this
指向的是全局对象,即 window
。这也是 this
和作用域链的唯一交点,作用域链的最低端包含了 window
对象,全局执行上下文中的 this
也是指向 window
对象。
函数执行上下文中的 this
function foo() {
console.log(this);
}
foo();
// window
默认情况下调用一个函数,其执行上下文中的 this
是指向 window
对象的。可以通过一些方法来设置函数执行上下文中的 this
值:
- 通过函数的
call
方法设置(还有bind
和apply
)
let bar = {
myName: 'cell',
test1: 1,
};
function foo () {
this.myName = 'cellinlab';
}
foo.call(bar);
console.log(bar.myName); // cellinlab
- 通过对象调用方法设置
var myObj = {
name: 'cellinlab',
showName: function () {
console.log(this.name);
}
}
myObj.showName(); // cellinlab
- 使用对象来调用其内部的一个方法,该方法的
this
指向对象本身
var name = 'default';
var myObj = {
name: 'cellinlab',
showName: function () {
console.log(this.name);
this.name = 'cell';
console.log(this.name);
}
};
var foo = myObj.showName;
foo();
// default
// cell
- 在全局环境中调用一个函数,函数内部的
this
指向对象本身
- 通过构造函数设置
function CreateObj () {
this.name = 'cellinlab';
}
var myObj = new CreateObj();
console.log(myObj.name); // cellinlab
- 当执行
new CreateObj()
时,JavaScript 引擎会:- 创建一个空对象
tempObj
- 调用
CreateObj.call
,并将tempObj
作为call
的参数,这样当CreateObj
的执行上下文创建时,它的this
指向的就是tempObj
- 执行
CreateObj
函数,此时的CreateObj
函数上下文中的this
指向了tempObj
对象; - 返回
tempObj
对象
- 创建一个空对象
- 可以理解为:
var tempObj = {};
CreateObj.call(tempObj);
var myObj = tempObj;
this 的设计缺陷及应对方案
-
嵌套函数中的
this
不会从外层函数中继承var name = 'default'; var myObj = { name: 'cellinlab', showName: function () { console.log(this.name); function foo () { console.log(this.name); }; foo(); } }; myObj.showName(); // cellinlab // default
foo
中的this
指向的是全局 window 对象,而函数showName
中的this
指向的是myObj
对象- 可以通过在
showName
中声明式地引用this
来解决这个问题,这种方案的本质是将this
体系转换为了作用域的体系
var name = 'default'; var myObj = { name: 'cellinlab', showName: function () { console.log(this.name); const self = this; function foo () { console.log(self.name); }; foo(); } }; myObj.showName(); // cellinlab // cellinlab
- 也可以使用箭头函数解决这个问题,因为箭头函数不会创建自身的执行上下文,所以箭头函数中的
this
取决于它的外部函数
var name = 'default'; var myObj = { name: 'cellinlab', showName: function () { console.log(this.name); const self = this; const foo = () => { console.log(self.name); }; foo(); } }; myObj.showName(); // cellinlab // cellinlab
-
普通函数中的
this
默认指向全局对象window
如果要让函数执行上下文中的 this
指向某个对象,最好的方法是使用 call
来做显示绑定。
也可以通过 严格模式 来解决,严格模式下,默认执行一个函数,其函数的执行上下文中的 this
是 undefined
。