第07章 函数

158 阅读17分钟

一、概述

所谓 “函数”,就是使用关键字 function 定义的一段具有独立作用域,能被反复执行的语句块,或者说函数就是功能。函数在JavaScript中也是以一种“值”的方式存在的,和其它数据类型相比,它是一种可以接收参数的、可运行的值,除此之外,它并没有任何的不同。JavaScript作为一个“面向对象”和“函数式编程”的语言,因此函数部分自然是一个重点

二、函数定义

函数利用关键字 function 声明,其语法形式如下:

function function_name(arguments) {
    // function_body
	return ; 
}

参数解读:

  • function:定义函数
  • function_name:函数名字
  • function_body:函数体(代码块)
  • arguments:函数参数,参数之间以逗号隔开
  • return:函数返回值

【实例 1】创建一个 sum 函数,计算两个数的和,并返回结果,代码如下:

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

三、函数调用

函数定义以后,并不会立即执行(自调函数除外,自调函数一旦创建,程序执行之后自调函数也会自动执行),因此要执行函数,需要调用函数,我们看下面这个实例。

// 定义函数
function sayHi() {
	console.log("Hi");
}
// 调用函数
sayHi(); 

上述代码定义函数之后,通过 sayHi() 对函数进行调用,控制台输出 ‘Hi’

提示:函数调用的形式为:function_name(argument),需要注意的是,即使没有参数,圆括号也不能省略。

四、函数参数

我们在很早以前就已经接触过函数参数了,它是让函数可复用的关键性存在。就是当程序里很多地方都在做着同样一件事件,但只是部分需要呈现的内容不同的时候,我们就可以使用配置参数的形式来完成一个函数的功能。如在学习JavaScript之初,我们就已经接触到的alert()函数和console.log()方法一样,我们只需要在使用它们的时候往该函数或方法的括号内添加我们需要显示的内容即可让它实现其功能。

1、参数声明

函数参数无需指定类型,它的类型是在调用函数时,根据传递的参数值的类型所确定的,并且,函数参数无需使用var关键字声明,函数允许有多个参数,多个参数之间使用逗号,隔开。

// 定义函数
function sum(a, b) {
    return a + b;
}

// 2、调用函数
var res = sum(5, 6);

2、参数作用域

在之前讲变量时我们已经提到变量的作用域,所谓“作用域 “,就是变量起作用的范围,全局变量的作用域为全局,而局部变量的作用域限定在某个范围。函数参数为局部变量,其起作用的范围只限于函数内部,外界不能访问。

function sum(a, b) {
    return a + b;
}
console.log(a);
// Uncaught ReferenceError: a is not defined

3、形参与实参

函数参数分为”形参 “与”实参

  • 所谓形参,就是指形式参数,它并无确定的值;

  • 所谓实参,就是指实际参数,它有确定的值;

定义函数时,圆括号内的参数为形参,调用函数时,圆括号内的参数为实参,具有确定的值。

形参是对实参的引用,在函数内部,如果对传递的值进行修改,并不会修改原始值。

var x = 0;
function test(n) {
	n++;
}
test(x)
console.log(x); // 0

上述例子中,函数外部定义了变量 x,在调用test函数时,将x作为实参传递给形参 n,函数内部 n++,变量x的值并未修改,依旧为 0

4、为参数设置默认值

为参数设置默认值可通过 三元运算符(?:)或者 或运算符(||)。

function print(str, num) {
    num = num == undefined ? 0 : num;
    str = str || "Hello, world!";
    console.log(str);
}

提示:

  • 值得注意的是,或运算符只对非数字对象有效,如果参数为数值类型,则使用三元运算符,因为如果传递的数字为0,根据自动类型转换,0被转换为false,因此也会认为该参数没有值。
  • 在ES6中,你可以直接在声明形参时赋默认值,比如:function sum(a = 0, b = 0);

6、对位传参法

函数参数的配置和参数的设置需要一一对位,即配置参数的顺序和函数定义参数时的顺序一致。

function info(name, age, major, origin) {
    console.log(`
        姓名:${name}
        年龄:${age}
        专业:${major}
        籍贯:${origin};
    `);
}
info("耀哥", 31, "软件技术", "四川自贡");

