函数
# 05.函数
# 目录介绍
- 4.1 函数定义
- 4.2 属性和方法
- 4.3 函数作用域
- 4.4 函数参数
- 4.5 返回值
- 4.6 匿名函数
- 4.7 闭包
- 4.8 立即执行函数
- 4.9 回调函数
- 4.10 异步函数
- 4.11 函数绑定
- 4.12 this对象
# 4.1 函数定义
函数是一段可以反复调用的代码块。函数还能接受输入的参数,不同的参数有唯一对应的返回值。
函数的底层本质:在 JavaScript 引擎中,函数本质上是一个可调用的对象(Callable Object)。每个函数对象内部都有一个 [[Call]] 方法,当你使用 () 调用函数时,引擎实际调用的是这个内部方法。此外,用 function 声明或 function 表达式创建的函数还有 [[Construct]] 方法(可以被 new 调用),而箭头函数没有 [[Construct]],所以不能作为构造函数。
# 4.1.1 函数声明
使用 function 关键字定义函数。
函数声明会被提升(hoisted),可以在定义之前调用。
// 可以在声明前调用——函数提升
console.log(greet("Alice")); // Hello, Alice!
function greet(name) {
return `Hello, ${name}!`;
}
2
3
4
5
6
# 4.1.2 函数表达式
函数表达式不会被提升,必须在定义之后调用。将函数赋值给变量。
const greet = function(name) {
return `Hello, ${name}!`;
};
console.log(greet("Bob")); // Hello, Bob!
2
3
4
5
疑惑:函数声明和函数表达式看起来功能一样,为什么要有两种方式?
答疑:两者的核心区别在于提升行为不同。函数声明整体被提升到作用域顶部,而函数表达式只有变量声明被提升(值为 undefined),赋值在运行时才发生。
论证:
// 函数声明:整体提升
console.log(typeof foo); // "function"
function foo() { return 1; }
// 函数表达式:只有变量提升
console.log(typeof bar); // "undefined"
var bar = function() { return 2; };
2
3
4
5
6
7
结果展示:函数表达式更安全——避免了函数在声明前就被调用的潜在问题,配合 const 使用时还能防止函数被意外重新赋值。现代 JavaScript 推荐使用 const + 函数表达式或箭头函数。
# 4.1.3 箭头函数
使用 => 语法定义函数。箭头函数(ES6)
箭头函数的注意点:
- 如果形参只有一个,则小括号可以省略
- 函数体如果只有一条语句,则花括号可以省略,函数的返回值为该条语句的执行结果
- 箭头函数 this 指向声明时所在作用域下 this 的值,箭头函数不会更改 this 指向,用来指定回调函数会非常合适
- 箭头函数不能作为构造函数实例化
- 不能使用 arguments 实参
箭头函数的底层原理:箭头函数在 V8 引擎中的实现与普通函数有显著不同。普通函数在每次调用时会创建一个新的执行上下文(Execution Context),其中包含 this、arguments、new.target 等绑定。箭头函数不创建自己的 this 绑定,而是在创建时捕获外层作用域的 this 值,存储在其 [[Environment]] 内部属性中。这就是为什么箭头函数的 this 是"词法绑定"——它由定义位置决定,而非调用方式决定。
省略小括号的情况:
let fn = num => {
return num * 10;
};
2
3
省略花括号的情况:箭头函数没有自己的 this,适合用于回调函数。
const greet = (name) => `Hello, ${name}!`;
console.log(greet("Charlie")); // Hello, Charlie!
2
箭头函数返回对象字面量的陷阱:
// 错误:花括号被解析为函数体
const getObj = () => { name: "Alice" };
console.log(getObj()); // undefined
// 正确:用圆括号包裹对象字面量
const getObj2 = () => ({ name: "Alice" });
console.log(getObj2()); // { name: "Alice" }
2
3
4
5
6
7
来看一个比较复杂的箭头函数:
const getStringLang = (key: string): string => {
return translations[key] || ''; // 如果 key 不存在,返回空字符串
};
console.log(getStringLang("welcome")); // "Welcome"
console.log(getStringLang("hello")); // ""
2
3
4
5
6
- getStringLang:函数名。
- (key: string):函数参数,名为 key,类型为 string。
- : string:函数返回值类型为 string。
- => { ... }:箭头函数体。
- return '';:函数返回一个空字符串。
# 4.1.4 函数重复声明
如果同一个函数被多次声明,后面的声明就会覆盖前面的声明。
function f() {
console.log(1);
}
f() // 2
function f() {
console.log(2);
}
f() // 2
2
3
4
5
6
7
8
9
上面代码中,后一次的函数声明覆盖了前面一次。而且,由于函数名的提升(参见下文),前一次声明在任何时候都是无效的,这一点要特别注意。
# 4.1.5 函数提升的底层原理
疑惑:为什么函数声明可以在代码的任何位置调用,而函数表达式不行?
答疑:这是因为 JavaScript 引擎在执行代码前,会进行一个编译阶段。在这个阶段,引擎会扫描所有的声明,将它们注册到对应的作用域中。
论证:JavaScript 代码的执行分为两个阶段:
- 编译阶段(Creation Phase):引擎创建执行上下文,扫描当前作用域中的所有声明
function声明:整体(函数名 + 函数体) 被添加到变量对象中var声明:变量名被添加到变量对象中,初始值为undefinedlet/const声明:变量名被添加到词法环境中,但不初始化(TDZ)
- 执行阶段(Execution Phase):逐行执行代码
// 编译阶段后的等效代码
var bar; // var 提升,值为 undefined
function foo() { return 1; } // 函数声明整体提升
console.log(foo()); // 1 —— 函数已完整存在
console.log(bar); // undefined —— 变量已声明但未赋值
bar = function() { return 2; };
2
3
4
5
6
7
结果展示:函数声明提升优先于变量声明提升。如果同名的函数声明和变量声明同时存在,函数声明优先:
console.log(typeof a); // "function"(不是 "undefined")
var a = 1;
function a() {}
console.log(typeof a); // "number"(执行阶段 a 被赋值为 1)
2
3
4
# 4.1.6 三种定义方式的对比
| 特性 | 函数声明 | 函数表达式 | 箭头函数 |
|---|---|---|---|
| 提升 | 整体提升 | 仅变量名提升 | 仅变量名提升 |
| this 绑定 | 动态绑定 | 动态绑定 | 词法绑定(定义时确定) |
| arguments | 有 | 有 | 无(需用剩余参数) |
| 可作为构造函数 | 是 | 是 | 否 |
| prototype 属性 | 有 | 有 | 无 |
| 可用于 Generator | 是 | 是 | 否 |
# 4.2 属性和方法
# 4.2.1 name属性
函数的name属性返回函数的名字。
function f1() {}
f1.name // "f1"
//如果是通过变量赋值定义的函数,那么`name`属性返回变量名。
var f2 = function () {};
f2.name // "f2"
2
3
4
5
6
name属性的一个用处,就是获取参数函数的名字。
var myFunc = function () {};
function test(f) {
console.log(f.name);
}
test(myFunc) // myFunc
2
3
4
5
6
7
不同场景下的 name 属性:
// bind 返回的函数
function foo() {}
foo.bind({}).name; // "bound foo"
// 构造函数创建的函数
(new Function()).name; // "anonymous"
// Symbol 作为函数名
const sym = Symbol('description');
const obj = { [sym]() {} };
obj[sym].name; // "[description]"
// getter/setter
const descriptor = Object.getOwnPropertyDescriptor({
get age() { return 25; }
}, 'age');
descriptor.get.name; // "get age"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 4.2.2 length属性
函数的length属性返回函数预期传入的参数个数,即函数定义之中的参数个数。
function f(a, b) {}
f.length // 2
2
上面代码定义了空函数f,它的length属性就是定义时的参数个数。不管调用时输入了多少个参数,length属性始终等于2。
length属性提供了一种机制,判断定义时和调用时参数的差异,以便实现面向对象编程的"方法重载"(overload)。
length 的精确规则:length 只统计第一个默认参数之前的参数个数,不包含剩余参数:
function a(x, y, z) {} // a.length = 3
function b(x, y = 1, z) {} // b.length = 1(y 有默认值,从 y 开始不计)
function c(x, ...rest) {} // c.length = 1(剩余参数不计)
function d(x = 1) {} // d.length = 0
2
3
4
# 4.2.3 toString()
函数的toString()方法返回一个字符串,内容是函数的源码。
function f() {
a();
b();
c();
}
f.toString()
// function f() {
// a();
// b();
// c();
// }
2
3
4
5
6
7
8
9
10
11
12
上面示例中,函数f的toString()方法返回了f的源码,包含换行符在内。
ES2019 的改进:在 ES2019 之前,toString() 可能会省略注释和空白。ES2019 规范要求 toString() 必须返回函数的原始源代码,包括注释、空白等:
function /* 注释 */ foo() {
// 函数体
}
foo.toString();
// "function /* 注释 */ foo() {\n // 函数体\n}"
2
3
4
5
# 4.2.4 方法
方法是对象的属性,值为函数。
const person = {
name: "Alice",
greet: function() {
return `Hello, ${this.name}!`;
},
};
console.log(person.greet()); // Hello, Alice!
2
3
4
5
6
7
8
ES6 方法的简写语法:
const person = {
name: "Alice",
// ES6 简写方法——内部有 [[HomeObject]] 属性,可以使用 super
greet() {
return `Hello, ${this.name}!`;
},
// 等价的传统写法——没有 [[HomeObject]]
greet2: function() {
return `Hello, ${this.name}!`;
}
};
2
3
4
5
6
7
8
9
10
11
ES6 简写方法和传统函数表达式的区别:简写方法有内部的 [[HomeObject]] 属性,可以使用 super 关键字访问原型,而传统函数表达式不行。
# 4.2.5 函数是一等公民
JavaScript 中函数是"一等公民"(First-Class Citizen),这意味着函数可以像其他值一样被使用:
// 1. 赋值给变量
const greet = function(name) { return `Hi, ${name}`; };
// 2. 作为参数传递
function execute(fn, value) {
return fn(value);
}
execute(greet, "Alice"); // "Hi, Alice"
// 3. 作为返回值
function createGreeter(greeting) {
return function(name) {
return `${greeting}, ${name}!`;
};
}
const hello = createGreeter("Hello");
hello("Bob"); // "Hello, Bob!"
// 4. 存储在数据结构中
const operations = {
add: (a, b) => a + b,
sub: (a, b) => a - b,
mul: (a, b) => a * b,
};
operations.add(3, 4); // 7
// 5. 拥有属性(因为函数是对象)
function counter() { counter.count++; }
counter.count = 0;
counter(); counter(); counter();
console.log(counter.count); // 3
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 4.3 函数作用域
# 4.3.1 作用域链的形成原理
疑惑:JavaScript 是如何查找变量的?为什么内部函数能访问外部函数的变量?
答疑:JavaScript 使用词法作用域(Lexical Scope),变量的可访问性由代码的书写位置决定。引擎通过作用域链来查找变量。
论证:当引擎执行到一个函数时,会创建该函数的执行上下文(Execution Context)。每个执行上下文包含:
- 变量环境(Variable Environment):存储
var声明和函数声明 - 词法环境(Lexical Environment):存储
let/const声明 - 外部环境引用(Outer Reference):指向定义时的父作用域
var global = "全局";
function outer() {
var outerVar = "外层";
function inner() {
var innerVar = "内层";
console.log(innerVar); // 在 inner 的变量环境中找到
console.log(outerVar); // inner 中没有 → 沿 Outer Reference 到 outer 中找到
console.log(global); // inner 和 outer 中都没有 → 继续沿链到全局找到
}
inner();
}
outer();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
结果展示:作用域链的形成是在函数定义时确定的(而非调用时),这就是"词法作用域"的含义。
# 4.3.2 词法作用域与动态作用域
var value = 1;
function foo() {
console.log(value);
}
function bar() {
var value = 2;
foo(); // 输出 1,不是 2!
}
bar();
2
3
4
5
6
7
8
9
10
11
12
分析:foo 在全局作用域中定义,因此其外部环境引用指向全局作用域。无论 foo 在哪里被调用,它查找 value 时都会沿着定义时的作用域链查找,找到全局的 value = 1。
如果 JavaScript 采用动态作用域(像 Bash 那样),foo 会在调用位置的作用域中查找 value,结果会是 2。但 JavaScript 是词法作用域,结果是 1。
# 4.3.3 执行上下文与变量对象
V8 引擎管理执行上下文的完整流程:
1. 全局代码开始执行
→ 创建全局执行上下文,压入调用栈
2. 遇到函数调用 outer()
→ 创建 outer 的执行上下文,压入调用栈
→ 初始化变量环境(var 声明)
→ 初始化词法环境(let/const 声明)
→ 设置 Outer Reference 指向全局词法环境
→ 执行 outer 的代码
3. outer 中遇到 inner()
→ 创建 inner 的执行上下文,压入调用栈
→ Outer Reference 指向 outer 的词法环境
→ 执行 inner 的代码
4. inner 执行完毕 → 弹出调用栈
5. outer 执行完毕 → 弹出调用栈
6. 回到全局执行上下文
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 4.4 函数参数
# 4.4.1 默认参数
为参数设置默认值。
function greet(name = "Guest") {
return `Hello, ${name}!`;
}
console.log(greet()); // Hello, Guest!
2
3
4
5
默认参数的求值时机:默认参数是在函数调用时求值的(惰性求值),不是在函数定义时:
let count = 0;
function getDefault() {
return ++count;
}
function foo(x = getDefault()) {
console.log(x);
}
foo(); // 1(第一次调用 getDefault)
foo(); // 2(第二次调用 getDefault)
foo(10); // 10(传了参数,不触发默认值求值)
2
3
4
5
6
7
8
9
10
11
12
默认参数有自己的作用域:
var x = 1;
function foo(x, y = function() { x = 2; }) {
var x = 3;
y();
console.log(x); // 3(var x 是函数体的局部变量)
}
foo();
console.log(x); // 1(全局 x 未被修改)
2
3
4
5
6
7
8
# 4.4.2 剩余参数
使用 ... 语法将多个参数收集到一个数组中。
function sum(...numbers) {
return numbers.reduce((acc, num) => acc + num, 0);
}
console.log(sum(1, 2, 3, 4)); // 10
2
3
4
5
剩余参数必须放在最后:
// 正确
function foo(first, second, ...rest) {
console.log(first); // 1
console.log(second); // 2
console.log(rest); // [3, 4, 5]
}
foo(1, 2, 3, 4, 5);
// 错误:SyntaxError
// function bar(...rest, last) {}
2
3
4
5
6
7
8
9
10
# 4.4.3 解构参数
使用解构语法从对象或数组中提取参数。
function greet({ name, age }) {
return `Hello, ${name}! You are ${age} years old.`;
}
console.log(greet({ name: "Alice", age: 25 })); // Hello, Alice! You are 25 years old.
2
3
4
5
解构参数与默认值的组合使用:
// 解构 + 默认值 + 整体默认值
function createUser({
name = "Anonymous",
age = 0,
role = "user"
} = {}) {
return { name, age, role };
}
console.log(createUser()); // { name: "Anonymous", age: 0, role: "user" }
console.log(createUser({ name: "Alice" })); // { name: "Alice", age: 0, role: "user" }
console.log(createUser({ name: "Bob", age: 25, role: "admin" }));
2
3
4
5
6
7
8
9
10
11
12
# 4.4.4 arguments对象
在非箭头函数中,arguments 是一个类数组对象,包含了函数调用时传入的所有参数:
function showArgs() {
console.log(arguments.length); // 参数个数
console.log(arguments[0]); // 第一个参数
// arguments 不是真正的数组,没有 map、filter 等方法
// 转换为数组的方式:
const arr1 = Array.from(arguments);
const arr2 = [...arguments];
const arr3 = Array.prototype.slice.call(arguments);
}
showArgs(1, 2, 3);
2
3
4
5
6
7
8
9
10
11
12
arguments 与参数的关系(非严格模式的坑):
function foo(a) {
console.log(a === arguments[0]); // true
arguments[0] = 99;
console.log(a); // 99 —— 非严格模式下,arguments 和命名参数共享引用!
}
function bar(a) {
"use strict";
arguments[0] = 99;
console.log(a); // 1 —— 严格模式下,arguments 是参数的副本
}
foo(1);
bar(1);
2
3
4
5
6
7
8
9
10
11
12
13
14
现代 JavaScript 推荐使用剩余参数代替 arguments,因为:
- 剩余参数是真正的数组
- 语义更清晰
- 箭头函数中可用
# 4.4.5 参数传递机制:值传递的本质
疑惑:JavaScript 函数参数是"值传递"还是"引用传递"?为什么修改对象参数时外部对象也会变?
答疑:JavaScript 始终是值传递。但对于对象类型,传递的"值"是对象的引用地址(指针的拷贝),而不是对象本身。
论证:
// 基本类型:传递值的副本
function changeValue(x) {
x = 100;
}
let a = 1;
changeValue(a);
console.log(a); // 1 —— 未改变
// 对象类型:传递引用的副本
function changeProp(obj) {
obj.name = "Bob"; // 通过引用修改对象属性——外部可见
}
let person = { name: "Alice" };
changeProp(person);
console.log(person.name); // "Bob" —— 改变了!
// 关键:重新赋值引用不影响外部
function replaceObj(obj) {
obj = { name: "Charlie" }; // 这只是修改了局部变量 obj 的指向
}
replaceObj(person);
console.log(person.name); // "Bob" —— 未改变!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
结果展示:如果 JavaScript 是真正的"引用传递",replaceObj 应该能改变外部 person 的指向。但事实是不能——这证明了 JavaScript 是值传递,只不过传递的值是引用地址。
# 4.5 返回值
# 4.5.1 基础用法
使用 return 语句返回函数的结果。如果没有 return,函数默认返回 undefined。
function add(a, b) {
return a + b;
}
console.log(add(2, 3)); // 5
2
3
4
5
return 的执行机制:return 语句会立即终止函数执行,函数中 return 之后的代码不会执行。但 finally 块中的 return 可以覆盖 try 块中的 return:
function foo() {
try {
return 1;
} finally {
return 2; // 会覆盖 try 中的 return
}
}
console.log(foo()); // 2
2
3
4
5
6
7
8
# 4.5.2 返回函数
JavaScript 函数可以返回另一个函数,这种特性称为 高阶函数。
function createMultiplier(factor) {
return function (number) {
return number * factor;
};
}
let double = createMultiplier(2);
console.log(double(5)); // 输出: 10
2
3
4
5
6
7
8
# 4.5.3 返回Promise
在异步编程中,函数可以返回一个 Promise 对象,用于处理异步操作的结果。
function fetchData(){
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Data fetched!");
}, 1000);
});
}
fetchData().then((data) => {
console.log(data); // 输出: Data fetched!
});
2
3
4
5
6
7
8
9
10
11
# 4.5.4 显式和隐式
function fetchData() : Promise<string> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Data fetched!");
}, 1000);
});
}
function fetchData(){
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Data fetched!");
}, 1000);
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. 第一种写法:带有显式类型声明
- 显式类型声明:函数的返回类型被明确声明为
Promise<string>。 - 类型检查:TypeScript 会检查函数是否返回了正确类型的值。如果
resolve的值不是string类型,TypeScript 会报错。 - 代码可读性:显式类型声明提高了代码的可读性和可维护性,开发者可以清楚地知道函数的返回类型。
2. 第二种写法:没有显式类型声明
- 隐式类型推断:TypeScript 会根据
resolve的值("Data fetched!")自动推断函数的返回类型为Promise<string>。 - 灵活性:如果
resolve的值类型发生变化,TypeScript 会自动更新推断的类型。 - 代码简洁:省略了类型声明,代码更简洁。
主要区别
| 特性 | 第一种写法(显式类型声明) | 第二种写法(隐式类型推断) |
|---|---|---|
| 类型声明 | 显式声明返回类型为 Promise<string> | 无显式声明,TypeScript 自动推断 |
| 类型检查 | 严格检查返回值是否符合声明类型 | 根据 resolve 的值自动推断类型 |
| 代码可读性 | 更清晰,开发者明确知道返回类型 | 依赖 TypeScript 推断,可能不够直观 |
| 灵活性 | 如果返回值类型变化,需要手动更新类型声明 | 自动适应返回值类型的变化 |
| 适用场景 | 适合需要明确类型声明的场景 | 适合代码简洁、类型简单的场景 |
实际开发中的建议
- 推荐使用显式类型声明:在团队协作或复杂项目中,显式类型声明可以提高代码的可读性和可维护性,减少潜在的错误。
- 隐式类型推断适合简单场景:如果函数逻辑简单,且类型明确,可以使用隐式类型推断,减少代码冗余。
# 4.6 匿名函数
通过匿名函数表达式来定义一个函数。其语法形式为在 function 后跟可以省略的函数名、参数列表以及函数体。
# 4.6.1 匿名函数语法
匿名函数没有名称,直接通过 function 关键字定义。
const greet = function() {
console.log("Hello, World!");
};
greet(); // 输出: Hello, World!
2
3
4
5
函数声明语句和匿名函数表达式在语法上几乎一模一样,唯一的区别仅仅是能否省略函数名称而已。
# 4.6.2 作为回调函数
匿名函数常用于作为回调函数,传递给其他函数。
setTimeout(function() {
console.log("This runs after 1 second.");
}, 1000);
2
3
# 4.6.3 立即执行函数表达式
匿名函数可以立即执行,称为立即执行函数表达式(IIFE)。
(function() {
console.log("This runs immediately.");
})();
2
3
IIFE 通常用于创建一个独立的作用域,避免变量污染全局命名空间。
# 4.6.4 赋值给变量
匿名函数可以赋值给变量,作为变量值使用。
const sayHello = function(name) {
console.log(`Hello, ${name}!`);
};
sayHello("Alice"); // 输出: Hello, Alice!
2
3
4
5
# 4.6.5 作为对象方法
如果一个函数作为一个对象的属性保存,那么我们称这个函数是这个对象的方法,调用这个函数就说调用对象的方法(method)。
注意:方法和函数只是名称上的区别,没有其它别的区别
const person = {
name: "Alice",
greet: function() {
console.log(`Hello, my name is ${this.name}.`);
}
};
person.greet(); // 输出: Hello, my name is Alice.
2
3
4
5
6
7
8
JavaScript 的函数是一种对象,不过并不是说所有的对象都是函数。函数是一种包含了可执行代码,并能够被其他代码调用的特殊的对象。
# 4.6.6 动态生成函数
匿名函数可以动态生成,根据条件返回不同的函数。
const getGreeting = function(isFormal) {
return isFormal
? function() { console.log("Good day to you!"); }
: function() { console.log("Hey!"); };
};
const greeting = getGreeting(true);
greeting(); // 输出: Good day to you!
2
3
4
5
6
7
8
# 4.6.7 匿名函数表达式
如果需要在调试时追踪函数,可以使用命名函数表达式(Named Function Expression),它是匿名函数的一种变体。
const greet = function sayHello() {
console.log("Hello, World!");
};
greet(); // 输出: Hello, World!
console.log(greet.name); // 输出: sayHello
2
3
4
5
6
匿名函数表达式在一些程序设计语言中并不存在(至少 Java 中的方法是无法实现这样的功能的)。
命名函数表达式的特殊性:函数名 sayHello 只在函数内部可见,外部无法访问:
const greet = function sayHello() {
console.log(typeof sayHello); // "function"(内部可见)
// 可以用于递归:sayHello()
};
console.log(typeof sayHello); // "undefined"(外部不可见)
2
3
4
5
6
这个特性使得命名函数表达式非常适合用于递归,即使外部变量被重新赋值也不会影响递归调用。
# 4.7 闭包
闭包(Closure) 是 JavaScript 中一个非常重要的概念,它允许函数访问其词法作用域(Lexical Scope)中的变量,即使函数在其词法作用域之外执行。
闭包是 JavaScript 强大功能的体现,广泛应用于模块化、数据封装、回调函数等场景。
# 4.7.1 闭包定义
闭包是指一个函数能够记住并访问其词法作用域,即使该函数在其词法作用域之外执行。换句话说,闭包是函数和其词法环境的组合。
闭包的底层原理:在 V8 引擎中,每个函数在创建时都会携带一个 [[Environment]] 内部属性,指向创建时所在的词法环境(Lexical Environment)。词法环境包含一个环境记录(Environment Record)(存储局部变量)和一个外部环境引用(Outer Reference)(指向父作用域)。当内部函数引用了外部函数的变量时,即使外部函数已经执行完毕,其词法环境不会被垃圾回收——因为内部函数的 [[Environment]] 仍然引用着它。V8 的优化策略是:只有被内部函数实际引用的变量才会被保留在闭包中(称为闭包变量),未被引用的变量仍然可以被 GC 回收。
# 4.7.2 闭包的形成
闭包的形成需要满足以下条件:
- 函数嵌套:一个函数内部定义了另一个函数。
- 内部函数引用外部函数的变量。
- 内部函数在其词法作用域之外执行。
function outer() {
const outerVar = 'I am from outer function';
function inner() {
console.log(outerVar); // 访问外部函数的变量
}
return inner;
}
const closureFunc = outer(); // 返回 inner 函数
closureFunc(); // 输出: I am from outer function
2
3
4
5
6
7
8
9
10
11
12
在上面的例子中:
inner函数引用了outer函数的变量outerVar。- 即使
outer函数已经执行完毕,inner函数仍然可以访问outerVar,这就是闭包。
# 4.7.3 闭包作用
1.数据封装
闭包可以创建私有变量,避免全局污染。示例:
function createCounter() {
let count = 0; // 私有变量
return {
increment: function () {
count++;
console.log(count);
},
decrement: function () {
count--;
console.log(count);
},
};
}
const counter = createCounter();
counter.increment(); // 输出: 1
counter.increment(); // 输出: 2
counter.decrement(); // 输出: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2.回调函数
闭包常用于回调函数中,保留上下文信息。示例:
function delayGreeting(name) {
setTimeout(function () {
console.log(`Hello, ${name}!`);
}, 1000);
}
delayGreeting('Alice'); // 1秒后输出: Hello, Alice!
2
3
4
5
6
7
3.函数柯里化(Currying)
闭包可以用于实现函数柯里化,即将多参数函数转换为单参数函数。示例:
function add(a) {
return function (b) {
return a + b;
};
}
const add5 = add(5);
console.log(add5(3)); // 输出: 8
2
3
4
5
6
7
8
4.函数记忆化(Memoization)
利用闭包缓存计算结果,避免重复计算:
function memoize(fn) {
const cache = {}; // 闭包变量,在函数外部不可访问
return function(...args) {
const key = JSON.stringify(args);
if (cache[key] !== undefined) {
console.log('从缓存中获取');
return cache[key];
}
console.log('计算中...');
const result = fn.apply(this, args);
cache[key] = result;
return result;
};
}
const expensiveCalc = memoize((n) => {
// 模拟耗时计算
let sum = 0;
for (let i = 0; i < n; i++) sum += i;
return sum;
});
expensiveCalc(10000); // "计算中..." → 49995000
expensiveCalc(10000); // "从缓存中获取" → 49995000
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 4.7.4 闭包注意事项
闭包会保留对其词法作用域的引用,可能导致内存泄漏。如果不再需要闭包,应手动释放引用。
function createHeavyClosure() {
const largeArray = new Array(1000000).fill('data');
return function () {
console.log('Closure is still holding the largeArray');
};
}
let heavyClosure = createHeavyClosure();
// 不再需要时,手动释放
heavyClosure = null;
2
3
4
5
6
7
8
9
10
# 4.7.5 经典面试题
以下代码会输出什么?如何修复?
for (var i = 1; i <= 3; i++) {
setTimeout(function () {
console.log(i);
}, 1000);
}
2
3
4
5
输出:
4
4
4
2
3
原因:var 是函数作用域,循环结束后 i 的值为 4,所有 setTimeout 回调都引用同一个 i。
修复:使用 let(块级作用域)或闭包。
方法 1:使用 let
for (let i = 1; i <= 3; i++) {
setTimeout(function () {
console.log(i);
}, 1000);
}
2
3
4
5
方法 2:使用闭包
for (var i = 1; i <= 3; i++) {
(function (i) {
setTimeout(function () {
console.log(i);
}, 1000);
})(i);
}
2
3
4
5
6
7
方法 3:使用 setTimeout 的第三个参数
for (var i = 1; i <= 3; i++) {
setTimeout(function (j) {
console.log(j);
}, 1000, i); // i 作为参数传给回调
}
2
3
4
5
# 4.7.6 闭包与V8垃圾回收
疑惑:闭包到底会不会导致内存泄漏?V8 引擎是如何处理闭包中的变量的?
答疑:V8 引擎对闭包有精细的优化。并非外部函数的所有变量都会被保留在闭包中——V8 在编译时会分析内部函数实际引用了哪些外部变量,只有被引用的变量才会被存储在**闭包对象(Closure Object)**中。
论证:
function outer() {
const used = "我被引用了";
const unused = new Array(1000000).fill("大数组");
return function inner() {
console.log(used); // 只引用了 used
};
}
const fn = outer();
// unused 虽然在 outer 的作用域中,但 inner 没有引用它
// V8 会在 outer 执行完毕后回收 unused 占用的内存
2
3
4
5
6
7
8
9
10
11
12
结果展示:可以通过 Chrome DevTools 的 Memory 面板验证——使用 Heap Snapshot 对比闭包中实际保留的变量。但要注意:如果使用了 eval() 或 with,V8 无法进行静态分析,会保留外部函数的所有变量。
# 4.8 立即执行函数
立即执行函数(IIFE),定义后立即执行的函数。
(function() {
console.log("IIFE executed!");
})();
2
3
# 4.8.1 IIFE的设计由来
疑惑:为什么需要 IIFE?直接写代码不行吗?
答疑:在 ES6 之前,JavaScript 只有函数作用域和全局作用域,没有块级作用域。IIFE 是在这个限制下创造出的"立即创建独立作用域"的技巧。
论证:IIFE 的多种写法和应用场景:
// 写法一:外层括号包裹(推荐)
(function() { /* ... */ })();
// 写法二:内层括号
(function() { /* ... */ }());
// 写法三:使用运算符(让引擎将 function 识别为表达式)
!function() { /* ... */ }();
+function() { /* ... */ }();
void function() { /* ... */ }();
// 经典应用:模块模式(ES6 之前的模块化方案)
var Module = (function() {
var privateVar = 0;
function privateMethod() {
return ++privateVar;
}
return {
publicMethod: function() {
return privateMethod();
},
getCount: function() {
return privateVar;
}
};
})();
Module.publicMethod(); // 1
Module.publicMethod(); // 2
Module.getCount(); // 2
// Module.privateVar → undefined(外部不可访问)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
结果展示:jQuery、Lodash 等经典库在 ES6 之前都大量使用 IIFE 进行模块封装。ES6 的模块系统(import/export)和块级作用域(let/const)出现后,IIFE 的使用频率大大降低,但在需要立即执行且创建独立作用域的场景中仍然有用。
# 4.9 回调函数
作为参数传递给其他函数的函数。
function fetchData(callback) {
setTimeout(() => {
callback("Data received!");
}, 1000);
}
fetchData((data) => {
console.log(data); // Data received!
});
2
3
4
5
6
7
8
9
# 4.9.1 回调地狱与解决方案
疑惑:回调函数看起来很简单,为什么人们常说"回调地狱"?
答疑:当多个异步操作需要顺序执行时,回调嵌套会越来越深,形成"金字塔"结构,难以阅读和维护。
论证:
// 回调地狱示例
getUser(userId, function(user) {
getOrders(user.id, function(orders) {
getOrderDetails(orders[0].id, function(details) {
getShippingInfo(details.shippingId, function(shipping) {
console.log(shipping);
// 如果还需要更多异步操作...
}, function(err) { console.error(err); });
}, function(err) { console.error(err); });
}, function(err) { console.error(err); });
}, function(err) { console.error(err); });
2
3
4
5
6
7
8
9
10
11
技术演变过程:
// 阶段1:回调函数(ES3)
fs.readFile('a.txt', function(err, data) { /* ... */ });
// 阶段2:Promise(ES6/2015)
readFile('a.txt')
.then(data => process(data))
.then(result => writeFile('b.txt', result))
.catch(err => console.error(err));
// 阶段3:async/await(ES8/2017)
async function main() {
try {
const data = await readFile('a.txt');
const result = await process(data);
await writeFile('b.txt', result);
} catch (err) {
console.error(err);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
结果展示:从回调到 Promise 到 async/await,每一步都在解决前一代方案的可读性和可维护性问题。现代 JavaScript 推荐使用 async/await,它让异步代码看起来像同步代码。
# 4.10 异步函数
# 4.10.1 普通异步函数
使用 async 和 await 简化异步代码。
async function fetchData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve("Data received!");
}, 1000);
});
}
async function main() {
const data = await fetchData();
console.log(data); // Data received!
}
main();
2
3
4
5
6
7
8
9
10
11
12
13
14
# 4.10.2 导出异步函数
使用了 export const 来导出一个常量函数 handleExceptionError。这个函数接收一个参数 errorType,其类型为 ERROR_TYPE。
export const handleExceptionError = async (errorType: ERROR_TYPE) => {
// 函数体
};
2
3
export:将handleExceptionError函数导出,以便其他模块可以导入并使用它。const:声明一个常量函数,确保函数不能被重新赋值。async:表示这是一个异步函数,函数内部可以使用await关键字。errorType: ERROR_TYPE:参数errorType的类型为ERROR_TYPE(假设ERROR_TYPE是一个自定义类型或枚举)。
在实际应用中,handleExceptionError 可能会在 try-catch 块中调用,用于捕获和处理异常。
async function fetchData() {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
} catch (error) {
await handleExceptionError(ERROR_TYPE.NETWORK_ERROR);
}
}
2
3
4
5
6
7
8
# 4.10.3 async函数的底层原理
疑惑:async/await 是怎么实现"暂停"和"恢复"函数执行的?JavaScript 不是单线程吗?
答疑:async/await 本质上是 Generator + Promise 的语法糖。await 并不会真正阻塞线程,而是将 await 之后的代码注册为 Promise 的回调(微任务),然后让出执行权给事件循环。
论证:
// async/await 写法
async function foo() {
const a = await fetchA();
const b = await fetchB(a);
return a + b;
}
// 等价的 Generator + Promise 写法
function foo() {
return spawn(function* () {
const a = yield fetchA();
const b = yield fetchB(a);
return a + b;
});
}
// spawn 是一个自动执行 Generator 的函数
function spawn(genFunc) {
return new Promise((resolve, reject) => {
const gen = genFunc();
function step(nextF) {
let next;
try {
next = nextF();
} catch (e) {
return reject(e);
}
if (next.done) {
return resolve(next.value);
}
Promise.resolve(next.value).then(
(v) => step(() => gen.next(v)),
(e) => step(() => gen.throw(e))
);
}
step(() => gen.next(undefined));
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
结果展示:当执行到 await 时,引擎会:
- 将
await后面的表达式包装为 Promise(如果不是的话) - 将
await之后的代码注册为该 Promise 的.then()回调 - 让出执行权,返回到调用者(事件循环继续执行其他任务)
- 当 Promise 决议后,微任务队列中的回调被执行,函数从
await处恢复
# 4.11 函数绑定
在 JavaScript 中,函数绑定(Function Binding)是指将函数的 this 值显式地绑定到某个对象。
JavaScript 中的 this 值在函数调用时动态确定,因此通过绑定可以确保函数在特定上下文中执行。
call和apply:立即调用函数,并显式指定this值。bind:返回一个绑定this的新函数,适合需要延迟调用的场景。- 箭头函数:无法绑定
this,它会捕获外层作用域的this值。
# 4.11.1 call绑定
call 方法允许你调用一个函数,并显式指定 this 的值,同时可以传递参数列表。
function.call(thisArg, arg1, arg2, ...);
示例
const person = {
name: "Alice",
greet: function (message) {
console.log(`${message}, ${this.name}!`);
},
};
const anotherPerson = { name: "Bob" };
// 使用 call 绑定 this 到 anotherPerson
person.greet.call(anotherPerson, "Hello"); // 输出: Hello, Bob!
2
3
4
5
6
7
8
9
10
11
# 4.11.2 apply绑定
apply 方法与 call 类似,但参数以数组形式传递。
function.apply(thisArg, [arg1, arg2, ...]);
示例
const person = {
name: "Alice",
greet: function (message) {
console.log(`${message}, ${this.name}!`);
},
};
const anotherPerson = { name: "Bob" };
// 使用 apply 绑定 this 到 anotherPerson
person.greet.apply(anotherPerson, ["Hi"]); // 输出: Hi, Bob!
2
3
4
5
6
7
8
9
10
11
call 与 apply 的经典用法:
// 借用 Array.prototype 方法处理类数组
function listArgs() {
// arguments 是类数组,没有 join 方法
const str = Array.prototype.join.call(arguments, '-');
console.log(str);
}
listArgs('a', 'b', 'c'); // "a-b-c"
// 找数组最大值(ES6 之前)
const max = Math.max.apply(null, [1, 5, 3, 9, 2]); // 9
// ES6 之后可以用展开运算符
const max2 = Math.max(...[1, 5, 3, 9, 2]); // 9
2
3
4
5
6
7
8
9
10
11
12
# 4.11.3 bind绑定
bind 方法创建一个新的函数,并将 this 值永久绑定到指定的对象。与 call 和 apply 不同,bind 不会立即调用函数,而是返回一个绑定后的函数。
const boundFunction = function.bind(thisArg, arg1, arg2, ...);
示例
const person = {
name: "Alice",
greet: function (message) {
console.log(`${message}, ${this.name}!`);
},
};
const anotherPerson = { name: "Bob" };
// 使用 bind 绑定 this 到 anotherPerson
const boundGreet = person.greet.bind(anotherPerson);
boundGreet("Hey"); // 输出: Hey, Bob!
2
3
4
5
6
7
8
9
10
11
12
bind 的偏函数应用(Partial Application):
function multiply(a, b) {
return a * b;
}
// bind 可以预设部分参数
const double = multiply.bind(null, 2); // a 固定为 2
const triple = multiply.bind(null, 3); // a 固定为 3
console.log(double(5)); // 10
console.log(triple(5)); // 15
2
3
4
5
6
7
8
9
10
bind 的不可重新绑定特性:
function foo() {
console.log(this.name);
}
const bound1 = foo.bind({ name: "Alice" });
const bound2 = bound1.bind({ name: "Bob" }); // 再次 bind 无效!
bound1(); // "Alice"
bound2(); // "Alice"(仍然是 Alice,不是 Bob)
2
3
4
5
6
7
8
9
# 4.11.4 手写实现call/apply/bind
理解 call/apply/bind 的底层原理,最好的方式是手写实现:
// 手写 call
Function.prototype.myCall = function(context, ...args) {
context = context == null ? globalThis : Object(context);
const key = Symbol('temp'); // 用 Symbol 避免属性冲突
context[key] = this; // 将函数作为 context 的方法
const result = context[key](...args); // 调用
delete context[key]; // 清理
return result;
};
// 手写 apply
Function.prototype.myApply = function(context, args = []) {
context = context == null ? globalThis : Object(context);
const key = Symbol('temp');
context[key] = this;
const result = context[key](...args);
delete context[key];
return result;
};
// 手写 bind
Function.prototype.myBind = function(context, ...outerArgs) {
const fn = this;
return function boundFunction(...innerArgs) {
// 支持 new 调用:new 时 this 指向新创建的对象
if (new.target) {
return new fn(...outerArgs, ...innerArgs);
}
return fn.call(context, ...outerArgs, ...innerArgs);
};
};
// 验证
function greet(greeting, punctuation) {
console.log(`${greeting}, ${this.name}${punctuation}`);
}
greet.myCall({ name: "Alice" }, "Hello", "!"); // Hello, Alice!
greet.myApply({ name: "Bob" }, ["Hi", "?"]); // Hi, Bob?
const bound = greet.myBind({ name: "Charlie" }, "Hey");
bound("."); // Hey, Charlie.
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 4.12 this对象
解析器在调用函数每次都会向函数内部传递进一个隐含的参数,这个隐含的参数就是this,this指向的是一个对象,这个对象我们称为函数执行的上下文对象,根据函数的调用方式的不同,this会指向不同的对象
以函数的形式调用时,this永远都是window
以方法的形式调用时,this就是调用方法的那个对象
this 绑定的底层原理:JavaScript 中的 this 不是在函数定义时确定的,而是在函数调用时根据调用方式动态绑定的。引擎内部通过调用记录(Call Record) 中的 thisValue 字段来传递 this。绑定规则优先级从高到低为:①new 绑定(this 指向新创建的对象);②显式绑定(call/apply/bind 指定的对象);③隐式绑定(作为对象方法调用时,this 指向该对象);④默认绑定(独立函数调用,非严格模式下为 window,严格模式下为 undefined)。箭头函数没有自己的 this,它会捕获定义时外层作用域的 this 值(词法 this)。
# 4.12.1 this绑定规则优先级
通过代码验证四种绑定规则的优先级:
// 规则1:默认绑定
function showThis() {
console.log(this);
}
showThis(); // 非严格模式:window;严格模式:undefined
// 规则2:隐式绑定
const obj = {
name: "Alice",
showThis: function() {
console.log(this.name);
}
};
obj.showThis(); // "Alice"
// 隐式绑定丢失
const fn = obj.showThis;
fn(); // undefined(默认绑定生效)
// 规则3:显式绑定 > 隐式绑定
const obj2 = { name: "Bob" };
obj.showThis.call(obj2); // "Bob"(显式绑定覆盖隐式绑定)
// 规则4:new 绑定 > 显式绑定
function Foo(name) {
this.name = name;
}
const bound = Foo.bind({ name: "Alice" });
const instance = new bound("Bob");
console.log(instance.name); // "Bob"(new 绑定覆盖了 bind)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//创建一个全局变量name
var name = "全局变量name";
//创建一个函数
function fun() {
console.log(this.name);
}
//创建一个对象
var obj = {
name: "孙悟空",
sayName: fun
};
//我们希望调用obj.sayName()时可以输出obj的名字而不是全局变量name的名字
obj.sayName(); //打印结果:孙悟空
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
箭头函数的 this —— 一劳永逸的解决方案:
class Timer {
constructor() {
this.count = 0;
}
// 问题:普通函数作为回调时 this 丢失
startBroken() {
setInterval(function() {
this.count++; // this 指向 window,不是 Timer 实例
console.log(this.count); // NaN
}, 1000);
}
// 解决方案1:箭头函数
startArrow() {
setInterval(() => {
this.count++; // this 指向 Timer 实例
console.log(this.count);
}, 1000);
}
// 解决方案2:bind
startBind() {
setInterval(function() {
this.count++;
console.log(this.count);
}.bind(this), 1000);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29