要问程序员最心虚的面试题,如果要投票选择,手撕代码一定是前三位的。其中在前端领域,以手写 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 可以分为三个境界:
- 初级:只用 ES6 新语法
- 优点:因为可以使用 const 、… 操作符,代码简洁
- 缺点:兼容性稍差
- 中级:使用 ES5 语法
- 优点:兼容 IE(其实可以忽略)
- 缺点:参数要用 Array.prototype.slice 获取,复杂且不支持 new
- 高级:ES5 + 支持 new
初级 bind
这种方式的优点是因为可以使用 const 、… 操作符,代码简洁;缺点是不兼容 IE 等一些古老浏览器
1 2 3 4 5 6 7
| function bind_1(asThis, ...args) { const fn = this; 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
| 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,
其实等价于:
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
| function bind_3(asThis) { var slice = Array.prototype.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, args1.concat(args2) ); } resultFn.prototype = fn.prototype; return resultFn; }
|
接下来是前端年年考,年年不会,网上博客又经常误人子弟的“手写深拷贝”。
手写深拷贝
先问这么几个问题,
- 首先为什么要深拷贝?不希望数据被修改或者只需要部分修改数据。
- 怎么实现深拷贝?简单需求用 JSON 反序列化,复杂需求用递归克隆。
- 手写深拷贝的优点?体现扎实的 JS 基础。
- 至于缺点以及如何解决稍后再回答
简单需求
最简单的手写深拷贝就一行,通过 JSON 反序列化来实现
1
| const B = JSON.parse(JSON.stringify(A))
|
缺点也是显而易见的,JSON value 不支持的数据类型,都拷贝不了
- 不支持函数
- 不支持 undefined(支持 null)
- 不支持循环引用,比如 a = {name: ‘a’}; a.self = a; a2 = JSON.parse(JSON.stringify(a))
- 不支持 Date,会变成 ISO8601 格式的字符串
- 不支持正则表达式
- 不支持 Symbol
如何支持这些复杂需求,就需要用到递归克隆了。
复杂需求
核心有三点:
- 递归
- 对象分类型讨论
- 解决循环引用(环)
下面给出我的模板:
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 →
递归克隆看起来很强大,但是完美无缺吗?其实还是有不小的距离:
- 对象类型支持不够多(Buffer,Map,Set 等都不支持)
- 存在递归爆栈的风险
如果要解决这些问题,实现一个”完美“的深拷贝,只能求教上百行代码的 Lodash.cloneDeep() 了 。
让我们再引申一下,深拷贝有局限吗?
深拷贝的局限
如果需要对一个复杂对象进行频繁操作,每次都完全深拷贝一次的话性能岂不是太差了,因为大部分场景下都只是更新了这个对象的某几个字段,而其他的字段都不变,对这些不变的字段的拷贝明显是多余的。那么问题来了,浅拷贝不更新,深拷贝性能差,怎么办?
这里推荐 3 个可以实现”部分“深拷贝的库:
- Immutable.js Immutable.js 会把对象所有的 key 进行 hash 映射,将得到的 hash 值转化为二进制,从后向前每 5 位进行分割后再转化为 Trie 树。Trie 树利用这些 hash 值的公共前缀来减少查询时间,最大限度地减少无谓 key 的比较。关于 Trie 树(字典树)的介绍,可以看我的博客算法基础 06-字典树、并查集、高级搜索、红黑树、AVL 树
- seamless-immutable,如果数据量不大但想用这种类似 updateIn 便利的语法的话可以用 seamless-immutable。这个库就没有上面的 Trie 树这些幺蛾子了,就是为其扩展了 updateIn、merge 等 9 个方法的普通简单对象,利用 Object.freeze 冻结对象本身改动, 每次修改返回副本。感觉像是阉割版,性能不及 Immutable.js,但在部分场景下也是适用的。
- Immer.js,通过用来数据劫持的 Proxy 实现:对原始数据中每个访问到的节点都创建一个 Proxy,修改节点时修改副本而不操作原数据,最后返回到对象由未修改的部分和已修改的副本组成。(这不就是 Vue3 数据响应式原理嘛)
总结
看完这一段,你现在能回答怎么实现深拷贝了吗?概括成一句就是:简单需求用 JSON 反序列化,复杂需求用递归克隆。
对于递归克隆的深拷贝,核心有三点:
- 对象分类
- 递归
- 用缓存对付环
手写 EventHub(发布-订阅)
核心思路是:
- 使用一个对象作为缓存
- on 负责把方法发布到缓存的 EventName 对应的数组
- emit 负责遍历触发(订阅) EventName 下的方法数组
- 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); if (index === -1) return; this.cache[eventName].splice(index, 1); } }
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 同理
实现思路:
- then(succeed, fail) 先把成功失败回调放到一个回调数组 callbacks[] 上
- resolve() 和 reject() 遍历 callbacks
- 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 { 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) { 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++ 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);
|
最后
总结一下,会手写关键代码对技术发展的重要性是不言而喻的,所以大家一定要勇于克服自己内心的恐惧,刻意练习,终有一天,你会体会到技术精进的快感!