函数进阶

递归与堆栈

递归

一种编程模式,将一个任务可以自然地拆分成多个相同类型但更简单的任务。
简单来说就是将任务拆分为最基础情况和调用自身,通过不断调用自身到达最基础情况。

function pow(x, n) {
  if (n == 1) {
    return x;
  } else {
    return x * pow(x, n - 1);
  }
}

console.info( pow(2, 3) ); // 8

当执行时会被分为2个分支:

              if n==1  = x
             /
pow(x, n) =
             \
              else     = x * pow(x, n - 1)

递归会不断调用自身,直到到达最基础的递归,即上述的 if n==1

最大的嵌套调用次数(包括首次)被称为 递归深度

最大递归深度受限于 JavaScript 引擎。
引擎在最大迭代深度为 10000 及以下时是可靠的,有些引擎可能允许更大的最大深度,
但是对于大多数引擎来说,100000 可能就超出限制了。
有一些自动优化能够帮助减轻这种情况(尾部调用优化),但目前它们还没有被完全支持,
只能用于简单场景。

循环迭代思路:

function pow(x, n) {
  let result = 1;

  // 在循环中,用 x 乘以 result n 次
  for (let i = 0; i < n; i++) {
    result *= x;
  }

  return result;
}

console.info( pow(2, 3) ); // 8

执行上下文与上下文堆栈(Call Stack)

  1. 执行上下文(Execution Context)
    每个被调用的函数都会创建一个执行上下文,内部保存:

    • 当前局部变量
    • 所在代码行(恢复位置)
    • this 等运行时信息
  2. 上下文堆栈(Call Stack)
    JavaScript 引擎使用“栈”结构管理这些上下文:

    • 压栈:发生嵌套调用时,当前上下文暂停并压入栈,新建子上下文并置顶。
    • 出栈:子调用返回后,其上下文被弹出,原上下文恢复执行。
  3. 递归示例 pow(2, 3)
    调用链与栈变化(→ 表示栈顶):

    pow(2,3) → {x:2, n:3, line 5}
    

    计算 x * pow(2,2),压入新上下文:

    pow(2,2) → {x:2, n:2, line 5}
    pow(2,3) → {x:2, n:3, line 5}
    

    继续 pow(2,1)

    pow(2,1) → {x:2, n:1, line 1}  // n==1 命中返回 2
    pow(2,2) → {x:2, n:2, line 5}
    pow(2,3) → {x:2, n:3, line 5}
    

    pow(2,1) 返回后弹出,继续计算并逐层弹出,最终得到 8

  4. 内存与性能

    • 递归深度 = 栈最大长度;每次递归都占用一份上下文内存。
    • 循环版本只需一份上下文,内存恒定,通常更快
    • 任何递归都可改写成循环,但递归代码往往更短、可读性更高;在性能瓶颈不明显时优先选择可维护性。

递归遍历(Recursive Traversal)

1. 适用场景

当数据结构“部分复制自身”——即对象里又嵌套相同结构的对象/数组时,用循环写 for/while 会迅速出现 3-4 级嵌套,难以维护。递归把“复杂节点”自动拆成“同构子节点”,代码简洁且能应对任意深度。

2. 公司薪资汇总示例

function sumSalaries(dept) {
  if (Array.isArray(dept)) {                 // 叶子:员工数组
    return dept.reduce((s, p) => s + p.salary, 0);
  }
  // 分支:子部门对象 → 递归合并
  return Object.values(dept)
               .reduce((s, sub) => s + sumSalaries(sub), 0);
}

console.info(sumSalaries(company)); // 7700

调用过程形成一棵“递归树”:对象节点继续分叉,数组节点立即给出结果,最后层层汇总。

3. 递归结构(Recursive Data Structure)

定义里“包含自身”的结构,例如:

  • 公司部门 = 人员数组 | {子部门…}
  • HTML 元素 = 文本 | 注释 | 其他 HTML 元素…

遍历算法与结构定义一一对应,天然适合递归。