/*
姓名:耀哥
年龄:31
专业:软件技术
籍贯:四川自贡*/

注意:对位传参必须一一对应,否则将会出现无法预期的结果。

7、对象传参法

将对象作为参数传递:

function stuInfo(stu) {
    console.log(`
        姓名:${stu.name}
        年龄:${stu.age}
        专业:${stu.major}
        籍贯:${stu.origin};
    `);
}
stuInfo({ major: "软件技术", name: "耀哥", age: 31, origin: "四川自贡"});

/*
姓名:耀哥
年龄:31
专业:软件技术
籍贯:四川自贡*/

8、arguments 对象

在某些特定的情况下,我们根本不知道函数在调用的时候到底需要配置几个参数,比如你要设置一个求和的函数,可能你需要求两个数的和,那就需要两个参数,如果需要求五个数的和,就需要五个参数。为了应对这种情况,JavaScript对函数提供了一个 arguments 对象来应对以上情况。

我们首先要对 arguments 这个对象进行一个基本概念的了解。arguments 对象只能出现在函数内部,在“全局空间”里该对象是无效的。该对象包含了函数运行时的所有参数,arguments[0] 就是第一个参数,arguments[1]就是第二个参数,以此类推。

function sum() {
    console.log(arguments);
}
sum(1, 2);
sum(1, 2, 3);
// Arguments(2) [1, 2, callee: ƒ, Symbol(Symbol.iterator): ƒ]
// Arguments(3) [1, 2, 3, callee: ƒ, Symbol(Symbol.iterator): ƒ]
console.log(arguments);
// Uncaught ReferenceError: arguments is not defined

需要注意的是,虽然 arguments 很像数组:

Arguments(3) [1, 2, 3, callee: ƒ, Symbol(Symbol.iterator): ƒ]
0: 1
1: 2
2: 3
callee: ƒ sum()
length: 3
Symbol(Symbol.iterator): ƒ values()
__proto__: Object

但它是一个对象(即“ 类似数组对象 ”)。数组专有的方法,不能在arguments对象上直接使用。不过我们可以通过数组的 slice 原型链中的 call 方法将一个类似数组转化为一个真正的数组,然后你就可以使用数组方法了。

function sum() {
    // 将arguments对象转换为真正的数组
    arguments = Array.prototype.slice.call(arguments);
    // 调用数组的reduce方法累加数据
    var res = arguments.reduce(function(pre, cur) {
        return pre + cur;
    });
    console.log(`sum = ${res}`);
}
sum(1, 2);  // 3
sum(1, 2, 3); // 6
sum(1, 2, 3, 4); // 10

9、值传递与地址传递

参数传递分为“值传递” 与 “地址传递”,

  • 值传递:就是调用函数时,形参是对实参的拷贝,即将实参值拷贝一份赋给了形参,因此我们不能在函数内部通过形参修改原始值。
  • 地址传递:传递的是地址,在计算机内存中,地址是唯一的,在函数内部我们可以通过该地址修改原始值。

在JavaScript中,原始数据类型(如数值、字符串、布尔值),为值传递;对象类型(如对象、数组),为地址传。

// 值传递
var m = 10;
function test_1(n) {
    n = 20;
    console.log(m, n);// 10 20
}
test_1(m);
console.log(m);

// 地址传递
var stu = {name: "张三", major: "软件技术"};
function test_2(obj) {
    obj.major = "软件工程";
}
test_2(stu);
console.log(stu.major); // 软件工程


var nums = [1, 2];
function test_3(arr) {
    arr.push(3);
}
test_3(nums);
console.log(nums); // (3) [1, 2, 3]

五、函数返回值

每一个函数都会有一个返回值,这个返回值可以通过关键字 return 进行设置,若未显示地设置函数的返回值,那函数会默认返回一个 undefined 值。

function test() {
    console.log("Hello, world!");
}
test(); // undefined

但设置了返回值,则直接返回 return 之后的值。

function getSkill() {
	return '佛山无影脚';
}
getSkill(); // "佛山无影脚"

