目录
JavaScript 2. 函数
函数是 JavaScript 中组织行为和建立抽象的核心机制。函数可以被调用,也可以像其他值一样存入变量、作为参数传递、作为返回值返回或保存在对象属性中。
从语言类型看,函数属于对象。并不是所有对象都可以调用,也不是所有函数对象都可以通过 new 构造实例。ECMAScript 使用 [[Call]]、[[Construct]] 等内部方法描述这些能力。
理解函数需要同时掌握以下方面:
- 函数在何时创建,以及名称属于哪个作用域;
- 参数、实参和返回值如何传递;
- 函数如何根据词法作用域解析外部变量;
- 普通函数的
this如何由调用方式决定; - 箭头函数为何没有自己的
this; - 闭包如何保存状态;
- 回调、高阶函数和定时器如何组织控制流。
2.1 函数是一等值
JavaScript 中的函数可以赋给变量:
function greet(name) {
return `Hello, ${name}!`;
}
const action = greet;
console.log(action("Alice")); // Hello, Alice!
也可以作为参数传入其他函数:
function execute(action, value) {
return action(value);
}
console.log(execute(greet, "Bob")); // Hello, Bob!
还可以作为返回值:
function createMultiplier(factor) {
return function multiply(value) {
return value * factor;
};
}
const double = createMultiplier(2);
console.log(double(5)); // 10
能够接收函数作为参数或返回函数的函数称为高阶函数。函数作为值的能力,是回调、闭包、装饰器、函数式数据处理和异步编程的共同基础。
2.2 函数的定义方式
2.2.1 函数声明
函数声明使用 function 关键字、函数名、形参列表和函数体:
function greet(name, title) {
return `Hello, ${title} ${name}!`;
}
其中:
greet是函数名;name和title是形参;- 调用时传入的具体值是实参;
return将结果返回给调用位置。
函数声明在相应作用域的声明实例化阶段就会创建并初始化,因此可以在源码中的声明位置之前调用:
console.log(greet("Alice"));
function greet(name) {
return `Hello, ${name}!`;
}
所谓函数声明“提升”,描述的是绑定在执行语句前已经可用的效果,并不要求引擎实际把源代码移动到顶部。
块中的函数声明
现代 ECMAScript 对严格模式和模块中的块级函数声明具有明确的词法作用域语义:
"use strict";
if (true) {
function insideBlock() {
return "inside";
}
console.log(insideBlock()); // inside
}
// ReferenceError
// console.log(insideBlock());
传统浏览器脚本的非严格模式还可能受 Web 兼容规则影响。为了避免环境差异,不应依赖块级函数声明在块外是否可见。
需要按条件创建函数时,可以使用块级变量和函数表达式:
let action;
if (condition) {
action = function runPrimaryAction() {
return "primary";
};
} else {
action = function runFallbackAction() {
return "fallback";
};
}
2.2.2 函数表达式
函数表达式把函数作为表达式的一部分创建:
const greet = function (name) {
return `Hello, ${name}!`;
};
变量绑定仍然遵循其声明方式的规则。使用 const 时,在初始化语句之前访问该变量会触发暂时性死区:
// ReferenceError
// greet("Alice");
const greet = function (name) {
return `Hello, ${name}!`;
};
使用 var 时,变量会先初始化为 undefined,但函数对象尚未赋值:
// TypeError: greet is not a function
// greet("Alice");
var greet = function (name) {
return `Hello, ${name}!`;
};
这里的错误不是函数表达式本身“不能提升”,而是变量绑定和右侧函数对象的求值发生在不同阶段。
2.2.3 具名函数表达式
函数表达式可以具有内部名称:
const factorial = function calculate(n) {
if (n <= 1) {
return 1;
}
return n * calculate(n - 1);
};
calculate 只在函数自身内部可见:
console.log(factorial(5)); // 120
// ReferenceError
// console.log(calculate(5));
具名函数表达式具有两个主要价值:
- 函数可以使用稳定的内部名称递归调用自身;
- 调试器和错误堆栈可以显示更明确的函数名称。
即使外部变量被重新赋值,内部名称仍然指向当前函数:
let action = function repeat(count) {
if (count <= 0) {
return;
}
console.log(count);
repeat(count - 1);
};
const savedAction = action;
action = null;
savedAction(3);
输出:
3
2
1
不应把内部名称描述为某个固定物理指针。规范使用专门的词法环境和不可变绑定定义其可见范围与行为。
2.2.4 箭头函数
箭头函数提供更简洁的函数表达式语法:
const add = (first, second) => {
return first + second;
};
如果函数体只有一个表达式,可以省略花括号和 return:
const add = (first, second) => first + second;
只有一个简单形参时,可以省略形参圆括号:
const square = value => value ** 2;
没有形参或具有多个形参时必须保留圆括号:
const getMessage = () => "Hello";
const add = (a, b) => a + b;
隐式返回对象
对象字面量必须用圆括号包裹,否则花括号会被解析为函数体:
const createUser = () => ({
name: "Alice",
score: 95,
});
console.log(createUser());
下面的函数不会返回对象:
const createUser = () => {
name: "Alice";
};
console.log(createUser()); // undefined
其中 name: 被解析为标签语句。
2.2.5 箭头函数的词法绑定
箭头函数没有自己的 this、arguments、super 和 new.target 绑定。对这些名称的访问会按照词法作用域继续向外查找。
const user = {
name: "Alice",
createGreeting() {
const greet = () => {
return `Hello, ${this.name}!`;
};
return greet();
},
};
console.log(user.createGreeting());
// Hello, Alice!
这里箭头函数中的 this 来自外层 createGreeting() 方法调用建立的 this。
call()、apply() 和 bind() 不能改变箭头函数的词法 this:
const arrow = () => this;
const object = {
value: 10,
};
console.log(arrow.call(object) === object); // false
具体结果取决于箭头函数创建位置的外层 this,而不是 call() 提供的对象。
“箭头函数没有 super”并不表示箭头函数内部永远不能写 super。如果箭头函数定义在允许使用 super 的方法环境中,它可以词法捕获外层方法的 super 语义:
class Parent {
greet() {
return "Hello";
}
}
class Child extends Parent {
greetLater() {
const action = () => super.greet();
return action();
}
}
console.log(new Child().greetLater()); // Hello
类和继承将在第六章系统讨论。
2.2.6 箭头函数不能作为构造函数
箭头函数没有 [[Construct]] 内部方法,不能通过 new 调用:
const User = (name) => ({
name,
});
// TypeError
// const user = new User("Alice");
箭头函数通常也没有自己的 prototype 属性:
const action = () => {};
console.log(
Object.hasOwn(action, "prototype"),
); // false
需要注意,“普通函数”也不等于“一定可构造”。对象方法简写、生成器函数和异步函数等也不具有普通构造函数能力。是否能使用 new,取决于具体函数对象是否具有 [[Construct]]。
2.2.7 选择定义方式
常见选择原则如下:
- 需要声明式定义并允许在声明位置前调用时,使用函数声明;
- 需要把函数作为值保存、按条件创建或传递时,使用函数表达式;
- 需要继承外层
this,或编写简短回调时,可以使用箭头函数; - 对象原型方法和需要动态
this的方法通常使用普通方法语法; - 需要构造实例时,不使用箭头函数。
箭头函数不是普通函数的全面替代品。应根据调用语义选择,而不是只根据代码长度选择。
2.3 参数与实参
JavaScript 不要求实参数量与形参数量完全一致:
function describe(name, score) {
console.log(name, score);
}
describe("Alice"); // Alice undefined
describe("Alice", 95, true); // Alice 95
缺少的实参对应 undefined。多余实参不会自动绑定到具名形参,但普通函数可以通过剩余参数或 arguments 访问它们。
2.3.1 JavaScript 始终按值传递
JavaScript 的参数传递始终是按值传递。函数调用时,实参求值结果会被赋给新的形参绑定。
原始值示例:
function update(value) {
value = 20;
}
let number = 10;
update(number);
console.log(number); // 10
对象示例:
function updateUser(user) {
user.score = 100;
}
const student = {
score: 90,
};
updateUser(student);
console.log(student.score); // 100
传入对象时,被复制的值能够引用同一个对象。因此,函数通过该引用修改对象属性时,调用方可以观察到变化。
但重新给形参赋值不会改变外部变量的绑定:
function replaceUser(user) {
user = {
name: "Bob",
};
}
const student = {
name: "Alice",
};
replaceUser(student);
console.log(student.name); // Alice
因此,“对象按引用传递”容易产生歧义。更准确的表述是:JavaScript 按值传递,而对象值具有引用语义。
2.3.2 默认参数
默认参数只在对应实参为 undefined 或缺失时求值:
function greet(name = "Guest") {
return `Hello, ${name}!`;
}
console.log(greet()); // Hello, Guest!
console.log(greet(undefined)); // Hello, Guest!
console.log(greet(null)); // Hello, null!
默认参数表达式在每次调用时按需执行:
let count = 0;
function createDefaultName() {
count += 1;
return `Guest ${count}`;
}
function greet(name = createDefaultName()) {
return `Hello, ${name}!`;
}
console.log(greet()); // Hello, Guest 1!
console.log(greet("Alice")); // Hello, Alice!
console.log(greet()); // Hello, Guest 2!
后面的默认参数可以引用前面的参数:
function createRange(start, end = start + 10) {
return [start, end];
}
console.log(createRange(5)); // [5, 15]
前面的参数不能引用尚未初始化的后续参数:
function invalid(first = second, second = 10) {
return [first, second];
}
// ReferenceError
// invalid();
默认参数具有自己的参数初始化环境。函数体内部用 var 声明的变量不能反向供默认参数初始化器访问:
const value = "outer";
function example(input = value) {
var value = "inner";
return input;
}
console.log(example()); // outer
2.3.3 剩余参数
剩余参数把未匹配的实参收集为真正的数组:
function sum(...values) {
return values.reduce(
(total, value) => total + value,
0,
);
}
console.log(sum(1, 2, 3)); // 6
剩余参数必须位于形参列表最后:
function log(prefix, ...values) {
console.log(prefix, values);
}
以下语法非法:
// SyntaxError
// function invalid(...values, last) {}
剩余参数只收集尚未匹配给前面形参的实参:
function inspect(first, ...rest) {
console.log(first);
console.log(rest);
}
inspect(1, 2, 3);
// 1
// [2, 3]
2.3.4 arguments
非箭头函数具有类数组 arguments 对象:
function inspect() {
console.log(arguments[0]);
console.log(arguments.length);
}
inspect("Alice", 95);
arguments 不是普通数组,不能直接使用所有数组方法:
function inspect() {
const values = Array.from(arguments);
return values.map(value => String(value));
}
现代代码通常优先使用剩余参数,因为它:
- 明确出现在函数签名中;
- 是真正的数组;
- 适用于箭头函数;
- 不涉及非严格模式下简单参数与
arguments之间的历史映射行为。
在非严格模式且使用简单参数列表时,具名参数与 arguments 的某些索引可能互相反映修改:
function example(value) {
value = 20;
console.log(arguments[0]);
}
example(10); // 非严格传统脚本中可能输出 20
严格模式、默认参数、剩余参数或解构参数会使这一历史关联不再成立。不要依赖这种行为。
2.3.5 箭头函数与 arguments
箭头函数不会创建自己的 arguments:
function outer() {
const arrow = () => {
return [...arguments];
};
return arrow();
}
console.log(outer(1, 2, 3)); // [1, 2, 3]
箭头函数访问到的是外层普通函数的 arguments。
需要获取箭头函数自己的全部实参,应使用剩余参数:
const collect = (...values) => values;
console.log(collect(1, 2, 3)); // [1, 2, 3]
在模块顶层通常不存在 arguments 绑定。不同 CommonJS 等宿主包装环境还可能提供自己的顶层 arguments,因此不应依赖箭头函数在顶层访问它。
2.3.6 参数解构
对象解构参数适合接收选项对象:
function createUser({
name = "Guest",
score = 0,
} = {}) {
return {
name,
score,
};
}
console.log(createUser());
console.log(
createUser({
name: "Alice",
score: 95,
}),
);
给整个参数设置默认值 {},可以避免未传实参时对 undefined 解构导致错误。
数组解构参数按照迭代顺序取值:
function addPair([first, second]) {
return first + second;
}
console.log(addPair([10, 20])); // 30
复杂嵌套解构虽然能够缩短代码,但会增加调用契约的阅读成本。公共 API 应优先保持参数结构清晰,并对输入进行必要验证。
2.3.7 调用时的展开语法
展开语法可以把可迭代对象的元素展开为独立实参:
function add(first, second, third) {
return first + second + third;
}
const values = [1, 2, 3];
console.log(add(...values)); // 6
展开语法与剩余参数都使用 ...,但方向相反:
- 形参中的剩余参数负责收集;
- 调用表达式中的展开语法负责展开。
2.4 返回值与函数控制流
2.4.1 return
return 会结束当前函数调用,并向调用位置提供一个值:
function add(first, second) {
return first + second;
}
const result = add(1, 2);
console.log(result); // 3
没有显式 return,或者执行 return;,都会返回 undefined:
function logMessage(message) {
console.log(message);
}
console.log(logMessage("Hello")); // undefined
return 只终止当前函数,不会直接终止调用它的外层函数:
function inner() {
return "inner result";
}
function outer() {
inner();
return "outer result";
}
console.log(outer()); // outer result
2.4.2 return 与 ASI
return 后不能直接换行再写返回表达式:
function invalid() {
return
{
value: 10,
};
}
console.log(invalid()); // undefined
正确写法:
function valid() {
return {
value: 10,
};
}
或者:
function valid() {
return (
{
value: 10,
}
);
}
2.4.3 提前返回
提前返回可以减少嵌套:
function calculateDiscount(user) {
if (user === null) {
return 0;
}
if (!user.active) {
return 0;
}
if (user.level === "premium") {
return 0.2;
}
return 0.05;
}
与深层嵌套相比,守卫子句通常更容易阅读。但函数存在资源清理要求时,还需结合 try...finally 分析控制流。错误处理将在第八章讨论。
2.5 词法作用域
JavaScript 使用词法作用域。一个标识符能够访问哪些外部绑定,主要由函数在源代码中的定义位置决定,而不是由函数从哪里被调用决定。
const language = "JavaScript";
function printLanguage() {
console.log(language);
}
function run() {
const language = "Python";
printLanguage();
}
run(); // JavaScript
printLanguage() 定义在全局词法环境中,因此它访问全局 language,不会因为在 run() 中调用而改为读取 run() 的局部变量。
2.5.1 作用域链
当代码读取一个标识符时,运行时会从当前环境记录开始查找。如果当前环境没有对应绑定,则沿外部环境引用继续查找,直到找到绑定或到达作用域链末端。
const globalValue = "global";
function outer() {
const outerValue = "outer";
function inner() {
const innerValue = "inner";
console.log(innerValue);
console.log(outerValue);
console.log(globalValue);
}
inner();
}
outer();
这里 inner() 可以访问三个层级中的绑定。
“作用域链”是对词法环境关系的通俗描述。规范中的环境记录和外部环境引用是用于定义标识符解析的抽象结构,不要求引擎在物理内存中实现为某种固定单向链表。
2.5.2 遮蔽
内层作用域可以声明与外层相同名称的绑定:
const value = "outer";
function example() {
const value = "inner";
console.log(value);
}
example(); // inner
console.log(value); // outer
内层绑定会遮蔽外层绑定。遮蔽本身是合法的,但同名过多会增加阅读负担。
2.5.3 函数作用域与块级作用域
var 和函数声明传统上主要与函数作用域相关;let、const 和类声明具有块级作用域:
function example() {
if (true) {
var functionScoped = 1;
let blockScoped = 2;
}
console.log(functionScoped); // 1
// ReferenceError
// console.log(blockScoped);
}
现代代码应优先使用块级声明,让变量生命周期与实际使用范围一致。
2.6 闭包
闭包是函数与其词法环境关系产生的结果。函数在原定义位置之外执行时,仍然能够访问定义时可见的外部绑定。
function createCounter() {
let count = 0;
return function increment() {
count += 1;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
createCounter() 已经返回,但 increment() 仍然可以访问 count。这是因为该函数保留了访问相应词法环境所需的关联。
2.6.1 规范中的 [[Environment]]
ECMAScript 函数对象通常具有规范内部槽 [[Environment]],用于记录函数创建时关联的词法环境。调用函数时,新建立的函数环境会通过该关联接入外层作用域链。
[[Environment]] 是规范内部槽,JavaScript 代码不能像普通属性一样直接读取:
function example() {}
console.log(example["[[Environment]]"]); // undefined
不应把它理解为开发者可操作的隐藏属性或固定物理内存指针。它用于定义函数如何解析外部标识符。
2.6.2 多个闭包可以共享环境
function createCounter() {
let count = 0;
return {
increment() {
count += 1;
return count;
},
decrement() {
count -= 1;
return count;
},
getValue() {
return count;
},
};
}
const counter = createCounter();
counter.increment();
counter.increment();
counter.decrement();
console.log(counter.getValue()); // 1
三个方法访问同一个 count 绑定,因此共享状态。
2.6.3 每次调用可以创建独立环境
function createCounter() {
let count = 0;
return () => {
count += 1;
return count;
};
}
const firstCounter = createCounter();
const secondCounter = createCounter();
console.log(firstCounter()); // 1
console.log(firstCounter()); // 2
console.log(secondCounter()); // 1
两次调用 createCounter() 形成彼此独立的状态。
2.6.4 闭包实现私有状态
function createBankAccount(initialBalance = 0) {
let balance = initialBalance;
return {
deposit(amount) {
if (amount <= 0) {
throw new RangeError(
"amount 必须大于 0",
);
}
balance += amount;
},
getBalance() {
return balance;
},
};
}
const account = createBankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
外部代码不能直接通过返回对象的普通属性访问局部变量 balance,只能通过闭包暴露的方法操作它。
这是一种封装方式,但并非不可突破的安全隔离机制。闭包主要提供语言层面的可见性控制,不负责抵御恶意代码、调试器或同一执行环境中的所有攻击。
2.6.5 循环与闭包
使用 var 时,多个回调会共享同一个函数级绑定:
const callbacks = [];
for (var index = 0; index < 3; index += 1) {
callbacks.push(() => index);
}
console.log(callbacks[0]()); // 3
console.log(callbacks[1]()); // 3
console.log(callbacks[2]()); // 3
使用 let 时,每轮迭代具有相应的词法绑定:
const callbacks = [];
for (let index = 0; index < 3; index += 1) {
callbacks.push(() => index);
}
console.log(callbacks[0]()); // 0
console.log(callbacks[1]()); // 1
console.log(callbacks[2]()); // 2
这也是循环变量通常应使用 let 的原因之一。
2.6.6 闭包与垃圾回收
只要闭包仍可达,并且其行为可能需要某些外部绑定,运行时就必须保留相应状态,使程序结果符合语言语义。
当闭包及其相关对象不再可达时,垃圾回收器可以回收相应资源:
let counter = createCounter();
counter();
counter = null;
把变量设为 null 不会立即强制回收内存,只是移除一个引用。实际回收时机由引擎决定。
现代引擎可以优化闭包环境,只保留实际需要的状态。但这些优化不应改变程序的可观察行为,也不应作为代码正确性的前提。
闭包本身并不等于内存泄漏。只有当程序长期保留了不再需要的闭包、事件监听器、定时器或大型对象引用时,才可能造成不必要的内存占用。
2.7 this 绑定
this 与普通词法变量不同。普通函数中的 this 主要由调用形式决定,而箭头函数则从外层词法环境取得 this。
分析 this 时,应先观察函数如何被调用,而不是只看它在哪里定义。
2.7.1 顶层 this
浏览器传统脚本顶层的 this 通常指向 window:
<script>
console.log(this === window); // true
</script>
ES 模块顶层的 this 为 undefined:
// 在 ES 模块中
console.log(this); // undefined
Node.js CommonJS 文件顶层还受到模块包装机制影响,通常不是 globalThis。因此,不能笼统地说“JavaScript 顶层 this 永远指向全局对象”。
需要跨环境访问全局对象时,使用:
console.log(globalThis);
2.7.2 独立函数调用
非严格模式下,普通函数以独立形式调用时,this 中的 undefined 或 null 会被替换为全局对象:
function inspectThis() {
return this;
}
// 在浏览器传统非严格脚本中通常为 true
console.log(inspectThis() === globalThis);
严格模式下,独立调用的 this 为 undefined:
"use strict";
function inspectThis() {
return this;
}
console.log(inspectThis()); // undefined
模块默认严格,因此模块中的普通独立函数调用也得到 undefined。
2.7.3 方法调用
通过对象属性引用调用函数时,this 通常是点号或方括号左侧的基对象:
const user = {
name: "Alice",
greet() {
return `Hello, ${this.name}!`;
},
};
console.log(user.greet());
// Hello, Alice!
方括号访问具有相同规则:
console.log(user["greet"]());
方法并不会永久绑定到定义它的对象。相同函数作为不同对象的属性调用时,可以得到不同的 this:
function greet() {
return `Hello, ${this.name}!`;
}
const first = {
name: "Alice",
greet,
};
const second = {
name: "Bob",
greet,
};
console.log(first.greet()); // Hello, Alice!
console.log(second.greet()); // Hello, Bob!
2.7.4 方法提取导致 this 丢失
把方法提取到变量中后,调用表达式不再包含原对象:
"use strict";
const user = {
name: "Alice",
greet() {
return `Hello, ${this.name}!`;
},
};
const greet = user.greet;
// TypeError,因为 this 为 undefined。
// greet();
规范使用 Reference Record 等抽象结构描述属性访问和调用之间如何传递基对象信息。把 user.greet 求值为普通函数值并保存后,后续 greet() 调用不再携带原来的基对象。
不应把这一过程描述为某种用户可见“引用类型值被解构”。可靠结论是:调用点决定普通函数的 this,单独保存函数不会自动保留原对象。
2.7.5 回调中的 this
把方法直接作为回调传递,也可能丢失原调用对象:
const user = {
name: "Alice",
greet() {
console.log(`Hello, ${this.name}!`);
},
};
// 不会自动以 user 为 this
// setTimeout(user.greet, 0);
可以使用箭头函数保留期望的调用表达式:
setTimeout(() => {
user.greet();
}, 0);
或者使用 bind():
const boundGreet = user.greet.bind(user);
setTimeout(boundGreet, 0);
2.7.6 call
call() 立即调用函数,并显式提供 this 和逐个列出的实参:
function introduce(greeting, punctuation) {
return `${greeting}, I am ${this.name}${punctuation}`;
}
const user = {
name: "Alice",
};
console.log(
introduce.call(user, "Hello", "!"),
);
严格函数会原样接收 thisArg。非严格函数会对 null、undefined 和原始值进行传统的 this 转换。
2.7.7 apply
apply() 与 call() 类似,但使用数组或类数组对象提供实参:
console.log(
introduce.apply(
user,
["Hello", "!"],
),
);
现代代码也可以结合展开语法使用 call():
const args = ["Hello", "!"];
console.log(
introduce.call(user, ...args),
);
2.7.8 bind
bind() 不立即调用原函数,而是返回一个绑定函数:
const boundIntroduce = introduce.bind(
user,
"Hello",
);
console.log(boundIntroduce("!"));
绑定函数可以固定 this,也可以预先固定部分参数。这种预设参数属于偏函数应用(Partial Application)。
对于普通调用,之后再使用 call() 或 apply() 不能覆盖已经绑定的 this:
const otherUser = {
name: "Bob",
};
console.log(
boundIntroduce.call(otherUser, "?"),
);
// 仍使用 Alice
如果原函数可构造,绑定函数也可能仍可通过 new 调用。此时构造调用创建的新实例会取代绑定的 thisArg,而预设参数仍然有效。完整构造与原型行为将在第六章讨论。
2.7.9 箭头函数作为对象方法
直接把箭头函数写成对象属性,通常不能取得该对象作为 this:
const user = {
name: "Alice",
greet: () => {
return this.name;
},
};
箭头函数的 this 来自对象字面量外层,而不是 user。因此,需要动态接收对象的普通方法应使用方法简写:
const user = {
name: "Alice",
greet() {
return this.name;
},
};
箭头函数适合在方法内部创建回调,以继承外层方法的 this:
const user = {
name: "Alice",
greetLater() {
setTimeout(() => {
console.log(`Hello, ${this.name}!`);
}, 0);
},
};
2.7.10 this 规则汇总
| 调用形式 | 普通函数中的 this |
|---|---|
func() | 严格模式为 undefined;非严格模式通常为全局对象 |
obj.func() | obj |
func.call(obj) | obj |
func.apply(obj) | obj |
func.bind(obj)() | 绑定的 obj |
new Func() | 新创建的实例,具体规则见第六章 |
| 箭头函数调用 | 不创建自己的 this,读取外层词法 this |
只背诵表格仍然不够。真正稳定的方法是观察调用表达式,并区分普通函数与箭头函数。
2.8 高阶函数与回调
高阶函数接收函数作为参数,或返回函数。
2.8.1 回调函数
作为实参传入另一个函数、并由该函数决定何时调用的函数,通常称为回调函数:
function repeat(count, action) {
for (let index = 0; index < count; index += 1) {
action(index);
}
}
repeat(3, (index) => {
console.log(index);
});
回调可以是同步的,也可以是异步的。不能仅凭“作为参数传入”就断定它会异步执行。
[1, 2, 3].map(value => value * 2);
map() 中的回调会在本次 map() 调用过程中同步执行。定时器、网络请求和事件监听中的回调则由宿主调度。
2.8.2 返回函数
function greaterThan(limit) {
return value => value > limit;
}
const greaterThanTen = greaterThan(10);
console.log(greaterThanTen(15)); // true
console.log(greaterThanTen(5)); // false
这里返回的箭头函数通过闭包记住 limit。
2.8.3 函数组合
可以把多个单一职责函数组合起来:
const trim = value => value.trim();
const toLowerCase = value => value.toLowerCase();
function compose(first, second) {
return value => first(second(value));
}
const normalize = compose(
toLowerCase,
trim,
);
console.log(normalize(" JavaScript "));
// javascript
实际项目中应根据可读性决定是否采用函数式组合。过度抽象会使简单控制流变得难以追踪。
2.9 函数装饰与记忆化
函数装饰器在这里指接收一个函数并返回包装函数的高阶函数。它与第六章可能讨论的语言级装饰器语法不是同一概念。
2.9.1 日志包装
function withLogging(func) {
return function (...args) {
console.log("arguments:", args);
const result = func.apply(this, args);
console.log("result:", result);
return result;
};
}
function add(first, second) {
return first + second;
}
const loggedAdd = withLogging(add);
console.log(loggedAdd(1, 2));
包装函数使用 apply(this, args) 转发调用者提供的 this 和全部参数。
对于构造函数、私有字段、函数元数据、异步函数和错误堆栈等复杂场景,通用包装器还需要处理更多语义,不能假设简单示例适用于所有函数。
2.9.2 记忆化
记忆化缓存相同输入对应的计算结果:
function memoizeUnary(func) {
const cache = new Map();
return function (argument) {
if (cache.has(argument)) {
return cache.get(argument);
}
const result = func.call(this, argument);
cache.set(argument, result);
return result;
};
}
const square = memoizeUnary(
value => value * value,
);
console.log(square(5)); // 首次计算
console.log(square(5)); // 读取缓存
这个示例只处理单个参数,并使用 Map 的键相等规则:
- 原始值按相应值语义匹配;
- 对象参数按对象身份匹配。
记忆化并不自动适合所有函数。使用前应确认:
- 函数在相同输入下应产生相同输出;
- 函数没有必须重复执行的副作用;
- 缓存不会无限增长;
- 对象参数的身份比较符合需求;
- 缓存失效策略明确。
对于对象键且希望不阻止对象被垃圾回收的场景,可以研究 WeakMap。相关内容将在第五章讨论。
2.10 柯里化与偏函数应用
2.10.1 柯里化
柯里化把一个接收多个参数的函数转换为一系列每次接收一个参数的函数:
function add(first) {
return function (second) {
return first + second;
};
}
console.log(add(1)(2)); // 3
箭头函数可以简写为:
const add = first => second => first + second;
2.10.2 偏函数应用
偏函数应用预先固定部分参数,并返回等待剩余参数的新函数:
function multiply(first, second) {
return first * second;
}
const double = multiply.bind(null, 2);
console.log(double(5)); // 10
bind() 同时还会绑定 this。当函数不需要 this 时,可以使用普通包装函数使意图更明确:
const double = value => multiply(2, value);
柯里化与偏函数应用相关,但不是同一概念:
- 柯里化改变函数形态,使每层通常只接收一个参数;
- 偏函数应用固定部分参数,但剩余函数仍可一次接收多个参数。
2.10.3 通用 curry 的局限
教学中常见以下自动柯里化写法:
function curry(func) {
return function curried(...args) {
if (args.length >= func.length) {
return func.apply(this, args);
}
return function (...nextArgs) {
return curried.apply(
this,
args.concat(nextArgs),
);
};
};
}
它依赖 func.length 判断所需参数数量。但 func.length 只统计第一个具有默认值的形参之前的参数,并且不包括剩余参数:
function example(first, second = 0, ...rest) {}
console.log(example.length); // 1
因此,这种 curry() 只能用于参数数量规则简单且调用约定明确的函数,不能视为适用于任意函数的完全通用转换器。
此外,多阶段调用中的 this 应如何继承也需要提前设计。实际业务中,手写具有明确语义的局部函数通常比通用柯里化工具更容易维护。
2.11 定时器与回调调度
setTimeout() 和 setInterval() 是宿主环境 API,不属于 ECMAScript 语言本身。浏览器和 Node.js 都提供类似接口,但具体返回值类型、事件循环阶段和环境限制并不完全相同。
事件循环、任务与微任务将在第四章系统讨论。本节只介绍函数回调相关的基本语义。
2.11.1 setTimeout
console.log("start");
setTimeout(() => {
console.log("timer");
}, 0);
console.log("end");
通常输出:
start
end
timer
setTimeout(callback, 0) 不表示立即同步调用。它表示在满足最小延迟并且宿主调度到相应任务后,才有机会执行回调。
延迟参数表示最早可执行时间附近的调度要求,而不是精确执行时刻。以下因素都可能导致更晚执行:
- 当前同步代码尚未结束;
- 任务队列中还有其他工作;
- 主线程繁忙;
- 页面处于后台;
- 浏览器执行节流;
- 操作系统调度延迟。
2.11.2 取消定时器
const timerId = setTimeout(() => {
console.log("不会执行");
}, 1000);
clearTimeout(timerId);
取消操作应在回调被调度执行前完成。
2.11.3 嵌套定时器的最小延迟
HTML 标准对浏览器定时器规定了嵌套层级限制。当定时器嵌套层级超过 5,并且请求延迟小于 4 ms 时,延迟会被提升到至少 4 ms。
let count = 0;
function scheduleNext() {
setTimeout(() => {
count += 1;
console.log(count);
if (count < 10) {
scheduleNext();
}
}, 0);
}
scheduleNext();
这一规则只说明规范最低限制。浏览器仍然可以因为后台标签页、功耗控制或其他原因施加更长延迟。
不要把“第六次以后固定为 4 ms”理解为精确计时保证。正确理解是:在相应嵌套条件下,请求值会被限制为至少 4 ms,实际执行仍可能更晚。
2.11.4 setInterval
setInterval() 尝试按照指定间隔重复调度回调:
const intervalId = setInterval(() => {
console.log("tick");
}, 1000);
setTimeout(() => {
clearInterval(intervalId);
}, 5000);
如果回调执行时间较长,或者主线程持续繁忙,实际间隔可能发生延迟或漂移。
对于必须等待上一次异步工作完成后再安排下一次执行的任务,递归 setTimeout() 往往更容易控制:
async function poll() {
try {
await checkStatus();
} finally {
setTimeout(poll, 1000);
}
}
poll();
这里使用了 async 和 await,其语义将在第四章讨论。
2.11.5 定时器中的 this
把普通方法直接交给定时器,不会自动保留原对象:
const user = {
name: "Alice",
greet() {
console.log(this.name);
},
};
// 不推荐
// setTimeout(user.greet, 0);
应使用包装箭头函数:
setTimeout(() => {
user.greet();
}, 0);
或者使用绑定函数:
setTimeout(
user.greet.bind(user),
0,
);
2.12 函数设计原则
2.12.1 单一职责
函数应围绕一个明确任务组织。函数名出现 and、then 或包含多个无关动词时,通常需要检查是否承担了过多职责。
function validateUser(user) {
// 只负责验证。
}
function saveUser(user) {
// 只负责保存。
}
2.12.2 明确输入与输出
函数签名和返回值应让调用者能够理解契约:
function calculateAverage(values) {
if (!Array.isArray(values)) {
throw new TypeError(
"values 必须是数组",
);
}
if (values.length === 0) {
return null;
}
const total = values.reduce(
(sum, value) => sum + value,
0,
);
return total / values.length;
}
是否返回 null、undefined、默认值或抛出异常,应根据接口语义统一设计。
2.12.3 控制副作用
修改外部变量、改变传入对象、操作 DOM、写入文件和发送网络请求都属于副作用。
副作用并非错误,但应保持清晰:
function calculateTotal(items) {
return items.reduce(
(total, item) => total + item.price,
0,
);
}
这个函数只根据输入计算结果,更容易测试和复用。
2.12.4 避免隐式 this 依赖
不需要动态上下文时,优先通过参数显式传递依赖:
function formatUserName(user) {
return user.name.trim();
}
需要方法多态、对象状态或原型共享行为时,再使用 this。这可以减少方法提取和回调传递引发的上下文问题。
2.12.5 谨慎使用过度抽象
高阶函数、装饰器、柯里化和函数组合能够减少重复,但也会增加间接层级。抽象应满足以下条件之一:
- 消除真实重复;
- 建立清晰接口;
- 隔离变化;
- 提高可测试性;
- 表达稳定的领域概念。
不要仅为了展示技巧而引入复杂包装。
2.13 本章小结
本章介绍了 JavaScript 函数对象的核心语义:
- 函数是一等值,可以保存、传递和返回;
- 函数声明在作用域初始化阶段创建,函数表达式在执行到相应表达式时求值;
- 具名函数表达式的内部名称只在函数内部可见,适合递归与调试;
- 箭头函数没有自己的
this、arguments、super和new.target绑定; - 箭头函数没有
[[Construct]],不能通过new调用; - JavaScript 始终按值传递,对象值具有引用语义;
- 默认参数按需在调用期间求值,剩余参数会生成真正的数组;
return后直接换行可能被 ASI 截断;- JavaScript 使用词法作用域,标识符解析由函数定义位置决定;
- 闭包使函数在原作用域之外仍能访问相关外部绑定;
- 规范中的词法环境和
[[Environment]]是执行语义模型,不是可直接操作的物理指针; - 普通函数的
this主要由调用形式决定,箭头函数的this来自外层词法环境; call()和apply()立即调用函数,bind()返回绑定函数;- 高阶函数可以实现回调、装饰、记忆化、柯里化和偏函数应用;
- 定时器属于宿主 API,延迟参数不是精确执行时间,零延迟也不会同步执行。
参考资料
- javascript.info: Functions
- javascript.info: Function expressions
- javascript.info: Arrow functions, the basics
- javascript.info: Rest parameters and spread syntax
- javascript.info: Variable scope and closure
- javascript.info: Function binding
- javascript.info: Decorators and forwarding, call/apply
- javascript.info: Scheduling—setTimeout and setInterval
- MDN JavaScript Guide: Functions
- MDN JavaScript Reference: Arrow function expressions
- MDN JavaScript Reference: The arguments object
- MDN JavaScript Reference: Rest parameters
- MDN JavaScript Guide: Closures
- MDN JavaScript Reference: this
- MDN JavaScript Reference: Function.prototype.bind()
- ECMAScript 2026 Language Specification: Functions and Classes
- HTML Standard: Timers