关于前端场景题,你了解多少?

1,749 阅读11分钟

基础题

实现Object.create

function create(obj) {
    function F() {}
    F.prototype = obj
    return new F()
}

create本质上是构建一个构造函数,并将传入的对象赋给该构造函数的原型,同时返回构造函数的实例,本质上属于一种原型链继承

实现Instanceof

function myInstanceof(left, right) {
	let proto = Object.getPrototypeOf(left), // 获取对象的原型
        prototype = right.prototype; // 获取构造函数的prototype对象

        // 判断构造函数的prototype对象是否在对象的原型链上
        while(true) {
            if (!proto) return false;
            if (proto === prototype) return true;
            proto = Object.getPrototypeOf(proto);
        }
}

通过原型链一直向上找,直到找到对应的构造函数的原型对象,说明该对象是目标构造函数的实例

实现New操作符

function New() {
	let newObj = null;
	let construtor = Array.prototype.shift.call(arguments);
	let result = null;
	// 判断参数是否是一个构造函数
	if (typeof construtor !== 'function') {
		return;
	}
	// 新建一个空对象,对象的原型为构造函数的prototype对象
	newObj = Object.create(construtor.prototype);
	// 对this指向新建对象,并执行函数
	result = construtor.apply(newObj, arguments);
	// 判断返回对象
	let flag = result && (typeof result === 'object' || typeof result === 'function');
	return flag ? result : newObj;
}

new一个对象分为三个步骤:

  1. 新建一个对象, 对象原型是目标new对象构造函数的prototype
  2. 执行目标构造函数,将this指向新建对象
  3. 判断构造函数返回值,如果是一个对象或函数,则将该对象返回,否则返回新建对象

实现Promise.all

function myPromiseAll(promises) {
    return new Promise(function(resolve, reject) => {
        if (!Array.isArray(promises)) {
                throw new TypeError('参数必须是Array')
        }
        let count = 0;
        let num = promises.length
        let resolveList = [];
        for (let i = 0; i < num; i++) {
            Promise.resolve(promises[i]).then(value => {
                count ++;
                resolveList[i] = value
                if (count === num) {
                        return resolve(resolveList)
                }
            }).catch(err => {
                    return reject(err)
            })
        }
    })
}

将每个promise resolve的值保存到一个数组中,最后将该数组作为resolve结果,如果出现某个promise失败,则直接reject该失败结果

实现Promise.race

function myPromiseRace(promises) {
    return new Promise((resolve, reject) => {
        if (Array.isArray(promises)) {
            return new TypeError('参数必须为Array')
        }
        for(let i = 0; i < promises.length; i++) {
            Promise.resolve(promises[i]).then(res => {
                    return resolve(res)
            }).catch(err => {
                    return reject(err)
            })
        }
	})
}

race,即为竞赛,resolve最早有结果的promise,同时如果最早的promise rejected,则将rejected的结果包装到返回的promise reject中

实现Promise.allSettled

function myAllSettled(list) {
	return new Promise((resolve, reject) => {
		if (!Array.isArray(promises)) {
			return new TypeError('参数必须为Array')
		}
		let count = 0;
		let resolvedCount = 0;
		const result = [];
		for (const l of list) {
			let i = count;
			count ++;
			Promise.resolve(l).then(res => {
				resolvedCount ++;
				result[i] = {
					status: 'fullfilled',
					value: res,
				}
			}, (err => {
				resolvedCount ++;
				result[i] = {
					status: 'rejected',
					value: err
				}
			}
			))
			.finally(() => {
				if (resolvedCount >= count) {
					resolve(result)
				}
			})
		}
	})
}

每个promise都有一个对应的result,不管resolve还是reject,并且保持原来的顺序将其存入到一个result数组中,最后等所有的promise都改变状态后将其返回。

实现Promise.any

function myPromiseAny(promises) {
	const rejectedArr = []; // 记录失败的结果
	// let rejectedTimes = 0; // 记录失败的次数

	return new Promise((resolve, reject) => {
		if(promises == null || promises.length == 0){
			reject("无效的 any");
		}
		for (let i = 0; i < promises.length; i++) {
			let p = promises[i];
			// 处理promise
			if (p && typeof p.then === 'function') {
				p.then(res => { 
					resolve(res)
				}, err => { // 如果失败了,保存错误信息;当全失败时,any 才失败
					rejectedArr[i] = err;
					// rejectedTimes ++;
					if (rejectedArr.length === promises.length) {
						reject(rejectedArr)
					}
				})
			} else { // 处理普通值 直接成功
				resolve(p)
			}
		}
	})
}

