存在一个典型的场景,同时也是常见的面试题。
问题描述
在一个页面中,左侧是文章列表,右侧是文章详情。当用户点击某篇文章时,页面会从后端获取对应文章的内容并在右侧显示。如果用户快速切换点击多个文章,比如先点击 “JavaScript”,随后快速点击 “CSS”,可能出现以下问题:
1、加载顺序错乱:由于网络请求是异步的,"JavaScript" 的请求可能比 "CSS" 的请求先完成,即便用户当前需要显示 "CSS",却看到的是 "JavaScript" 的内容。
2、体验不佳:快速切换文章时,用户期望看到与点击内容一致的结果,如果出现错乱,会导致页面逻辑混乱。
解决这个问题需要确保页面始终显示用户最后点击的内容,即“用户点到哪一篇文章,就实际加载哪一篇”。
具体方法
针对该问题提供两种可行的解决方案:
1、唯一标识符:每次请求文章时生成唯一标识,仅允许当前有效请求更新页面内容,避免旧请求覆盖最新内容。
2、请求取消法:使用 AbortController,在新请求发起时取消旧的未完成请求,从根本上避免加载顺序错乱。
代码实现
问题 🌰 代码
<template>
<div>
<ul>
<li v-for="article in articles" :key="article.id">
<button @click="handleArticleClick(article.id)">
{{ article.title }}
</button>
</li>
</ul>
<div>
<p v-if="loading">Loading...</p>
<p v-else>{{ currentArticle }}</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
// 模拟文章数据
const articles = [
{ id: 1, title: 'JavaScript', content: 'JavaScript content...' },
{ id: 2, title: 'CSS', content: 'CSS content...' },
{ id: 3, title: 'HTML', content: 'HTML content...' },
];
// 模拟延迟获取文章详情
const fetchArticleContent = async (id) => {
// 模拟网络延迟
await new Promise((resolve) => setTimeout(resolve, 2000));
return articles.find((article) => article.id === id)?.content || 'Not Found';
};
// 状态管理
const currentArticle = ref(null);
const loading = ref(false);
// 点击事件处理
const handleArticleClick = async (id) => {
loading.value = true;
const content = await fetchArticleContent(id);
currentArticle.value = content;
loading.value = false;
};
</script>
从上到下挨个点击,2秒后,按顺序依次出现,最后到需要展示的 HTML 内容。
解决方法 1:唯一标识法
原理:
通过给每个请求生成一个唯一的标识符(比如:一个递增的数字 or UUID)。每次发送请求时,都会附带这个标识符,然后在请求成功时比较当前请求的标识符,确保页面内容只会更新为最近一次请求的内容。这样,即使之前请求返回了数据,只要它的标识符不是最新的,就会被忽略,从而避免内容错乱。
适用场景:
1、请求数量少:当请求量不大时,管理请求的标识符是一种轻量且高效的方式,不需要复杂的取消操作。
2、无需主动取消请求:不需要关系请求是否已经完成,只关心最终展示的是用户点击的内容,而不必处理是否取消旧请求的问题。
改进代码:
import { ref } from 'vue';
const articles = [
{ id: 1, title: 'JavaScript', content: 'JavaScript content...' },
{ id: 2, title: 'CSS', content: 'CSS content...' },
{ id: 3, title: 'HTML', content: 'HTML content...' },
];
const fetchArticleContent = async (id) => {
await new Promise((resolve) => setTimeout(resolve, Math.random() * 2000));
return articles.find((article) => article.id === id)?.content || 'Not Found';
};
const currentArticle = ref(null);
const loading = ref(false);
const currentRequestId = ref(0);
const handleArticleClick = async (id) => {
const requestId = ++currentRequestId.value; // 生成唯一标识
loading.value = true;
const content = await fetchArticleContent(id);
console.log('~ requestId:', requestId);
console.log('~ currentRequestId:', currentRequestId.value);
if (requestId === currentRequestId.value) {
// 确保只有最新请求生效
currentArticle.value = content;
loading.value = false;
}
};
分析一下为什么可以实现?很简单,打印看一下。
requestId 的值根据 currentRequestId 变化而改变,但是 currentRequestId 是响应式数据,当点击修改它的值时,Vue 会把这个更改标记为需要更新的状态,实际上它的更新是异步的,只有在下一个 tick(也就是事件循环的下一个阶段)时才会反映出来。因此 requestId 的值会比 currentRequestId.value 小,当二者一样,即为最后一次请求。
优点:实现简单,不需要使用额外的复杂逻辑。
缺点:不适合大量请求场景,因为每个请求都需要生成唯一标识符并通过比较来控制,随着请求数量增多,可能会带来一定的性能负担。
解决方法 2:请求取消法
原理:
当发起一个新请求时,主动取消掉当前未完成的请求,确保只有最新的请求会返回并更新页面内容。通过浏览器的 AbortController 来实现,AbortController 提供了一个取消信号,可以让开发者手动取消请求。
适用场景:
1、请求频繁触发:比如在搜索框中,用户每次输入一个字符都会触发一个请求。请求频繁的情景中,取消旧的请求能减少无意义的计算和展示,提示性能。
2、请求取消:如果应用场景中,早起的请求不再有意义(比如用户已更换搜索条件或文章),就需要取消掉这些不必要的请求,避免它们返回后干扰当前的展示。
如何工作:
每次发起新请求时,都会使用 AbortController 来创建一个新的取消信号。发起请求时,将取消信号传入请求中。如果在请求过程中,新的请求被触发,则会调用 abort() 方法取消前一个请求。这样确保页面始终只显示用户当前需要的内容。
改进代码:
import { ref } from 'vue';
const articles = [
{ id: 1, title: 'JavaScript', content: 'JavaScript content...' },
{ id: 2, title: 'CSS', content: 'CSS content...' },
{ id: 3, title: 'HTML', content: 'HTML content...' },
];
const fetchArticleContent = async (id, controller) => {
try {
await new Promise((resolve) => setTimeout(resolve, Math.random() * 2000));
if (controller.signal.aborted) {
throw new Error('Request Aborted');
}
return articles.find((article) => article.id === id)?.content || 'Not Found';
} catch (error) {
if (error.name === 'AbortError') {
console.warn('Request canceled');
}
throw error;
}
};
const currentArticle = ref(null);
const loading = ref(false);
let abortController = null;
const handleArticleClick = async (id) => {
if (abortController) {
abortController.abort(); // 取消之前的请求
}
abortController = new AbortController();
const controller = abortController;
loading.value = true;
try {
const content = await fetchArticleContent(id, controller);
currentArticle.value = content;
} catch (error) {
console.error(error.message); // 处理取消的请求
} finally {
loading.value = false;
}
};
依次点击展示为:
优点:能有效避免不必要的请求,尤其是在请求频繁的场景中,避免了“加载顺序错乱”问题。
缺点:比唯一标识法实现更复杂,需要使用 AbortController 或类似的取消机制。可能会引入更多的异步错误处理逻辑。
总结
1、唯一标识符
适用于请求量较少、无需主动取消请求的场景。通过给每个请求分配一个唯一标识符,保证只有最后一个请求会更新页面内容。
2、请求取消法
适用于请求量较大、请求频繁的场景。通过取消旧的未完成请求,确保只有用户当前的请求会得到响应并更新页面内容。
选择哪种方法取决于实际应用场景。