Skip to content

Latest commit

 

History

History
executable file
·
880 lines (645 loc) · 31.4 KB

第06章 函数.md

File metadata and controls

executable file
·
880 lines (645 loc) · 31.4 KB

一、概述

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

二、函数相关

1. 函数定义

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

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

参数解读:

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

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

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

2. 函数调用

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

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

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

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

3. 函数参数

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

3.1. 参数声明

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

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

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

3.2. 参数作用域

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

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

3.3. 形参与实参

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

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

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

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

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

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

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

3.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);

3.5. 对位传参法

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

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

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

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

3.6. 对象传参法

将对象作为参数传递:

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

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

3.7. 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

3.8. 传值与传址

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

  • 传值:即将实参值拷贝一份赋给形参,不能在函数内部通过形参修改原始值。
    • 基本类型(如数值、字符串、布尔值、null、undefined)—— 栈区
  • 传址:即地址传递,在计算机内存中,地址是唯一的,在函数内部我们可以通过该地址修改原始值。
    • 对象类型(对象、数组、函数)—— 堆区
// 值传递
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]

4. 函数返回值

每一个函数都会有一个返回值,这个返回值可以通过关键字 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};
}

5. 函数名的提升

js引擎将函数名视同变量名,使用 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!"

6. 函数类型

6.1. 声明式函数

语法形式:

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

6.2. 表达式函数

语法形式:

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

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

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

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

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

6.3. 回调函数

在 js 中,回调函数是作为另一个函数的参数传递的函数。当这个函数执行完毕后,它会调用该回调函数来通知结果。回调函数常用于异步操作中,例如在网络请求完成后处理返回的数据。

举个例子,假设有一个函数 doSomething 它接受两个参数:一个输入值和一个回调函数。该函数会对输入值进行计算并在计算完成后调用回调函数,并将计算结果作为参数传入回调函数。下面是一个使用回调函数的示例代码:

function doSomething(input, callback) {
  // 计算 input 的平方
  const result = input * input;

  // 调用回调函数,并将计算结果作为参数传入
  callback(result);
}

// 使用 doSomething 函数,并传入回调函数
doSomething(5, function(result) {
  console.log(result); // 输出 25
});

在上面的示例代码中,我们调用了 doSomething 函数,并传入了一个回调函数。当 doSomething 函数计算完成后,它会调用该回调函数,并将计算结果作为参数传递给它。在本例中,页面上会输出 25,因为 5 的平方是 25。

6.4. 立即执行函数(IIFE)

立即执行函数语法形式为:

(function(){})()

由于函数被包含在一对 ( ) 括号内部,因此成为了一个表达式,通过在末尾加上另外一个 ( ) 可以立即执行这个函数。这种模式很常见,几年前社区给它规定了一个术语:IIFE,代表立即执行函数表达式(Immediately Invoked Function Expression);

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

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

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

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

6.5. 构造函数

构造函数是一种特殊的函数,用于创建对象并设置其属性和方法。构造函数使用new关键字调用,并返回一个新的对象实例。构造函数通常具有与其相关联的名称,并且使用大写字母开头以区别于普通函数。

例如,以下是一个名为 Person 的构造函数:

function Person(name, age) {
  this.name = name;
  this.age = age;
  
  this.sayHello = function() {
    const s = "";
    s += "Hello, my name is ";
    s += this.name;
    s += " and I am ";
    s += this.age;
    s += " years old.";
    console.log(s);
  }
}

通过使用new关键字,可以使用该构造函数创建新的 Person 对象实例:

const person1 = new Person("John", 30);
const person2 = new Person("Jane", 25);

person1.sayHello(); // 输出 "Hello, my name is John and I am 30 years old."
person2.sayHello(); // 输出 "Hello, my name is Jane and I am 25 years old."

在这个例子中,Person 构造函数接受两个参数 nameage,并将它们分别存储为新对象的属性。此外,构造函数还定义了一个 sayHello 方法,该方法可用于打印当前人员的名称和年龄。通过使用 new 关键字创建两个新的 Person 对象实例,并对每个对象调用 sayHello 方法,从而打印出不同的输出。

