1. CSS 题目

1.1 画等腰直角三角形

  • 这题是美团前端面试一面题: 参考链接

  • 第一种方法: 使用boarder

  • 第二种方法: 使用linear-gradient
  • 第三种方法: 使用clip-path

1.2 不定宽高水平垂直居中

  • 第一种方法: 使用Flexbox (也可以父元素是flex然后子元素margin:auto)
  • 第二种方法: 使用Grid
  • 第三种方法: 绝对定位 + transform

1.3 正方形子元素

  • 这题是字节跳动一面题: 父元素的宽高不固定,并且可以随窗口变形,而子元素必须保持正方形 (只能使用CSS)
    • 解决方案: 子元素使用百分比宽度并使用aspect-ratio属性来保持子元素的正方形比例

1.4 position 定位

  • position: static(默认定位): 所有元素的默认定位方式。元素按照正常的文档流排列,不会受toprightbottomleft等属性的影响

  • position: relative(相对定位): 相对于元素自身的原始位置进行定位,但可以通过toprightbottomleft等属性调整元素的相对位置

  • position: absolute(绝对定位): 相对于最近的非static定位的父元素进行定位。如果没有非static的父元素,则相对于文档的根元素(通常是<html><body>)进行定位
  • position: fixed(固定定位): 相对于浏览器窗口进行定位,无论页面如何滚动,元素始终保持在浏览器窗口中的固定位置

  • position: sticky(粘性定位): 结合了relativefixed的特性。元素一开始是相对于文档流定位的(relative),当页面滚动到某个阈值时,元素会变为固定定位(fixed

    • 父元素不能overflow:hidden或者overflow:auto属性
    • 必须指定topbottomleftright四个值之一,否则只会处于相对定位

1.5 盒模型

盒模型分为IE盒模型和W3C标准盒模型

  • 在标准盒模型下,一个块的总宽度 = width + margin(左右) + padding(左右) + border(左右)
  • IE盒模型下,一个块的总宽度 = width + margin(左右)(即width已经包含了paddingborder值)

当设置box-sizing:content-box时,采用标准盒模型计算,也是默认模式
当设置box-sizing:border-box时,采用IE盒模型计算

  • JS如何获取盒模型对应的宽和高
    • window.getComputedStyle(dom).width/height: 取到的是最终渲染后的宽和高
    • dom.getBoundingClientRect().width/height: 得到渲染后的宽和高,还可以取到相对于视窗的上下左右的距离
    • dom.offsetWidth/offsetHeight: 包括高度(宽度)、内边距和边框,不包括外边距 (兼容性最好)

1.6 CSS 实现扇形


1.7 BFC

  • BFCCSS布局的一个概念,是一块独立的渲染区域,是一个环境,里面的元素不会影响到外部的元素

2. JS 题目

2.1 var 与循环

  • 字节跳动一面的题目
  • 下面循环的最终结果是什么,i的最终值是什么,i在循环结束后的值是什么
for (var i = 0; i < 5; i++) {
console.log(i);
}
  • 这个循环将会输出0 1 2 3 4i的最终值为5

  • 由于var声明的变量在整个函数(或全局范围内)都是共享的,它不受块作用域的限制。因此,i在整个函数或脚本范围内是可见的。换句话说,i仍然在循环结束后存在并且可以访问

for (var i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 1000);
}
  • 这个循环将会输出5 5 5 5 5i的最终值为5
  • 由于var是函数级作用域,所有的setTimeout回调函数在执行时访问的都是同一个i,即循环结束时的i5。因为setTimeout是异步的,它会在1秒后执行,此时循环早已结束,i已经被更新为5
  • 解决方案: 使用let或闭包可以解决这个问题,确保每次迭代时i的值是独立的

2.2 手写倒计时

  • 美团一面的题目

  • 手写一个倒计时函数,要求输出5 4 3 2 1,每隔一分钟输出一个值

  • 方法一: 使用setInterval

    • setInterval会在指定的时间间隔内反复执行一个函数,直到手动清除这个定时器
