当价格计算、名称格式化或数据校验在多个地方重复出现时,复制代码看似最快,修改起来却很危险:同一条规则散落在不同位置,漏改一处就会产生不一致。
函数解决的正是这个问题。它把一段动作封装成可调用的值,再通过参数接收变化,通过返回值交出结果。调用者只需要知道“给它什么、会拿回什么”,不必每次重写过程。
这一章会从最小函数开始,逐步走到作用域、闭包、递归和回调。最后还会厘清箭头函数与 this 的边界,让你不仅会写函数,也能判断该选哪一种写法。
以下示例以 Node.js v25.2.1 为运行环境。涉及 setTimeout() 的部分只保证执行先后关系,不把实际等待时间写成固定值。
学完本章,你应该能够:
return 返回结果、提前结束函数,并避免把打印误当成返回;this 的需求选择普通函数或箭头函数。函数可以包含表达式、条件、循环和变量声明。定义函数是在创建一段可执行逻辑;写出调用表达式,才会真正执行函数体。
函数声明以 function 开头,后面依次是函数名、参数列表和函数体。
function buildGreeting(name) {
const cleanName = name.trim();
return `你好,${cleanName}!`;
}
console.log("函数已经定义");
console.log(buildGreeting(" 小林 "));运行环境:Node.js v25.2.1
函数已经定义
你好,小林!第一行输出说明定义过程已经完成,但 buildGreeting 的函数体还没有执行。直到代码遇到 buildGreeting(" 小林 "),实参才会进入函数,函数体才从上到下运行。

