JavaScript函数(arguments,this)

7 3月

JavaScript因为其语法松散,导致函数(尤其是this)看似简单,其实里面花头很多。本篇介绍一下JavaScript函数及其调用方法。

  • 函数声明和函数表达式
  • arguments
  • this
  • this补充说明

函数声明和函数表达式

JavaScript里对象字面量产生的对象将被连接到Object.prototype,函数对象将被连接到Function.prototype(但该对象本身也连接到Object.prototype)。先看一下函数声明和函数表达式(分匿名和命名):

function count(a,b) { return a*b; }             //函数声明
var d1 = function(n) { return n*2; };           //匿名函数表达式
var d2 = function double(n) { return n*2; };    //命名函数表达式

console.log(count(3,4));    //12
console.log(d1(3));         //6
console.log(d2(3));         //6
console.log(double(3));     //error,double未定义

上面代码可以看出函数声明和函数表达式在后续的调用中,效果是没有差别的。除语法不同外,两者的区别在于JS编译器读取的顺序。

编译器会事先读取函数声明,即使你把函数声明放在代码的末端也没关系,本质上就是作用域提升。而对于函数表达式,同其它基本类型的变量一样,只有在执行到该行语句时才解析。因此用函数表达式时,必须确保它在调用语句之前,否则会报错。

再看匿名和命名函数表达式的区别。上例中命名函数表达式将函数绑定到变量d2上,而非变量double上,因此double(3);会出现未定义error。

那命名函数表达式有什么用呢?比如上面的变量double有什么用呢?函数名double可用于在函数内部做递归,但可惜仍旧没必要,因为变量d2同样也可以在函数内部递归。因此命名函数表达式真正的作用在于调试,JavaScript环境提供对Error对象的栈追踪功能,可以用double进行栈追踪。

但命名函数表达式仍旧有很多问题,类似with一样。因此通常推荐用匿名函数表达式,不推荐用命名函数表达式:

var d1 = function(n) { return n*2; };           //Yes,推荐
var d2 = function double(n) { return n*2; };    //No,不推荐

arguments

每个函数都接受2个附加参数:thisarguments。先看arguments。JS的函数参数其实就是个类似数组的arguments对象,是形参的一个映射,但是值是通过索引来获取的。因此JS的函数天然支持可变参数。

arguments对象看似像数组,但请不要使用arguments.shift()等方法来修改arguments。修改arguments对象将可能导致命名参数失去意义。

例如person(name, age),参数name是arguments[0]的别名,age是arguments[1]的别名,如果用shift移除arguments后,name仍旧是arguments[0]的别名,age仍旧是arguments[1]的别名,函数开始失控。

因此,如果你无论如何要修改arguments,需要先将arguments对象转化为真正的数组:

var args = [].slice.call(arguments);

之后对args对象进行shift()等操作。这也常见于获取可变参数值,同样需要上述那样将arguments对象转化为真正的数组。

另外每个arguments对象都有两个额外的属性:arguments.calleearguments.caller。前者指向使用该arguments对象被调用的函数。后者指向调用该arguments对象的函数。

其实arguments.callee除允许匿名函数递归调用自身外,并没有什么太大用处。但可惜用函数名也能实现递归,所以它真没什么用处:

//用arguments.callee来递归
var factorial = (function(n) {
    return (n <= 1) ? 1 : (n * arguments.callee(n - 1));    //递归
});

//但也可以直接用函数名来递归
function factorial(n) {
    return (n <= 1) ? 1 : (n * factorial(n - 1));    
}

用arguments.caller可以跟踪栈信息,但它不可靠,如果某函数在栈中出现了不止一次,很容易陷入死循环,大多数环境已经移除了此特性。

JS严格模式下禁止使用arguments.callee和arguments.caller,因此这两个属性就不多废话了。

this

在JS中this取决于调用的方式,this具体指向哪个object,要看这个函数是如何被调用的,所以this不是编写时绑定,而是运行时绑定。不同的函数调用方式,this绑定的对象也不同。有5种绑定情况:

  • 默认绑定(Default Binding)
  • 隐式绑定(Implicit Binding)
  • 显式绑定(Explicit Binding)
  • new 绑定(New Binding)
  • =>箭头函数

