JavaScript简明教程-函数

165 阅读13分钟

基本概念

函数

函数是一段可以反复调用的代码块。函数还能接收输入的参数,不同的参数会返回不同的值

JavaScript 有三种声明函数的方法:

  • function 命令
function add () {}
  • 函数表达式
// 根据某些前端标准会建议总是给函数一个具体的函数名称(airbnb)
let add = function addFn () {}
  • Function 构造函数
let add = new Function(
  'x',
  'y',
  'return x + y'
);

// 等同于
function add(x, y) {
  return x + y;
}

函数的声明存在变量提升

函数如果有return语句则返回return的相关数据,如果没有return返回undefined

函数参数的默认值

函数参数可以设置默认值,这个默认值放到最后,避免因为传参报错

function log(x, y = 'World') {
  console.log(x, y);
}

log('Hello') // Hello World
log('Hello', 'China') // Hello China

默认值可以使用解构赋值

function log({x, y}) {
  console.log(x, y);
}

log({x: 'hello', y: 'world'}); // hello world

指定解构赋值的默认值

function log({x = 'hello', y = 'world'}) {
  console.log(x, y);
}
log({}); // hello world
...
function log({x, y} = {x: 'hello', y: 'world'}) {
	console.log(x, y);
}
log(); // hello world
// 两者都是指定一个默认值,第一种是参数指定一个默认值,第二种是函数指定一个默认值

如果指定了默认参数,那么函数的length属性就不准确

(function add (x, y) {}).length; // 2
(function add (x, y = 0) {}).length; // 1

如果指定了参数默认值,函数在进行初始化时,函数会形成一个单独的作用域,等初始化结束,这个作用域就会消失,如果未设置默认值,这个作用域不会出现

// 这里设定变量x为1
let x = 1;

function f(y = x) {
  let x = 2;
  console.log(y);
}

f();

参数设置了默认值为x,但是在初始化时会形成一个单独作用域,在这个作用域内x无效,所以指向全局变量x

let x = 1;
function f(x, y = x) {
  console.log(y);
}

f(2);

这时在函数初始化时,形成的单独作用域内,x是一个有效值,所以就不会指向全局变量x,所以在指定函数参数默认值时一定要注意保证默认值有效

let x = 1;

// 这么写会报错,因为‘暂时性死区,定义前不能被调用’
function foo(x = x) {
  // ...
}

foo() 

对于不确定参数个数的传参,使用rest参数更为简洁

const sortNumbers = (...numbers) => numbers.sort();
sortNumbers(7, 1, 3, 4, 5);
// [1, 3, 4, 5, 7]

函数在js中是一个特殊的存在

数字,字符,布尔,对象,数组都是直接的数据类型,也都是我们进行的处理代码处理的值,而函数按照原本的定义只是普通的可执行代码片段,但是在js中,js把函数当作和普通数据类型一样,能用数据类型的定义一样可以使用函数(函数又称第一等公民)。

块级代码中不可使用函数声明(ES5的标准),在ES6的标准下是可以声明,但是按照某些前端标准是不建议如此使用(airbnb)

函数的相关属性和方法

name

函数的名称

// 这个属性来在调试定位问题时会比较方便
let add = function addFn () {};
let info = function () {};

add.name; // addFn
info.name; // info

// 上面的代码如果我们使用add.name能知道调用的是个函数,并且这个命名我们也可以命名的更清晰
// 但是相对的info.name这种匿名函数,对于我们而言在调试时并没有任何有意义的提示

// 并且如果你也定义了类似下面的代码
let foo = {name: 'foo'}; // 当你运行foo.name时一样返回'foo',看起来和info定义的情况类型

length

返回函数预期传入的参数个数,即函数定义之中的参数个数

let add = function addFn (a, b, c) {};
add.length; // 3

// 如果参数设置了默认值,这个属性值会判断有误
add = function addFn (a, b, c = 1) {};
add.length; // 2

// 默认值的参数不是尾参数,那么length属性也不再计入后面的参数了
add = function addFn (a = 1, b, c) {};
add.length; // 0

toString

返回一个字符串,内容是函数的源码

