一篇文章吃透闭包的各种业务场景

156 阅读27分钟

前言

所谓闭包就是里层函数访问外层函数变量就形成闭包,要想搞懂闭包:JS 中同步和异步代码的区别、变量作用域、闭包等概念有正确的理解、var和let以及const的区别理解、call、apply、bind的使用以及理解

接下来会根据图上面的标记顺序去学习知识点。

复制代码
for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(new Date, i);
    }, 1000);
}

console.log(new Date, i);

这段代码很短,只有 7 行,我想,能读到这里的同学应该不需要我逐行解释这段代码在做什么吧。候选人面对这段代码时给出的结果也不尽相同,以下是典型的答案:

  • A. 20% 的人会快速扫描代码,然后给出结果:0,1,2,3,4,5
  • B. 30% 的人会拿着代码逐行看,然后给出结果:5,0,1,2,3,4
  • C. 50% 的人会拿着代码仔细琢磨,然后给出结果:5,5,5,5,5,5

只要你对 JS 中同步和异步代码的区别、变量作用域、闭包等概念有正确的理解,就知道正确答案是 C,代码的实际输出是:

复制代码
2017-03-18T00:43:45.873Z 5
2017-03-18T00:43:46.866Z 5
2017-03-18T00:43:46.868Z 5
2017-03-18T00:43:46.868Z 5
2017-03-18T00:43:46.868Z 5
2017-03-18T00:43:46.868Z 5

接下来我会追问:如果我们约定,用箭头表示其前后的两次输出之间有 1 秒的时间间隔,而逗号表示其前后的两次输出之间的时间间隔可以忽略,代码实际运行的结果该如何描述?会有下面两种答案:

  • A. 60% 的人会描述为:5 -> 5 -> 5 -> 5 -> 5,即每个 5 之间都有 1 秒的时间间隔;
  • B. 40% 的人会描述为:5 -> 5,5,5,5,5,即第 1 个 5 直接输出,1 秒之后,输出 5 个 5;

这就要求候选人对 JS 中的定时器工作机制非常熟悉,循环执行过程中,几乎同时设置了 5 个定时器,一般情况下,这些定时器都会在 1 秒之后触发,而循环完的输出是立即执行的,显而易见,正确的描述是 B。

如果到这里算是及格的话,100 个人参加面试只有 20 人能及格,读到这里的同学可以仔细思考,你及格了么?

追问 1:闭包

如果这道题仅仅是考察候选人对 JS 异步代码、变量作用域的理解,局限性未免太大,接下来我会追问,如果期望代码的输出变成:5 -> 0,1,2,3,4,该怎么改造代码?熟悉闭包的同学很快能给出下面的解决办法:

复制代码
for (var i = 0; i < 5; i++) {
    (function(j) {  // j = i
        setTimeout(function() {
            console.log(new Date, j);
        }, 1000);
    })(i);
}

console.log(new Date, i);

巧妙的利用 IIFE(Immediately Invoked Function Expression:声明即执行的函数表达式)来解决闭包造成的问题,确实是不错的思路,但是初学者可能并不觉得这样的代码很好懂,至少笔者初入门的时候这里琢磨了一会儿才真正理解。

增补:如果有同学给出如下的解决方案,则说明他是一个仔细看API 文档的人,这种习惯会让他学习的时候少走弯路,具体代码如下:

复制代码
for (var i = 0; i < 5; i++) {
    setTimeout(function(j) {
        console.log(new Date, j);
    }, 1000, i);
}

console.log(new Date, i);

有没有更符合直觉的做法?答案是有,我们只需要对循环体稍做手脚,让负责输出的那段代码能拿到每次循环的 i 值即可。该怎么做呢?利用 JS 中基本类型(Primitive Type)的参数传递是按值传递(Pass by Value)的特征,不难改造出下面的代码:

复制代码
var output = function (i) {
    setTimeout(function() {
        console.log(new Date, i);
    }, 1000);
};

for (var i = 0; i < 5; i++) {
    output(i);  // 这里传过去的 i 值被复制了
}

console.log(new Date, i);

