
JavaScript 回调函数怎么写:同步与异步场景的区别
在 JavaScript 中,回调函数(callback)不是一种特殊语法,而是一个普通函数:把它作为参数传给另一个函数,由后者在某个时机调用。这个时机可能就在当前调用过程中,也可能要等计时器、文件读取、网络请求或用户操作完成之后。
回调在事件处理、数组遍历、定时任务和 Node.js 接口中都很常见。写回调时,真正需要弄清楚的不是“怎么把函数传进去”,而是调用方与回调之间的约定:回调什么时候执行、会收到什么参数、可能执行几次、错误怎么表示,以及它是同步还是异步的。约定不清,代码就容易出现顺序错误、异常丢失或重复执行。
一、回调函数的基本写法
先看一个最小例子。printResult 被作为参数传给 calculate,calculate 在计算完成后调用它:
function calculate(a, b, callback) {
const result = a + b;
callback(result);
}
function printResult(value) {
console.log("结果:", value);
}
calculate(2, 3, printResult);
输出为 结果:5。也可以直接传入匿名函数:
calculate(2, 3, function (value) {
console.log("结果:", value);
});
或者使用箭头函数:
calculate(2, 3, (value) => {
console.log("结果:", value);
});
这里要区分“传入函数”和“调用函数”。calculate(2, 3, printResult) 传入的是函数本身;如果写成 calculate(2, 3, printResult()),则会先执行 printResult,再把它的返回值传入。除非这个函数有意返回另一个函数,否则这通常是错误的。
二、同步回调:在当前调用过程中执行
同步回调会在外层函数返回之前执行。数组的 map、filter、forEach 都使用同步回调。例如:
const numbers = [1, 2, 3];
const doubled = numbers.map((number) => {
return number * 2;
});
console.log(doubled);
console.log("map 已经返回");
map 会依次调用回调,并等这些调用完成后才返回新数组。因此第一条输出是 [2, 4, 6],第二条输出随后出现。对这类同步回调来说,回调返回值通常有明确用途:map 收集返回值,filter 根据返回值判断是否保留元素,而 forEach 会忽略回调的返回值。
下面是一个自定义同步遍历函数:
function forEachItem(items, callback) {
for (let index = 0; index < items.length; index += 1) {
callback(items[index], index);
}
}
forEachItem(["a", "b"], (item, index) => {
console.log(index, item);
});
此处的参数次序由 forEachItem 的实现决定。JavaScript 不会自动规定回调必须接收哪些参数;调用方传什么,回调就能按位置读取什么。回调声明的形参数量也不会限制调用方传入参数的数量,多出来的参数通常可以通过剩余参数或 arguments 获取,少传的参数则是 undefined。
例如,下面的回调只声明一个形参,但仍能读取调用方传入的第二个参数:
forEachItem(["a"], function (item) {
console.log(item, arguments[1]);
});
不过,依赖未写明的额外参数会让代码难以维护。设计 API 时应明确参数的含义和顺序,使用时也应查阅对应 API 的约定。不能仅凭参数名称猜测第三方函数会传什么。
三、异步回调:稍后执行
异步回调不会在当前调用栈中立即执行,而是在后续某个时机执行。常见例子包括 setTimeout、DOM 事件以及许多基于回调的 Node.js API:
console.log("开始");
setTimeout(() => {
console.log("计时器回调");
}, 0);
console.log("结束");
输出顺序是“开始”“结束”“计时器回调”。延迟设为 0 不代表回调立刻执行;它通常要等当前同步代码执行完,再由事件循环安排执行。计时器的实际触发时间也可能晚于指定延迟,不能用它保证精确计时。
浏览器事件也是异步回调的常见形式:
const button = document.querySelector("#save");
button.addEventListener("click", (event) => {
console.log("用户点击了按钮", event.type);
});
这段代码要放在存在 #save 元素的页面中运行。注册事件监听器时,回调不会立刻执行;用户触发点击后,浏览器才会调用它,并按事件监听器的约定传入事件对象。
同步与异步的差别不在回调函数的外观,而在调用时机和控制流程。同一形式的函数可以被同步调用,也可以被异步调用。不能因为参数名叫 callback,就认定它一定稍后执行。
四、异步回调的参数与错误优先约定
早期 Node.js 风格的回调常采用“错误优先”约定:第一个参数表示错误,成功时为 null;后续参数表示结果。这是许多 API 采用的约定,不是 JavaScript 语言强制规定的规则。
function loadUser(id, callback) {
setTimeout(() => {
if (!Number.isInteger(id) || id <= 0) {
callback(new Error("用户 ID 无效"));
return;
}
callback(null, { id, name: "Ada" });
}, 100);
}
loadUser(7, (error, user) => {
if (error) {
console.error("加载失败:", error.message);
return;
}
console.log("加载成功:", user.name);
});
这个示例可在浏览器控制台或 Node.js 中运行。函数通过 setTimeout 模拟异步操作:校验失败时回调收到一个错误;成功时错误参数为 null,第二个参数是用户对象。
错误优先回调的常见处理方式是先判断错误并提前返回,避免失败时继续访问不存在的结果。不要只检查结果是否为空,因为“没有结果”“操作失败”和“尚未完成”可能是不同状态,应以 API 文档约定为准。
编写自己的异步回调 API 时,至少要说明:
- 回调参数的数量、顺序及类型;
- 失败时如何报告,是错误优先参数、事件,还是其他形式;
- 回调可能执行几次,是否可能完全不执行;
- 回调是否异步执行,以及何时算作操作完成。
如果这些约定含糊,调用方就无法可靠地处理结果。尤其要避免某些分支同步调用回调、另一些分支异步调用回调,这会导致执行顺序随输入情况变化。除非接口明确需要这种行为,否则应尽量保持时机一致。
五、回调的参数由调用方决定,不存在通用限制
JavaScript 函数本身没有一个通用规则,要求回调必须有几个参数、必须返回什么类型,或者只能被调用一次。这些限制来自具体 API 的设计,而不是“回调函数”这个概念。例如,数组的 map 会传入元素、索引和原数组;计时器回调通常不接收结果参数;事件监听器会收到事件对象。
需要在 API 边界检查回调是否可调用,可以使用 typeof:
function runLater(callback) {
if (typeof callback !== "function") {
throw new TypeError("callback 必须是函数");
}
setTimeout(() => {
callback("完成");
}, 0);
}
这只能检查值是否为函数,不能证明它符合某种业务用途,也不能保证调用时不会抛错。JavaScript 也不会强制回调的参数类型或返回类型;必要时仍要验证回调返回值或其产生的数据。
六、回调中的 this 与闭包
普通函数的 this 通常取决于它如何被调用,不一定指向传入回调的对象。下面的代码会把对象的方法作为独立函数传入:
const counter = {
value: 0,
increment() {
this.value += 1;
console.log(this.value);
}
};
setTimeout(counter.increment, 0);
回调执行时,this 不会因为函数原先写在 counter 对象上,就自动绑定为 counter。在严格模式下,普通函数直接调用时 this 可能是 undefined;在非严格模式下则可能指向全局对象,具体取决于执行环境。可以显式绑定:
setTimeout(counter.increment.bind(counter), 0);
箭头函数没有自己的 this,它会从定义位置的外层作用域继承 this。但这不意味着箭头函数会自动绑定到作为参数传入它的对象。选择普通函数还是箭头函数,应结合是否需要动态 this 来判断。
闭包则常用于让回调访问外层变量:
function makeLogger(prefix) {
return function (message) {
console.log(prefix + ": " + message);
};
}
const logInfo = makeLogger("信息");
logInfo("任务完成");
返回的函数仍可读取创建它时的 prefix。闭包适合保存回调所需的上下文,但也要注意生命周期:如果长期存活的事件监听器闭包引用了大型对象,这些对象可能因此不能及时释放。需要时应移除监听器或解除不再需要的引用。
七、常见错误与边界情况
1. 把函数调用结果当作回调传入
function greet() {
console.log("hello");
}
setTimeout(greet(), 1000);
这里 greet() 会立刻执行,传给 setTimeout 的是它的返回值,而不是函数。正确写法是 setTimeout(greet, 1000)。如果需要参数,可以传入包装函数:
setTimeout(() => {
greet("Ada");
}, 1000);
2. 假定异步回调已经执行
let result;
setTimeout(() => {
result = "已完成";
}, 0);
console.log(result);
打印的是 undefined,因为读取发生在计时器回调之前。异步结果必须在回调中处理,或使用 Promise、async/await 等机制表达后续流程,不能把异步操作当作同步赋值。
3. 在外层 try/catch 中捕获稍后发生的异常
try {
setTimeout(() => {
throw new Error("稍后发生的错误");
}, 0);
} catch (error) {
console.error("捕获失败", error);
}
外层 try/catch 在计时器回调执行前已经结束,因此不能捕获回调中稍后抛出的异常。要在回调执行处处理错误,可以把 try/catch 放进回调;但这只处理该处同步抛出的异常。异步 API 自身的失败,应按其提供的错误参数或错误事件处理。
setTimeout(() => {
try {
doWork();
} catch (error) {
console.error("回调中的同步错误:", error);
}
}, 0);
4. 假定回调只会执行一次
某些接口可能因重试、多个事件或错误处理路径而多次调用回调。是否可能重复执行,要看 API 合同。如果业务要求某段逻辑最多执行一次,可以在调用方设置状态保护:
let finished = false;
function finishOnce(value) {
if (finished) {
return;
}
finished = true;
console.log("最终结果:", value);
}
状态保护不能替代对 API 行为的理解。更好的做法是让自己设计的“一次性完成”接口在内部保证只完成一次,并把这一点写入说明。
5. 忘记处理监听器与资源生命周期
注册事件回调后,如果不再需要它,应在适当时机移除,特别是重复创建组件或页面区域时。DOM 监听器可以使用同一个函数引用调用 removeEventListener;也可以用 AbortController 管理一组支持信号的监听器。匿名函数若没有保存引用,通常就无法直接用同一引用移除。
八、回调与 Promise:何时考虑替代
回调适合简单的一次性异步通知、事件监听和需要立即定制处理逻辑的 API。但多个异步步骤串接时,回调容易层层嵌套,错误处理也会分散。Promise 可以把异步结果表示为一个对象,并通过 then、catch 串接;async/await 则提供更接近顺序代码的写法。
一个使用 Promise 的例子:
function wait(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function main() {
console.log("等待开始");
await wait(100);
console.log("等待结束");
}
main().catch((error) => {
console.error("发生错误:", error);
});
Promise 并没有让回调消失。传给 then、catch 和 finally 的仍然是回调,只是 Promise 统一了异步结果和错误传播方式。事件监听器这类持续发生多次的通知,通常仍直接使用回调更自然;一次性操作及多步异步流程,则常用 Promise 或 async/await。
九、回调与安全:不要把它当作安全边界
回调本身不会自动校验数据、过滤恶意内容或隔离权限。把一个函数称为“校验回调”,并不意味着数据就安全了;也不能通过限制回调形参数量来限制它能访问什么。参数个数和类型的约定是 API 层面的接口设计,不是安全机制。
若回调收到来自用户、网络或文件的数据,应在真正使用数据的边界进行验证和处理。例如,数据将进入 HTML 页面时,要根据输出上下文采用合适的转义或安全 DOM API;数据将用于 SQL、命令行或文件路径时,应使用对应领域的参数化接口、白名单或规范化方法。不要把普通字符串拼接误当成安全防护。
同样,调用不可信函数时,不能认为把它放进回调就会限制它的能力。普通回调通常与调用方运行在同一 JavaScript 环境中,可能访问该环境允许它访问的对象和资源。需要权限隔离时,应使用相应的沙箱、进程或平台安全机制,而不是依赖回调形式。
十、写回调前的检查清单
- 确认回调由谁调用、何时调用,以及它是同步还是异步。
- 按 API 文档核对参数顺序、成功和失败的表示方式。
- 区分传入函数与调用函数,避免误写成
callback()。 - 确认回调可能执行零次、一次还是多次;需要时处理重复调用。
- 不要依赖外层同步代码读取尚未产生的异步结果。
- 检查普通函数的
this绑定,并在必要时显式绑定。 - 管理事件监听器和闭包引用,避免生命周期长于预期。
- 把输入验证和输出安全处理放在实际数据使用边界,而不是寄希望于回调参数限制。
掌握回调的关键,是把它看成双方约定的一种控制流程接口:传入一个函数,调用方在约定的时机提供约定的数据。同步回调适合立即完成的遍历和转换;异步回调用于操作完成后通知结果或响应事件。理解执行时机、参数约定、错误处理和生命周期后,回调代码就会清晰许多,也更容易判断何时继续使用回调、何时改用 Promise。