function countdown() {
let count = 5;
const intervalId = setInterval(() => {
console.log(count);
count--;
if (count === 0) {
clearInterval(intervalId);
}
}, 60000);
}

countdown();
  • 方法二: 使用Generator函数
    • 使用Generator函数可以通过yield暂停和恢复函数的执行,结合setTimeout来实现倒计时
function* countdownGenerator() {
let count = 5;
while (count > 0) {
yield count;
count--;
}
}

function countdown() {
const generator = countdownGenerator();

function nextCountdown() {
const { value, done } = generator.next();
if (!done) {
console.log(value);
setTimeout(nextCountdown, 60000);
}
}

nextCountdown();
}

countdown();

2.3 防抖和节流

  • 可以使用类似loadash这种库来实现

  • 防抖 Debounce

    • 防抖是指在事件被触发n秒后在执行回调,如果在这n秒内时间又被触发,则重新计时
    • 可以使用在一些点击请求的事件上,避免因为用户的多次点击向后端发送多次请求
function debounce(fn, delay = 500) {
// 通过闭包缓存一个定时器 id
let timer = null

return function(...args) {
// 如果已经设定过定时器就清空上一次的定时器
if (timer) clearTimeout(timer)

// 开始设定一个新的定时器,定时器结束后执行传入的函数 fn
// 这里必须是箭头函数,不然 this 指向 window,要让 this 就指向 fn 的调用者
timer = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}

// 执行 debounce 函数返回新函数
const betterFn = debounce(() => console.log('fn 防抖执行了'), 1000)
// 停止滑动 1 秒后执行函数 () => console.log('fn 防抖执行了')
document.addEventListener('scroll', betterFn)

防抖函数分为非立即执行版和立即执行版,需要根据不同的场景来决定需要使用哪一个版本的防抖函数

  • 节流 Throttle
    • 节流就是一定时间内执行一次事件,即使重复触发,也只有一次生效
    • 可以使用在监听滚动scroll事件上,通过事件节流来降低事件调用的频率
const throttle = (fn, delay = 500) => {
// 上一次执行 fn 的时间
let prev = Date.now();

return function(...args) {
// 获取当前时间,转换成时间戳,单位毫秒
let now = Date.now();
// 如果差值大于等于设置的等待时间就执行函数
if (now - prev >= delay) {
fn.apply(this, args);
prev = Date.now();
}
}
}

// 执行 throttle 函数返回新函数
const betterFn = throttle(() => console.log('fn 函数执行了'), 1000)
// 每 10 毫秒执行一次 betterFn 函数,但是只有时间差大于 1s 时才会执行 fn
setInterval(betterFn, 10)
const throttle = (fn, delay = 500) => {
let timer = null;

return function(...args) {
if (timer) return;

timer = setTimeout(() => {
fn.apply(this, args);
// 执行完后,需重置定时器,不然 timer 一直有值,无法开启下一个定时器
timer = null;
}, delay)
}
}

对于节流,一般有两种方式可以实现,分别是时间戳版和定时器版。时间戳版的函数触发是在时间段内开始的时候,而定时器版的函数触发是在时间段内结束的时候


2.4 undefined 与 null 的区别

  • undefined表示”缺少值”,就是此处应该有一个值,但是还没有定义
  • null表示”没有对象”,即该处不应该有值

2.5 浅拷贝和深拷贝

  • 浅拷贝
    • 浅拷贝是创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝
    • 如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址,所以如果其中一个对象改变了这个地址,就会影响到另一个对象
function shallowCopy(obj) {
// 只拷贝对象,基本类型或 null 直接返回
if (typeof obj !== 'object' || object === null)
return obj;

// 判断是新建一个数组还是对象
let newObj = Array.isArray(obj) ? [] : {};

for (let key in obj) {
// hasOwnProperty 判断是否是对象自身属性,会忽略从原型链上继承的属性
if (obj.hasOwnProperty(key))
newObj[key] = obj[key]; // 只拷贝对象本身的属性
}

return newObj;
}

//测试
var obj ={
name:'张三',
age:8,
pal:['王五','王六','王七']
}
let obj2 = shallowCopy(obj);
obj2.name = '李四'
obj2.pal[0] = '王麻子'
console.log(obj); //{age: 8, name: "张三", pal: ['王麻子', '王六', '王七']}
console.log(obj2); //{age: 8, name: "李四", pal: ['王麻子', '王六', '王七']}
  • 深拷贝
    • 深拷贝是将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象
function deepCopy(obj, map = new WeakMap()) {
// 基本类型或 null 直接返回
if (typeof obj !== 'object' || obj === null)
return obj;

// 判断是新建一个数组还是对象
let newObj = Array.isArray(obj) ? [] : {};

// 利用 map 解决循环引用
if (map.has(obj))
return map.get(obj);

map.set(obj, newObj); // 将当前对象作为 key,克隆对象作为 value

for (let key of obj) {
if (obj.hasOwnProperty(key))
newObj[key] = deepCopy(object[key], map); // 递归
}

return newObj;
}

// 测试
let obj1 = {
name : 'AK、哒哒哒',
arr : [1,[2,3],4],
};
let obj2=deepCopy(obj1)
obj2.name = "哒哒哒";
obj2.arr[1] = [5,6,7] ; // 新对象跟原对象不共享内存

console.log('obj1',obj1) // obj1 { name: 'AK、哒哒哒', arr: [ 1, [ 2, 3 ], 4 ] }
console.log('obj2',obj2) // obj2 { name: '哒哒哒', arr: [ 1, [ 5, 6, 7 ], 4 ] }

2.6 函数柯里化

  • 在数学和计算机科学中,柯里化是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术

柯里化实际是把简单的问题复杂化,但是复杂化的同时,在使用函数时拥有了更加多的自由度。而对于函数参数的自由处理,正是柯里化的核心所在。柯里化本质上是降低通用性,提高适用性

  • 参数定长的柯里化
    • 假设存在一个原函数fnfn接受三个参数a, b, c,那么函数fn最多被柯里化三次
function curry(fn) {
// 获取原函数的参数长度
const argLen = fn.length;
// 保存预置参数
const presetArgs = [].slice.call(arguments, 1)
// 返回一个新函数
return function() {
// 新函数调用时会继续传参
const restArgs = [].slice.call(arguments)
const allArgs = [...presetArgs, ...restArgs]
if (allArgs.length >= argLen) {
// 如果参数够了,就执行原函数
return fn.apply(this, allArgs)
} else {
// 否则继续柯里化
return curry.call(null, fn, ...allArgs)
}
}
}

function sum(a, b, c) {
return a + b + c;
}
var curried = curry(sum);
console.log(curried(1, 2, 3)); //6
console.log(curried(1, 2)(3)); //6
console.log(curried(1)(2, 3)); //6
console.log(curried(1)(2)(3)); //6
  • 参数不定长的柯里化
    • 如果要支持参数不定长的场景,已经柯里化的函数在执行完毕时不能返回一个值,只能返回一个函数。同时要让JS引擎在解析得到的这个结果时,能求出预期的值
function curry(fn) {
// 保存预置参数
const presetArgs = [].slice.call(arguments, 1)
// 返回一个新函数
function curried () {
// 新函数调用时会继续传参
const restArgs = [].slice.call(arguments)
const allArgs = [...presetArgs, ...restArgs]
return curry.call(null, fn, ...allArgs)
}
// 重写toString
curried.toString = function() {
return fn.apply(null, presetArgs)
}
return curried;
}

function dynamicAdd() {
return [...arguments].reduce((prev, curr) => {
return prev + curr
}, 0)
}
var add = curry(dynamicAdd);
add(1)(2)(3)(4) // 10
add(1, 2)(3, 4)(5, 6) // 21

2.7 数组扁平化

实现扁平化的方法,封装flatten
已有多级嵌套数组[1, [2, [3, [4, 5]]], 6] 将其扁平化处理,输出[1,2,3,4,5,6]

  • ES6 flat
    • flat(depth)方法会按照一个可指定的深度递归遍历数组,并将所有元素与遍历到的子数组中的元素合并为一个新数组返回。使用Infinity,可展开任意深度的嵌套数组
const arr = [1, [2, [3, [4, 5]]], 6]

function flatten(params) {
return params.flat(Infinity)
}
console.log(flatten(arr));

// 输出: [1,2,3,4,5,6]

⭐ 直接使用自带的方法可以很快的实现, 但是面试官当然不希望就看到这些呀

  • 循环递归
    • 循环判断数组的每一项是否是数组Array.isArray(arr[i])
    • 是数组就递归调用扁平化代码result = result.concat(flatten(arr[i]));
    • 不是数组,直接通过push添加到返回值数组
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
}

