前端实现手写签字_一次搞定前端“四大手写”

「爱情、让人受尽委屈。」 2023-01-07 15:27 255阅读 0赞

2072ca002a62c30574faf99aedee1f9f.png

本文首发于个人GitHub博客

要问程序员最心虚的面试题,如果要投票选择,手撕代码一定是前三位的。其中在前端领域,以手写 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 的一个拷贝。使用方法如下:let boundFunc = func.bind(thisArg[, arg1[, arg2[, ...argN]]])
那怎么实现呢?我认为手写 bind 可以分为三个境界:

  1. 初级:只用 ES6 新语法

    • 优点:因为可以使用 const... 操作符,代码简洁
    • 缺点:兼容性稍差
  2. 中级:使用 ES5 语法

    • 优点:兼容 IE(其实可以忽略)
    • 缺点:参数要用Array.prototype.slice 获取,复杂且不支持 new
  3. 高级:ES5 + 支持 new

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

初级 bind

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

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

中级 bind

  • 优点:兼容 IE
  • 缺点:参数要用Array.prototype.slice 取,复杂且不支持 new

    // 中级:兼容 ES5
    function bind_2(asThis) {

    var slice = Array.prototype.slice;
    var args = slice.call(arguments, 1);
    var fn = this;
    if (typeof fn !== “function”) {

    1. throw new Error("cannot bind non_function");

    }
    return function () {

    1. var args2 = slice.call(arguments, 0);
    2. return fn.apply(asThis, args.concat(args2));

    };
    }

高级 bind

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

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

new fn(args) 其实等价于

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

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

  1. // 高级:支持 new,例如 new (funcA.bind(thisArg, args))
  2. function bind_3(asThis) {
  3. var slice = Array.prototype.slice;
  4. var args1 = slice.call(arguments, 1);
  5. var fn = this;
  6. if (typeof fn !== "function") {
  7. throw new Error("Must accept function");
  8. }
  9. function resultFn() {
  10. var args2 = slice.call(arguments, 0);
  11. return fn.apply(
  12. resultFn.prototype.isPrototypeOf(this) ? this : asThis, // 用来绑定 this
  13. args1.concat(args2)
  14. );
  15. }
  16. resultFn.prototype = fn.prototype;
  17. return resultFn;
  18. }

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


手写深拷贝

先问这么几个问题,

  • 首先为什么要深拷贝?不希望数据被修改或者只需要部分修改数据。
  • 怎么实现深拷贝?简单需求用 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. class DeepClone {
  2. constructor() {
  3. this.cacheList = [];
  4. }
  5. clone(source) {
  6. if (source instanceof Object) {
  7. const cache = this.findCache(source);
  8. if (cache) return cache; // 如果找到缓存,直接返回
  9. else {
  10. let target;
  11. if (source instanceof Array) {
  12. target = new Array();
  13. } else if (source instanceof Function) {
  14. target = function () {
  15. return source.apply(this, arguments);
  16. };
  17. } else if (source instanceof Date) {
  18. target = new Date(source);
  19. } else if (source instanceof RegExp) {
  20. target = new RegExp(source.source, source.flags);
  21. }
  22. this.cacheList.push([source, target]); // 把源对象和新对象放进缓存列表
  23. for (let key in source) {
  24. if (source.hasOwnProperty(key)) {
  25. // 不拷贝原型上的属性,太浪费内存
  26. target[key] = this.clone(source[key]); // 递归克隆
  27. }
  28. }
  29. return target;
  30. }
  31. }
  32. return source;
  33. }
  34. findCache(source) {
  35. for (let i = 0; i < this.cacheList.length; ++i) {
  36. if (this.cacheList[i][0] === source) {
  37. return this.cacheList[i][1]; // 如果有环,返回对应的新对象
  38. }
  39. }
  40. return undefined;
  41. }
  42. }

补充一句,如果您想看详细的测试与运行结果,请参见 我的 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 方法的索引,并删除

    class EventHub {

    cache = {};
    on(eventName, fn) {

    1. this.cache[eventName] = this.cache[eventName] || [];
    2. this.cache[eventName].push(fn);

    }
    emit(eventName) {

    1. this.cache[eventName].forEach((fn) => fn());

    }
    off(eventName, fn) {

    1. const index = indexOf(this.cache[eventName], fn); // 这里用 this.cache[eventName].indexOf(fn) 完全可以,封装成函数是为了向下兼容
    2. if (index === -1) return;
    3. 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) {

    1. if (arr[i] === item) {
    2. index = i;
    3. break;
    4. }

    }
    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. class Promise2 {
  2. state = "pending";
  3. callbacks = [];
  4. constructor(fn) {
  5. if (typeof fn !== "function") {
  6. throw new Error("must pass function");
  7. }
  8. fn(this.resolve.bind(this), this.reject.bind(this));
  9. }
  10. resolve(result) {
  11. if (this.state !== "pending") return;
  12. this.state = "fulfilled";
  13. nextTick(() => {
  14. this.callbacks.forEach((handle) => {
  15. if (typeof handle[0] === "function") {
  16. handle[0].call(undefined, result);
  17. }
  18. });
  19. });
  20. }
  21. reject(reason) {
  22. if (this.state !== "pending") return;
  23. this.state = "rejected";
  24. nextTick(() => {
  25. this.callbacks.forEach((handle) => {
  26. if (typeof handle[1] === "function") {
  27. handle[1].call(undefined, reason);
  28. }
  29. });
  30. });
  31. }
  32. then(succeed, fail) {
  33. const handle = [];
  34. if (typeof succeed === "function") {
  35. handle[0] = succeed;
  36. }
  37. if (typeof fail === "function") {
  38. handle[1] = fail;
  39. }
  40. this.callbacks.push(handle);
  41. }
  42. }
  43. function nextTick(fn) {
  44. if (process !== undefined && typeof process.nextTick === "function") {
  45. return process.nextTick(fn);
  46. } else {
  47. // 用MutationObserver实现浏览器上的nextTick
  48. var counter = 1;
  49. var observer = new MutationObserver(fn);
  50. var textNode = document.createTextNode(String(counter));
  51. observer.observe(textNode, {
  52. characterData: true,
  53. });
  54. counter += 1;
  55. textNode.data = String(counter);
  56. }
  57. }

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


最后

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

发表评论

表情:
评论列表 (有 0 条评论,255人围观)

还没有评论,来说两句吧...

相关阅读

    相关 前端都是ECharts ?

    一、自定义的必要性      绘制的底层是强大的,我们所用的各端语言只是在现代UI追求的步伐中和用户喜好的交互中求同存异,抽取封装出自成个性风格的UI控件,当然面对万亿级