能给出上述 2 种解决方案的候选人可以认为对 JS 基础的理解和运用是不错的,可以各加 10 分。当然实际面试中还有候选人给出如下的代码:

复制代码
for (let i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(new Date, i);
    }, 1000);
}

console.log(new Date, i);

细心的同学会发现,这里只有个非常细微的变动,即使用 ES6 块级作用域(Block Scope)中的 let 替代了 var,但是代码在实际运行时会报错,因为最后那个输出使用的 i 在其所在的作用域中并不存在,i 只存在于循环内部。

能想到 ES6 特性的同学虽然没有答对,但是展示了自己对 ES6 的了解,可以加 5 分,继续进行下面的追问。

追问 2:ES6

有经验的前端同学读到这里可能有些不耐烦了,扯了这么多,都是他知道的内容,先别着急,挑战的难度会继续增加。

接着上文继续追问:如果期望代码的输出变成 0 -> 1 -> 2 -> 3 -> 4 -> 5,并且要求原有的代码块中的循环和两处 console.log 不变,该怎么改造代码?新的需求可以精确的描述为:代码执行时,立即输出 0,之后每隔 1 秒依次输出 1,2,3,4,循环结束后在大概第 5 秒的时候输出 5(这里使用大概,是为了避免钻牛角尖的同学陷进去,因为 JS 中的定时器触发时机有可能是不确定的,具体可参见 How Javascript Timers Work)。

看到这里,部分同学会给出下面的可行解:

复制代码
for (var i = 0; i < 5; i++) {
    (function(j) {
        setTimeout(function() {
            console.log(new Date, j);
        }, 1000 * j);  // 这里修改 0~4 的定时器时间
    })(i);
}

setTimeout(function() { // 这里增加定时器,超时设置为 5 秒
    console.log(new Date, i);
}, 1000 * i);

不得不承认,这种做法虽粗暴有效,但是不算是能额外加分的方案。如果把这次的需求抽象为:在系列异步操作完成(每次循环都产生了 1 个异步操作)之后,再做其他的事情,代码该怎么组织?聪明的你是不是想起了什么?对,就是 Promise

可能有的同学会问,不就是在控制台输出几个数字么?至于这样杀鸡用牛刀?你要知道,面试官真正想考察的是候选人是否具备某种能力和素质,因为在现代的前端开发中,处理异步的代码随处可见,熟悉和掌握异步操作的流程控制是成为合格开发者的基本功。

顺着下来,不难给出基于 Promise 的解决方案(既然 Promise 是 ES6 中的新特性,我们的新代码使用 ES6 编写是不是会更好?如果你这么写了,大概率会让面试官心生好感):

复制代码
const tasks = [];
for (var i = 0; i < 5; i++) {   // 这里 i 的声明不能改成 let,如果要改该怎么做?
    ((j) => {
        tasks.push(new Promise((resolve) => {
            setTimeout(() => {
                console.log(new Date, j);
                resolve();  // 这里一定要 resolve,否则代码不会按预期 work
            }, 1000 * j);   // 定时器的超时时间逐步增加
        }));
    })(i);
}

Promise.all(tasks).then(() => {
    setTimeout(() => {
        console.log(new Date, i);
    }, 1000);   // 注意这里只需要把超时设置为 1 秒
});

相比而言,笔者更倾向于下面这样看起来更简洁的代码,要知道编程风格也是很多面试官重点考察的点,代码阅读时的颗粒度更小,模块化更好,无疑会是加分点。

复制代码
const tasks = []; // 这里存放异步操作的 Promise
const output = (i) => new Promise((resolve) => {
    setTimeout(() => {
        console.log(new Date, i);
        resolve();
    }, 1000 * i);
});

// 生成全部的异步操作
for (var i = 0; i < 5; i++) {
    tasks.push(output(i));
}

// 异步操作完成之后,输出最后的 i
Promise.all(tasks).then(() => {
    setTimeout(() => {
        console.log(new Date, i);
    }, 1000);
});

读到这里的同学,恭喜你,你下次面试遇到类似的问题,至少能拿到 80 分。

