8基础篇函数千变万化的特殊对象
什么是函数
根据 ECMAScript 规范的定义,函数(function
)是一种特殊的对象,它的特殊性体现在它的内部必须要存在一个 [[Call]]
方法。这个方法代表了一段可复用的过程。
这个方法对于我们使用者来说是不可见的,不过在规范内部,typeof
的原理就看对象里面有没有这个 [[Call]]
属性的。
ECMAScript 规范还特意定义来一个操作(Operation)
,叫做 Call(F,V[,argumentsList] )
(以下简称Call()
)。它的第一个参数 F 是一个函数对象,第二个参数 V 是一个上下文对象,最后是不定数量的参数。这个操作的语义很明确,就是:在 V 上调用 F,传入 argumentsList 参数。
以 Object.assign(a, b)
为例,它在规范内部的表述就是 Call(assign, Object, a, b)
。
大部分函数对象的内部还会存在一个叫做 [[Construct]]
方法,代表这个函数可以作为一个构造函数来创建对象。相应的,也有一个 Construct(F[,argumentsList[,newTarget]])
操作。
对于一般的函数来说,它作为对象,也是被特定构造函数来创建出来的,哪个构造函数呢?自然是 Function
。我们可以这样验证:
function foo() {}
foo.constructor === Function // true
foo instanceof Function // true
只不过这个过程是隐式的,如果没有 function
语法关键字,我们仍然可以通过构造的方式来创建函数:
const foo = new Function();
💡 调用 Function 时,也可以选择不使用
new
,有点类似于我们前面讲过的 RegExp。
由于动态代码的特性,这种写法通常作为 eval 的一种替代。
函数会创建出一个新的上下文,还记得前面讲过的 Function Environment Record
吗?不过不同类型的函数,其上下文也是有较大不同的。
函数的种类
在 ES6 之前,函数只有函数声明
和函数表达式
两种写法,但是它们只会影响到变量提升
,本质上还是普通的同步函数。
ES6 之后,引入了如箭头(Arrow)函数
、异步(Async)函数
、生成器(Generator)函数
这几类特殊函数。它们的用法存在着很大不同。
箭头函数
箭头函数的特点就是它的内部没有 this
的概念,也就是说,Function Environment Record
的 HasThisBinding()
函数返回 false。
当然这并不代表在箭头函数内部不可以使用 this
,只不过它会顺着作用域链向上查找最近的 this,举一个例子:
<script>
const foo = () => { return this; };
foo(); // window
</script>
它就会找到全局中的 this,即 window/globalThis
。
这种特性不因调用方式的改变而改变,我们都知道,函数的 call
、apply
、bind
方法都可以重置上下文,但是大家注意,这对箭头函数无效!
<script>
const foo = () => { return this; };
foo.call(5); // window
foo.bind(4)(); //window
</script>
所以,当你想锁定一个函数的上下文的时候,那么就应该把它定义成箭头函数。如果不打算使用 this,那么箭头函数还是写起来更简练。
除了这个最重要的特性之外,箭头函数还有一个由此而来的推论:箭头函数不可以作为构造函数
。这很容易理解,它没有 this
,而构造函数又必须有,由此产生了不可调和的冲突,强行使用 new
来创建对象会导致错误:
const foo = () => {};
new foo(); // ❌ Uncaught TypeError: foo is not a constructor
在上一节我们讲过,函数对象可能存在一个 [[Construct]]
内部函数,但是也可能不存在,箭头函数就不存在这个函数,因而不能作为构造函数。
而且我们还应该能理解箭头函数内部也不能使用 super
。更严格的是,不像 this 可以顺着作用域链往上查找,super
连出现都不能出现,否则直接报出一个语法错误(SyntaxError):
const foo = () => { return super; }; // ❌ Uncaught SyntaxError: 'super' keyword unexpected here
最后,arguments
也不能出现在箭头函数中:
const foo = () => { return arguments; };
foo(1,2,3); // ❌ Uncaught ReferenceError: arguments is not defined
不过在 ES6 以后,我们也不再建议使用 arguments
了,展开语法(Function Spread
)写起来更优雅,下面会讲到。
总体来说,箭头函数还是比较特别的,它的语法其实是一种 lambda
表达式的写法,它没有独立的 this
,不能使用 super
、arguments
,也不能作为构造函数使用。
异步函数
异步函数是对传统异步编程的革命性改变。但事实上,讲到异步函数就不能够避开 Promise
的话题,关于它,我们在后面的章节中还会详细地讲到,今天我们仅需要知道:Promise
是对回调地狱
的升级,是多种语言公认的优秀异步方案。但还不够。
在复杂的逻辑代码中,混合包含多个同步异步过程,且带有分支,那么用 Promise 写起来是什么样子的呢?
Promise.resolve()
.then(() => fetch("/xxx"))
.then(res => res.json())
.then(data => {
if (data.role === 1) {
return Promise.resolve()
.then(() => {
return processAdmin(data.payload);
});
} else {
return Promise.resolve()
.then(() => {
return processMemeber(data.payload);
});
}
})
.catch(err => console.log(err));
我只写了一个非常非常简单的例子,已经能够看到 Promise 这种链式调用的写法,虽然比回调函数已经好太多,但是在需要处理分支的情况下,依然产生了大量的缩进,需要进一步提取出去进行封装,才能让代码阅读起来容易一些。
归根到底,Promise 仍然离不开回调函数。异步(async
)函数从语法层面解决了这个问题,配合 await
,能实现类似同步代码的写法:
async onMount() {
try {
const res = await fetch("/xxx");
const data = await res.json();
if (data.role === 1) {
await processAdmin(data.payload);
} else {
await processMemeber(data.payload);
}
} catch(err) {
console.log(err);
}
}
这样看着就舒适多了。由于异步函数通常需要内部的 await 配合,因此将这种写法也称作 async/await
。
async/await
在 ES2017(ES8)引入,在现代浏览器中,如果不打算支持 Safari 10 和 IE,那么可以认为浏览器都原生支持。
async/await
的原理并不难,事实上,它只是 Promise 的语法糖
,即每一个异步函数的返回值,都一定是一个 Promise 对象,加上 await 后才是我们想要的数据:
async function foo() {
return 1;
}
foo() instanceof Promise; // true
await foo(); // 1
由于这种异步的关系,我们自然也能推测出:异步函数不可以作为构造函数,不可以使用 super
。至于 this 和 arguments,取决于 async 修饰的是一个箭头函数还是普通函数。
大家注意,异步函数并不是由 Function 隐式创建的,而是叫做 AsyncFunction
,它是 Function 的子类。不过 AsyncFunction 并不能直接访问得到,只能间接获取:
const AsyncFunction = (async () => {}).constructor;
Object.getPrototypeOf(AsyncFunction) === Function; // true
因此,当你需要决定一个函数如何调用时,便可以以此来判断类型:
if (callback instanceof AsyncFunction) {
await callback();
} else {
callback();
}
也可以动态创建一个异步函数
:
const fn = AsyncFunction("", "return 1");
fn().then(ret => console.log(ret)); // 1
生成器函数
这是一类理解起来比较晦涩的函数类型,在语法层面就表现出一种复杂感:
function* foo(seed) {
const ret = yield seed;
return ret * seed;
}
异步函数总是返回一个 Promise 对象,类似地,生成器函数每次总是返回一个迭代器(iterator)
对象。至于什么是迭代器,我们将在后面单独开一章节详细讲解。今天我们只需要知道生成器函数的返回值不能直接使用,但是可以用 for...of
来遍历就可以了。
function* count() {
yield 9;
yield 8;
yield 7;
}
const it = count();
for (let k of it) {
console.log(k); // 7 8 9
}
与异步函数类似,生成器函数也不是由 Function 构造的,而是不可直接访问到的 GeneratorFunction
:
const GeneratorFunction = (function*() {}).constructor;
Object.getPrototypeOf(GeneratorFunction) === Function; // true
注意,生成器函数也可以是异步的,构成一个异步生成器函数
。但生成器函数不可以用箭头函数的形式定义。可见,箭头函数、异步函数、生成器函数之间并不是并列关系的分类,可以相互组合,但生成器和箭头不能组合
。
不同函数的创建方式不同,使用场景不同,决定了它们作为函数有着共同数据属性的同时,也会存在一些差异。
函数的结构
函数作为一个特殊的对象,也有自己独特的属性:
- name;
- length;
- prototype。
name
name
即函数的名字,如果函数这样定义:
function foo (){}
那么,name 显然就是 foo,这并不难,我们看一些容易让人困惑的例子:
// 匿名
(function() {}).name // ""
(() => {}).name // ""
(async () => {}).name //""
(function*() {}).name // ""
// 普通函数
const foo = function(){};
foo.name // "foo"
// 箭头函数
const foo = () => {};
foo.name // "foo"
// 构造函数
const fn = Function()
fn.name // "anonymous"
// 成员函数
const obj = {
foo() {},
[Symbol.for("bar")]() {},
get baz() {},
set baz() {},
};
obj.foo.name // "foo"
obj[Symbol.for("bar")] // "[bar]"
Object.getOwnPropertyDescriptor(obj, "baz").get.name // "get baz"
Object.getOwnPropertyDescriptor(obj, "baz").set.name // "set baz"
// 私有函数
class Foo {
#say() {}
bark() {
return this.#say.name;
}
}
new Foo().bark() // "#say"
// 绑定函数
const foo = function() {}
foo.bind(3).name // "bound foo"
(() => {}).bind(3).name // "bound"
// 属性定义/赋值
const obj = {}
Object.defineProperty(obj, 'foo', {
value: async() => {}
});
obj.bar = () => {};
obj.foo.name // "value"
obj.bar.name // ""
// ESM场景
// lib.js
export default function() {};
// index.js
import("./lib.js").then(({ default }) => {
default.name // "default"
});
上面我列举的能想到的典型场景,如果你仔细阅读这段代码,就能发现虽然看上去场景很多,name 取值差别很大,但仍然有一些规律可循:
- 各种匿名函数的 name 均为空串("");
- 函数表达式的 name 为定义时赋值给的独立变量名;
- 对象成员函数 name 为 key 的字符串表达;
- 使用 Function 创建的函数的 name 为
anonymous
; bind
后的函数,name 前置bound
;export default
导出的匿名函数 name 为default
。
诸如类私有函数、getter/setter,都符合上述规则。事实上,name 只是一个字符串表达,其值可以任意定义。但由于 name 作为对象属性本身是只读的(后面的章节中会详细讲解对象属性的知识),我们只能以重新定义的方式来修改:
function foo() { }
Object.defineProperty(foo,'name', {
value: 'bar',
writable: false,
configurable: true,
enumerable: false,
});
foo.name // "bar"
严格来讲,我们不建议使用 name 来作为逻辑操作的判断依据,但是可以作为日志打印的构成信息。
length
length
即指函数的参数个数,注意是函数的代码静态声明的参数,而不是运行时传入的参数。
一般来说,函数在定义时,其参数声明就已经定了,因此 length
也是一个不可写的属性。
(function (a, b) {}).length // 2
ES6 引入了函数展开语法后,有一些特殊情况:
(function (...a) {}).length // 0
(function (a, ...b) {}).length // 1
可见,展开的那部分参数并不参与 length 的计算。下面是我们日常常用的一些函数的 length 值,看看有没有让你感到意外的:
Function.length // 1
Function.prototype.call.length // 1
Function.prototype.apply.length // 2
Array.prototype.splice.length // 2
window.setTimeout.length // 1
window.alert.length // 0
window.getComputedStyle.length // 1
parseInt.length // 2
JSON.stringify.length // 3
prototype
一般的函数还会有一个 prototype
属性,这个属性在函数用作构造函数时是至关重要的,用来实现 JavaScript 的继承。
function Foo() {}
const foo = new Foo();
以上面的代码为例,函数 Foo 会有一个默认的 prototype
,那么对于用其创建的对象 foo 来说,访问属性将很有可能顺着原型链访问到 Foo.prototype 上来。相当于以 Foo 创建的所有对象,都会共享 Foo.prototype 上面的属性。
关于原型链的知识,我们在下一章会详细探讨。现在我们来看看 Foo.prototype 从何而来。
根据 ECMAScript 规范的定义,函数在定义的时候,就应该为其创建一个 prototype
属性,值是一个包含 constructor
属性的简单对象,大概是这么个意思:
const fooProto = {};
Objet.defineProperty(fooProto, 'constructor', {
value: Foo,
writable: true,
enumerable: false,
configurable: true
});
Objet.defineProperty(Foo, 'prototype', {
value: fooProto,
writable: true,
enumerable: false,
configurable: false
});
constructor
就是指向 Foo 本身。不过注意,它是不可枚举的,这也解释了当你用 for...in
遍历一个对象的时候,根本遍历不到 constructor。
如果在 Foo.prototype 上定义新的方法,那么将实现了全部实例的数据共享:
Foo.prototype.bar = function() {};
foo.bar();
这样写不够优雅,比较麻烦,从 ES6 开始,JavaScript 有了 class
语法来实现这一机制,我们也有一个章节来专门讲解 class
的原理和使用。
💡 注意:异步函数和箭头函数没有
prototype
属性,这很容易理解,因为它们不能作为构造函数。
小结
这一讲,我们梳理了 JavaScript 中的函数,讲到了函数是一种特殊的对象,有普通函数、箭头函数、异步函数和生成器函数 4 种类型,它们各有特点,部分可以相互组合。箭头函数没有自己的 this
,异步函数背后的 async/await
本质是 Promise
的语法糖,生成器函数始终返回迭代器对象。它们之中只有普通函数允许作为构造函数,可以被 class
语法替代。
函数有 name
和 length
两个固定属性,不同创建方式,其值不同。构造函数还可以有 prototype
属性,用来实现继承。
由于对象通常都是函数创建的,围绕着 constructor
、prototype
形成了 JavaScript 最为特别的对象原型链体系,是最难的一部分知识,同时也是能灵活运用 JavaScript 实现各种复杂数据关系的根基。
下一节,我们就以对象的结构为出发点,连续花几讲的时间来把这一部分吃透,以后能在面对任意的对象操作时游刃有余。