MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript普通函数的定义与特点

2022-07-094.2k 阅读

JavaScript 普通函数的定义

在 JavaScript 中,函数是一等公民,这意味着函数可以像其他数据类型(如字符串、数字)一样被使用。普通函数是 JavaScript 中定义函数的一种常见方式,它可以通过多种语法形式来进行定义。

函数声明

函数声明是定义普通函数最常见的方式。其语法结构如下:

function functionName(parameters) {
    // 函数体
    statements;
    return value;
}

在这里,function 是关键字,用于声明一个函数。functionName 是给函数取的名字,这个名字要遵循 JavaScript 的命名规则,通常使用驼峰命名法。parameters 是函数的参数列表,多个参数之间用逗号分隔,参数就像是函数的输入,在函数内部可以通过这些参数来接收外部传递进来的值。statements 是函数体,这里面包含了函数执行时要做的具体操作。return 语句是可选的,用于返回一个值给调用函数的地方,如果没有 return 语句,函数默认返回 undefined

下面是一个简单的示例,定义一个函数用于计算两个数的和:

function addNumbers(a, b) {
    return a + b;
}
let result = addNumbers(3, 5);
console.log(result); 

在这个例子中,addNumbers 函数接受两个参数 ab,在函数体中通过 return 返回它们的和。然后调用这个函数并将结果赋值给 result 变量,最后打印出结果。

函数声明有一个重要的特性叫做函数提升。这意味着在 JavaScript 代码执行之前,函数声明会被提升到其所在作用域的顶部。例如:

console.log(add(2, 3)); 

function add(a, b) {
    return a + b;
}

在上述代码中,虽然 console.log(add(2, 3))add 函数声明之前调用,但由于函数提升,代码可以正常运行并输出 5。这是因为在代码执行时,JavaScript 引擎会先将函数声明提升到当前作用域的顶部,所以即使提前调用也能找到该函数。

函数表达式

除了函数声明,还可以使用函数表达式来定义函数。函数表达式将函数定义作为一个值赋给一个变量。其语法如下:

let functionVariable = function functionName(parameters) {
    // 函数体
    statements;
    return value;
};

这里 functionVariable 是变量名,通过这个变量来引用函数。functionName 同样是函数名,但在函数表达式中,函数名是可选的。如果提供了函数名,它只能在函数内部通过 arguments.callee 来引用自身(在严格模式下 arguments.callee 不可用),通常在递归函数中可能会用到这种方式。

以下是一个使用函数表达式定义函数并计算两个数乘积的例子:

let multiply = function (a, b) {
    return a * b;
};
let product = multiply(4, 6);
console.log(product); 

在这个例子中,multiply 变量被赋值为一个函数表达式,这个函数接受两个参数 ab,返回它们的乘积。

与函数声明不同,函数表达式不会被提升。例如:

console.log(multiply(4, 6)); 
let multiply = function (a, b) {
    return a * b;
};

上述代码会抛出 TypeError,因为 console.log(multiply(4, 6))multiply 变量初始化之前调用,由于函数表达式没有提升,此时 multiply 变量的值是 undefined,而不是一个函数,所以无法调用。

箭头函数形式

ES6 引入了箭头函数,它是一种更简洁的函数定义方式。虽然箭头函数与普通函数有一些区别,但在函数定义形式上,它也可以归为函数定义的一种变体。其基本语法如下:

let arrowFunction = (parameters) => {
    // 函数体
    statements;
    return value;
};

如果只有一个参数,可以省略参数的括号:

let square = num => {
    return num * num;
};

如果函数体只有一条语句,并且这条语句是返回值,可以省略花括号和 return 关键字:

let square = num => num * num;

箭头函数的出现主要是为了简化函数的写法,特别是在一些作为回调函数的场景中。例如,在数组的 map 方法中使用箭头函数来对数组中的每个元素进行平方操作:

let numbers = [1, 2, 3, 4];
let squaredNumbers = numbers.map(num => num * num);
console.log(squaredNumbers); 

