首页 阅读DAY14 JavaScript高级程序设计 10章下 函数
文章
取消

阅读DAY14 JavaScript高级程序设计 10章下 函数

开始阅读JavaScript高级程序设计(第5版)学习JS,总共有1000+页,非常全面,短期看完不太现实,找到了一篇博客,花些时间跟着这篇博客过一下红宝书。

JavaScript高级程序设计(第5版)微信读书

红宝书《JavaScript高级程序设计(第5版)》学习大纲 - 大前端全栈开发 - SegmentFault 思否

尾调用优化:

JavaScript引擎会在满足条件时重用栈帧以优化内存管理。具体来说,这项优化非常适合尾调用,即外部函数的返回值是一个内部函数的返回值。

尾调用:外部函数的返回值就是内部函数的返回值,因为函数的最后一件事是调用另一个函数,自己就可以收工了,所以自己的栈帧能提前被优化掉:

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

无优化时:每次嵌套调用都会新增一个栈帧,可能层层堆叠:

1
2
3
4
5
outerFunction 栈帧入栈
 → innerFunction 栈帧入栈
 → 计算 innerFunction 返回值
 → 传回 outerFunction
→ outerFunction 栈帧出栈

优化后:引擎发现外部函数的返回值就是内部函数的返回值,外部栈帧可以直接弹出复用:

1
2
3
4
5
outerFunction 栈帧入栈
 → 发现尾调用,弹出 outerFunction 栈帧
 → innerFunction 栈帧入栈
 → 计算 innerFunction 返回值
 → innerFunction 栈帧出栈

关键区别在于无论嵌套多少次尾调用,栈上始终只有一个栈帧,不会溢出。

无法检测尾调用优化是否生效,但现代浏览器在代码满足条件时会自动应用。

尾调用优化的条件:

必须全部满足以下条件才适用尾调用优化:

  • 严格模式执行
  • 外部函数的返回值是对尾调用函数的调用
  • 尾调用返回后不需要执行额外逻辑
  • 尾调用函数不是引用外部函数作用域自由变量的闭包

不符合条件的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
"use strict";

// 无优化:尾调用没有返回
function outerFunction() {
  innerFunction();
}

// 无优化:尾调用没有直接返回
function outerFunction() {
  let innerFunctionResult = innerFunction();
  return innerFunctionResult;
}

// 无优化:返回后还有额外操作(.toString())
function outerFunction() {
  return innerFunction().toString();
}

// 无优化:尾调用是闭包,引用了外部变量
function outerFunction() {
  let foo = 'bar';
  function innerFunction() { return foo; }
  return innerFunction();
}

符合条件的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
"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();
}

递归尾调用和非递归尾调用都能优化,引擎不区分调用的是自己还是别的函数。递归场景效果最明显,因为递归最容易产生大量栈帧。

要求严格模式的原因:非严格模式下允许使用 f.argumentsf.caller,它们会引用外部函数的栈帧,导致栈帧不能被销毁。

尾调用优化的代码:

原始斐波那契递归不符合尾调用优化,因为返回值有相加操作:

1
2
3
4
5
function fib(n) {
  if (n < 2) return n;
  return fib(n - 1) + fib(n - 2); // 返回后还要相加,不是尾调用
}
// 栈帧复杂度 O(2ⁿ),fib(1000) 直接爆栈

结果,fib(n)的栈帧数的内存复杂度是O(2^n),因此,即使这么一个简单的调用也可以给浏览器带来麻烦。

重构思路:用两个嵌套函数,外层做框架,内层做递归,把中间结果作为参数往下传,避免返回后的额外计算:

1
2
3
4
5
6
7
8
9
10
11
"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); // 尾调用:返回后无额外操作
}
// 栈帧始终只有 1 个,fib(1000) 不会爆栈

核心转变:把 fib(n-1) + fib(n-2) 的返回后计算,变成 fibImpl(b, a+b, n-1) 的参数计算。计算发生在调用前,调用后无需额外操作,满足尾调用优化条件。