return关键字,除了能够返回值以外,另外一个作用便是终止函数,return关键字后的代码不会执行。

function getSkill() {
	return '佛山无影脚';
  console.log("Hello, world!"); // 该语句不会被执行
}
getSkill(); // "佛山无影脚"

return 关键字后可以是变量也可以是表达式,甚至可以是数组或对象,只要符合JavaScript基本数据类型,都可以返回。

// 1、返回值为变量
function func_1() {
	var a = 10;
	return a;
}
// 2、返回值为表达式
function func_2() {
	var a = 10, b = 10;
	return a + b;
}
// 3、返回值为数组
function func_3() {
	return [1, 2, 3];
}
// 4、返回值为对象
function func_4() {
	return {name:"Petter", age:23}
}
// 5、返回值为布尔类型的值
function func_5() {
	return true;
}
...

函数返回值只能是一个,不能有多个返回值,否则程序报错,如果要返回多个值,可以用数组或对象。

// 以数组形式返回
function minAndMaxNumInArr(arr) {
	var min = Math.min(...arr), max = Math.max(...arr);
	return [min, max];
}

// 以对象形式返回
function minAndMaxNumInArr(arr) {
	var min = Math.min(...arr), max = Math.max(...arr);
	return {min, max};
}

六、函数名的提升

JavaScript引擎将函数名视同变量名,所以采用function命令声明函数时,整个函数会像变量声明一样,被提升到代码头部。所以,下面的代码不会报错。

sayHello(); // "Hello, world!"
function sayHello() {
	console.log("Hello, world!");
}

上述代码中,函数的调用在函数定义之前,但是由于变量提升,函数sayHello被提升到了代码头部,也就是在调用之前已经声明了。但是,如果采用赋值语句定义函数,JavaScript就会报错。

f();
var f = function (){};
// TypeError: undefined is not a function

上面的代码等同于下面的形式。

var f;
f();
f = function () {};

上面代码第二行,调用f的时候,f只是被声明了,还没有被赋值,等于undefined,所以会报错。因此,如果同时采用function命令和赋值语句声明同一个函数,最后总是采用赋值语句的定义。

var f = function() {
  console.log('1');
}

function f() {
  console.log('2');
}

f() // 1

如果函数名重复,则后定义的函数会覆盖之前定义的函数。

function sayHello() {
	console.log("Hello, world!");
}
function sayHello() {
	console.log("Hello, China!");
}

sayHello(); // "Hello, China!"

七、函数类型

1、普通函数

语法形式:

function <函数名>([参数1, 参数2...]) {
	函数体(业务逻辑)
	return <返回值>
}

2、变量函数

变量函数利用函数表达式进行声明,基本语法形式为:

/*
var a = 10;
var a = function() {};*/

var function_name = function(arguments) {
	函数体(业务逻辑)
  return;
}

通过表达式声明的函数需要注意两点:

  • **1、**这样声明的函数,需要先声明后调用;
  • **2、**表达式内部的 function 无需再设置函数名,如果这样写,function后方的函数名只能被函数内部调用,在外部是无法使用的;

除了上述需要注意的两点外,这种方式声明的函数和利用funciton关键字定义的函数并没有什么区别。

3、自调用函数(IIFE)

自调函数又叫立即调用函数。其基本的语法形式为:

(function(){})()

这种函数声明方式的最大特点是“即时性”。它不需要任何调用,即可立即执行。自调用函数一般无需设置函数名( 没有函数名的函数我们可以称其为匿名函数)。它执行的原理是利用小括号将函数自身括起来,以到达提升括号内函数表达式优先级的作用,括号内部的函数生效后,又紧接着后面的括号进行函数的调用,从而实现自我调用的效果。如下例:

(function() {
	console.log("Hello, world!");
})();
// "Hello, world!"

(function(a, b) {
	return a + b;
})(1, 2);
// 3

这种函数的声明方式和其它函数的声明方式一样,它仍然有自己的独立作用域。自调用函数还有一个特点就是,它的运行虽然还是在程序的独立线程完成的,但是却可以达到程序在主线程完成的效果。

4、闭包函数

