JavaScript 中,函数是 First-class Object

在一些其他的语言中(比如 Scala,一种动态的 JVM 语言),我们也会看到这句话:

函数是 First-class Object

这句话潜在含义是该语言支持函数式编程范式,并且函数在该语言中拥有与普通变量一样的特性:

  • 可以通过字面量创建
  • 可以赋值给变量、数组或其他对象的属性
  • 可以作为函数的参数
  • 可以作为函数的返回值
  • 可以拥有动态创建并赋值的属性(这是动态类型的特性)

函数式编程范式声明式编程范式的一种,而过程式编程范式面对对象编程范式都属于命令式编程范式的范畴。

函数定义

JavaScript 中,函数也是一个对象,所以函数定义的方式也与其他语言有所不同,除了一般的函数声明之外,还可以使用函数对象构造器等方式创建函数对象。

之前,我们就说过,在 JavaScript 是一个支持函数式编程范式的特性,其中一个重要点就是函数可以通过字面量来创建。

函数字面量

对函数字面量的理解和对其他字面量的理解是一致的。

我们首先来看看其他字面量的用法。

// 使用数字字面量声明并创建数字类型变量
var number = 1;
// 使用字符串字面量声明并创建字符串类型变量
var string = 'hello';
// 使用数组字面量声明并创建数组
var array = [number, string]
// 使用对象字面量声明并创建对象
var object = {
number: number,
string: string,
array: array
}

每一种字面量都有每一种字面量的既定格式。
比如,使用 '...' 表示一个字符串字面量,使用 [..., ...] 表示一个数组字面量,使用 {...: ..., ...: ...} 表示一个对象字面量。

函数字面量也有其既定的格式,一般由以下四部分组成:

  1. function 关键字。
  2. 可选名称,符合 JavaScript 标识符规范。
  3. 圆括号及可选的逗号分隔的参数列表。
  4. 大括号及可选的函数体内容。

遵循以上格式要求,我们可以从简至繁地构建以下函数字面量。

function () {}
function foo() {}
function foo(a) {}
function foo(a, ) {} // 这在 JavaScript 中是允许的
function foo(a, b) {}
function foo(a, b) { return a + b; }

不难发现,函数字面量与 C 或 Java 语言的函数声明语法非常相似。
在 JavaScript 中,以函数字面量开头的语句就是一个函数声明,也就是说以 fucntion 关键字开头的语句会被当作函数声明语句进行处理。

函数字面量拥有与其他字面量一样的特性。

// 变量赋值
var foo = function () {};
// 数组赋值
[1, 'string', function () {}]
// 属性赋值
{
number: 1,
string: 'string',
fn: function () {}
}
// 参数传递
foo(function () {});
// 函数返回值
function fn() { return function () {}; }

在上面的场景中,我们可以看到关键字 function 不再是语句的开头了。
此时,JavaScript 会把函数字面量当成函数表达式处理。
函数表达式JavaScript 中扮演了很重要的角色。

函数声明与函数表达式

函数声明和函数表达式的主要区别有:

  • 关键字 function 是否位于语句的开头。
  • 绑定函数实例的函数标识符可访问的范围。
  • 是否返回函数对象。

第一条是用于区分函数声明和函数表达式,
之后的则是函数声明和函数表达式的差异。

首先,我们来看函数标识符的可用范围。

// 函数声明
function fn () {
console.log(fn); // 可在函数体内访问
}
fn(); // 可在函数体外直接访问声明的函数
// 函数表达式
(function foo() {
console.log(fn); // 可在函数体内访问
})() // 因为函数表达式返回了一个函数对象,所以可以通过 () 操作符调用该函数。
foo(); // 无法在函数体外直接访问函数表达式中定义的函数

上面的代码示例中已说明了函数表达式会返回函数对象的特性,但不妨再比较一下。

// 在函数声明后使用 () 操作符
function () {}(); // 报错
// 在函数表达式后使用 () 操作符
(function () {})(); // 不会报错,成功调用函数

函数表达式的这两个特性在 JavaScript 中有着非常广泛的用法。

函数名与函数定义方式的关系

既然说到函数标识符的作用域,那在这里介绍一下函数的 name 属性,该属性值为函数名。

别忘记,函数也是对象哦,既然是对象,自然也可以有属性的。

(function () {}).name // ""
(function fn() {}).name // "fn"

我们可以看到 name 属性值与函数字面量的函数标识符相同,但我们继续往下看。

var fn = function () {}
fn.name // "fn"

我们发现当使用 fn 引用匿名函数并获取 name 属性值时,得到的并不是 "",而是 "fn"。看上去,函数的 name 属性不仅仅会考虑函数标识符,还会考虑函数引用的标识符。

var fn = function () {}
fn.name // "fn"
var foo = fn
foo.name // "fn"

而且,只会考虑第一次引用函数的标识符。

我们不妨再考虑下以下情况:

// 01. 作为数组字面量元素
[function () {}][0].name // ""
// 02. 赋值给数组元素
([][0] = function () {}).name // ""
// 03. 作为对象字面量属性
({ fn: function () {} }).fn.name // "fn"
// 04. 赋值给对象的属性
(({}).fn = function () {}).name // ""
// 05. 作为函数参数传递
function foo(fn) {
console.log(fn.name); // ""
return fn;
}
// 06. 作为函数返回值
var fn = foo(function () {}); fn.name // ""

由此我们可以总结一下:

  • 具名函数字面量的 name 属性为函数名称。
  • 匿名函数字面量的 name 属性一般为 "",但存在以下两种特殊情况:
    • 当将匿名函数赋值给普通变量时,函数的 name 属性值为普通变量名。
    • 当在对象字面量中使用匿名函数字面量时,函数的 name 属性值为对象属性名。

作用域

作用域指的是一个声明变量或声明函数可以被访问的范围

JavaScript 中,我们主要讨论两种作用域:函数的作用域和变量的作用域。

在这之前,我们还得介绍一下 JavaScript顶级作用域,又称为全局作用域

一切 JavaScript 代码都是先在全局作用域下执行的。在浏览器环境下,<script> 标签内的 JavaScript 代码以及引用的外部 .js 文件中的代码都是处于同一个全局作用域下。

全局作用域下声明函数会作为全局对象的属性

function fn() {}
fn // function fn() {}
window.fn // function fn() {}

全局作用域下使用 var 声明的变量会作为全局对象的属性

var a = 1;
a // 1
window.a // 1

⚠️ ES6 中的 let 和 const 声明的变量不具备上述特性。

> let a = 1;
> a // 1
> window.a // undefined
> const b = 2;
> b // 2
> window.b // undefined
>

变量的作用域从声明位置开始

console.log(window.a); // undefinded
var a = 1;
console.log(window.a); // 1

函数的作用域从当前函数定义作用域开始

foo(); // 1
function foo() { return 1; }

也被称为函数提升

每个函数都会创建一个子作用域

var a = 1;
(function () {
var a = 2, b = 3;
console.log(a); // 2
console.log(b); // 3
console.log(window.a); // 1
})()
(function () {
var a = 3, b = 4;
console.log(a); // 3
console.log(b); // 4
console.log(window.a); // 1
})()
console.log(a); // 1
console.log(b); // undefinded

不同作用域下允许同名变量。