《你不知道的JavaScript(上)》阅读笔记(二)—— this与函数柯里化

this的指代问题是JavaScript常见的坑。当然它也是面试的时候常见的问题。

这里对于《你不知道的JavaScript(上)》的第二部分前几章关于this的讲解进行一些摘录,并且谈谈自己的想法。

1、关于this

首先搞清楚this存在的价值: 复用。很多时候我们会为不同的对象抽象相同的方法,那么我们用this来进行指代,就可以方便这些逻辑的复用。

关于js的this有两个误解:

  • this指向函数对象自己
  • this指向函数的作用域

上面两种理解都是错误的。

this 是在运行时绑定的,不是在编写时绑定的。它的上下文取决于函数调用时的各种条件。this的绑定与函数声明的位置没有任何关系,只取决于函数的调用方式。

当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中用到。

2、this的绑定规则

2.1 默认绑定

函数独立执行的时候。在无法使用其他绑定方法的时候使用默认绑定。默认绑定会将this绑定到全局对象即windows. 注意严格模式下全局对象无法使用默认绑定

2.2 隐式绑定

考虑调用的位置是否有上下文对象。如果有的话this会绑定到调用函数的对象上。

例如

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
console.log( this.a );
}
var obj2 = {
a: 42,
foo: foo
};
var obj1 = {
a: 2,
obj2: obj2
};
obj1.obj2.foo(); // 42

对象属性引用链中只有最顶层或者说最后一层会影响调用位置

再此注意,这里提到的上下文是调用的时候,而不是在声明的时候。

隐式丢失:

如果对于上面“调用”的理解不够深刻,会出现很多意想不到的事情。一个常见的问题就是隐式绑定的函数可能会丢失绑定的对象,从而使用默认绑定。

1
2
3
4
5
6
7
8
9
10
function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var bar = obj.foo; // 函数别名!
var a = "oops, global"; // a 是全局对象的属性
bar(); // "oops, global"

在这里,我们可以发现bar()在调用的时候,this并没有指向对象obj,而是采用了默认绑定,绑定到了全局对象上。

这并不是一个错误,我们考虑一下js的赋值语句,我们知道对于非简单类型的值,赋值的时候都是对引用的赋值,并且这个引用不会传递。在上面这个例子中这个不可传递的意思也就是barobj.foo都直接引用了函数foo. bar的调用就是普通的调用,而不是对于obj.foo的调用。

上面的这个错误更容易发生在函数作为参数的函数调用环节。如下面的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
console.log( this.a );
}
function doFoo(fn) {
// fn 其实引用的是foo
fn(); // <-- 调用位置!
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
doFoo( obj.foo ); // "oops, global"

这里函数传参的方式和上面的例子是一样的,但是它更加隐蔽,容易被忽略。这个问题也导致我们的回调函数有很大的麻烦。因为回调函数会在未来被执行,它总是会丢失自己的this。

2.3 显式绑定

如果我们想要强制的将函数与某个对象绑定,我们有一些显式绑定的方法。比如call和apply。每个函数都有call和apply方法,它接收的第一个参数就是this要绑定的对象,后面跟随一些参数。这两个方法的区别也仅仅是后面参数的形式。

apply和call语法比较

1
2
Function.call(obj[,param1,[param2[,[...paramN]]]]);
Function.apply(obj[,argArray]);

如上所示,call的参数是一个一个参数单独放入,而apply则是一个参数的列表。

由于我们显式声明了一个对象,因此称之为显式绑定。

硬绑定

显式绑定同样没有办法解决丢失绑定的问题。但是显示绑定的一个变种可以解决该问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
console.log( this.a );
}
var obj = {
a:2
};
var bar = function() {
foo.call( obj );
};
bar(); // 2
setTimeout( bar, 100 ); // 2
// 硬绑定的bar 不可能再修改它的this
bar.call( window ); // 2

实际的操作方法也很简单,其实就是在绑定外包裹一个函数,通过调用包裹在外层的函数来避免绑定的丢失。这样我们外层函数的赋值、绑定,都不会影响其内部写死的绑定。

硬绑定是一个常用的手法,因此我们可以写一个通用的硬绑定生成器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 简单的辅助绑定函数
function bind(fn, obj) {
return function() {
return fn.apply( obj, arguments );
};
}