链表(Singly Linked List)

1. 节点定义

node = { value: any, next: node | null }

2. 优势

  • 插入/删除只需修改相邻节点的 nextO(1),不像数组需批量搬移元素。
  • 可动态拼接/拆分,无需重新分配连续内存。

3. 基本操作

let list = { value: 1, next: { value: 2, next: { value: 3, next: null } } };

// 头部插入
list = { value: "new", next: list };

// 删除第 2 个节点
list.next = list.next.next;

4. 劣势

  • 随机访问 O(n),必须从头顺 next 遍历;数组为 O(1)
  • 每个节点额外存储指针,内存开销略高。

5. 扩展

  • 双向链表:增加 prev 指针,支持倒序遍历与尾部删除。
  • 循环链表:尾节点指向头节点,用于轮询场景。
  • 尾指针 tail:在尾部插入/删除时可保持 O(1)

总结:链表在“频繁头尾插入/删除、很少随机下标访问”的场景(如队列、LRU 缓存)中优于数组;数组在随机读取与缓存局部性上更强。根据实际需求选择或组合使用。

Rest 与 Spread

Rest

即为不定长参数。

语法:...变量名

将会声明一个数组并指定其名称,其中存有剩余的参数

function showName(firstName, lastName, ...titles) {
  console.info( firstName + ' ' + lastName ); // Julius Caesar

  // 剩余的参数被放入 titles 数组中
  // i.e. titles = ["Consul", "Imperator"]
  console.info( titles[0] ); // Consul
  console.info( titles[1] ); // Imperator
  console.info( titles.length ); // 2
}

showName("Julius", "Caesar", "Consul", "Imperator");

注意:Rest 参数必须放到参数列表的末尾。

历史遗留:arguments

一个名为 arguments 的特殊类数组对象可以在函数中被访问,该对象以参数在参数列表中的索引作为键,存储所有参数。

过去不支持rest语法,因此JavaScript使用一个类数组对象arguments来存储参数。
但其只能保存全部参数,不能自动进行截断。

function showName() {
  console.info( arguments.length );
  console.info( arguments[0] );
  console.info( arguments[1] );

  // 它是可遍历的
  // for(let arg of arguments) console.info(arg);
}

// 依次显示:2,Julius,Caesar
showName("Julius", "Caesar");

// 依次显示:1,Ilya,undefined(没有第二个参数)
showName("Ilya");

注意:箭头函数没有arguments,其所能访问的为外部函数的arguments

Spread

作用是自动展开可迭代对象。

内部使用迭代器来收集元素,与for of一致。

let arr1 = [1, -2, 3, 4];
let arr2 = [8, 3, -8, 1];

console.info( Math.max(1, ...arr1, 2, ...arr2, 25) ); // 25

let str = "Hello";

console.info( [...str] ); // H,e,l,l,o

快速进行浅拷贝

Object.assign更加方便

数组:

let arr = [1, 2, 3];

let arrCopy = [...arr]; // 将数组 spread 到参数列表中
                        // 然后将结果放到一个新数组

// 两个数组中的内容相同吗?
console.info(JSON.stringify(arr) === JSON.stringify(arrCopy)); // true

// 两个数组相等吗?
console.info(arr === arrCopy); // false(它们的引用是不同的)

// 修改我们初始的数组不会修改副本:
arr.push(4);
console.info(arr); // 1, 2, 3, 4
console.info(arrCopy); // 1, 2, 3

对象:

let obj = { a: 1, b: 2, c: 3 };

let objCopy = { ...obj }; // 将对象 spread 到参数列表中
                          // 然后将结果返回到一个新对象

// 两个对象中的内容相同吗?
console.info(JSON.stringify(obj) === JSON.stringify(objCopy)); // true

// 两个对象相等吗?
console.info(obj === objCopy); // false (not same reference)

// 修改我们初始的对象不会修改副本:
obj.d = 4;
console.info(JSON.stringify(obj)); // {"a":1,"b":2,"c":3,"d":4}
console.info(JSON.stringify(objCopy)); // {"a":1,"b":2,"c":3}

