很多刚入行的同学对我说:“很多API记不住怎么办?数组这个方法那个方法总是傻傻的不清楚,怎么办?操作DOM的方法今天记住了,明天就忘记了,好郁闷啊!”
甚至有开发者在讨论面试时向我抱怨:“面试官总是执着于API的使用,甚至某些方法的参数顺序都要跟我说清楚!”
我觉得对于重复使用的方法,大家一定要做到“机械记忆”,能够反手写出来。 一些似乎永远不会被记住的API,根本就没有被充分利用过。
我当面试官的时候,从不强迫开发人员准确地“背诵”API。 相反,我喜欢换个角度审视面试官:“既然记不住怎么用,那我就告诉你怎么用,你自己去实现吧!” 实现一个API,除了考察面试官对API的理解程度之外,更能体现开发者的编程思维和编码能力。 对于有上进心的前端工程师来说,模仿和实现一些经典的方法应该是“常做的”,这是一个比较基本的要求。
在本节中,我根据自己理解的面试题和作为面试官的经验,选取了几个典型的API,通过不同程度、不同方式的实现,涵盖了API中的一些知识点和编程要点。 通过学习本节的内容,希望你不仅能了解代码的奥秘,还能学会以一例举其他情况的方法。
API专题相关知识点如下:
完成
这个话题是从今天头条某部门的一道面试题演变而来的。 当时面试官问:“如何获取文档中任意元素到文档顶部的距离?”
熟悉的同学应该不陌生,该方法返回或设置匹配元素相对于文档的偏移量(位置)。 此方法返回的对象包含两个整数属性:top 和 left,以像素为单位。 如果可用,我们可以直接调用 API 获取结果。 但是您如何以本地方式(即手动方式)进行操作呢?
有两个主要想法:
递归实现
我们通过遍历目标元素、目标元素的父节点、父节点的父节点依次溯源,累加这些遍历的节点相对于它们最近的祖先节点的偏移量(属性不是),向上 直到,相加得到结果。
其中,我们需要使用 来访问DOM节点的上边框相对于其值不是的最近的祖先元素的垂直偏移量。 具体实现是:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
const offset = ele => {
let result = {
top: 0,
left: 0
}
// 当前 DOM 节点的 display === 'none' 时, 直接返回 {top: 0, left: 0}
if (window.getComputedStyle(ele)['display'] === 'none') {
return result
}
let position
const getOffset = (node, init) => {
if (node.nodeType !== 1) {
return
}
position = window.getComputedStyle(node)['position']
if (typeof(init) === 'undefined' && position === 'static') {
getOffset(node.parentNode)
return
}
result.top = node.offsetTop + result.top - node.scrollTop
result.left = node.offsetLeft + result.left - node.scrollLeft
if (position === 'fixed') {
return
}
getOffset(node.parentNode)
}
getOffset(ele, true)
return result
}
上面的代码不难理解,是用递归实现的。 如果节点节点。 类型不是(1),跳出; 如果相关节点的属性是,则不计入计算,进入下一个节点(其父节点)的递归。 如果相关属性的属性为none,则直接返回0作为结果。
这个实现很好地考验了开发者对递归的初级应用和方法的掌握程度。
接下来我们换个思路,用一个比较新的API:t来实现方法。
方法
t方法用于描述元素的具体位置,这个位置的以下四个属性都是相对于视口左上角的位置。 在一个节点上执行该方法,返回值是一个类型的对象。 该对象表示一个矩形框,其中包含:left、top、right 等只读属性。
请参考实现:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
const offset = ele => {
let result = {
top: 0,
left: 0
}
// 当前为 IE11 以下,直接返回 {top: 0, left: 0}
if (!ele.getClientRects().length) {
return result
}
// 当前 DOM 节点的 display === 'none' 时,直接返回 {top: 0, left: 0}
if (window.getComputedStyle(ele)['display'] === 'none') {
return result
}
result = ele.getBoundingClientRect()
var docElement = ele.ownerdocument.documentElement
return {
top: result.top + window.pageYOffset - docElement.clientTop,
left: result.left + window.pageXOffset - docElement.clientLeft
}
}
需要注意的细节是:
从这个问题可以看出,这样的实现比考察“死记硬背”的API更有意义。 我经常站在面试官的角度,给面试官(开发者)提供相关的方法提示,引导他给出最终的解决方案。
数组方法的相关实现
数组方法很重要:因为数组就是数据,数据就是状态,状态反映视图。 对数组的操作我们不能陌生,方法也应该不陌生。 我觉得这种方法很好的体现了“函数式”的概念,也是目前非常热门的研究点之一。
我们知道该方法是ES5引入的,英文解释翻译为“, , , and ”。 MDN直接将方法描述为:
a 和数组的每个值(从左到右)给它一个值。
它的使用语法是:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
arr.reduce(callback[, initialValue])
下面我们简单介绍一下。
完成
我们来看一个典型的应用:按顺序运行:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
const runPromiseInSequence = (array, value) => array.reduce(
(promiseChain, currentFunction) => promiseChain.then(currentFunction),
Promise.resolve(value)
)
该方法会被一个数组调用,每一项返回一个,数组中的每一项都会依次执行,请仔细理解。 如果觉得晦涩难懂,可以参考例子:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
const f1 = () => new Promise((resolve, reject) => {
setTimeout(() => {
console.log('p1 running')
resolve(1)
}, 1000)
})
const f2 = () => new Promise((resolve, reject) => {
setTimeout(() => {
console.log('p2 running')
resolve(2)
}, 1000)
})
const array = [f1, f2]
const runPromiseInSequence = (array, value) => array.reduce(
(promiseChain, currentFunction) => promiseChain.then(currentFunction),
Promise.resolve(value)
)
runPromiseInSequence(array, 'init')
执行结果如下:
实现管道
另一个典型的应用可以参考函数式方法pipe的实现:pipe(f,g,h)是一个柯里化函数,返回一个新的函数,完成(...args)=> h(g(f(. ..args))) 调用。 即管道方法返回的函数会接收一个参数,该参数传递给管道方法的第一个参数,供其调用。
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
const pipe = (...functions) => input => functions.reduce(
(acc, fn) => fn(acc),
input
)
仔细理解pipe和pipe这两个方法,都是典型的应用场景。
实现一个
那么我们如何实施呢? 来自MDN的参考:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
if (!Array.prototype.reduce) {
Object.defineProperty(Array.prototype, 'reduce', {
value: function(callback ) {
if (this === null) {
throw new TypeError( 'Array.prototype.reduce ' +
'called on null or undefined' )
}
if (typeof callback !== 'function') {
throw new TypeError( callback +
' is not a function')
}
var o = Object(this)
var len = o.length >>> 0
var k = 0
var value
if (arguments.length >= 2) {
value = arguments[1]
} else {
while (k < len && !(k in o)) {
k++
}
if (k >= len) {
throw new TypeError( 'Reduce of empty array ' +
'with no initial value' )
}
value = o[k++]
}
while (k < len) {
if (k in o) {
value = callback(value, o[k], k, o)
}
k++
}
return value
}
})
}
上面代码中以value作为初值,通过while循环依次累加计算value的结果并输出。 但是相比上面MDN上的实现方式,我个人比较喜欢的实现方式是:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
Array.prototype.reduce = Array.prototype.reduce || function(func, initialValue) {
var arr = this
var base = typeof initialValue === 'undefined' ? arr[0] : initialValue
var startPoint = typeof initialValue === 'undefined' ? 1 : 0
arr.slice(startPoint)
.forEach(function(val, index) {
base = func(base, val, index + startPoint, arr)
})
return base
}
核心原理是用while代替while来实现结果累加,本质上是一样的。
我也看了ES5-shim中的那个,和上面的思路一模一样。 唯一的区别是:我使用的是迭代,而 ES5-shim 使用的是简单的 for 循环。 事实上,如果我们更精确一点,我们会指出数组方法对于 ES5 也是新的。 因此,用一个ES5 API( )实现另一个ES5 API( )没有实际意义——这里是模拟ES5不兼容情况下的降级方案。 这里不多做考察,根本目的是希望读者对本书有一个全面、透彻的了解。
通过koa only模块源码
通过对方法的理解和实现,我们对它有了更深的理解。 最后再来看一个使用示例——通过Koa源码的唯一模块来加深一下印象:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
var o = {
a: 'a',
b: 'b',
c: 'c'
}
only(o, ['a','b']) // {a: 'a', b: 'b'}
此方法返回具有指定过滤器属性的新对象。
唯一的模块实现:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
var only = function(obj, keys){
obj = obj || {}
if ('string' == typeof keys) keys = keys.split(/ +/)
return keys.reduce(function(ret, key) {
if (null == obj[key]) return ret
ret[key] = obj[key]
return ret
}, {})
}
小场景和衍生场景中有很多值得我们深思和探索的地方。 以此类推,灵活学习和应用是技术进步的关键。
几种方案实现
函数式概念——这个古老的概念如今在前端领域“遍地开花”。 函数式风格的很多思想值得借鉴,其中一个细节:因其设计巧妙而被广泛使用。 对于它的实现,从面向过程的到函数式的,风格迥异,值得探讨。 面试的时候,面试官经常会问到实现方法。 让我们先看看它是什么。
其实就像上面说的管道一样,就是执行一系列变长的任务(方法),比如:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
let funcs = [fn1, fn2, fn3, fn4]
let composeFunc = compose(...funcs)
实施:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
composeFunc(args)
相当于:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
fn1(fn2(fn3(fn4(args))))
总结该方法的要点:
我们发现,其实和pipe的区别只在于调用的顺序:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
// compose
fn1(fn2(fn3(fn4(args))))
// pipe
fn4(fn3(fn2(fn1(args))))
既然和我们前面实现的pipe方法一模一样,那还有什么值得深入分析的呢? 继续阅读,看看还有什么可以玩的。
最简单的实现是面向过程的:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
const compose = function(...args) {
let length = args.length
let count = length - 1
let result
return function f1 (...arg1) {
result = args[count].apply(this, arg1)
if (count <= 0) {
count = length - 1
return result
}
count--
return f1.call(null, result)
}
}
这里的关键是使用闭包,使用闭包变量存储结果和函数数组的长度并遍历索引,使用递归的思想对结果进行累加计算。 整体实现符合正常的流程化思维,不难理解。
聪明的同学可能也意识到,使用上面提到的方法,应该可以更函数式地解决问题:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
const reduceFunc = (f, g) => (...arg) => g.call(this, f.apply(this, arg))
const compose = (...args) => args.reverse().reduce(reduceFunc, args.shift())
通过前面的学习,结合call和apply方法,这样的实现不难理解。
我们继续展开思路,“既然涉及到级联和流控”,那我们也可以使用实现:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
const compose = (...args) => {
let init = args.pop()
return (...arg) =>
args.reverse().reduce((sequence, func) =>
sequence.then(result => func.call(null, result))
, Promise.resolve(init.apply(null, arg)))
}
这个实现利用了一个特点:先通过.(init.apply(null,arg))启动逻辑,启动一个值,就是上一个函数接收参数后的返回值,依次执行函数。 因为.then()返回的还是一个类型值,所以可以根据实例执行。
既然能实现,那当然应该是可以的。 这里有一个问题供大家思考。 有兴趣的同学可以试试。 欢迎在评论区讨论。
最后再看看社区中比较知名的和Redux的实现。
版本
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
// lodash 版本
var compose = function(funcs) {
var length = funcs.length
var index = length
while (index--) {
if (typeof funcs[index] !== 'function') {
throw new TypeError('Expected a function');
}
}
return function(...args) {
var index = 0
var result = length ? funcs.reverse()[index].apply(this, args) : args[0]
while (++index < length) {
result = funcs[index].call(this, result)
}
return result
}
}
更像是我们的第一个实现,更容易理解。
Redux 版本
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
// Redux 版本
function compose(...funcs) {
if (funcs.length === 0) {
return arg => arg
}
if (funcs.length === 1) {
return funcs[0]
}
return funcs.reduce((a, b) => (...args) => a(b(...args)))
}
总之,还是充分利用数组的方法。
功能概念确实有些抽象,需要开发者仔细思考,手工调试。 一旦有所顿悟,一定会感受到其中的优雅与质朴。
应用、绑定高级实现
采访中这个绑定相关的话题现在“泛滥”,社区中也有关于绑定方法实现的相关讨论。 但很多内容还不系统,存在一些瑕疵。 这里简单摘录我2017年初写的一篇文章,从一道面试题到“我可能读过假源代码”循序渐进地讨论。 在《Catch this in One Catch》一课中,我们介绍了bind的实现,这里更进一步。
bind函数的使用这里不再赘述,不清楚的读者可以自行补充基础知识。 先来看一个初级实现版本:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
Function.prototype.bind = Function.prototype.bind || function (context) {
var me = this;
var argsArray = Array.prototype.slice.call(arguments);
return function () {
return me.apply(context, argsArray.slice(1))
}
}
这是一般合格的开发人员提供的答案。 如果面试官能写到这里,就给他60分。
我们先简单读一下:
原理是使用apply来模拟bind。 函数体中的this就是需要绑定this的函数,或者说是原函数。 最后使用apply绑定参数()并返回。
同时,使用第一个参数( )以外的参数作为预设参数提供给原函数也是“柯里化”的基本依据。
在上面的实现方法中,我们返回的参数列表包括:.slice(1),它的问题是失去了预设参数的功能。
想象一下,在我们返回的绑定函数中,如果要实现预置参数传递(就像bind做的那样),我们会面临一个尴尬的局面。 真正“咖喱”的“完美方式”是:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
Function.prototype.bind = Function.prototype.bind || function (context) {
var me = this;
var args = Array.prototype.slice.call(arguments, 1);
return function () {
var innerArgs = Array.prototype.slice.call(arguments);
var finalArgs = args.concat(innerArgs);
return me.apply(context, finalArgs);
}
}
但是继续探索,我们要注意bind方法:如果bind返回的函数作为构造函数,并且以new关键字出现,那么我们的绑定this就需要“忽略”,必须将this绑定到实例上。 也就是说new操作符高于bind绑定,兼容这种情况的实现:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
Function.prototype.bind = Function.prototype.bind || function (context) {
var me = this;
var args = Array.prototype.slice.call(arguments, 1);
var F = function () {};
F.prototype = this.prototype;
var bound = function () {
var innerArgs = Array.prototype.slice.call(arguments);
var finalArgs = args.concat(innerArgs);
return me.apply(this instanceof F ? this : context || this, finalArgs);
}
bound.prototype = new F();
return bound;
}
如果你认为这是结束,我会告诉你,高潮才刚刚开始。 之前一直认为上面的方法很完美,直到看了es5-shim的源码(已适当删除):
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
function bind(that) {
var target = this;
if (!isCallable(target)) {
throw new TypeError('Function.prototype.bind called on incompatible ' + target);
}
var args = array_slice.call(arguments, 1);
var bound;
var binder = function () {
if (this instanceof bound) {
var result = target.apply(
this,
array_concat.call(args, array_slice.call(arguments))
);
if ($Object(result) === result) {
return result;
}
return this;
} else {
return target.apply(
that,
array_concat.call(args, array_slice.call(arguments))
);
}
};
var boundLength = max(0, target.length - args.length);
var boundArgs = [];
for (var i = 0; i < boundLength; i++) {
array_push.call(boundArgs, '$' + i);
}
bound = Function('binder', 'return function (' + boundArgs.join(',') + '){ return binder.apply(this, arguments); }')(binder);
if (target.prototype) {
Empty.prototype = target.prototype;
bound.prototype = new Empty();
Empty.prototype = null;
}
return bound;
}
es5-shim 实现到底在做什么? 您可能不知道,但每个函数都有属性。 是的,就像数组和字符串一样。 函数的属性,用来表示函数的形参个数。 更重要的是,函数的属性值不能被覆盖。 我写了一个测试代码来演示:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
function test (){}
test.length // 输出 0
test.hasOwnProperty('length') // 输出 true
Object.getOwnPropertyDescriptor('test', 'length')
// 输出:
// configurable: false,
// enumerable: false,
// value: 4,
// writable: false
说到这里,很好解释:es5-shim是为了最大限度的兼容,包括返回函数属性的恢复。 而在我们之前实现它的方式中,该值始终为零。 所以,既然属性值不能被修改,那么在初始化的时候总是可以赋值的! 所以我们可以通过eval和sum来动态定义函数。 但出于安全原因,使用 eval 或 () 构造函数在某些浏览器中会抛出异常。 不过巧合的是,这些不兼容的浏览器基本都实现了bind功能,不会触发这些异常。 在上面的代码中,重置绑定函数的属性:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
var boundLength = max(0, target.length - args.length)
构造函数调用案例也有效兼容:
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
if (this instanceof bound) {
... // 构造函数调用情况
} else {
... // 正常方式调用
}
if (target.prototype) {
Empty.prototype = target.prototype;
bound.prototype = new Empty();
// 进行垃圾回收清理
Empty.prototype = null;
}
```
对比过几版的 polyfill 实现,对于 `bind` 应该有了比较深刻的认识。这一系列实现有效地考察了很重要的知识点:比如 `this` 的指向、Javascript 闭包、原型与原型链,设计程序上的边界 case 和兼容性考虑经验等硬素质。
####一道更好的面试题
最后,现如今在很多面试中,面试官都会以“实现 `bind`”作为题目。**如果是我,现在可能会规避这个很容易“应试”的题目,而是别出心裁,让面试者实现一个 “call/apply”。**我们往往用 `call`/`apply` 模拟实现 `bind`,而直接实现 `call`/`apply` 也算简单:
.. = (, ) {if( === '' || === null) { = []}
portant;border-width: 1px !important;border-style: solid !important;border-color: rgb(226, 226, 226) !important;">
if(typeof targetObject === 'undefined' || targetObject === null){
targetObject = this
}
targetObject = new Object(targetObject)
const targetFnKey = 'targetFnKey'
targetObject[targetFnKey] = this
const result = targetObject[targetFnKey](...argsArray)
delete targetObject[targetFnKey]
return result
}```
这样的代码不难理解,函数体中的this指向被调用的函数。 为了在函数体中绑定 this,我们使用隐式绑定方法:[](...)。
细心的读者会发现这里有个问题:如果对象本身有这样一个属性,那么在使用该函数的时候,原来的属性值会被覆盖,然后删除。 解决方案可以使用()来保证key的唯一性; 另一种方案是使用Math.()实现唯一键,这里不再赘述。
实施这些 API 的影响
这些API的实现并不复杂,但是很能考验开发者的基础。 基础是基础,是探索更深入内容的关键,也是进阶道路上最重要的一环,需要每一位开发者的重视。 在前端技术快速发展迭代的今天,在浮躁的环境下,“前端市场是否饱和”、“前端求职火爆”、“前端容易上手,很多人都是花钱傻”,基本内功的修养尤为重要。 . 这也是你在前端这条路上能走多远、能走多远的关键。
从面试的角度来看,面试题归根结底是对基础的考察。 只有熟悉了基础知识,才能具备突破面试的基本条件。