我们都知道使用 Promise 处理异步代码比回调机制让代码可读性更高,但是使用 Promise 的问题也很明显,即如果没有处理 Promise 的 reject,会导致错误被丢进黑洞,好在新版的 Chrome 和 Node 7.x 能对未处理的异常给出 Unhandled Rejection Warning,而排查这些错误还需要一些特别的技巧(浏览器Node.js)。

追问 3:ES7

既然你都看到这里了,那就再坚持 2 分钟,接下来的内容会让你明白你的坚持是值得的。

多数面试官在决定聘用某个候选人之前还需要考察另外一项重要能力,即技术自驱力,直白的说就是候选人像有内部的马达在驱动他,用漂亮的方式解决工程领域的问题,不断的跟随业务和技术变得越来越牛逼,究竟什么是牛逼?建议阅读程序人生的这篇剖析

回到正题,既然 Promise 已经被拿下,如何使用 ES7 中的 async/await 特性来让这段代码变的更简洁?你是否能够根据自己目前掌握的知识给出答案?请在这里暂停 1 分钟,思考下。

下面是笔者给出的参考代码:

复制代码
// 模拟其他语言中的 sleep,实际上可以是任何异步操作
const sleep = (timeountMS) => new Promise((resolve) => {
    setTimeout(resolve, timeountMS);
});

(async () => {  // 声明即执行的 async 函数表达式
    for (var i = 0; i < 5; i++) {
        if (i > 0) {
            await sleep(1000);
        }
        console.log(new Date, i);
    }

    await sleep(1000);
    console.log(new Date, i);
})();

js中同步与异步的区别,以及执行顺序哪个更快?

同步任务的执行速度更快,因为它们是直接执行的,不需要等待其他任务完成。然而,异步任务也有其优点,它们不会阻塞事件循环,可以让JavaScript引擎继续处理其他任务,从而提高应用程序的响应速度和性能。

因此,在JavaScript中,应该根据实际情况选择使用同步任务还是异步任务。对于一些需要长时间运行的任务,如网络请求或读取大文件等,应该使用异步任务来避免阻塞事件循环。而对于一些简单的、短时间的任务,使用同步任务可能更加高效。

一、宏任务(Macro Task)

宏任务是指由 JavaScript 主线程执行的任务,它包括但不限于以下情况:

  • 包括script整个脚本
  • 浏览器事件(如 click、mouseover 等)
  • 定时器任务(如 setTimeout 和 setInterval)
  • 页面渲染(如 回流或重绘)
  • 事件回调(如 I/O、点击事件等)
  • 网络请求 (如 XMLHttpRequest 和 fetch 等)

二、 微任务(Micro Task)

微任务是指由 JavaScript 引擎执行的任务,它在宏任务之后执行,但在下一次渲染之前执行。微任务通常是由宏任务中的某个特定任务触发的,并立即执行。常见的微任务有:

  • Promise 的回调函数
  • Async/Await 函数
  • MutationObserver 的回调函数
  • process.nextTick(Node.js 环境下)

宏任务与微任务的执行顺序执行顺序:

1.先执行同步任务

2.执行微任务

3.是否还有宏任务?如果还有的话就执行完微任务先

4.执行微任务

5.退出

作用域(scope)

1.什么是作用域

概念:作用域是在程序运行时代码中的某些特定部分中变量、函数和对象的可访问性

从使用方面来解释,作用域就是变量的使用范围,也就是在代码的哪些部分可以访问这个变量,哪些部分无法访问到这个变量,换句话说就是这个变量在程序的哪些区域可见。代码演示:

scss
复制代码
function Fun() {
    var inVariable = "内部变量";
}
Fun();
console.log(inVariable); // Uncaught ReferenceError: inVariable is not defined
//inVariable是在Fun函数内部被定义的,属于局部变量,在外部无法访问,于是会报错

转存失败,建议直接上传图片文件

从存储上来解释的话,作用域本质上是一个对象, 作用域中的变量可以理解为是该对象的成员

总结:作用域就是代码的执行环境,全局作用域就是全局执行环境,局部作用域就是函数的执行环境,它们都是栈内存

2.作用域分类

