2025-02-05 日报 Day88

2025-02-05 日报 Day88

Yuyang 前端小白🥬

今日的鸡汤

无论听了多少忠告,你的人生最终还是要靠自己去创造,靠自己去领悟,靠自己去努力,靠自己去坚持。

今日学习内容

1、JS 红皮书 P304-311 第十章:函数

今日笔记

1、函数表达式: 定义函数有两种方式:函数声明和函数表达式。函数声明是这样的:

1
2
3
function functionName(arg0, arg1, arg2) { 
// 函数体
}

函数声明的关键特点是函数声明提升,即函数声明会在代码执行之前获得定义。这意味着函数声明可以出现在调用它的代码之后:

1
2
3
4
sayHi(); 
function sayHi() {
console.log("Hi!");
}

这个例子不会抛出错误,因为 JavaScript 引擎会先读取函数声明,然后再执行代码。
第二种创建函数的方式就是函数表达式。函数表达式有几种不同的形式,最常见的是这样的:

1
2
3
4
5
6
7
8
let functionName = function(arg0, arg1, arg2) { 
// 函数体
};

sayHi(); // Error! function doesn't exist yet
let sayHi = function() {
console.log("Hi!");
};

理解函数声明与函数表达式之间的区别,关键是理解提升。比如,以下代码的执行结果可能会出乎意料:

1
2
3
4
5
6
7
8
9
10
// 千万别这样做!
if (condition) {
function sayHi() {
console.log('Hi!');
}
} else {
function sayHi() {
console.log('Yo!');
}
}

不过,如果把上面的函数声明换成函数表达式就没问题了:

1
2
3
4
5
6
7
8
9
10
11
// 没问题 
let sayHi;
if (condition) {
sayHi = function() {
console.log("Hi!");
};
} else {
sayHi = function() {
console.log("Yo!");
};
}

2、递归: 递归函数通常的形式是一个函数通过名称调用自己,如下面的例子所示:

1
2
3
4
5
6
7
function factorial(num) { 
if (num <= 1) {
return 1;
} else {
return num * factorial(num - 1);
}
}

这是经典的递归阶乘函数。虽然这样写是可以的,但如果把这个函数赋值给其他变量,就会出问题:

1
2
3
let anotherFactorial = factorial; 
factorial = null;
console.log(anotherFactorial(4)); // 报错

在写递归函数时使用 arguments.callee 可以避免这个问题。
arguments.callee 就是一个指向正在执行的函数的指针,因此可以在函数内部递归调用,如下所示:

1
2
3
4
5
6
7
function factorial(num) { 
if (num <= 1) {
return 1;
} else {
return num * arguments.callee(num - 1);
}
}

不过,在严格模式下运行的代码是不能访问 arguments.callee 的,因为访问会出错。此时,可以使用命名函数表达式(named function expression)达到目的。比如:

1
2
3
4
5
6
7
const factorial = (function f(num) { 
if (num <= 1) {
return 1;
} else {
return num * f(num - 1);
}
});

3、尾调用优化: ECMAScript 6 规范新增了一项内存管理优化机制,让 JavaScript 引擎在满足条件时可以重用栈帧。具体来说,这项优化非常适合“尾调用”,即外部函数的返回值是一个内部函数的返回值。比如:

1
2
3
function outerFunction() { 
return innerFunction(); // 尾调用
}

在 ES6 优化之前,执行这个例子会在内存中发生如下操作。
(1) 执行到 outerFunction 函数体,第一个栈帧被推到栈上。
(2) 执行 outerFunction 函数体,到 return 语句。计算返回值必须先计算 innerFunction。
(3) 执行到 innerFunction 函数体,第二个栈帧被推到栈上。
(4) 执行 innerFunction 函数体,计算其返回值。
(5) 将返回值传回 outerFunction,然后 outerFunction 再返回值。
(6) 将栈帧弹出栈外。
在 ES6 优化之后,执行这个例子会在内存中发生如下操作。
(1) 执行到 outerFunction 函数体,第一个栈帧被推到栈上。
(2) 执行 outerFunction 函数体,到达 return 语句。为求值返回语句,必须先求值 innerFunction。
(3) 引擎发现把第一个栈帧弹出栈外也没问题,因为 innerFunction 的返回值也是 outerFunction的返回值。
(4) 弹出 outerFunction 的栈帧。
(5) 执行到 innerFunction 函数体,栈帧被推到栈上。
(6) 执行 innerFunction 函数体,计算其返回值。
(7) 将 innerFunction 的栈帧弹出栈外。
很明显,第一种情况下每多调用一次嵌套函数,就会多增加一个栈帧。而第二种情况下无论调用多少次嵌套函数,都只有一个栈帧。这就是 ES6 尾调用优化的关键:如果函数的逻辑允许基于尾调用将其销毁,则引擎就会那么做。
4、尾调用优化的条件: 尾调用优化的条件就是确定外部栈帧真的没有必要存在了。涉及的条件如下:
 代码在严格模式下执行;
 外部函数的返回值是对尾调用函数的调用;
 尾调用函数返回后不需要执行额外的逻辑;
 尾调用函数不是引用外部函数作用域中自由变量的闭包
