又一年金三银四到来,似乎 “面向手写/机写代码面试” 已经成为一些公司的必备面试之一了,在互联网面试中也占据着重要地位,当被面试手写代码时,刚开始自信心爆棚,有种提笔就来的感觉,上来就是一顿写、一顿画,然后写完又删。。。如果是在白板应该就是反复的涂画了,因为没有经过一些思考,没有好的思路,最后的结果就是漏洞百出、相当的不顺利了,之后吐槽:“什么年代了?一个库就搞定了,还手写代码?”。
为什么要 “手写代码” ?
社区已经有一些现成的库了,为什么还要手写?当然这并不是必须的,谈一点自己的思考,在实际的开发过程中,也会去优先选择一些优秀的库,并不会到处造轮子,否则也会增加维护的成本。
为什么有时候大家会谈论 “CURD 工程师 / API 工程师”?如果只会 API 调用,哪怕它的实现很简单,也是不知道的,如果能在工作中多一些思考,对一些自己经常使用的东西多一些学习和思考,一方面能加深自己的理解、例如 Promise 的 resolve 函数不执行会发生什么?之前写过一个并发请求控制的实现 “实现浏览器中的最大请求并发数控制” 核心的也是利用的 Promise 这一点。另一方面了解其背后实现,也可以反思是否有待优化的空间,优秀的项目不都是不断的总结、迭代优化的吗?
在写代码时,变量名定义、函数或接口设计、代码可读性和细节处理这些点也可体现出一个面试者的代码水平和习惯。在平常的工作中要养成一个良好的习惯,不要只是为了面试而面试。
不论是手写/机写代码,所谓的 “手撕代码” 并非最终目的,做为一个面试官也不要只把最后的运行结果来做为最终评估,可以更多关注下其实现的一些思路。
笔者日常看到一些库或文章对于感兴趣的点,会记录下来,尝试着去写下,也才有大家看到的 “某某 API 是如何实现?”,之前在交流群上记得一个小伙伴问了一个问题,大致是关于 Node.js Stream 的 pipe 实现,正好对这块也感兴趣,看了 Node.js 相关源码也才有了这篇文章 Nodejs Stream pipe 的使用与实现原理分析。
下文中,笔者归纳了 10 个 JavaScript 相关的问题分享给大家,希望能对您有所帮助!Good Luck!
十道题目
- 数组降维
- 数组/对象数组去重
- 深拷贝
- 实现一个 sleep 函数
- 柯里化函数实现
- 实现一个 new/instanceof 操作符
- 手撕 call/apply/bind 三兄弟
- 实现 map/reduce
- 手写 Promise 各方法的实现
- Co / Async 实现原理
数组降维
实现思路:
- 定义 arrReduction 方法接收一个数组参数
- 行 {1} 调用递归函数 arrReductionRecursive(arr, []) 第二个参数可选,也可以在行 {2} 设置默认值,需要 ES6 以上支持
- 行 {3} 使用 forEach 对数组循环遍历
- 行 {4} 检测到当前遍历到的元素为数组继续递归遍历
- 行 {5} 如果当前元素不为数组,result 保存结果
- 行 {6} 返回结果
/**
* 数组降维
* @param { Array } arr
* @returns { Array } 返回一个处理完成的数组
*/
function arrReduction(arr) {
return arrReductionRecursive(arr, []); // {1}
}
/**
* 数组降维递归调用
* @param { Array } arr
*/
function arrReductionRecursive(arr, result=[]) { // {2}
arr.forEach(item => { // {3}
item instanceof Array ?
arrReductionRecursive(item, result) // {4}
:
result.push(item); // {5}
})
return result; // {6}
}
// 测试
const arr = [[0, 1], [2, [4, [6, 7]]]];
console.log('arrReduction: ', arrReduction(arr)); // [ 0, 1, 2, 4, 6, 7 ]
数组/对象数组去重
实现思路:
- 定义 unique 去重方法接收两个参数 arr、name
- 行 {1} 如果待去重为对象数组则 name 必传
- 行 {2} 设置 key 是下面用来过滤的依据
- 行 {3} 如果 name 不存在,按照普通数组做过滤, key 设置为 current 即当前的数组元素
- 行 {4} 检测要过滤的 key 是否在当前对象中,如果是将值赋予 key
- 行 {5} 对于对象元素,如果 key 不在当前对象中,设置一个随机值,使得其它 key 不受影响,例如 [{a: 1}, {b: 1}] 现在对 key 为 a 的元素做过滤,但是 b 中没有 a 针对这种情况做处理
- 行 {6} 为了解决类似于 [3, '3'] 这种情况,这样会把 '3' 也过滤掉
- 行 {7} 这是我们实现的关键,如果 key 在 hash 对象中不存在什么也不做,否则,设置 hash[key] = true 且像 prev 中添加元素。
- 行 {8} 返回当前结果用户下次遍历
/**
* 数组/对象数组去重
* @param { Array } arr 待去重的数组
* @param { String } name 如果是对象数组,为要过滤的依据 key
* @returns { Array }
*/
function unique (arr=[], name='') {
if ((arr[0] instanceof Object) && !name) { // {1}
throw new Error('对象数组请传入需要过滤的属性!');
}
const hash = {};
return arr.reduce((prev, current) => {
let key; // {2}
if (!name) {
key = current; // {3}
} else if (current.hasOwnProperty(name)) {
key = current[name]; // {4}
} else {
key = Math.random(); // {5} 保证其它 key 不受影响
}
if (!(Object.prototype.toString.call(key) === '[object Number]')) { // {6}
key += '_';
}
hash[key] ? '' : hash[key] = true && prev.push(current); // {7}
return prev; // {8}
}, []);
}
let arr = [1, 2, 2, 3, '3', 4];
arr = [{ a: 1 }, { b: 2 }, { b: 2 }]
arr = [{ a: 1 }, { a: 1 }, { b: 2 }]
console.log(unique(arr, 'a'));
一种更简单的 ES6 新的数据结构 Set,因为 Set 能保证集合中的元素是唯一的,可以利用这个特性,但是支持有限,对象数组这种就不支持咯
let arr = [1, 2, 2, 3, '3', 4];
[...new Set(arr)] // [ 1, 2, 3, '3', 4 ]
深拷贝
深拷贝与浅拷贝区别:前者深拷贝遇到复杂类型对象、数组之后会切断与原先对象的引用,进行层层拷贝,保证两者互不影响,而浅拷贝遇到对象、数组之后拷贝的是其引用。
实现思路:
- 定义 deepClone 函数接收参数 elements
- 行 {1} 校验参数是否合法
- 行 {2} 定义递归函数 deepCloneRecursive 这是深度拷贝的核心实现
- 行 {3} 创建一个新的对象或数组,从而开辟一个新的存储地址,切断与原先对象的引用关系
- 行 {4} 遍历对象
- 行 {5} 校验如果是对象或数组继续递归深度拷贝,否则对于基本类型或函数进行复制
- 行 {6} 遍历结束,返回新克隆的对象
/**
* 深拷贝
* @param { Object|Array|Function } elements
* @returns { Object|Array|Function } newElements
*/
function deepClone(elements) {
if (!typeCheck(elements, 'Object')) { // {1}
throw new Error('必须为一个对象')
}
return deepCloneRecursive(elements); // {2}
}
/**
* 深度拷贝递归调用
* @param { Object|Array|Function } elements
*/
function deepCloneRecursive(elements) {
const newElements = typeCheck(elements, 'Array') ? [] : {}; // {3}
for (let k in elements) { // {4}
// {5}
if (typeCheck(elements[k], 'Object') || typeCheck(elements[k], 'Array')) {
newElements[k] = deepCloneRecursive(elements[k]);
} else {
newElements[k] = elements[k];
}
}
return newElements; // {6}
}
/**
* 类似检测 | 辅助函数
* @param {*} val 值
* @param { String } type 类型
* @returns { Boolean } true|false
*/
function typeCheck(val, type) {
return Object.prototype.toString.call(val).slice(8, -1) === type;
}
// 测试
const obj = { a: 1, b: { c: [], d: function() {} }};
const obj2 = deepClone(obj);
obj.b.c.push('a');
obj.b.d = [];
console.log(obj); // { a: 1, b: { c: [ 'a' ], d: [] } }
console.log(obj2); // { a: 1, b: { c: [], d: [Function: d] } }
介绍另外一种简单的方法:使用 JSON 进行序列化和反序列化
const obj = { a: 1, b: { c: [], d: function() {} }};
const obj2 = JSON.parse(JSON.stringify(obj));
console.log(obj); // { a: 1, b: { c: [], d: [Function: d] } }
console.log(obj2); // { a: 1, b: { c: [] } }
上面运行结果发现函数 d 没有了,因为在 JSON 的标准中有规定仅支持 object, array, number, or string 四个数据类型,或 false, null, true 这三个值,解析时对于其它类型的编码都会被默认转换掉。
实现一个 sleep 函数
JavaScript 在语言层面没有直接提供类似 Java 或其它的语言中的 sleep() 线程沉睡功能,也许你会看到如下代码:
function sleep(seconds) { // 不可取
const start = new Date();
while (new Date() - start < seconds) {}
}
sleep(10000); // 10 秒钟
运行之后如上图所示,CPU 暴涨,因为 JavaScript 是单线程的,这样 CPU 资源都会为这段代码服务,这是一种阻塞操作,不是线程睡眠,另外也会破坏事件循环调度,导致其它任务无法执行。
方法一
正确写法推荐以下代码,通过 setTimeout 来控制延迟执行。
/**
* 延迟函数
* @param { Number } seconds 单位秒
*/
function sleep(seconds) {
return new Promise(resolve => {
setTimeout(function() {
resolve(true);
}, seconds)
})
}
async function test() {
console.log('hello');
await sleep(5000);
console.log('world! 5 秒后输出');
}
test();
方法二
ECMA262 草案提供了 Atomics.wait API 来实现线程睡眠,它会真正的阻塞事件循环,阻塞线程直到超时。
该方法 Atomics.wait(Int32Array, index, value[, timeout]) 会验证给定的 Int32Array 数组位置中是否仍包含其值,在休眠状态下会等待唤醒或直到超时,返回一个字符串表示超时还是被唤醒。
同样的因为我们的业务是工作在主线程,避免在主线程中使用,在 Node.js 的工作线程中可以根据实际需要使用。
/**
* 真正的阻塞事件循环,阻塞线程直到超时,不要在主线程上使用
* @param {Number} ms delay
* @returns {String} ok|not-equal|timed-out
*/
function sleep(ms) {
const valid = ms > 0 && ms < Infinity;
if (valid === false) {
if (typeof ms !== 'number' && typeof ms !== 'bigint') {
throw TypeError('ms must be a number');
}
throw RangeError('ms must be a number that is greater than 0 but less than Infinity');
}
return Atomics.wait(int32, 0, 0, Number(ms))
}
sleep(3000)
方法三
通过 N-API 写 C/C++ 插件的方式实现,参见笔者的这个项目 github.com/qufei1993/e… 里面也包含了上述各方法的实现。
柯里化函数实现
这个名次第一次听到时感觉好神秘、好高大上,其实明白之后也就没那么复杂了,下面让我们一块揭秘这个神秘的柯里画函数是什么!
接收函数作为参数的函数称为高阶函数,柯里化是高阶函数中的一种特殊写法。
函数柯里化是一把接受多个参数的函数转化为最初只接受一个参数且返回接受余下的参数返回结果的新函数。
常见的面试题是这样 add(1)(2)(3) 计算 1 + 2 +3 的和,下面也是一种函数柯里化的写法,但自由度不高,如果我在增加一个参数呢,例如 add(1, 2)(3)
function add(a) {
return function(b) {
return function(c) {
return a + b + c;
}
}
}
console.log(add(1)(2)(3)); // 6
函数柯里化具备更加强大的能力,因此,我们要去想办法实现一个柯里化的通用式,上面例子中我们使用了闭包,但是代码是重复的,所以我们还需要借助递归来实现。
实现思路:
- 行 {1} 定义 addFn 函数
- 行 {2} 定义 curry 柯里化函数接收两个参数,第一个为 fn 需要柯里化的函数,第二个 ...args 实际为多个参数例如 1, 2 ...
- 行 {3} args.length 是函数传入的参数,如果小于 fn.length 说明期望的参数长度未够,继续递归调用收集参数
- 行 {4} 为一个匿名函数
- 行 {5} 获取参数,注意获取到的数据为数组,因此行 {6} 进行了解构传递
- 行 {3} 如果 args.length > fn.length 说明参数 args 收集完成,开始执行代码行 {7} 因为 args 此时为数组,所以使用了 apply 或者也可以使用 call,改动行 {7} fn.call(null, ...args)
- 行 {8} 创建一个柯里化函数 add,此时 add 返回结果 curry 的匿名函数也就是代码行 {4} 处
- 至此整个函数柯里化已完成可以自行测试。
/**
* add 函数
* @param { Number } a
* @param { Number } b
* @param { Number } c
*/
function addFn(a, b, c) { // {1}
return a + b + c;
}
/**
* 柯里化函数
* @param { Function } fn
* @param { ...any } args 记录参数
*/
function curry(fn, ...args) { // {2}
if (args.length < fn.length) { // {3}
return function() { // {4}
let _args = Array.prototype.slice.call(arguments); // {5}
return curry(fn, ...args, ..._args); // {6} 上面得到的结果为数组,进行解构
}
}
return fn.apply(null, args); // {7}
}
// curry 函数简写如下,上面写法可能更易理解
// const curry = (fn, ...args) => args.length < fn.length ?
// (..._args) => curry(fn, ...args, ..._args)
// :
// fn.call(null, ...args);
// 柯里化 add 函数
const add = curry(addFn); // {8}
console.log(add(1)(2)(3)); // 6
console.log(add(1, 2)(3)); // 6
console.log(add(1)(2, 3)); // 6
实现一个 new/instanceof 操作符
自定义 _new() 实现 new 操作符
自定义 _new() 方法,模拟 new 操作符实现原理,共分为以下 3 步骤:
- 行 {1} 以构造器的 prototype 属性为原型,创建新对象
- {1.1} 创建一个新对象 obj
- {1.2} 新对象的 proto 指向构造函数的 prototype,实现继承
- 行 {2} 改变 this 指向,将新的实例 obj 和参数传入给构造函数 fn 执行
- 行 {3} 如果构造器没有手动返回对象,则返回第一步创建的对象,例如:function Person(name) { this.name = name; return this; } 这样手动给个返回值,行 {2} result 会拿到一个返回的对象,否则 result 返回 undefined,最后就只能将 obj 给返回。
/**
* 实现一个 new 操作符
* @param { Function } fn 构造函数
* @param { ...any } args
* @returns { Object } 构造函数实例
*/
function _new(fn, ...args) {
// {1} 以构造器的 prototype 属性为原型,创建新对象
// 以下两行代码等价于
// const obj = Object.create(fn.prototype)
const obj = {}; // {1.1} 创建一个新对象 obj
obj.__proto__ = fn.prototype; // {1.2} 新对象的 __proto__ 指向构造函数的 prototype,实现继承
// {2} 改变 this 指向,将新的实例 obj 和参数传入给构造函数 fn 执行
const result = fn.apply(obj, args);
// {3} 返回实例,如果构造器没有手动返回对象,则返回第一步创建的对象
return typeof result === 'object' ? result : obj;
}
将构造函数 Person 与行参传入我们自定义 _new() 方法,得到实例 zhangsan,使用 instanceof 符号检测与使用 new 是一样的。
function Person(name, age) {
this.name = name;
this.age = age;
}
const zhangsan = _new(Person, '张三', 20);
const lisi = new Person('李四', 18)
console.log(zhangsan instanceof Person, zhangsan); // true Person { name: '张三', age: 20 }
console.log(lisi instanceof Person, lisi); // true Person { name: '李四', age: 18 }
自定义 _instanceof() 实现 instanceof 操作符
function Person() {}
const p1 = new Person();
const n1 = new Number()
console.log(p1 instanceof Person) // true
console.log(n1 instanceof Person) // false
console.log(_instanceof(p1, Person)) // true
console.log(_instanceof(n1, Person)) // false
function _instanceof(L, R) {
L = L.__proto__;
R = R.prototype;
while (true) {
if (L === null) return false;
if (L === R) return true;
L = L.__proto__;
}
}
手撕 call/apply/bind 三兄弟
三者区别:
- call:改变 this 指向,其它参数挨个传入,会立即执行,例如:test.call(obj, 1, 2);
- apply:改变 this 指向,第二个参数需传入数组类型,会立即执行,例如:test.call(obj, [1, 2]);
- bind:改变 this 执行,会接收两次参数传递,需要手动执行,例如:const testFn = test.bind(this, 1); testFn(2);
1. 自定义 mayJunCall 函数
- 行 {1} 如果 context 不存,根据环境差异,浏览器设置为 window,Nodejs 设置为 global
- 行 {2} 上下文定义的函数保持唯一,借助 ES6 Symbol 方法实现
- 行 {3} this 为需要执行的方法,例如 function test(){}; test.call(null) 这里的 this 就代表 test() 方法
- 行 {4} 将 arguments 类数组转化为数组
- 行 {5} 执行函数 fn
- 行 {6} 记得删除上下文绑定的 fn 函数
- 行 {7} 如果该函数有返回值,将结果返回
/*
* 实现一个自己的 call 方法
*/
Function.prototype.mayJunCall = function(context) {
// {1} 如果 context 不存,根据环境差异,浏览器设置为 window,Nodejs 设置为 global
context = context ? context : globalThis.window ? window : global;
const fn = Symbol(); // {2} 上下文定义的函数保持唯一,借助 ES6 Symbol 方法
context[fn] = this; // {3} this 为需要执行的方法,例如 function test(){}; test.call(null) 这里的 this 就代表 test() 方法
const args = [...arguments].slice(1); // {4} 将 arguments 类数组转化为数组
const result = context[fn](...args) // {5} 传入参数执行该方法
delete context[fn]; // {6} 记得删除
return result; // {7} 如果该函数有返回值,将结果返回
}
// 测试
name = 'lisi';
const obj = {
name: 'zs'
};
function test(age, sex) {
console.log(this.name, age, sex);
}
test(18, '男'); // lisi 18 男
test.mayJunCall(obj, 18, '男'); // zs 18 男
2. 自定义 mayJunApply 函数
与上面模拟 call 函数的实现类似,唯一的区别在于 apply 接受数组做为参数传递,因此刚开始要做下参数校验,如果参数传了且不为数组,抛出一个 TypeError 错误。
/**
* 实现一个自己的 apply 方法
*/
Function.prototype.mayJunApply = function(context) {
let args = [...arguments].slice(1); // 将 arguments 类数组转化为数组
if (args && args.length > 0 && !Array.isArray(args[0])) { // 参数校验,如果传入必须是数组
throw new TypeError('CreateListFromArrayLike called on non-object');
}
context = context ? context : globalThis.window ? window : global;
const fn = Symbol();
context[fn] = this;
args = args.length > 0 ? args[0] : args; // 因为本身是一个数组,此时传值了就是 [[0, 1]] 这种形式
const result = context[fn](...args);
delete context[fn];
return result
}
3. 自定义 mayJunBind 函数
bind 的实现与 call、apply 不同,但也没那么复杂,首先 bind 绑定之后并不会立即执行,而是会返回一个新的匿名函数,只有我们手动调用它才会执行。
另外 bind 可以分为两部接收:
- 第一次是在执行 bind 的时候
function test(age, sex) {
console.log(`name: ${this.name}, age: ${age}, sex: ${sex}`);
}
const fn = test.mayJunBind(obj, 20); // 进行 bind 调用
- 第二次是在真正执行时
fn('男') // 传入第二个参数
以下为实现 bind 的模式实现,最后还是调用了 apply 实现了上下文的绑定
/**
* 实现一个自己的 bind 方法
*/
Function.prototype.mayJunBind = function(context) {
const that = this; // 保存当前调用时的 this,因为 bind 不是立即执行
const firstArgs = [...arguments].slice(1); // 获取第一次绑定时的参数
return function() {
const secondArgs = [...arguments]; // 获取第二次执行时的参数
const args = firstArgs.concat(secondArgs); // 两次参数拼接
return that.apply(context, args); // 将函数与 context 进行绑定,传入两次获取的参数 args
}
}
实现 map/reduce
定义 mayJunMap 实现 map 函数
/**
* 实现 map 函数
* map 的第一个参数为回调,第二个参数为回调的 this 值
*/
Array.prototype.mayJunMap = function(fn, thisValue) {
const fnThis = thisValue || [];
return this.reduce((prev, current, index, arr) => {
prev.push(fn.call(fnThis, current, index, arr));
return prev;
}, []);
}
const arr1 = [undefined, undefined];
const arr2 = [undefined, undefined].mayJunMap(Number.call, Number);
const arr3 = [undefined, undefined].mayJunMap((element, index) => Number.call(Number, index));
// arr2 写法等价于 arr3
console.log(arr1) // [ undefined, undefined ]
console.log(arr2) // [ 0, 1 ]
console.log(arr3) // [ 0, 1 ]
定义 mayJunReduce 实现 reduce 函数
Array.prototype.mayJunReduce = function(cb, initValue) {
const that = this;
for (let i=0; i<that.length; i++) {
initValue = cb(initValue, that[i], i, that);
}
return initValue;
}
const arr = [1, 2, 3];
const arr1 = arr.mayJunReduce((prev, current) => {
console.log(prev, current);
prev.push(current)
return prev;
}, [])
console.log(arr1)
手写 Promise 代码
这是一个经典的面试问题了,我将它放了最后,不废话直接上代码,共分为 5 部份完成,实现思路如下,理清了 Promise 的实现原理,很多问题自然就迎刃而解了。
1. 声明 MayJunPromise 类
主要在构造函数里做一些初始化操作
- 行 {1} 初始化一些默认值,Promise 的状态、成功时的 value、失败时的原因
- 行 {2} onResolvedCallbacks 用于一些异步处理 const p = new Promise(resolve => { setTimeout(function(){ resolve(1) }, 5000) }),当 resolve 在 setTimeout 里时,我们调用 p.then() 此时的状态为 pending,因此我们需要一个地方来保存,此处就是用于保存 Promise resolve 时的回调函数集合
- 行 {3} onRejectedCallbacks 与行 {2} 同理,保存 Promise reject 回调函数集合
- 行 {4} 成功时回调,先进行状态判断是不可逆的,如果 status = pending 修改状态和成功时的 value
- 行 {5} 失败时回调,与上面行 {4} 同理,例如 resolve(1); reject('err'); 第二个 reject 就无法覆盖
- 行 {6} 自执行
- 行 {7} 运行失败错误捕获
/**
* 封装一个自己的 Promise
*/
class MayJunPromise {
constructor(fn) {
// {1} 初始化一些默认值
this.status = 'pending'; // 一个 promise 有且只有一个状态 (pending | fulfilled | rejected)
this.value = undefined; // 一个 JavaScript 合法值(包括 undefined,thenable,promise)
this.reason = undefined; // 是一个表明 promise 失败的原因的值
this.onResolvedCallbacks = []; // {2}
this.onRejectedCallbacks = []; // {3}
// {4} 成功回调
let resolve = value => {
if (this.status === 'pending') {
this.status = 'fulfilled'; // 终态
this.value = value; // 终值
this.onResolvedCallbacks.forEach(itemFn => {
itemFn()
});
}
}
// {5} 失败回调
let reject = reason => {
if (this.status === 'pending') { // 状态不可逆,例如 resolve(1);reject('err'); 第二个 reject 就无法覆盖
this.status = 'rejected'; // 终态
this.reason = reason; // 终值
this.onRejectedCallbacks.forEach(itemFn => itemFn());
}
}
try {
// {6} 自执行
fn(resolve, reject);
} catch(err) {
reject(err); // {7} 失败时捕获
}
}
}
2. Then 方法
- 一个 promise 必须提供一个 then 方法以访问其当前值、终值和据因
- 行 {8} onFulfilled、onRejected 这两个参数可选,由于 Promise .then 是可以链式调用的,对于值穿透的场景要做判断,如果不传,则返回一个函数,也就是将上个结果进行传递
- 行 {9} then 方法必须返回一个 promise 对象
- 行 {10}、{11} 、{12} 也是 then 方法内实现的三种情况,相类似,次数只拿状态等于 fulfilled 进行说明
- 行 {10.1} Promise/A+ 规范定义:要确保 onFulfilled、onRejected 在下一轮事件循环中被调用,你可以使用 setTimeout 来实现,因为我这里是在 Node.js 环境下,因此推荐使用了 setImmediate 来注册事件(因为可以避免掉 setTimeout 的延迟)
- 行 {10.2} Promise/A+ 标准规定:如果 onFulfilled 或 onRejected 返回的是一个 x,那么它会以 [[Resolve]](promise2, x) 处理解析,我们定义解析的函数 resolveMayJunPromise,也是一个核心函数,下面进行讲解
/**
* 封装一个自己的 Promise
*/
class MayJunPromise {
...
/**
* 一个 promise 必须提供一个 then 方法以访问其当前值、终值和据因
* @param { Function } onFulfilled 可选,如果是一个函数一定是在状态为 fulfilled 后调用,并接受一个参数 value
* @param { Function } onRejected 可选,如果是一个函数一定是在状态为 rejected 后调用,并接受一个参数 reason
* @returns { Promise } 返回值必须为 Promise
*/
then(onFulfilled, onRejected) {
// {8} 值穿透,把 then 的默认值向后传递,因为标准规定 onFulfilled、onRejected 是可选参数
// 场景:new Promise(resolve => resolve(1)).then().then(value => console.log(value));
onFulfilled = Object.prototype.toString.call(onFulfilled) === '[object Function]' ? onFulfilled : function(value) {return value};
onRejected = Object.prototype.toString.call(onRejected) === '[object Function]' ? onRejected : function(reason) {throw reason};
// {9} then 方法必须返回一个 promise 对象
const promise2 = new MayJunPromise((resolve, reject) => {
// {10}
if (this.status === 'fulfilled') { // 这里的 this 会继承外层上下文绑定的 this
// {10.1} Promise/A+ 规定:确保 onFulfilled、onRejected 在下一轮事件循环中被调用
// 可以使用宏任务 (setTimeout、setImmediate) 或微任务(MutationObsever、process.nextTick)
setImmediate(() => {
try {
// {10.2} Promise/A+ 标准规定:如果 onFulfilled 或 onRejected 返回的是一个 x,那么它会以 [[Resolve]](promise2, x) 处理解析
const x = onFulfilled(this.value);
// 这里定义解析 x 的函数为 resolveMayJunPromise
resolveMayJunPromise(promise2, x, resolve, reject);
} catch (e) {
reject(e);
}
});
}
// {11}
if (this.status === 'rejected') {
setImmediate(() => {
try {
const x = onRejected(this.reason)
resolveMayJunPromise(promise2, x, resolve, reject);
} catch (e) {
reject(e);
}
});
}
// {12}
// 有些情况无法及时获取到状态,初始值仍是 pending,例如:
// return new Promise(resolve => { setTimeout(function() { resolve(1) }, 5000) })
// .then(result => { console.log(result) })
if (this.status === 'pending') {
this.onResolvedCallbacks.push(() => {
setImmediate(() => {
try {
const x = onFulfilled(this.value);
resolveMayJunPromise(promise2, x, resolve, reject);
} catch (e) {
reject(e);
}
});
});
this.onRejectedCallbacks.push(() => {
setImmediate(() => {
try {
const x = onRejected(this.reason)
resolveMayJunPromise(promise2, x, resolve, reject);
} catch (e) {
reject(e);
}
});
});
}
});
return promise2;
}
}
3. Promise 解决过程
声明函数 resolveMayJunPromise(),Promise 解决过程是一个抽象的操作,在这里可以做到与系统的 Promise 或一些遵循 Promise/A+ 规范的 Promise 实现相互交互,以下代码建议跟随 Promise/A+ 规范进行阅读,规范上面也写的很清楚。
注意:在实际编码测试过程中规范 [2.3.2] 样写还是有点问题,你要根据其它的 Promise 的状态值进行判断,此处注释掉了,建议使用 [2.3.3] 也是可以兼容的 。
/**
* Promise 解决过程
* @param { Promise } promise2
* @param { any } x
* @param { Function } resolve
* @param { Function } reject
*/
function resolveMayJunPromise(promise2, x, resolve, reject){
// [2.3.1] promise 和 x 不能指向同一对象,以 TypeError 为据因拒绝执行 promise,例如:
// let p = new MayJunPromise(resolve => resolve(1))
// let p2 = p.then(() => p2); // 如果不做判断,这样将会陷入死循环
if (promise2 === x) {
return reject(new TypeError('Chaining cycle detected for promise'));
}
// [2.3.2] 判断 x 是一个 Promise 实例,可以能使来自系统的 Promise 实例,要兼容,例如:
// new MayJunPromise(resolve => resolve(1))
// .then(() => new Promise( resolve => resolve(2)))
// 这一块发现也无需,因为 [2.3.3] 已经包含了
// if (x instanceof Promise) {
// // [2.3.2.1] 如果 x 是 pending 状态,那么保留它(递归执行这个 resolveMayJunPromise 处理程序)
// // 直到 pending 状态转为 fulfilled 或 rejected 状态
// if (x.status === 'pending') {
// x.then(y => {
// resolveMayJunPromise(promise2, y, resolve, reject);
// }, reject)
// } else if (x.status === 'fulfilled') { // [2.3.2.2] 如果 x 处于执行态,resolve 它
// x.then(resolve);
// } else if (x.status === 'rejected') { // [2.3.2.3] 如果 x 处于拒绝态,reject 它
// x.then(reject);
// }
// return;
// }
// [2.3.3] x 为对象或函数,这里可以兼容系统的 Promise
// new MayJunPromise(resolve => resolve(1))
// .then(() => new Promise( resolve => resolve(2)))
if (x != null && (x instanceof Promise || typeof x === 'object' || typeof x === 'function')) {
let called = false;
try {
// [2.3.3.1] 把 x.then 赋值给 then
// 存储了一个指向 x.then 的引用,以避免多次访问 x.then 属性,这种预防措施确保了该属性的一致性,因为其值可能在检索调用时被改变。
const then = x.then;
// [2.3.3.3] 如果 then 是函数(默认为是一个 promise),将 x 作为函数的作用域 this 调用之。
// 传递两个回调函数作为参数,第一个参数叫做 resolvePromise (成功回调) ,第二个参数叫做 rejectPromise(失败回调)
if (typeof then === 'function') {
// then.call(x, resolvePromise, rejectPromise) 等价于 x.then(resolvePromise, rejectPromise),笔者理解此时会调用到 x 即 MayJunPromise 我们自己封装的 then 方法上
then.call(x, y => { // [2.3.3.3.1] 如果 resolvePromise 以值 y 为参数被调用,则运行 [[Resolve]](promise, y)
if (called) return;
called = true;
resolveMayJunPromise(promise2, y, resolve, reject);
}, e => { // [2.3.3.3.2] 如果 rejectPromise 以据因 r 为参数被调用,则以据因 r 拒绝 promise
if (called) return;
called = true;
reject(e);
});
} else {
// [2.3.3.4 ] 如果 then 不是函数,以 x 为参数执行 promise
resolve(x)
}
} catch(e) { // [2.3.3.2] 如果取 x.then 的值时抛出错误 e ,则以 e 为据因拒绝 promise
if (called) return;
called = true;
reject(e);
}
} else {
resolve(x);
}
}
4. 验证你的 Promise 是否正确
Promise 提供了一个测试脚本,进行正确性验证。
npm i -g promises-aplus-tests
promises-aplus-tests mayjun-promise.js
同时需要暴露出一个 deferred 方法。
MayJunPromise.defer = MayJunPromise.deferred = function () {
let dfd = {}
dfd.promise = new MayJunPromise((resolve,reject)=>{
dfd.resolve = resolve;
dfd.reject = reject;
});
return dfd;
}
module.exports = MayJunPromise;
5. catch、resolve、reject、all、race 方法实现
Promise/A+ 规范中只提供了 then 方法,但是我们使用的 catch、Promise.all、Promise.race 等都可以在 then 方法的基础上进行实现
class MayJunPromise {
constructor(fn){...}
then(){...},
/**
* 捕获错误
* @param { Function } onRejected
*/
catch(onRejected) {
return this.then(undefined, onRejected);
}
}
/**
* 仅返回成功态,即 status = fulfilled
*/
MayJunPromise.resolve = function(value) {
return (value instanceof Promise || value instanceof MayJunPromise) ? value // 如果是 Promise 实例直接返回
: new MayJunPromise(resolve => resolve(value));
}
/**
* 仅返回失败态,即 status = rejected
*/
MayJunPromise.reject = function(value) {
return (value instanceof Promise || value instanceof MayJunPromise) ? value : new MayJunPromise(reject => reject(value));
}
/**
* MayJunPromise.all() 并行执行
* @param { Array } arr
* @returns { Array }
*/
MayJunPromise.all = function(arr) {
return new MayJunPromise((resolve, reject) => {
const length = arr.length;
let results = []; // 保存执行结果
let count = 0; // 计数器
for (let i=0; i<length; i++) {
MayJunPromise.resolve(arr[i]).then(res => {
results[i] = res;
count++;
if (count === length) { // 全部都变为 fulfilled 之后结束
resolve(results);
}
}, err => reject(err)); // 只要有一个失败,就将失败结果返回
}
});
}
/**
* MayJunPromise.race() 率先执行,只要一个执行完毕就返回结果;
*/
MayJunPromise.race = function(arr) {
return new MayJunPromise((resolve, reject) => {
for (let i=0; i<arr.length; i++) {
MayJunPromise.resolve(arr[i])
.then(result => resolve(result), err => reject(err));
}
})
}
6. 并发请求控制
Promise.all 同时将请求发出,假设我现在有上万条请求,势必会造成服务器的压力,如果我想限制在最大并发 100 该怎么做?例如,在 Chrome 浏览器中就有这样的限制,Chrome 中每次最大并发链接为 6 个,其余的链接需要等待其中任一个完成,才能得到执行,下面定义 allByLimit 方法实现类似功能。
/**
* 并发请求限制
* @param { Array } arr 并发请求的数组
* @param { Number } limit 并发限制数
*/
MayJunPromise.allByLimit = function(arr, limit) {
const length = arr.length;
const requestQueue = [];
const results = [];
let index = 0;
return new MayJunPromise((resolve, reject) => {
const requestHandler = function() {
console.log('Request start ', index);
const request = arr[index]().then(res => res, err => {
console.log('Error', err);
return err;
}).then(res => {
console.log('Number of concurrent requests', requestQueue.length)
const count = results.push(res); // 保存所有的结果
requestQueue.shift(); // 每完成一个就从请求队列里剔除一个
if (count === length) { // 所有请求结束,返回结果
resolve(results);
} else if (count < length && index < length - 1) {
++index;
requestHandler(); // 继续下一个请求
}
});
if (requestQueue.push(request) < limit) {
++index;
requestHandler();
}
};
requestHandler()
});
}
测试,定义一个 sleep 睡眠函数,模拟延迟执行
/**
* 睡眠函数
* @param { Number } ms 延迟时间|毫秒
* @param { Boolean } flag 默认 false,若为 true 返回 reject 测试失败情况
*/
const sleep = (ms=0, flag=false) => new Promise((resolve, reject) => setTimeout(() => {
if (flag) {
reject('Reject ' + ms);
} else {
resolve(ms);
}
}, ms));
MayJunPromise.allByLimit([
() => sleep(5000, true),
() => sleep(1000),
() => sleep(1000),
() => sleep(4000),
() => sleep(10000),
], 3).then(res => {
console.log(res);
});
// 以下为运行结果
Request start 0
Request start 1
Request start 2
Number of concurrent requests 3
Request start 3
Number of concurrent requests 3
Request start 4
Error Reject 5000
Number of concurrent requests 3
Number of concurrent requests 2
Number of concurrent requests 1
[ 1000, 1000, 'Reject 5000', 4000, 10000 ]
7. Promise reference
- zhuanlan.zhihu.com/p/21834559
- juejin.cn/post/684490…
- promisesaplus.com/
- www.ituring.com.cn/article/665…
CO 实现原理
co 是一个自动触发调度 next 的函数
/**
* 定义一个生成器函数 test
*/
function *test() {
yield 1;
const second = yield Promise.resolve(2);
// console.log('second', second);
const third = yield 3;
// console.log('third', third);
return 'ok!';
}
const gen = test();
console.log(gen.next()) // { value: 1, done: false }
console.log(gen.next()) // { value: Promise { 2 }, done: false }
console.log(gen.next()) // { value: 3, done: false }
console.log(r.next()) // { value: 'ok!', done: true }
自定义一个 co 函数自动触发 next 函数
/**
* 自定义 CO 函数实现
* @param { Generator } gen 生成器函数
*/
function mayJunCo(gen) {
return new Promise((resolve, reject) => {
function fn(data) {
const { value, done } = gen.next(data);
// 如果 done 为 true 递归到尾结束
if (done) return resolve(value);
// 否则递归 fn 函数自动执行迭代器
Promise.resolve(value).then(fn, reject);
}
return fn();
})
}
mayJunCo(test()).then(console.log)
总结
回到文章最开始的:“手写/机写代码在当今互联网面试中已占据重要地位”,尽管它被经常吐槽,但仍未动摇,牛客网、LeetCode 等这些平台足可以证明这一点了。
如果面试者能通过手写/机写代码面试,从面试角度这也许可以预测面试者有胜任工作的一些专业技能,但是这也并非是一个完全肯定的答案,如果仅凭这一点,也许你会放进来错误的人,而拒绝优秀的人,就像一个人可能会非常善于做题,但在现实世界中解决真实问题的经验不一定多。
做为面试者,如果在某次手写/机写代码面试中失败了,也不要因为被拒绝而对自己失去信心,同样的这也并不能完全的评估你是否能胜任这份工作。
本文是 “五月君” 在日常的一些记录,并非一日所完成,如果不写上这么多关于 “手写/机写代码” 的思考,就更像一个 “十道 JavaScript 高频面试题你还不会吗?”,别人的始终的是别人的,自己也要多思考、多动手实践。希望本文这些思考与技术能对你有所帮助。
一个优秀的软件工程师,一定是善于思考与总结的,关注公众号 “五月君” 让我们一起成为自己心中优秀的软件工程师。