Skip to content

函数:定义、参数、返回值与调用 ​

前一章把规则写在一段程序里。函数把一段规则变成可以命名、传入输入并拿到结果的单元。它不只是减少重复,也是让调用者不用同时理解所有细节的方法。

定义不会立刻运行函数体 ​

js
function readingMinutes(pages, minutesPerPage) {
  return pages * minutesPerPage;
}
const first = readingMinutes(4, 3);
const second = readingMinutes(6, 2);
console.log(first, second); // 12 12

pages 和 minutesPerPage 是形参,调用时的 4、3 是实参。每次调用都建立自己的局部绑定;第一次调用的 pages 不会留给第二次继续用。return 把值交还给调用位置,并结束当前调用。

console.log 是观察副作用,返回值则是可以继续计算的数据。函数里打印了 12,并不代表调用表达式的值也是 12。没有执行到带值的 return 时,返回值是 undefined。

js
function announce() {
  console.log('开始');
}
const result = announce(); // 先打印 开始
console.log(result);      // undefined

return 后不要换行再写结果。自动分号插入会把它理解为无值返回。需要跨行时写 return (,把表达式包在括号里。

调用结束后,返回到哪一个位置 ​

函数调用可以嵌在表达式中。外层算式需要先拿到内层调用的结果,才能继续:

js
function double(n) {
  console.log('进入double', n);
  return n * 2;
}
function total(base, extra) {
  console.log('进入total', base, extra);
  return base + extra;
}
const result = total(double(3), 4);
console.log('最后结果', result);
// 进入double 3
// 进入total 6 4
// 最后结果 10

执行到 total(...) 时,先计算实参。double(3)进入自己的函数体,得到6并返回,于是外层相当于继续执行 total(6, 4)。外层函数返回10,再把10赋给result。每一次返回都要找到对应调用的后续位置,而不是跳到文件下一行随便继续。

text
计算 total 的第一个实参
  → 调用 double(3)
  ← 得到6
调用 total(6, 4)
  ← 得到10
初始化 result 为10

调用栈记录尚未完成调用的执行现场,包括接下来要回到哪里。它与下一篇的作用域链职责不同:栈回答“怎样回去继续执行”,作用域链回答“名字从哪里查到”。先用同步例子分清,后面再加入异步执行。

返回值还可以是函数本身。return double交回可调用的函数值,return double(3)交回此次调用的结果。漏掉或多加一对括号,会改变函数是在现在执行,还是留给调用者以后执行。

声明、表达式与箭头各有位置 ​

js
function add(a, b) { return a + b; }
const subtract = function (a, b) { return a - b; };
const multiply = (a, b) => a * b;
console.log(add(3, 2), subtract(3, 2), multiply(3, 2)); // 5 1 6

函数表达式产生函数值,再交给变量。只有一个普通参数时,箭头左侧可以省略括号:n => n * 2 等同于 (n) => n * 2;没有参数或有多个参数时保留括号。箭头函数没有花括号时自动返回表达式;有花括号就需要显式 return。若要直接返回对象,写 () => ({ value: 1 }),不然花括号会被理解为函数体。

函数声明可以在同一作用域内声明前调用;赋给 const 的函数必须等初始化之后才能调用。下一节解释原因。箭头函数还没有自己的 this 和 arguments,也不能当构造器,这些差异会在本章第三篇解释,不把箭头理解成纯粹的短写法。

缺省参数在调用时求值 ​

js
function label(name = '读者', prefix = '你好') {
  return `${prefix},${name}`;
}
console.log(label());          // 你好,读者
console.log(label(undefined)); // 你好,读者
console.log(label(null));      // 你好,null

缺省值只在参数是 undefined 或省略时启用,null、0、空字符串会保留。缺省表达式每次调用时求值,可以读取前面的参数;不能读取后面尚未初始化的参数,也不能依赖函数体内的局部声明。

参数过少时得到 undefined,过多时普通具名参数不会自动接收剩余项。语言不强制你的参数数量契约,必要时由函数校验类型、范围和结构。

默认表达式何时运行,可以数出来 ​

js
let nextId = 0;
function makeLabel(id = ++nextId) {
  return `标签${id}`;
}
console.log(makeLabel(20), nextId); // 标签20 0
console.log(makeLabel(), nextId); // 标签1 1
console.log(makeLabel(undefined), nextId); // 标签2 2
console.log(makeLabel(null), nextId); // 标签null 2

函数定义时没有分配默认id;传入20时也没有;只有需要缺省值的调用才执行 ++nextId。因此默认表达式可以有开销和副作用,不能把它当成函数定义时计算一次的常量。

如果缺省表达式创建对象,每次省略参数会得到新对象:

js
function settings(value = { size: 10 }) {
  return value;
}
const first = settings();
const second = settings();
console.log(first === second); // false

反过来,如果默认值来自外部已经存在的对象,就会共享它。关键是“执行的是创建表达式,还是读取已有变量”,不是默认参数有特殊的复制能力。

参数默认值还拥有独立的可见性边界。下面先建立外层名字,再在函数体内声明同名名字:

js
const size = 10;
function show(value = size) {
  const size = 20;
  return value;
}
console.log(show()); // 10

计算参数时,看不到函数体里面的size。若缺省表达式引用一个只在函数体里声明的辅助函数,会在省略实参时抛ReferenceError;显式传入实参却可能不报错,因为默认表达式根本未执行。这说明测试默认路径不能只测试“参数都齐全”的情况。

rest 收集,spread 展开 ​

js
function sum(...values) {
  let total = 0;
  for (const value of values) total += value;
  return total;
}
console.log(sum(2, 4, 6)); // 12
const values = [3, 5];
console.log(sum(...values)); // 8

这里提前使用最小数组知识:[3, 5] 按顺序保存两个值,for...of 依次读取它们。参数中的 ...values 把剩余实参收集成真正数组,只能是最后一个形参;调用处的 ...values 则把序列拆成多个实参。一个负责收,一个负责放。

普通函数还可以访问 arguments,它是类数组对象,包含传入实参及 length,但不是数组。严格模式中它不与形参双向同步。旧非严格模式且简单参数列表时存在映射行为;缺省、rest、解构等非简单参数会改变这一点。新代码一般使用具名参数与 rest,理解 arguments 用来阅读旧接口。

解构参数把配置的名字写在入口 ​

js
function describe({ title, minutes = 10 } = {}) {
  return `${title ?? '未命名'}:${minutes} 分钟`;
}
console.log(describe({ title: '闭包', minutes: 20 })); // 闭包:20 分钟
console.log(describe()); // 未命名:10 分钟

{ title, minutes } 从对象中读取同名属性。内层 minutes = 10 处理属性缺失,外层 = {} 处理整个参数省略,两层解决不同问题。传入 null 仍会报错,因为不能从 null 解构。解构语法在第三章完整展开,这里只认识常见配置参数写法。

改属性与替换参数不是同一件事 ​

js
function revise(note) {
  note.title = '已修订';
  note = { title: '另一本' };
  return note;
}
const original = { title: '草稿' };
const returned = revise(original);
console.log(original.title, returned.title); // 已修订 另一本
console.log(original === returned); // false

传入时,局部参数也关联原对象,所以第一次赋值修改了共享对象。第二次赋值只让局部参数关联新对象,不会改写调用者的 original 绑定。所有参数都是按值传递;对象值共享身份这一特性也常被称为共享传递。

设计函数时写清是否允许修改输入,返回值代表什么,非法输入如何处理。三个问题明确后,调用者就不用先钻进函数体寻宝。

练习 ​

  1. 写 area(width, height = width),不打印,只返回面积。测试 area(3) 和 area(3, 0)。

  2. 解释 function f(x = 1) { return x; } 接收省略、undefined、null 时的差异,以及为什么重新赋值对象参数不会替换外面的对象。

  3. 解释total(double(3), 4)中谁先执行;默认参数为新对象与默认参数读取外部对象时,连续两次省略参数是否共享对象?

查看答案

用心记录每一步成长