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
函数的声明其实相当于两个步骤:
- 实例化一个函数对象,其名为
fib
。这个函数名表现在两个方面:fib.name
的值就是'fib'
fib
的函数作用域里,有一个隐藏的变量fib
指向函数本身
- 把这个函数对象赋值给变量
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指向进行本次函数调用的对象”。所谓的"调用"更像是这样的两个步骤:
- 从对象的属性中取出这个函数,把 this 指向这个对象。如果函数不是依附在对象上的,那么把 this 指向 window。(但是这个行为已被禁止,所以
foo()
这样的调用,this 为 undefined)。 - 把 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.sayHello
的 this
始终指向实例 p
,所以它不会发生隐式的执行上下文变更。因此,fn()
的执行结果符合预期。
函数接口 – 参数
jQuery 的很多接口用起来很方便。因为它在内部根据 arguments
的参数个数、参数的类型,提供了不同的调用行为,这有点像函数重载。不过我还是觉得函数的接口要尽量的清晰,所以我个人喜欢这样设计函数接口:
- 把类似的功能声明成多个函数,命名上一看就知道是同一个系列的。而不是全部放在同一个函数里;
- 参数的个数尽量小于 5 个,再多就会用对象参数,比如像 options 这样命名的参数;
- 默认参数一多,也会使用对象参数,这样调用的时候也方便,不需要为了跳过某个参数而传 undefined;
- 不使用复杂的默认参数表达式,
- 不使用复杂的参数解构。在参数列表里,解构一多容易让参数列表不清晰;
函数声明提升与变量声明提升
也许是为了方便写自上而下的代码,JS 一设计出来的时候,就有"声明提升"的机制。但是这种机制不是那么简单、直观。尤其是结合各种语句之后,其行为更是怪异。
经常能看到考这方面的面试题。我个人觉得这种测试意义不大。因为:
- 声明提升机制最好不要用;
- 有 eslint 这样的 lint 工具帮我们检查,其实也不太需要关注;
闭包
函数的作用域链允许内部的函数访问外部函数定义的变量(局部变量),那么当这个内部函数存在的时候,被其使用的外部函数的变量就会一直存在,而不会被 GC。对于外部世界而言,这个内部函数好像有一个不可见的"状态”。所谓闭包大概就是这样的概念。
我感觉 wikipedia 上的解释更精准一点,把它叫做 “词法闭包”。