..

3. 函数

JS 的函数也是一种对象, 默认情况下其原型链上都有 Function.prototype,因此它在 JS 里是 “一等公民” 可以当做值(毕竟是对象)来使用。不过在解析器层面,应该还是对函数做了很多处理,这些不是我们从使用者角度能检查得到的。

函数的属性

我们来看看函数拥有哪些属性。

// pg-title: 函数对象的属性
function fib(n) {
  if (n <= 1) {
    return n;
  } else {
    return fib(n - 1) + fib(n - 2);
  }
}

// 声明出来的函数,属于 Function 的实例:
console.log(fib.constructor === Function);
console.log(Object.getPrototypeOf(fib) === Function.prototype);

// 简单看看继承的原型上拥有什么属性:
const inheritedProperties = Object.getOwnPropertyNames(Function.prototype);
console.log(JSON.stringify(inheritedProperties)); // ["length","name","arguments","caller","constructor","apply","bind","call","toString"]

// 再看看函数实例 fib 自身有什么属性:
const ownProperties = Object.getOwnPropertyNames(fib);
console.log(JSON.stringify(ownProperties)); // ["length","name","arguments","caller","prototype"]
console.log(fib.name); // fib 函数的名字
console.log(fib.length); // 1 函数的参数个数
console.log(fib.toString()); // 函数的源码

这些属性或方法当中,大概可以分为三类:

  • 描述函数实例自身基本属性或方法:

    • name 函数的名字
    • length 函数的参数个数
    • toString 返回函数的源码
  • 描述函数的执行行为的属性或方法。为了区分表达,结合上面的代码里的 fib 函数实例来表述:

    • fib.arguments。跟 arguments 一样,表示调用函数的参数数组,非标准实现,且已经弃用了。估计一开始想到反正 JS 是单线程的,使用单个属性表示调用参数,即便有递归调用也没事。但是直觉感觉这种实现很不保险。后来实现的 arguments 变量应该是更保险的方案,每次调用的时候,在执行空间里隐式声明一个特殊"变量" arguments 。
    • fib.caller 表示调用者,跟上面的 fib.arguments 一样,也是一个非标准实现。貌似没有替代的标准实现。
    • apply, bind, call。这三个都是修改(绑定)执行上下文 this 的。这些方法都不需要实例方法,继承自原型即可。
  • 描述构造行为的

    • prototype,当函数作为构造函数实现的时候。prototype 属性会成为新实例的原型。

函数的声明

我们回头来看看函数的声明方式。上面的 fib 函数的声明其实相当于两个步骤:

  1. 实例化一个函数对象,其名为 fib。这个函数名表现在两个方面:
    • fib.name 的值就是 'fib'
    • fib 的函数作用域里,有一个隐藏的变量 fib 指向函数本身
  2. 把这个函数对象赋值给变量 fib

这样说,可能不好理解,我们换个方式来声明 fib 函数,按上述的步骤来进行拆解。

// pg-title: 分步骤声明函数 fib

let myFib = function fib(n) {
  if (n <= 1) {
    return n;
  } else {
    return fib(n - 1) + fib(n - 2);
  }
};

console.log(myFib.name); // fib
console.log(myFib(7));
// console.log(fib); // 会报错:Uncaught ReferenceError: fib is not defined

可以看到,按步骤拆解后应该就能理解"fib 的函数作用域里,有一个隐藏的变量 fib 指向函数本身"这句话的含义。如果我们把这个 myFib 变量改名为 fib,那这段代码就几乎等价于上一段代码。

MDN 上把等号右边的部分称为"函数表达式",即步骤 1. 所说的过程。而前一节所展示的那个 fib 函数声明方式(没有显示声明一个变量fib,并且赋值给fib的过程)称为"函数声明",说起来很拗口。

