解构再组合 - 「你不知道的JavaScript」(一)
作用域和闭包
不要用eval
考虑如下代码:
function foo(str, a) {
eval( str );
console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 );
想30秒,你认为会输出什么?
是1, 3。也就是eval导致了原本在外部定义的变量b,被eval动态生成的局部变量b给替换了。
所以如果eval的内容能够被最终用户修改的话,那这会带来不可控的后果。
最后,在程序中动态生成代码的使用场景非常罕见,因为它所带来的好处无法抵消性能上的损失。
最小暴露原则
考虑如下代码:
function doSomething(a) {
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
function doSomethingElse(a) {
return a - 1;
}
var b;
doSomething( 2 ); // 15
你觉得它的问题在哪里?
答案是变量b被无意义的暴露在了外部环境,如果外部代码无意识的修改了b的值,就会影响到doSomething函数的正确输出。下面的代码就演示了如何避免这种可能:
function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
doSomething( 2 ); // 15
在使用变量前,一定要声明该变量
考虑如下代码:
function foo() {
function bar(a) {
i = 3;
console.log( a + i );
}
for (var i=0; i<10; i++) {
bar( i * 2 );
}
}
foo();
执行上面这段代码,会发生什么事情?是可怕的无限循环!因为 i = 3修改了i的值,导致循环无法正确结束。
所以要避免这种情况发生,就要记得在使用局部变量前,一定要声明它,如下所示:
function foo() {
function bar(a) {
var i = 3;
console.log( a + i );
}
for (var i=0; i<10; i++) {
bar( i * 2 );
}
}
foo();
匿名和具名函数的区别
看如下两段代码的区别在哪里:
setTimeout( function() {
console.log("I waited 1 second!");
}, 1000 );
setTimeout( function timeoutHandler() {
console.log( "I waited 1 second!" );
}, 1000 );
考虑匿名函数的缺点:
- 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
- 如果没有函数名,当函数需要引用自身时只能使用已经过期的arguments.callee引用, 比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑 自身。
- 匿名函数省略了对于代码可读性/可理解性很重要的函数名。一个描述性的名称可以让 代码不言自明。
解决方案:给函数表达式指定一个函数名可以有效解决以上问题。始终给函数表达式命名是一个最佳实践
用let而不是var
考虑如下代码:
var foo = true;
if (foo) {
var bar = foo * 2;
bar = something( bar );
console.log( bar );
}
当在if的代码块中定义了变量bar,bar随后可以在if语句块外也能够被调用,这样就污染了整个函数环境,而这是我们不想看到的。而用了let就可以避免这一点。
var foo = true;
if (foo) {
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
console.log( bar ); // ReferenceError
理解提升
考虑如下代码:
a = 2;
var a;
console.log( a );
编译器对声明提升后,实际执行的代码:
var a;
a = 2;
console.log( a );
试着说如下代码执行结果吧:
console.log( a );
var a = 2;
它的实际执行代码如下:
var a;
console.log( a );
a = 2;
所以对JS而言,要记住:先有蛋(声明),后有鸡(赋值)。
我们习惯将var a = 2;看作一个声明,而实际上JavaScript引擎并不这么认为。它将var a
和 a = 2 当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。
这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。 可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的 最顶端,这个过程被称为提升。
理解闭包
考虑如下代码的最终输出是什么:
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
每秒输出6,而不是设想中的每秒输出1、2…5,因为闭包指向的都是同一个共有变量。想一下,代码要怎么改?
for (var i=1; i<=5; i++) {
(function(result) {
setTimeout( function timer() {
console.log( result );
}, result*1000 );
})(i);
}
答案是利用IIFE技术,把i作为函数的参数传递,从而生成一个新的作用域,把每次迭代的i的值封闭在新的作用域内。
更好的做法,利用let:
for (let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
this, this, this
考虑如下代码的最终输出是什么:
function foo(num) {
console.log( "foo: " + num );
// 记录 foo 被调用的次数
this.count++;
}
foo.count = 0;
var i;
for (i=0; i<10; i++) {
if (i > 5) {
foo( i );
}
}
console.log( foo.count );
是0,想想为什么?
因为this指向的不是foo的count,它指向的是全局的count。为什么会这样?因为在JS中,this指向的对象,是在运行时通过上下文实时获取,而不是在编译期间决定的。
function foo(num) {
console.log( "foo: " + num );
// 记录 foo 被调用的次数
this.count++;
}
foo.count = 0;
var i;
for (i=0; i<10; i++) {
if (i > 5) {
foo.call( foo, i );
}
}
console.log( foo.count ); // 4
通过call方法传递foo自身作为上下文参数,我们就能成功将this指向到foo。
this的绑定规则
默认绑定
function foo() {
console.log( this.a );
}
var a = 2;
foo(); // 2
可以看到,当调用this.a的时候,应用了this的默认绑定,此时的this指向的全局对象。
那么我们怎么知道这里应用了默认绑定呢?可以通过分析调用位置来看看 foo() 是如何调用的。在代码中,foo() 是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其他规则。
隐式绑定
思考如下代码:
function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2
这和我们平时写的代码有点接近了。原理是当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。
对象属性引用链中只有最顶层或者说最后一层会影响调用位置。思考如下的代码,最终的输出结果是什么:
function foo() {
console.log( this.a );
}
var obj2 = {
a: 42,
foo: foo
};
var obj1 = {
a: 2,
obj2: obj2
};
obj1.obj2.foo();
隐式绑定的风险 - 隐式丢失
思考下面的代码的最终输出:
function foo() {
console.log( this.a );
}
function doFoo(fn) {
fn();
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global";
doFoo( obj.foo );
参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值。因此实际上,代码最终引用的是 foo 函数本身,即一个不带任何修饰的函数调用,因此应用了默认绑定。
显式绑定
就像我们刚才看到的那样,在分析隐式绑定时,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把 this 间接(隐式)绑定到这个对象上。
那么如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么做呢?
具体点说,可以使用函数的 call(..) 和 apply(..) 方法。
function foo() {
console.log( this.a );
}
var obj = {
a:2
};
foo.call( obj ); // 2
可惜,显式绑定无法解决我们之前提出的丢失绑定问题。这种时候,可以用硬绑定。
硬绑定
function foo(something) {
console.log( this.a, something );
return this.a + something;
}
// 简单的辅助绑定函数
function bind(fn, obj) {
return function() {
return fn.apply(obj, arguments );
};
}
var obj = {
a:2
};
var bar = bind( foo, obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5
由于硬绑定是一种非常常用的模式,所以在 ES5 中提供了内置的方法 Function.prototype.bind,它的用法如下:
function foo(something) {
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
var bar = foo.bind( obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5