let add = function addFn (a, b, c) {};
add.toString(); // function addFn (a, b, c) {}

箭头函数

  • 箭头函数算是对匿名函数的一种简写方式

标识符 => 表达式

let info = msg => `new ${msg}`;

// 相当于
let info = function (msg) {
	return `new ${msg}`;
}
info('info'); // new info
  • 箭头函数的默认行为会直接return表达式的值,但是如果定义了其他操作会破坏默认行为
let info = () => 'info';
let foo = info();
foo; // info
...
let info = () => console.log('info');
let foo = info();
foo; // undefined
  • 如果不需要传参或者需要多个参数,可以使用圆括号代表参数部分
let info = () => 'info';
info(); // info

let add = (num1, num2) => num1 + num2;
add(1, 2); // 3
  • 如果执行代码块多于,多于一条语句需要使用大括号包裹然后在其中编写相关代码块
let add = (...args) => {
	let count = 0;
	args.forEach((item) => {
		count += item;
	});
	return count;
}
add(1, 2, 3); // 6

// 但是如果你直接返回一个对象(看教程中的例子,实际操作估计没人会这么干...)
let getTempItem = id => { id: id, name: "Temp" }; // 报错
let getTempItem = id => ({ id: id, name: "Temp" }); // 正常
  • 箭头函数可以简化某些函数的写法
// 下面是一个特定的功能函数,这种写法简单明了
const isEven = n => n % 2 === 0; // 判断一个数是否为偶数

// 下面是对回调函数的简化
[1,2,3].map(x => x * x);
  • 箭头函数内的this对象是定义时所在对象,不是使用时所在对象
function foo() {
  setTimeout(() => {
    console.log('id:', this.id);
  }, 100);
}

var id = 21;

foo.call({ id: 42 }); // id: 42,当对象{id:42}调用foo方法时,内部的箭头函数在定义时的this对象是{id:42},所以最终输出值为42

// 如果稍做改造,结果就不一样
foo.call({ age: 42}); // id: undefined, 看起来因为我们在顶层对象上定义了id,所以在运行foo()方法时如果this指向的是顶层对象,那this.id应该是21,但是因为其指向的是{ age: 42}对象,这个对象并没有id属性,所以运行结果就是undefined

// 可以看下面的例子强化对这个问题的理解
var s1 = -10;
var s2 = -10;
function Timer() {
  this.s1 = 0;
  this.s2 = 0;
  // 箭头函数
  setInterval(() => this.s1++, 1000);
  // 普通函数
  setInterval(function () {
    this.s2++;
	console.log('this.s2:' + this.s2);
  }, 1000);
}

var timer = new Timer();

setTimeout(() => console.log('s1: ', timer.s1), 3100);
setTimeout(() => console.log('s2: ', timer.s2), 3100);

通过观察控制台,我们能发现在3.1秒后会输出,s1:3, s2:0,但控制台却一直在输出this.s2为多少的信息,能看到相应的值一直在增加,而且运行到3.1秒时s2: -7,这个原因是因为:

  • 箭头函数的this,指向定义时所在的作用域(即Timer函数)

  • 普通函数的this,指向运行时所在的作用域(全局作用域,这里是故意写成var定义变量,如果是let定义,s2输出的一直是NaN)

this指向的固定化,实际上是箭头函数根本没有自己的this,导致内部的this就是外部代码块的this,正是因为没有this,所以也就不难用作构造函数,同时不能使用call、apply、bind这些方法去改变this的指向

在上面的例子中,我们还看到了一个没有清除定时方法,造成对象一直在使用的情况,这时不管是查看控制台的输出信息,还是直接查看window对象下的s1值,我们看到这些值一直再更新。所以在使用定时函数时,一定要注意清除,不要造成这种一直使用内存的情况

// 当使用call方法时,this指向都会发生变化
// 此时的this指向都是{ age: 42}
function foo() {
  var id = 30;	
  console.log('foo id:' + this.id);
  setTimeout(() => {
    console.log('id:', this.id);
  }, 100);
}
foo.call({age: 42}); // foo id:undefined\n id: undefined

call和apply方法的就是在特定的作用域中调用函数,相当于重设函数体内this对象的值

