基本概念
函数
函数是一段可以反复调用的代码块。函数还能接收输入的参数,不同的参数会返回不同的值
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这个关键字即可以当作语句,也可以当作表达式