function foo(something) {
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
var bar = bind( foo, obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5

上面的bind函数接收一个函数和一个对象,返回这个函数将this绑定到该对象上的版本。事实上这里bind建立了一个闭包,使用闭包返回了绑定后的函数。

由于硬绑定是一种非常常用的模式,所以在ES5 中提供了内置的方法Function.prototype.bind

2.4 new 绑定

JavaScript 中new 的机制实际上和面向类的语言完全不同。

首先我们重新定义一下JavaScript 中的“构造函数”。在JavaScript 中,构造函数只是一些使用new 操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上,它们甚至都不能说是一种特殊的函数类型,它们只是被new 操作符调用的普通函数而已。

作者的这段话有助于我们对于js的new和构造函数进行深入的理解。实际上不存在构造函数,只存在函数的构造调用

使用new来调用函数会执行以下流程:

  • 创建一个全新的对象
  • 新对象被执行 [[prototype]] 连接
  • 新对象会绑定到函数调用的this
  • 如果函数没有返回其他对象那么new表达式中的函数调用会自动返回这个新对象。

这里的绑定也就是new 绑定

3、this绑定的优先级

先说结论,new绑定 > 显式绑定 > 隐式绑定 > 默认绑定。

可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的顺序来进行判断:

  • 函数是否在new 中调用(new 绑定)?如果是的话this 绑定的是新创建的对象。 var bar = new foo()
  • 函数是否通过call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是指定的对象。var bar = foo.call(obj2)
  • 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上下文对象。var bar = obj1.foo()
  • 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到全局对象。var bar = foo()

显式绑定和隐式绑定以及默认绑定的优先级可以很容易的用代码来验证。

而new绑定和显式绑定的优先级的验证方式可以用硬绑定bind来验证。

1
2
3
4
5
6
7
8
9
10
function foo(something) {
this.a = something;
}
var obj1 = {};
var bar = foo.bind( obj1 );
bar( 2 );
console.log( obj1.a ); // 2
var baz = new bar(3);
console.log( obj1.a ); // 2
console.log( baz.a ); // 3

如上的结果,new成功改变了bind的绑定。(注意这个bind是es5的bind,不是我们之前写过的那个简单的bind)

我们更应该关注的是为什么要把bind和new结合起来使用。这里就是我们本文的第二个知识点: 函数柯里化。 我们在后续章节进行具体介绍。容我先把this讲完。

4、绑定例外

如果你把null 或者undefined 作为this 的绑定对象传入call、apply 或者bind,这些值在调用时会被忽略,实际应用的是默认绑定规则

另一个需要注意的是,你有可能(有意或者无意地)创建一个函数的“间接引用”,在这种情况下,调用这个函数会应用默认绑定规则。

箭头函数: es6中的箭头函数this绑定永远是外层作用域的this

bind与函数柯里化

1、从new与bind开始谈起

在上文验证new与bind的优先级时提到过new与bind同时使用的场景。

其实这个场景是有意义的,并非是我们为了验证而特意编写出来的。

举个例子:

1
2
3
4
5
6
7
8
function adder(p1,p2) {
this.val = p1 + p2;
}
// 之所以使用null 是因为在本例中我们并不关心硬绑定的this 是什么
// 反正使用new 时this 会被修改
var adder5 = adder.bind( null, 5 );
var baz = new adder5( 1 );
baz.val; // 6

在上面的例子中,我们分两步才向adder中传完两个参数。首先传了一个5,返回了一个函数,其次向上面返回的函数传入1,得到了结果1+5

函数柯里化做的就是这种事情。

2、函数柯里化

柯里化,英语:Currying(果然是满满的英译中的既视感),是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 普通的add函数
function add(x, y) {
return x + y
}

// Currying后
function curryingAdd(x) {
return function (y) {
return x + y
}
}

add(1, 2) // 3
curryingAdd(1)(2) // 3

这个例子和之前我们的bind是类似的。

函数柯里化的主要作用就是参数复用。

我们可以自己封装一个函数柯里化的方法:

1
2
3
4
5
6
7
8
function curry(fn) {
var args = Array.prototype.slice.call(arguments,1);
return function() {
var innerArgs = Array.prototype.slice.call(arguments);
var finalArgs = args.concat(innerArgs);
return fn.apply(null,finalArgs);
}
}

首先获取第一次的参数,然后返回一个闭包,闭包中获取剩下的参数,然后利用apply方法执行fn

es5的bind实现了函数的柯里化。拿红宝书的一个例子

1
2
3
4
5
6
7
8
var handler = {
message: "Event handled",
handleClick: function(name,event) {
alert(this.message+":"+name+":"+event.type);
}
};
var btn = document.getElementById('my-btn');
EventUtil.addHandler(btn,'click',handler.handleClick.bind(handler,'my-btn'));

这个例子中回调函数有两个参数:name和event,我们在bind的时候先把name传入,当监听的事件触发时,回调函数被调用,第二个参数event被传入。

最后参考 详解JS函数柯里化[https://www.jianshu.com/p/2975c25e4d71]
实现一道有名的面试题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 实现一个add方法,使计算结果能够满足如下预期:
add(1)(2)(3) = 6;
add(1, 2, 3)(4) = 10;
add(1)(2)(3)(4)(5) = 15;

function add() {
// 第一次执行时,定义一个数组专门用来存储所有的参数
var _args = Array.prototype.slice.call(arguments);

// 在内部声明一个函数,利用闭包的特性保存_args并收集所有的参数值
var _adder = function() {
_args.push(...arguments);
return _adder;
};

// 利用toString隐式转换的特性,当最后执行时隐式转换,并计算最终的值返回
_adder.toString = function () {
return _args.reduce(function (a, b) {
return a + b;
});
}
return _adder;
}

add(1)(2)(3) // 6
add(1, 2, 3)(4) // 10
add(1)(2)(3)(4)(5) // 15
add(2, 6)(1) // 9