目的
JS 手写题是一些常见的算法或原生 API 的实现,是对 JS 基础知识的综合考察和对实际工作的应用。
在日常开发中,我们能够根据实际业务需求或者遇到性能问题等原因出现各种手写题。
而在面试中,手写题也是占据了很大的比例,主要是考察应聘者对于问题的分析与解决能力,以及对基础知识的熟练程度。
为此,本文着重介绍了一些常见的手写题,以帮助读者在实际开发和面试中更加得心应手。
题目
防抖
防抖函数常用于处理高频触发的事件,将多次触发的回调函数合并成一次执行,减少性能消耗
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| function debounce(fn, delay) { let timer; return function (...args) { clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, args); }, delay); }; }
const debounceFn = debounce(() => { console.log("你点击了 window"); }, 1000); window.addEventListener("click", debounceFn);
|
节流
节流函数常用于处理高频触发的事件,将多次触发的回调函数合并成固定时间间隔内执行一次,减少性能消耗。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function throttle(fn, delay) { let lastTime = 0; return function (...args) { const nowTime = Date.now(); if (nowTime - lastTime > delay) { fn.apply(this, args); lastTime = nowTime; } }; }
const throttleFn = throttle(() => { console.log("不论你点多快,1s 我只执行一次"); }, 1000); window.addEventListener("click", throttleFn);
|
数据类型判断
一般来说我们使用 typeof 可以判断基本数据类型,使用 instance of 判断复杂对象类型。但是有时候在系统里我们需要一个统一的方法,那么可以理由 Object 原型上的 toString 方法来实现。
1 2 3 4 5 6 7 8 9 10
| function typeOf(obj) { return Object.prototype.toString.call(obj).slice(8, -1).toLowerCase(); }
console.log(typeOf([])); console.log(typeOf({})); console.log(typeOf(1)); console.log(typeOf(true)); console.log(typeOf(new Date()));
|
发布订阅模式
发布订阅模式是一种消息通信机制,其中发布者将消息发送到“主题”,而订阅者通过“订阅”该主题来接收消息。Node 中的 EventEmitter 是一种实现发布订阅模式的内置模块,可用于在不同组件之间通信,如在服务器端与客户端之间发送实时通知或在应用程序内的组件之间发送事件。它具有多个方法,包括 on、emit 和 once,可用于注册监听器、触发事件和单次监听事件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| class EventEmitter { constructor() { this.events = {}; }
on(name, listener) { if (!this.events[name]) { this.events[name] = []; } this.events[name].push(listener); }
once(name, listener) { const onceListener = (...args) => { listener.apply(this, args); this.removeListener(name, onceListener); } this.on(name, onceListener); }
emit(name, ...args) { if (!this.events[name]) { return; } this.events[name].forEach(listener => { listener.apply(this, args); }) }
removeListener(name, fnHandle) { if (!this.events[name]) { return; } this.events[name] = this.events[name].filter(listener => listener !== fnHandle); } }
const em = new EventEmitter()
em.on('greet', function(name) { console.log(`Hello, ${name}!`) })
em.once('bye', function() { console.log('Goodbye!') })
em.emit('greet', 'Alice')
em.emit('bye')
em.emit('bye')
|
字符串模板
ES6 引入了的字符串模板,那么来看看是如何自己实现的吧。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| function render(template, context) { const reg = /\$\{(\w+)\}/g; return template.replaceAll(reg, function(...args) { const [, variableName] = args; return context[variableName]; }) }
const template = '我是${name},今年 ${age} 岁了!';
const context = { name: '张三', age: 18 } console.log(render(template, context));
|
如果要实现类似 {{variableName}} 的模板,稍微更改一下正则即可实现。
函数柯里化
柯里化(Currying)是函数式编程的概念之一,指的是将一个接受多个参数的函数转化为一系列接受单一参数的函数的组合。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| function curry(fn) { return function curried (...args) { if (args.length >= fn.length) { return fn.apply(this, args); } return function(...args2) { return curried.apply(this, args.concat(args2)); } } }
function threeSum(a, b, c) { return a + b + c; } const cAdd = curry(threeSum); console.log(cAdd(1)(2)(3)); console.log(cAdd(1, 2)(3)); console.log(cAdd(1, 2, 3));
|
Promise Limit
为了防止给下游造成突增 QPS,在咱们日常编程中经常有请求并发限制的需求,如果是实际开发可能直接使用类似 async 的一些异步库来进行限制。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| class PromiseLimit { constructor(limit) { this.limit = limit; this.queue = []; this.pendingCount = 0; }
add(fn) { this.queue.push(fn); this.run(); }
run() { while(this.pendingCount < this.limit && this.queue.length) { const fn = this.queue.shift(); this.pendingCount++; fn().finally(() => { this.pendingCount--; this.run(); }) } } }
const promiseLimit = new PromiseLimit(2); promiseLimit.add(() => { console.log("promise 1"); return new Promise((resolve) => { setTimeout(() => { resolve(); }, 1000); }); }); promiseLimit.add(() => { console.log("promise 2"); return new Promise((resolve) => { setTimeout(() => { resolve(); }, 1000); }); }); promiseLimit.add(() => { console.log("promise 3"); return new Promise((resolve) => { setTimeout(() => { resolve(); }, 1000); }); }); promiseLimit.add(() => { console.log("promise 4"); return new Promise((resolve) => { setTimeout(() => { resolve(); }, 1000); }); });
|
详细的过程和解答可参考实现 Promise 的并发限制
实现中间件效果
实现类似 express、koa 等洋葱🧅模型的效果,依次按顺序执行函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| class App { constructor() { this.queue = []; }
use(fn) { this.queue.push(fn); }
run() { const fn = this.queue.shift(); if (fn) { fn(() => this.run()) } } }
const app = new App(); app.use(next => { setTimeout(() => { next(); }, 1000) }); app.use(next => { console.log("hello"); next(); }); app.run();
|
详细的过程和解答可参考用 JS 实现一个简单支持中间件的 APP
实现函数原型上的 call 方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| Function.prototype.call2 = function(context, ...args) { const ctx = context ? Object(context) : window; const key = Symbol('key'); ctx[key] = this; const result = ctx[key](...args); delete ctx[key]; return result; }
const obj = { name: 'sam', };
function fn() { console.log(this.name); } fn.call2(obj);
|
apply 和 call 类似,以数组形式传入参与即可。
实现函数原型上的 bind 方法
需要注意的是 bind 函数改变 this 指向时可以指定参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| Function.prototype.bind2 = function(context, ...args) { const self = this; return function(...arg) { return self.apply(context, [...args, ...arg]); } }
const obj = { name: 'sam', };
function fn(...args) { const sum = args.reduce((prev, cur) => prev + cur) console.log(this.name, sum); } const bindFn = fn.bind2(obj, 1, 2); bindFn(3);
|
实现 instance of 关键字
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function instanceOf(instance, constructor) { let proto = instance.__proto__; while (proto) { if (proto === constructor.prototype) { return true; } proto = proto.__proto__; } return false; }
const arr = []; console.log(instanceOf(arr, Array));
|
封装一个带超时和重试的 request 函数
基于浏览器原生的 fetch 封装一个带超时和重试的业务 request 函数,要求如下:
- 函数支持配置超时时间和重试次数
- 超时和发起请求是竞争关系,即两者只能成功一个。当请求超时,如果没有超过重试次数,则发起下一轮的请求;请求成功时返回结果,清除定时器
- 请求超时需要取消正在发起的请求,再重新发起下一轮的请求
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| function request(url, options) { const { retry, timeout, ...restOptions } = options;
return new Promise((resolve, reject) => { let timer; let count = 0;
const doRequest = () => { const controller = new AbortController(); const signal = controller.signal;
const timeoutPromise = new Promise((_, rej) => { timer = setTimeout(() => { controller.abort(); rej(new Error(`Timeout of ${timeout}ms exceeded`)); }, timeout); }); const fetchPromise = fetch(url, { ...restOptions, signal });
Promise.race([timeoutPromise, fetchPromise]) .then((res) => { clearTimeout(timer); resolve(res); }) .catch((e) => { if (count < retry) { count++; doRequest(); } else { reject(e); } }); };
doRequest(); }); }
|
可以通过 Node 搭建一个简单的服务来验证,验证代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| request("http://127.0.0.1:3000", { retry: 3, timeout: 3000 }) .then((res) => res.text()) .then((res) => { console.log("🚀 ~ file: index.html:59 ~ res:", res); });
const http = require('http'); const fs = require('fs'); const path = require('path');
const hostname = '127.0.0.1'; const port = 3000; let count = 0;
const server = http.createServer((req, res) => { if (req.url === '/page') { const filePath = path.join(__dirname, 'index.html'); fs.readFile(filePath, (err, data) => { if (err) { res.statusCode = 500; res.end(`Error getting the file: ${err.message}`); } else { res.setHeader('Content-Type', 'text/html'); res.end(data); } }); } else { count++; res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); const timeout = (count % 5) * 1000; setTimeout(() => { res.end('Hello World\n'); }, timeout); } });
server.listen(port, hostname, () => { console.log(`Server running at http://${hostname}:${port}/`); });
|
访问 http://127.0.0.1:3000/page 即可验证,可通过调整 timeout 的值来实现不同场景的测试
实现 Promise 队列
给出如下的一个使用示例,要求每次按顺序输出 3 4 5,要你实现这个类:
1 2 3 4 5 6 7 8 9 10 11 12
| const queue = new Queue(async (param) => { await new Promise(resolve => setTimeout(resolve, 1000 * Math.random())); return param + 2; }); queue.call(1).then(console.log); queue.call(2).then(console.log); queue.call(3).then(console.log);
|
这里的思路是利用 Promise 链式调用的特点,来实现一个队列的效果,先来看最终代码:
1 2 3 4 5 6 7 8 9 10 11
| class Queue { constructor(worker) { this.worker = worker this.currentPromise = Promise.resolve() }
call(param) { this.currentPromise = this.currentPromise.then(() => this.worker(param)) return this.currentPromise } }
|
主要就是利用 promise 的特性,使用 then 将他们链接成一条链表:Promise.resolve() => this.worker(1) => console.log => this.worker(2) => console.log => this.worker(3) => console.log
手写Promise.all、Promise.race、Promise.allSettled
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| function promiseAll(promises) { return new Promise((resolve, reject) => { let results = []; let completed = 0;
promises.forEach((promise, i) => { promise.then((result) => { results[i] = result; completed += 1;
if (completed === promises.length) { resolve(results); } }).catch(reject); }); }); }
function promiseRace(promises) { return new Promise((resolve, reject) => { promises.forEach((promise) => { promise.then(resolve).catch(reject); }); }); }
function promiseAllSettled(promises) { return new Promise((resolve) => { let results = []; let completed = 0;
promises.forEach((promise, i) => { promise.then((result) => { results[i] = { status: 'fulfilled', value: result }; }).catch((error) => { results[i] = { status: 'rejected', reason: error }; }).finally(() => { completed += 1;
if (completed === promises.length) { resolve(results); } }); }); }); }
|
实现 new 操作符
1 2 3 4 5 6
| function myNew(constructor, ...args) { const obj = Object.create(constructor.prototype) const res = constructor.apply(obj, args) return res instanceof Object ? res : obj }
|
深度拷贝
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| function deepCopy(obj) { if (typeof obj !== 'object' || obj === null) { return obj; } if (obj instanceof Date) return new Date(obj); if (obj instanceof RegExp) return new RegExp(obj);
const copy = Array.isArray(obj) ? [] : {};
Object.keys(obj).forEach(key => { copy[key] = deepCopy(obj[key]); });
return copy; }
|
查找网页中标签类型数量
这道题不要想复杂了,就是使用 querySelectorAll('*') 然后遍历出 nodeName 去重即可
1 2
| const tagSet = new Set([...document.querySelectorAll('*')].map(n => n.nodeName)) console.log(tagSet.size)
|
总结
本文介绍了一些常见的 JS 手写题,包括防抖、节流、数据类型判断、Promise 限制等。这些手写题和细节问题都是我们在日常开发中遇到的常见问题,在面试中也常常被问到。通过学习和掌握这些手写题,可以加深对 JS 基础知识的理解和应用,提高面试和实际开发的能力,帮助我们在实际工作中更加得心应手。