默认绑定(Default Binding)

非对象属性方法调用时,this被绑定到全局对象window。这其实是语言设计上的一个错误(或曰特性),导致this不能调用内部函数。要调用内部函数,可以将that = this保存起来。

错误的例子中,由于someFunc是非对象属性方法调用,因此this绑定的是window。最终obj.myNum值并没有被改变:

var obj = {
    myNum: 1
};

function double(n) {
    return n * 2; 
}

obj.double = function() {
    console.log('obj.double this: ', this);    // this绑定到obj
    var someFunc = function() {
        console.log('someFunc this: ', this);  // this绑定到window
        this.myNum = double(this.myNum);       // NaN
    };
    someFunc();  // someFunc是非对象属性方法调用,所以默认将this绑定到了window上
}
obj.double();    // 调用点是对象属性,所以this绑定到obj
obj.myNum;       // 1,不变

正确的例子中,在obj.double方法里,this绑定的是obj对象,因此先用that将this保存起来。然后在内部传递的都是that,回避了someFunc函数内this发生改变的问题:

var obj = {
    myNum: 1
};

function double(n) {
    return n * 2; 
}

obj.double = function() {
    console.log('obj.double this: ', this);    // this绑定到obj
    var that = this;
    var someFunc = function() {
        console.log('someFunc this: ', this);  // this绑定到window,错就错了,who care~
        that.myNum = double(that.myNum); 
    };
    someFunc();  // doDouble 是非对象属性方法调用,所以默认将this绑定到了window上
}
obj.double();    // 调用点是对象属性,所以this绑定到obj
obj.myNum;       // 2,正确

隐式绑定(Implicit Binding)

对象属性方法调用时,函数里的this被绑定到该对象上

var obj = {
    value: 0,
    increment: function(inc) {
        this.value += inc;
    }
};
obj.increment(2);  // 对象属性方法调用函数,this绑定到对象obj上 
obj.value;         // 2

显式绑定(Explicit Binding)

apply / call / bind,允许我们自己绑定想要的this

var obj = {
    name: "Jack"
};

var getName = function() {
    return this.name; 
}

getName.apply(obj);    // Jack,显式地告知JS引擎,执行getName方法时,将this绑定到对象obj上
getName.call(obj);     // Jack
getName.bind(obj)();   // Jack

new 绑定(New Binding)

用new调用构造函数,会先创建一个连接到构造函数的prototype的新对象,再将this会绑定到该新对象

var Person = function() { 
    this.name = "Jack";
    this.getName = function() {
        return this.name; 
    };
    this.setName = function(n) { 
        this.name = n; 
    };
};

var p = new Person();  // this绑定到p对象,p对象的prototype指向Person
p.getName();           // Jack
p.setName("Rose");
p.getName();           // Rose

=>箭头函数

ES6里的箭头函数和上面四个规则不同,是通过词法作用域绑定this的,本质上是that = this代码的语法糖,回避了this默认绑定(Default Binding)时的问题

所以箭头函数是在编译时绑定this,而非运行时绑定,这是它和上面四个规则最根本的区别。

function foo() {
  setTimeout(() => {
    console.log('id:', this.id);
  }, 100);
}
var id = 10;
foo.call({ id: 20 }); // id: 20

上例中,setTimeout的参数是一个箭头函数,该箭头函数被定义在普通函数foo内。如果不是ES6的箭头函数,而是ES5普通函数的话,参照上面默认绑定(Default Binding)的说明,100毫秒后执行时this应该指向全局对象window,应该输出10。

为了更清晰地分辨ES6箭头函数和ES5普通函数对this绑定的区别,再看一个例子:

function Timer() {
    this.count1 = 0;
    this.count2 = 0; 
    setInterval(function () { this.count1++; }, 1000);  // 普通函数
    setInterval(() => this.count2++, 1000);             // 箭头函数
} 

var timer = new Timer();
setTimeout(() => console.log('count1: ', timer.count1), 3100); // count1: 0
setTimeout(() => console.log('count2: ', timer.count2), 3100); // count2: 3