这样重构之后,就可以满足尾调用优化的所有条件,再调用fib(1000)就不会对浏览器造成威胁了。

闭包:

闭包是引用了另一个函数作用域中变量的函数,通常在嵌套函数中实现:

1
2
3
4
5
6
7
8
9
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;
  };
}

内部函数被返回后仍能访问 propertyName,因为它的作用域链包含了外部函数的作用域。

理解这点,需要先搞清楚作用域链的创建和使用。

作用域链的创建过程

  1. 定义时:为函数创建作用域链,预装载外部变量对象,保存在内部属性 [[Scope]]
  2. 调用时:创建执行上下文,复制 [[Scope]] 生成作用域链,再创建活动对象推入链前端

以全局上下文中调用 compare() 为例:

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

这里定义的compare()函数是在全局上下文中调用的。第一次调用compare()时,会为它创建一个包含arguments、value1和value2的活动对象,这个对象是其作用域链上的第一个对象。而全局上下文的变量对象则是compare()作用域链上的第二个对象,其中包含this、result和compare。下图展示了相关关系:

image-20260508165008581

作用域链结构(从内到外):

1
2
[0] 活动对象(arguments、value1、value2) ← compare() 局部
[1] 全局变量对象(this、result、compare) ← 全局上下文

函数执行时,每个执行上下文中都会有一个包含其中变量的对象。

全局上下文中的叫变量对象,它会在代码执行期间始终存在

函数局部上下文中的叫活动对象,只在函数执行期间存在。在定义compare()函数时,就会为它创建作用域链,预装载全局变量对象,并保存在内部的 [[Scope]] 中。在调用这个函数时,会创建相应的执行上下文,然后通过复制函数的 [[Scope]] 来创建其作用域链。接着会创建函数的活动对象(用作变量对象)并将其推入作用域链的前端。

在这个例子中,这意味着compare()函数执行上下文的作用域链中有两个变量对象:局部变量对象和全局变量对象。作用域链其实是一个包含指针的列表,每个指针分别指向一个变量对象,物理上并不会包含相应的对象。

两个术语区分:

  • 变量对象:全局上下文中的叫法,始终存在
  • 活动对象:函数局部上下文中的叫法,只在执行期间存在,本质也是变量对象

函数内部的代码在访问变量时,就会使用给定的名称从作用域链中查找变量。函数执行完毕后,局部活动对象会被销毁,内存中就只剩下全局作用域。不过,闭包就不一样了。

闭包的作用域链:

现在沿用本节开头的闭包例子:

1
2
3
4
5
6
7
8
9
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;
  };
}

在一个函数内部定义的函数会把其包含函数的活动对象添加到自己的作用域链中。

因此,在createComparisonFunction()函数中,匿名函数的作用域链中实际上包含createComparisonFunction()的活动对象。下图展示了以下代码执行后的结果。

1
2
let compare = createComparisonFunction('name');
let result = compare({ name: 'Alice' }, { name: 'Matt' });

在createComparisonFunction()返回匿名函数后,它的作用域链被初始化为包含createComparisonFunction()的活动对象和全局变量对象。这样,匿名函数就可以访问到createComparisonFunction()可以访问的所有变量

另一个有意思的副作用就是,createComparisonFunction()的活动对象并不能在它执行完毕后销毁,因为匿名函数的作用域链中仍然有对它的引用。

在createComparisonFunction()执行完毕后,其执行上下文的作用域链会销毁,但它的活动对象仍然会保留在内存中,直到匿名函数被销毁后才会被销毁

1
2
3
4
5
6
7
8
// 创建比较函数
let compareNames = createComparisonFunction('name');

// 调用函数
let result = compareNames({ name: 'Alice' }, { name: 'Matt' });

// 解除对函数的引用,这样就可以释放内存了
compareNames = null;

