前端四大手写
Published in:2020-07-23 | category: 前端 面试

要问程序员最心虚的面试题,如果要投票选择,手撕代码一定是前三位的。其中在前端领域,以手写 bind手写深拷贝手写 EventHub(发布-订阅)、手写 Promise最为常见,我将他们称为四大手写。本文的目的就是要破除大家对四大手写的恐惧,将从为什么要会手写,到每个手写的关键思路总结,再到最终模板,我都会毫无保留地分享给大家。话不多说,让我们开始吧。

为什么要会手写

面试遇到手写题一脸懵逼的你也许一定想问:网上代码一堆,随便抄一下不香吗,为什么要手写?关于这个问题最直接的回答:为了区分厉害的和普通的。但坦白来讲,会白板实现关键功能的人,实现业务需求的效率一定更高
为什么这么说?
拿手写 Promise 举例来讲,真实的业务场景会遇到大量的 AJAX 异步请求,而且大多是嵌套多层的异步代码。
普通前端 A 平时只会最简单的 Promise 用法,遇到多层嵌套的 Promise 就搞不清楚逻辑了,于是开发 1 小时,修 Bug 3 小时,内卷 996
高级前端 B 会手写 Promise,对 Promise 的内在逻辑一清二楚,于是开发半小时,修 Bug 15 分钟,完成质量高速度快,深受 PM 小姐姐和测试小哥哥的喜爱,准点下班绩效高
再举个 EventHub 的例子,会手写 EventHub 的前端,Vue 里的 $emit、$on 基本就是闭眼写;同理还有 React 里面组件想要调用普通函数(非箭头函数),需要 this.fn.bind(this),会手写 bind 的前端就更容易举一反三,不会的就只能死记硬背,遇到 Bug 不知所措。。。
所以,会“四大手写”是前端进阶的必由之路,甚至可以说,手写关键代码的能力 ≈ 编程能力


手写 bind

bind 用法不难,一句话解释就是把新的 this 绑定到某个函数 func 上,并返回 func 的一个拷贝。使用方法如下:

1
let boundFunc = func.bind(thisArg[, arg1[, arg2[, ...argN]]])


那怎么实现呢?我认为手写 bind 可以分为三个境界:

  1. 初级:只用 ES6 新语法
  • 优点:因为可以使用 const 、… 操作符,代码简洁
  • 缺点:兼容性稍差
  1. 中级:使用 ES5 语法
  • 优点:兼容 IE(其实可以忽略)
  • 缺点:参数要用 Array.prototype.slice 获取,复杂且不支持 new
  1. 高级:ES5 + 支持 new
  • 优点:支持 new
  • 缺点:最复杂

初级 bind

这种方式的优点是因为可以使用 const 、… 操作符,代码简洁;缺点是不兼容 IE 等一些古老浏览器

1
2
3
4
5
6
7
// 初级:ES6 新语法 const/...
function bind_1(asThis, ...args) {
const fn = this; // 这里的 this 就是调用 bind 的函数 func
return function (...args2) {
return fn.apply(asThis, ...args, ...args2);
};
}

中级 bind

  • 优点:兼容 IE
  • 缺点:参数要用 Array.prototype.slice 取,复杂且不支持 new
1
2
3
4
5
6
7
8
9
10
11
12
13
// 中级:兼容 ES5
function bind_2(asThis) {
var slice = Array.prototype.slice;
var args = slice.call(arguments, 1);
var fn = this;
if (typeof fn !== "function") {
throw new Error("cannot bind non_function");
}
return function () {
var args2 = slice.call(arguments, 0);
return fn.apply(asThis, args.concat(args2));
};
}

高级 bind

  • 优点:支持 new
  • 缺点:最复杂

写之前,我们先来看一看我们应该如何判断 new,

1
new fn(args)

其实等价于

1
2
3
4
const temp = {}
temp.__proto__ = fn.prototype
fn.apply(temp, [...args])
return temp

核心在第二句:temp.proto = fn.prototype,有了这个,我们便知道可以用 fn.prototype 是否为对象原型来判断是否为 new 的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 高级:支持 new,例如 new (funcA.bind(thisArg, args))
function bind_3(asThis) {
var slice = Array.prototype.slice;
// 改变数组slice方法的作用域,使 this 指向arguments对象
// call () 方法的第二个参数表示传递给slice的参数即截取数组的起始位置
var args1 = slice.call(arguments, 1);
var fn = this;
if (typeof fn !== "function") {
throw new Error("Must accept function");
}
function resultFn() {
var args2 = slice.call(arguments, 0);
return fn.apply(
resultFn.prototype.isPrototypeOf(this) ? this : asThis, // 用来绑定 this
args1.concat(args2)
);
}
resultFn.prototype = fn.prototype;
return resultFn;
}

