场景题
请求竞态问题
假如我们页面上有两个按钮,这两个按钮是调用同一个函数发送请求,但是他们的参数是不一样的,最终页面上只能展示一个请求的结果, 如果我们不对它进行限制,那么就会导致请求竞态问题,假设我们点击按钮1,在按钮1发送的请求没回来的时候,我们又点击按钮2, 那么我们很难保证按钮2的请求一定会比按钮1的请求先回来,所以是有可能导致按钮2先回来,那么按钮1的请求结果就会覆盖按钮2的请求结果, 这样就会导致页面上的结果是错误的,所以我们就需要解决这个问题
/**
* 创建一个可取消的异步任务包装器
* @param fn 需要被包装的异步函数
* @returns 返回一个对象, 包含一个 cancel 方法和一个 run 方法
*/
export function createCancelableTask(fn: any) {
// 定义一个空操作函数,用于重置 resolve 和 reject
const NOOP = () => { }
// 初始化取消函数
let cancel = NOOP
return {
// 提供取消当前任务的方法
cancel: () => cancel(),
// 执行异步任务的方法,接收与原函数相同的参数
run: (...args: any[]) => {
return new Promise((resolve, reject) => {
// 如果有正在执行的任务,先取消它
cancel()
// 重新定义取消函数,将 resolve 和 reject 重置为空操作
cancel = () => (resolve = reject = NOOP)
fn(...args)
.then(
(res: any) => resolve(res), // 当 fn 执行成功时,调用 resolve
(err: any) => reject(err), // 当 fn 执行失败时,调用 reject
)
})
}
}
}
// 测试用例
console.log("开始防竞态请求测试...");
const { run, cancel } = createCancelableTask(async (num: number) => {
console.log(`开始请求 ${num}`);
await new Promise(resolve => setTimeout(resolve, 1000));
console.log(`请求 ${num} 完成`);
return `结果 ${num}`;
});
// 连续发送三个请求
run(1).then(result => {
console.log(`请求1结果: ${result}`);
}).catch(err => {
console.log(`请求1被取消`);
});
run(2).then(result => {
console.log(`请求2结果: ${result}`);
}).catch(err => {
console.log(`请求2被取消`);
});
run(3).then(result => {
console.log(`请求3结果: ${result}`);
}).catch(err => {
console.log(`请求3被取消`);
});
console.log("注意: 只有最后一个请求(请求3)会成功完成,前两个会被自动取消");
// 开始防竞态请求测试...
// App.vue:36 开始请求 1
// App.vue:36 开始请求 2
// App.vue:36 开始请求 3
// App.vue:61 注意: 只有最后一个请求(请求3)会成功完成,前两个会被自动取消
// App.vue:38 请求 1 完成
// App.vue:38 请求 2 完成
// App.vue:38 请求 3 完成
// App.vue:56 请求3结果: 结果 3这个函数实现了一个可取消的异步任务包装器,它接收一个异步函数作为参数,并返回一个包含run 和 cancel 方法的对象。
run方法接收与原函数相同的参数,并返回一个Promise,当原函数执行成功时,调用resolve,当原函数执行失败时,调用reject,如果上一次请求没有完成,那么会取消上一次请求,并重新发起新的请求cancel方法用于取消当前正在进行的任务,它会调用原函数的取消函数,并将resolve和reject重置为空操作,置空后,无论请求成功还是失败,都不会再执行之前的resolve和reject,所以Promise的状态不会改变,也就不会触发then或者catch方法
注意,这种方法只是说我们不再需要响应结果了,但是请求是已经发出去的,函数也是已经执行的,我们并不能对它已经执行的操作进行回滚
并发控制器
/**
* 假设我们要上传一个20GB的大文件,将其分成100个10MB的分片。如果不使用任务调度器,同时发起100个上传请求会导致以下问题:
* 1.浏览器的并发请求数量被占满
* 2.其他正常业务请求可能无法及时发送
* 3.服务器压力过大
* 4.网络带宽被占满
* 那么这个问题我们应该如何解决呢?
* 我们可以尝试着将所有的任务,都放到一个队列里面去,
* 这样我们每次最多发送两个请求,如果有一个请求发送完成,那么我们再从这个队列里面取出一个发送请求,
* 我们只需要保证同时发送的请求不超过两个,这样就可以保证并发数量不被占满,也不会占用过多的带宽,下面我们来实现这个功能
*/
/**
* 任务调度器类
* 用于控制并发任务的执行数量
* 实现了任务队列和并发限制的功能
*/
export class TaskScheduler {
// 存储待执行的任务队列
tasks: any[] = []
// 当前正在执行的任务数量
runningCount = 0
// 最大并发限制数
limit: number
constructor(limit: number) {
this.limit = limit
}
/**
* 执行任务
* 检查是否可以执行新的任务,并从任务队列中取出任务执行
* 任务执行完成后会检查队列中是否还有待执行的任务,如果有则继续执行
*/
run() {
if (this.runningCount >= this.limit || this.tasks.length <= 0) {
// 如果当前运行的任务数达到上限或者任务队列为空,则不执行新任务
return
}
this.runningCount++
// 从队列头部取出一个任务
const task = this.tasks.shift()
// 执行任务,任务执行完成后递归执行 run 函数
task().finally(() => {
// 减少运行中的任务计数
this.runningCount--
// 检查是否还有任务可以执行
this.run()
})
}
/**
* 添加新任务到调度器
* @param task - 要执行的任务函数,必须返回 Promise
* @returns - 返回一个 Promise,当任务执行完成时 resolve
*/
addTask(task: any) {
return new Promise((...args: any[]) => {
// 将任务包装后添加到队列
this.tasks.push(() => task().then(...args))
// 立即执行任务
this.run()
})
}
}
//创建一个最大并发数为2的调度器实例
const scheduler = new TaskScheduler(2)
// 创建一个延迟函数
function delay(time: number) {
return new Promise(resolve => setTimeout(resolve, time))
}
// 开始计时
console.time('分片1')
console.time('分片2')
console.time('分片3')
console.time('分片4')
console.time('分片5')
// 添加5个任务到调度器
// 由于并发限制为2,这些任务会分批执行:
// 第一批:分片1和2同时执行,1秒后完成
// 第二批:分片3和4同时执行,2秒后完成
// 第三批:分片5单独执行,3秒后完成
scheduler.addTask(() => delay(1000).then(() => console.timeEnd('分片1'))) // 1秒后输出分片1
scheduler.addTask(() => delay(1000).then(() => console.timeEnd('分片2'))) // 1秒后输出分片2
scheduler.addTask(() => delay(2000).then(() => console.timeEnd('分片3'))) // 2秒后输出分片3
scheduler.addTask(() => delay(2000).then(() => console.timeEnd('分片4'))) // 2秒后输出分片4
scheduler.addTask(() => delay(3000).then(() => console.timeEnd('分片5'))) // 3秒后输出分片5
// 打印
// 分片1: 1017.328857421875 ms
// 分片2: 1017.73486328125 ms
// 分片3: 3029.158935546875 ms
// 分片4: 3029.530029296875 ms
// 分片5: 6035.072021484375 ms版本更新导致的副作用
场景: 当我们的项目部署上线后,假设用户正在某个页面填写表单,这个表单比较大,有50个字段,用户在填写的过程中,突然你们发布了新版本,这个新版本导致表单填完之后跳转的页面中js加载错误,原因是重新部署后,之前的文件没了,这个问题怎么解决?
解决方案: 保留上个版本的静态资源,当客户端请求的资源在当前版本中不存在时,通过Nginx的配置自动在历史版本目录中查找文件
Nginx的具体配置流程
- 目录结构设计 首先,设计合理的目录结构来管理多个版本:
/var/www/static/
├── current/ # 当前版本 -> v2 (软链接)
├── v1/ # 版本1目录
│ ├── js/
│ ├── css/
│ └── assets/
├── v2/ # 版本2目录
│ ├── js/
│ ├── css/
│ └── assets/
└── v3/ # 版本3目录
├── js/
├── css/
└── assets/- Nginx 配置方案(使用映射表)
# 创建版本映射
map $uri $static_version {
default "current";
# 可以根据文件特征映射到特定版本
~*main\.[a-f0-9]{8}\.js$ "v2";
~*chunk-\w+\.[a-f0-9]{8}\.js$ "v2";
}
server {
listen 80;
server_name your-domain.com;
root /var/www/static;
location / {
try_files /current/$uri /current/$uri/ /current/index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
# 使用映射的版本,如果找不到则回退查找
try_files /$static_version/$uri /current/$uri @historical_versions;
expires 1y;
add_header Cache-Control "public, immutable";
}
location @historical_versions {
# 在历史版本中查找
try_files /v3/$uri /v2/$uri /v1/$uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Static-Fallback "true";
}
}- 部署脚本示例
#!/bin/bash
# deploy.sh
VERSION="v3"
PREVIOUS_VERSION="v2"
BACKUP_COUNT=3
# 创建新版本目录
mkdir -p /var/www/static/$VERSION
# 复制新版本文件
cp -r ./dist/* /var/www/static/$VERSION/
# 更新当前版本软链接
ln -sfn /var/www/static/$VERSION /var/www/static/current
# 清理旧版本(保留最近3个版本)
cd /var/www/static
ls -d v* | sort -r | tail -n +$((BACKUP_COUNT+1)) | xargs rm -rf
# 重载Nginx
nginx -s reload
echo "Deployed version $VERSION successfully"- 最佳实践建议
- 版本化资源命名:使用文件hash作为文件名,如 main.a1b2c3d4.js
- 保留策略:根据业务需求保留2-3个历史版本
- 监控告警:监控404错误,及时发现资源缺失
- 渐进式更新:先部署静态资源,再更新HTML入口文件
- CDN配置:如果使用CDN,配置类似的回退策略
- 验证配置 测试Nginx配置:
nginx -t重载配置:
nginx -s reload这样配置后,即使新版本发布,用户正在填写的表单所需的旧版本静态资源仍然可以正常加载,确保用户体验不受影响。
localStorage 存储 token 的过期时间
在前端登录流程中,后端会返回一个token用于用户的身份验证。 如果你把这个token存到localStorage里,下次用户刷新页面时可以直接使用它去请求接口,但如果token永不过期,就会造成安全风险,比如被人盗用。 因此,需要在存到localStorage时额外保存一个过期时间戳,在取token时判断是否过期,过期则跳转到登录页。
注意我们正常也可以存到cookie里面,cookie里面是可以设置过期时间的,但是有的同学还是会放到
localStorage里,这里就介绍一下如何在localStorage单存储token的过期时间
// 存储 token(假设后端返回的 token 有效期是 2 小时)
function setToken(token: string, expireSeconds: number = 7200) {
const data = {
token,
expire: Date.now() + expireSeconds * 1000
}
localStorage.setItem('authToken', JSON.stringify(data))
}
// 获取 token
function getToken() {
const tokentStr = localStorage.getItem('authToken')
if (!tokentStr) {
return null
}
const tokenData = JSON.parse(tokentStr)
if (Date.now() > tokenData.expire) {
localStorage.removeItem('authToken') // 清理过期 token
return null
}
return tokenData.token
}
// 使用 token
const token = getToken()
if (!token) {
// token 过期或者不存在,跳转到登录页
window.location.href = '/login'
}请求缓存
createCacheRequest是一个高阶函数,用于为异步请求创建一个临时的缓存层。它可以在指定的时间内缓存相同的请求结果,从而避免重复请求,提高应用性能。
函数详解
export function defaultGenKey(options: any) {
return JSON.stringify(options)
}
// `createCacheRequest`是一个高阶函数,用于为异步请求创建一个临时的缓存层。
// 它可以在指定的时间内缓存相同的请求结果,从而避免重复请求,提高应用性能。
export function createCacheRequest<T = any>(fn: (...args: T[]) => Promise<T> | any, ms: number = 0, generatorKey = defaultGenKey) {
const map: Record<string, Promise<any>> = {}
return (...args: T[]) => {
// 生成唯一key,如果用户没有传递,则使用 JSON.stringify 进行序列化
const key = generatorKey(args)
// 如果缓存中存在则直接返回,否则执行函数并缓存结果,最后在指定时间后删除缓存
return (map[key] ??= fn(...args)).finally(() => {
setTimeout(() => delete map[key], ms)
})
}
}
// 测试用例
const mockApi = (arg: any) => {
console.log('执行API调用,参数:', arg);
return Promise.resolve(`结果: ${arg}`);
};
const cachedRequest = createCacheRequest(mockApi, 1000);
console.log('=== 第一次调用 ===');
cachedRequest('test').then(result => console.log('第一次结果:', result));
console.log('=== 第二次相同参数调用 ===');
cachedRequest('test').then(result => console.log('第二次结果:', result));
console.log('=== 第三次不同参数调用 ===');
cachedRequest('other').then(result => console.log('第三次结果:', result));
setTimeout(() => {
console.log('=== 1秒后再次调用相同参数 ===');
cachedRequest('test').then(result => console.log('第四次结果:', result));
}, 1500);
// === 第一次调用 ===
// 执行API调用,参数: test
// === 第二次相同参数调用 ===
// === 第三次不同参数调用 ===
// 执行API调用,参数: other
// 第一次结果: 结果: test
// 第二次结果: 结果: test
// 第三次结果: 结果: other
// === 1秒后再次调用相同参数 ===
// 执行API调用,参数: test
// 第四次结果: 结果: test参数说明
fn: 需要被缓存的异步函数ms: 缓存持续时间(毫秒),默认为0generatorKey: 自定义的缓存键生成函数,默认使用 JSON.stringify
工作原理
- 创建一个闭包内的
map对象用于存储缓存 - 返回一个新函数,接收原函数的所有参数
- 根据参数生成缓存键
- 如果缓存中存在结果,直接返回缓存的Promise
- 如果缓存不存在,执行原函数并缓存结果
- 在指定时间后自动清除缓存
使用场景
频繁的 API 请求
- 短时间内多次请求相同的数据
- 多个组件同时请求相同的数据
并发请求合并
- 多个相同的并发请求合并为一个(比如在不同的组件中同时调用了同一个接口查询字典)
- 避免服务器压力
实际案例
模拟获取用户信息的场景
// 原始的获取用户信息的函数
async function fetchUserInfo(userId) {
const response = await fetch(`/api/user/${userId}`)
return response.json()
}
// 创建带缓存的版本
const cachedFetchUserInfo = createCacheRequest(fetchUserInfo, 5000)
// 使用示例
async function handleUserRequest() {
// 第一次调用 - 实际发起请求
const user1 = await cachedFetchUserInfo(123)
console.log('First call', user1)
// 第二次调用 - 使用缓存
const user2 = await cachedFetchUserInfo(123)
console.log('Second call', user2)
// 5秒后缓存失效
setTimeout(() => {
// 缓存已失效,将重新发起请求
const user3 = await cachedFetchUserInfo(123)
console.log('缓存过期后', user3)
}, 6000)
}注意事项
- 缓存时间设置
- 需要根据数据的实时性要求来设置
- 建议不要设置过长的缓存时间,以免造成数据不一致
- 缓存键生成
- 默认使用 JSON.stringify 可能不适用于所有场景,需要根据实际情况来定义
- 可以根据需要自定义键生成函数
- 内存管理
- 缓存会占用内存,注意合理设置缓存时间
- 对于大量数据的缓存需要考虑内存占用
- 错误处理
- 缓存的是 Promise,需要妥善处理错误情况
- 考虑是否需要缓存错误结果
最佳实践
- 合理的缓存时间
// 示例: 设置 5 秒缓存
const cachedRequest = createCacheRequest(apiCall, 5000);- 自定义缓存键
// 示例: 使用自定义键生成函数
const customKeyGen = args => args.map(arg => arg.id).join('-')
const cachedRequest = createCacheRequest(apiCall, 5000, customKeyGen);- 错误处理
cachedRequest(params)
.then(data => {
// 处理成功情况
})
.catch(error => {
// 处理错误情况
})总结
createCacheRequest是一个强大的工具函数,它可以帮助我们优化前端应用中的请求处理。通过合理使用这个函数,我们可以:
- 减少不必要的重复请求
- 提高应用响应速度
- 降低服务器压力
- 优化用户体验
在实际使用中,需要根据具体场景合理设置缓存时间和缓存键生成策略,以达到最佳的效果。