console.log(flatten(arr));

⭐⭐⭐ 使用递归写出数组扁平化, 但是缺少控制层级关系

  • 增加参数控制扁平化深度
    • 可以理解为手写flat()方法
// forEach 遍历数组会自动跳过空元素
const eachFlat = (arr = [], depth = 1) => {
const result = []; // 缓存递归结果
// 开始递归
(function flat(arr, depth) {
// forEach 会自动去除数组空位
arr.forEach((item) => {
// 控制递归深度
if (Array.isArray(item) && depth > 0) {
// 递归数组
flat(item, depth - 1)
} else {
// 缓存元素
result.push(item)
}
})
})(arr, depth)
// 返回递归结果
return result;
}

⭐⭐⭐⭐ 使用递归写出数组扁平化, 可以通过参数控制层级关系

  • while循环+some方法
    • 通过some来判断数组中是否用数组, 通过while不断循环执行判断, 如果是数组的话可以使用拓展运算符..., ...每次只能展开最外层的数组, 加上contact来减少嵌套层数
function flatten(arr) {
while (arr.some(item=> Array.isArray(item))) {
arr = [].concat(...arr)
}
return arr
}
console.log(flatten(arr));

⭐⭐⭐⭐ 使用while循环取消递归操作, 巧用some操作进行判断


