👉 个人博客主页 👈
📝 一个努力学习的程序猿
更多文章/专栏推荐:
HTML和CSS
JavaScript
Vue
Vue3
React
TypeScript
个人经历+面经+学习路线【内含免费下载初级前端面试题】
前端面试分享
前端学习+方案分享(VitePress、html2canvas+jspdf、vuedraggable、videojs)
前端踩坑日记(ElementUI)
【一文读懂】对闭包的理解及其衍生问题
前言
本文主要将回答以下问题:原型(链)、事件循环(宏任务、微任务)、异步操作(Promist、async/await)、递归以及解决递归栈溢出的方法。
1、原型+原型链
1.1 箭头函数和function函数
在进入正式内容前,先表述几个其他问题。箭头函数和 function 函数相信大家都不陌生。ES6 新增的箭头函数,主要就是提供更简洁的函数语法,且解决传统函数中 this 绑定的问题。
示例:
// 传统函数
function traditionalFunc(x) {
return x * 2;
}
// 箭头函数
const arrowFunc = x => x * 2;
箭头函数和传统的 function 函数有以下主要区别:
(1)this 绑定(this 指向问题,详见1.3节):
箭头函数没有自己的 this,它会捕获其所在上下文的 this 值。
传统函数的 this 由调用方式决定。
(2)构造函数(构造函数,详见1.4节):
箭头函数不能用作构造函数(不能使用new)。
传统函数可以作为构造函数。
(3)arguments 对象(详见1.2节):
箭头函数没有自己的 arguments 对象。
传统函数有自己的 arguments 对象。
(4)隐式返回:
箭头函数在只有一个表达式时可以省略 return 和花括号。
传统函数总是需要显式的 return。
(5)prototype 属性(原型属性,详见1.5节):
箭头函数没有 prototype 属性。
传统函数有 prototype 属性。
相关具体内容将在下方按节叙述。
1.2 arguments 对象
arguments 对象是 JavaScript 中的一个特殊对象,被称为类数组对象。它存在于所有非箭头函数中,包含了传递给函数的所有参数,不需要显式声明,可以直接使用。
示例:
function example(a, b, c) {
console.log(arguments.length); // 输出参数的数量
console.log(arguments[0]); // 可以通过索引访问
console.log(Array.isArray(arguments)); // false
console.log(typeof arguments); // object
}
example(1, 2, 3);
通过示例可以看出它的特殊点:
它有数组的一部分特性,但又不完全是数组:有length属性,可以通过索引访问元素,但不是 Array 的实例(没有数组的内置方法,如 push、pop、slice…)。
如果你想把它当作数组使用,你只需要转化它为真正的数组即可:
function example() {
const args = Array.from(arguments);
// 或者
// const args = [...arguments];
args.forEach(arg => console.log(arg)); // 现在可以使用数组方法了
}
而为什么 arguments 不设计成真正的数组,或许是因为创建真正的数组会带来额外的内存和性能开销,且 arguments 对象的设计早于许多现代的 JS 特性。
使用场景:在不知道具体参数数量的情况下操作所有参数。
不过现在如果要实现这样的功能,都会用如下的现代替代方案,这会让语法更加清晰和直观:
function modern(...args) {
console.log(args); // 是一个真正的数组
}
1.3 修改this的指向:call / apply / bind
(1)call(thisArg, arg1, arg2, …)
• 立即执行函数
• 第一个参数指定this,后续参数作为函数参数
• 适用于:明确知道参数数量,且需要借用其他对象的方法
function greet(name) {
console.log(`Hello, ${name}! I'm ${this.name}`);
}
const person = { name: 'Alice' };
greet.call(person, 'Bob'); // 输出: Hello, Bob! I'm Alice
(2)apply(thisArg, [arg1, arg2, …])
• 与 call 类似,但第二个参数是数组
• 适用于:参数已经存在于数组的情况
function greet(name) {
console.log(`Hello, ${name}! I'm ${this.name}`);
}
const person = { name: 'Alice' };
greet.apply(person, ['Bob']); // 输出: Hello, Bob! I'm Alice
const numbers = [5, 6, 2, 3, 7];
const max = Math.max.apply(null, numbers); // 返回 7
(3)bind(thisArg, arg1, arg2, …)
• 返回一个新函数,不立即执行
• 适用于:需要创建一个永久绑定特定 this 值的函数
function greet(name) {
console.log(`Hello, ${name}! I'm ${this.name}`);
}
const person = { name: 'Alice' };
const boundGreet = greet.bind(person);
boundGreet('Bob'); // 输出: Hello, Bob! I'm Alice
const module = {
x: 42,
getX: function() {
return this.x;
}
};
const unboundGetX = module.getX;
console.log(unboundGetX()); // 报错:TypeError: Cannot read properties of undefined (reading 'x')(因为 this 不存在,是undefined)
const boundGetX = unboundGetX.bind(module);
console.log(boundGetX()); // 42
1.4 构造函数
示例:
function Person(name, age) {
this.name = name;
this.age = age;
}
const alice = new Person('Alice', 30);
console.log(alice); // Person { name: 'Alice', age: 30 }
分析下示例:
(1)console.log 中可以看到,它有 Prototype 属性,这将在1.5节中说明;
(2)在使用时,需要大写函数名,且使用 new 关键字,这将帮我们自动创建新对象并设置原型链(你可以暂时理解为出现了 Prototype),并确保 this 指向新创建的对象。
=> 我们在 1.3 节关于 bind 的示例中可以发现,如果是个普通函数,你在不指定 this 时,this 将是 undefined。
使用构造函数可以更方便地创建具有相同结构的多个对象实例,并且可以通过原型链共享方法,提高代码的复用性和效率。
1.5 原型与原型链
接下来是最重要的内容,上述所有问题中,大部分都涉及到原型链,且也是原型链的相关基础。
原型是 JavaScript 中一个非常重要的概念,每个 JavaScript 对象都有一个原型对象。接下来用构造函数来演示:
// 创建一个 person 构造函数
function Person(name, age) {
this.name = name;
this.age = age;
}
// 默认拥有 prototype 属性
// Person.prototype = { constructor: Person };
// 在 Person 的原型上添加一个方法
Person.prototype.sayHello = function() {
console.log(`Hello, my name is ${this.name}`);
};
// 创建一个 Person 实例
const john = new Person("John", 30);
// 相当于
// john = {};
// john.__proto__ = Person.prototype;
// Person.call(john, “John”, 30);
// john的原型链
// john { name: “John”, age: 30 } ---> Person.prototype ---> Object.prototype ---> null
john.sayHello(); // 输出: Hello, my name is John
在这个例子中:
• Person.prototype 是 Person 构造函数的原型对象
• sayHello 方法被添加到 Person.prototype 上
• john 对象可以直接访问 sayHello 方法,尽管它没有直接定义在 john 对象上
那么为什么没有直接定义在对象上,现在却也可以访问呢?原因如下:
当你试图访问一个对象的属性时,如果这个对象本身没有这个属性,JavaScript 会沿着原型链向上查找,直到找到该属性或到达原型链的末端 (通常是 Object.prototype)。
为了验证这一点,需要先介绍另一个概念“继承”。继承就是允许一个对象基于另一个对象(或类)来创建,继承其属性和方法。继承主要通过原型链来实现,实现的方式就是 1.3 节提到的 call。接下来扩展上面的例子。
// …
// 创建一个 Employee 构造函数,继承自 Person
function Employee(name, age, job) {
Person.call(this, name, age);
this.job = job;
}
// 设置 Employee 的原型为 Person 的一个实例
Employee.prototype = Object.create(Person.prototype);
// 因为重写了 prototype,所以需要重新设置,确保对象和构造函数之间关系正确
Employee.prototype.constructor = Employee;
// 在 Employee 的原型上添加一个方法
Employee.prototype.introduce = function() {
console.log(`Hi, I'm ${this.name}, I work as a ${this.job}`);
};
// 创建一个 Employee 实例
const jane = new Employee("Jane", 25, "Developer");
jane.sayHello(); // 输出: Hello, my name is Jane
jane.introduce(); // 输出: Hi, I'm Jane, I work as a Developer
在这个例子中:
• Employee 继承自 Person
• Employee.prototype = Object.create(Person.prototype); 这里会出现一个现象:对 Person.prototype 的修改也会影响 Employee 的实例,而在 Employee.prototype 上添加或覆盖方法,不会影响 Person.prototype。
• 之所以需要 Employee.prototype.constructor = Employee; 主要是用于类型检查(不写这句话不会导致代码出错,但可能会在后续的开发中出现混淆和潜在的错误),比如:
console.log(jane.constructor === Person); // 如果不修正,这将返回 true
console.log(jane.constructor === Employee); // 如果不修正,这将返回 false
let newEmployee = new jane.constructor("Alice", 28, "Manager");
// 如果不修正,这将创建一个 Person 实例而不是 Employee 实例
• jane 对象可以访问 sayHello 方法 (来自 Person.prototype) 和 introduce 方法 (来自 Employee.prototype)
=> 当我们调用 jane.sayHello() 时,JavaScript 首先在 jane 对象上查找 sayHello 方法;因为没找到,所以它会在 jane 的原型 (Employee.prototype) 上查找;因为还没找到,它会继续在 Employee.prototype 的原型(Person.prototype) 上查找。最终在 Person.prototype 上找到 sayHello 方法并执行。示例图大致如下:
所有原型链的终点都是 Object.prototype。比如:
console.log(jane.__proto__); // Employee.prototype
console.log(jane.__proto__.__proto__); // Person.prototype
console.log(jane.__proto__.__proto__.__proto__); // Object.prototype
console.log(jane.__proto__.__proto__.__proto__.__proto__); // null
除此以外,还可以使用 Object.getPrototypeOf() 方法或 proto 属性来检查对象的原型:
console.log(Object.getPrototypeOf(jane) === Employee.prototype); // true
console.log(jane.__proto__ === Employee.prototype); // true
最后再给一个总结版的原型链与继承的例子:
function Animal(name) {
this.name = name;
}
Animal.prototype.makeSound = function() {
console.log('Some sound');
};
function Dog(name) {
Animal.call(this, name);
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
const dog = new Dog('Buddy');
dog.makeSound(); // 输出: Some sound
1.6 原型链的注意事项
在了解了原型链的基础概念后,需要注意:所有对象都有原型链:原型链的顶端是 Object.prototype。Object.prototype 的原型是 null。
示例:
const obj = {x: 10}
console.log(obj)
console.log(obj.toString())
toString 就是在原型链上的方法。这也就是为什么普通对象、数组对象及其他对象可以使用一个“看似”对象中不存在的方法,因为它们都在原型链上。这也就是原型链的好处:通过原型链这种共享方法,提高了代码的复用性和效率。(创建的每个对象,都有对应定义好的方法)
2、事件循环(宏任务、微任务)
感兴趣的话,可以先看一下我之前的其他文章,有助于你的理解:其中(互联网是如何运作的)目录
https://blog.youkuaiyun.com/qq_45613931/article/details/109028679
总结下就是:页面渲染 + JS 是单线程执行的。比如:
const num1 = 1 + 2 // 任务1
const num2 = 20 / 5 // 任务2
const num3 = 7 * 8 // 任务3
按顺序执行是我们认知中的。但如果出现需要耗费大量时间的函数(例如: setTimeout、 ajax)的时候,由于单线程的机制,理论上浏览器只能等待它们完成,从而被阻塞,导致浏览器停止渲染。但没出现这个问题,就是因为采用了异步回调函数的机制(事件循环机制)。
这个机制就是说:在代码从上到下执行的过程中,如果碰到了需要延后执行的任务,那么就会被存放到一个队列中(用来单独管理异步状态的事件队列)。在事件队列中,又会根据任务的类型分为微任务队列和宏任务队列。
执行顺序就是:主线程代码先执行,执行过后,到事件队列中执行。在事件队列中,先去微任务队列中执行,没有微任务再去执行宏任务。执行过程中还会检查是否存在微任务,如果有就又会再去微任务队列执行。如此往复。这个流程就是事件循环,即eventloop。
宏任务包括:定时器(setTimeout、setInterval)、事件绑定、requestAnimationFrame、UI渲染、I/O操作
微任务包括:promise.then/catch/finally、 async/await、process.nextTick、MutationObserver
(如果对 Promise、async/await 不太了解,可以先跳转第3节)
现在用一个例子来验证这一点:
console.log('1. 脚本开始');
setTimeout(() => {
console.log('2. 宏任务 (setTimeout)');
}, 0);
Promise.resolve().then(() => {
console.log('3. 微任务 (Promise)');
});
console.log('4. 脚本结束');
输出结果为:
- 脚本开始
- 脚本结束
- 微任务 (Promise)
- 宏任务 (setTimeout)
执行流程:
• JavaScript 在开头碰到 console.log ,打印 1. 脚本开始
• 解析至 setTimeout,将其“回调”推入宏任务队列中执行
• 遇到 Promise,将其“回调”(也就是.then中的内容)推入微任务队列中执行
• 继续往下,碰到 console.log,打印 4. 脚本结束
• 至此,主线程空闲,开始从微任务队列里拿出任务,放入主线程执行。打印 3. 微任务 (Promise)
• 最后,主线程再次空闲,微任务队列为空,开始从宏任务队列里拿出任务,放入主线程执行。打印 2. 宏任务 (setTimeout)
注意上述关于“回调”的表述,现在写一个更复杂的例:
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(() => {
console.log('setTimeout')
}, 0)
async1()
new Promise((resolve) => {
console.log('promise1')
resolve('resolve')
}).then(() => {
console.log('promise2')
})
console.log('script end')
输出结果:
这里就需要注意执行顺序:
• JavaScript 先遇到两个 function 定义,随后碰到 console.log ,打印 script start
• 解析至 setTimeout,将其回调推入宏任务队列中执行
• 碰到调用 async1,因此进入 function 内,打印 async1 start。随后遇到 await async2 => 此时会先执行 async2 中的内容,打印 async2,随后其回调(也就是 await 后面的内容)会推入微任务队列
• 接下来遇到 Promise,执行其内容,打印 promise1 后,将其回调推入微任务队列(也就是 then 里的内容)
• 继续往下,打印 script end
• 至此,主线程空闲,开始从微任务队列里按照顺序拿出任务,放入主线程执行,依次打印:async1 end、promise2
• 最后,主线程再次空闲,微任务队列为空,开始从宏任务队列里拿出任务,放入主线程执行。打印 setTimeout
最后补一个问题:“Vue处理页面渲染卡顿问题”,也和以上内容息息相关。
https://blog.youkuaiyun.com/qq_45613931/article/details/143828634
3、Promise、async/await
3.1 Promise
通过第2节我们知道,Promise、async/await 都是微任务。它们的出现是因为页面的任务都在主线程上执行,如果耗时较长就会阻塞页面完成其他操作,所以为了提高性能,出现了异步操作,出现了事件循环机制。通常情况下,我们会用 Promise、async/await 处理接口请求。接下来看一个 Promise 示例:
// 模拟从API获取用户数据的函数
function fetchUserData(userId) {
return new Promise((resolve, reject) => {
// 模拟网络请求延迟
setTimeout(() => {
// 模拟成功和失败的情况
if (userId === 1) {
resolve({
id: 1,
name: "张三",
email: "zhangsan@example.com"
});
} else {
reject(new Error("用户不存在"));
}
}, 1000); // 1秒延迟
});
}
// 使用Promise
console.log("开始获取用户数据...");
fetchUserData(1)
.then(userData => {
console.log("用户数据:", userData);
return userData.name; // 可以继续链式调用
})
.then(name => {
console.log("用户名:", name);
})
.catch(error => {
console.error("错误:", error.message);
})
.finally(() => {
console.log("数据获取操作完成");
});
console.log("请求已发送,等待数据...");
输出结果:
开始获取用户数据…
请求已发送,等待数据…
用户数据: { id: 1, name: ‘张三’, email: ‘zhangsan@example.com’ }
用户名: 张三
数据获取操作完成
Promise 内,会有一个状态管理的机制,其中它有三种状态:
- Pending (待定)
当 Promise 被创建时,它的初始状态是 Pending。表示异步操作尚未完成。
const promise = new Promise((resolve, reject) => {
// 异步操作尚未完成,Promise处于Pending状态
});
- Fulfilled (已兑现)
当异步操作成功完成时,Promise 从 Pending 转换为 Fulfilled。一旦变为 Fulfilled,状态就不能再改变。最后通过 .then 来处理后续内容。
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("操作成功"); // Promise变为Fulfilled状态
}, 1000);
});
promise.then(value => console.log(value)); // 输出: 操作成功
- Rejected (已拒绝)
当异步操作失败时,Promise 从 Pending 转换为 Rejected。一旦变为 Rejected,状态就不能再改变。最后通过 .catch 来处理后续内容。
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error("操作失败")); // Promise变为Rejected状态
}, 1000);
});
promise.catch(error => console.error(error.message)); // 输出: 操作失败
这里有几个关键特性:
(1)状态不可逆
一旦 Promise 的状态从 Pending 变为 Fulfilled 或 Rejected,就不能再改变。
const promise = new Promise((resolve, reject) => {
resolve("成功");
reject(new Error("失败")); // 这行不会有效果
});
promise.then(console.log); // 输出: 成功
(2)状态的传递
在Promise链中,状态会沿着链传递(错误会自动向下传播,直到被捕获)。
Promise.resolve(1)
.then(value => value + 1)
.then(value => { throw new Error("出错了"); })
.then(value => console.log("不会执行"))
.catch(error => console.error(error.message));
通过该特性你还可以发现,Promise 最大的好处就是通过一种更加线性和可读的方式,解决了传统回调函数嵌套代码导致的“回调地狱”。而且 catch 会统一处理异常,不用每次回调都重复处理。比如:
function getUserById(id, callback) {
setTimeout(() => {
console.log("获取用户信息...");
callback({ id: id, name: "张三" });
}, 1000);
}
function getOrdersByUser(user, callback) {
setTimeout(() => {
console.log("获取订单信息...");
callback([
{ id: 1, amount: 200 },
{ id: 2, amount: 300 },
{ id: 3, amount: 500 }
]);
}, 1000);
}
function calculateTotal(orders, callback) {
setTimeout(() => {
console.log("计算总额...");
const total = orders.reduce((sum, order) => sum + order.amount, 0);
callback(total);
}, 1000);
}
// 回调地狱开始
getUserById(1, function(user) {
console.log("用户:", user);
getOrdersByUser(user, function(orders) {
console.log("订单:", orders);
calculateTotal(orders, function(total) {
console.log("总金额:", total);
// 可能还有更多嵌套...
});
});
});
改成 Promise 后:
// 将回调函数改造成返回Promise的函数
function getUserById(id) {
return new Promise((resolve) => {
setTimeout(() => {
console.log("获取用户信息...");
resolve({ id: id, name: "张三" });
}, 1000);
});
}
function getOrdersByUser(user) {
return new Promise((resolve) => {
setTimeout(() => {
console.log("获取订单信息...");
resolve([
{ id: 1, amount: 200 },
{ id: 2, amount: 300 },
{ id: 3, amount: 500 }
]);
}, 1000);
});
}
function calculateTotal(orders) {
return new Promise((resolve) => {
setTimeout(() => {
console.log("计算总额...");
const total = orders.reduce((sum, order) => sum + order.amount, 0);
resolve(total);
}, 1000);
});
}
// 使用Promise链
getUserById(1)
.then(user => {
console.log("用户:", user);
return getOrdersByUser(user);
})
.then(orders => {
console.log("订单:", orders);
return calculateTotal(orders);
})
.then(total => {
console.log("总金额:", total);
})
.catch(error => {
console.error("发生错误:", error);
});
3.2 Promise.all
在上述例子中,因为上述调用存在先后关系,所以为了避免代码的深层嵌套,已是最优解。但如果没有先后关系,你可以使用 Promise.all 来避免使用很多 .then 或多次使用 Promise。如下示例:
const fetchUser = (id) => new Promise(resolve =>
setTimeout(() => resolve(`User ${id}`), 1000 * id))
Promise.all([
fetchUser(3),
fetchUser(1),
fetchUser(2),
])
.then(([user1, user2, user3]) => {
console.log('所有用户:', user1, user2, user3)
})
.catch(error => {
console.error('获取用户失败:', error)
})
const p1 = new Promise((resolve) => {
setTimeout(() => {
console.log("P1 完成");
resolve("P1");
}, 1000);
});
const p2 = new Promise((resolve, reject) => {
setTimeout(() => {
console.log("P2 失败");
reject("P2 错误");
}, 500);
});
const p3 = new Promise((resolve) => {
setTimeout(() => {
console.log("P3 完成");
resolve("P3");
}, 1500);
});
Promise.all([p1, p2, p3])
.then((results) => {
console.log("所有 Promise 成功:", results);
})
.catch((error) => {
console.error("至少一个 Promise 失败:", error);
});
// 输出顺序:
// P2 失败
// 至少一个 Promise 失败: P2 错误
// P1 完成
// P3 完成
该用法将会并行处理多个任务,如果其中有一项失败,则会立即触发 catch 中内容,但不会结束其他正在进行的任务。此时任务均处理完成后,只要有一项失败,.then 中内容就不会执行。
3.3 Promise.race
另外还有一个 Promise.race 方法。它将返回第一个完成的 Promise 的结果,无论成功还是失败。一旦有一个 Promise 执行完成,其他的 Promise 结果将会被忽略。
const fetchData = (id, delay) => new Promise((resolve, reject) => {
setTimeout(() => {
if (id === 2) reject(`Error from ${id}`);
else resolve(`Data from ${id}`);
}, delay);
});
Promise.race([
fetchData(1, 2000),
fetchData(2, 1000),
fetchData(3, 3000)
])
.then(result => console.log("获胜的结果:", result))
.catch(error => console.error("错误:", error));
// 输出 (约1秒后):
// 错误: Error from 2
3.4 async/await
示例:
// 模拟异步操作:获取用户信息
async function fetchUserInfo(userId) {
// 模拟网络请求延迟
await new Promise(resolve => setTimeout(resolve, 1000));
console.log("获取用户信息...");
return { id: userId, name: "张三", email: "zhangsan@example.com" };
}
// 模拟异步操作:获取用户订单
async function fetchUserOrders(userId) {
await new Promise(resolve => setTimeout(resolve, 1000));
console.log("获取用户订单...");
return [
{ id: 1, product: "手机", price: 5000 },
{ id: 2, product: "电脑", price: 8000 }
];
}
// 主函数:使用 async/await 组合异步操作
async function getUserData(userId) {
try {
console.log("开始获取用户数据...");
// 等待获取用户信息
const userInfo = await fetchUserInfo(userId);
console.log("用户信息:", userInfo);
// 等待获取用户订单
const userOrders = await fetchUserOrders(userId);
console.log("用户订单:", userOrders);
// 计算订单总额
const totalAmount = userOrders.reduce((sum, order) => sum + order.price, 0);
console.log("订单总额:", totalAmount);
return {
userInfo,
userOrders,
totalAmount
};
} catch (error) {
console.error("获取数据时出错:", error);
}
}
// 调用主函数
(async () => {
const result = await getUserData(1);
console.log("最终结果:", result);
})();
// 输出结果:
// 开始获取用户数据...
// 获取用户信息...
// 用户信息: { id: 1, name: '张三', email: 'zhangsan@example.com' }
// 获取用户订单...
// 用户订单: [
// { id: 1, product: '手机', price: 5000 },
// { id: 2, product: '电脑', price: 8000 }
// ]
// 订单总额: 13000
// 最终结果: {
// userInfo: { id: 1, name: '张三', email: 'zhangsan@example.com' },
// userOrders: [
// { id: 1, product: '手机', price: 5000 },
// { id: 2, product: '电脑', price: 8000 }
// ],
// totalAmount: 13000
// }
async/await 本质上是 Promise 的语法糖,使异步代码看起来更像同步代码,提高了可读性和维护性。需要注意:
• async 函数总是返回一个Promise,即使你 await 的方法里没有任何需要异步的操作。
• await 关键字只能在 async 函数内部使用。
• 如果 await 后的内容抛出异常,可以用try/catch捕获。
3.5 手写 Promise
在面试中,还可能会让你手写一个 Promise。仿写一个基础的 Promise 实际上没想象中难。通过 3.1 节,我们先分析一下手写一个 Promise 需要实现的基础事项:
(1) new Promise((resolve, reject) => {}),需要是这样的调用结构,内部用 resolve 代表成功,reject 代表失败;
(2) 调用后,可以使用 .then、.catch、.finally(多次使用 .then) => 注意链式调用的状态传递(这将在后续流程中完成);
(3) 内部有状态管理机制(Pending、Fulfilled、Rejected),一旦状态变化就不能再改变。
通过 1.4 节的构造函数,我们可以发现它们结构很像,所以我们就用构造函数来实现它的基础定义。
function MyPromise(executor) {
var self = this
self.state = 'pending'
self.value = undefined
self.reason = undefined
function resolve(value) {
if (self.state === 'pending') {
self.state = 'fulfilled'
self.value = value
}
}
function reject(reason) {
if (self.state === 'pending') {
self.state = 'rejected'
self.reason = reason
}
}
try {
executor(resolve, reject)
} catch (error) {
reject(error)
}
}
分析一下:
(1) 首先,我们定义构造函数时,一定会接收一个 Function 类型入参,在这里我们叫做 executor。它需要在最后传递 resolve、reject 并立即执行。如果非函数,用 try、catch 捕获,直接返回 reject 即可;
(2) 内部存储一个状态 state(Pending、Fulfilled、Rejected),一个成功标识 value,一个错误标识 reason;
(3) 为实现状态不可逆,resolve 和 reject 只能在当前状态为 pending 的情况下触发,随后改变当前实例状态和结果。
(4)之所以 resolve 方法和 reject 方法要放在构造函数内部,而不是原型链上,主要是因为:这样能防止外部直接调用它们,且它们本身只会用于 executor 函数调用,确保状态变更只影响当前实例。
现在我们完成了预期事项(1)和(3)。
通过 1.5 节的原型链,我们可以把 then、catch、finally 放在原型链上,从而让每个 Promise 实例都可以使用,避免每个实例都要创建一份方法副本,节省内存空间。
定下这个基调后,我们需要重新设定一下关于(2)的预期。因为接下来比较复杂,我们将逐步分析并完成预期。
第一步:我们先写一个基础的 then 方法。需要注意:
在第 2 节我们知道了事件循环。再看 3.1 节的示例,如果在初始化 new MyPromise 时,里面用到了宏任务、微任务,此时需要确保 then 的回调函数不会立即执行(catch、finally 同理)。
因为此时初始化的 Promise 实例的状态还是 pending,所以我们就可以根据实例状态来判断,是否可以立即执行回调函数。如果为 pending 就不能;如果为 fulfilled 就执行。(catch 后续判断 rejected 同理)
此时我们要在构造函数内存储一个 List,里面存放暂未执行的回调函数。当触发了 resolve(rejected)的时候,遍历 List,将本次 resolve(rejected)的 value 结果传递进去即可。
除此以外,如果初始化 new MyPromise 时,里面没有延后执行的任务,假如执行成功,那么在进入 then 时,Promise 实例的状态将是 fulfilled。此时按照上述说法,我们要执行回调函数,但是 Promise 执行回调我们知道是微任务,所以我们要用第 2 节中其他原生的微任务封装一下(假如环境不支持其他微任务,则还用宏任务 setTimeout 来替代)。
MutationObserver:https://blog.youkuaiyun.com/qq_35385241/article/details/121989261
最后还有一点,为了能够链式调用,then 在执行回调函数后,需要返回一个新的 MyPromise 对象(因为我们不能强制用户在回调函数里 return 一个 MyPromise 对象 => 但如果用户真这么写,我们将在下方其他环节里改进)
该逻辑比较抽象,接下来通过示例理解下:
function queueMicrotask(callback) {
if (typeof process !== 'undefined' && typeof process.nextTick === 'function') {
process.nextTick(callback)
} else if (typeof MutationObserver !== 'undefined') {
var observer = new MutationObserver(callback)
var node = document.createTextNode('')
observer.observe(node, { characterData: true })
node.data = ''
} else {
setTimeout(callback, 0)
}
}
function MyPromise(executor) {
var self = this
self.state = 'pending'
self.value = undefined
self.reason = undefined
self.onFulfilledCallbacks = []
self.onRejectedCallbacks = []
function resolve(value) {
if (self.state === 'pending') {
self.state = 'fulfilled'
self.value = value
self.onFulfilledCallbacks.forEach(callback => callback(value))
}
}
function reject(reason) {
if (self.state === 'pending') {
self.state = 'rejected'
self.reason = reason
self.onRejectedCallbacks.forEach(callback => callback(reason))
}
}
try {
executor(resolve, reject)
} catch (error) {
reject(error)
}
}
MyPromise.prototype.then = function(onFulfilled) {
var self = this
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : function(value) { return value }
var promise2 = new MyPromise(function(resolve, reject) {
function handleFulfilled(value) {
queueMicrotask(function() {
try {
var x = onFulfilled(value)
resolve(x)
} catch (error) {
reject(error)
}
})
}
if (self.state === 'fulfilled') {
handleFulfilled(self.value)
} else if (self.state === 'pending') {
self.onFulfilledCallbacks.push(handleFulfilled)
}
})
return promise2
}
此时我们用 3.1 示例,试用 MyPromise 并分析一下:
function fetchUserData(userId) {
return new MyPromise((resolve, reject) => {
setTimeout(() => {
if (userId === 1) {
resolve({
id: 1,
name: '张三',
email: 'zhangsan@example.com'
})
} else {
reject(new Error('用户不存在'))
}
}, 1000)
})
}
fetchUserData(1)
.then(userData => {
console.log('用户数据:', userData)
return 'name:',
})
.then(name => {
console.log(name)
})
(1) fetchUserData 时,因为有 setTimeout,所以内部状态为 pending。此时触发第一个 then,因为状态是 pending,所以将本次封装好的执行函数 handleFulfilled 推入到了 fetchUserData 返回的 Promise 对象的 onFulfilledCallbacks 中。再触发第二个 then,因为第一个 then 返回的 Promise 对象状态为 pending,所以将本次执行函数 handleFulfilled 推入到 第一个then 返回的 Promise 对象的 onFulfilledCallbacks 中。
=> 假如 then 方法接收了一个非函数的参数,我们在这里帮用户写了一个最简单的 Function,以确保链式调用能够正确向下传递。
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : function(value) { return value }
(2) 随后 setTimeout 触发(先只关注执行成功的情况),执行成功。内部触发 resolve 方法,并通过 onFulfilledCallbacks.forEach(callback => callback(value)),将本次得到的值 value 传递给存储的函数(第一个 then 封装的 handleFulfilled)。随后第一个 then 的 Promise 执行回调函数成功后,触发 resolve,并通过 onFulfilledCallbacks.forEach(callback => callback(value)) 再将本次得到的值 value 传递给下一个存储的函数(第二个 then 封装的 handleFulfilled)。
以此类推,基础功能完成。
(3) 同理的,假如 fetchUserData 没有 setTimeout,而是直接 resolve 结果,那么此时状态为 fulfilled。触发第一个 then 时,因为状态是 fulfilled,所以直接执行 handleFulfilled(self.value)。但是因为内部封装了微任务,所以第一个 then 的 Promise 对象状态将会是 pending。最后还是和上述流程一模一样。
第二步:完成 catch 方法。需要注意:
因为之前只关注执行成功的情况,目前在链式传递状态的过程中,当前 then 方法没有判定状态为 Rejected 的情况。所以我们先把这个功能补上:
// ....
MyPromise.prototype.then = function(onFulfilled) {
var self = this
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : function(value) { return value }
onRejected = function(reason) { throw reason }
var promise2 = new MyPromise(function(resolve, reject) {
function handleFulfilled(value) {
queueMicrotask(function() {
try {
var x = onFulfilled(value)
resolve(x)
} catch (error) {
reject(error)
}
})
}
function handleRejected(reason) {
queueMicrotask(function() {
try {
var x = onRejected(reason)
reject(x)
} catch (error) {
reject(error)
}
})
}
if (self.state === 'fulfilled') {
handleFulfilled(self.value)
} else if (self.state === 'rejected') {
handleRejected(self.reason)
} else if (self.state === 'pending') {
self.onFulfilledCallbacks.push(handleFulfilled)
self.onRejectedCallbacks.push(handleRejected)
}
})
return promise2
}
分析一下:补充的功能和第一步相同。如果第一个 Promise 状态为 pending,则这次额外将 handleRejected 推入到 onRejectedCallbacks。因为我们不知道第一个 Promise 会是成功还是失败。此时,当执行失败后,触发 self.onRejectedCallbacks.forEach(callback => callback(reason)),将原因 reason 给到存储的函数。后续流程同理。如果第一个 Promise 对象执行结果为 rejected,流程依然同理。
此时我们也可以关注到,如果其中任意一个 Promise 出现了 rejected,后续流程就都会是 rejected。
接下来关于 catch 方法,我们现在应该能知道,它和 then 原理是相同的:都需要返回 Promise 对象。唯一的不同点就是,catch 的回调,在上一个 Promise 的结果为 Fulfilled 的情况下不触发(那我们就要默认把上一步 value 结果向下传递)。所以我们可以快速写出答案:
// ....
MyPromise.prototype.catch = function(onRejected) {
var self = this
onFulfilled = function(value) { return value }
onRejected = typeof onRejected === 'function' ? onRejected : function(reason) { throw reason }
var promise2 = new MyPromise(function(resolve, reject) {
function handleFulfilled(value) {
queueMicrotask(function() {
try {
var x = onFulfilled(value)
resolve(x)
} catch (error) {
reject(error)
}
})
}
function handleRejected(reason) {
queueMicrotask(function() {
try {
var x = onRejected(reason)
reject(x)
} catch (error) {
reject(error)
}
})
}
if (self.state === 'fulfilled') {
handleFulfilled(self.value)
} else if (self.state === 'rejected') {
handleRejected(self.reason)
} else if (self.state === 'pending') {
self.onFulfilledCallbacks.push(handleFulfilled)
self.onRejectedCallbacks.push(handleRejected)
}
})
return promise2
}
此时,你就会发现,和 then 方法高度吻合,所以我们来合并一下最终结果:
// ...
MyPromise.prototype.then = function(onFulfilled, onRejected) {
var self = this
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : function(value) { return value }
onRejected = typeof onRejected === 'function' ? onRejected : function(reason) { throw reason }
var promise2 = new MyPromise(function(resolve, reject) {
function handleFulfilled(value) {
queueMicrotask(function() {
try {
var x = onFulfilled(value)
resolve(x)
} catch (error) {
reject(error)
}
})
}
function handleRejected(reason) {
queueMicrotask(function() {
try {
var x = onRejected(reason)
reject(x)
} catch (error) {
reject(error)
}
})
}
if (self.state === 'fulfilled') {
handleFulfilled(self.value)
} else if (self.state === 'rejected') {
handleRejected(self.reason)
} else if (self.state === 'pending') {
self.onFulfilledCallbacks.push(handleFulfilled)
self.onRejectedCallbacks.push(handleRejected)
}
})
return promise2
}
MyPromise.prototype.catch = function(onRejected) {
return this.then(null, onRejected)
}
第三步:完成 finally 方法。需要注意:
finally 的核心还是和 then 一样,所以我们还是直接调用 then 方法来执行回调函数。只是这里 finally 比 then、catch 复杂一点。
首先我们要保证,无论上一步是成功还是失败,当前回调函数都要执行。所以调用 then 方法时的两个入参都要能触发回调函数。比如:
MyPromise.prototype.finally = function(callback) {
return this.then(
callback,
callback
)
}
但是 finally 里的内容不应该随链向下传递(还是要将上一步的成功/失败结果传递下去),比如(这个是错误用法):
Promise.resolve(42)
.finally(() => 'xxx')
.then((value) => console.log(value)); // 应该输出 42,而不是 xxx
所以目前的 then 方法肯定不支持,它会直接调用回调函数,并将结果传下去。为了解决这一点,我们就要在传递给 then 的入参上想办法。那就是:在执行回调函数后,返回上一步执行的结果。
说到这里,答案就呼之欲出了:
MyPromise.prototype.finally = function(callback) {
return this.then(
value => MyPromise.resolve(callback()).then(() => value),
reason => MyPromise.resolve(callback()).then(() => { throw reason })
)
}
MyPromise.resolve = function(value) {
if (value instanceof MyPromise) {
return value
}
return new MyPromise(function(resolve) {
resolve(value)
})
}
我们在 MyPromise 上创建静态方法 resolve。为什么不写在原型链上,是因为这样能让我们在不用创建 MyPromise 实例的情况下,去直接调用。比如:
// 将普通值转换为 Promise
const p1 = MyPromise.resolve(123);
// 等价于
const p1 = new MyPromise(resolve => resolve(123));
这样一来,我们在 finally 里,就可以直接使用 MyPromise.resolve 返回一个 Promise 实例,而不用写的更复杂了。
所以 MyPromise.resolve(callback()).then(() => value)
的结果就是:调用 callback 回调函数后,返回一个 Promise 对象,随后触发 then(catch 同理),返回 value(reason)。
value => MyPromise.resolve(callback()).then(() => value),
// 最终结果等价于(只不过是在 callback 执行结束后触发)
function(value) { return value }
第四步:以上 我们完成了所有基本事项。接下来还要考虑一种情况:如果在 then、catch 中返回一个 Promise 对象,此时一定不会按照预期工作(因为当前不会深层次遍历执行 内部嵌套的 Promise)。我们来解决这一点,完整代码:
function queueMicrotask(callback) {
if (typeof process !== 'undefined' && typeof process.nextTick === 'function') {
process.nextTick(callback)
} else if (typeof MutationObserver !== 'undefined') {
var observer = new MutationObserver(callback)
var node = document.createTextNode('')
observer.observe(node, { characterData: true })
node.data = ''
} else {
setTimeout(callback, 0)
}
}
function MyPromise(executor) {
var self = this
self.state = 'pending'
self.value = undefined
self.reason = undefined
self.onFulfilledCallbacks = []
self.onRejectedCallbacks = []
function resolve(value) {
if (self.state === 'pending') {
self.state = 'fulfilled'
self.value = value
self.onFulfilledCallbacks.forEach(callback => callback(value))
}
}
function reject(reason) {
if (self.state === 'pending') {
self.state = 'rejected'
self.reason = reason
self.onRejectedCallbacks.forEach(callback => callback(reason))
}
}
try {
executor(resolve, reject)
} catch (error) {
reject(error)
}
}
MyPromise.prototype.then = function(onFulfilled, onRejected) {
var self = this
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : function(value) { return value }
onRejected = typeof onRejected === 'function' ? onRejected : function(reason) { throw reason }
var promise2 = new MyPromise(function(resolve, reject) {
function handleFulfilled(value) {
queueMicrotask(function() {
try {
var x = onFulfilled(value)
resolvePromise(promise2, x, resolve, reject)
} catch (error) {
reject(error)
}
})
}
function handleRejected(reason) {
queueMicrotask(function() {
try {
var x = onRejected(reason)
resolvePromise(promise2, x, resolve, reject)
} catch (error) {
reject(error)
}
})
}
if (self.state === 'fulfilled') {
handleFulfilled(self.value)
} else if (self.state === 'rejected') {
handleRejected(self.reason)
} else if (self.state === 'pending') {
self.onFulfilledCallbacks.push(handleFulfilled)
self.onRejectedCallbacks.push(handleRejected)
}
})
return promise2
}
MyPromise.prototype.catch = function(onRejected) {
return this.then(null, onRejected)
}
MyPromise.prototype.finally = function(callback) {
var P = this.constructor
return this.then(
value => P.resolve(callback()).then(() => value),
reason => P.resolve(callback()).then(() => { throw reason })
)
}
function resolvePromise(promise, x, resolve, reject) {
if (promise === x) {
reject(new TypeError('Chaining cycle detected for promise'))
return
}
var called = false
if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
try {
var then = x.then
if (typeof then === 'function') {
then.call(x, function(y) {
if (called) return
called = true
resolvePromise(promise, y, resolve, reject)
}, function(r) {
if (called) return
called = true
reject(r)
})
} else {
resolve(x)
}
} catch (error) {
if (called) return
called = true
reject(error)
}
} else {
resolve(x)
}
}
MyPromise.resolve = function(value) {
if (value instanceof MyPromise) {
return value
}
return new MyPromise(function(resolve) {
resolve(value)
})
}
共有两个调整点:
(1) finally 增加 var P = this.constructor 的处理。增加原因很简单:我们在 1.5 节中有看到 constructor 会得到对应的构造函数(在这里就是 MyPromise 构造函数)以及继承功能。如果没继承,此时通过它可以使用到 MyPromise.resolve。但如果被继承(重写了 resolve 方法),此时将使用被重写后的 resolve 方法,避免了写死 MyPromise.resolve 带来的不可扩展性。
(2) 在 resolvePromise 内,当上一步的结果是有 then 方法的(为什么不判断 MyPromise 实例,和(1)原因相同,可能存在继承关系,此时不一定是 MyPromise 实例),那我们就要立即执行 then 方法。为确保 this 指向正确,使用 call。为避免内部再次嵌套 Promise,所以递归调用 resolvePromise。
=> 其他情况在报错情况下 reject,正常情况下 resolve 即可。
至此,我们完成了所有预期事项。
3.6 手写 Promise.all
有了手写 MyPromise 的经验,我们先分析一下 MyPromise.all 的预期:
(1) 入参为一个 List,内部是多个需要执行的 Function。这些 Function 本质预期是返回 Promise 对象,所以我们需要兼容返回只是个普通值的情况(也就是上文的 MyPromise.resolve 的用法);
(2) 在进到 all 方法后,循环执行 List 内的 Function,并将执行结果逐个存储。过程中,只有全部成功,才成功;如果其中任一失败,最后结果都是失败(但不影响其他 Function 执行)。
(3) all 方法依然返回 Promise 对象,用于外层 .then / .catch。
示例:
MyPromise.all = function(promises) {
return new MyPromise(function(resolve, reject) {
if (!Array.isArray(promises)) {
return reject(new TypeError('promises must be an array'))
}
var results = []
var completed = 0
if (promises.length === 0) {
resolve(results)
}
function resolvePromise(index, value) {
results[index] = value
completed++
if (completed === promises.length) {
resolve(results)
}
}
for (var i = 0; i < promises.length; i++) {
(function(i) {
MyPromise.resolve(promises[i]).then(
function(value) {
resolvePromise(i, value)
},
function(error) {
reject(error)
}
)
})(i)
}
})
}
MyPromise.all([func1, func2, func3])
.then(results => {
console.log('All promises succeeded:', results);
})
.catch(error => {
console.log('At least one promise failed:', error);
});
3.7 手写 Promise.race
race 就更简单了,它只需要返回第一个完成的 Promise 的结果,无论成功还是失败。一旦有一个 Promise 执行完成,其他的 Promise 结果将会被忽略。我们只需要改造下 MyPromise.all 方法:
MyPromise.race = function(promises) {
return new MyPromise(function(resolve, reject) {
if (!Array.isArray(promises)) {
return reject(new TypeError('promises must be an array'))
}
if (promises.length === 0) {
return
}
for (var i = 0; i < promises.length; i++) {
MyPromise.resolve(promises[i]).then(resolve, reject)
}
})
}
4、解决递归栈溢出
4.1 什么是递归,递归栈溢出的原因
递归是一种编程技术,函数直接或间接调用自身来解决问题。递归通常用于解决可以被分解成相似的子问题的问题。以下先给一个会出现递归栈溢出的示例:
function recursiveFunc(n) {
if (n === 0) return;
recursiveFunc(n - 1);
}
关于出现递归栈溢出的原因,我们需要分析一下这段代码:
(上一篇文章:https://blog.youkuaiyun.com/qq_45613931/article/details/144483787)
(1) 在上一篇文章 1.3 节里,我们首先知道引用类型的值是存储在堆内存中的,存储在栈内存上的是指向堆内存中该对象的地址。在上一篇文章 3.3 节里还写到,函数在创建时,要记录函数能够访问的所有局部变量、函数的参数信息。所以该例在存储时,可用下面简图来表述。
(2)当每次进行函数调用时,都会在调用栈上创建一个新的栈帧。因此当递归层级较深或根本没有设置跳出递归的终止条件时,函数调用会不断创建新的栈帧,直到栈空间被耗尽,从而超过了调用栈的容量,发生栈溢出。如下所示:
function countDown(n) {
console.log(n);
countDown(n - 1); // 没有合适的终止条件
}
countDown(100000);
function recursiveCount(n) {
if (n === 0) return;
recursiveCount(n - 1);
}
try {
recursiveCount(1000000); // 尝试递归100万次
console.log("成功完成");
} catch (e) {
console.error("发生错误:", e.message);
}
其中,正是因为每次 recursiveCount 调用都需要等待其内部的递归调用完成才能计算结果并返回,所以它们都需要保持活跃状态,就不会被垃圾回收机制所回收。
从这里你也知道了:栈有固定的大小限制。这个限制是由操作系统和编程语言的运行时环境决定的。在大多数系统中,默认的栈大小通常在几MB左右。
4.2 解决方案
对于需要大量递归的问题,可以考虑以下方法来解决或避免递归栈溢出:
(1)将递归转换为循环
该方法最简单,就是将比较简单的递归用法,改成使用 for 循环的形式(前提是,能这么做的话)。
如下所示:
function factorialIterative(n) {
let result = 1;
for (let i = 2; i <= n; i++) {
result *= i;
}
return result;
}
console.log(factorialIterative(5)); // 输出: 120
=> 需要额外注意,如果循环次数过多,又会导致线程阻塞,导致页面卡顿。此时可以参考我的文章(JS代码优化):
https://blog.youkuaiyun.com/qq_45613931/article/details/143828634
(2)尾递归优化
尾递归是指递归调用是函数的最后一个操作(某些语言和环境支持尾递归优化,可以防止栈溢出)。如下所示:
function factorialTailRecursive(n, accumulator = 1) {
if (n <= 1) return accumulator;
return factorialTailRecursive(n - 1, n * accumulator);
}
console.log(factorialTailRecursive(5)); // 输出: 120
原理:在支持尾调用优化的环境中,编译器可以将尾递归转化为循环。此时当前函数帧的信息不再需要保留,编译器可以重用当前的栈帧,而不是创建新的。这样,无论递归多少次,栈的深度都保持不变。
(3)蹦床函数(Trampoline)
蹦床函数是一种在不支持尾调用的环境中,可以在不增加调用栈深度的情况下,模拟尾递归优化的技术。如下所示:
function trampoline(fn) {
return function(...args) {
let result = fn(...args);
while (typeof result === 'function') {
result = result();
}
return result;
};
}
function sumTail(x, y) {
if (y > 0) {
return () => sumTail(x + 1, y - 1);
} else {
return x;
}
}
const sum = trampoline(sumTail);
console.log(sum(1, 100000)); // 可以处理大数而不会栈溢出
原理:每次递归都不再是真正的函数调用,而是返回一个新的函数。此时 rampoline 函数使用循环来执行这些返回的函数,由于使用了循环而不是实际的递归调用,栈深度保持恒定。只是该方法更复杂些,但可以保证在任何环境中使用。
(4) 记忆化(Memoization)
该方法侧面解决栈溢出,即:通过缓存已计算的结果来优化递归,显著减少递归深度。如下所示:
function memoize(fn) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
const fibonacci = memoize(function(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
});
console.log(fibonacci(100)); // 可以快速计算大的斐波那契数