上例中,Timer函数内部的两个定时器,分别用了ES5普通函数和ES6箭头函数。前者普通函数在计时器到点后执行回调函数,回调函数是默认绑定,this运行时被绑定到了window上。后者箭头函数的this编译时就固化指向了Timer。

ES6的箭头函数显然更能预防this错误绑定的问题,因此推荐用ES6的新语法来写JS。例如以前在定义回调函数前,总是先写:

var _this = this;
var that = this;
var self = this;

你一定见过这些先将this保存起来,再在回调函数里需要用this的地方用_this / that / self来代替,就是为了解决(更精确地说是回避)回调函数执行时this绑定的问题。现在用ES6的箭头函数就不需要这么麻烦了:

var handler = {
  id: '123456',
  init: function() {
    document.addEventListener('click',
    event => this.doSomething(event.type), false);
  },
  doSomething: function(type) {console.log('Handling ' + type + ' for ' + this.id);}
};

上例中init方法内用了箭头函数,因此内部的this,总是指向handler对象。否则,回调函数运行时,由于this指向的是window,所以this.doSomething会报错。

究其本质,ES6的箭头函数能将this绑定固化,并不是增加了什么新语法,本质上就是ES5的语法糖。箭头函数没有自己的this,它的this其实就是外层代码块的this。将箭头函数用Babel转码一下:

// ES6的代码
function foo() {
  setTimeout(() => {
    console.log('id:', this.id);
  }, 100);
}

// 转码后ES5的代码
function foo() {
  var _this = this;
  setTimeout(function () {
    console.log('id:', _this.id);
  }, 100);
}

戏法拆穿就显得了无生趣了,仍旧是ES5那套_this / that / self的把戏。

function foo() {
  return () => {
    return () => {
      return () => {
        console.log('id:', this.id);
      };
    };
  };
}
var f = foo.call({id: 1});
var t1 = f.call({id: 2})()(); // id: 1
var t2 = f().call({id: 3})(); // id: 1
var t3 = f()().call({id: 4}); // id: 1

上例中,看似嵌套了很多箭头函数,有点晕。但只要知道了这是ES5老把戏的语法糖的话,就不会晕了。整个foo里只有一个this,指向foo对象。不论嵌套多少层,语法糖里箭头函数没有自己的this,只有_this即指向foo的this。

箭头函数没有自己的this,因此不能作为构造函数被使用,new会报错。也没有自身的arguments,super,new.target,它们指向的都是外层代码块的对应变量。也不能使用yield用作Generator函数。也不能用call() / apply() / bind()这些方法去改变this的指向。

this补充说明

这一节并无任何新的内容,只不过对this进一步补充说明一下。我们知道对象都有prototype俗称原型对象。那prototype里的this绑定谁呢?其实原则没有变,从上面构造函数调用的例子就能看出this仍旧是绑定调用的对象。

为了更清晰一点,将上面构造函数调用的例子稍微改一下:

var Name = function() {};
Name.prototype =  {
    name: "(not set)",
    setName: function(n) {
        this.name = n;
    }
}

var myName = new Name();
console.log(myName.name);                   //(not set)
console.log(myName.hasOwnProperty("name"));    //false
console.log(myName.hasOwnProperty("setName"));   //false

myName.setName("Jack");
console.log(myName.name);                       //Jack
console.log(myName.hasOwnProperty("name"));    //true
console.log(myName.hasOwnProperty("setName"));   //false

先看第一段结果代码,Name本身没有任何属性,name和setName是在它的原型prototype中定义的。因此用hasOwnProperty来检查全是false。这与我们的预想完全一致,没什么可奇怪的。

再看第二段结果代码,由于执行了myName.setName(“Jack”);。原型prototype中的this不是绑定原型对象,而是绑定调用的对象。即setName中的this绑定的是对象myName,会给对象增加一个name属性。所以hasOwnProperty(“name”)会为true。

明白这些原理后,再回过头看看以前不明白的代码里this,that,self等就轻松多了。

发表评论

电子邮件地址不会被公开。 必填项已用*标注