函数:定义、参数、返回值与调用
前一章把规则写在一段程序里。函数把一段规则变成可以命名、传入输入并拿到结果的单元。它不只是减少重复,也是让调用者不用同时理解所有细节的方法。
定义不会立刻运行函数体
function readingMinutes(pages, minutesPerPage) {
return pages * minutesPerPage;
}
const first = readingMinutes(4, 3);
const second = readingMinutes(6, 2);
console.log(first, second); // 12 12pages 和 minutesPerPage 是形参,调用时的 4、3 是实参。每次调用都建立自己的局部绑定;第一次调用的 pages 不会留给第二次继续用。return 把值交还给调用位置,并结束当前调用。
console.log 是观察副作用,返回值则是可以继续计算的数据。函数里打印了 12,并不代表调用表达式的值也是 12。没有执行到带值的 return 时,返回值是 undefined。
function announce() {
console.log('开始');
}
const result = announce(); // 先打印 开始
console.log(result); // undefinedreturn 后不要换行再写结果。自动分号插入会把它理解为无值返回。需要跨行时写 return (,把表达式包在括号里。
调用结束后,返回到哪一个位置
函数调用可以嵌在表达式中。外层算式需要先拿到内层调用的结果,才能继续:
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。每一次返回都要找到对应调用的后续位置,而不是跳到文件下一行随便继续。
计算 total 的第一个实参
→ 调用 double(3)
← 得到6
调用 total(6, 4)
← 得到10
初始化 result 为10调用栈记录尚未完成调用的执行现场,包括接下来要回到哪里。它与下一篇的作用域链职责不同:栈回答“怎样回去继续执行”,作用域链回答“名字从哪里查到”。先用同步例子分清,后面再加入异步执行。
返回值还可以是函数本身。return double交回可调用的函数值,return double(3)交回此次调用的结果。漏掉或多加一对括号,会改变函数是在现在执行,还是留给调用者以后执行。
声明、表达式与箭头各有位置
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,也不能当构造器,这些差异会在本章第三篇解释,不把箭头理解成纯粹的短写法。
缺省参数在调用时求值
function label(name = '读者', prefix = '你好') {
return `${prefix},${name}`;
}
console.log(label()); // 你好,读者
console.log(label(undefined)); // 你好,读者
console.log(label(null)); // 你好,null缺省值只在参数是 undefined 或省略时启用,null、0、空字符串会保留。缺省表达式每次调用时求值,可以读取前面的参数;不能读取后面尚未初始化的参数,也不能依赖函数体内的局部声明。
参数过少时得到 undefined,过多时普通具名参数不会自动接收剩余项。语言不强制你的参数数量契约,必要时由函数校验类型、范围和结构。
默认表达式何时运行,可以数出来
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。因此默认表达式可以有开销和副作用,不能把它当成函数定义时计算一次的常量。
如果缺省表达式创建对象,每次省略参数会得到新对象:
function settings(value = { size: 10 }) {
return value;
}
const first = settings();
const second = settings();
console.log(first === second); // false反过来,如果默认值来自外部已经存在的对象,就会共享它。关键是“执行的是创建表达式,还是读取已有变量”,不是默认参数有特殊的复制能力。
参数默认值还拥有独立的可见性边界。下面先建立外层名字,再在函数体内声明同名名字:
const size = 10;
function show(value = size) {
const size = 20;
return value;
}
console.log(show()); // 10计算参数时,看不到函数体里面的size。若缺省表达式引用一个只在函数体里声明的辅助函数,会在省略实参时抛ReferenceError;显式传入实参却可能不报错,因为默认表达式根本未执行。这说明测试默认路径不能只测试“参数都齐全”的情况。
rest 收集,spread 展开
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 用来阅读旧接口。
解构参数把配置的名字写在入口
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 解构。解构语法在第三章完整展开,这里只认识常见配置参数写法。
改属性与替换参数不是同一件事
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 绑定。所有参数都是按值传递;对象值共享身份这一特性也常被称为共享传递。
设计函数时写清是否允许修改输入,返回值代表什么,非法输入如何处理。三个问题明确后,调用者就不用先钻进函数体寻宝。
练习
写
area(width, height = width),不打印,只返回面积。测试area(3)和area(3, 0)。解释
function f(x = 1) { return x; }接收省略、undefined、null时的差异,以及为什么重新赋值对象参数不会替换外面的对象。解释total(double(3), 4)中谁先执行;默认参数为新对象与默认参数读取外部对象时,连续两次省略参数是否共享对象?