目录

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 RecordHasThisBinding() 函数返回 false

当然这并不代表在箭头函数内部不可以使用 this,只不过它会顺着作用域链向上查找最近的 this,举一个例子:

<script>
const foo  = () => { return this; };

foo(); // window
</script>

它就会找到全局中的 this,即 window/globalThis

这种特性不因调用方式的改变而改变,我们都知道,函数的 callapplybind 方法都可以重置上下文,但是大家注意,这对箭头函数无效!

<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,不能使用 superarguments,也不能作为构造函数使用。

异步函数

异步函数是对传统异步编程的革命性改变。但事实上,讲到异步函数就不能够避开 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 取值差别很大,但仍然有一些规律可循:

  1. 各种匿名函数的 name 均为空串("");
  2. 函数表达式的 name 为定义时赋值给的独立变量名;
  3. 对象成员函数 name 为 key 的字符串表达;
  4. 使用 Function 创建的函数的 name 为 anonymous
  5. bind 后的函数,name 前置 bound
  6. 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 语法替代。

函数有 namelength 两个固定属性,不同创建方式,其值不同。构造函数还可以有 prototype 属性,用来实现继承。

由于对象通常都是函数创建的,围绕着 constructorprototype 形成了 JavaScript 最为特别的对象原型链体系,是最难的一部分知识,同时也是能灵活运用 JavaScript 实现各种复杂数据关系的根基。

下一节,我们就以对象的结构为出发点,连续花几讲的时间来把这一部分吃透,以后能在面对任意的对象操作时游刃有余。