function info () {
	console.log(this.user);
}
let user = 'rede';
info(); // undefined

在info定义时,this代表的是window对象,此时window上并没有user的指,所以undefind(我们对一个对象取一个不存在的属性,会报undefind)

function info () {
	console.log(this.user);
}
var user = 'rede';
info(); // rede

此时其实依然是调用定义时window对象上的属性user,不过因为var定义是直接定义到顶层对象上,所以在后面调用依然可以取到相应的指

info.call({user: 'tom'}); // tom 方法的意义是对象{user: 'tom'}调用info方法

// call方法可以接受参数
function info (name = '', age = 0) {
	console.log(this.user);
	if (name) {
		console.log(`name: ${name}`);
	}
	if (age) {
		console.log(`age: ${age}`);
	}
}
info.call({user: 'rede'}, 'tom', 23); // rede\n name: tom\n age: 23
info.apply({user: 'rede'}, ['tom', 23]); // 与call使用方法一致,区别在于只接收两个参数,第二个参数以数组接收多个参数

通过上面的例子,我们看出不管是通过new对象的方式还是使用call的方式调用,或者对方法的直接使用箭头函数的this总是能指向我们期望的指向,这避免了很多that self的写法,算是最早被广泛使用的ES6语法

箭头函数除了没有this, arguments、super、new.target也是不存在的,使用箭头函数时,这些值对应的也是最定义时的函数

箭头函数可以进行嵌套,这种嵌套能简化对多重函数的使用

const plus1 = a => a + 1;
const mult2 = a => a * 2;

mult2(plus1(5)); // 12

尾调用

尾调用(Tail Call)就是指某个函数的最后一步是调用另一个函数

function f(x){
  return g(x);
}
// 相当于直接调用g(x)
...
// 下面不是直接返还函数的都不属于尾调用
function f(x){
  let y = g(x);
  return y;
}

function f(x){
  return g(x) + 1;
}

function f(x){
  g(x);
}

函数调用会在内存形成一个“调用记录”,又称“调用帧”

function fA() {
	console.log('fA1');
	fB()
	console.log('fA2')
}
function fB() {
	console.log('fB1');
	fC()
	console.log('fB2');
}
function fC() {
	console.log('fC');
}
// 比如上面三层代码,fA 调用 fB, fB 调用 fC(我理解这就是一个调用记录)
// 控制台最终输出为fA1\nfB1\nfC\nfB2\nfA2

我们可以想象在内存中运行到fB时一定还存在对fA的调用,运行到fC时一定还存在对fB、fA的调用如果这种调用非常多,必然会造成内存使用过大的情况,这种问题最容易出现的是在递归调用中,记为复杂度O(3)

// 下面是使用递归实现斐波那契数列
function Fibonacci (n) {
  if ( n <= 1 ) {return 1};

  return Fibonacci(n - 1) + Fibonacci(n - 2);
}

Fibonacci(10) // 89 别输入三位以上的数字,浏览器会卡死

定义函数时使用"尾递归优化",保证函数调用只保留内层函数的调用帧

function f() {
  let m = 1;
  let n = 2;
  return g(m + n);
}
f();
// 针对上面的函数,我们可以这么优化
function f() {
  return g(3);
}
// 保证了在执行g()函数时,无须考虑外部变量,上一个方法就是还需要考虑m + n的值,这里直接传3
// 使用尾递归优化,我们可以把Fibonacci方法改写,让其可以计算很大的数值
function Fibonacci2 (n , ac1 = 1 , ac2 = 1) {
  if( n <= 1 ) {return ac2};

  return Fibonacci2 (n - 1, ac2, ac1 + ac2);
}
Fibonacci2(100); // 573147844013817200000 能计算出结果,并且速度明显比上面的方法快

参数以3为例,执行过程是

Fibonacci2(3) -> Fibonacci2(2, 1, 2) -> Fibonacci2(1, 2, 3) -> 3

