告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP01】

294 阅读13分钟

前言

当我们去准备面试,浏览其他同学在网上分享的面经时,我们会发现,很多公司都会出几道手写题来考察应试者。

不知道大家在遇到这些手写题的时候,是否也会同我一样,在脑海中产生这样的疑问👇

为什么我们会被考察手写题 ?

很多时候这些手写题的题目:

  • 要么是平时业务上根本用不到的(比如手写一个Promise
  • 要么是已经有很完善的轮子造好了的(比如手写一个深拷贝)
    • 人生苦短,我选lodash.cloneDeep ,😄

这看起来就像是面试官在刁难我们,让我们为了面试而面试,去背题,去做一些无用功

image.png

实则不然

其实一道手写题的背后是面试官对应试者多个方面能力的考察。比起一句一句问应试者是否掌握了某某知识点,通过手写题的方式更加能够考察应试者对于相关知识的掌握程度。

以“手写一个深拷贝”这道题为例,它都考察了哪些知识点?

  • JS的基本数据类型
  • 堆、栈、循环引用、递归 ——> JS Runtime
  • ES6新特性,你能够拷贝Symbol类型吗?
  • WeakMapMap的区别,为什么选择使用WeakMap ——> JS的垃圾回收机制
  • 为什么typeof null === 'object' ——> ECMAScript规范、JS底层实现
  • ES6新特性,为什么使用Reflect.ownKeys去替代obj.hasOwnProperty?

当我们去正视一道手写题,并尝试梳理它背后所涉及的知识点时,我们会发现,它并不是无意义的,里头的门道有很多。

作为一名前端开发工程师,从提升个人的技术水平这个立场出发,难道掌握一道手写题,不能够提升我们自己的技术水平吗?

  • 为什么要了解JS的垃圾回收机制
    • 因为能够帮助我们避免内存泄漏!如果在node.js服务端出现了内存泄漏,这是很严重的生产事故!
  • 为什么要了解ES6新特性?
    • 因为新的标准带来了新的内置功能和语法糖,能够简化代码的同时又提升我们的工作效率
  • 为什么要知道JS Runtime?
    • 了解它,我们才能将其与Web APIs结合,去搞明白浏览器的事件循环,从而做一些性能优化的工作,提升用户的使用体验,增强产品的竞争力

我们会被考察手写题的原因,由此可见一斑。

如果是背面试题,尤其是背手写题的话,那真的很痛苦,而且在面试的时候,压力一大,一旦卡壳,就很容易露馅。

image.png

因此,本篇文章会从手写题背后的知识点出发,循序渐进,通过理解知识点的方式来掌握手写题的解答。

我们应当将手写题视为一个深入理解JavaScript和编程原理的机会,而不是单纯的记忆任务。

最终,当我们能够将这些手写题背后的知识点融会贯通,我们不仅能在面试中脱颖而出,更能在日常工作中游刃有余,成为一名真正技术过硬的前端开发工程师。

maomao.gif

做题

实现一个 compose 函数

描述

期望你能够实现一个 compose 函数,该函数的用法如下:

function fn1(x) {
  return x + 1;
}
function fn2(x) {
  return x + 2;
}
function fn3(x) {
  return x + 3;
}
function fn4(x) {
  return x + 4;
}
const sum = compose(fn1, fn2, fn3, fn4);
console.log(sum(823)); 
// 输出:833
// 833 = 823+4+3+2+1

思路

根据描述内容,我们需要提供一个compose函数,这个函数接收多个函数类型的参数,并返回组合后的一个新的函数(即返回值类型是函数)。

我们可以大致先写下这么一个代码:

function compose(fn1, fn2, fn3, fn4) {
    // do something
    return function () { }
}

虽然描述内容给的示例是compose接收4个函数类型的参数,但是从实用性的角度来看,显然,compose的参数个数并不是固定的,它可以是0个、也可以是1个、也可以是10个,等等。

(这种感觉就像在力扣上刷算法题一样,题目给的用例只是两三个,实际上我们的代码需要执行通过的用例却是几百个。)

于是,我们就遇到了👇第一个问题👇:

如何定义一个参数个数不固定的函数呢?

知识点01

使用 ES6 新特性,剩余参数 - JavaScript | MDN (mozilla.org)

剩余参数 语法允许我们将一个不定数量的参数表示为一个数组。

——MDN

看完定义后可能还不是那么明了,我们看个更直观的代码例子:

function multiple(...args) {
    let result = 1;
    for (const arg of args) {
        result *= arg;
    }
    return result;
}
console.log(multiple(8));
// 期望输出: 8

console.log(multiple(8, 2));
// 期望输出: 16

console.log(multiple(8, 2, 3));
// 期望输出: 48

根据上方的代码例子,我们可以看到,multiple函数的第一个也是最后一个命名参数以...为前缀,这就是 剩余参数 的一种使用方式。

👇我们可以得出这样一条结论👇:

如果函数的最后一个命名参数以...为前缀,则它将成为一个由剩余参数组成的真数组,其中索引为0(包括)到索引为args.length - 1的元素由传递给函数的实际参数提供。

👇我们可以使用Array.isArray()去判断args是否真的如上所述,是个数组👇:

image.png

既然 剩余参数 是ES6的新特性,那么在 剩余参数 出来之前(即ECMAScript2015之前),当时的面试官如果拿这道题去考当时的应试者,应试者又该怎么去写呢?

argument,让我们也看一个代码例子:

function test() {
    // 获取传入的所有函数参数
    var args = arguments;
    console.log('arguments', arguments)
    console.log('index 0', arguments[0])
    console.log('index 1', arguments[1])
}

test(1, 2, 3, 4, 5)

image.png

👇我们来对比一下 剩余参数argument👇:

  • 剩余参数只包含那些没有对应形参的实参,而 arguments 对象包含了传给函数的所有实参。
  • arguments对象不是一个真正的数组,而剩余参数是真正的 Array实例,也就是说你能够在它上面直接使用所有的数组方法,比如 sortmapforEachpop
  • arguments对象还有一些附加的属性(如callee属性)。

得益于 剩余参数 ,我们成功解决了遇到的第一个问题,现在我们可以将最初版的代码稍作修改,如下:

function compose(...fns) {
    // do something
    return function () { }
}

现在,我们能够在compose内部拿到fnsfns是一个Array实例,是一个数组。

出于力扣选手的做题“惯性”,遇到数组,我们一定要想到边界条件

image.png

这是测试用例里头很喜欢埋伏的一种类型:

  • 比如给你一个数组为空时的用例
  • 或者数组长度为1时的用例

这些用例都是我们在写和处理数组相关的函数中需要特别注意的,有时候能帮助我们削减一部分的时间复杂度,或者避免错误逻辑。

回到这道题,我们想一想,如果不传任何参数给compose函数,是不是就是对应fns数组为空的情况,此时我们要做什么呢?难道我们还要继续去考虑如何进行多个函数的嵌套组合吗?

显然不需要,因此这就是我们要处理的边界条件,针对边界条件,给出特殊的处理逻辑。

结合题目描述中的要求,当出现这种情况时:

const sum = compose() // 没有传入任何函数

console.log(sum(823)) // 期望输出823,不会对值做任何修改

我们期望此时传给sum什么,就输出什么,因为实际上compose函数不会对参数值做任何修改。

👇于是我们又可以改进一下作为答案的代码了👇:

function compose(...fns) {
    if (!fns.length) {
        // 如果没有传入函数,返回一个接受任何值并返回该值的函数
        return function (value) {
            return value;
        };
    }
    // do something
    return function () { }
}

image.png

同理,当传给compose函数的参数只有1个时,此时我们是不是也不需要进行多个函数嵌套组合的逻辑?

但是它又与不传任何参数给compose函数不同,因此针对这种情况,我们也要给出特殊的处理逻辑。

结合题目描述中的要求,当出现这种情况时:

// 测试函数
function fn1(x) {
    return x + 1;
}
const sum = compose(fn1) // 传入一个参数

console.log(sum(823)) // 期望输出824,824 = 823 + 1

根据期望的输出结果,实际上此时sum(823)fn1(823)的输出结果是相同的。也就是说,当传给compose函数的参数只有1个时,我们直接返回这个函数类型的参数即可。 👇于是我们又可以改进一下作为答案的代码了👇:

function compose(...fns) {
    if (!fns.length) {
        // 如果没有传入函数,返回一个接受任何值并返回该值的函数
        return function (value) {
            return value;
        };
    }
    else if (fns.length == 1) {
        return fns[0]
    }
    else {
        // do something
        return function () { }
    }
}

image.png

ok,至此我们解决了不固定个数的传参设置的问题、边界条件缺失特殊处理逻辑的问题,最后剩下的就是解决多个函数嵌套组合的问题。

如何解决多个函数嵌套组合的问题呢?

知识点02

使用Array.prototype.reduce() - JavaScript | MDN (mozilla.org)

reduce()  方法对数组中的每个元素按序执行一个提供的 reducer 函数,每一次运行 reducer 会将先前元素的计算结果作为参数传入,最后将其结果汇总为单个返回值。

第一次执行回调函数时,不存在“上一次的计算结果”。如果需要回调函数从数组索引为 0 的元素开始执行,则需要传递初始值。否则,数组索引为 0 的元素将被用作初始值,迭代器将从第二个元素开始执行(即从索引为 1 而不是 0 的位置开始)。

——MDN

👇同样的,让我们通过一个代码例子更直观地了解👇:

// 快速计算数组中全部成员的和

const nums = [1, 2, 3, 4, 5];

const sum = nums.reduce((pre, cur) => pre + cur, 0);

console.log(sum);
// 期望输出:15
  • pre: 代表上一次的计算结果,如果传入了第二个参数(也就是例子中的0),则pre的初始值就是0,否则,是索引值为0的数组成员(即nums[0]
  • cur: 代表当前遍历到的数组成员,如果传入了第二个参数(也就是例子中的0),则cur的初始值是索引值为0的数组成员,否则,是索引值为1的数组成员。

我们可以通过一个表格展示各阶段时precur的值:

precur
01
12
33
64
105

ok,根据上面对reduce的介绍,我们已经大致了解了它的使用。现在回到题目中,我们最后剩下要做的工作就是实现多个函数的组合嵌套,期望达成这样的效果:

const sum = fn1(fn2(fn3(fn4(823))))
// 期望输出:1 + 2 + 3 + 4 + 823 = 833

这一串组合的样子是不是可以通过不给reduce传递第2个参数,从而使得pre的初始值直接是fn1

image.png

然后利用pre去嵌套cur,即可实现多个函数的组合嵌套效果。

我们可以通过一个表格展示各阶段时precur的值:

precur
fn1fn2
fn1(fn2)fn3
fn1(fn2(fn3))fn4

我们可以通过可视化工具看下执行过程:

动画掘金timu123.gif

👇于是我们最后再改进一下作为答案的代码👇:

function compose(...fns) {
    if (!fns.length) {
        // 如果没有传入函数,返回一个接受任何值并返回该值的函数
        return function (value) {
            return value;
        };
    }
    else if (fns.length == 1) {
        return fns[0]
    }
    else {
        return fns.reduce(
            (pre, cur) =>
                // 由于题目要求的sum只接收1个参数,因此这里
                // 作为reduce结果返回的函数
                // 也只需要接收1个参数即可
                (num) =>
                    pre(cur(num))
        );
    }
}

我们发现,reduce中回调函数的写法和普通的函数不同:

() => {}

没错,这又是一个知识点,箭头函数表达式 - JavaScript | MDN (mozilla.org)

知识点03

箭头函数表达式的语法比传统的函数表达式更简洁,但在语义上有一些差异,在用法上也有一些限制:

更详细的差异,比如:

  • 箭头函数中的this指向
  • call()、apply()、bind()方法在箭头函数上调用为什么不会生效
  • 一些限制,比如箭头函数不能用作构造函数、不能用作生成器、没有参数绑定等等....

大家可以通过阅读MDN的文档详细了解,本文不再赘述。

在这道题当中,我们主要是看中了箭头函数的“简洁性”,于是我们把fns.length为0的情况也返回成箭头函数的形式,使代码更简洁,得到最终版的答案。

答案

function compose(...fns) {
    if (!fns.length) {
        // 如果没有传入函数,返回一个接受任何值并返回该值的函数
        return (value) => value
    }
    else if (fns.length == 1) {
        return fns[0]
    }
    else {
        return fns.reduce(
            (pre, cur) =>
                // 由于题目要求的sum只接收1个参数,因此这里
                // 作为reduce结果返回的函数
                // 也只需要接收1个参数即可
                (num) =>
                    pre(cur(num))
        );
    }
}

image.png

结语

在本篇文章中,我罗列了1道手写题,和大家一起遵循思路=>知识点=>答案的步骤,完成了代码的编写。在这个过程中,我们再一次熟悉了 ES6 的新特性,也对Javascript相关的内容有了更深的理解。

不知道大家是否能够习惯这种学习的方式呢?

虽然只是1道手写题的深入浅出解析,但是正文字数却达到了近3700字....

如果再多加几题,可能总字数又会飙到一万以上了😓,因此作为本系列的第一篇文章,我很保守地只放了1道题,对此如果你有任何建议或意见,欢迎评论区留言或者直接私信我。

期待与大家在下一篇EP02相遇!

image.png