2.8 typeof 判断

  • typeof null“object”: 历史遗留问题
  • typeof NaN“number”: NaN 实际存储是一种特殊的数值类型
  • typeof Function.prototypefunction
类型结果
Undefined"undefined"
Null"object"
Boolean"boolean"
Number"number"
BigInt"bigint"
String"string"
Symbol"symbol"
Function (class)"function"
其他任何对象"object"

2.9 事件委托优化

  • 字节一面题

举个例子,比如我们需要去做Event Tracking System,需要去记录用户在网站内做了什么,点击了什么按钮。如果有很多个按钮,每个都绑一个点击事件性能很差,如何优化?

  • 绑定事件到父元素
    • 而不是为每个子节点单独绑定事件处理程序,事件委托可以将事件处理程序绑定到公共的父元素上,然后通过事件的target来确定实际触发事件的子节点。
  • 检查目标元素
    • 使用事件对象的event.target属性来判断哪个子节点触发了事件,并根据需要处理相应的逻辑
<ul id="parent">
<li class="child">子节点 1</li>
<li class="child">子节点 2</li>
<li class="child">子节点 3</li>
<li class="child">子节点 4</li>
</ul>

// 使用事件委托,将点击事件绑定到父元素
const parent = document.getElementById('parent');

// 事件处理器绑定在父元素上
parent.addEventListener('click', function (event) {
// 通过 event.target 来判断点击的是否是目标子节点
if (event.target && event.target.classList.contains('child')) {
console.log('点击了子节点:', event.target.innerText);
}
});

事件委托特别适用于存在大量类似元素的场景,如列表、表格中的行、动态生成的元素等


3. 网络安全

3.1 跨站脚本攻击 XSS

  • XSS(跨站脚本攻击,Cross-Site Scripting)是一种安全漏洞,攻击者通过向网页注入恶意脚本,使得当用户访问受感染的页面时,恶意脚本会在用户的浏览器中执行
    • 防止 XSS攻击的关键是严格处理用户输入和输出
    • 对用户输入进行严格验证和过滤使用安全的框架和库

