解构再组合 - 「你不知道的JavaScript」(一)


解构再组合 - 「你不知道的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 );

考虑匿名函数的缺点:

  1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
  2. 如果没有函数名,当函数需要引用自身时只能使用已经过期的arguments.callee引用, 比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑 自身。
  3. 匿名函数省略了对于代码可读性/可理解性很重要的函数名。一个描述性的名称可以让 代码不言自明。

解决方案:给函数表达式指定一个函数名可以有效解决以上问题。始终给函数表达式命名是一个最佳实践

用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

new绑定(略)

箭头函数(略)


Author: Kevin Gu
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source Kevin Gu !
  TOC