这个和前面的Promise.all逻辑相反,all是不允许出现reject,否则自身reject,而any是允许reject,只要有一个resolve,自身resolve,除非所有的promise都reject,则自身reject。

实现防抖

function myFD(fn, delay) {
	let timer = null;
	return function() {
		let context = this, args = arguments;
		if (timer) clearTimeout(timer);
		timer = setTimeout(() => {
			fn.apply(context, args);
		}, delay)
	}
}

防抖基本是最常见的手写js题了,经典的闭包应用,内层函数保留对外层函数timer的引用,timer不会被销毁,每次触发目标函数都必须要等定时器到时间才会执行。

实现节流

function myJL(fn, delay) {
	let date = new Date().getTime();
	return function() {
		let context = this;
		let args = arguments;
		if (new Date().getTime() - date >= delay) {
			date = new Date().getTime()
			fn.apply(context, args);
		}
	}
} 

节流和上述防抖类似,只不过节流函数是一段时间内只执行一次目标函数,而防抖是频繁触发目标函数后过n秒才执行一次目标函数(有点像坐电梯,只要有人进去电梯门就永远关不上)。节流可以通过时间戳差值方法实现,也可以通过定时器方案实现。

封装类型判断方法

function myType(value) {
	if (value === null) {
		return value + '';
	}
	// 判断数据是引用类型的情况
	if (typeof value === 'object') {
		let type = Object.prototype.toString.call(value);
		const result = type.split(' ')[1].split('');
		result.pop();
		return result.join('').toLowerCase();
	} else {
		return typeof value;
	}
}

常规的类型判断有typeof、instanceof和Object.prototype.toString.call,typeof无法细粒度判断对象类型,instanceof只能进行构造函数原型链判断,基本类型不适用,一般用Object.prototype.toString.call这种方法去判断,Object.prototype.toString() 是一个标准的 JavaScript 方法,用于返回一个对象的字符串表示形式。这个方法可以被所有对象继承,包括基本类型的包装对象(如 NumberStringBoolean 等),转换后的字符串信息包含具体类型,需要进行字符串解析。

实现Object.freeze

function deepFreeze(obj) {
	if (typeof obj !== 'object') {
		throw 'error'
	}
	const props = Object.getOwnPropertyNames(obj)
	props.forEach(item => {
		if (typeof obj[item] === 'object') {
			deepFreeze(obj[item])
		}
	})
	return Object.freeze(obj)
}

实现call

function myCall(context) {
	// 判断调用对象
	if (typeof this !== 'function') {
		console.error('type error')
	}
	// 获取参数
	let args = [...arguments].slice(1);
	let result = null;
	// 判断context是否传入,没传则为window
	context = context || window;
	// 将调用函数设为对象的方法
	context.fn = this;
	// 调用函数
	result = context.fn(...args);
	delete context.fn;
	return result;
}

call用于改变目标函数的this指向,具体原理通过传入的要指向的目标对象,将调用函数赋值给该目标对象的属性上,通过对象.函数属性的方式执行,这样this就会指向到目标对象上,进而修改了this指向。

实现apply

function myApply(context) {
	if (typeof this !== 'function') {
		console.error('type error');
	}
	let result = null;
	context = context || window;
	context.fn = this;
	if (arguments[1]) {
		result = context.fn(...arguments[1]);
	} else {
		result = context.fn()
	}
	delete context.fn;
	return result;
}

和call类似,只是apply第二个参数必须是一个数组。

实现bind

function myBind(context) {
	// 判断调用对象是否是函数
	if (typeof this !== 'function') {
		console.error('type error')
	}
	const args = [...arguments].slice(1)
	let fn = this;
	return function Fn() {
		const isNew = new.target !== undefined // 判断函数是否被new过
		return fn.apply(
			isNew ? this : context, // this如果被new过,则该this指向可能发生修改 (这里还需要对new bind进行一个实现)
			args.concat(...arguments)
		)
	}
}

bind函数会返回一个新的函数,这个函数的this会被绑定到传入的目标对象上,因为new的优先级最高,因此使用new的话会改变bind返回函数里的this指向。(需要额外考虑new的情况)

实现柯里化函数

function carry(fn, ...args) {
	if (args.length >= fn.length) {
		return fn(...args)
	}
	return (..._args) => {
		carry(fn, ...args, ..._args)
	}
}

判断传入的函数参数长度和rest参数长度,如果参数长度大于函数要接收的入参长度,则直接返回执行结果,否则继续递归执行carry函数,将额外的参数拼到carry rest参数部分(参数累加)。

实现数组乱序