7. 尾调用

函数尾调用是指在一个函数的 最后一步 调用另一个函数,并将其返回值作为自己的返回值。

尾调用的优势:

  1. 避免浪费内存,因为调用栈中不需要保存多余的调用信息。
  2. 有助于优化代码性能(但需要注意的是,尾调用只能出现在函数的最后一步,否则无法达到优化的效果)
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的最后一步操作。

三、闭包

5.1. 概念

闭包是基于词法作用域书写代码时所产生的自然结果。js中 闭包无处不在,你只需要能够识别并拥抱它。

简单理解:闭包就是能够读取其他函数内部变量的函数,使得函数不被GC(垃圾回收机制)回收。

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。下面用一些代码来解释这个定义:

function foo() {
  var a = 2;
  function bar() {
    console.log(a); // → 2
  }
  bar();
}
foo();

这段代码看起来和嵌套作用域中的示例代码很相似。基于词法作用域的查找规则,函数 bar() 可以访问外部作用域中的变量 a(这个例子中的是一个 RHS 引用查询)。

这是闭包吗?技术上来讲,也许是。但根据前面的定义,确切地说并不是。我认为最准确地用来解释 bar()a 的引用的方法是词法作用域的查找规则,而这些规则只是闭包的一部分。(但却是非常重要的一部分!)

从纯学术的角度说,在上面的代码片段中,函数 bar() 具有一个涵盖 foo() 作用域的闭包(事实上,涵盖了它能访问的所有作用域,比如全局作用域)。也可以认为 bar() 封闭了 foo() 的作用域中。为什么呢?原因简单明了,因为 bar() 嵌套在 foo() 内部。

但是通过这种方式定义的闭包并不能直接进行观察,也无法明白在这个代码片段中闭包是如何工作的。我们可以很容易地理解词法作用域,而闭包则隐藏在代码之后的神秘阴影里,并不那么容易理解。

下面我们来看一段代码,清晰地展示了闭包:

function foo() {
  var a = 2;
  function bar() {
    console.log(a);
  }
  return bar;
}
var baz = foo();
baz(); // → 2 —— 朋友,这就是闭包的效果。

函数 bar() 的词法作用域能够访问 foo() 的内部作用域。然后我们将 bar() 函数本身当作一个值类型进行传递。在这个例子中,我们将 bar 所引用的函数对象本身当作返回值。

foo() 执行后,其返回值(也就是内部的 bar() 函数)赋值给变量 baz 并调用 baz(),实际上只是通过不同的标识符引用调用了内部的函数 bar()

bar() 显然可以被正常执行。但是在这个例子中,它在自己定义的词法作用域以外的地方执行。

foo() 执行后,通常会期待 foo() 的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去 foo() 的内容不会再被使用,所以很自然地会考虑对其进行回收。

而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收。谁在使用这个内部作用域?原来是 bar() 本身在使用。拜 bar() 所声明的位置所赐,它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一直存活,以供 bar() 在之后任何时间进行引用。

bar() 依然持有对该作用域的引用,而这个引用就叫作 闭包

因此,在几微秒之后变量 baz 被实际调用(调用内部函数 bar),不出意外它可以访问定义时的词法作用域,因此它也可以如预期般访问变量 a

这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的词法作用域。

当然,无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包。

function foo() {
  var a = 2;
  function baz() {
    console.log(a); // → 2
  }
  bar(baz);
}
function bar(fn) {
  fn(); // → 快看呀,这就是闭包!
}

把内部函数 baz 传递给 bar,当调用这个内部函数时(现在叫作 fn),它涵盖的 foo() 内部作用域的闭包就可以观察到了,因为它能够访问 a

传递函数当然也可以是间接的。

var fn;
function foo() {
  var a = 2;
  function baz() {
    console.log(a);
  }
  fn = baz; // → 将 baz 分配给全局变量
}
function bar() {
  fn(); // → 快看呀,这就是闭包!
}
foo();
bar(); // → 2

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