闭包(closure)是Javascript语言的一个难点,也是它的特色,很多高级应用都要依靠闭包实现。闭包就是能够读取其他函数内部变量的函数,使得函数不被GC(垃圾回收机制)回收,如果过多使用闭包,容易导致内存泄露。

要理解闭包,首先必须理解变量作用域。前面提到,JavaScript有两种作用域:全局作用域和函数作用域。函数内部可以直接读取全局变量。

var str = "Hello, world!";
function test() {
	console.log(str);
}
test();

上面代码中,函数sayHi 方法可以读取全局变量str

但是,在函数外部无法读取函数内部声明的变量。

function test() {
	var str = "Hello, world!";
}
test();
console.log(str);
// Uncaught ReferenceError: str is not defined

上面代码中,函数内部声明的变量str,函数外是无法读取的。

如果出于种种原因,需要得到函数内的局部变量。正常情况下,这是办不到的,只有通过变通方法才能实现。那就是在函数的内部,再定义一个函数。

function func_1() {
	var str = "Hello, world!";
  
	function func_2() {
		console.log(str); // "Hello, world!"
	}
  
  func_2();
}
func_1();

上面代码中,函数 func_2 定义在函数 func_1 内部,这时 func_1 内部的所有局部变量,对 func_2 都是可见的。但是反过来就不行,func_2 内部的局部变量,对 func_1 是不可见的。这就是JavaScript语言特有的”链式作用域”结构(chain scope),子对象会一级一级地向上寻找所有父对象的变量。所以,父对象的所有变量,对子对象都是可见的,反之则不成立。

既然 func_2 可以读取 func_1 的局部变量,那么只要把 func_2 作为返回值,我们就可以在 func_1 外部读取它的内部变量了。

function func_1() {
	var str = "Hello, world!";
	function func_2() {
		console.log(str);
	}
	return func_2;
}
func_1()(); // "Hello, world!"

上面代码中,函数 func_1 的返回值就是函数 func_2,由于 func_2 可以读取 func_1 的内部变量,所以就可以在外部获得 func_1 的内部变量了。

func_2 函数就是闭包 ,即能够读取其他函数内部变量的函数。由于在JavaScript语言中,只有函数内部的子函数才能读取内部变量,因此可以把闭包简单理解成 “定义在一个函数内部的函数即为闭包”。闭包最大的特点,就是它可以“记住”诞生的环境,比如 func_2 记住了它诞生的环境 func_1,所以从 func_2 可以得到 func_1 的内部变量。在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。

闭包的最大用处有两个:

  • 1)可以读取函数内部的变量,让这些变量始终保持在内存中,即闭包可以使得它诞生环境一直存在。

请看下面的例子,闭包使得内部变量记住上一次调用时的运算结果。

function increment(n) {
	return function () {
		return n++;
	};
}
var inc = increment(5);
inc() // 5
inc() // 6
inc() // 7
inc() // 8

上面代码中,n 是函数 increment 的内部变量。通过闭包,n 的状态被保留了,每一次调用都是在上一次调用的基础上进行计算。从中可以看到,闭包 inc 使得函数 increment 的内部环境一直存在。所以,闭包可以看作是函数内部作用域的一个接口。

为什么会这样呢?原因就在于 inc 始终在内存中,而 inc 的存在依赖于 increment,因此也始终在内存中,不会在调用结束后,被垃圾回收机制回收。

  • 2)封装对象的私有属性和私有方法。
function Person(name, sex, age) {
	var _id = 0;
	this.setId = function(val) {
		_id = val;
	}
	this.getId = function() {
		return _id;
	}
}

上面代码中,函数 Person 的内部变量 _id 为私有变量,通过闭包 setIdgetId 封装,我们可以设置或访问该私有变量。

由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。如将当前变量的值设置为“null”,将变量的引用解除,当垃圾回收启动时,会自动对这些值为“null”的变量回收。将上面的闭包示例稍微修改一下来减少对内存的消耗:

function increment(n) {
	return function () {
		return n++;
	};
}
var inc = increment(5);
inc = null;

【实例】模拟数组forEach方法