然而,箭头函数与普通函数在一些关键特性上有所不同,比如箭头函数没有自己的 this 绑定,它的 this 取决于外层作用域,这在后面介绍函数特点时会详细说明。

JavaScript 普通函数的特点

作用域

在 JavaScript 中,函数定义会创建自己的作用域。作用域决定了变量的可访问性和生命周期。普通函数的作用域遵循词法作用域规则,也称为静态作用域。这意味着函数的作用域在函数定义时就已经确定,而不是在函数调用时确定。

考虑以下代码:

let outerVariable = 'I am from outer scope';

function outerFunction() {
    let innerVariable = 'I am from inner scope';

    function innerFunction() {
        console.log(outerVariable); 
        console.log(innerVariable); 
    }

    innerFunction();
}

outerFunction();

在这个例子中,innerFunction 可以访问 outerVariableinnerVariable,因为它处于 outerFunction 的作用域内,而 outerFunction 的作用域包含了外部全局作用域。这体现了词法作用域的规则,函数可以访问其定义时所在作用域链上的变量。

当函数嵌套时,内层函数可以访问外层函数的变量,但外层函数无法访问内层函数的变量。例如:

function outer() {
    let outerVar = 'Outer variable';

    function inner() {
        let innerVar = 'Inner variable';
        console.log(outerVar); 
    }

    inner();
    console.log(innerVar); 
}

outer();

在上述代码中,inner 函数可以访问 outerVar,但 outer 函数中尝试访问 innerVar 会导致 ReferenceError,因为 innerVar 只在 inner 函数的作用域内有效。

变量提升

前面在介绍函数声明时提到了函数提升,其实变量提升在普通函数中也存在。在函数内部,使用 var 声明的变量会被提升到函数作用域的顶部,但它们的值不会被提升,仍然是 undefined。例如:

function variableHoisting() {
    console.log(a); 
    var a = 10;
    console.log(a); 
}

variableHoisting();

在这个函数中,console.log(a) 第一次调用时,a 由于变量提升已经存在于函数作用域中,但它的值还没有被赋值,所以输出 undefined。第二次调用 console.log(a) 时,a 已经被赋值为 10,所以输出 10

需要注意的是,使用 letconst 声明的变量不会被提升到函数作用域顶部,它们存在所谓的“暂时性死区”。例如:

function letConstHoisting() {
    console.log(b); 
    let b = 20;
}

letConstHoisting();

上述代码会抛出 ReferenceError,因为在 console.log(b) 时,b 处于暂时性死区,虽然它已经在块级作用域中声明,但还未被初始化,所以无法访问。

this 绑定

this 关键字在 JavaScript 中是一个非常重要且有时容易混淆的概念,特别是在普通函数中。this 的值取决于函数的调用方式。

在全局作用域中,this 指向全局对象。在浏览器环境中,全局对象是 window,在 Node.js 环境中,全局对象是 global。例如:

console.log(this); 

在浏览器控制台中运行上述代码,会输出 window 对象。

当函数作为对象的方法被调用时,this 指向该对象。例如:

let person = {
    name: 'John',
    sayHello: function () {
        console.log('Hello, I am'+ this.name);
    }
};

person.sayHello(); 

在这个例子中,sayHello 函数作为 person 对象的方法被调用,所以 this 指向 person 对象,输出 Hello, I am John

如果函数是通过普通函数调用方式调用(不是作为对象的方法),在非严格模式下,this 指向全局对象,在严格模式下,this 指向 undefined。例如:

function normalCall() {
    console.log(this); 
}

normalCall(); 

在非严格模式下运行上述代码,在浏览器中会输出 window。而在严格模式下:

function strictNormalCall() {
    'use strict';
    console.log(this); 
}

strictNormalCall(); 

此时会输出 undefined

还有一种情况是使用 callapplybind 方法来改变函数中 this 的指向。callapply 方法会立即调用函数,并且将 this 绑定到指定的对象,它们的区别在于传递参数的方式。bind 方法会返回一个新的函数,新函数的 this 被绑定到指定的对象。例如:

let obj1 = { name: 'Object 1' };
let obj2 = { name: 'Object 2' };

function greet() {
    console.log('Hello, I am'+ this.name);
}

greet.call(obj1); 
greet.apply(obj2); 
let newGreet = greet.bind(obj1);
newGreet(); 

在上述代码中,greet.call(obj1)greet.apply(obj2) 分别将 greet 函数中的 this 临时绑定到 obj1obj2 并调用函数。greet.bind(obj1) 返回一个新的函数 newGreet,其 this 已经被绑定到 obj1,调用 newGreet 时会输出 Hello, I am Object 1

闭包

闭包是 JavaScript 中一个强大且重要的特性,它与普通函数密切相关。当一个函数内部定义了另一个函数,并且内部函数可以访问外部函数的变量时,就形成了闭包。例如:

function outerFunction() {
    let outerVar = 'I am from outer function';

    function innerFunction() {
        console.log(outerVar); 
    }

    return innerFunction;
}

let closureFunction = outerFunction();
closureFunction(); 

在这个例子中,innerFunction 形成了对 outerFunction 作用域的闭包。即使 outerFunction 执行完毕,outerVar 变量在内存中依然不会被释放,因为 innerFunction 仍然可以访问它。通过返回 innerFunction 并赋值给 closureFunction,之后调用 closureFunction 时,它仍然能够访问并输出 outerVar 的值。

闭包在很多场景中都有应用,比如实现数据封装和模块模式。例如,通过闭包实现一个简单的计数器:

function counter() {
    let count = 0;

    return function () {
        count++;
        return count;
    };
}

let myCounter = counter();
console.log(myCounter()); 
console.log(myCounter()); 

在这个例子中,counter 函数返回一个内部函数,这个内部函数形成了对 counter 函数作用域中 count 变量的闭包。每次调用 myCounter 时,count 变量的值都会增加并返回,实现了一个简单的计数器功能。

函数重载

严格来说,JavaScript 本身并不支持传统意义上的函数重载。在一些静态类型语言中,函数重载是指在同一个作用域内,可以定义多个同名但参数列表不同的函数。在 JavaScript 中,函数名是唯一标识函数的,当定义多个同名函数时,后面的函数会覆盖前面的函数。例如:

function addNumbers(a, b) {
    return a + b;
}

function addNumbers(a, b, c) {
    return a + b + c;
}

let result1 = addNumbers(2, 3); 
let result2 = addNumbers(2, 3, 4); 

在上述代码中,虽然定义了两个 addNumbers 函数,但实际上只有第二个函数有效,result1 调用 addNumbers(2, 3) 时,由于函数参数不足,c 的值为 undefined,最终结果可能不是预期的简单两数相加。

然而,可以通过在函数内部检查参数的个数或类型来模拟函数重载的效果。例如:

function add() {
    if (arguments.length === 2) {
        return arguments[0] + arguments[1];
    } else if (arguments.length === 3) {
        return arguments[0] + arguments[1] + arguments[2];
    }
}

let result3 = add(2, 3); 
let result4 = add(2, 3, 4); 

在这个 add 函数中,通过 arguments.length 来检查传入参数的个数,从而实现不同的逻辑,模拟了函数重载的行为。

递归

递归是指函数在其函数体内调用自身的过程。普通函数可以很方便地实现递归算法。递归通常用于解决可以分解为相似子问题的问题,并且有一个明确的终止条件。例如,计算阶乘的递归函数:

function factorial(n) {
    if (n === 0 || n === 1) {
        return 1;
    } else {
        return n * factorial(n - 1);
    }
}

let fact = factorial(5);
console.log(fact); 

在这个 factorial 函数中,当 n01 时,函数返回 1,这是递归的终止条件。否则,函数通过调用自身 factorial(n - 1) 并乘以 n 来计算阶乘。递归在处理树形结构、遍历数据结构等场景中经常被使用,但需要注意避免无限递归,否则会导致栈溢出错误。

作为一等公民

