一个bug了解JS的核心知识点

由一段代码来开始对JS中一些知识点的学习

1
2
3
4
5
6
7
8
9
10
11
12
13
function wrapElements(a){
var result = [] , i , length;
for (i = 0 ,length = a.length; i < length; i ++){
result[i] = function (){
return a[i];
};
}
return result;
}

var wrapped = wrapElements([10,20,30,40,50]);
var f = wrapped[0];
f(); // ?

预想的这里要输出10,但是实际上却是udefined

这个bug代码段的主要原因是作用域问题,但是还涉及到闭包变量提升立即调用函数等,因此做一个笔记。

作用域

ES5只有全局作用域函数作用域而没有块级作用域,这跟其他的很多编程语言都是不一样的,而且这样会照成很多的不合理的场景。

至于js没有块级作用域的意思是,变量定义的作用域不是离其最近的封闭语句或者代码块,而是包含他的函数。

但是到了ES6,新增了let命令,他用来声明的变量只在代码块中有效,这样就形成了一个块级作用域,另外,ES6新增的const也是可以形成块级作用域的。

1
2
3
4
5
6
7
{
let a = 10;
var b = 2;
}

a // ReferenceError
b // 2

闭包

难以理解闭包的一点其实是:闭包存储的是其外部变量的引用而不是其值。这一点和OC中的block是一致的。

在js中关于闭包你需要知道的有三条:

  1. js允许引用当前函数以外定义的变量
  2. 即使当前函数已经返回,闭包也可以引用到变量的值
  3. 闭包可以更新外部变量的值

另外一点是,闭包比创建他们的函数有更长的生命周期,这么看来真的和OC中的block是一样的。

绑定与赋值

在运行时进入到一个作用域,js会为每一个绑定到该作用域的变量在内存中分配一个槽(slot),这里的wrapElements函数绑定了三个局部变量:resultilength。在循环体里面,为result存储的每一个变量分配了一个闭包

在这里由于赋值变量为一个闭包,我们希望的是该闭包函数存储的是其创建时的i值。但是实际上由于上述闭包的特性,这里存储的是i的引用,在循环结束之前,i都是变化的,最后固定为5,然后结束循环。

因此这里所有的result数组中的变量存储的都是一个返回a[5]的匿名函数,到这里,就应该明白了,a数组一共才存储了5个数据,最大索引是4,对他进行索取第5个元素肯定是undefined的。

换句话说,绑定和赋值就是对变量的声明行为:对变量的声明赋值。js隐式的提升声明部分到封闭函数的顶部(绑定行为,为变量分配槽),从而使得变量的作用域就是整个函数,而将赋值留在原地。

变量提升(Hoists)

既然js会为当前作用域下绑定的变量分配槽,那么,我这么写会不会就会在循环体中一直创建新的槽

1
2
3
4
5
6
7
8
9
function wrapElements(a){
var result = [];
for (var i = 0 ,length = a.length; i < length; i ++){
result[i] = function (){
return a[i];
};
}
return result;
}

运行之后发现,结果还是一样的,这是因为js还有一个特性叫变量提升

var i = 0 ,length = a.length由于js的变量提升,实际执行效果相当于一开始的写法。

变量提升的意思一句话来说就是:脚本开始运行时,会把该变量提升到该作用域的头部进行声明

一个简单的变量提升例子:

1
2
3
4
5
6
7
8
9
10
function oop(){
console.log(b);
console.log(a);
let b = 4;
var a = 3;
}

oop();
// ReferenceError
// undefined

实际上在js编译的时候是这个样子的:

1
2
3
4
5
6
7
8
9
function oop(){
var a;

console.log(b);// ReferenceError
console.log(a);// undefined

let b = 4;
a = 3;
}

这是由于let不会发生变量提升,所以打印b的时候报错。var将a提升到当前作用域的头部,对a的打印为没有赋值。

Airbnb的js编码规范中提到,尽量在作用域的顶部声明该作用域下的全局变量,这更可以有效的避免变量声明提升的问题。

另外Airbnb关于提升总结了以下有几点:

  • 变量声明会提升至作用域的顶部,但赋值不会

  • 匿名函数表达式会提升他们的变量名,但不会提升函数的赋值。这一点其实是和普通的变量是一样的。