下面展示了几个违反上述条件的函数,因此都不符号尾调用优化的要求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
"use strict"; 
// 无优化:尾调用没有返回
function outerFunction() {
innerFunction();
}
// 无优化:尾调用没有直接返回
function outerFunction() {
let innerFunctionResult = innerFunction();
return innerFunctionResult;
}
// 无优化:尾调用返回后必须转型为字符串
function outerFunction() {
return innerFunction().toString();
}
// 无优化:尾调用是一个闭包
function outerFunction() {
let foo = 'bar';
function innerFunction() { return foo; }
return innerFunction();
}
// 下面是几个符合尾调用优化条件的例子:
"use strict";
// 有优化:栈帧销毁前执行参数计算
function outerFunction(a, b) {
return innerFunction(a + b);
}
// 有优化:初始返回值不涉及栈帧
function outerFunction(a, b) {
if (a < b) {
return a;
}
return innerFunction(a + b);
}
// 有优化:两个内部函数都在尾部
function outerFunction(condition) {
return condition ? innerFunctionA() : innerFunctionB();
}

差异化尾调用和递归尾调用是容易让人混淆的地方。无论是递归尾调用还是非递归尾调用,都可以应用优化。引擎并不区分尾调用中调用的是函数自身还是其他函数。不过,这个优化在递归场景下的效果是最明显的,因为递归代码最容易在栈内存中迅速产生大量栈帧。
4、可以通过把简单的递归函数转换为待优化的代码来加深对尾调用优化的理解。下面是一个通过递归计算斐波纳契数列的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
function fib(n) { 
if (n < 2) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
console.log(fib(0)); // 0
console.log(fib(1)); // 1
console.log(fib(2)); // 1
console.log(fib(3)); // 2
console.log(fib(4)); // 3
console.log(fib(5)); // 5
console.log(fib(6)); // 8

当然,解决这个问题也有不同的策略,比如把递归改写成迭代循环形式。不过,也可以保持递归实现,但将其重构为满足优化条件的形式。为此可以使用两个嵌套的函数,外部函数作为基础框架,内部函数执行递归:

1
2
3
4
5
6
7
8
9
10
11
12
"use strict"; 
// 基础框架
function fib(n) {
return fibImpl(0, 1, n);
}
// 执行递归
function fibImpl(a, b, n) {
if (n === 0) {
return a;
}
return fibImpl(b, a + b, n - 1);
}

5、闭包: 匿名函数经常被人误认为是闭包(closure)。闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。比如,下面是之前展示的 createComparisonFunction()函数,注意其中加粗的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
function createComparisonFunction(propertyName) { 
return function(object1, object2) {
let value1 = object1[propertyName];
let value2 = object2[propertyName];
if (value1 < value2) {
return -1;
} else if (value1 > value2) {
return 1;
} else {
return 0;
}
};
}

理解作用域链创建和使用的细节对理解闭包非常重要。在调用一个函数时,会为这个函数调用创建一个执行上下文,并创建一个作用域链。然后用 arguments和其他命名参数来初始化这个函数的活动对象。外部函数的活动对象是内部函数作用域链上的第二个对象。这个作用域链一直向外串起了所有包含函数的活动对象,直到全局执行上下文才终止。
在函数执行时,要从作用域链中查找变量,以便读、写值。来看下面的代码:

1
2
3
4
5
6
7
8
9
10
function compare(value1, value2) { 
if (value1 < value2) {
return -1;
} else if (value1 > value2) {
return 1;
} else {
return 0;
}
}
let result = compare(5, 10);

注意 因为闭包会保留它们包含函数的作用域,所以比其他函数更占用内存。过度使用闭包可能导致内存过度占用,因此建议仅在十分必要时使用。V8 等优化的 JavaScript 引擎会努力回收被闭包困住的内存,不过我们还是建议在使用闭包时要谨慎。

此页目录
2025-02-05 日报 Day88