function chaosArr(arr) {
	for (let i = 0; i < arr.length; i ++) {
		const randomIndex = Math.round(Math.random() * (arr.length - i - 1)) + i;
		[arr[i], arr[randomIndex]] = [arr[randomIndex], arr[i]] // 交换位置
	}
	return arr;
}

随机索引核心算法是第三行,通过得到随机索引和当前索引交换位置,实现数组乱序。

数组(含多维)求和

// 数组求和
let arr = arr.reduce((pre, cur) => pre += cur, 0)
// 多维数组求和
let arr = arr.toString().split(",").reduce((pre, cur) => pre += Number(cur), 0);

数组扁平化

// 第一种
function flatten(arr) {
	return arr.reduce((result, item) => {
	  if (Array.isArray(item)) {
		result = result.concat(flatten(item));
	  } else {
		result.push(item);
	  }
	  return result;
	}, []);
  }
  // 第二种
  function flatten(arr) {
	return arr.toString().split(',');
}

除此之外,方法还有很多,比如正则匹配、es6的flat等。

实现数组push

Array.prototype.push = function() {
	for (let i = 0; i < arguments.length; i++) {
		this[this.length] = arguments[i];
	}
	return this.length;
}

实现数组filter

Array.prototype.filter = function(fn) {
	if (typeof fn !== 'function') {
		throw Error('参数必须是一个函数')
	}
	const res = [];
	for (let i = 0; i < this.length; i ++) {
		fn(this[i]) && res.push(this[i])
	}
	return res;
}

实现数组map

Array.prototype.map = function(fn) {
	if (typeof fn !== 'function') {
		throw Error('参数必须是一个函数')
	}
	const res = [];
	for (let i = 0; i < this.length; i ++) {
		res.push(fn(this[i]))
	}
	return res;
}

实现数组reduce

function myReduce(arr, callback, initialValue) {
	let accumulator = initialValue; // 初始值为传入的initialValue参数或者第一个元素

	for (let i = 0; i < arr.length; i++) {
        if (!accumulator && !i) {
            accumulator = arr[i]; // 如果没有提供初始值,则将第一个元素设置为累加器
        } else {
            accumulator = callback(accumulator, arr[i], i); // 调用callback函数来处理每个元素
        }
	}
	
	return accumulator;
}

数组重复

function repeatArr(n, s) {
	return (new Array(n + 1)).join(s)
}

0.1+0.2问题

function funcAdd(x, y) {
		const num1 = x.toString().split('.')[1].length
		const num2 = y.toString().split('.')[1].length
		const pow = Math.pow(10, Math.max(num1, num2))
		const result = (x * pow + y * pow) / pow
		return result
}

字符串反转

function reverseStr(str) {
	return str.split('').reverse().join('');
}

冒泡排序

function sort(arr) {
    for (let i = 0; i < arr.length; i++) {
        for (let j = 0;j < arr.length - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                const temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
    return arr;
}

快速排序

function quickSort(arr) {
	if (arr.length <= 1) {
		return arr;
	}
	let temp = arr[0]; // 基准 取数组的第一个元素
	const leftList = [];
	const rightList = [];
	for (let i = 0; i < arr.length; i++) {
		if (arr[i] > temp) {
			rightList.push(arr[i])
		} else {
			leftList.push(arr[i])
		}
	}
	return quickSort(leftList).concat([temp], quickSort(rightList));
}

二分法

function BinarySearch(arr, target) {
	let from = 0;
	let to = arr.length - 1;
	// from <= to 条件
	while(from <= to) {
		mid = Math.floor((from + to) / 2);
		if (arr[mid] < target) {
			from = mid + 1;
		} else if (arr[mid] > target) {
			to = mid - 1;
		} else {
			return mid;
		}
	}
	return -1;
}

对象迭代器

Object.prototype[Symbol.iterator] = function* iterators() {
	const keys = Object.keys(this)
	for (let i = 0;i < keys.length; i++) {
		const key = keys[i]
		yield this[key]
	}
}

使用生成器方式去实现迭代器,具备Symbol.iterator接口方法的对象具备可迭代性,可以使用for...of进行遍历。

实现JSONP

function addScript(src) {
	const js = document.createElement('script')
	js.src = src
	js.type = 'text/javascript'
	document.body.appendChild(js)
}
addScript('http://xxx.xxx.com/xxx.js?callback=handleRes')
function handleRes(data) { console.log(data); }

JSONP 的原理是通过动态创建 <script> 标签来跨域获取数据。通常情况下,浏览器的同源策略会阻止从不同源(协议、域名或端口)加载的资源进行交互。然而,<script> 标签不受同源策略的限制,因为它可以从任何源加载和执行 JavaScript 代码。 在这个例子中,http://xxx.xxx.com/xxx.js?callback=handleRes 是一个 JSONP 请求的 URL。其中,handleRes 是回调函数的名称,该函数将在数据从服务器返回后被调用。服务器端的 JSONP 接口会将数据包装在一个 JavaScript 函数调用中,并以此作为响应。

中等题

封装AJAX

function getJSONAJAX(url) {
	let promise = new Promise((resolve, reject) => {
		let xhr = new XMLHttpRequest();
		xhr.open("GET", url, true);
		// 设置状态的监听函数
		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));
    };
		// 设置响应的数据类型
    xhr.responseType = "json";
		// 设置请求头信息
    xhr.setRequestHeader("Accept", "application/json");
    // 发送 http 请求
    xhr.send(null);
	})
	return promise;
}