5.2. 现在我懂了...

前面的代码片段有点死板,并且为了解释如何使用闭包而人为地在结构上进行了修饰。但我保证闭包绝不仅仅是一个好玩的玩具。你已经写过的代码中一定到处都是闭包的身影。现在让我们来搞懂这个事实。

function wait(message) {
  setTimeout(function timer() {
    console.log(message);
  }, 1000);
}
wait('Hello, closure!');

将一个内部函数(名为 timer)传递给 setTimeout(..)timer 具有涵盖 wait(..) 作用域的闭包,因此还保有对变量 message 的引用。

wait(..) 执行 1000 毫秒后,它的内部作用域并不会消失,timer 函数依然保有 wait(..) 作用域的闭包。

在引擎内部,内置的工具函数 setTimeout(..) 持有对一个参数的引用,这个参数也许叫作 fn 或者 func,或者其他类似的名字。引擎会调用这个函数,在例子中就是内部的 timer 函数,而词法作用域在这个过程中保持完整。

这就是闭包。

或者,如果你很熟悉 jQuery(或者其他能说明这个问题的 JavaScript 框架),可以思考下面的代码:

function setupBot(name, selector) {
  $(selector).click(function activator() {
    console.log('Activating: ' + name);
  });
}
setupBot('Closure Bot 1', '#bot_1');
setupBot('Closure Bot 2', '#bot_2');

本质上无论何时何地,如果将(访问它们各自词法作用域的)函数当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!

5.3. 循环和闭包

要说明闭包,for 循环是最常见的例子。

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

正常情况下,我们对这段代码行为的预期是分别输出数字 1~5,每秒一次,每次一个。但实际上,这段代码在运行时会以每秒一次的频率输出五次 6。这是为什么?

首先解释 6 是从哪里来的。这个循环的终止条件是 i 不再 <=5。条件首次成立时 i 的值是6。因此,输出显示的是循环结束时 i 的最终值。

仔细想一下,这好像又是显而易见的,延迟函数的回调会在循环结束时才执行。事实上,当定时器运行时即使每个迭代中执行的是 setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个 6 出来。

这里引伸出一个更深入的问题,代码中到底有什么缺陷导致它的行为同语义所暗示的不一致呢?

缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i

这样说的话,当然所有函数共享一个 i 的引用。循环结构让我们误以为背后还有更复杂的机制在起作用,但实际上没有。如果将延迟函数的回调重复定义五次,完全不使用循环,那它同这段代码是完全等价的。

下面回到正题。缺陷是什么?我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。

我们知道,IIFE 会通过声明并立即执行一个函数来创建作用域。试一下:

for (var i = 1; i <= 5; i++) {
  (function () {
    setTimeout(function timer() {
      console.log(i);
    }, i * 1000);
  })();
}

这样能行吗?不行!但是为什么呢?我们现在显然拥有更多的词法作用域了。的确每个延迟函数都会将 IIFE 在每次迭代中创建的作用域封闭起来。

如果作用域是空的,那么仅仅将它们进行封闭是不够的。仔细看一下,我们的 IIFE 只是一个什么都没有的空作用域。它需要包含一点实质内容才能为我们所用。

它需要有自己的变量,用来在每个迭代中储存 i 的值:

for (var i = 1; i <= 5; i++) {
  (function () {
    var j = i;
    setTimeout(function timer() {
      console.log(j);
    }, j * 1000);
  })();
}

行了!它能正常工作了!可以对这段代码进行一些改进:

for (var i = 1; i <= 5; i++) {
  (function (j) {
    setTimeout(function timer() {
      console.log(j);
    }, j * 1000);
  })(i);
}

当然,这些 IIFE 也不过就是函数,因此我们可以将 i 传递进去,如果愿意的话可以将变量名定为 j,当然也可以还叫作 i。无论如何这段代码现在可以工作了。

在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。这样就问题解决啦!

5.4. 重返块作用域