作用域

代码块

每一个代码块{}都是一个独立的环境,使用constlet声明的变量只在内部可见。

// 代码块一
{
  // 显示 message
  let message = "Hello";
  console.info(message);
}
// 代码块二
{
  // 显示另一个 message
  let message = "Goodbye";
  console.info(message);
}

如果没有代码块进行隔离,那么会出现变量重复定义错误

for (let i = 0; i < 3; i++)中的let i也属于代码块内部。

代码块嵌套:
内部代码块可以访问外部代码块的变量数据

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();

console.info( counter() ); // 0
console.info( counter() ); // 1
console.info( counter() ); // 2

词法环境

Step 1. 变量

  • 每个脚本 / 代码块 / 函数都有一个与之关联的词法环境
  • 词法环境 = 环境记录(存局部变量等属性) + 外部引用(指向外层词法环境;全局为 null
  • 变量就是环境记录的属性;读写变量即读写该内部对象属性
  • 执行流程中环境记录状态变化:
    • 预填充所有 let 变量 → 初始“未初始化”
    • 执行到 let 行 → 变为 undefined
    • 后续赋值 / 修改 → 值更新

Step 2. 函数声明

  • 函数也是值,保存在环境记录
  • 函数声明在词法环境建立阶段立即完成初始化,因此可先调用后声明
  • 函数表达式(如 let f = function...)不具备此特性

Step 3. 内部与外部词法环境

  • 函数被调用时新建内部词法环境存放参数与局部变量
  • 内部环境通过“外部引用”链向全局(或外层)环境
  • 变量查找顺序:内部 → 外部 → ... → 全局;找不到则严格模式报错
  • 全局词法环境定义的变量为全局变量,其余词法环境定义的变量为局部变量

Step 4. 返回函数(闭包)

  • 内部函数“诞生”时获得隐藏属性 [[Environment]],永久保存其创建时的词法环境引用
  • 当内部函数被调用:
    • 新建当前调用环境
    • 当前环境的外部引用 = 被调函数的 [[Environment]]
  • 因此即使外部函数已结束,内部函数仍能访问并更新外部变量
  • 多次调用同一闭包,均在同一外部环境记录上操作,变量持续累加

闭包(Closure)

  1. 定义
    一个函数能够记住并访问其外部变量的能力,称为闭包。
  2. JavaScript 中的实现
  • 所有函数在“诞生”时自动通过隐藏属性 [[Environment]] 保存对创建位置词法环境的引用。
  • 因此所有函数天生都是闭包(唯一例外:new Function 语法)。
  1. 效果
    即使外部函数已执行完毕,内部函数仍能通过 [[Environment]] 链访问并更新外部变量,形成“变量常驻”现象。

垃圾回收

与正常垃圾一样,仅在词法环境可达(存在外部引用)的情况下才会被保留在内存当中,
否则将会被删除。

注意:

JavaScript 引擎会试图优化它。会导致在调试过程中变得困难。

运行以下代码,暂停后输入console.info(value)
会输出 No such variable 错误

function f() {
  let value = Math.random();

  function g() {
    debugger; // 在 Console 中:输入 console.info(value); No such variable!
  }

  return g;
}

let g = f();
g();

Var

无块级作用域

var会忽略代码块,因此无块级作用域,仅拥有函数作用域和全局作用域

if (true) {
  var test = true; // 使用 "var" 而不是 "let"
}

console.info(test); // true,变量在 if 结束后仍存在

同理,对于循环局部作用域也不支持

for (var i = 0; i < 10; i++) {
  var one = 1;
  // ...
}

console.info(i);   // 10,"i" 在循环结束后仍可见,它是一个全局变量
console.info(one); // 1,"one" 在循环结束后仍可见,它是一个全局变量

声明区别

允许重新声明:
会自动忽略已声明变量,只进行赋值操作。

var user = "Pete";
var user = "John"; // 这个 "var" 无效(因为变量已经声明过了)
// ……不会触发错误
console.info(user); // John

允许声明前使用:
在不考虑嵌套的情况下,var声明的变量会被自动移至函数开头,因此允许:

function sayHi() {
  phrase = "Hello";

  console.info(phrase);

  var phrase;
}
sayHi();

但是只是声明被提升,赋值并没有

function sayHi() {
  console.info(phrase); // undefined

  var phrase = "Hello";
}

sayHi();

由于不存在块级作用域,并且自动提升至函数开头,因此会出现以下情况:

function sayHi() {
  phrase = "Hello"; // (*)

  if (false) {
    var phrase;
  }

  console.info(phrase);// 正常打印
}
sayHi();

使用立即调用函数模拟块级作用域(借助函数作用域):

(function() {

  var message = "Hello";

  console.info(message); // Hello

})();

Var 特性速览

特性var
块级作用域❌ 无视 if / for / {} 块,只在函数或全局层面有效
重复声明✅ 同一作用域内可重复声明,后续视为赋值
声明提升✅ 声明自动提升到函数顶部,赋值留在原地
暂时性死区❌ 不存在,提升后可在声明前使用(值为 undefined
全局挂载✅ 在全局作用域声明的变量会成为 window 的属性

var vs. let vs. const 对比表

维度varletconst
作用域函数 / 全局块级 {}块级 {}
重复声明允许禁止禁止
声明提升完全提升提升但不可访问(TDZ)提升但不可访问(TDZ)
暂时性死区
是否只读是(绑定不可重新赋值)
是否需初始化是(声明时必须赋值)
全局对象属性

“暂时性死区”(TDZ,Temporal Dead Zone)是 ES6 引入 let / const 后出现的一种**语法保护期> **:

块级作用域开始变量声明语句真正执行前的这段区域 / 时间,变量虽然已存在于词法环境,但任何访问或赋值都会抛 ReferenceError
只有执行完声明语句,变量才“解冻”,进入可正常使用状态。

{
  console.log(a); // ReferenceError:处于 TDZ
  let a = 1;      // 声明执行,TDZ 结束
  console.log(a); // 1
}

对比 var 的“声明提升+默认 undefined”,TDZ 让“先使用后声明”成为显式错误,避免隐藏 bug。

全局对象

全局对象提供可在任何地方使用的变量和函数。默认情况下,这些全局变量内建于语言或环境中。
比如:

console.info("Hello");
// 等同于
window.console.info("Hello");

Browser与Node

  • 在Browser(浏览器)中,全局对象名称为window
  • 在Node中,全局对象名称为global
  • 在最新标准中,都支持全局对象globalThis
    // 在浏览器中
    console.info(globalThis == window ); // true
    // 在Node中
    console.info(global == globalThis); // true
    

全局属性挂载

支持为全局对象增加新的属性和方法

要注意:使用var声明的变量会自动挂载到全局对象当中

var gVar = 5;

console.info(globalThis.gVar); // 5(成为了全局对象的属性)
// 将当前用户信息全局化,以允许所有脚本访问它
globalThis.currentUser = {
  name: "John"
};

// 代码中的另一个位置
console.info(currentUser.name);  // John

// 或者,如果我们有一个名为 "currentUser" 的局部变量
// 从 globalThis 显式地获取它(这是安全的!)
console.info(globalThis.currentUser.name); // John

自定义polyfills

if (!globalThis.Promise) {
  globalThis.Promise = ... // 定制实现现代语言功能
}

函数对象 与 NFE

在 JavaScript 中,函数的类型是对象。不仅可以调用它们,还能把它们当作对象来处理:增/删属性,按引用传递等。

名称属性 name

一个函数的名字可以通过属性 “name” 来访问。规范中把这种特性叫做「上下文命名」,在没有具体提供时,会自动根据上下文进行匹配。当无法进行匹配时,则置为空。

function sayHi() {
  console.info("Hi");
}

console.info(sayHi.name); // sayHi

// 或者
let sayHi = function() {
  console.info("Hi");
};

console.info(sayHi.name); // sayHi(有名字!)

// 甚至
function f(sayHi = function() {}) {
  console.info(sayHi.name); // sayHi(生效了!)
}

f();

入参个数 length

要注意:rest参数不参与计数

function f1(a) {}
function f2(a, b) {}
function many(a, b, ...more) {}

console.info(f1.length); // 1
console.info(f2.length); // 2
console.info(many.length); // 2

可以利用其实现函数多态:

function ask(question, ...handlers) {
  let isYes = console.info(question);

  for(let handler of handlers) {
    if (handler.length == 0) {
      if (isYes) handler();
    } else {
      handler(isYes);
    }
  }

}

// 对于肯定的回答,两个 handler 都会被调用
// 对于否定的回答,只有第二个 handler 被调用
ask("Question?", () => console.info('You said yes'), result => console.info(result));

自定义属性

属性不是变量,变量不是属性,二者毫不相干。

可以借助函数属性实现闭包功能。

闭包的变量无法在外部访问,但函数属性可以在外部访问。

function makeCounter() {
  // 不需要这个了
  // let count = 0

  function counter() {
    return counter.count++;
  };

  counter.count = 0;

  return counter;
}

let counter = makeCounter();
console.info( counter() ); // 0
console.info( counter() ); // 1

NFE(命名函数表达式)

NFE特性:

  • 允许函数在内部引用自己。
  • 在函数外是不可见的。

此处的func即为一个NFE

let sayHi = function func(who) {
  if (who) {
    console.info(`Hello, ${who}`);
  } else {
    func("Guest"); // 使用 func 再次调用函数自身
  }
};

sayHi(); // Hello, Guest

// 但这不工作:
func(); // Error, func is not defined(在函数外不可见)

为何在嵌套时不使用sayHi呢:

因为当发生将sayHi赋值给其他变量时,同时将sayHi置空,则嵌套的调用就会失效

let sayHi = function(who) {
  if (who) {
    console.info(`Hello, ${who}`);
  } else {
    sayHi("Guest"); // Error: sayHi is not a function
  }
};

let welcome = sayHi;
sayHi = null;

welcome(); // Error,嵌套调用 sayHi 不再有效!

因此采用NFE,即可避免此问题发生。

new Function

支持从字符串创建函数,但

  • 词法环境指向全局,无法访问内部变量
  • 经过压缩程序压缩后的代码,变量名称会变化,通过字符串创建的函数则无法直接进行访问

语法:

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

使用示例:
本质返回一个函数,进行正常调用即可。

new Function('a', 'b', 'return a + b'); // 基础语法
new Function('a,b', 'return a + b'); // 逗号分隔
new Function('a , b', 'return a + b'); // 逗号和空格分隔

延时执行

不希望一个函数立即执行,而是等待特定时间后执行,称为计划调用。

setTimeout

将函数推迟到一段时间间隔之后再执行。

签名

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

保存返回的timerId为可选操作

参数

参数说明
func / code要执行的函数代码字符串(历史遗留,强烈建议用函数)
delay延迟毫秒数,默认 0;实际最小间隔约为 4 ms
arg1, arg2…传给 func 的实参列表(IE9 以下不支持)

正确 vs 错误示例

function sayHi() {
  console.info('Hello');
}
// ✅ 传函数引用
setTimeout(sayHi, 1000, "Hello", "John");

// ✅ 箭头函数
setTimeout(() => console.info('Hello'), 1000);

// ❌ 立即执行,实际传入 undefined
setTimeout(sayHi(), 1000);

返回值
timerId —— 用于稍后 clearTimeout(timerId) 取消调度。

setInterval

重复运行一个函数,从一段时间间隔之后开始运行,之后以该时间间隔连续重复运行该函数。

签名

let timerId = setInterval(func|code, [delay], [arg1], [arg2], ...)

保存返回的timerId为可选操作

参数

参数说明
func / code要周期性执行的函数代码字符串(推荐用函数)
delay间隔毫秒数,默认 0;实际最小间隔约为 4 ms
arg1, arg2…传给 func 的实参列表(IE9 以下不支持)

正确 vs 错误示例

// ✅ 传函数引用
let timerId = setInterval(sayHi, 2000, "Hello", "John");

// ✅ 箭头函数
setInterval(() => console.info('tick'), 1000);

// ❌ 立即执行,实际传入 undefined
setInterval(sayHi(), 1000);

返回值
timerId —— 用于稍后 clearInterval(timerId) 取消循环调度。

取消调度:clearTimeoutclearInterval

  1. 语法

    let timerId = setTimeout(...);   // 或 setInterval(...)
    clearTimeout(timerId);           // 取消单次
    clearInterval(timerId);          // 取消循环
    
  2. 作用

    • clearTimeout:在延迟到期前取消已排队的单次任务;取消后回调不会执行。
    • clearInterval:取消正在循环的定时任务;后续周期不再触发。
  3. 定时器标识符

    • 浏览器:返回一个数字。
    • Node.js:返回一个定时器对象(含方法)。
      标识符类型无统一规范,但均可直接传给对应的 clear* 方法。
  4. 示例

    let t1 = setTimeout(() => console.info("never happens"), 1000);
    let t2 = setInterval(() => console.info("tick"), 1000);
    
    clearTimeout(t1);    // 立即取消单次任务
    clearInterval(t2);   // 立即停止循环
    console.info(t1);           // 仍是原标识符(不会变 null)
    

装饰器与转发,call/apply

装饰器

增强原有函数的功能,所具有的优势:

  • 装饰器功能可重用,复用到其他方法上
  • 装饰器逻辑独立,没有增加被增强函数的原有复杂度
  • 可以使用多个装饰器进行组合调用
// 假设这是一个耗时计算函数
function heavyCal(x) {
    return x*x;
}

// heavyCal(2);

function cacheFunc(func) {
    let caches = new Map();

    return function(key, ...args) {
        if ( caches.has(key) ) {
            console.info(`has: ${caches.get(key)}`);
        }
        else {
          let res = func(key, ...args);
          caches.set(key, res);
          console.info(res);
        }
    };
}

// 获得带有缓存功能的函数
let easyCal = cacheFunc(heavyCal);
easyCal(2); // 4
easyCal(3); // 9
easyCal(2); // has: 4

注意:
装饰器包装后的函数不再具有原函数的内置属性,
因此尽量避免使用函数属性来记录数据,而使用变量。

上下文绑定 func.call

对于对象方法直接使用装饰器进行增强会导致this丢失

let worker = {
  someMethod() {
    return 1;
  },

  heavyCal(x) {
    return x* this.someMethod(); // (*)
  }
};

console.info( worker.heavyCal(1) ); // 原始方法有效

worker.heavyCal = cacheFunc(worker.heavyCal); // 现在对其进行缓存

console.info( worker.heavyCal(2) ); // Error: Cannot read property 'someMethod' of undefined

原因为cacheFunc中执行到let res = func(key, ...args);时,func即为worker.heavyCal
worker.heavyCal内部调用了this.someMethod(),此处的this发生了丢失,导致someMethod()方法调用失败,导致出错。

func.call(context, …args)

允许调用一个显式设置 this 的函数。在运行func,提供的第一个参数作为 this,后面的作为参数

function sayHi(phrase) {
  console.info(this.name + ': ' + phrase);
}

let user = { name: "John" };
let admin = { name: "Admin" };

// 使用 call 将不同的对象传递为 "this"
sayHi.call( user, "Hello"); // John
sayHi.call( admin, "Hi"); // Admin

修复后的装饰器:

let worker = {
  someMethod() {
    return 1;
  },

  heavyCal(x) {
    return x* this.someMethod(); // (*)
  }
};

console.info( worker.heavyCal(1) ); // 原始方法有效

function cacheFunc(func) {
    let caches = new Map();

    return function(key, ...args) {
        if ( caches.has(key) ) {
            console.info(`has: ${caches.get(key)}`);
        }
        else {
          let res = func.call(this, key, ...args);
          caches.set(key, res);
          console.info(res);
        }
    };
}

worker.heavyCal = cacheFunc(worker.heavyCal); // 现在对其进行缓存

worker..heavyCal(2);

func.apply

可以替代func.call,唯一的语法区别是,call 期望一个参数列表,而 apply 期望一个包含这些参数的类数组对象。

将所有参数连同上下文一起传递给另一个函数被称为“呼叫转移(call forwarding)”

对于即可迭代又是类数组的对象,使用apply会更快,引擎内部进行了优化。

语法:

func.apply(context, args)

使用apply的装饰器:

function cacheFunc(func, hash) {
    let caches = new Map();

    return function(...args) {
      let key = hash(args);
        if ( caches.has(key) ) {
            console.info(`has: ${caches.get(key)}`);
        }
        else {
          let res = func.apply(this, args);
          caches.set(key, res);
          console.info(res);
        }
    };
}

function hash(...args) {
  console.info( args.join(",") ); // 1,2
}

方法借用

对于可迭代以及类数组对象,需要使用数组的内建方法时,可以:

// 以函数内建数据arguments为例(包含函数所有参数)
function hash() {
  console.info( [].join.call(arguments) ); // 1,2
}

函数绑定

一、为什么需要“绑定”

  1. JavaScript 的“this 绑定”是运行时决定的:
    obj.method() → this = obj
    const f = obj.method; f() → this = undefined(严格模式)或 window
  2. 对象方法当回调(setTimeout、事件监听器、Promise 等)时,调用位置不再位于对象之后,造成“this 丢失”。
  3. 目标:把 this 固化到指定对象,同时可选地预设部分参数,得到“可安全传递”的新函数。

二、原生工具:Function.prototype.bind

签名
const boundFunc = func.bind(context[, arg1, arg2, ...])

行为

  • 返回一个“外来对象”,内部按以下规则转发:
    • this = context(不可再被 call/apply 改变)
    • 预设参数 = arg1, arg2...(排在最前)
    • 其余参数由调用时继续追加
  • 立即生成不执行原函数;可多次绑定形成链。
  • 无原型(boundFunc.prototype 未定义),因此不能当构造函数用(new 会抛 TypeError)。

典型范式

// 1. 只锁 this
const log = console.log.bind(console);
log('hello'); // this 永远指向 console

// 2. 锁 this + 部分参数(柯里化雏形)
function mul(a, b) { return a * b; }
const double = mul.bind(null, 2); // 乘数固定为 2
double(7); // 14

三、bind 解决 this 丢失的三种场景(含丢→错→修)

场景丢失现场结果修复代码(bind)
① setTimeoutsetTimeout(user.greet, 1000)Hi, undefinedsetTimeout(user.greet.bind(user), 1000)
② DOM 事件button.addEventListener('click', obj.inc)NaN(this=button)button.addEventListener('click', obj.inc.bind(obj))
③ 解构导出const { get } = api; get()TypeError 找不到 baseapi.get = api.get.bind(api); 再导出

完整前后对比示例

// ① 异步回调
let user = { name: 'John', greet() { console.log('Hi, ' + this.name); } };
setTimeout(user.greet, 1000);          // → Hi, undefined
setTimeout(user.greet.bind(user), 1000); // → Hi, John (this 永固)

// ② 事件监听
let counter = { val: 0, inc() { this.val++; console.log(this.val); } };
button.addEventListener('click', counter.inc);               // 点一次 NaN
button.addEventListener('click', counter.inc.bind(counter)); // 点一次 1 2 3...

// ③ 批量导出
let api = { base: 'https://api.io', get(p) { return this.base + p; } };
const { get } = api;   // 解构后 this 丢失
get('/users');         // TypeError
// 批量绑定后任意用
for (const k in api) if (typeof api[k] === 'function') api[k] = api[k].bind(api);
get('/users');         // https://api.io/users

四、partial —— “只锁参数,不锁 this”

当不想固定上下文,只想预设参数时,原生 bind 强制传 context,手写 partial 更干净:

function partial(fn, ...preset) {
  return function(...args) {
    return fn.call(this, ...preset, ...args); // 保留当前 this
  };
}
let user = {
  firstName: "John",
  say(time, phrase) {
    console.info(`[${time}] ${this.firstName}: ${phrase}!`);
  }
};
// 示例:把当前时间注入日志,但保留运行时 this
user.sayNow = partial(user.say, `${Date.now()}`);
user.sayNow('ms') // [1639...] John: ms!

lodash 的 _.partial 同理。

五、bind vs 箭头函数

维度bind箭头函数
this 来源显式指定一次,不可再变词法捕获(声明处外层 this)
是否创建新对象( exotic function )(原始函数形态)
能否 new
参数预设✅ 原生支持❌ 需再包一层
多次重用同一段逻辑可生成多个绑定实例每次写 => 都是新函数
性能多一次包装,内存略高无运行时成本,但闭包变量常驻

口诀:

  • 固定 this预设参数bind
  • 只要词法 this 且代码简洁 → =>
  • 只想预设参数、保留动态 this → partial

六、常见误区 & 最佳实践

  1. 在构造函数里绑定方法

    class Counter {
      constructor() { this.inc = this.inc.bind(this); } // 每个实例共享同一逻辑
    }
    

    保证把实例方法当回调时 this 不丢,且所有子类继承后仍有效。

  2. 不要过度绑定
    只在“方法会被脱离对象调用”时绑;否则增加内存(每个实例一份新函数)。

  3. React / Vue 事件

    • 类组件:构造函数内绑定 或 类字段箭头函数
    • 函数组件:直接用箭头函数定义事件处理器即可
  4. 记得清理
    绑定后的函数无法通过 removeEventListener(fn) 卸载(因为是新引用),需保留同一变量:

    const boundClick = handler.bind(this);
    element.addEventListener('click', boundClick);
    // 卸载时
    element.removeEventListener('click', boundClick);
    

七、一句话记住

this 弄丢 → bind 回;参数想留 → partial 走;词法不变 → 箭头就够!

箭头函数详解

1. 没有自己的 this

访问 this 时沿词法作用域向外查找,因此嵌套在对象方法里仍可正确使用外部 this

let group = {
  title: "Our Group",
  students: ["John", "Pete", "Alice"],
  showList() {
    this.students.forEach(st => console.log(this.title + ': ' + st));
  }
};
group.showList(); // Our Group: John ...

2. 没有 arguments 对象

内部 arguments 也是沿词法链从外部获取,适合透明转发。

function defer(f, ms) {
  return function (...args) {
    setTimeout(() => f.apply(this, args), ms);
  };
}
function sayHi(who) { console.log('Hello ' + who); }

let sayHiDeferred = defer(sayHi, 1000);
sayHiDeferred('World'); // 1s 后 Hello World

3. 不能当构造函数

由于缺失 this,不能用 new 调用。

const Foo = (name) => this.name = name;
new Foo('Tom'); // TypeError: Foo is not a constructor

4. 对比 .bind(this)

  • .bind(this) 显式创建绑定了 this 的新函数
  • 箭头函数单纯“没有 this”,查找方式与普通变量一致,更简洁且无需保存中间变量
function normal() { console.log(this.id); }
const bound = normal.bind({ id: 42 });
bound(); // 42

const arrow = () => console.log(this.id);
arrow.call({ id: 99 }); // 依旧取外部 this,无效化 call

5. 典型场景

回调、迭代器、延迟执行等需要保留当前上下文和参数时,用箭头函数可直接使用外部 thisarguments,省去 let self = this 或额外闭包变量。