在 JavaScript 中,普通函数是一等公民,这意味着函数可以像其他基本数据类型一样被使用。具体表现为以下几个方面:

  1. 可以作为变量的值:如前面介绍的函数表达式,将函数赋值给变量:
let myFunction = function () {
    console.log('This is a function assigned to a variable');
};
myFunction(); 
  1. 可以作为参数传递给其他函数:这在很多高阶函数(如 mapfilterreduce 等)中广泛应用。例如:
function forEach(array, callback) {
    for (let i = 0; i < array.length; i++) {
        callback(array[i]);
    }
}

let numbersArray = [1, 2, 3, 4];
forEach(numbersArray, function (num) {
    console.log(num * 2);
});

在这个例子中,forEach 函数接受一个数组和一个回调函数作为参数,回调函数在 forEach 函数内部被调用,处理数组中的每个元素。 3. 可以作为其他函数的返回值:就像前面介绍闭包时的例子,函数可以返回另一个函数:

function outer() {
    let message = 'Hello from outer';

    function inner() {
        console.log(message);
    }

    return inner;
}

let innerFunction = outer();
innerFunction(); 

这些特性使得 JavaScript 函数在编程中具有很高的灵活性和表现力,能够实现各种复杂的编程模式和设计模式。

函数的属性和方法

普通函数在 JavaScript 中有一些自身的属性和方法。

函数的属性

  1. namename 属性返回函数的名称。例如:
function myFunc() {}
console.log(myFunc.name); 

输出结果为 myFunc。对于匿名函数表达式,name 属性会返回一个空字符串或者根据函数的使用场景有一些特殊的值。例如:

let anonymousFunc = function () {};
console.log(anonymousFunc.name); 

在现代 JavaScript 环境中,这里会输出 anonymousFunc,因为变量名被作为函数名。但在一些旧环境中可能输出空字符串。

  1. lengthlength 属性返回函数定义时的参数个数。例如:
function funcWithParams(a, b, c) {}
console.log(funcWithParams.length); 

这里输出 3,表示函数定义了三个参数。

函数的方法

  1. callcall 方法用于调用一个函数,并将 this 绑定到指定的对象,同时可以传递参数。语法为 function.call(thisArg, arg1, arg2,...)。例如:
let person1 = { name: 'Alice' };
let person2 = { name: 'Bob' };

function greet() {
    console.log('Hello, I am'+ this.name);
}

greet.call(person1); 
greet.call(person2); 
  1. applyapply 方法与 call 方法类似,也是用于调用函数并绑定 this,但它通过数组来传递参数。语法为 function.apply(thisArg, [arg1, arg2,...])。例如:
function sum(a, b) {
    return a + b;
}

let numbers = [3, 5];
let result = sum.apply(null, numbers);
console.log(result); 

这里 null 表示 this 指向全局对象(在非严格模式下),如果在严格模式下,this 会是 null

  1. bindbind 方法返回一个新的函数,新函数的 this 被绑定到指定的对象,并且可以预设部分参数。语法为 function.bind(thisArg, arg1, arg2,...)。例如:
function multiply(a, b) {
    return a * b;
}

let double = multiply.bind(null, 2);
let resultDouble = double(5);
console.log(resultDouble); 

在这个例子中,bind 方法返回一个新函数 doublethis 绑定到 null,并且预设了第一个参数为 2,调用 double(5) 相当于调用 multiply(2, 5)

通过对 JavaScript 普通函数的定义方式及其各种特点的深入了解,开发者能够更好地利用函数这一强大工具,编写出更高效、灵活和可维护的 JavaScript 代码。无论是简单的函数声明,还是涉及闭包、this 绑定等复杂概念的应用,都为 JavaScript 编程提供了丰富的可能性。在实际开发中,根据不同的需求和场景,合理选择函数的定义方式和运用其特点,是提升代码质量和开发效率的关键。同时,理解函数在作用域、变量提升、递归等方面的行为,也有助于避免常见的编程错误,确保代码的正确性和稳定性。在现代 JavaScript 开发中,随着各种框架和库的广泛应用,对普通函数底层原理和特点的掌握,更是深入理解和运用这些工具的基础。