3.2 跨站请求伪造 CSRF

  • 跨站点请求伪造 (CSRF) 是一种前端安全攻击,通过伪造的形式来执行你原本不希望执行的操作
    • 防止CSRF攻击需要确保请求是合法的,并且是用户有意发起的
    • 防止CSRF攻击的最简单方法之一是使用从服务器生成的CSRF令牌。如果客户端无法提供准确的令牌,服务器可以拒绝请求的操作

4. 网络协议

4.1 网络七层模型与四层模型区别

  • 参考: 链接
  • 网络七层模型 OSIOpen Systems Interconnection Model)是一个标准,而非实现

  • OSI模型是从上往下的,越底层越接近硬件,越往上越接近软件,这七层模型分别是物理层、数据链路层、网络层、传输层、会话层、表示层、应用层
> 物理层:底层数据传输,如网线;网卡标准。
> 数据链路层:定义数据的基本格式,如何传输,如何标识;如网卡MAC地址。
> 网络层:定义IP编址,定义路由功能;如不同设备的数据转发。
> 传输层:端到端传输数据的基本功能;如 TCP、UDP。
> 会话层:控制应用程序之间会话能力;如不同软件数据分发给不同软件。
> 标识层:数据格式标识,基本压缩加密功能。
> 应用层:各种应用软件,包括 Web 应用。
  • 网络四层模型是一个实现的应用模型,由七层模型简化合并而来

  • TCP/IP模型将OSI模型由七层简化为四层,传输层和网络层被完整保留,因此网络中最核心的技术就是传输层和网络层技术

4.2 http 和 https 基本概念

  • HTTP: 是互联网上应用最为广泛的一种网络协议,是一个客户端和服务器端请求和应答的标准TCP,用于从WWW服务器传输超文本到本地浏览器的传输协议,它可以使浏览器更加高效,使网络传输减少
  • HTTPS: 是以安全为目标的HTTP通道,简单讲是HTTP的安全版,即HTTP下加入SSL层,HTTPS的安全基础是SSL,因此加密的详细内容就需要SSL
  • HTTPS协议的主要作用可以分为两种:一种是建立一个信息安全通道,来保证数据传输的安全。另一种就是确认网站的真实性

4.3 http 和 https 区别

HTTP协议传输的数据都是未加密的,也就是明文的,因此使用HTTP协议传输隐私信息非常不安全,为了保证 这些隐私数据能加密传输,于是网景公司设计了SSL(Secure Sockets Layer)协议用于对HTTP协议传输的数据进行加密,从而就诞生了HTTPS。简单来说,HTTPS协议是由SSL+HTTP协议构建的可进行加密传输、身份认证的网络协议,要比HTTP协议安全

  • https协议需要到ca申请证书,一般免费证书较少,因而需要一定费用
  • http是超文本传输协议,信息是明文传输,https则是具有安全性的ssl加密传输协议
  • httphttps使用的是完全不同的连接方式,用的端口也不一样,前者是80,后者是443
  • http的连接很简单,是无状态的。https协议是由SSL+HTTP协议构建的可进行加密传输、身份认证的网络协议,比http协议安全

注意: HTTPS = HTTP + SSL/TLS,如今SSL已废弃,所以现在只关注HTTP + TLS


4.4 http1.x 和 http2.x 区别

http1.xhttp2.x主要有以下4个区别

  • HTTP2使用的是二进制传送,HTTP1.X是文本(字符串)传送

    • 二进制传送的单位是帧和流。帧组成了流,同时流还有流ID标示
    • 优势: 传输速度更快 (二进制数据体积较小), 处理更高效 (不需要进行文本解析), 适用于复杂数据类型 (图像、音频、视频), 安全性更高 (二进制数据不易被直接阅读)
  • HTTP2支持多路复用

    • 因为有流ID,所以通过同一个http请求实现多个http请求传输变成了可能,可以通过流ID来标示究竟是哪个流从而定位到是哪个http请求
  • HTTP2头部压缩

    • HTTP2通过gzipcompress压缩头部然后再发送,同时客户端和服务器端同时维护一张头信息表,所有字段都记录在这张表中,这样后面每次传输只需要传输表里面的索引ID就行,通过索引ID查询表头的值
  • HTTP2支持服务器推送

    • HTTP2支持在未经客户端许可的情况下,主动向客户端推送内容