1
2
3
4
5
6
7
8
9
10
11
function exp(){

console.log(foo); // undefined,这里还没有为其赋值,所以为undefined

foo(); // TypeError,这里js是不知道foo这个变量是什么类型的,因此报类型错误

var foo = function(){ // 直到这里,foo这个变量才被决定作为一个函数

console.log('this is foo .');
}
}
  • 命名函数表达式会提升变量名,但是不会提升函数名和函数体。这一点和上一条的匿名函数式一样的。
1
2
3
4
5
6
7
8
9
10
11
12
function example() {
console.log(named); // => undefined

named(); // => TypeError named is not a function

superPower(); // => ReferenceError superPower is not defined

var named = function superPower() {
console.log('Flying');
};
}
// 对于命名函数,即使变量名和函数名一样(`将superPower替换为named`),结果还是一样的。
  • 函数声明提升他们的名字和函数体
1
2
3
4
5
6
7
function example() {
superPower(); // => Flying

function superPower() {
console.log('Flying');
}
}

立即调用函数(IIFE)

在js中函数是一等公民,这一点是使用js的程序员都知道的。关于函数,他可以作为一个变量,也可以作为另一个函数的参数以及返回值

声明一个函数很简单:

1
2
3
function foo(){
console.log('this is a function');
}

将一个函数作为一个变量的值也可以有多种方法:

1
2
3
4
5
6
7
8
9
10
var f = function foo(){
return 'name';
}
f();

// or 使用匿名函数
var f = function (){
return 'name';
}
f();

而立即调用函数则是函数的一个新写法,他会在书写的地方立即执行,不需要调用:

1
2
3
(function(){
...
})();

如果这个函数有参数,也可以这样写,只不过上一种把参数省略掉了

1
2
3
4
5
6
7
var outerI = 'green';

(function(i){

console.log(i);

})(outerI);

其实这做是没有必要的,因为立即调用函数可以获得到当前作用域内所有的变量,大可这么写

1
2
3
4
5
6
7
8
9
var outerI = 'green';

(function(){

var j = outerI;

console.log(j);

})();

或者直接省略中间变量j,这都得益于上面闭包的特性:

1
2
3
4
5
6
7
var outerI = 'green';

(function(){

console.log(outerI);

})();

使用函数需要注意的点:

  • 永远不要在一个非函数代码块(if、while、for等)中声明一个函数,要把那个函数赋给一个变量,而且使用函数表达式来实现。

先来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function f(){return 'global';};

function test(x){

function f() {return 'local';};

var result[];

if(x){

result.push(f());
}

result.push(f());

return result;
}

test(true); // ['local','local']
test(false); // ['local']

上面的代码没有问题,如果稍微修改一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function f(){return 'global';};

function test(x){

var result[];

if(x){

function f() {return 'local';};

result.push(f());
}

result.push(f());

return result;
}

test(true); // ['local','global'] ?
test(false); // ['global'] ?

输出结果真的是这样的么?真实的输出结果其实是:

1
2
test(true);     // ['local','local']
test(false); // ['local']

我们想当然的以为if代码块中的f()是一个局部变量,只有在x === true的时候才会放入result中,但是实际上在if代码块中的f()函数是当前作用域下的全局变量,不是if代码块内部的局部变量,if代码块不会产生作用域,这一点和其他的编程语言可能会有些许不同。

因此,一个好的编码规范是如下这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// bad
if (currentUser) {
function test() {
console.log('Nope.');
}
}

// good
var test;
if (currentUser) {
test = function test() {
console.log('Yup.');
};
}

最后,如何修改一开始的代码bug呢

上面说了,照成这样的bug是由于作用域问题,那么合理使用作用域就可以解决这样的bug,或者说合理的使用作用域就可以有效的规避这样的bug出现。

1.使用let

由于使用var存在变量提升,那么就使用let不让他变量提升

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function wrapElements(a){

var result = [] , length;

for (let i = 0 ,length = a.length; i < length; i ++){

result[i] = function (){

return a[i];

};

}

return result;
}

2.使用立即调用函数

另外是使用立即调用函数,强制性的创建一个局部作用域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function wrapElements(a){

var result = [] , length;

for (let i = 0 ,length = a.length; i < length; i ++){

(function(){

var j = i;

result[i] = function (){

return a[j];
};

})();

}

return result;
}

学到了什么

  • 在for循环计数器中,最好使用let命令
  • js作用域规则
  • 闭包的特点和OC中的block是一样的