开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第7天,点击查看活动详情
一、概述
年关将至,找不到工作准备回家过年了,在这整理前端常见的手写代码的面试题。希望能帮助到需要的人。
二、手写代码题
1. 防抖函数 和 节流函数
防抖函数
函数可以使事件被触发n秒之后再进行处理,n秒内事件再次被触发则重新计时。比如页面上的用户点击事件
节流函数
节流指的是规定的一个时间,触发一次之后,如果在规定的时间内重复被触发了,只有一次是生效的。比如常见的在scroll函数的事件监听,input框的输入事件等等
防抖函数
// 防抖函数
function debounce(fn, wait) {
// 1.需要一个定时器
let timer = null;
return function () {
const args = arguments;
// 3.中途再次触发,则清空定时器
timer && clearTimeout(timer);
// 2.将定时器设定成指定时间触发
timer = setTimeout(() => {
fn.apply(this, args);
}, wait);
};
}
节流函数
// 节流函数
function throttle(fn, delay) {
// 获取初始的时间点
let currentTime = Date.now();
return function () {
// 触发的时间点
let nowTime = Date.now();
const args = arguments;
if (nowTime - currentTime >= delay) {
// 重置初始时间点
currentTime = Date.now();
fn.apply(this, args);
}
};
}
2. 手写函数call、apply、bind 方法
call()
和apply()
都是来改变this
指向的,调用之后立即执行调用它们的函数。call()
方法接受的是一个参数列表,而apply()
方法接受的是一个包含多个参数的数组。bind()
返回的是一个函数,参数和call()
一样
call方法
call()
方法使用一个指定的 this
值和单独给出的一个或多个参数来调用一个函数。
Function.prototype.myCall = function (context) {
// 1.判断执行的对象是否为函数
if (typeof this !== "function") {
console.error("this is not a function");
}
// 2.获取参数
const args = [...arguments].slice(1);
// 3.定义接收函数返回的结果
let result = null;
// 4.判断是否传入context, 没有传入则指向全局即window
context = context || window;
// 5.执行对象挂载在context上
context.fn = this;
// 6.执行函数并接收结果
result = context.fn(...args);
// 7.将context复原 删除临时属性
delete context.fn;
// 8.返回6的结果
return result;
};
apply 方法
apply()
方法调用一个具有给定 this
值的函数,以及以一个数组
(或一个类数组对象
)的形式提供的参数。
Function.prototype.myApply = function (context) {
// 1.判断执行的对象是否为函数
if (typeof this !== "function") {
console.error("this is not a function");
}
// 2.获取参数
const args = arguments[1];
// 3.定义接收函数返回的结果
let result = null;
// 4.判断是否传入context, 没有传入则指向全局即window
context = context || window;
// 5.执行对象挂载在context上
context.fn = this;
// 6.判断是否有参数,
if (args) {
result = context.fn(...args);
} else {
result = context.fn();
}
// 7. 将上下文复原,删除新增临时属性
delete context.fn;
// 8. 返回5的结果
return result;
};
bind 方法
bind()
方法创建一个新的函数,在 bind()
被调用时,这个新函数的 this
被指定为 bind()
的第一个参数,而其余参数将作为新函数的参数,供调用时使用。
Function.prototype.myBind = function (context) {
// 1.判断执行的对象是否为函数
if (typeof this !== "function") {
console.error("this is not a function");
}
// 2.获取参数
const args = [...arguments].slice(1);
// 3.定义this指向
let fn = this;
// 4.返回函数
return function Fn() {
// 5.根据调用方,确定最终返回值
return fn.apply(
this instanceof Fn ? this : context,
args.concat(...arguments)
);
};
};
使用 bind()
函数
const obj = {
name: "winter",
};
// 定义函数
function test() {
console.log(this.name);
console.log(arguments);
return arguments;
}
// 打印
console.log(test.bind(obj, 333)(1, 2));
console.log(test.myBind(obj, 333)(1, 2));
3. 手写浅拷贝
如果属性是基本类型,拷贝的就是基本类型的值。如果属性是引用类型,拷贝的就是内存地址,即浅拷贝是拷贝一层,深层次的引用类型则共享内存地址
快速方法
- Object.assign(obj1, obj2)
- arr.slice()
- arr.concat()
- 扩展运算符 [...arr]
手写浅拷贝
// 浅拷贝
function shallowClone(obj) {
if (!obj || typeof obj !== "object") return;
let result = Array.isArray(obj) ? [] : {};
// 循环遍历
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
result[key] = obj[key];
}
}
return result;
}
4. 手写深拷贝
- _.cloneDeep()
- jQuery.extend()
- JSON.stringify()
- 手写循环递归
JSON.stringify()
JSON.stringify()
会忽略undefined
、symbol
和函数
举个例子:
// 定义一个对象
const obj = {
name: "winter",
a: null,
b: undefined,
c: function () {},
d: Symbol("aa"),
e: [1, 2, 3],
info: {
age: 21,
},
};
// 通过 JSON.stringify() 转换
console.log(JSON.parse(JSON.stringify(obj)));
// 打印的结果 只有 name a e info 这四个属性
// {
// name: 22,
// a: null,
// e: [1, 2, 3],
// info: {
// age: 22,
// },
// };
手写循环递归
// 深拷贝
function deepClone(obj) {
// 如果为 undefined 或 null 或 不是 object 类型则直接返回
if (obj == null || typeof obj !== "object") return obj;
// 定义返回格式
let result = Array.isArray(obj) ? [] : {};
// 循环遍历
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
result[key] = deepClone(obj[key]);
}
}
// 返回结果
return result;
}
5. 函数柯里化
函数柯里化指的是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术。
例如:可以将 f(a, b, c)
转换成 f(a)(b)(c)
, 也可以转换成 f(a, b)(c)
function curry(fn, ...args) {
return fn.length <= args.length ? fn(...args) : curry.bind(null, fn, ...args);
}
6. Promise相关
手写Promise(简易版)
Promise
想必不用过多介绍了,Promise
入参是一个函数,可以用then
、catch
等链式调用,首先明确几点:
- Promise 有三个状态:
pending
fulfilled
rejected
new promise
时, 需要传递一个executor()
执行器,执行器立即执行;executor
接受两个参数,分别是resolve
和reject
;- promise 的默认状态是
pending
; - promise 只能从
pending
到rejected
, 或者从pending
到fulfilled
,状态一旦确认,就不会再改变;
按照上面的特征,我们尝试照葫芦画瓢手写一下:
class MyPromise {
constructor(executor) {
// 默认状态是pending
this.state = "pending";
// 存放成功和失败的值
this.value = undefined;
// 调用执行器 将resolve reject 传给使用者
executor(this.resolve.bind(this), this.reject.bind(this));
}
// 调用此方法就是成功
resolve(data) {
// 状态不可变
if (this.state !== "pending") return;
// 成功的状态
this.state = "fulfilled";
this.value = data;
}
// 调用此方法就是失败
reject(reason) {
// 状态不可变
if (this.state !== "pending") return;
// 失败的状态
this.state = "rejected";
this.value = reason;
}
// 包含一个 then 方法,并接收两个参数 onFulfilled、onRejected
then(onFulfilled, onRejected) {
if (this.state === "fulfilled") {
onFulfilled(this.value);
}
if (this.state === "rejected") {
onRejected(this.value);
}
}
}
写完代码我们可以测试下:
const pro = new MyPromise((resolve, reject) => {
resolve("成功");
reject("失败");
});
console.log(pro);
pro.then(
(data) => {
console.log("success", data);
},
(err) => {
console.log("error", err);
}
);
控制台输出:
MyPromise {state: 'fulfilled', value: '成功'}
success 成功
手写Promise.all
promise.all
是解决并发问题的,多个异步并发获取最终的结果(如果有一个失败则失败)。
在 MyPromise
里面添加一个静态方法,如下:
static all(PromiseList) {
// 判断是否是数组
if (!Array.isArray(PromiseList)) {
// PromiseList 的类型
const type = typeof PromiseList;
return reject(new TypeError(`${type} ${PromiseList} is not iterable`));
}
return new Promise((resolve, reject) => {
let resultArr = []; // 保留结果
let orderIndex = 0; // 索引
// 对结果进行处理
const ProcessResultByKey = function (value, index) {
resultArr[index] = value;
orderIndex += 1;
if (orderIndex === PromiseList.length) {
resolve(resultArr);
}
};
// 循环
for (let i = 0; i < PromiseList.length; i++) {
let promise = PromiseList[i];
// 判断是否传入的是 Promise
if (promise && typeof promise.then === "function") {
promise.then((res) => {
ProcessResultByKey(res, i);
}, reject);
} else {
// 非Promise直接调用
ProcessResultByKey(promise, i);
}
}
});
}
手写Promise.race
传参和上面的 all 一模一样,传入一个 Promise 实例集合的数组,然后全部同时执行,谁先快先执行完就返回谁,只返回一个结果
同样的在 MyPromise
中添加静态方法:
static race(promisesList) {
return new Promise((resolve, reject) => {
// 直接循环同时执行传进来的promise
for (const promise of promisesList) {
// 直接返回出去,所以只有一个,就看那个快
// 使用 Promise.resolve 可以兼容非Promise的值
Promise.resolve(promise).then(resolve, reject);
}
});
}
7. ajax 相关
手写ajax请求
ajax
是使用 XMLHttpRequest
实现的,创建ajax请求的步骤:
- 创建一个
XMLHttpRequest
对象 - 在对象上使用
open
方法创建一个HTTP请求 - 使用
send
方法发送数据 - 使用
onreadystatechange
监听readyState
状态的变化,当readyState
变成4
的时候代表服务器返回的数据接收完成,再根据state
判断请求的状态
根据以上步骤,我们来简单封装一个ajax:
const url =
" https://lf3-cdn-tos.bytescm.com/obj/static/xitu_juejin_web/6c61ae65d1c41ae8221a670fa32d05aa.svg";
// 创建对象
let xhr = new XMLHttpRequest();
// 创建HTTP请求
xhr.open("GET", url, true);
// 发送数据
xhr.send(null);
// 监听状态
xhr.onreadystatechange = function () {
if (this.readyState !== 4) return;
if (this.status === 200) {
// 打印返回的内容
console.log(this.response);
} else {
console.log(this.statusText);
}
};
// 监听错误
xhr.onerror = function () {
console.log(this.statusText);
};
Promise 封装一个ajax
function fetchData(url) {
return new Promise((resolve, reject) => {
// 创建对象
let xhr = new XMLHttpRequest();
// 创建HTTP请求
xhr.open("GET", url, true);
// 发送数据
xhr.send(null);
// 监听状态
xhr.onreadystatechange = function () {
if (this.readyState !== 4) return;
if (this.status === 200) {
// 成功返回
resolve(this.response);
} else {
reject(new Error(this.statusText));
}
};
// 监听错误
xhr.onerror = function () {
reject(new Error(this.statusText));
};
});
}
8. 数组相关
数组扁平化
通过循环递归的方式,一项一项地去遍历,如果每一项还是一个数组,那么就继续往下遍历,利用递归程序的方法,来实现数组的每一项的连接:
function flatten(arr) {
let result = [];
// 循环遍历
for (let i = 0; i < arr.length; i++) {
if (Array.isArray(arr[i])) {
result = result.concat(flatten(arr[i]));
} else {
result.push(arr[i]);
}
}
return result;
}
乱序输出
实现的思路如下:
- 取出数组的第一个元素,随机产生一个索引值,将第一个元素和这个索引对应的元素进行交换
- 第二次取出数组第二个元素,随机产生除了自身索引之外的索引值,并将第二个元素和该索引值对应的元素进行交换
- 按照上面的规律,直至遍历完成
const arr = [1, 2, 3, 4, 5, 6, 7, 8];
for (let i = 0; i < arr.length; i++) {
// 获取随机索引
const randomIndex = Math.round(Math.random() * (arr.length - 1 - i)) + i;
// 交换
let temp = arr[i];
arr[i] = arr[randomIndex];
arr[randomIndex] = temp;
}
数组和类数组的转换
// 类数组 => 数组的转换
Array.prototype.slice.call(a_array);
Array.prototype.splice.call(a_array, 0);
Array.prototype.concat.call([], a_array);
Array.from(a_array);
实现数组的flat方法
function flat(arr, depth = 1) {
// depth 为0 则不需要往下遍历
if (!Array.isArray(arr) || depth <= 0) return arr;
let result = [];
// 循环遍历
for (let i = 0; i < arr.length; i++) {
if (Array.isArray(arr[i])) {
result = result.concat(flat(arr[i], --depth));
} else {
result.push(arr[i]);
}
}
return result;
}
实现数组的push方法
Array.prototype.myPush = function () {
for (let i = 0; i < arguments.length; i++) {
this[this.length] = arguments[i];
}
return this.length;
};
实现数组的filter方法
filter方法输入一个函数,返回新的数组, 不会对原来的数组产生影响
Array.prototype.myFilter = function (fn) {
if (typeof fn !== "function") {
throw Error("参数必须是一个函数");
}
// 定义返回的数组
const res = [];
// 循环执行fn 返回true的元素 则将该元素放到res中
for (let i = 0; i < this.length; i++) {
fn(this[i]) && res.push(this[i]);
}
return res;
};
实现数组的map方法
map()
方法创建一个新数组,这个新数组由原数组中的每个元素都调用一次提供的函数后的返回值组成。
Array.prototype.myMap = function (fn) {
if (typeof fn !== "function") {
throw Error("参数必须是一个函数");
}
// 定义返回的数组
const res = [];
// 循环执行fn 返回true的元素 则将该元素放到res中
for (let i = 0; i < this.length; i++) {
res.push(fn(this[i]));
}
return res;
};
请写出至少三种数组去重的方法
- 第一种 利用数组的
filter
方法 - 利用ES6 中的
Set
去重(ES6 中最常用) - 遍历循环,然后用splice去重
更多的去重方法可参考: 去重方法
首先定义一个包含重复元素的数组
const arr = [
1,
1,
"2",
"2",
true,
true,
false,
false,
undefined,
undefined,
null,
null,
NaN,
NaN,
[],
[],
{},
{},
0,
0,
];
filter 方法
数组的 indexOf
方法会忽略 NaN
类型,console.log(arr.indexOf(NaN))
打印的是 -1
function unique1(arr) {
return arr.filter((item, index) => {
// 重复的元素,只返回第一个
return arr.indexOf(item) === index;
});
}
console.log(unique1(arr));
// [1, "2", true, false, undefined, null, [], [], {}, {}, 0];
Set
去重
function unique2(arr) {
// Array.from() 方法对一个类似数组或可迭代对象创建一个新的,浅拷贝的数组实例
return Array.from(new Set(arr));
}
console.log(unique2(arr));
// [1, "2", true, false, undefined, null, NaN, [], [], {}, {}, 0];
循环去重
function unique3(arr) {
for (var i = 0; i < arr.length; i++) {
for (var j = i + 1; j < arr.length; j++) {
// 注意 NaN === NaN 的结果是false
if (arr[i] === arr[j]) {
// 去除重复元素
arr.splice(j, 1);
}
}
}
return arr;
}
console.log(unique3(arr));
// [1, "2", true, false, undefined, null, NaN, NaN, [], [], {}, {}, 0];
9. URL的解析并转成对象
例如有一条url地址为:www.xxx.com?id=2&age=18&name=张三
, 解析为对象{id:2,age:18,name:'张三'}
的形式
function parseParam(url) {
// 先提取 ? 后面的参数
const paramsStr = /.+\?(.+)$/.exec(url)[1]; // id=2&age=a18&name=%E5%BC%A0%E4%B8%89&ok
// 对提取出来的字符根据 & 分割
const paramsArr = paramsStr.split("&"); // ['id=2', 'age=a18', 'name=%E5%BC%A0%E4%B8%89', 'ok']
// 定义返回结果
const result = {};
console.log(paramsStr);
// 循环 paramsArr
(paramsArr || []).forEach((param) => {
if (/=/.test(param)) {
let [key, value] = param.split("=");
// 中文解码 将转换后的中文转码成正确的中文
value = decodeURIComponent(value); // %E5%BC%A0%E4%B8%89 转为 张三
// 如果是数字 则转换成数字
value = /^\d+$/.test(value) ? parseFloat(value) : value;
if (result.hasOwnProperty(key)) {
// 有相同的属性 则转换成数组
result[key] = [].concat(result[key], value);
} else {
result[key] = value;
}
} else {
result[param] = true;
}
});
return result;
}
const url = "ww.xxx.com?id=2&age=a18&name=%E5%BC%A0%E4%B8%89&ok";
console.log(parseParam(url));
// {id: 2, age: 'a18', name: '张三', ok: true}
10. 对象转换成树
比如:
const objArr = [
{
id: 1,
parent: 0,
name: "a",
},
{
id: 2,
parent: 1,
name: "b",
},
{
id: 3,
parent: 1,
name: "b",
},
{
id: 4,
parent: 2,
name: "d",
},
];
转换成:
const tree = [
{
id: 1,
name: "a",
parent: 0,
children: [
{
id: 2,
parent: 1,
name: "b",
children: [
{
id: 4,
parent: 2,
name: "d",
},
],
},
{
id: 3,
parent: 1,
name: "b",
},
],
},
];
下面代码实现一下:
function arrToTree(arr) {
if (!Array.isArray(arr)) {
return [];
}
let result = [];
let map = {};
arr.forEach((item) => {
map[item.id] = item;
});
arr.forEach((item) => {
let _parent = map[item.parent];
// parent 不为0
if (_parent) {
(_parent.children || (_parent.children = [])).push(item);
} else {
result.push(item);
}
});
return result;
}
11. 手写instanceof
instanceof
运算符用于判断构造函数的prototype
属性是否出现在对象的原型链中的任何位置。实现的思路:
- 获取左边的原型, 通过
Object.getPrototypeOf()
- 获取右边的
prototype
原型 - 循环往上获取左边的原型,直至为
null
function myInstanceof(left, right) {
// 获取左边的原型
let proto = Object.getPrototypeOf(left);
// 获取右边的原型
const prototype = right.prototype;
// 循环
while (true) {
// 为 null 则没找到
if (!proto) return false;
if (proto === prototype) return true;
// 往上一级查找
proto = Object.getPrototypeOf(proto);
}
}
12. 手写new
实现 new
有以下几个步骤:
- 创建一个空对象
- 将上一步创建的空对象的原型设置为传进来的函数的
prototype
对象 - 让函数的
this
指向这个对象,并执行这个函数 - 判断函数返回值的类型,如果是引用类型,则返回函数返回值,否则返回创建的对象
function myNew(fn, ...args) {
if (typeof fn !== "function") {
return console.error("type error");
}
// 创建空对象
const obj = {};
// 将新对象原型指向构造函数原型对象
obj.__proto__ = fn.prototype;
// 将构建函数的this指向新对象并执行
let result = fn.apply(obj, args);
// 根据返回值判断
return result instanceof Object ? result : obj;
}
13. 手写Object.create
将传入的对象作为原型
function create(obj) {
function F() {}
F.prototype = obj;
return new F();
}
14. 手写发布-订阅模式
发布-订阅模式其实是一种对象间一对多
的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到状态改变的通知。
订阅者(Subscriber)
把自己想订阅的事件注册(Subscribe)
到调度中心(Event Channel)
,当发布者(Publisher)发布该事件(Publish Event)到调度中心,也就是该事件触发时,由调度中心统一调度(Fire Event)订阅者注册到调度中心的处理代码。
有几个核心方法如下:
addEventListener
把函数加到事件中心dispatchEvent
发布者发布事件removeEventListener
取消订阅
具体的实现思路参考:手写一个基于发布订阅模式的js事件处理中心(EventEmitter)
class EventCenter {
// 1.定义事件容器
events = {};
// 2.添加事件方法 参数:事件名 事件方法
addEventListener(type, handler) {
if (!this.events[type]) {
this.events[type] = [];
}
// 存入事件
this.events[type].push(handler);
}
// 3.触发事件 参数:事件名 事件参数
dispatchEvent(type, ...params) {
if (!this.events[type]) {
return new Error("不存在该事件");
}
// 循环执行事件
this.events[type].forEach((handler) => {
handler(...params);
});
}
// 4.事件移除 参数:事件名 要移除的参数 如果第二个参数不存在则删除该事件的订阅和发布
removeEventListener(type, handler) {
if (!this.events[type]) {
return new Error("事件无效");
}
if (!handler) {
delete this.events[type];
} else {
// 获取索引
const index = this.events[type].findIndex((el) => el === handler);
if (index === -1) {
return new Error("无该绑定事件");
}
// 移除事件
this.events[type].splice(index, 1);
if (this.events[type].length === 0) {
delete this.events[type];
}
}
}
}
// 测试
const dom = new EventCenter();
const eventClick = function (e) {
console.log("点击了", e);
};
const eventClick2 = function (e) {
console.log("点击了2", e);
};
dom.addEventListener("click", eventClick);
dom.addEventListener("click", eventClick2);
dom.dispatchEvent("click", "click1");
console.log(dom.events);
dom.removeEventListener("click", eventClick);
15. Object.defineProperty
Vue2的响应式原理,结合了Object.defineProperty的数据劫持,以及发布订阅者模式
const obj = {
name: "winter",
};
const data = {};
for (let key in obj) {
Object.defineProperty(data, key, {
get() {
console.log(`读取了data里面的${key}`);
return obj[key];
},
set(value) {
if (value === obj[key]) return;
console.log(`设置了${key}的值为${value}`);
obj[key] = value;
},
});
}
16. Proxy
vue3的数据劫持通过Proxy
函数对代理对象的属性进行劫持,通过Reflect
对象里的方法对代理对象的属性进行修改
const obj = {
name: "winter",
age: 18,
};
const p = new Proxy(obj, {
set(target, key, value) {
console.log(`设置了${key}的值${value}`);
return Reflect.set(target, key, value);
},
get(target, key) {
console.log(`获取了${key}的值`);
return Reflect.get(target, key);
},
deleteProperty(target, key) {
console.log(`删除了${key}`);
return Reflect.deleteProperty(target, key);
},
});
p.age = 20;
console.log(p.age);
17. 定时器相关
用setTimeout 实现 setInterval
实现思路是使用递归函数,不断地去执行 setTimeout 从而达到 setInterval 的效果
function myInterval(cb, timeout) {
function fn() {
cb();
setTimeout(fn, timeout);
}
setTimeout(fn, timeout);
}
使用setInterval实现setTimeout
function mySetTimeout(cb, timeout) {
const timer = setInterval(() => {
clearInterval(timer);
cb();
}, timeout);
}
三、总结
后续有其他的手写题再陆续加进来...