作用域又分为全局作用域局部作用域。在ES6之前,局部作用域只包含了函数作用域,ES6的到来为我们提供了 ‘块级作用域’(由一对花括号包裹),可以通过新增命令let和const来实现;而对于全局作用域这里有一个小细节需要注意一下:

  • 在 Web 浏览器中,全局作用域被认为是 window 对象,因此所有全局变量和函数都是作为 window 对象的属性和方法创建的。
  • 在 Node环境中,全局作用域是 global 对象。

全局作用域很好理解,现在我们再来解释一下局部作用域吧,先来看看函数作用域,所谓函数作用域,顾名思义就是由函数定义产生出来的作用域,代码示例:

csharp
复制代码
function fun1(){
    var variable = 'abc'
}
function fun2(){
    var variable = 'cba'
}
fun1();
fun2();
//这里有两个函数,他们分别都有一个同名变量variable,在严格模式下,程序不会报错,
//这是因为这两个同名变量位于不同的函数内,也就是位于不同的作用域中,所以他们不会产生冲突。

转存失败,建议直接上传图片文件

我们再来看看块级作用域,ES6 之前 JavaScript 没有块级作用域,只有全局作用域和函数(局部)作用域。块语句( {} 中间的语句),如 if 和 switch 条件语句, for 和 while 循环语句,不同于函数,它们不会创建一个新的作用域;但是ES6及之后的版本,块语句也会创建一个新的作用域, 块级作用域可通过新增命令let和const声明,所声明的变量在指定块作用域外无法被访问。块级作用域在如下情况被创建:

  1. 在一个函数内部
  2. 在一个代码块(由一对花括号包裹)内部

let 声明的语法与 var 的语法一致。基本上可以用 let 来代替 var 进行变量声明,但会将变量的作用域限制在当前代码块中 (注意:块级作用域并不影响var声明的变量)。 但是使用let时有几点需要注意

  • 声明变量不会提升到代码块顶部,即不存在变量提升
  • 禁止重复声明同一变量
  • for循环语句中()内部,即圆括号之内会建立一个隐藏的作用域,该作用域不属于for后边的{}中,并且只有for后边的{}产生的块作用域能够访问这个隐藏的作用域,这就使循环中 绑定块作用域有了妙用

这里分别演示一下ES5和ES6版本的代码,ES5:

css
复制代码
if(true) {
    var a = 1
}
for(var i = 0; i < 10; i++) {
    ...
}
console.log(a) // 1
console.log(i) // 10

转存失败,建议直接上传图片文件

ES6:

ini
复制代码
for (let i = 0; i < 10; i++) {
            console.log(i);//0,1,2,3,4,5,6,7,8,9
 }
console.log(i);// Uncaught ReferenceError: i is not defined

转存失败,建议直接上传图片文件

ini
复制代码
if (true) {
     let i = 9;
}
console.log(i);// Uncaught ReferenceError: i is not defined

转存失败,建议直接上传图片文件

作用域链(scope chain)

概念:多个作用域对象连续引用形成的链式结构。

使用方面解释:当在Javascript中使用一个变量的时候,首先Javascript引擎会尝试在当前作用域下去寻找该变量,如果没找到,再到它的上层作用域寻找,以此类推直到找到该变量或是已经到了全局作用域,如果在全局作用域里仍然找不到该变量,它就会直接报错。

存储方面解释:作用域链在JS内部中是以数组的形式存储的,数组的第一个索引对应的是函数本身的执行期上下文,也就是当前执行的代码所在环境的变量对象,下一个索引对应的空间存储的是该对象的外部执行环境,依次类推,一直到全局执行环境

代码示例:

kotlin
复制代码
var a = 100
function fun() {
    var b = 200
    console.log(a) //100
// fun函数局部作用域中没有变量a,于是从它的上一级,也就是全局作用域中找,
//在全局中a被赋值为100,于是输出100
    console.log(b)//200 fun函数局部作用域中有变量b,并且它被赋值为了200,输出200
}
fun()

转存失败,建议直接上传图片文件

再来看个栗子:

kotlin
复制代码
var a = 10
function fun() {
   console.log(a)
}
function show(f) {
   var a = 20
   (function() {
      f()   //10,而不是20; 函数的作用域是在函数定义的时候就被决定了,与函数在哪里被调用无关
   })()
}
show(fun)

转存失败,建议直接上传图片文件

由于变量的查找是沿着作用域链来实现的,所以也称作用域链为变量查找的机制。是不是很好理解,这里再来补充一点作用域的作用

  1. 作用域最为重要的一点是安全。变量只能在特定的区域内才能被访问,外部环境不能访问内部环境的任何变量和函数,即可以向上搜索,但不可以向下搜索, 有了作用域我们就可以避免在程序其它位置意外对某个变量做出修改导致程序发生事故。
  2. 作用域能够减轻命名的压力。我们可以在不同的作用域内定义相同的变量名,并且这些变量名不会产生冲突。

什么时候用到const?

在JavaScript中,const 关键字用于声明一个只读的常量。一旦你给它赋了一个值,就不能再给它重新赋值。这有助于确保某些变量的值不会被意外地修改,从而提高代码的可预测性和可靠性。

以下是一个使用 const 的例子:

javascript复制代码
	const PI = 3.14159;  

	  

	console.log(PI); // 输出 3.14159  

	  

	// 试图重新赋值将会引发错误  

	// PI = 3.14; // 错误:TypeError: Assignment to constant variable.

在这个例子中,我们声明了一个常量 PI 并赋值为 3.14159。然后,我们试图重新赋值 PI = 3.14;,但这会导致一个错误,因为 PI 是一个常量,不能重新赋值。

使用 const 可以帮助你编写更安全、更可靠的代码,因为它限制了某些变量的可变性。如果你知道某个变量的值不应该在程序的执行过程中改变,那么使用 const 是个好选择。

在什么时候用到let?

let 在 JavaScript 中用于声明一个块级作用域的变量,它在关键字声明的变量范围是局部的。与 var 不同的是,let 可以在块、条件(if-else)和循环(for、while)中声明变量。

以下是一些使用 let 的例子:

  1. 在循环中声明变量:在 for 循环中,我们通常使用 let 来声明迭代变量。
javascript复制代码
	for (let i = 0; i < 5; i++) {  

	    console.log(i); // 输出 0, 1, 2, 3, 4  

	}

2. 避免变量提升:在 let 中声明的变量不会被提升到整个块或函数作用域,而是只在声明它的块内存在。

javascript复制代码
	if (true) {  

	    let x = 10;  

	    console.log(x); // 输出 10  

	} else {  

	    console.log(x); // ReferenceError: x is not defined  

	}

3. 重新声明:在同一个作用域内,你可以多次使用 let 声明同一个变量。

javascript复制代码
	let x = 10;  

	let x = 20; // 这并不会报错,x 的新值将被赋予为 20

4. 块级作用域:使用 let 声明的变量具有块级作用域,这意味着它们只在其被声明的代码块内有效。

需要注意的是,let 的一个主要用途是避免变量的提升(hoisting)。在 JavaScript 中,使用 var 声明的变量会发生变量提升,即变量可以在声明之前使用,但其值是 undefined。而使用 let 和 const 声明的变量不会发生这种情况。

在什么时候用到var?

在JavaScript中,var关键字通常用于声明变量,并且该变量的值可以在程序的执行过程中被改变。

以下是一些使用var的例子:

  1. 声明全局变量:当你在函数之外声明一个变量时,它是全局变量。
javascript复制代码
	var globalVar = "I am global!";

2. 声明局部变量:当你在函数内部声明一个变量时,它是局部变量。

javascript复制代码
	function exampleFunction() {  

	    var localVar = "I am local!";  

	}

3. 在循环中声明变量:在for循环中,我们通常使用var来声明迭代变量。

javascript复制代码
	for (var i = 0; i < 5; i++) {  

	    console.log(i); // 输出 0, 1, 2, 3, 4  

	}

4. 默认声明:如果省略了var,JavaScript会隐式地使用var来声明变量。这就是所谓的“默认声明”。

javascript复制代码
	function anotherFunction() {  

	    x = "I am still local!"; // 隐式使用 var x = ...  

	}

