该文章为转载,仅用于记录学习
死磕 36 个 JS 手写题(搞懂后,提升真的大)
为什么要写这类文章
作为一个程序员,代码能力毋庸置疑是非常非常重要的,就像现在为什么大厂面试基本都问什么 API 怎么实现可见其重要性。我想说的是居然手写这么重要,那我们就必须掌握它,所以文章标题用了死磕,一点也不过分,也希望不被认为是标题党。
作为一个普通前端,我是真的写不出 Promise A+ 规范,但是没关系,我们可以站在巨人的肩膀上,要相信我们现在要走的路,前人都走过,所以可以找找现在社区已经存在的那些优秀的文章,比如工业聚大佬写的 100 行代码实现 Promises/A+ 规范,找到这些文章后不是收藏夹吃灰,得找个时间踏踏实实的学,一行一行的磨,直到搞懂为止。我现在就是这么干的。
能收获什么
这篇文章总体上分为 2 类手写题,前半部分可以归纳为是常见需求,后半部分则是对现有技术的实现;
- 对常用的需求进行手写实现,比如数据类型判断函数、深拷贝等可以直接用于往后的项目中,提高了项目开发效率;
- 对现有关键字和 API 的实现,可能需要用到别的知识或 API,比如在写 forEach 的时候用到了无符号位右移的操作,平时都不怎么能够接触到这玩意,现在遇到了就可以顺手把它掌握了。所以手写这些实现能够潜移默化的扩展并巩固自己的 JS 基础;
- 通过写各种测试用例,你会知道各种 API 的边界情况,比如 Promise.all, 你得考虑到传入参数的各种情况,从而加深了对它们的理解及使用;
阅读的时候需要做什么
阅读的时候,你需要把每行代码都看懂,知道它在干什么,为什么要这么写,能写得更好嘛?比如在写图片懒加载的时候,一般我们都是根据当前元素的位置和视口进行判断是否要加载这张图片,普通程序员写到这就差不多完成了。而大佬程序员则是会多考虑一些细节的东西,比如性能如何更优?代码如何更精简?比如 yeyan1996 写的图片懒加载就多考虑了 2 点:比如图片全部加载完成的时候得把事件监听给移除;比如加载完一张图片的时候,得把当前 img 从 imgList 里移除,起到优化内存的作用。
除了读通代码之外,还可以打开 Chrome 的 Script snippet 去写测试用例跑跑代码,做到更好的理解以及使用。
在看了几篇以及写了很多测试用例的前提下,尝试自己手写实现,看看自己到底掌握了多少。条条大路通罗马,你还能有别的方式实现嘛?或者你能写得比别人更好嘛?
好了,还楞着干啥,开始干活。
数据类型判断
typeof 可以正确识别:Undefined、Boolean、Number、String、Symbol、Function 等类型的数据,但是对于其他的都会认为是 object,比如 Null、Date 等,所以通过 typeof 来判断数据类型会不准确。但是可以使用 Object.prototype.toString 实现。
1 | function typeOf(obj) { |
继承
原型链继承
1 | function Animal() { |
原型链继承存在的问题:
- 问题1:原型中包含的引用类型属性将被所有实例共享;
- 问题2:子类在实例化的时候不能给父类构造函数传参;
借用构造函数实现继承
1 | function Animal(name) { |
借用构造函数实现继承解决了原型链继承的 2 个问题:引用类型共享问题以及传参问题。但是由于方法必须定义在构造函数中,所以会导致每次创建子类实例都会创建一遍方法。
组合继承
组合继承结合了原型链和盗用构造函数,将两者的优点集中了起来。基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。
1 | function Animal(name) { |
寄生式组合继承
组合继承已经相对完善了,但还是存在问题,它的问题就是调用了 2 次父类构造函数,第一次是在 new Animal(),第二次是在 Animal.call() 这里。
所以解决方案就是不直接调用父类构造函数给子类原型赋值,而是通过创建空函数 F 获取父类原型的副本。
寄生式组合继承写法上和组合继承基本类似,区别是如下这里:
1 | - Dog.prototype = new Animal() |
稍微封装下上面添加的代码后:
1 | function object(o) { |
如果你嫌弃上面的代码太多了,还可以基于组合继承的代码改成最简单的寄生式组合继承:
1 | - Dog.prototype = new Animal() |
class 实现继承
1 | class Animal { |
数组去重
ES5 实现:
1 | function unique(arr) { var res = arr.filter(function(item, index, array) { return array.indexOf(item) === index }) return res}复制代码 |
ES6 实现:
1 | var unique = arr => [...new Set(arr)]复制代码 |
数组扁平化
数组扁平化就是将 [1, [2, [3]]] 这种多层的数组拍平成一层 [1, 2, 3]。使用 Array.prototype.flat 可以直接将多层数组拍平成一层:
1 | [1, [2, [3]]].flat(2) // [1, 2, 3] |
现在就是要实现 flat 这种效果。
ES5 实现:递归。
1 | function flatten(arr) { |
ES6 实现:
1 | function flatten(arr) { |
深浅拷贝
浅拷贝:只考虑对象类型。
1 | function shallowCopy(obj) { |
简单版深拷贝:只考虑普通对象属性,不考虑内置对象和函数。
1 | function deepClone(obj) { |
复杂版深克隆:基于简单版的基础上,还考虑了内置对象比如 Date、RegExp 等对象和函数以及解决了循环引用的问题。
1 | const isObject = (target) => (typeof target === "object" || typeof target === "function") && target !== null; |
事件总线(发布订阅模式)
1 | class EventEmitter { |
解析 URL 参数为对象
1 | function parseParam(url) { |
字符串模板
1 | function render(template, data) { |
测试:
1 | let template = '我是{{name}},年龄{{age}},性别{{sex}}'; |
图片懒加载
与普通的图片懒加载不同,如下这个多做了 2 个精心处理:
- 图片全部加载完成后移除事件监听;
- 加载完的图片,从 imgList 移除;
1 | let imgList = [...document.querySelectorAll('img')] |
参考:图片懒加载
函数防抖
触发高频事件 N 秒后只会执行一次,如果 N 秒内事件再次触发,则会重新计时。
简单版:函数内部支持使用 this 和 event 对象;
1 | function debounce(func, wait) { |
使用:
1 | var node = document.getElementById('layout') |
最终版:除了支持 this 和 event 外,还支持以下功能:
- 支持立即执行;
- 函数可能有返回值;
- 支持取消功能;
1 | function debounce(func, wait, immediate) { |
使用:
1 | var setUseAction = debounce(getUserAction, 10000, true); |
参考:JavaScript专题之跟着underscore学防抖
函数节流
触发高频事件,且 N 秒内只执行一次。
简单版:使用时间戳来实现,立即执行一次,然后每 N 秒执行一次。
1 | function throttle(func, wait) { |
最终版:支持取消节流;另外通过传入第三个参数,options.leading 来表示是否可以立即执行一次,opitons.trailing 表示结束调用的时候是否还要执行一次,默认都是 true。 注意设置的时候不能同时将 leading 或 trailing 设置为 false。
1 | function throttle(func, wait, options) { |
节流的使用就不拿代码举例了,参考防抖的写就行。
参考:JavaScript专题之跟着 underscore 学节流
函数柯里化
什么叫函数柯里化?其实就是将使用多个参数的函数转换成一系列使用一个参数的函数的技术。还不懂?来举个例子。
1 | function add(a, b, c) { |
现在就是要实现 curry 这个函数,使函数从一次调用传入多个参数变成多次调用每次传一个参数。
1 | function curry(fn) { |
偏函数
什么是偏函数?偏函数就是将一个 n 参的函数转换成固定 x 参的函数,剩余参数(n - x)将在下次调用全部传入。举个例子:
1 | function add(a, b, c) { return a + b + c}let partialAdd = partial(add, 1)partialAdd(2, 3)复制代码 |
发现没有,其实偏函数和函数柯里化有点像,所以根据函数柯里化的实现,能够能很快写出偏函数的实现:
1 | function partial(fn, ...args) { return (...arg) => { return fn(...args, ...arg) }}复制代码 |
如上这个功能比较简单,现在我们希望偏函数能和柯里化一样能实现占位功能,比如:
1 | function clg(a, b, c) { |
_
占的位其实就是 1 的位置。相当于:partial(clg, 1, 2),然后 partialClg(3)。明白了原理,我们就来写实现:
1 | function partial(fn, ...args) { |
JSONP
JSONP 核心原理:script 标签不受同源策略约束,所以可以用来进行跨域请求,优点是兼容性好,但是只能用于 GET 请求;
1 | const jsonp = ({ url, params, callbackName }) => { |
AJAX
1 | const getJSON = function(url) { return new Promise((resolve, reject) => { const xhr = XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject('Mscrosoft.XMLHttp'); xhr.open('GET', url, false); xhr.setRequestHeader('Accept', 'application/json'); xhr.onreadystatechange = function() { if (xhr.readyState !== 4) return; if (xhr.status === 200 || xhr.status === 304) { resolve(xhr.responseText); } else { reject(new Error(xhr.responseText)); } } xhr.send(); })}复制代码 |
实现数组原型方法
forEach
1 | Array.prototype.forEach2 = function(callback, thisArg) { |
O.length >>> 0 是什么操作?就是无符号右移 0 位,那有什么意义嘛?就是为了保证转换后的值为正整数。其实底层做了 2 层转换,第一是非 number 转成 number 类型,第二是将 number 转成 Uint32 类型。感兴趣可以阅读 something >>> 0是什么意思?。
map
基于 forEach 的实现能够很容易写出 map 的实现:
1 | - Array.prototype.forEach2 = function(callback, thisArg) { |
filter
同样,基于 forEach 的实现能够很容易写出 filter 的实现:
1 | - Array.prototype.forEach2 = function(callback, thisArg) { |
some
同样,基于 forEach 的实现能够很容易写出 some 的实现:
1 | - Array.prototype.forEach2 = function(callback, thisArg) { |
reduce
1 | Array.prototype.reduce2 = function(callback, initialValue) { |
实现函数原型方法
call
使用一个指定的 this 值和一个或多个参数来调用一个函数。
实现要点:
- this 可能传入 null;
- 传入不固定个数的参数;
- 函数可能有返回值;
1 | Function.prototype.call2 = function (context) { var context = context || window; context.fn = this; var args = []; for(var i = 1, len = arguments.length; i < len; i++) { args.push('arguments[' + i + ']'); } var result = eval('context.fn(' + args +')'); delete context.fn return result;}复制代码 |
apply
apply 和 call 一样,唯一的区别就是 call 是传入不固定个数的参数,而 apply 是传入一个数组。
实现要点:
- this 可能传入 null;
- 传入一个数组;
- 函数可能有返回值;
1 | Function.prototype.apply2 = function (context, arr) { var context = context || window; context.fn = this; var result; if (!arr) { result = context.fn(); } else { var args = []; for (var i = 0, len = arr.length; i < len; i++) { args.push('arr[' + i + ']'); } result = eval('context.fn(' + args + ')') } delete context.fn return result;}复制代码 |
bind
bind 方法会创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。
实现要点:
- bind() 除了 this 外,还可传入多个参数;
- bing 创建的新函数可能传入多个参数;
- 新函数可能被当做构造函数调用;
- 函数可能有返回值;
1 | Function.prototype.bind2 = function (context) { var self = this; var args = Array.prototype.slice.call(arguments, 1); var fNOP = function () {}; var fBound = function () { var bindArgs = Array.prototype.slice.call(arguments); return self.apply(this instanceof fNOP ? this : context, args.concat(bindArgs)); } fNOP.prototype = this.prototype; fBound.prototype = new fNOP(); return fBound;}复制代码 |
实现 new 关键字
new 运算符用来创建用户自定义的对象类型的实例或者具有构造函数的内置对象的实例。
实现要点:
- new 会产生一个新对象;
- 新对象需要能够访问到构造函数的属性,所以需要重新指定它的原型;
- 构造函数可能会显示返回;
1 | function objectFactory() { var obj = new Object() Constructor = [].shift.call(arguments); obj.__proto__ = Constructor.prototype; var ret = Constructor.apply(obj, arguments); // ret || obj 这里这么写考虑了构造函数显示返回 null 的情况 return typeof ret === 'object' ? ret || obj : obj;};复制代码 |
使用:
1 | function person(name, age) { |
实现 instanceof 关键字
instanceof 就是判断构造函数的 prototype 属性是否出现在实例的原型链上。
1 | function instanceOf(left, right) { |
上面的 left.proto 这种写法可以换成 Object.getPrototypeOf(left)。
实现 Object.create
Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。
1 | Object.create2 = function(proto, propertyObject = undefined) { |
实现 Object.assign
1 | Object.assign2 = function(target, ...source) { if (target == null) { throw new TypeError('Cannot convert undefined or null to object') } let ret = Object(target) source.forEach(function(obj) { if (obj != null) { for (let key in obj) { if (obj.hasOwnProperty(key)) { ret[key] = obj[key] } } } }) return ret}复制代码 |
实现 JSON.stringify
JSON.stringify([, replacer [, space]) 方法是将一个 JavaScript 值(对象或者数组)转换为一个 JSON 字符串。此处模拟实现,不考虑可选的第二个参数 replacer 和第三个参数 space,如果对这两个参数的作用还不了解,建议阅读 MDN 文档。
- 基本数据类型:
- undefined 转换之后仍是 undefined(类型也是 undefined)
- boolean 值转换之后是字符串 “false”/“true”
- number 类型(除了 NaN 和 Infinity)转换之后是字符串类型的数值
- symbol 转换之后是 undefined
- null 转换之后是字符串 “null”
- string 转换之后仍是string
- NaN 和 Infinity 转换之后是字符串 “null”
- 函数类型:转换之后是 undefined
- 如果是对象类型(非函数)
- 如果是一个数组:如果属性值中出现了 undefined、任意的函数以及 symbol,转换成字符串 “null” ;
- 如果是 RegExp 对象:返回 {} (类型是 string);
- 如果是 Date 对象,返回 Date 的 toJSON 字符串值;
- 如果是普通对象;
- 如果有 toJSON() 方法,那么序列化 toJSON() 的返回值。
- 如果属性值中出现了 undefined、任意的函数以及 symbol 值,忽略。
- 所有以 symbol 为属性键的属性都会被完全忽略掉。
- 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。
1 | function jsonStringify(data) { let dataType = typeof data; if (dataType !== 'object') { let result = data; //data 可能是 string/number/null/undefined/boolean if (Number.isNaN(data) || data === Infinity) { //NaN 和 Infinity 序列化返回 "null" result = "null"; } else if (dataType === 'function' || dataType === 'undefined' || dataType === 'symbol') { //function 、undefined 、symbol 序列化返回 undefined return undefined; } else if (dataType === 'string') { result = '"' + data + '"'; } //boolean 返回 String() return String(result); } else if (dataType === 'object') { if (data === null) { return "null" } else if (data.toJSON && typeof data.toJSON === 'function') { return jsonStringify(data.toJSON()); } else if (data instanceof Array) { let result = []; //如果是数组 //toJSON 方法可以存在于原型链中 data.forEach((item, index) => { if (typeof item === 'undefined' || typeof item === 'function' || typeof item === 'symbol') { result[index] = "null"; } else { result[index] = jsonStringify(item); } }); result = "[" + result + "]"; return result.replace(/'/g, '"'); } else { //普通对象 /** * 循环引用抛错(暂未检测,循环引用时,堆栈溢出) * symbol key 忽略 * undefined、函数、symbol 为属性值,被忽略 */ let result = []; Object.keys(data).forEach((item, index) => { if (typeof item !== 'symbol') { //key 如果是symbol对象,忽略 if (data[item] !== undefined && typeof data[item] !== 'function' && typeof data[item] !== 'symbol') { //键值如果是 undefined、函数、symbol 为属性值,忽略 result.push('"' + item + '"' + ":" + jsonStringify(data[item])); } } }); return ("{" + result + "}").replace(/'/g, '"'); } }}复制代码 |
实现 JSON.parse
介绍 2 种方法实现:
- eval 实现;
- new Function 实现;
eval 实现
第一种方式最简单,也最直观,就是直接调用 eval,代码如下:
1 | var json = '{"a":"1", "b":2}';var obj = eval("(" + json + ")"); // obj 就是 json 反序列化之后得到的对象复制代码 |
但是直接调用 eval 会存在安全问题,如果数据中可能不是 json 数据,而是可执行的 JavaScript 代码,那很可能会造成 XSS 攻击。因此,在调用 eval 之前,需要对数据进行校验。
1 | var rx_one = /^[\],:{}\s]*$/;var rx_two = /\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g;var rx_three = /"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g;var rx_four = /(?:^|:|,)(?:\s*\[)+/g;if ( rx_one.test( json.replace(rx_two, "@") .replace(rx_three, "]") .replace(rx_four, "") )) { var obj = eval("(" +json + ")");}复制代码 |
new Function 实现
Function 与 eval 有相同的字符串参数特性。
1 | var json = '{"name":"小姐姐", "age":20}';var obj = (new Function('return ' + json))();复制代码 |
实现 Promise
实现 Promise 需要完全读懂 Promise A+ 规范,不过从总体的实现上看,有如下几个点需要考虑到:
- then 需要支持链式调用,所以得返回一个新的 Promise;
- 处理异步问题,所以得先用 onResolvedCallbacks 和 onRejectedCallbacks 分别把成功和失败的回调存起来;
- 为了让链式调用正常进行下去,需要判断 onFulfilled 和 onRejected 的类型;
- onFulfilled 和 onRejected 需要被异步调用,这里用 setTimeout 模拟异步;
- 处理 Promise 的 resolve;
1 | const PENDING = 'pending'; |
Promise 写完之后可以通过 promises-aplus-tests 这个包对我们写的代码进行测试,看是否符合 A+ 规范。不过测试前还得加一段代码:
1 | // promise.js |
全局安装:
1 | npm i promises-aplus-tests -g |
终端下执行验证命令:
1 | promises-aplus-tests promise.js |
上面写的代码可以顺利通过全部 872 个测试用例。
参考:
Promise.resolve
Promsie.resolve(value) 可以将任何值转成值为 value 状态是 fulfilled 的 Promise,但如果传入的值本身是 Promise 则会原样返回它。
1 | Promise.resolve = function(value) { // 如果是 Promsie,则直接输出它 if(value instanceof Promise){ return value } return new Promise(resolve => resolve(value))}复制代码 |
参考:深入理解 Promise
Promise.reject
和 Promise.resolve() 类似,Promise.reject() 会实例化一个 rejected 状态的 Promise。但与 Promise.resolve() 不同的是,如果给 Promise.reject() 传递一个 Promise 对象,则这个对象会成为新 Promise 的值。
1 | Promise.reject = function(reason) { return new Promise((resolve, reject) => reject(reason))}复制代码 |
Promise.all
Promise.all 的规则是这样的:
- 传入的所有 Promsie 都是 fulfilled,则返回由他们的值组成的,状态为 fulfilled 的新 Promise;
- 只要有一个 Promise 是 rejected,则返回 rejected 状态的新 Promsie,且它的值是第一个 rejected 的 Promise 的值;
- 只要有一个 Promise 是 pending,则返回一个 pending 状态的新 Promise;
1 | Promise.all = function(promiseArr) { |
Promise.race
Promise.race 会返回一个由所有可迭代实例中第一个 fulfilled 或 rejected 的实例包装后的新实例。
1 | Promise.race = function(promiseArr) { |
Promise.allSettled
Promise.allSettled 的规则是这样:
- 所有 Promise 的状态都变化了,那么新返回一个状态是 fulfilled 的 Promise,且它的值是一个数组,数组的每项由所有 Promise 的值和状态组成的对象;
- 如果有一个是 pending 的 Promise,则返回一个状态是 pending 的新实例;
1 | Promise.allSettled = function(promiseArr) { |
Promise.any
Promise.any 的规则是这样:
- 空数组或者所有 Promise 都是 rejected,则返回状态是 rejected 的新 Promsie,且值为 AggregateError 的错误;
- 只要有一个是 fulfilled 状态的,则返回第一个是 fulfilled 的新实例;
- 其他情况都会返回一个 pending 的新实例;
1 | Promise.any = function(promiseArr) { |