function forEach(arr, callback) {
    for(var i = 0, len = arr.length; i < len; i++) {
        callback(arr[i], i, arr)
    }
}
forEach(["A", "B", "C"], function(item, index, arr) {
    console.log(item, index, arr);
});

// 输出结果
A 0 (3) ["A", "B", "C"]
B 1 (3) ["A", "B", "C"]
C 2 (3) ["A", "B", "C"]

5、递归函数

JavaScript和其它编程技术一样,拥有一种函数在执行的时候调用自身的实现,这种实现在编程技术中叫做**“递归”**,通过递归可以同更少的代码完成很多需要大量代码,甚至是不确定代码所能完成的事。我们先在看一个简单的“递归”实现的例子:

function count(n) {
	console.log(n--);
	if (n > 0) {
		count(n);
	}
}

count(5); 

// 5
// 4
// 3
// 2
// 1

通过上面的例子可以简单地分析递归的原理,可以发现它和循环十分的相似。在使用递归时同样要注意一个问题,就是要防止结束条件的不明确导致出现“死循环”,导致浏览器崩溃。根据浏览器不同报出的错误描述各有差异,但总得来说都属于“RangeError”类型的错误,按照O'Reilly技术书籍出版社出版的书籍中的称呼叫做“调用栈错误”,书中也建议使用“迭代”(即循环)来替代“递归”,防止有些浏览器对递归迭代周期过长而产生的报错。

尽管如此,在思路和结束条件都比较明确的时候用递归去执行一些需要重复的“递增”或“递减”算法还是一件比较“有趣”的事的,它和执行一个循环一样,可以减少我们大部分不必要的重复性的工作,比如求n!。

// 5! -> 5 * 4 * 3 * 2 * 1
function factorial(n) {
	if (n == 0 || n == 1) {
		return 1;
	}else {
		return n * factorial(n - 1);
	}
}
factorial(5);  // 120

八、尾调用

尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数,注意是最后一步。

function f(x){
	return g(x);
}

上面代码中,函数 f 的最后一步是调用函数 g,这就叫尾调用。以下三种情况,都不属于尾调用。

// 情况一
function f(x) {
	var y = g(x);
	return y;
}

// 情况二
function f(x){
	return g(x) + 1;
}

// 情况三
function f(x){
	g(x);
}

上面代码中,情况一是调用函数g之后,还有赋值操作,所以不属于尾调用,即使语义完全一样。情况二也属于调用后还有操作,即使写在一行内。情况三等同于下面的代码。

function f(x){
	g(x);
	return undefined;
}

尾调用不一定出现在函数尾部,只要是最后一步操作即可。

function f(x) {
	if (x > 0) {
		return m(x);
	}
	return n(x);
}

上面代码中,函数m和n都属于尾调用,因为它们都是函数f的最后一步操作。

九、拓展

1. call()、bind()、apply()

callbindapply 都是修改对象的this指针。

func.call(this, ...args):修改 this 指针并 执行函数,参数为具体的参数,返回值就是调用函数的返回值。

func.apply(this, [..args]):修改 this 指针并 执行函数,参数为参数数组,返回值就是调用函数的返回值。

func.bind(this, ...args):修改 this 指针但是 不执行函数,返回的是函数名称,参数为具体参数,返回值就是绑定 this 之后的新函数。

var value = 2;
var foo = {
  value: 1,
};
function bar(name, job) {
  return {
    value: this.value,
    name,
    job,
  };
}

bar.call(foo, "Muzili", "前端工程师");
// {value: 1, name: "Muzili", job: "前端工程师"}

bar.apply(this, ["Muzili", "前端工程师"]);
// {value: 2, name: "Muzili", job: "前端工程师"}

var func = bar.bind(foo, "Muzili", "前端工程师");
console.log(func());
// {value: 1, name: "Muzili", job: "前端工程师"}

2. 函数声明与函数表达式的区别

在Javscript中,解析器在向执行环境中加载数据时,对函数声明和函数表达式并非是一视同仁的,解析器会率先读取函数声明,并使其在执行任何代码之前可用(可以访问),至于函数表达式,则必须等到解析器执行到它所在的代码行,才会真正被解析执行。