4.5 http 请求方式

http请求方式有以下8种,其中GETPOST是最常用的

  • GET: 向特定的资源发出请求。GET方法不应当被用于产生“副作用”的操作中
  • POST: 向指定资源提交数据进行处理请求, 例如提交表单或者上传文件。数据被包含在请求体中。POST请求可能会导致新的资源的建立和/或已有资源的修改
  • PUT: 向指定资源位置上传其最新内容
  • DELETE: 请求服务器删除Request-URL所标识的资源
  • HEAD: 向服务器索与GET请求相一致的响应,只不过响应体将不会被返回。这一方法可以再不必传输整个响应内容的情况下,就可以获取包含在响应小消息头中的元信息
  • OPTIONS: 返回服务器针对特定资源所支持的HTTP请求方法,也可以利用向web服务器发送‘*’的请求来测试服务器的功能性
  • TRACE: 回显服务器收到的请求,主要用于测试或诊断
  • CONNECT: HTTP/1.1协议中预留给能够将连接改为管道方式的代理服务器

4.6 TCP 三次握手


5. React 题目


6. 缓存

6.1 强缓存与协商缓存

  • 参考: 链接

  • 为了减少资源请求次数,加快资源访问速度,浏览器会对资源文件如图片、css文件、js文件等进行缓存,而浏览器缓存策略又分为强缓存和协商缓存

  • 强缓存 Strong Cache

    • 所谓强缓存,可以理解为强制缓存的意思,即浏览器在访问某个资源时会判断是否使用本地缓存里已经存在的资源文件,使用本地缓存的话则不会发送请求到服务器,从而达到减轻服务器访问压力的作用,且由于直接从本地缓存读取资源文件,大大提高了加载速度
    • 浏览器第一次请求远程服务器的某个资源时,如果服务器希望浏览器得到该资源后一段时间内不要再发送请求过来,直接从浏览器里的缓存里取,则服务器可以通过在响应头里设置Cache-Control: max-age=31536000max-age代表缓存时间,单位为秒,这里的数据换算过来就是一年,意味着在一年内浏览器不会再向服务器发送请求。

  • 使用缓存的话,状态码200后面会标明情况。浏览器缓存资源的地方有两个: 磁盘缓存(disk cache)和内存缓存(memory cache

  • 当缓存时间到期后再次访问时,状态码200后面便没有括号内的内容了

一般来说,浏览器会将较大的资源缓存到disk cache,而较小的资源则被缓存到memory cache里。内存缓存与磁盘缓存相比,访问速度要更快一些

  • 强缓存除了使用Cache-Control实现之外,还可以使用Expires字段,ExpiresHttp1.0规范,Cache-ControlHttp1.1规范,Expires返回一个具体的时间值,代表缓存的有效期,在该日期内浏览器不会向服务器发起请求,而是直接从缓存里获取资源

  • 因为Expires参照的是本地客户端的时间,而客户端的时间是可以被修改的,所以会有误差产生的情况,这也是Expires的一个缺点,所以有了后来Http1.1规范的Cache-control

Cache-control的优先级要高于Expires,如果两者同时设置,会优先使用Cache-control而忽略掉Expires

  • 协商缓存 Negotiation Cache
    • 在强缓存里,是否使用缓存是由浏览器来确定的,而协商缓存则是由服务器来告诉浏览器是否使用缓存资源,也就是浏览器每一次都要发送请求到服务器询问是否使用缓存
    • 浏览器初次请求资源,服务器返回资源,同时生成一个Etag值携带在响应头里返回给浏览器,当浏览器再次请求资源时会在请求头里携带If-None-Match,值是之前服务器返回的Etag的值,服务器收到之后拿该值与资源文件最新的Etag值做对比

如果没有变化则返回304,告诉浏览器继续使用缓存(不返回资源文件)
如果发生变化,则返回200和最新的资源文件给浏览器使用

  • 除了Etag外,还有一个Last-Modified的属性,它是Http1.0规范的,服务器返回Last-Modified,浏览器请求头对应携带的是If-Modified-since,与Etag不同的是,Last-Modified的值是一个时间值,代表文件的修改时间,服务器通过对比文件的修改时间是否发生改变来判断是否使用缓存

  • 相比Last-ModifiedEtag优先级更高,使用上也更精确一些,因为有时候会存在文件内容并没有改变,但文件的修改时间变更了,Last-Modified不一致所以服务器会重新返回资源文件,实际上还是可以继续使用缓存的

强缓存优先级大于协商缓存,即两者同时存在时,如果强缓存开启且在有效期内,则不会走协商缓存


7. 算法

7.1 LRU Cache

  • 字节一面算法题
  • 使用JS实现一个魔改版的LRU Cache,并且满足以下要求

维护一个容量为n的缓存
每个缓存项如果在X秒后没有被使用,则自动删除

  • 可以使用Map来维护缓存的顺序和容量,利用setTimeout来实现自动删除的功能
class LRUCache {
constructor(capacity, expireTime) {
this.capacity = capacity; // 缓存容量
this.expireTime = expireTime; // 缓存项的有效时间(秒)
this.cache = new Map(); // 用 Map 来存储缓存数据
this.timers = new Map(); // 用来存储每个缓存项的定时器
}

// 获取缓存
get(key) {
if (!this.cache.has(key)) return -1; // 如果缓存中没有这个key,返回-1

// 如果缓存命中,需要刷新缓存的顺序(将这个key移到最新的位置)
const value = this.cache.get(key);
this.cache.delete(key); // 先删除老的位置
this.cache.set(key, value); // 重新插入,保证最新访问的在末尾

// 重置定时器,延长过期时间
clearTimeout(this.timers.get(key)); // 清除老的定时器
this.timers.set(key, this.setExpiration(key)); // 重新设置定时器

return value;
}

// 添加缓存
put(key, value) {
if (this.cache.has(key)) {
// 如果已经存在,删除旧的缓存
this.cache.delete(key);
clearTimeout(this.timers.get(key));
}

// 如果缓存已满,删除最老的缓存
if (this.cache.size >= this.capacity) {
const oldestKey = this.cache.keys().next().value; // 获取 Map 中第一个(最旧)的key
this.cache.delete(oldestKey); // 删除最旧的缓存项
clearTimeout(this.timers.get(oldestKey)); // 清除相应的定时器
this.timers.delete(oldestKey); // 删除定时器记录
}

// 插入新的缓存项
this.cache.set(key, value);
this.timers.set(key, this.setExpiration(key)); // 设置定时器
}

// 设置缓存项的自动删除定时器
setExpiration(key) {
return setTimeout(() => {
this.cache.delete(key); // 删除缓存项
this.timers.delete(key); // 删除定时器记录
}, this.expireTime * 1000); // 转换为毫秒
}
}

const lru = new LRUCache(3, 5); // 创建容量为3,缓存项5秒后失效的LRU Cache
lru.put(1, 'A');
lru.put(2, 'B');
console.log(lru.get(1)); // 输出 'A'
setTimeout(() => {
console.log(lru.get(2)); // 在5秒内访问,输出 'B'
}, 4000);

setTimeout(() => {
console.log(lru.get(1)); // 超过5秒未访问,输出 -1(已过期)
}, 6000);

7.2 格式化数字

  • 字节一面算法题

  • 给一个数字比如1000000,把它转化成1,000,000。或者是1000000.12,把它转化成1,000,000.12。只能使用JS实现

  • 解决方案一: 使用toLocaleString()

function formatNumberWithCommas(number) {
// 分离整数和小数部分,默认小数部分为空字符串
let [integer, decimal = ''] = (number + '').split('.');

// 通过 toLocaleString 格式化整数部分,自动加上千分位逗号
integer = (+integer).toLocaleString();

// 如果没有小数部分,直接返回格式化后的整数部分
if (decimal === '') return integer;

// 小数部分无需反转或使用 toLocaleString,直接返回原来的小数部分即可
return integer + '.' + decimal;
}

console.log(formatNumberWithCommas(1000000)); // 输出 "1,000,000"
console.log(formatNumberWithCommas(1000000.12)); // 输出 "1,000,000.12"
  • 解决方案二: 正则表达式
function formateNumberWithCommas(number) {
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

console.log(formateNumberWithCommas(1000000)); // 输出 "1,000,000"
console.log(formateNumberWithCommas(1000000.12)); // 输出 "1,000,000.12"
  • 解决方案三: 手动实现格式化
function formatNumberWithCommas(number) {
// 类型检测
if (typeof number !== 'number') {
return '';
}
// 先转成字符串
number = number.toString();
// 支持小数,按小数点分成两部分 使用了es6解构
let [integer, decimal] = number.split('.');

// 封装了 doSplit 方法 第二个参数 isInteger 来表示是整数部分还是小数部分
const doSplit = (num, isInteger = true) => {
// 如果为空,直接返回
if (!num) return '';
// 如果是整数部分 先按位切割再反转
// 整数部分数字从右往左数,每3位插入一个逗号
// 小数部分从左往右数
// 两次反转,它的逗号顺序是一样的。
if (isInteger) num = num.split('').reverse();
let str = [];
for (let i = 0; i < num.length; i++) {
if (i !== 0 && i % 3 === 0) str.push(',');
str.push(num[i]);
}
if (isInteger) return str.reverse().join('');
return str.join('');
};

// 处理整数部分
integer = doSplit(integer);

// 处理小数部分,确保 undefined 小数不会导致问题
decimal = decimal ? doSplit(decimal, false) : '';

return integer + (decimal === '' ? '' : '.' + decimal);
}

console.log(formatNumberWithCommas(1000000)); // 输出 "1,000,000"
console.log(formatNumberWithCommas(1000000.12)); // 输出 "1,000,000.12"

7.3 手写数组转树

  • 做到类似下面的转换
let input = [
{
id: 1, val: '学校', parentId: null
}, {
id: 2, val: '班级1', parentId: 1
}, {
id: 3, val: '班级2', parentId: 1
}, {
id: 4, val: '学生1', parentId: 2
}, {
id: 5, val: '学生2', parentId: 2
}, {
id: 6, val: '学生3', parentId: 3
},
]

let output = {
id: 1,
val: '学校',
children: [{
id: 2,
val: '班级1',
children: [
{
id: 4,
val: '学生1',
children: []
},
{
id: 5,
val: '学生2',
children: []
}
]
}, {
id: 3,
val: '班级2',
children: [{
id: 6,
val: '学生3',
children: []
}]
}]
}
function arrayToTree(array) {
let root = array[0];
array.shift();
let tree = {
id: root.id,
val: root.val,
children: array.length > 0 ? toTree(root.id, array) : []
}
return tree;
}

function toTree(parentId, array) {
let children = [];
for (let i = 0; i < array.length; i++) {
let node = array[i];
if (node.parentId === parentId) {
children.push({
id: node.id,
val: node.val,
children: toTree(node.id, array)
});
}
}
return children;
}

console.log(arrayToTree(input));

7.4 数组去重

  • [1,1,2,2,3,3,4,4,5,5]去重, 结果应该是[1,2,3,4,5]

  • 方法一: ES6Set去重

const arr = [1,1,2,2,3,3,4,4,5,5];
const setData = Array.from(new Set(arr));
console.log(setData);

Set去重有一个弊端,无法去重引用类型的数据。比如对象数组[{a:1}, {a:1}]

  • 方法二: 双重for循环去重
const handleRemoveRepeat = (arr) => {
for (let i = 0, len = arr.length; i < len; i++) {
for (let j = i + 1; j < len; j++) {
if (arr[i] === arr[j]) {
arr.splice(j, 1);
j--;
len--;
}
}
}
return arr;
}

使用len = arr.length的原因: 假设这个循环需要循环10000次,length就会被执行10000


附录

文件还未上传 Github