前言
总结函数定义和调用的位置不同带来的差异、理解递归和掌握基本使用、探究闭包产生的原理和了解内存泄漏的概念。其他相关笔记:
- JavaScript 基础(超详细)
- js 中的引用类型(内置对象)
- js 中的对象属性——configurable、writable 等(数据属性和访问器属性)
- js 中原型、原型链和继承概念(详细全面)
- 函数使用进阶——递归——闭包
1. 函数定义和调用的位置总结
总结递归和闭包之前,我先对函数定义的位置和调用的位置的各种不同情况做了一个总结。各种情形的主要特点如下:
- 普通函数的使用方式。
- 回调函数使用方式,常结合匿名函数用于操作异步API的处理结果。
- 这种情况主要特点是会产生闭包,有关闭包下面详细介绍。
- 将返回的闭包函数作为全局变量当然也就可以在其他函数内部调用。
- 这种情况虽然也会产生闭包,但是闭包的作用范围局限于包含函数内部。包含函数执行完毕,闭包函数引用销毁。
- 这种函数也称递归函数,使用时注意退出条件,防止出现死循环。
// 1、函数在全局作用域定义并调用
function outer1(x) {
console.log(x);
};
outer1(1);
// 2、在函数内部调用另一个外部定义的函数
function outer2(callback) {
let a = 2;
callback && callback(a);
}
// 这种方式常用于操作异步API处理结果
outer2(outer1);
function outer3() {
let a = 3;
// 直接调用跟上面的调用没有本质区别,不易维护
outer1(a);
}
outer3();
// 3、函数内部定义的函数在函数外部调用(产生闭包)
function outer4() {
let a = 4;
// 这里内部函数有没有传参均一样,因为只是定义没有调用
return function insider() {
console.log(a);
};
};
// 如果只是定义没有返回则只是私有方法,外部无法访问
const insider1 = outer4();
insider1();
// 4、内部定义的函数返回后也可以在其他函数内部使用,形同 2
// 5、内部定义的函数在函数内部自己调用(也会产生闭包)
function outer5() {
let a = 5;
// 这里内部函数有没有传参均一样,因为只是定义没有调用
function insider2() {
console.log(a);
};
insider2();
};
// 这里产生的闭包会在outer5执行结束后销毁
outer5();
// 6、外部的函数在自己内部调用自己
function outer6(num) {
return num == 1 ? num : num * outer6(num - 1);
}
console.log(outer6(4));
2. 递归
递归:在函数定义中调用函数自身的方式就是递归。以定义一个计算 n 的阶乘的函数为例。n! 的定义是在 n 等于 0 或者 1 时,n! 值为 1;n 的值为其他值时 n! = n x (n - 1)! 。
function fn(num) {
if (num == 1 || num == 0) {
return 1;
} else {
return num * fn(num - 1)
};
};
console.log(fn(4));
执行过程如下图所示:
递归的两个关键特性:
- 链条:计算过程中存在规律而有序的递归链条,例如 n 的阶乘等于 n 与 n - 1 的阶乘的乘积,那么 n 与 n - 1 的阶乘就构成了递归链条。
- 基例:基础的实例,存在一个或多个不需要再次递归的基例,他们是结束递归的基础。例如 n 为 0 或者 n 为 1 时,阶乘的值为确切的值 1 ,不再与其他值之间存在递归关系。
形成递归上述两个特性缺一不可,但是在实现递归函数时会出现以下细节问题。
// 1、递归求阶乘
function fn(num) {
return num == 1 ? num : num * fn(num - 1);
};
console.log(fn(4));
var factorial = fn;
console.log(factorial(4));
// 删除fn指向递归函数的指针
fn = null;
// 由于在内层调用时找不到fn函数而报错
console.log(factorial(4)) // ->error
// 2、在非严格模式下可以使用arguments.callee代替递归函数本身
// 'use strict'
function fn(num) {
// arguments.callee指向当前执行的函数
return num == 1 ? num : num * arguments.callee(num - 1);
};
console.log(fn(4));
var factorial = fn;
console.log(factorial(4));
fn = null;
console.log(factorial(4))
// 3、严格模式下不能通过脚本访问callee,采用函数表达式的形式
var factorial = (function fn(num) {
return num == 1 ? num : num * fn(num - 1);
});
console.log(factorial(4));
fn = null;
console.log(factorial(4));
// 即使再次赋值给另一个变量,函数名fn依旧有效
var fn2 = factorial;
factorial = null;
console.log(fn2(4));
示例 3 好像比较难以理解,个人的理解是使用表达式声明函数时,将 fn 函数表达式的值赋值给了 factorial 变量。既然是表达式则需要返回其最终的值,因此需要返回函数 fn 的引用。而这还不算是最终的值,内层的 fn 也替换成了该引用值。所以无论外部变量名怎么改变,内层的引用值均不会改变。示例 2 则不同,它只是在全局作用域定义了一个名为 fn 的函数对象(只是个声明语句并不是表达式语句)。里面的 fn 依旧只是一个标识符并没有返回 fn 的引用。
以上是对递归的理解,递归除了计算阶乘外还有很多应用。例如求斐波那契数列和解决汉诺塔问题。
斐波那契数列:该数列的第 1 项和第 2 项的值为 1 ,其他项的值为前两项的和,求第 n 项的值。
var fact = function fn(n) {
return n == 1 || n == 2 ? 1 : fn(n - 1) + fn(n - 2);
};
console.log(fact(8)); // ->21 (1, 1, 2, 3, 5, 8, 13, 21)
汉诺塔问题:有 A B C 三根柱子,A 柱子从下往上按照大到小的顺序依次堆叠着 n 个圆盘。需求是每次移动一个圆盘,且在小圆盘上不能放大圆盘。最终将所有圆盘按照一样的顺序摆放在另一根柱子上。
var hanoi = function fn(disc, src, aux, dst) {
if (disc > 0) {
fn(disc - 1, src, dst, aux);
console.log(disc + ':' + src + '-->' + dst);
fn(disc - 1, aux, src, dst)
}
}
hanoi(3, 'A', 'B', 'C')
3. 闭包
3.1 闭包概述
闭包是指有权访问另一个函数作用域中变量的函数。例如上面的函数的定义和调用位置总结中的一个简单示例:
function outer4() {
let a = 4;
return function insider() {
let b = 'insider';
console.log(a);
};
};
// 如果只是定义没有返回则只是私有方法,外部无法访问
const insider1 = outer4();
insider1();
上述代码中的 insider() 函数访问量外部函数 outer4() 中的变量 a 。即使这个内部函数被返回了,并且不是在 outer() 的内部调用,但是它依旧可以访问变量 a 。彻底搞清楚闭包之前必须要理解函数创建和调用时具体会发生什么。
3.2 闭包产生原理
创建函数对象的同时会为其创建一个内部的 [[Scopes]] 属性。这个属性保存的是定义该函数时,执行环境中可直接访问的作用域链。注意此时只是函数定义并未执行所以该属性指向的作用域链前端并不是该函数的变量对象指针。这里必须明确一个概念:作用域链本质上是一个指向变量对象的指针列表,它只引用但不实际包含变量对象。如下图所示:
当某个函数被调用时,会创建一个执行环境及相应的作用域链。然后使用 arguments 和其他命名参数的值来初始化函数的活动对象。构建作用域链时通过(浅)复制函数的 [[Scopes]] 属性中的对象并将为该函数创建的活动对象(变量对象)推入执行环境的作用域的前端。
如图是演示函数 outer4() 和 insider() 定义和调用过程中作用域链和各自的 [[Scopes]] 属性的关系和状态。
全局作用域下定义的所有函数的 [[Scopes]] 指针列表中只保存着一个指向 Window 对象的指针。但是在 outer4() 内部定义的函数 insider() 的 [[Scopes]] 指针列表中除了 Window 对象指针外还保存着指向其外层函数 outer4() 活动对象的指针。outer() 执行完毕,退出函数执行环境后将 insider() 返回到全局作用域中(无回收该函数的内存空间,即无法销毁返回的函数,它已经作为一个全局变量存在)。那么 insider() 的 [[Scopes]] 列表属性中第一个指针(此时是第一个,Window 是第二个)依旧保存着 outer4() 活动对象引用,所以此时这个活动对象也伴随着 insider() 一直保存在内存中。当调用该函数时则会利用 [[Scopes]] 属性根据上述方式构建作用域链,所以也就可以访问 outer() 内部的变量 a ,这就是闭包产生的原理。
每次执行 outer() 函数返回的 insider() 函数都不是同一个函数对象,所以他们的 [[Scopes]] 属性第一个指针指向的也是不同的对象(稳妥构造函数的原理)。因此形成的作用域链中的变量的值也就互不影响。也要注意的是闭包只能取得包含函数中任何变量的最终值(外层函数结束后的值)。
function outer4() {
let a = 'insider' + arguments[0];
console.dir(outer4);
function insider() {
console.log(a);
};
return insider;
};
// 返回两个不同的 insider 函数
var insider1 = outer4(1);
var insider2 = outer4(2);
insider1(); // ->'insider1'
insider2(); // ->'insider2'
3.3 简单应用
闭包主要有延申作用域的作用,在这里就举两个简单的应用。其他的应用场景以后有遇到再补上。
<body>
<ul class="nav">
<li>第 0 个小li</li>
<li>第 1 个小li</li>
<li>第 2 个小li</li>
<li>第 3 个小li</li>
</ul>
<script>
var lis = document.querySelectorAll('li')
// 错误示范:i此时是全局变量,点击事件外异步任务。点击时i的值已经为4
for (var i = 0; i < lis.length; i++) {
// 绑定点击事件输出i
lis[i].onclick = function() {
console.log('点击元素li的索引:' + i);
}
};
// 解决方案: 利用闭包的方式得到当前小li 的索引号
for (var i = 0; i < lis.length; i++) {
// 利用for循环创建了4个立即执行函数
// 立即执行函数也成为小闭包因为立即执行函数里面的任何一个函数都可以使用它的i这变量
(function(i) {
// console.log(i);
lis[i].onclick = function() {
console.log(i);
}
})(i);
}
// 另一个应用:3秒钟之后,打印所有li元素的内容
for (var i = 0; i < lis.length; i++) {
(function(i) {
setTimeout(function() {
console.log(lis[i].innerHTML);
}, 3000)
})(i);
}
</script>
</body>
当然上述方式不是解决问题的唯一方案,例如可以绑定点击事件前给 li 添加自定义属性 index ,或者直接利用 ES6 中 let 关键字声明块级作用域变量(上面的立即执行函数其实就有模仿块级作用域的作用)等等。
3.4 内存泄漏问题
内存泄漏是指程序在不再需要占用(使用)某一块内存的时候,由于某些原因,这块内存没有返回给操作系统或者空闲内存池的现象。造成内存泄漏的情况有很多,在此只记录由闭包导致导致的内存泄漏。例如事件处理回调,导致 DOM 对象和脚本中对象双向引用,这个是常见的泄漏原因。
function fn1() {
var submitBtn = document.getElementById('submitBtn');
submitBtn.onclick = function() {
console.log(submitBtn.id);
};
}
fn1();
// 改进
function fn2() {
var submitBtn = document.getElementById('submitBtn');
var id = submitBtn.id;
submitBtn.onclick = function() {
console.log(id);
};
submitBtn = null;
}
fn2();
上示例中均会形成闭包,第一个闭包里面包含了一个 DOM 对象 submitBtn ,但是点击事件的回调函数访问的只是该对象的一个 id 属性。所以为了防止内存泄漏,fn2() 声明一个 id 变量保存该对象的 id 属性。当然也必须最后将 submitBtn 变量设置为 null ,这样手动解除对 DOM 对象的引用,顺利减少其引用数,确保正常回收其占用的内存。