本质上还是一个AJAX的实现,只不过在外层包装了一层Promise。

实现深拷贝

function deepCopy(targetObj, hash = new WeakMap()) {
	if (!targetObj || typeof targetObj !== 'object') return;
	if (targetObj instanceof Date) {
		return new Date(targetObj)
	}
	if (targetObj instanceof Function) {
		return new Function(targetObj)
	}
	if (target instanceof RegExp) {
		return new RegExp(target)
	}
	const obj = Array.isArray(targetObj) ? [] : {};
	if (hash.has(targetObj)) {
		return hash.get(targetObj)
	}
	hash.set(targetObj, obj)
	for (let key in targetObj) {
		if (targetObj.hasOwnProperty(key)) {
			obj[key] = typeof targetObj === 'object' ? deepCopy(targetObj[key], hash) : targetObj[key];
		}
	}
	return obj
}

这个方法处理了深拷贝时的循环引用问题,同时针对一部分无法直接进行对象深拷贝的数据类型——比如正则、函数和日期类型进行了相应处理。

大整数加法

function subStrings(a, b) {
	var res = '', c = 0
	a = String(a).split('') // number -> string -> array
	b = String(b).split('')
	while(a.length || b.length || c) {
		c += ~~a.pop() + ~~b.pop();  // 从个位开始 依次往前做加法
		res = c % 10 + res;
		c = c > 9 ? 1 : 0; // c > 9返回1,否则返回0
	}
	return res.replace(/^0+/, '')
}

其实这种算法类似数学里的竖式运算,遇到进位+1,模10取当前位的值,再累加自身,最终得到最终的和,最后处理一下前缀0即可。

数字千分位用逗号隔开

function format(n) {
	let num = n.toString()
	let decimals = ''
	// 处理小数
	if (num.indexOf('.') > -1) {
		decimals = num.split('.')[1] // 获取小数部分
	}
	let len = num.length
	if (decimals.length) {
		len = len - decimals.length - 1 // 去掉小数部分和小数点,剩余部分做千分位处理
	}
	if (len <= 3) {
		return num // 长度不够3个一组,则将原数返回
	}
	let temp = ''
	if (decimals) {
		temp = `.${decimals}` // 存在小数部分,则加上小数部分
	}
	let remainder = len % 3 // 取余数,如果存在多余,则追加到字符串最前面
	if (remainder > 0) {
		// 不是3的整数倍
            return num.slice(0, remainder) + ',' + num.slice(remainder, len).match(/\d{3}/g).join(',') + temp // 三部分 多余前缀+主体+小数部分
	}
	return num.slice(0, len).match(/\d{3}/g).join(',') + temp // 两部分,主体+小数部分
}

交替亮灯

function task(timer, light, callback) {
	setTimeout(() => {
		if (light === 'red') {
			red()
		} else if (light === 'green') {
			green()
		} else if (light === 'yellow') {
			yellow()
		}
	}, timer);
}
const taskRunner = async () => {
	await task(3000, 'red')
	await task(2000, 'green')
	await task(1000, 'yellow')
	taskRunner()
}
taskRunner();

这道理主要考察async和await的用法,await会阻塞当前异步函数体内的代码执行,因此通过await一个task,可以阻塞后续任务执行,当前任务执行完毕后再执行下一个任务。

图片异步加载

function imageAsync(url) {
	return new Promise((resolve, reject) => {
		let img = new Image()
		img.src = url
		img.onload = function() {
			// ...
			resolve(img)
		}
		img.onerror = function(err) {
			// ...
			reject(err)
		}
	})
}
imageAsync('url').then(() => {
	// 加载成功
}).catch(err => {
	// 加载失败
})

通过Promise实现图片的异步加载,常用于图片懒加载场景。

实现双向绑定

let obj = {}
let input = document.getElementById('input')
let span = document.getElementById('span')

