前言
当我们去准备面试,浏览其他同学在网上分享的面经时,我们会发现,很多公司都会出几道手写题来考察应试者。
不知道大家在遇到这些手写题的时候,是否也会同我一样,在脑海中产生这样的疑问👇
为什么我们会被考察手写题 ?
很多时候这些手写题的题目:
- 要么是平时业务上根本用不到的(比如手写一个
Promise
) - 要么是已经有很完善的轮子造好了的(比如手写一个深拷贝)
- 人生苦短,我选
lodash.cloneDeep
,😄
- 人生苦短,我选
这看起来就像是面试官在刁难我们,让我们为了面试而面试,去背题,去做一些无用功。
实则不然
其实一道手写题的背后是面试官对应试者多个方面能力的考察。比起一句一句问应试者是否掌握了某某知识点,通过手写题的方式更加能够考察应试者对于相关知识的掌握程度。
以“手写一个深拷贝”这道题为例,它都考察了哪些知识点?
- JS的基本数据类型
- 堆、栈、循环引用、递归 ——> JS Runtime
- ES6新特性,你能够拷贝
Symbol
类型吗? WeakMap
和Map
的区别,为什么选择使用WeakMap
——> JS的垃圾回收机制- 为什么
typeof null === 'object'
——> ECMAScript规范、JS底层实现 - ES6新特性,为什么使用
Reflect.ownKeys
去替代obj.hasOwnProperty
?
当我们去正视一道手写题,并尝试梳理它背后所涉及的知识点时,我们会发现,它并不是无意义的,里头的门道有很多。
作为一名前端开发工程师,从提升个人的技术水平这个立场出发,难道掌握一道手写题,不能够提升我们自己的技术水平吗?
- 为什么要了解JS的垃圾回收机制?
- 因为能够帮助我们避免内存泄漏!如果在node.js服务端出现了内存泄漏,这是很严重的生产事故!
- 为什么要了解ES6新特性?
- 因为新的标准带来了新的内置功能和语法糖,能够简化代码的同时又提升我们的工作效率
- 为什么要知道JS Runtime?
- 了解它,我们才能将其与Web APIs结合,去搞明白浏览器的事件循环,从而做一些性能优化的工作,提升用户的使用体验,增强产品的竞争力
我们会被考察手写题的原因,由此可见一斑。
如果是背面试题,尤其是背手写题的话,那真的很痛苦,而且在面试的时候,压力一大,一旦卡壳,就很容易露馅。
因此,本篇文章会从手写题背后的知识点出发,循序渐进,通过理解知识点的方式来掌握手写题的解答。
我们应当将手写题视为一个深入理解JavaScript
和编程原理的机会,而不是单纯的记忆任务。
最终,当我们能够将这些手写题背后的知识点融会贯通,我们不仅能在面试中脱颖而出,更能在日常工作中游刃有余,成为一名真正技术过硬的前端开发工程师。
做题
实现一个 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
是否真的如上所述,是个数组👇:
既然 剩余参数 是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)
👇我们来对比一下 剩余参数 和 argument
👇:
得益于 剩余参数 ,我们成功解决了遇到的第一个问题,现在我们可以将最初版的代码稍作修改,如下:
function compose(...fns) {
// do something
return function () { }
}
现在,我们能够在compose
内部拿到fns
,fns
是一个Array
实例,是一个数组。
出于力扣选手的做题“惯性”,遇到数组,我们一定要想到边界条件。
这是测试用例里头很喜欢埋伏的一种类型:
- 比如给你一个数组为空时的用例
- 或者数组长度为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 () { }
}
同理,当传给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 () { }
}
}
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的数组成员。
我们可以通过一个表格展示各阶段时pre
和cur
的值:
pre | cur |
---|---|
0 | 1 |
1 | 2 |
3 | 3 |
6 | 4 |
10 | 5 |
ok,根据上面对reduce
的介绍,我们已经大致了解了它的使用。现在回到题目中,我们最后剩下要做的工作就是实现多个函数的组合嵌套,期望达成这样的效果:
const sum = fn1(fn2(fn3(fn4(823))))
// 期望输出:1 + 2 + 3 + 4 + 823 = 833
这一串组合的样子是不是可以通过不给reduce
传递第2个参数,从而使得pre
的初始值直接是fn1
?
然后利用pre
去嵌套cur
,即可实现多个函数的组合嵌套效果。
我们可以通过一个表格展示各阶段时pre
和cur
的值:
pre | cur |
---|---|
fn1 | fn2 |
fn1(fn2) | fn3 |
fn1(fn2(fn3)) | fn4 |
我们可以通过可视化工具看下执行过程:
👇于是我们最后再改进一下作为答案的代码👇:
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))
);
}
}
结语
在本篇文章中,我罗列了1道手写题,和大家一起遵循思路=>知识点=>答案
的步骤,完成了代码的编写。在这个过程中,我们再一次熟悉了 ES6 的新特性,也对Javascript
相关的内容有了更深的理解。
不知道大家是否能够习惯这种学习的方式呢?
虽然只是1道手写题的深入浅出解析,但是正文字数却达到了近3700字....
如果再多加几题,可能总字数又会飙到一万以上了😓,因此作为本系列的第一篇文章,我很保守地只放了1道题,对此如果你有任何建议或意见,欢迎评论区留言或者直接私信我。
期待与大家在下一篇EP02相遇!