扯皮
最近又把红宝书从零开始刷了一遍,发现很多知识点都有遗忘,之前阅读的过程中有不少用笔划线的内容现在都忘记了,因此这次准备把一些不太常见的知识点记录成文章方便以后回看。
正文
new Function 定义函数以及区分
针对于函数有三种定义方式,前两种是较为常用,最后一种很少在业务代码中使用:
- function 函数声明
- 函数表达式
- new Function
因为函数的本质也是对象,所以定义函数的方法也可以把它当作实例化对象的过程,前两种反而较为特殊,第三种更符合实例化对象的操作。
这里简单介绍一下 Function
构造函数的使用,我们通过 new Function 创建一个函数对象,创建时可以给构造函数传递参数:
参数列表的最后一项会作为函数体,而前面的几项都将作为函数的参数进行传递
javascript
const foo = new Function("a", "b", "console.log(a + b);");
foo(1, 2); // 3
可以看到创建的实例函数对象使用方式和普通函数没有任何区别,但一个函数的函数体往往不会那么简单,再看下面的例子:
javascript
const sum = new Function("a", "b", "console.log(a + b) return a + b;"); // ❌
这里的错误较为明显,实际上它相当于我们这样定义函数,而这种语法在 JS 中是不允许的:
javascript
function sum(a, b) {
console.log(a + b) return a + b; // ❌
}
因此我们需要这样做,这时候 new Function 用起来就比较鸡肋了:
javascript
// ; 分割语句
const sum = new Function("a", "b", "console.log(a + b); return a + b;");
// \n 语句换行
const sum = new Function("a", "b", "console.log(a + b)\n return a + b");
不管怎么样也是 JS 为我们提供定义函数的一种方式,但在红宝书上也有说明不推荐这种做法并给出了原因: new Function 定义函数的代码会被执行两次,第一次会把它当作常规的 ECMAScript 代码,第二次是解释传给构造函数的字符串,这显然会影响性能。
实际上与前两种定义方式不同,通过 new Function 的方式创建函数是可以区分出来的,我们知道函数的本质是对象,那么函数对象上面一定有一些相关的属性:
我们重点关注 name 属性,它是一个只读属性,值是函数名,我们关注这三种定义函数方式的 name 属性看看结果:
javascript
function test1() {
console.log("test1");
}
const test2 = function () {
console.log("test2");
};
const test3 = new Function("console.log('test3');");
console.log(test1.name); // test1
console.log(test2.name); // test2
console.log(function () {}.name); // (空字符串) 💡
console.log(test3.name); // anoymous 💡
注意这里的匿名函数以及通过 new Function 创建的函数,它们的 name 属性与正常的函数名都不同,且都是固定的,这也是区分函数不同类型的一种方式。
额外补充一个使用 bind 绑定 this 后的函数名,我们知道 bind 后会返回一个新的函数:
Javascript
function test() {
console.log(this);
}
const t = test.bind({ name: "abc" });
console.log(t.name); // bound test 💡
新的函数 name 属性是 bound 前缀加上原函数名
默认参数作用域问题
函数的默认参数我们并不陌生,当我们调用函数时不传入对应参数或者传入 undefined 会自动使用默认值进行初始化:
javascript
function foo(a = 1, b = 2) {
console.log(a, b);
}
foo(); // 1 2
foo(undefined, undefined); // 1 2
foo(undefined, 100); // 1 100
但参数的初始化是按照顺序的,因此就有下面的例子:
javascript
function foo(a = 1, b = a) {
console.log(a, b);
}
foo(); // 1 1
javascript
function foo(a = b, b = 2) {
console.log(a, b);
}
foo(); // ❌ 运行时错误:Cannot access 'b' before initialization
这个错误现象被称之为参数的暂时性死区,类似用 let 声明变量一样,我们不能在其声明之前使用。
函数的参数有自己的作用域,它不能访问函数体中的作用域,但是函数体中却能够访问到参数:
javascript
function foo(a = 1, b = c) {
let c = 100;
console.log(a, b, c);
}
foo(); // ❌ 运行时错误:c is not defined
arguments 与 函数参数的关系
我们知道 arguments 是一个类数组对象,它保存着函数的参数列表以及 callee 等属性,但当调用者不传入参数值时它是空的,并且即使设置了参数默认值也是空的,arguments 的对象长度是根据传入的参数个数,而非定义函数时给出的命名参数个数决定 :
javascript
function foo1(a, b) {
console.log(arguments[0], arguments[1], a, b); // undefined undefined undefined undefined
}
// 有默认值
function foo2(a = 1, b = 2) {
console.log(arguments[0], arguments[1], a, b); // undefined undefined 1 2
}
// 实参不传入第二个参数
function foo3(a, b) {
b = 1000;
console.log(arguments[1], b); // undefined 1000
}
foo1();
foo2();
foo3(100);
而 arguments 与形参存在一种同步关系,这种关系只是单向的。
当我们修改形参时,arguments 保存的参数列表也会跟着改变
当我们修改 arguments 中参数列表中的元素值时,形参值也会跟着变
javascript
// 修改形参
function foo1(a, b) {
console.log(arguments[0], arguments[1], a, b); // 1 2 1 2
a = 100;
b = 1000;
console.log(arguments[0], arguments[1], a, b); // 100 1000 100 1000
}
// 修改 arguments
function foo2(a, b) {
console.log(arguments[0], arguments[1], a, b); // 1 2 1 2
arguments[0] = 100;
arguments[1] = 1000;
console.log(arguments[0], arguments[1], a, b); // 100 1000 100 1000
}
foo1(1, 2);
foo2(1, 2);
虽然具有同步关系,但是两者访问的并不是同一个内存地址,它们在内存中还是分开的。
不过需要注意一点,一旦给参数添加默认值,即使没有使用默认值,也会导致这种同步关系直接消失:
javascript
function foo1(a = 3, b) {
console.log(arguments[0], arguments[1], a, b); // 1 2 1 2
a = 100;
b = 1000;
console.log(arguments[0], arguments[1], a, b); // 1 2 100 1000
}
function foo2(a = 3, b) {
console.log(arguments[0], arguments[1], a, b); // 1 2 1 2
arguments[0] = 100;
arguments[1] = 1000;
console.log(arguments[0], arguments[1], a, b); // 100 1000 1 2
}
foo1(1, 2);
foo2(1, 2);
这里补充一个小的插曲,在我的红宝书中针对于这种同步关系是这样描述的,注意看红色笔画线部分,描述的是同步关系是单向的,这显然与上面的代码例子结论相违背:
我又去网上找到了红宝书的电子版找到对应的位置,才发现这句话居然是没有的,这也太难绷了😂:
递归与尾调用优化
递归指的是一个函数调用自己,通常会设置一个出口来表示不断将函数压入调用栈的终点,比如最简单的计算阶乘:
javascript
function factorial(num) {
if (num === 1) return num;
else return num * factorial(num - 1);
}
console.log(factorial(5)); // 120
console.log(factorial(6)); // 720
但是可能会出现一种情况,我们使用函数表达式来定义递归函数,如果中途修改了函数引用,那这样写就会出现问题:
Javascript
let factorial = function (num) {
if (num === 1) return num;
else return num * factorial(num - 1);
};
const foo = factorial;
factorial = null;
console.log(foo(5)); // ❌:factorial is not a function
那么解决办法很简单,借助 arguments 中的 callee 属性来获取函数自身,无需通过函数名进行调用:
javascript
let factorial = function (num) {
if (num === 1) return num;
else return num * arguments.callee(num - 1); // 💡
};
const foo = factorial;
factorial = null;
不过一般情况下在业务编码阶段也很少对递归函数进行这样的操作,毕竟 arguments 也有限制。
ES6 中新增了内存管理优化机制,即重用栈帧,这种优化方式较适合尾调用,尾调用的定义很简单,就是一个函数的返回值是其一个内部函数的返回值:
javascript
function outer() {
return inner();
}
function inner() {
return "hello";
}
简单来讲函数的调用符合栈结构,当函数进行嵌套时会按照栈的特性进行压栈出栈操作
ES6 之前针对于上述的代码是这样的流程:
outer 进栈 -> 执行 -> inner 进栈 -> 执行 -> inner 出栈 -> outer 出栈
而 ES6 针对于内存管理进行了优化,它的流程是这样的:
outer 进栈 -> 执行(发现只有 return 并且还要去求出 inner 的返回值) -> outer 出栈 -> inner 进栈 -> 执行 -> 出栈
相当于在整个函数执行流程中 outer 提前出栈了,这里只展示了嵌套一层,假如是一个尾调用递归函数,那么将是极大的优化(执行递归时内存中将不会一直执行进栈,而是中间会有弹出操作,最终整个过程只有一个栈帧)
但是这种优化是有一定条件的,就是在 outer 弹出之前的一个判断,如何判断它符合尾调用的条件去走出栈的逻辑? 红宝书中给出了答案,必须满足下面的所有条件:
- 代码在严格模式下执行
- 外部函数的返回值是对尾调用函数的调用
- 尾调用函数返回后不需要执行额外的逻辑
- 尾调用函数不是引用外部函数作用域中自由变量的闭包
针对于这几个条件红宝书中也给出了不符合条件的例子:
javascript
// 尾调用没有返回
function outer() {
inner();
}
// 尾调用没有直接返回
function outer() {
let innerRes = inner();
return innerRes;
}
// inner 返回后还在 outer 中执行了 toString 逻辑
function outer() {
return inner().toString();
}
// 存在闭包
function outer() {
let foo = "test";
function inner() { return foo; }
return inner();
}
符合条件的例子:
javascript
"use strict";
// 👆开启严格模式
function outer(a, b) {
return inner(a + b);
}
function outer(a, b) {
if(a < b) return a;
return inner(a + b);
}
function outer(flag) {
return flag ? innerA() : innerB();
}
最后我们将递归与尾调用进行结合,以斐波那契为例先看递归的写法:
javascript
function fib(n) {
if (n < 2) return n;
return fib(n - 1) + fib(n - 2);
}
由于 fib 最后 return 时是进行两个 fib 函数执行结果进行相加,不符合尾调用优化的第三条。
我们可以额外补充一个函数,将具体的运算逻辑进行抽离,这样两个函数结合,符合尾调用优化的所有条件:
javascript
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);
}
最后我们来比较两者的耗时看看是不是真有优化效果:
javascript
function fib1(n) {
if (n < 2) return n;
return fib1(n - 1) + fib1(n - 2);
}
function fib2(n) {
return fibImpl(0, 1, n);
}
function fibImpl(a, b, n) {
if (n === 0) return a;
return fibImpl(b, a + b, n - 1);
}
console.time("test1");
console.log(fib1(40));
console.timeEnd("test1");
console.time("test2");
console.log(fib2(40));
console.timeEnd("test2");
显而易见,爆杀了家人们!😄