仔细思考我们对前面的解决方案的分析。我们使用 IIFE 在每次迭代时都创建一个新的作用域。换句话说,每次迭代我们都需要一个块作用域。前面介绍了 let 声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量。

本质上这是将一个块转换成一个可以被关闭的作用域。因此,下面这些看起来很酷的代码就可以正常运行了:

for (var i = 1; i <= 5; i++) {
  let j = i; // → 是的,闭包的块作用域!
  setTimeout(function timer() {
    console.log(j);
  }, j * 1000);
}

但是,这还不是全部!for 循环头部的 let 声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随 后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

for (let i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

很酷是吧?块作用域和闭包联手便可天下无敌。不知道你是什么情况,反正这个功能让我成为了一名快乐的 JavaScript 程序员。

5.5. 小结

必要有两个特性:

  1. 封闭性:数据的私密性,外界无法访问闭包内部的数据,除非闭包主动向外界提供访问接口。
  2. 持久性:延长变量生命周期

闭包优势:

  1. 允许在函数外部访问函数内部的变量;

  2. 保护变量不受全局环境污染,提高程序安全性;

  3. 可以实现私有变量和方法的封装;

  4. 可以延长变量的生命周期,使得数据维持在内存中,适合于高阶函数的使用。

闭包劣势:

  1. 过多的使用闭包可能导致 内存泄漏,必须手动解除对变量的引用才能释放内存;

  2. 闭包的使用会增加代码的复杂度,降低可读性和可维护性;

  3. 函数执行 效率低下,因为需要保存当前词法环境。

闭包就好像从js中分离出来的一个充满神秘色彩的未开化世界,只有最勇敢的人才能够到达那里。但实际上它只是一个普通且明显的事实,那就是我们在词法作用域的环境下写代码,而其中的函数也是值,可以随意传来传去。

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。

如果没能认出闭包,也不了解它的工作原理,在使用它的过程中就很容易犯错,比如在循环中。现在我们会发现代码中到处都有闭包存在,并且我们能够识别闭包然后用它来做一些有用的事!

6. 递归函数

递归函数是一种函数调用自身的方式。在js中,递归函数通常用于解决需要重复执行相同任务的问题。例如,遍历树形结构、计算斐波那契数列等。

示例:

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

console.log(factorial(5)); // 输出 120

这个函数用递归来计算阶乘。当n为0时,返回1,否则返回nfactorial(n-1)的积。每次递归都会将参数n减1,直到n等于0为止。

四、拓展

1. call()bind()apply()

它们都是用于改变函数执行上下文(this)的方法:

  1. call(this, ...args):修改 this 指针并 立即执行函数

  2. bind(this, ...args):修改 this 指针但是 不会立即执行函数,而是 返回一个新函数

  3. apply(this, [..args]):修改 this 指针并 立即执行函数

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

var r = bar.call(foo, 'Li-HONGYAO', 'Web Front-end Engineer');
console.log(r); // → { value: 1, name: 'Li-HONGYAO', job: 'Web Front-end Engineer' }

var r = bar.apply(this, ['Li-HONGYAO', 'Web Front-end Engineer']); /** this 指向window, 所以this.value = 2 */
console.log(r); // → {value: 2, name: 'Li-HONGYAO', job: 'Web Front-end Engineer'}

var func = bar.bind(foo, 'Li-HONGYAO', 'Web Front-end Engineer'); /** 返回新函数 */
console.log(func()); // → { value: 1, name: 'Li-HONGYAO', job: 'Web Front-end Engineer' }

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

函数声明与函数表达式是在 js 中定义函数的两种不同方式:

  1. 函数声明通过使用 function 关键字来创建一个函数,并将其命名为一个标识符。这个函数可以在声明之前或之后被调用,因为函数声明会提升到当前作用域的顶部。
  2. 函数表达式则是在一个表达式中定义一个函数,并将其赋值给一个变量。这个函数只能在其定义之后被调用。

**Tips:**需要注意的是,函数表达式中的函数没有名称,它只是被赋值给了一个变量。如果需要对该函数进行递归或调试,则必须使用该变量名而不是函数名。