// 数据劫持
Object.defineProperty(obj, 'text', {
	configurable: true, // 可配置
	enumerable: true, // 可枚举
	get() {
		console.log('获取数据')
	},
	set(newVal) {
		console.log('数据更新')
		input.value = newVal
		span.innerHTML = newVal
	}
})
input.addEventListener('keyup', function(e) {
	obj.text = e.target.value
})

vue2双向绑定简易版实现,通过Object.defineProperty来完成属性的定义,当操作该对象的属性时,会自动触发getter和setter,进而完成数据劫持。

递归setTimeout

function mySetInterval(fn, timeout) {
	let temp = {
		flag: true
	}
	function interval() {
		if (temp.flag) {
			fn()
			setTimeout(interval, timeout)
		}
	}
	setTimeout(interval, timeout)
	return temp;
}

通过setTimeout+递归实现了setInterval,好处是:

  1. 避免累积延迟: 当使用 setInterval 时,如果函数执行时间超过了间隔时间,可能会导致后续的调用被累积起来,造成不必要的负载和延迟。使用递归 setTimeout 可以确保每次调用都在上一次调用完成后才开始,避免了这种问题。
  2. 更精确的控制setInterval 的间隔时间是从上一次函数调用开始计算的,而不是从上一次函数结束时开始计算的。这可能会导致一些微妙的时间差异。使用递归 setTimeout 可以更精确地控制函数的执行时间点。
  3. 处理异步操作: 如果你的函数中包含异步操作,例如网络请求或文件读写,使用 setInterval 可能会在上一个操作尚未完成时就触发下一个操作。使用递归 setTimeout 可以在上一个操作完成后再触发下一个操作,避免了这种问题。

可以看一下下面这个Case,是setTimeout用法的延伸:

实现一个定时器函数myTimer(fn, a, b), 让fn执行, 第一次执行是a毫秒后, 第二次执行是a+b毫秒后, 第三次是a+2b毫秒, 第N次执行是a+Nb毫秒后

function myTimer(fn, a, b) {
  let timerId;
  let count = 0;
  function schedule() {
    const delay = a + count * b;
    timerId = setTimeout(() => {
      fn();
      count++;
      schedule();
    }, delay);
  }
  schedule();
  return function () {
    clearTimeout(timerId);
  };
}

实现getElementById

function getElementById(id) {
	const root = document.body || document.documentElement
	function traverse(node) {
		// 检查当前节点id是否匹配
		if (node.id === id) {
			return node
		}
		for (let i = 0; i < node.children.length; i++) {
			const result = traverse(node.children[i])
			if (result) {
				return result
			}
		}
		return null
	}
	return traverse(root)
}

深度搜索,找到可以和目标元素id匹配上的节点,如果找到了直接返回,如果没找到继续遍历当前节点的子节点,继续重复以上步骤,直到找到为止。

数组结构转树状结构

function tranListToTreeData(list) {
  // 最终要产出的树状图 数据的数组
  const treeList = []
  // 所有项都使用对象存储起来
  const map = {}
  // 建立映射关系 通过id快速找到对应的元素
  list.forEach(item => {
    if (!item.children) {
      item.children = []
    }
    map[item.id] = item // 直接赋值 更新其他地址会自动触发children变更
  })
  list.forEach(item => {
    const parent = map[item.pid]
    if (parent) {
      parent.children.push(item)
    } else {
      treeList.push(item)
    }
  })
  return treeList
}

基本思路:把数组转成对象格式存入到map中,数组中的id当对象的键,数组中的对象当值。然后遍历原数组,使用map[item.pid] 取对象里面的值,如果能取到,表明pid和某项id相等,把这一项追加到children中,如果不能取到,表明这一项没有父级追加到新的数组中。parent、map和item之间通过直接赋值的形式,共享同一份内存空间,操作parent,map会发生变化,同时影响item的变化,因此最终只取item的内容即可,节省空间。

构造函数相关实现

写一个构造函数Foo,该函数每个实例为一个对象,形如{id:N},其中N表示第N次调用得到的。

const Foo = (function () {
  let count = 0;
  function Foo() {
    if (!(this instanceof Foo)) {
      return new Foo();
    }
    count++;
    this.id = count;
  }
  return Foo;
})();

const foo1 = new Foo();
console.log(foo1); // { id: 1 }

const foo2 = new Foo();
console.log(foo2); // { id: 2 }

const foo3 = Foo();
console.log(foo3); // { id: 3 }

图片转base64格式

用到canvas中的toDataURL方法,该方法可以把image转成真实的url供你放到img src上展示,这个url就是base64格式。

let img = new Image() // 创建一个新的image对象