函数名通常用小驼峰,并以动词或动词短语开头,例如 formatName、calculateTotal、renderList。名字应说明动作,而不是使用 doStuff 这类含义模糊的词。
函数也是值,因此可以赋给变量。右侧没有函数名时,它是匿名函数表达式;调用时使用保存它的变量名。
const makeLabel = function (name, score) {
return `${name}:${score} 分`;
};
const square = function squareNumber(value) {
return value * value;
};
console.log(makeLabel("小夏", 92));
console.log(运行环境:Node.js v25.2.1
小夏:92 分
25第二个表达式给函数本身起了 squareNumber 这个名字,有利于调试;外部仍通过变量 square 调用它。
函数声明与用 const 保存的函数表达式还有一个重要区别:
console.log(double(4));
function double(value) {
return value * 2;
}
const triple = function (value) {
return value * 3;
};
console.log(triple(4));运行环境:Node.js v25.2.1
8
12函数声明会在当前作用域初始化时准备好,因此可以在声明文本之前调用。triple 则必须等 const triple = ... 执行完才能使用;若提前访问,会触发 ReferenceError。实际项目中,把定义放在首次使用之前通常更易读。
函数之所以可复用,是因为同一套步骤可以处理不同输入。定义时括号里的名字叫参数,调用时传入的具体值叫实参。
function calculateSubtotal(price, count = 1, discount = 0) {
return price * count * (1 - discount);
}
console.log(calculateSubtotal(20, 3));
console.log(calculateSubtotal(20));
console.log(calculateSubtotal运行环境:Node.js v25.2.1
60
20
45调用时,实参按位置依次交给参数。第二次调用没有提供 count 和 discount,所以它们分别使用默认值 1 和 0。

默认值会在实参缺失或实参严格等于 undefined 时启用,null 不会触发默认值:
function describe(value = "未填写") {
return String(value);
}
console.log(describe());
console.log(describe(undefined));
console.log(describe(null));运行环境:Node.js v25.2.1
未填写
未填写
null如果实参数量少于没有默认值的参数数量,对应参数会得到 undefined。多出的实参不会自动报错,但只有显式接收它们,函数才能方便地使用它们。
同样是三个点,位置不同,方向也不同:
function collect(first, ...rest) 中的 ...rest 把剩余实参收集成数组;collect(...items) 中的 ...items 把可迭代值拆成一个个实参。function collect(first, ...rest) {
return {
first,
rest,
count: 1 + rest.length,
};
}
const words = ["函数", "参数", "返回值"];
console.log(collect(...words));运行环境:Node.js v25.2.1
{ first: '函数', rest: [ '参数', '返回值' ], count: 3 }剩余参数必须位于参数列表最后,而且一个函数最多只能有一个。它得到的是真数组,可以直接使用 map()、reduce() 等数组方法。
旧代码中还会看到 arguments。它只存在于普通函数中,是一个类数组对象,不是剩余参数数组:
function countLegacyArguments() {
return arguments.length;
}
function countArguments(...items) {
return items.length;
}
console.log(countLegacyArguments("a", "b", "c"));
console.log(countArguments("a", "b", "c"运行环境:Node.js v25.2.1
3
3新代码优先使用剩余参数,因为参数意图更明确,也能直接使用数组 API。箭头函数没有自己的 arguments,这也是剩余参数更通用的原因之一。
一个好用的计算函数通常把结果返回给调用者,由调用者决定是打印、保存,还是继续传给另一个函数。
console.log() 是展示,return 是交付function logSum(a, b) {
console.log(a + b);
}
const printed = logSum(2, 3);
console.log(printed);
function returnSum(a, b) {
return a + b;
}
const saved = returnSum
运行环境:Node.js v25.2.1
5
undefined
50logSum() 的确打印了 5,但没有显式 return,所以调用表达式的结果是 undefined。returnSum() 返回 5,这个值才可以保存并继续参与计算。
return 也可以提前结束函数return 执行后,当前函数会立即结束,后面的语句不会继续运行。这个特性适合处理无效输入和特殊情况,让主流程少一层缩进。
function getShippingFee(total, isMember = false) {
if (!Number.isFinite(total) || total < 0) {
return null;
}
if (isMember || total >= 300) {
return 0;
}
return 15;
}
console.
运行环境:Node.js v25.2.1
null
0
15还有一个容易忽略的语法边界:不要在 return 后立刻换行再写返回值。JavaScript 可能在行末自动补分号,使函数提前返回 undefined。
function makeUser() {
return {
name: "小林",
};
}需要返回对象时,把 { 放在 return 同一行。箭头函数隐式返回对象时,则要用圆括号包住对象,这一点稍后会看到。
作用域描述一个标识符可以在哪些位置被访问。JavaScript 使用词法作用域:可见范围由代码写在哪里决定,而不是由函数从哪里被调用决定。
函数参数和函数内部声明的变量,默认不能从函数外部访问。let 与 const 还受到代码块限制,var 则以函数为边界。
const courseName = "JavaScript";
function printLesson(index) {
const title = `${courseName} 第 ${index} 节`;
if (index === 1) {
let tip = "先运行最小示例";
var legacyNote = "var 会越过 if 块";
console.log(title, tip);
}
console.log
运行环境:Node.js v25.2.1
JavaScript 第 1 节 先运行最小示例
var 会越过 if 块在这个例子中:
courseName 位于外层作用域,函数内部可以读取;index 和变量 title 只属于 printLesson();tip 只在 if 代码块中可见;legacyNote 使用 var 声明,所以在整个函数中可见,但函数外仍不可见。现代代码优先使用 const,需要重新赋值时使用 let。这样变量的可见范围更窄,也更容易追踪。
函数内部还能定义函数。内层函数可以读取自己的局部变量,也可以沿着定义位置逐层向外查找。
const unit = "全局单元";
function createLessonLabel(topic) {
const unit = "函数单元";
function join(index) {
const suffix = "已就绪";
return `${unit} · ${topic} ${index} · ${suffix}`;
}
return join(1
运行环境:Node.js v25.2.1
函数单元 · 函数 1 · 已就绪
全局单元内层的 unit 暂时遮蔽了同名外层变量,但不会修改外层值。join() 可以读取 topic 和 unit;反过来,createLessonLabel() 不能直接读取 join() 内的 suffix,函数外也不能调用局部函数 join()。
立即调用函数表达式(IIFE)可以创建一次性的局部作用域:
const config = (function () {
const prefix = "DEV";
return {
format(message) {
return `[${prefix}] ${message}`;
},
};
})();
console.log(config.format("服务启动"));运行环境:Node.js v25.2.1
[DEV] 服务启动最后的 () 会立即调用前面的函数表达式。prefix 不会直接暴露到外部,只能通过返回对象的方法间接使用。
不要省略 let、const 或 var 直接给新变量赋值。在严格模式下这会抛出 ReferenceError;某些非严格环境可能意外创建全局属性,使错误扩散到整个程序。
如果一个函数离开原来的执行位置后,仍能访问创建它时的外层绑定,这组“函数加上词法环境”的组合就是闭包。
function createCounter(step = 1) {
let count = 0;
return function next() {
count += step;
return count;
};
}
const nextByTwo = createCounter(2);
console.log(nextByTwo());
console.log(nextByTwo());
运行环境:Node.js v25.2.1
2
4
5
6createCounter(2) 返回后,它那次调用产生的 count 和 step 仍被 nextByTwo 引用。再次调用 createCounter(5) 会创建另一套独立环境,因此两个计数器不会共享状态。

闭包常用于计数器、缓存、配置和事件处理器。外部不必直接接触状态,只通过明确的函数修改或读取它。
function createCart() {
const items = [];
return {
add(name) {
items.push(name);
return items.length;
},
snapshot() {
return [...items];
},
};
}
const cart = createCart();
cart.add(
运行环境:Node.js v25.2.1
[ '键盘', '鼠标' ]items 无法从外部直接访问,snapshot() 还返回了一个新数组,防止调用者通过返回值意外修改内部状态。
闭包保存的是绑定关系,不是创建瞬间的静态截图。只要闭包仍然可达,它引用的外层数据通常也会继续存活。因此,长期存在的事件监听器或定时器如果捕获了大型对象,应在不再需要时解除监听或清理定时器。
递归是函数直接或间接调用自己。它适合处理天然具有层级结构的问题,例如目录、评论树和嵌套菜单。
以阶乘为例,5! 等于 5 × 4 × 3 × 2 × 1。问题可以写成 factorial(5) = 5 × factorial(4),直到数字缩小到 1。
function factorial(n) {
if (!Number.isInteger(n) || n < 0) {
throw new RangeError("n 必须是非负整数");
}
if (n <= 1) {
return 1;
}
return n * factorial(n - 1);
}
运行环境:Node.js v25.2.1
120这里有三个不可缺少的部分:
n <= 1 时直接返回 1;n 减少 1,让问题靠近停止条件。function traceDown(n) {
console.log("进入", n);
if (n > 0) {
traceDown(n - 1);
}
console.log("离开", n);
}
traceDown(2);运行环境:Node.js v25.2.1
进入 2
进入 1
进入 0
离开 0
离开 1
离开 2每次调用都会在调用栈上等待更深一层完成,所以“进入”按 2、1、0 输出,“离开”则反向输出。若没有停止条件,或输入层级极深,调用栈最终可能溢出。
递归并不天然优于循环。简单的线性计数通常更适合循环;未知深度的树形数据更适合递归。选择时应优先考虑数据结构、可读性和最大深度。
JavaScript 的函数可以赋给变量、放进数组或对象、作为实参传入,也可以从另一个函数返回。能够这样被传递,是回调和函数式数组方法的基础。
匿名函数没有自己的名字,通常由变量、对象属性或参数持有:
const formatScore = function (score) {
return `${score} 分`;
};
const tools = [
formatScore,
value => String(value).trim(),
];
console.log(formatScore(88));
console.log(typeof tools[1]);运行环境:Node.js v25.2.1
88 分
function函数声明同样是函数值,也可以被赋给另一个变量或作为实参传递。“匿名”只表示函数本身没有名称,不表示它无法执行。
一个函数接收另一个函数,并在合适的位置调用它,被传入的函数通常称为回调。
function processScores(scores, transform) {
const results = [];
for (const score of scores) {
results.push(transform(score));
}
return results;
}
function toPassLabel(score) {
return score >= 60 ? "通过" : "待补考";
运行环境:Node.js v25.2.1
[ '通过', '待补考', '通过' ]
[ 87, 50, 76 ]第一次调用传的是 toPassLabel,没有括号。这表示把函数值交给 processScores();若写成 toPassLabel(),就会立刻调用它,并把调用结果传进去。
回调不等于异步。上面的回调在循环中立即执行,是同步回调;数组的 map()、filter() 和 forEach() 也经常接收同步回调。
setTimeout() 接收一个回调和最短延迟时间。当前同步代码结束后,运行环境才会在条件允许时执行回调。
setTimeout(() => {
console.log("定时器回调");
}, 0);
console.log("当前脚本");运行环境:Node.js v25.2.1
当前脚本
定时器回调延迟写成 0 不代表立刻插到当前语句前面,只表示不额外要求更长的等待时间;系统繁忙时,实际执行可能更晚。setInterval() 会反复安排回调,使用后应保存返回的标识,并在不再需要时调用 clearInterval()。
当多个回调层层嵌套时,控制流会变得难读。可以先把回调提取为有名字的小函数;处理更复杂的异步流程时,再选择更适合表达先后关系的工具。
箭头函数适合短小转换和回调,但它不仅是少写几个字符。它没有自己的 this 和 arguments,也不能作为构造函数使用。
const square = value => value * value;
const add = (a, b) => a + b;
const makeRecord = name => ({ name, active: true });
const normalize = text => {
const clean = text.trim();
return clean.toLowerCase
运行环境:Node.js v25.2.1
25
7
{ name: '小林', active: true }
hello规则可以归纳为:
(),只有一个简单参数时可以省略括号;() 中;{} 和 return;{} 编写多行函数体时,需要显式 return;({ ... }),否则 {} 会被当作函数体。普通函数中的 this 通常由调用位置决定。以对象方法形式调用时,this 指向点号前的对象:
const team = {
name: "前端组",
members: ["小林", "小夏"],
list() {
return this.members.map(member => `${this.name}:${member}`);
},
};
console.log(team.list());运行环境:Node.js v25.2.1
[ '前端组:小林', '前端组:小夏' ]list() 使用普通方法语法,所以 team.list() 中的 this 是 team。map() 里的箭头函数没有自己的 this,它沿用 list() 的 this,正好能读取 team.name。
下面的例子进一步展示两种函数的差异:
function createReaders() {
return {
normal() {
return this.label;
},
arrow: () => this.label,
};
}
const creator = {
label: "创建者",
createReaders,
};
const readers = creator.createReaders();
const caller = { label: "调用者"
运行环境:Node.js v25.2.1
调用者
创建者normal.call(caller) 改变了普通函数的 this。箭头函数在创建时已经沿用了 createReaders() 的 this,也就是 creator;call()、apply() 或 bind() 都不能再改变箭头函数的 this。
因此:
this 的短回调,可以使用箭头函数;arguments 或需要用 new 调用的函数,不使用箭头函数;this 会受脚本、模块和宿主环境影响,不要把它当作全局对象。
现在把参数、返回值、闭包、递归和回调组合起来。目标是创建一个任务统计器:任务既可以是叶子任务,也可以包含一组子任务。
sumMinutes(task):普通任务返回自己的分钟数,任务组递归汇总所有子任务。createTaskBoard(label = "今日计划"):用闭包保存私有的 tasks 数组。add(...newTasks),用剩余参数接收任意数量的任务。report(formatter):用回调决定每一行如何展示,并返回标题、总分钟数和文本行。sumMinutes() 能同时处理叶子任务和任意层级的任务组;add() 一次可以接收一个或多个任务,并返回任务板的顶层任务数;report() 不打印内部过程,而是返回可继续使用的数据;formatter 时仍能生成默认文本,传入回调后可以改变格式;createTaskBoard() 会得到互不共享任务状态的独立任务板;75 分钟,输出顺序与示例一致。进阶挑战:增加 remove(title) 方法,并让 report() 返回任务数组的副本,避免调用者直接修改闭包中的内部数据。
() 调用表达式才会执行函数体;undefined 时启用;return 会结束函数并交回结果,console.log() 只负责输出;this,普通方法则适合动态 this。运行环境:Node.js v25.2.1
今日计划
- 编写教程(45 分钟)
- 发布准备(30 分钟)
总计:75 分钟