需要注意的是,使用var声明的变量会发生变量提升(hoisting),即变量可以在声明之前使用,但其值是undefined。同时,使用var声明的变量具有函数作用域或全局作用域,而不是块级作用域。这意味着在块或条件语句中声明的变量实际上是在包围它的函数或全局作用域中声明的。

使用varletfor循环中声明迭代变量时,主要有以下区别:

  1. 作用域:使用var声明的迭代变量在循环体外仍然可见,而使用let声明的迭代变量只在循环体内有效。
  2. 变量提升:使用var声明的变量会发生变量提升,即变量可以在声明之前使用,但其值是undefined。而使用let声明的变量不会发生变量提升。
  3. 循环中的值:在使用var时,整个程序运行到结束使用的都是同一个循环变量的值。而在使用let时,每次循环时,let定义变量的结果都做了独立模块的解析。

总的来说,使用let比使用var更加严格和可靠,特别是在块级作用域中。它可以避免某些因作用域问题导致的错误,并且不会发生变量提升。因此,在可能的情况下,建议优先使用let来声明变量。

在JavaScript(以及其他编程语言)中,"自由变量"是指那些在某个作用域内被引用,但没有在该作用域内被声明的变量。换句话说,自由变量是在一个作用域内引用,但不在该作用域内定义的变量。

例如,考虑以下JavaScript代码:

javascript复制代码
	function exampleFunction() {  

	    console.log(x); // 这里x是一个自由变量  

	}

在这个例子中,x是一个自由变量,因为在exampleFunction函数的作用域内它被引用,但并没有在该作用域内被声明。

自由变量的值是不确定的,因为它们没有在作用域内定义。尝试访问或操作自由变量通常会导致运行时错误或未定义的行为。

什么是call、bind、apply?

callapplybind是JavaScript中用于改变普通函数this指向(无法改变箭头函数this指向)的方法,这三个函数实际上都是绑定在Function构造函数的prototype上,而每一个函数都是Function的实例,因此每一个函数都可以直接调用call,apply,bind

call、bind、apply的使用

call方法

  • 语法: function.call(thisArg, arg1, arg2, ...)。 其中thisArg是要设置为函数执行上下文的对象,也就是this要指向的对象,从第二个参数开始,arg1, arg2, ... 是传递给函数的参数。通过使用call方法,可以将一个对象的方法应用到另一个对象上。
js
复制代码
// 定义一个对象
const person1 = {
  name: 'Alice',
  greet: function() {
    console.log(`Hello, ${this.name}!`);
  }
};

// 定义另一个对象
const person2 = {
  name: 'Bob'
};

// 使用call方法将person1的greet方法应用到person2上
person1.greet.call(person2); // 输出:Hello, Bob!

apply方法

  • 语法:function.apply(thisArg, [argsArray])。 其中thisArg是要设置为函数执行上下文的对象,也就是this要指向的对象,argsArray是一个包含参数的数组。通过使用apply方法,可以将一个对象的方法应用到另一个对象上,并使用数组作为参数。
js
复制代码
function greet(name) {
  console.log(`Hello, ${name}!`);
}

const person = { name: 'John' };
greet.apply(person, ['Mary']); // 输出:Hello, Mary!

bind方法

  • 语法:function.bind(thisArg, arg1, arg2, ...)。 其中thisArg是要绑定到函数执行上下文的对象,也就是this要指向的对象,从第二个参数开始,arg1, arg2, ...是传递给函数的参数。与call和apply方法不同,bind方法并不会立即执行函数,而是返回一个新函数,可以稍后调用。这对于事件处理程序和setTimeout函数等场景非常有用。
js
复制代码
function greet(name) {
  console.log("Hello, " + name);
}

const delayedGreet = greet.bind(null, "John");
setTimeout(delayedGreet, 2000);  // 2秒后输出:Hello, John

