再看JavaScript,那些遗漏或易混淆的知识点(3)

递归和堆栈

递归

递归,简单来说就是函数在 return 的时候调用了函数自身。比如这个例子:

1
2
3
function pow(x, n) {
return n === 1 ? x : x * pow(x, n - 1);
}

功效与 Math.pow() 相同。

比如计算 pow(2, 4) ,递归经过下面几个步骤:

  1. pow(2, 4) = 2 * pow(2, 3)
  2. pow(2, 3) = 2 * pow(2, 2)
  3. pow(2, 2) = 2 * pow(2, 1)
  4. pow(2, 1) = 2

最大的嵌套调用次数(包括首次)被称为 递归深度。在上面的那个例子中,正好是 n

在 JavaScript 引擎中,最大递归深度会被受限。引擎在最大迭代深度是 10000 及以下时是可靠的,有些引擎可能允许更大的最大深度,但是对于大多数引擎来说, 100000 可能就超出限制了。所以,有一种尾递归的调用方式诞生了,但是目前还没有被完全支持,只能用于简单场景。

那什么是尾递归呢?

尾递归

尾递归中也包含递归这个词语,所以还是离不开递归。那么尾递归与普通递归有什么不同呢?

尾递归函数在 return 的时候除了常量,只会 return 自身函数,而不会 return 涉及到自身函数与其他函数或者常量的计算。

同样是上面的例子,如果 pow 函数 return 的只是 pow(x, n - 1) 而不包含 x * ,那么这就是一个尾递归函数。

那么与普通递归有什么不同呢?

普通递归函数因为涉及到了计算,所以会等最后一个深度的函数执行完成在回过来执行上一个函数,然后依次释放执行过的函数的内存空间,所以会存在最大深度的问题。

但是如果是尾递归,因为每只都是调用函数本事,不存在计算,所以,之前的函数不会占用内存空间,因而没有最大递归深度的概念。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 递归
function pow1(x, n) {
return n === 1 ? x : x * pow1(x, n - 1);
}

console.log(pow1(2, 4));

// 尾递归
function pow2(x, n, total = 1) {
return n === 0 ? total : pow2(x, n - 1, x * total)
}

console.log(pow2(2, 4));

如果 n 的值足够大,你会发现还是会报出栈溢出错误,并没有进行尾调用优化。因为上面说了,目前浏览器还没有完全支持这个方法。不过虽然没有支持,但是这种方法的调用也比普通递归好上一点。因为尾递归把时间复杂度从 $o(n)$ 降低到了 $o(1)$。

如果想要了解哪些语言支持尾递归,可以自行上 Google Search。

执行上下文和堆栈

递归函数在调用的时候为什么会存在 栈溢出 的情况?就是因为递归函数在执行的时候都是先执行的都是没有被计算的,仅仅只是保留在执行上面文中,等待后面的计算完成在返回来计算之前的。例如上面的例子

1
2
3
4
function pow1(x, n) {
return n === 1 ? x : x * pow1(x, n - 1);
}
pow1(2, 4)

在执行的时候,这样堆栈:

  • Context: {x: 2, n: 4, at: line 1} 调用 pow(2, 4)

  • Context: {x: 2, n: 3, at: line 2} 调用 pow(2, 3)

  • Context: {x: 2, n: 2, at: line 2} 调用 pow(2, 2)

  • Context: {x: 2, n: 1, at: line 2} 调用 pow(2, 1)

    接着是 n === 1 的时候,找到了出口 return x 。开始出栈

new Function ???

这种语法很少用到,估计在有限的程序员生涯也不会用到这种语法吧。但是很有趣,所以这里写写。

创建函数的语法:

1
let func = new Function ([arg1, arg2, ...argN], functionBody);

最后一个函数是函数体,如果只有一个参数,那他就是函数体。

1
2
3
4
let sum = new Function('a', 'b', 'return a + b');
// 或者 let sum = new Function('a, b', 'return a + b');

console.log( sum(1, 2) ); // 3

只有一个参数(即函数体):

1
2
3
let sayHi = new Function('alert("Hello")');

sayHi(); // Hello

还以一个问题,使用这种当时创建的函数,只能访问全局变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function getFunc() {
let value = "test";
let value2 = "test2";

let func = new Function('console.log(value)');
let func2 = function() {console.log(value2)};

return {
func,
func2
};
}

getFunc().func2(); // test2
getFunc().func(); // error: value is not defined

调度:setTimeout 与 setInterval

这两个函数都不陌生,平时也都用的挺多的。尤其是在 requestAnimationFrame 不是特别友好的时候,常常用来写动画效果。

按照我之前的写法

1
2
3
4
5
function say() {
alert('hello');
}

setTimeout(say, 1000);

上面的代码在一秒中以后执行,没有丝毫问题。上面的函数是没有参数的情况,其实还有很多的时候所需要执行的函数是带有参数的。如果带有参数,我一般是这样做的:

1
2
3
4
5
6
7
function sayHi(phrase, who) {
alert( phrase + ', ' + who );
}

setTimeout(() => {
sayHi("Hello", "John")
}, 1000); // Hello, John

其实还有另一种写法,来看看 setTimeout 的语法吧:

1
2
3
let timerId = setTimeout(func|code, [delay], [arg1], [arg2], ...)

// setInterval 一样的

第一个参数可以是一段代码,但是不建议这样做了。第二哥参数是延时时间。以前我就以为只有两个参数。从上面的语法中可以看出,其实还有很多参数的。这些参数就是所需执行的函数需要的参数,所以上面的代码还可以这样:

1
2
3
4
5
function sayHi(phrase, who) {
alert( phrase + ', ' + who );
}

setTimeout(sayHi, 1000, "Hello", "John"); // Hello, John

这样看起来就好看多了。

第二点就是这两个函数的返回值,返回值是一个 timerID ,是一个 number 类型的值。

接下来就是对比以下两者的使用,用 setTimerout 模仿一个 setInterval

1
2
3
4
5
6
7
8
9
10
function func(s) {
console.log(s)
}

setInterval(func, 100, 'setInterval'); // line1

setTimeout(function run() { // line2
func('setTimeout')
setTimeout(run, 100);
}, 100);

同样都是一秒的延迟,但是每一次都是 setInterval 先打印出来。如果将 line1line2 调换顺序,除了第一次是 setInterval 后执行,后面都是先执行了。为什么呢?因为 func 的执行所花费的时间“消耗”了一部分间隔时间。用图来解释:

诶。这就很明显了嘛。

文章作者: 踏浪
文章链接: https://www.lyt007.cn/技术/再看JavaScript,那些遗漏或易混淆的知识点-3.html
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 平凡的生活,不平凡的人生
支付宝
微信打赏