整个过程其实是把之前要通过Fibonacci(n - 1) + Fibonacci(n - 2)计算的形式,使用 (n - 1, ac2, ac1 + ac2)的形式,提前计算了,保证内部函数在运行时并不依赖外部变量,每一次的调用似乎都是单独调用(Fibonacci2(2, 1, 2),Fibonacci2(1, 2, 3)) 所以尾调用优化的重点就是要给调用函数确切的参数值(内部变量改写成函数的参数),确保最后一步只调用自身

尾调用优化,是函数式语言的语言规格,使用了这种方式能避免栈溢出,相对节省内存。针对这个问题,我想了想估计之前在IE低版本浏览器报错,或者页面假死都和这个操作有关

其他递归函数优化的例子

// 这是一个阶乘函数普通写法5! = 120;
function factorial(n) {
  if (n === 1) return 1;
  return n * factorial(n - 1);
}

factorial(5) // 120
...
// 使用尾递归优化
function factorial(n, total) {
  if (n === 1) return total;
  return factorial(n - 1, n * total); // n * factorial(n - 1)改写成n * total
}

factorial(5, 1); // 120
...
// 可以使用函数的默认值来优化上面的写法
function factorial(n, total = 1) {
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}

factorial(5); // 这种会比上面多传一个1看起来更好理解

以V8引擎解析代码为例,我们可以这么分析递归函数以及尾递归优化的实现:

我们假定函数调用后如果不消亡,依然存在内存中,是因为存放在累加器中,如果递归调用太深,就会造成累加器存放数据过多,造成内存溢出,但是使用了尾递归优化的方法,函数每次在累加器中只存上一步结果在寄存器计算本次运算,这样可以做到比较小的内存调用,避免占用很大的内存(个人理解)

函数的其他特点

  • 函数参数可指定默认值
function add (x = 0, y = 0) {
	console.log(x + y);
}
add(); // 0 x:0, y:0
add(1,3); // 4 x:1, y:3

// 参数变量是默认声明的,所以不能用let或const再次声明
function info (x) {
	let x = x; // 报错
	console.log(x);
}
info('x');

// 参数默认值是惰性求值,如果默认值涉及变量,每次使用都会重新计算
let x = 99;
function foo(p = x + 1) {
  console.log(p);
}
foo(); // 100 默认值是和变量x相关,每次使用都会重新计算                    
x = 100;
foo(); // 101

// 如果参数部分有默认值,部分没有,最好把设定默认值的参数放在尾部
// 比如下面的方法是计算任意数值与1的和
function add (x = 1, y) {
	console.log(x + y);
}
add(1); // NaN 此时y没有赋值,是undefined
add(undefined, 2); // 3
add(1, 2); // 3 这两种写法都体现不出参数默认值的优势,最好改写为如下逻辑
...
function add (x, y = 1) {
	console.log(x + y);
}
add(1); // 2
  • 函数的参数如果是原始数据类型,采用的是传值传递,在函数内部修改参数值并不会影响外部参数值,但是如果传的参数是复合数据类型,因为采用的传址传递,修改参数值则会影响外部参数值
// 原始数据类型
let p = 2;

function f(p) {
  p = 3;
}
f(p);

p; // 2

// 复合数据类型
var obj = { p: 1 };

function f(o) {
  o.p = 2;
}
f(obj);

obj.p

因为上面的特性,在一些前端规范中会要求禁止修改函数的参数值(airbnb)

  • arguments是可以获取函数参数,在前端规范中建议禁止使用这个参数,使用rest参数代替(airbnb)
  • rest参数(形式为...变量名),用于获取函数的多余参数,该变量是将多余的参数放入数组中
// 使用了rest参数会节省很多代码,比如下面定义一个不管传入多少参数都加到一起并返回总和方法
function add (...args) {
	let count = 0;
	args.forEach((item) => {
		count += item;
	})
	return count;
}

add(1); // 1
add(1, 2); // 3
add(1, 2, 3); // 6
add(1, 2, 3, 4); // 10

// rest参数代表的是剩余参数,所以这个值之后是不允许再跟参数,否则会报错
function add (...args, info) {
	let count = 0;
	args.forEach((item) => {
		count += item;
	})
	return count;
} // 直接报错: Rest parameter must be last formal parameter
  • function这个关键字即可以当作语句,也可以当作表达式