call、bind、apply的区别

  1. 调用方式:
  • call:使用函数的call方法可以直接调用函数,并传递参数列表。
  • bind:使用函数的bind方法可以返回一个新的函数,这个新函数的this值被绑定到指定的对象,但不会立即执行。
  • apply:使用函数的apply方法可以直接调用函数,并传递参数列表,与call方法类似,但参数需要以数组或类数组的形式传递。
  1. 参数传递方式:
  • call:使用call方法时,参数需要一个一个地列举出来,通过逗号分隔。
  • bind:使用bind方法时,可以传递任意数量的参数,可以在绑定时传递参数,也可以在调用时传递参数。
  • apply:使用apply方法时,参数需要以数组或类数组的形式传递。
  1. 执行时机:
  • call:调用call方法时,函数会立即执行。
  • bind:调用bind方法时,返回一个新函数,需要后续再调用这个新函数才会执行。
  • apply:调用apply方法时,函数会立即执行。

总结:

  • call可以直接调用函数,并传递参数列表,立即执行。
  • bind返回一个新函数,将绑定的对象作为this值,可以在绑定时或调用时传递参数,需要手动调用新函数执行。
  • apply可以直接调用函数,并传递参数列表,立即执行,参数以数组或类数组的形式传递。

手写call、apply、bind方法

手写call()

  • 原理:
  1. 首先,通过 Function.prototype.myCall 将自定义的 myCall 方法添加到所有函数的原型对象上,使得所有函数实例都可以调用该方法。
  2. myCall 方法内部,首先通过 typeof this !== "function" 判断调用 myCall 的对象是否为函数。如果不是函数,则抛出一个类型错误。
  3. 然后,判断是否传入了上下文对象 context。如果没有传入,则将 context 赋值为全局对象;ES11 引入了 globalThis,它是一个统一的全局对象,无论在浏览器还是 Node.js 中,都可以使用 globalThis 来访问全局对象。
  4. 接下来,使用 Symbol 创建一个唯一的键 fn,用于将调用 myCall 的函数绑定到上下文对象的新属性上。
  5. 将调用 myCall 的函数赋值给上下文对象的 fn 属性,实现了将函数绑定到上下文对象上的效果。
  6. 调用绑定在上下文对象上的函数,并传入 myCall 方法的其他参数 args
  7. 将绑定在上下文对象上的函数删除,以避免对上下文对象造成影响。
  8. 返回函数调用的结果。
js
复制代码
Function.prototype.myCall = function (context, ...args) {
  // 判断调用myCall的是否为函数
  if (typeof this !== "function") {
    throw new TypeError("Function.prototype.myCall - 被调用的对象必须是函数");
  }

  // 如果没有传入上下文对象,则默认为全局对象
  // ES11 引入了 globalThis,它是一个统一的全局对象
  // 无论在浏览器还是 Node.js 中,都可以使用 globalThis 来访问全局对象。
  context = context || globalThis;

  // 用Symbol来创建唯一的fn,防止名字冲突
  let fn = Symbol("key");

  // this是调用myCall的函数,将函数绑定到上下文对象的新属性上
  context[fn] = this;

  // 传入MyCall的多个参数
  const result = context[fn](...args);

  // 将增加的fn方法删除
  delete context[fn];

  return result;
};
  • 测试
js
复制代码
const test = {
  name: "xxx",
  hello: function () {
    console.log(`hello,${this.name}!`);
  },
  add: function (a, b) {
    return a + b;
  },
};
const obj = { name: "world" };
test.hello.myCall(obj); //hello,world!
test.hello.call(obj);//hello,world!
console.log(test.add.myCall(null, 1, 2));//3
console.log(test.add.call(null, 1, 2));//3

手写apply()

  • 原理:apply的实现思路跟call类似,就是apply传入参数是以数组的形式传入,所以多了一步判断传入的参数是否为数组以及在调用方法的时候使用扩展运算符 ... 将传入的参数数组 argsArr 展开