这里,创建的比较函数被保存在变量compareNames中。把compareNames设置为等于null会解除对函数的引用,从而让垃圾回收程序可以将内存释放掉。作用域链也会被销毁,其他作用域(除全局作用域之外)也可以销毁。下图展示了调用compareNames()之后作用域链之间的关系。

image-20260508170303251

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

关于作用域链的补充:

匿名函数的作用域链会继承上一层的作用域链,即使上一层函数的全局变量对象看似和匿名函数已经没有了直接关系:作用域链是一层层传递的,每定义一个函数就复制外层的整条链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
全局上下文
 作用域链: [全局变量对象]

createComparisonFunction() 定义时
 [[Scope]] = [全局变量对象] ← 复制全局的链

createComparisonFunction() 调用时
 作用域链: [自己的活动对象, 全局变量对象] ← 前插自己的活动对象

匿名函数定义时(在 ccf 内部)
 [[Scope]] = [ccf的活动对象, 全局变量对象] ← 复制 ccf 的整条链

匿名函数调用时
 作用域链: [自己的活动对象, ccf的活动对象, 全局变量对象] ← 前插自己的活动对象

每一步都是复制上层的整条链,再把自己的插到最前面

this对象:

匿名函数的 this 在运行时绑定到执行上下文,不会继承外部函数的 this。内部函数永远不可能直接访问外部函数的 thisarguments,它们是每个函数调用时自动创建的特殊变量,作用域链上找不到外层的:

1
2
3
4
5
6
7
8
9
10
window.identity = 'The Window';
let object = {
  identity: 'My Object',
  getIdentityFunc() {
    return function() {
      return this.identity; // this 指向 window,不是 object
    };
  }
};
console.log(object.getIdentityFunc()()); // 'The Window'

作用域链里是有外部的 arguments的,但问题在于遮蔽。

每个函数被调用时,自己的活动对象里也会创建自己的 arguments(正如前文所言,这个arguments会被插入在最前面)。作用域链查找是从 [0] 开始找到即停:

1
2
3
4
匿名函数的作用域链
 [0] 自己的活动对象 → arguments(自己的)← 找到了,停
 [1] 外部函数的活动对象 → arguments(外部的)← 找不到这里
 [2] 全局变量对象

自己的 arguments 先被找到,就会把外部的遮住了。

this 根本不在活动对象里,不参与作用域链查找。每个函数的 this 由调用方式单独决定。

解决方案:把外部函数的 this 保存到闭包可以访问的变量中:

1
2
3
4
5
6
7
getIdentityFunc() {
  let that = this; // 保存外部函数的 this
  return function() {
    return that.identity; // 通过 that 访问,闭包能找到这个变量
  };
}
console.log(object.getIdentityFunc()()); // 'My Object'

arguments 同理,内部函数也无法直接访问外部的 arguments,需要先保存到变量。

特殊调用方式影响 this

1
2
3
4
5
6
7
8
9
10
11
12
let object = {
  identity: 'My Object',
  getIdentity() { return this.identity; }
};

object.getIdentity(); // 'My Object',立刻执行
(object.getIdentity)();
// 'My Object',还是立刻执行,按照规范,object.getIdentity和 (object.getIdentity)是相等的
// 在调用时把object.getIdentity放在了括号里。虽然加了括号之后看起来是对一个函数的引用,但this值并没有变
(object.getIdentity = object.getIdentity)();
// 'The Window',object.getIdentity只是取出属性后赋值
// 赋值表达式的值此时是函数本身,this 不再绑定对象

第三行:赋值表达式的值是函数本身,脱离了对象绑定,this 变成 window

JavaScript 确定 this 只看调用那一刻的语法形式(即调用位置有没有 对象. 前缀),不追踪函数从哪来。

第三行的运行逻辑:

第一步:执行赋值表达式 object.getIdentity = object.getIdentity

  • 右边 object.getIdentity 读取属性,得到函数对象本身
  • 左边赋值回去(实际上没变,赋不赋值无所谓)
  • 整个赋值表达式的值 = 右边的值 = 函数对象本身

第二步:对表达式的值调用 ()

  • 引擎看到的语法是:(一个函数值)()
  • 没有 对象. 前缀 → 独立调用 → this = window

实际上第三行等价于:

1
2
let fn = object.getIdentity; // 把函数取出来,装进变量
fn(); // 调用 fn,没有 "object.",于是 this = window

如果说就想让这个函数在赋值时绑定着object对象,即this不会改变,那么就到了bind()作用场景了,或者用箭头函数也可以,逻辑会如下:

1
2
3
4
5
6
7
8
9
10
let fn = object.getIdentity; // 取出裸函数,this 没了
let fn = object.getIdentity.bind(object); // 取出函数,并把 this 锁死到 object

// bind 硬绑定
let fn = object.getIdentity.bind(object);
fn(); // 'My Object',this 被绑死了,怎么调用都不变

// 箭头函数包裹
let fn = () => object.getIdentity();
fn(); // 'My Object',箭头函数里调用时仍有 object. 前缀

内存泄漏:

在使用不当的情况下,闭包会导致内存泄漏。如果程序持续分配内存但又不释放内存,就会发生内存泄漏。内存泄漏会导致程序运行变慢,甚至崩溃。函数闭包之所以会导致内存泄漏,是因为闭包允许变量超出它们预期的生命周期而存在。下面看一个函数闭包导致内存泄漏的例子:

1
2
3
4
5
6
7
8
9
10
11
function createArrayAppender() {
  const arr = [];
  return function appendTo(num) {
    arr.push(num);
  };
}

const appendToLargeArray = createArrayAppender();
for (let i = 0; i < 1e8; i++) {
  appendToLargeArray(i);
}

在这个例子中,createArrayAppender函数返回一个闭包,闭包引用了父作用域中的变量arr。每次调用appendTo函数,都会向数组中推入一个数值。

这段代码的问题在于变量arr永远不会从内存中释放,即使闭包外面不再需要它。由于闭包维持着对这个数组的引用,即使在createArrayAppender函数执行之后,数组也不会被当作垃圾回收。

于是,当我们在循环中调用appendToLargeArray时,就会不断向同一个数组中推入数值,导致数组在内存中越来越大。经测试,单单是在网页中运行这段代码,内存占用就达到令人瞠目的1087 MB。

要解决这个问题,可以重构上面的代码,允许垃圾回收程序在代码执行完成后释放数组占用的内存。

1
2
3
4
5
6
7
8
function appendToArray(arr, num) {
  arr.push(num);
}

const largeArray = [];
for (let i = 0; i < 1e8; i++) {
  appendToArray(largeArray, i);
}

立即调用的函数表达式:

IIFE(Immediately Invoked Function Expression),也叫自执行匿名函数。用括号包裹函数声明使其被解释为函数表达式,紧跟的第二组括号立即调用:

1
2
3
(function() {
 // 代码
})();

用途 1:创建私有作用域,避免命名冲突

1
2
3
(function($) {
 // $ 始终指向传入的 jQuery,不受外部其他库的 $ 影响
})(jQuery);

如果你在写一个库或插件,那可能想把自己的代码封装起来,避免与同一页面加载的其他库发生冲突。

用途 2:异步 IIFE,在非异步上下文中使用 async/await

1
2
3
4
5
(async function() {
  const data = await fetch('/api/data');
  const result = await data.json();
  // 对result执行某些操作
})();

另一个使用异步IIFE的场景是执行某些异步准备逻辑。比如,在应用开始渲染前,先从某个API获取一些数据并将其保存在局部变量中。再比如,我们想在一个非异步函数内部使用async/await语法。

本文由作者按照 CC BY 4.0 进行授权

阅读DAY13 JavaScript高级程序设计 10章上 函数

番外 this解析