img.src = 'image.jpg'; // 设置图片的源

img.onload = function() {
    const canvas = document.createElement('canvas') // 创建一个canvas元素
    const ctx = canvas.getContext('2d')
    
    // 设置 Canvas 元素的宽高与图片一致 
    canvas.width = img.width; 
    canvas.height = img.height;
    
    ctx.drawImage(img, 0, 0) // 在canvas上绘制图片
    
    const base64Data = canvas.toDataURL('image/jpeg')
    
    console.log(base64Data) // base64Data就是转换后的base64格式的图片数据
}

手动发布订阅自定义事件实现异步操作

简单点的理解题意就是,有两个函数,要求使用自定义事件实现先执行A,A执行完毕后再执行B,可以用发布订阅保证A先执行完再执行B,A调用完发布一个事件,让B去订阅这个事件。自定义事件相关内容可以看下这篇文章juejin.cn/post/732266…

const ev = new Event('customEvent')

function fnA() {
    setTimeout(() => {
        console.log('A执行')
        window.dispatchEvent(ev)
    }, 1000)
}

fnA() // 让A执行,也就是开始发布事

window.addEventListener('ev', () => {
    setTimeout(() => { console.log('B执行') }, 500)
})

不过我们正常还是都会选择Promise或者async/await去实现异步操作,这里只是为了演示通过自定义事件也可以模拟异步的实现,应付面试即可。

困难题

实现发布订阅模式

class EventCenter {
	// 定义事件容器,装事件数组
	handlers = {}
	// 添加事件方法 参数:事件名、事件方法
	addEventListener(type, handler) {
		// 创建新数组容器
		if (!this.handlers[type]) {
			this.handlers[type] = []
		}
		// 存入事件
		this.handlers[type].push(handler)
	}
	// 触发事件,参数:事件名、事件参数
	dispatchEvent(type, params) {
		// 若没有注册该事件则抛出错误
		if (!this.handlers[type]) {
			return new Error('该事件未注册')
		}
		// 触发事件
		this.handlers[type].forEach(handler => {
			handler(...params)
		})
	}
	// 事件移除,参数:事件名 要删除事件,若无第二个参数则删除该事件的订阅和发布
	removeEventListener(type, handler) {
		if (!this.handlers[type]) {
			return new Error('事件无效')
		}
		if (!handler) {
			// 移除事件
			delete this.handlers[type]
		} else {
			const index = this.handlers[type].findIndex(el => el === handler)
			if (index === -1) {
				return new Error('无该绑定事件')
			}
			// 移除事件
			this.handlers[type].splice(index, 1)
			if (this.handlers[type].length === 0) delete this.handlers[type]
		}
	}
}

发布订阅模式是Vue响应式系统的底层设计模式,发布订阅是多对多的设计模式,只要发布者和订阅者之间存在订阅关系,当发布者发布新的变化,订阅者就可以根据变化来触发响应。整个模式下,需要一个事件容器进行事件的收纳,根据type来获取该订阅者所订阅的所有事件,当事件触发后,所有相关的订阅事件都会触发。事件移除的过程需要找到对应的事件索引,根据索引将当前事件删除,若没有具体事件传入,则将事件抽象订阅完全移除。

类似实现:EventEmitter

实现Hash路由

class Route {
	constructor() {
		// 路由存储对象
		this.routes = {}
		// 当前hash
		this.currentHash = ''
		// 绑定this,避免监听时this指向改变
		this.freshRoute = this.freshRoute.bind(this)
		// 监听
		window.addEventListener('load', this.freshRoute, false)
		window.addEventListener('hashchange', this.freshRoute, false)
	}
	storeRoute(path, cb) {
		this.routes[path] = cb || function() {}
	}
	freshRoute() {
		this.currentHash = window.location.hash.slice(1) || '/'
		this.routes[this.currentHash]()
	}
}

这是路由切换的基本实现,通过保存对应页面的渲染函数,当路由切换路径时,可以在hash表中获取页面的对应渲染函数,执行该渲染函数,进而渲染对应的页面。

判断对象是否存在循环引用

function isCycleObject(obj, parent) {
	const parentArr = parent || [obj]
	for (let i in obj) {
		// 判断obj[i]是否是一个对象
		if (typeof obj[i] === 'object') {
			let flag = false
			parentArr.forEach(pObj => {
				// 判断循环引用的必要条件
				if (pObj === obj[i]) {
					flag = true
				}
			})
			if (flag) return true
			flag = isCycleObject(obj[i], [...parentArr, obj[i]])
			if (flag) return true
		}
	}
	return false
}