这当中还藏着一些犄角旮旯的特殊情况已经隐式转换,实际使用过程中其实不太需要关注。也不太有人会去写这种 let myFib = funciton fib(n) { 怪异代码。只要遇到一些怪异情况的时候,回头看看即可。

变量作用域链

ES6 之前,JS 能构建变量作用域的方式只有函数。

// pg-title: ES6之前的变量作用域

function test() {
  // for 循环语句并不能构建出一个变量作用域,把变量 i 限制在循环体内。
  for (var i = 0; i < 10; i++) {
    console.log(i);
  }

  console.log("final i is: " + i); // final i is: 10
}

test();

console.log("final i is: " + i);  // 会报错,因为 i 只在 test 作用域内(即函数体内)可见。

所谓变量作用域链,就是函数嵌套产生的嵌套作用域。就像这样:

// pg-title: 变量作用域链

// 全局作用域
function address(country) {  // 作用域 a
  return function (provience) { // 作用域 b
    return function (city) { // 作用域 c
      return [country, provience, city].join('');
    }
  }
}

查找变量时,会沿着作用域链往上找。就像这个柯里化的 address 函数,嵌套最深的那个函数,能访问到的变量作用域链为:c -> b -> a -> 全局。与其说是"作用域链",不如说是"作用域树"。整个允许的程序,构建出了一个变量作用域树。每个节点所能访问的,是自身到根节点路径(链)上的所有节点,而子节点与兄弟节点都是无法访问的。

这种作用域树结构是建立在词法解析上下文的基础上的,无法动态改变。

只能由函数来构建变量作用域显然是不太合理的。因为函数不是唯一的 “子程序” 结构,各类功能性的语句,都应该算是子程序。需要变量作用域的隔离作用的驱动力是程序隔离,或者说是程序状态隔离。那么其它子程序结构,理应也能形成变量作用域,进而隔离状态。随着编程思维的发展,这算是现代编程语言的共识。

所以,ES6 之后引入了 let/const 两个声明变量的关键字。它们的变量作用域是块级的,所谓"块"即为子程序块,差不多是花括号包裹起来的区域。而处于向后兼容的考虑,var 关键字声明的变量的作用域保持原有行为。

// pg-title: 块级作用域
{
  const myName = 'Xiaoming';
  var myName2 = "Xiaobai";
  console.log(myName);
}

console.log(myName2);
console.log(myName); // 报错,ReferenceError: myName is not defined

执行上下文——this

函数体内还有一个特殊的变量 this 指向执行时的上下文。按"类与实例"的角度来讲,方法(也就是函数)的执行上下文就是实例。

class A {
  name = '1';
  hello() {
    console.log(this.name);
  }
}

const a = new A();
a.hello(); // a.hello() 执行的时候,this 就指向实例 a。

这在其它语言里面是蛮常见的,我们一般会说这个变量指向了实例。但是到了 JS 这里,准确的说法是 “this 指向函数的执行上下文。因为 JS 没有把 实例方法 强绑定在一起,它们可以动态改变。

// pg-title: 执行上下文动态改变

class A {
  name = '1';
  hello() {
    console.log(this.name);
  }
}

const a = new A();

const fakeA = {
  name: 'fake-a'
}
fakeA.hello = a.hello;

// this 指向了 fakeA 这个对象:
fakeA.hello(); // -> fake-a

// this 指向了 fakeB 这个对象:
const fakeB = {};
fakeB.hello = a.hello;
fakeB.hello(); // undeinfed

// 严格模式下 this 指向不存在:
const foo = a.hello;
foo(); // 执行上下文不存在,报错

上面三种情况,输出的结果都不是 1。通过观察,我们大概可以给函数的执行上下文 this 下这样非正式的定义:“函数运行时,this指向进行本次函数调用的对象”。所谓的"调用"更像是这样的两个步骤:

  1. 从对象的属性中取出这个函数,把 this 指向这个对象。如果函数不是依附在对象上的,那么把 this 指向 window。(但是这个行为已被禁止,所以 foo() 这样的调用,this 为 undefined)。
  2. 把 this 作为隐藏参数传入函数,调用这个函数;

刚接触 JS 的时候,一定会觉得很意外,一不小心就写出了 bug。公司过来客串写前端的同事,经常遇到的一个问题就是方法作为 event handler,在被调用的时候老是出现莫名奇妙的问题。要嘛是预期的值变成 undefined 了,就像 fakeB.hello() 一样,要嘛是直接报错 TypeError: Cannot read properties of undefined 就像 foo() 一样。

JS 这样设计的动机是什么?为什么要实现这么隐式的上下文变更?我个人估计,这跟 JS 的实现机制过于简单有关系。它的函数本质上是不分什么 “函数” 或者 “方法"的,函数就是函数,方法也是函数,它就是一个对象、一个值。函数与对象没有任何隐式的关联关系。其它语言会把类里面声明的函数(也就是大家所说的方法),跟这个类或者类的实例进行绑定,其编译器或解释器已经做了这件事情。这种绑定可能是每次实例化,都会生成相应的"绑定方法”,就像 python 那样,也可能并不需要这么麻烦。当然,某些语言可能就不允许把方法作为 进行传递,所以也就没有这种烦恼。

总而言之,我个人认为造成这个局面的原因是 JS 的机制过于简单。它实际上没有传统的那种类与继承,它是简单的基于对象与原型链(原型也是对象,这里叫它为对象链也不为过)。一个函数声明之后,this 不与任何词法上下文进行绑定,就像上面虽然 hello 声明在 类 A 里面,但它与 A 没有任何绑定关系。那么如何实现其它 OOP 语言里类似的 this 效果呢?既然声明时没有绑定,那只能调用时再决定了,于是就有了现在的这种行为。

在我看来,这种设计不合理。很容易因为无意间发生执行上下文改变而出现 bug。有些人会说,这样可以很方便的进行代码复用。但是在我看来,这与复用无关,真要复用不还有 bind/call/apply 这几个显示改变执行上下文的工具函数吗?

另外,个人认为,作为要被复用的函数,其接口声明应该是明确且清晰的,把接口的信息隐藏在 this 里,不够直观。就像这样:

// 我们可以把 map 实现成 Array 的静态方法
// 这样接口清晰,其它地方要复用这个方法,直接调用 Array.map 即可。
// 不需要去看看函数体里有没有使用 this,this 的形态需要符合什么条件
Array.map = (iter, fn) => {
  const result = [];
  let idx = 0;

  for (let it of iter) {
    result.push(fn(it, idx, arr));
    idx = idx + 1
  }

  return result;
}

// 而为了保持 Array 实例调用的便利性。
// 依然可以再实现一个实例方法 map,它几乎不用
// 附加过多的逻辑,只需要调用 Array.map 即可
Array.prototype.map = function (fn) {
  return Array.map(this, fn);
}

我认为这样才是更清晰的代码复用方式,心智负担也小。当然,这里的前提是代码比较通用。对于逻辑内敛的函数,其与上下文自成体系,使用 this 是很合理的。这种函数大多数情况也不是为复用而生的。

事实已是如此。希望未来会有默认把方法跟实例绑定的声明方式。而当下只能建议对于比较切确会被当做值传递的函数(方法),用箭头函数来声明。以免发生这种隐式的上下文切换而导致 bug。

箭头函数的this

JS 里,拥有执行上下文的结构只有两种:

  • function (箭头函数除外)
  • class

一个箭头函数的 this 指向的是词法上下文上,离这个箭头函数最近的拥有执行上下文的结构。如果这个结构被动态的切换了执行上下文,这个箭头函数的 this 也会跟着改变。

// pg-title: 箭头函数的 this 无法指向对象
"use strict";

const obj = {
  name: 'Jake',
  age: 17,
  sayHello: () => {
    console.log(`Hi, I am ${this.name}`)
  }
}

obj.sayHello();  // 报错 sayHello 的定义没有被任何 function 或者 class 包裹,所以 this 绑定不到任何地方,为 undefined

你可能会说,为什么不是绑定到 obj 这个对象上?因为对象没有执行上下文(它都不能执行)。

我们可以给上面的 sayHello 套一个 function,转成高阶函数。这样做没什么意义,仅仅只是作为演示:

// pg-title: 箭头函数的 this 指向最近的 function 的 this
"use strict";

const obj = {
  name: 'Jake',
  age: 17,
  sayHello: function() {
    return () => {
      // 这个箭头函数里的 this 指向了外面的那个 function 的 this
      console.log(`Hi, I am ${this.name}`)
    }
  }
}

// 这么调用的时候,obj.sayHello() 的执行上下文是 obj,进而
// ,obj.sayHello()() 的执行上下文也是 obj
obj.sayHello()(); // Hi, I am Jake

我们把上面的例子,改成用 class 实现,就会自然一点:

// pg-title: 箭头函数的 this 指向 class 的 instance

class People {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  sayHello = () => {
    console.log(`Hi, I am ${this.name}`);
  }
}

const p = new People('Jake', 17);

p.sayHello(); // Hi, I am Jake

const fn = p.sayHello;
fn(); // Hi, I am Jake, 

p.sayHellothis 始终指向实例 p,所以它不会发生隐式的执行上下文变更。因此,fn() 的执行结果符合预期。

函数接口 – 参数

jQuery 的很多接口用起来很方便。因为它在内部根据 arguments 的参数个数、参数的类型,提供了不同的调用行为,这有点像函数重载。不过我还是觉得函数的接口要尽量的清晰,所以我个人喜欢这样设计函数接口:

  • 把类似的功能声明成多个函数,命名上一看就知道是同一个系列的。而不是全部放在同一个函数里;
  • 参数的个数尽量小于 5 个,再多就会用对象参数,比如像 options 这样命名的参数;
  • 默认参数一多,也会使用对象参数,这样调用的时候也方便,不需要为了跳过某个参数而传 undefined;
  • 不使用复杂的默认参数表达式,
  • 不使用复杂的参数解构。在参数列表里,解构一多容易让参数列表不清晰;

函数声明提升与变量声明提升

也许是为了方便写自上而下的代码,JS 一设计出来的时候,就有"声明提升"的机制。但是这种机制不是那么简单、直观。尤其是结合各种语句之后,其行为更是怪异。

经常能看到考这方面的面试题。我个人觉得这种测试意义不大。因为:

  • 声明提升机制最好不要用;
  • eslint 这样的 lint 工具帮我们检查,其实也不太需要关注;

闭包

函数的作用域链允许内部的函数访问外部函数定义的变量(局部变量),那么当这个内部函数存在的时候,被其使用的外部函数的变量就会一直存在,而不会被 GC。对于外部世界而言,这个内部函数好像有一个不可见的"状态”。所谓闭包大概就是这样的概念。

我感觉 wikipedia 上的解释更精准一点,把它叫做 “词法闭包”。