接下来是前端年年考,年年不会,网上博客又经常误人子弟的“手写深拷贝”。


手写深拷贝

先问这么几个问题,

  • 首先为什么要深拷贝?不希望数据被修改或者只需要部分修改数据。
  • 怎么实现深拷贝?简单需求用 JSON 反序列化,复杂需求用递归克隆。
  • 手写深拷贝的优点?体现扎实的 JS 基础。
  • 至于缺点以及如何解决稍后再回答

简单需求

最简单的手写深拷贝就一行,通过 JSON 反序列化来实现

1
const B = JSON.parse(JSON.stringify(A))

缺点也是显而易见的,JSON value 不支持的数据类型,都拷贝不了

  1. 不支持函数
  2. 不支持 undefined(支持 null
  3. 不支持循环引用,比如 a = {name: ‘a’}; a.self = a; a2 = JSON.parse(JSON.stringify(a))
  4. 不支持 Date,会变成 ISO8601 格式的字符串
  5. 不支持正则表达式
  6. 不支持 Symbol

如何支持这些复杂需求,就需要用到递归克隆了。

复杂需求

核心有三点:

  1. 递归
  2. 对象分类型讨论
  3. 解决循环引用(环)

下面给出我的模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 深拷贝
class DeepClone {
constructor() {
this.cacheList = [];
}
clone(source) {
if (source instanceof Object) {
const cache = this.findCache(source);
if (cache) return cache; // 如果找到缓存,直接返回
else {
let target;
if (source instanceof Array) {
target = new Array();
} else if (source instanceof Function) {
target = function () {
return source.apply(this, arguments);
};
} else if (source instanceof Date) {
target = new Date(source);
} else if (source instanceof RegExp) {
target = new RegExp(source.source, source.flags);
}
this.cacheList.push([source, target]); // 把源对象和新对象放进缓存列表
for (let key in source) {
if (source.hasOwnProperty(key)) { // 不拷贝原型上的属性,太浪费内存
target[key] = this.clone(source[key]); // 递归克隆
}
}
return target;
}
}
return source;
}
findCache(source) {
for (let i = 0; i < this.cacheList.length; ++i) {
if (this.cacheList[i][0] === source) {
return this.cacheList[i][1]; // 如果有环,返回对应的新对象
}
}
return undefined;
}
}

补充一句,如果您想看详细的测试与运行结果,请参见 我的 GitHub →
递归克隆看起来很强大,但是完美无缺吗?其实还是有不小的距离:

  1. 对象类型支持不够多(Buffer,Map,Set 等都不支持)
  2. 存在递归爆栈的风险

如果要解决这些问题,实现一个”完美“的深拷贝,只能求教上百行代码的 Lodash.cloneDeep() 了 。
让我们再引申一下,深拷贝有局限吗?

深拷贝的局限

如果需要对一个复杂对象进行频繁操作,每次都完全深拷贝一次的话性能岂不是太差了,因为大部分场景下都只是更新了这个对象的某几个字段,而其他的字段都不变,对这些不变的字段的拷贝明显是多余的。那么问题来了,浅拷贝不更新,深拷贝性能差,怎么办?
这里推荐 3 个可以实现”部分“深拷贝的库:

  1. Immutable.js Immutable.js 会把对象所有的 key 进行 hash 映射,将得到的 hash 值转化为二进制,从后向前每 5 位进行分割后再转化为 Trie 树。Trie 树利用这些 hash 值的公共前缀来减少查询时间,最大限度地减少无谓 key 的比较。关于 Trie 树(字典树)的介绍,可以看我的博客算法基础 06-字典树、并查集、高级搜索、红黑树、AVL 树
  2. seamless-immutable,如果数据量不大但想用这种类似 updateIn 便利的语法的话可以用 seamless-immutable。这个库就没有上面的 Trie 树这些幺蛾子了,就是为其扩展了 updateIn、merge 等 9 个方法的普通简单对象,利用 Object.freeze 冻结对象本身改动, 每次修改返回副本。感觉像是阉割版,性能不及 Immutable.js,但在部分场景下也是适用的。
  3. Immer.js,通过用来数据劫持的 Proxy 实现:对原始数据中每个访问到的节点都创建一个 Proxy,修改节点时修改副本而不操作原数据,最后返回到对象由未修改的部分和已修改的副本组成。(这不就是 Vue3 数据响应式原理嘛)

总结

看完这一段,你现在能回答怎么实现深拷贝了吗?概括成一句就是:简单需求用 JSON 反序列化,复杂需求用递归克隆
对于递归克隆的深拷贝,核心有三点:

  1. 对象分类
  2. 递归
  3. 缓存对付

手写 EventHub(发布-订阅)

核心思路是:

  1. 使用一个对象作为缓存
  2. on 负责把方法发布到缓存的 EventName 对应的数组
  3. emit 负责遍历触发(订阅) EventName 下的方法数组
  4. off 方法的索引,并删除
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class EventHub {
cache = {};
on(eventName, fn) {
this.cache[eventName] = this.cache[eventName] || [];
this.cache[eventName].push(fn);
}
emit(eventName) {
this.cache[eventName].forEach((fn) => fn());
}
off(eventName, fn) {
const index = indexOf(this.cache[eventName], fn); // 这里用 this.cache[eventName].indexOf(fn) 完全可以,封装成函数是为了向下兼容
if (index === -1) return;
this.cache[eventName].splice(index, 1);
}
}

// 兼容 IE 8 的 indexOf
function indexOf(arr, item) {
if (arr === undefined) return -1;
let index = -1;
for (let i = 0; i < arr.length; ++i) {
if (arr[i] === item) {
index = i;
break;
}
}
return index;
}

如果您想看详细的测试与运行结果,请参见 我的 GitHub →


手写 Promise

无疑是要求最高的,如果要硬按照 Promises/A+ 规范来写,可能至少要 2-3 个小时,400+行代码,这种情况是几乎不可能出现在面试中。所以我们只需要完成一个差不多的版本,保留最核心的功能。
核心功能:

  • new Promise(fn) 其中 fn 只能为函数,且要立即执行
  • promise.then(success, fail)中的 success 是函数,且会在 resolve 被调用的时候执行,fail 同理

实现思路:

  1. then(succeed, fail) 先把成功失败回调放到一个回调数组 callbacks[] 上
  2. resolve() 和 reject() 遍历 callbacks
  3. resolve() 读取成功回调 / reject() 读取失败回调,并异步执行 callbacks 里面的成功和失败回调(放到本轮的微任务队列中)

下面分享我自己根据上述需求及思路实现的模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class Promise2 {
state = "pending";
callbacks = [];
constructor(fn) {
if (typeof fn !== "function") {
throw new Error("must pass function");
}
fn(this.resolve.bind(this), this.reject.bind(this));
}
resolve(result) {
if (this.state !== "pending") return;
this.state = "fulfilled";
nextTick(() => {
this.callbacks.forEach((handle) => {
if (typeof handle[0] === "function") {
handle[0].call(undefined, result);
}
});
});
}
reject(reason) {
if (this.state !== "pending") return;
this.state = "rejected";
nextTick(() => {
this.callbacks.forEach((handle) => {
if (typeof handle[1] === "function") {
handle[1].call(undefined, reason);
}
});
});
}
then(succeed, fail) {
const handle = [];
if (typeof succeed === "function") {
handle[0] = succeed;
}
if (typeof fail === "function") {
handle[1] = fail;
}
this.callbacks.push(handle);
}
}

function nextTick(fn) {
if (process !== undefined && typeof process.nextTick === "function") {
return process.nextTick(fn);
} else {
// 用MutationObserver实现浏览器上的nextTick
var counter = 1;
var observer = new MutationObserver(fn);
var textNode = document.createTextNode(String(counter));

observer.observe(textNode, {
characterData: true,
});
counter += 1;
textNode.data = String(counter);
}
}

同样的,如果您想看详细的测试与运行结果,请参见 我的 GitHub →


手写 Promise.all()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
function PromiseAll(promiseArray) {    //返回一个Promise对象
return new Promise((resolve, reject) => {

if (!Array.isArray(promiseArray)) { //传入的参数是否为数组
return reject(new Error('传入的参数不是数组!'))
}

const res = []
let counter = 0 //设置一个计数器
for (let i = 0; i < promiseArray.length; i++) {
Promise.resolve(promiseArray[i]).then(value => {
counter++ //使用计数器返回 必须使用counter
res[i] = value
if (counter === promiseArray.length) {
resolve(res)
}
}).catch(e => reject(e))
}
})
}

// 以上为核心实现

const p1 = new Promise((res, rej) => {
setTimeout(() => {
res('p1')
}, 1000)
})

const p2 = new Promise((res, rej) => {
setTimeout(() => {
res('p2')
}, 2000)
})

const p3 = new Promise((res, rej) => {
setTimeout(() => {
res('p3')
}, 3000)
})

const test = PromiseAll([p1, p2, p3])
.then(res => console.log(res))
.catch(e => console.log(e))

console.log(test);

最后

总结一下,会手写关键代码对技术发展的重要性是不言而喻的,所以大家一定要勇于克服自己内心的恐惧,刻意练习,终有一天,你会体会到技术精进的快感!

Prev:
css常见兼容问题
Next:
从输入url到页面加载