循环引用:一个对象内存在地址相同的属性。因此只需要判断当前对象的某个属性是否和其他属性的地址相同即可。我们可以把遍历过的对象放到一个数组中,在每次遍历新的属性时拿数组里的地址去对比,如果存在则证明存在循环引用,否则继续递归。

实现Promise

class MyPromise {
	constructor(callback) {
		// 初始化状态为pending
		this.status = 'pending'
		// 初始化值为空字符串
		this.value = ''
		// 初始化原因为空字符串
		this.reason = ''
		// 存储成功状态的回调函数数组
		this.onResolvedCallbacks = []
		// 存储失败状态的回调函数数组
		this.onRejectedCallbacks = []

		// 定义resolve函数,用于将状态从pending变为resolved
		const resolve = (value) => {
			if (this.status === 'pending') {
				this.value = value
				this.status = 'resolved'
				// 执行所有成功状态的回调
				this.onResolvedCallbacks.forEach(callback => callback())
			}
		}
		// 定义reject函数,用于将状态从pending变为rejected
		const reject = (reason) => {
			if (this.status === 'pending') {
				this.reason = reason
				this.status = 'rejected'
				// 执行所有失败状态的回调
				this.onRejectedCallbacks.forEach(callback => callback())
			}
		}
		try {
			// 调用传入的回调函数,并传入resolve和reject函数
			callback(resolve, reject)
		} catch (error) {
			// 如果回调函数抛出异常,则调用reject函数
			reject(error)
		}
	}
	then(onResolved, onRejected) {
		// 如果onResolved是函数,则直接使用,否则默认为返回值不变的函数
		onResolved = typeof onResolved === 'function' ? onResolved : (value) => Promise.resolve(value)
		// 如果onReject是函数,则直接使用,否则默认为抛出异常的函数
		onRejected = typeof onRejected === 'function' ? onRejected : (reason) => Promise.reject(reason)
		// 创建一个新的Promise对象
		const newPromise = new MyPromise((resolve, reject) => {
			if (this.status === 'resolved') {
				try {
					// 如果当前状态为resolved,则调用onResolved函数处理值
					const x = onResolved(this.value)
					resolve(x)
				} catch (error) {
					// 如果onResolved函数抛出异常,则调用reject函数
					reject(error)
				}
			}
			if (this.status === 'rejected') {
				try {
					// 如果当前状态为rejected,则调用onRejected函数处理值
					const x = onRejected(this.reason)
					reject(x)
				} catch (error) {
					// 如果onRejected函数抛出异常,则调用reject函数
					reject(error)
				}
			}
			// 如果当前状态是pending,则需要将onResolved、onRejected回调保存起来,等异步结束之后再执行
			if (this.status === 'pending') {
				// 如果当前状态为pending,则将回调函数添加到对应的数组中
				this.onResolvedCallbacks.push(() => {
					if (this.status === 'resolved') {
						try {
							// 如果当前状态变为resolved,则调用onResolved函数处理值
							const x = onResolved(this.value)
							resolve(x)
						} catch (error) {
							// 如果onResolved函数抛出异常,则调用reject函数
							reject(error)
						}
					}
				})
				this.onRejectedCallbacks.push(() => {
					if (this.status === 'rejected') {
						try {
							// 如果当前状态变为rejected,则调用onRejected函数处理值
							const x = onRejected(this.reason)
							reject(x)
						} catch (error) {
							// 如果onRejected函数抛出异常,则调用reject函数
							reject(error)
						}
					}
				})
			} else {
				// 执行完所有回调后,清空回调数组
				this.onResolvedCallbacks = []
				this.onRejectedCallbacks = []
			}
		})
		return newPromise
	}
	catch(onRejected) {
		// 等同于then(null, onRejected)
		return this.then(null, onRejected)
	}
}

这道题代码量很大,一般不会让你都写完,大概逻辑能说明清楚即可,首先要明白Promise的原理和使用特性、初始状态、状态变化逻辑、resolve和reject等。然后将所有东西拼凑成一个类,将模块聚合起来。比如resolve、reject都是函数,直接定义,函数体的作用就是状态变更,执行回调。定义then函数,结合promise链式调用的特性,可以知道then函数一定返回一个新的promise,这样我们只需要在新的promise里操作具体逻辑即可,区分三类状态,针对不同状态执行回调,如果pending代表状态未更改,将回调放到一个数组中,方便下一次状态变更后调用。catch作用类似then的第二个参数,因此直接返回包装then即可。

实现Async

async核心是generator(生成器),需要一直next调用(递归)