js
复制代码
Function.prototype.myApply = function (context, argsArr) {
  // 判断调用myApply的是否为函数
  if (typeof this !== "function") {
    throw new TypeError("Function.prototype.myApply - 被调用的对象必须是函数");
  }

  // 判断传入的参数是否为数组
  if (argsArr && !Array.isArray(argsArr)) {
    throw new TypeError("Function.prototype.myApply - 第二个参数必须是数组");
  }

  // 如果没有传入上下文对象,则默认为全局对象
  // ES11 引入了 globalThis,它是一个统一的全局对象
  // 无论在浏览器还是 Node.js 中,都可以使用 globalThis 来访问全局对象。
  context = context || globalThis;
  
  //如果第二个参数省略则赋值空数组
  argsArr = argsArr || [];

  // 用Symbol来创建唯一的fn,防止名字冲突
  let fn = Symbol("key");

  // this是调用myApply的函数,将函数绑定到上下文对象的新属性上
  context[fn] = this;

  // 传入myApply的多个参数
  const result = context[fn](...argsArr)

  // 将增加的fn方法删除
  delete context[fn];

  return result;
};
  • 测试
js
复制代码
const test = {
  name: "xxx",
  hello: function () {
    console.log(`hello,${this.name}!`);
  },
};
const obj = { name: "world" };
test.hello.myApply(obj); //hello,world!
test.hello.apply(obj); //hello,world!
const arr = [2,3,6,5,1,7,9,5,0]
console.log(Math.max.myApply(null,arr));//9
console.log(Math.max.apply(null,arr));//9

手写bind()

  • 原理:
  1. 首先,通过 Function.prototype.myBind 将自定义的 myBind 方法添加到所有函数的原型对象上,使得所有函数实例都可以调用该方法。
  2. myBind 方法内部,首先通过 typeof this !== "function" 判断调用 myBind 的对象是否为函数。如果不是函数,则抛出一个类型错误。
  3. 然后,判断是否传入了上下文对象 context。如果没有传入,则将 context 赋值为全局对象;ES11 引入了 globalThis,它是一个统一的全局对象,无论在浏览器还是 Node.js 中,都可以使用 globalThis 来访问全局对象。
  4. 保存原始函数的引用,使用 _this 变量来表示。
  5. 返回一个新的闭包函数 fn 作为绑定函数。这个函数接受任意数量的参数 innerArgs。(关于闭包的介绍可以看这篇文章->闭包的应用场景
  6. 在返回的函数 fn 中,首先判断是否通过 new 关键字调用了函数。这里需要注意一点,如果返回出去的函数被当作构造函数使用,即使用 new 关键字调用时,this 的值会指向新创建的实例对象。通过检查 this instanceof fn,可以判断返回出去的函数是否被作为构造函数调用。这里使用 new _this(...args, ...innerArgs) 来创建新对象。
  7. 如果不是通过 new 调用的,就使用 apply 方法将原始函数 _this 绑定到指定的上下文对象 context 上。这里使用 apply 方法的目的是将参数数组 args.concat(innerArgs) 作为参数传递给原始函数。
js
复制代码
Function.prototype.myBind = function (context, ...args) {
  // 判断调用myBind的是否为函数
  if (typeof this !== "function") {
    throw new TypeError("Function.prototype.myBind - 被调用的对象必须是函数");
  }

  // 如果没有传入上下文对象,则默认为全局对象
  // ES11 引入了 globalThis,它是一个统一的全局对象
  // 无论在浏览器还是 Node.js 中,都可以使用 globalThis 来访问全局对象。
  context = context || globalThis;

  // 保存原始函数的引用,this就是要绑定的函数
  const _this = this;

  // 返回一个新的函数作为绑定函数
  return function fn(...innerArgs) {
    // 判断返回出去的函数有没有被new
    if (this instanceof fn) {
      return new _this(...args, ...innerArgs);
    }
    // 使用apply方法将原函数绑定到指定的上下文对象上
    return _this.apply(context,args.concat(innerArgs));
  };
};
  • 测试
js
复制代码
const test = {
  name: "xxx",
  hello: function (a,b,c) {
    console.log(`hello,${this.name}!`,a+b+c);
  },
};
const obj = { name: "world" };
let hello1 = test.hello.myBind(obj,1);
let hello2 = test.hello.bind(obj,1); 
hello1(2,3)//hello,world! 6
hello2(2,3)//hello,world! 6
console.log(new hello1(2,3));
//hello,undefined! 6
// hello {}
console.log(new hello2(2,3));
//hello,undefined! 6
// hello {}