function generatorToAsync(generatorFn) {
    const gen = generatorFn()
    return function() {
        return new Promise((resolve, reject) => {
            function loop(key, arg) {
                let res = null
                res = gen[key](arg)
                const { value, done } = res
                if (done) {
                    return resolve(value)
                } else {
                    Promise.resolve(value).then(res => {
                        loop('next', value)
                    })
                }
            }
            loop('next')
        })
    }
}

async await没有错误捕获机制,需要手动try catch。

async、await通过递归的方式实现了generator自动执行next函数,等到done为true,结束递归,取到async函数的最终结果(CO模块实现原理)。

寄生组合式继承

function Parent(name) {
	this.name = name
	this.arr = [1,2,3]
}
function Child(name, age) {
	Parent.call(this, name)
	this.age = age
}
Child.prototype = Object.create(Parent.prototype)
Child.prototype.constructor = Child
  1. 创建父类构造函数:首先,定义一个父类构造函数,用于初始化父类对象的属性和方法。
  2. 创建子类构造函数:然后,定义一个子类构造函数,用于初始化子类对象的属性和方法。
  3. 设置子类原型:使用 Object.create() 方法将父类的原型对象作为子类的原型对象。这样,子类就可以访问父类的属性和方法。
  4. 将子类的构造函数指向自身:使用 Child.prototype.constructor = Child; 将子类的构造函数指向自身,确保在使用 new 关键字创建子类实例时,正确地调用子类的构造函数。
  5. 在子类构造函数中调用父类构造函数:使用 Parent.call(this,...) 在子类构造函数中调用父类构造函数,以便将父类的属性和方法复制到子类对象上。

对象扁平化

function flat(obj, path = '', res = {}, isArray) {
	for(let [k, v] of Object.entries(obj)) {
		if (Array.isArray(v)) {
			let _k = isArray ? `${path}[${k}]` : `${path}${k}`
			flat(v, _k, res, true)
		} else if (typeof v === 'object') {
			let _k = isArray ? `${path}[${k}].` : `${path}${k}.`
			flat(v, _k, res, false)
		} else {
			let _k = isArray ? `${path}[${k}]` : `${path}${k}`
			res[_k] = v
		}
	}
	return res
}

将对象拆成key-value形式,分别解析出key和value值,再针对value的类型做进一步判断,根据不同的类型解析成不同的path路径结构。

LRU

class LRU {
	constructor(capacity) {
		this.cache = new Map()
		this.capacity = capacity
	}
	get(key) {
		if (this.cache.has(key)) {
			const temp = this.cache.get(key)
			this.cache.delete(key)
			this.cache.set(key, temp)
			return temp
		}
		return undefined
	}
	set(key, value) {
		if (this.cache.has(key)) {
			this.cache.delete(key)
		} else if (this.cache.size >= this.capacity) {
			this.cache.delete(this.cache.keys().next().value)
		}
		this.cache.set(key, value)
	}
}

淘汰算法的一种,最久远使用频率最低的元素最容易被淘汰,通过一个Map来维护一个队列结构,当队列长度超过设置好的阈值,则将队尾元素出队。这里使用Map结构存储数据,因为Map结构可以保持数据的存入顺序,符合队列先进先出的特性。

请求并发控制

function sendRequest(requestList, limits, callback) {
	const promises = requestList // 取得请求list
	const concurrentNum = Math.min(limits, requestList.length) // 得到开始时,能执行的并发数
	let concurrentCount = 0 // 当前并发数
	// 第一次先跑起可以并发的任务
	const runTaskNeeded = () => {
		let i = 0
		// 启动当前能执行的任务
		while(i < concurrentNum) {
			i++
			runTask()
		}
	}
	// 取出任务并且执行任务
	const runTask = () => {
		const task = promises.shift()
		task && runner(task)
	}
	// 执行任务,同时更新当前并发数
	const runner = async (task) => {
		try {
			concurrentCount++
			await task()
		} catch (error) {
			console.error(error)
		} finally {
			concurrentCount-- // 并发数--
			picker() // 捞起下一个任务
		}
	}
	// 捞起下一个任务
	const picker = () => {
		// 任务队列里还有任务并且此时还有剩余并发数的时候 执行
		if (promises.length > 0 && concurrentCount < limits) {
			// 继续执行任务
			runTask()
		}
		// 队列为空的时候,并且请求池清空了,就可以执行最后的回调函数了
		else if (promises.length === 0 && concurrentCount === 0) {
			// 执行结束
			callback && callback()
		}
	}
	runTaskNeeded()
}

详情看注释...

总结

目前总结了我遇到的一些经典的前端场景题,这些都是从各个前端面经里捞出来的,大家可以参考下,如果有更多好题,欢迎补充!后续我还会继续补充场景题库🙏🙏🙏