Appearance
SWR乐观更新
概述
乐观更新(Optimistic Update)是一种提升用户体验的重要技术,它在服务器响应之前就更新UI,让用户感觉操作立即生效。如果请求失败,则回滚到之前的状态。本文将深入探讨在SWR中实现乐观更新的各种模式和最佳实践。
基础乐观更新
简单乐观更新
jsx
import useSWR from 'swr';
function SimpleOptimisticUpdate({ userId }) {
const { data: user, mutate } = useSWR(`/api/users/${userId}`, fetcher);
const updateName = async (newName) => {
// 保存当前数据用于回滚
const previousUser = user;
// 立即更新UI
mutate({ ...user, name: newName }, false);
try {
// 发送请求到服务器
const response = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName }),
});
if (!response.ok) throw new Error('Update failed');
const updatedUser = await response.json();
// 使用服务器返回的数据更新
mutate(updatedUser);
} catch (error) {
// 回滚到之前的数据
mutate(previousUser, false);
console.error('Failed to update:', error);
alert('Update failed, please try again');
}
};
return (
<div>
<h1>{user?.name}</h1>
<button onClick={() => updateName('New Name')}>
Update Name
</button>
</div>
);
}使用mutate选项
jsx
function OptimisticWithOptions({ userId }) {
const { data: user, mutate } = useSWR(`/api/users/${userId}`, fetcher);
const updateUser = async (updates) => {
await mutate(
// 更新函数
async () => {
const response = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) throw new Error('Update failed');
return response.json();
},
// 选项
{
optimisticData: { ...user, ...updates },
rollbackOnError: true,
populateCache: true,
revalidate: false,
}
);
};
return (
<div>
<h1>{user?.name}</h1>
<p>{user?.email}</p>
<button onClick={() => updateUser({ name: 'New Name' })}>
Update
</button>
</div>
);
}列表操作
添加项目
jsx
function TodoList() {
const { data: todos, mutate } = useSWR('/api/todos', fetcher);
const addTodo = async (text) => {
// 生成临时ID
const tempId = `temp-${Date.now()}`;
const newTodo = {
id: tempId,
text,
completed: false,
_pending: true, // 标记为待处理
};
// 乐观添加
mutate([...todos, newTodo], false);
try {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!response.ok) throw new Error('Failed to add todo');
const createdTodo = await response.json();
// 替换临时todo为真实todo
mutate(
todos.map(t => t.id === tempId ? createdTodo : t).concat(
todos.find(t => t.id === tempId) ? [] : [createdTodo]
),
false
);
} catch (error) {
// 移除临时todo
mutate(todos.filter(t => t.id !== tempId), false);
toast.error('Failed to add todo');
}
};
return (
<div>
<TodoInput onAdd={addTodo} />
<ul>
{todos?.map(todo => (
<li
key={todo.id}
className={todo._pending ? 'pending' : ''}
>
{todo.text}
</li>
))}
</ul>
</div>
);
}删除项目
jsx
function TodoDelete() {
const { data: todos, mutate } = useSWR('/api/todos', fetcher);
const deleteTodo = async (todoId) => {
// 保存被删除的项目用于回滚
const deletedTodo = todos.find(t => t.id === todoId);
if (!deletedTodo) return;
// 乐观删除
mutate(todos.filter(t => t.id !== todoId), false);
try {
const response = await fetch(`/api/todos/${todoId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Delete failed');
// 重新验证以确保同步
mutate();
} catch (error) {
// 恢复被删除的项目
const index = todos.findIndex(t => t.id === todoId);
const restored = [...todos];
restored.splice(index, 0, deletedTodo);
mutate(restored, false);
toast.error('Failed to delete todo');
}
};
return (
<ul>
{todos?.map(todo => (
<li key={todo.id}>
{todo.text}
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
);
}更新项目
jsx
function TodoUpdate() {
const { data: todos, mutate } = useSWR('/api/todos', fetcher);
const toggleTodo = async (todoId) => {
// 找到要更新的项目
const todo = todos.find(t => t.id === todoId);
if (!todo) return;
// 乐观更新
mutate(
todos.map(t =>
t.id === todoId ? { ...t, completed: !t.completed } : t
),
false
);
try {
const response = await fetch(`/api/todos/${todoId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !todo.completed }),
});
if (!response.ok) throw new Error('Update failed');
const updated = await response.json();
// 使用服务器返回的数据
mutate(
todos.map(t => t.id === todoId ? updated : t),
false
);
} catch (error) {
// 回滚
mutate(todos, false);
toast.error('Failed to update todo');
}
};
return (
<ul>
{todos?.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span className={todo.completed ? 'completed' : ''}>
{todo.text}
</span>
</li>
))}
</ul>
);
}复杂场景
级联更新
jsx
function CascadeUpdate({ postId }) {
const { data: post, mutate: mutatePost } = useSWR(
`/api/posts/${postId}`,
fetcher
);
const { data: user, mutate: mutateUser } = useSWR(
post ? `/api/users/${post.authorId}` : null,
fetcher
);
const { mutate: mutateAllPosts } = useSWRConfig();
const updatePost = async (updates) => {
const previousPost = post;
// 1. 乐观更新文章
mutatePost({ ...post, ...updates }, false);
try {
const response = await fetch(`/api/posts/${postId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) throw new Error('Update failed');
const updatedPost = await response.json();
// 2. 更新文章数据
mutatePost(updatedPost);
// 3. 如果更新了作者,需要更新相关缓存
if (updates.authorId && updates.authorId !== post.authorId) {
// 更新旧作者的文章列表
mutateAllPosts(
`/api/users/${post.authorId}/posts`,
undefined,
{ revalidate: true }
);
// 更新新作者的文章列表
mutateAllPosts(
`/api/users/${updates.authorId}/posts`,
undefined,
{ revalidate: true }
);
}
// 4. 更新所有文章列表
mutateAllPosts('/api/posts', undefined, { revalidate: true });
toast.success('Post updated successfully');
} catch (error) {
// 回滚
mutatePost(previousPost, false);
toast.error('Failed to update post');
}
};
return (
<div>
<h1>{post?.title}</h1>
<p>by {user?.name}</p>
<button onClick={() => updatePost({ title: 'New Title' })}>
Update Title
</button>
</div>
);
}嵌套数据更新
jsx
function NestedDataUpdate({ userId }) {
const { data: user, mutate } = useSWR(`/api/users/${userId}`, fetcher);
const updateAddress = async (addressUpdates) => {
const previousUser = user;
// 乐观更新嵌套数据
mutate(
{
...user,
address: {
...user.address,
...addressUpdates,
},
},
false
);
try {
const response = await fetch(`/api/users/${userId}/address`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(addressUpdates),
});
if (!response.ok) throw new Error('Update failed');
const updatedUser = await response.json();
mutate(updatedUser);
} catch (error) {
mutate(previousUser, false);
toast.error('Failed to update address');
}
};
const addTag = async (tag) => {
const previousUser = user;
// 乐观添加标签
mutate(
{
...user,
tags: [...(user.tags || []), tag],
},
false
);
try {
const response = await fetch(`/api/users/${userId}/tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag }),
});
if (!response.ok) throw new Error('Failed to add tag');
const updatedUser = await response.json();
mutate(updatedUser);
} catch (error) {
mutate(previousUser, false);
toast.error('Failed to add tag');
}
};
return (
<div>
<h1>{user?.name}</h1>
<div>
<h3>Address</h3>
<p>{user?.address?.street}</p>
<button onClick={() => updateAddress({ street: 'New Street' })}>
Update Address
</button>
</div>
<div>
<h3>Tags</h3>
{user?.tags?.map(tag => (
<span key={tag} className="tag">{tag}</span>
))}
<button onClick={() => addTag('new-tag')}>Add Tag</button>
</div>
</div>
);
}批量乐观更新
jsx
function BatchOptimisticUpdate() {
const { data: items, mutate } = useSWR('/api/items', fetcher);
const [selectedIds, setSelectedIds] = useState([]);
const batchUpdate = async (updates) => {
const previousItems = items;
// 乐观批量更新
mutate(
items.map(item =>
selectedIds.includes(item.id)
? { ...item, ...updates }
: item
),
false
);
try {
const response = await fetch('/api/items/batch', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ids: selectedIds,
updates,
}),
});
if (!response.ok) throw new Error('Batch update failed');
const updatedItems = await response.json();
// 合并更新后的数据
mutate(
items.map(item => {
const updated = updatedItems.find(u => u.id === item.id);
return updated || item;
})
);
setSelectedIds([]);
toast.success(`Updated ${selectedIds.length} items`);
} catch (error) {
mutate(previousItems, false);
toast.error('Batch update failed');
}
};
const batchDelete = async () => {
const previousItems = items;
// 乐观批量删除
mutate(
items.filter(item => !selectedIds.includes(item.id)),
false
);
try {
const response = await fetch('/api/items/batch', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: selectedIds }),
});
if (!response.ok) throw new Error('Batch delete failed');
mutate();
setSelectedIds([]);
toast.success(`Deleted ${selectedIds.length} items`);
} catch (error) {
mutate(previousItems, false);
toast.error('Batch delete failed');
}
};
return (
<div>
<div className="toolbar">
<span>{selectedIds.length} selected</span>
<button onClick={() => batchUpdate({ status: 'active' })}>
Mark as Active
</button>
<button onClick={batchDelete}>Delete Selected</button>
</div>
<ItemList
items={items}
selectedIds={selectedIds}
onSelect={setSelectedIds}
/>
</div>
);
}交互优化
点赞功能
jsx
function LikeButton({ postId }) {
const { data: post, mutate } = useSWR(`/api/posts/${postId}`, fetcher);
const [isAnimating, setIsAnimating] = useState(false);
const toggleLike = async () => {
const previousPost = post;
const wasLiked = post.liked;
// 触发动画
setIsAnimating(true);
setTimeout(() => setIsAnimating(false), 300);
// 乐观更新
mutate(
{
...post,
liked: !post.liked,
likeCount: post.liked ? post.likeCount - 1 : post.likeCount + 1,
},
false
);
try {
const response = await fetch(`/api/posts/${postId}/like`, {
method: wasLiked ? 'DELETE' : 'POST',
});
if (!response.ok) throw new Error('Failed to toggle like');
const updatedPost = await response.json();
mutate(updatedPost);
} catch (error) {
// 回滚并显示错误
mutate(previousPost, false);
toast.error('Failed to update like');
}
};
return (
<button
onClick={toggleLike}
className={`like-button ${post?.liked ? 'liked' : ''} ${
isAnimating ? 'animating' : ''
}`}
>
<span className="heart">{post?.liked ? '❤️' : '🤍'}</span>
<span className="count">{post?.likeCount || 0}</span>
</button>
);
}关注功能
jsx
function FollowButton({ userId }) {
const { data: currentUser, mutate: mutateCurrentUser } = useSWR(
'/api/user',
fetcher
);
const { data: targetUser, mutate: mutateTargetUser } = useSWR(
`/api/users/${userId}`,
fetcher
);
const isFollowing = currentUser?.following?.includes(userId);
const toggleFollow = async () => {
const previousCurrentUser = currentUser;
const previousTargetUser = targetUser;
// 乐观更新当前用户的关注列表
mutateCurrentUser(
{
...currentUser,
following: isFollowing
? currentUser.following.filter(id => id !== userId)
: [...currentUser.following, userId],
},
false
);
// 乐观更新目标用户的粉丝数
mutateTargetUser(
{
...targetUser,
followerCount: isFollowing
? targetUser.followerCount - 1
: targetUser.followerCount + 1,
},
false
);
try {
const response = await fetch(`/api/users/${userId}/follow`, {
method: isFollowing ? 'DELETE' : 'POST',
});
if (!response.ok) throw new Error('Failed to toggle follow');
// 重新验证数据
mutateCurrentUser();
mutateTargetUser();
} catch (error) {
// 回滚两个缓存
mutateCurrentUser(previousCurrentUser, false);
mutateTargetUser(previousTargetUser, false);
toast.error('Failed to update follow status');
}
};
return (
<button
onClick={toggleFollow}
className={isFollowing ? 'following' : 'not-following'}
>
{isFollowing ? 'Following' : 'Follow'}
</button>
);
}投票功能
jsx
function VoteButtons({ commentId }) {
const { data: comment, mutate } = useSWR(
`/api/comments/${commentId}`,
fetcher
);
const vote = async (voteType) => {
const previousComment = comment;
const currentVote = comment.userVote; // 'up', 'down', or null
let scoreChange = 0;
let newVote = null;
if (currentVote === voteType) {
// 取消投票
scoreChange = voteType === 'up' ? -1 : 1;
newVote = null;
} else if (currentVote) {
// 改变投票
scoreChange = voteType === 'up' ? 2 : -2;
newVote = voteType;
} else {
// 新投票
scoreChange = voteType === 'up' ? 1 : -1;
newVote = voteType;
}
// 乐观更新
mutate(
{
...comment,
score: comment.score + scoreChange,
userVote: newVote,
},
false
);
try {
const response = await fetch(`/api/comments/${commentId}/vote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voteType: newVote }),
});
if (!response.ok) throw new Error('Vote failed');
const updatedComment = await response.json();
mutate(updatedComment);
} catch (error) {
mutate(previousComment, false);
toast.error('Failed to vote');
}
};
return (
<div className="vote-buttons">
<button
onClick={() => vote('up')}
className={comment?.userVote === 'up' ? 'active' : ''}
>
▲
</button>
<span className="score">{comment?.score || 0}</span>
<button
onClick={() => vote('down')}
className={comment?.userVote === 'down' ? 'active' : ''}
>
▼
</button>
</div>
);
}错误处理
重试策略
jsx
function OptimisticWithRetry({ todoId }) {
const { data: todo, mutate } = useSWR(`/api/todos/${todoId}`, fetcher);
const updateWithRetry = async (updates, retries = 3) => {
const previousTodo = todo;
// 乐观更新
mutate({ ...todo, ...updates }, false);
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(`/api/todos/${todoId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) throw new Error('Update failed');
const updatedTodo = await response.json();
mutate(updatedTodo);
return;
} catch (error) {
if (i === retries - 1) {
// 最后一次重试失败,回滚
mutate(previousTodo, false);
toast.error('Update failed after retries');
} else {
// 等待后重试
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
}
}
};
return (
<div>
<h3>{todo?.text}</h3>
<button onClick={() => updateWithRetry({ completed: !todo.completed })}>
Toggle
</button>
</div>
);
}冲突解决
jsx
function ConflictResolution({ documentId }) {
const { data: document, mutate } = useSWR(
`/api/documents/${documentId}`,
fetcher
);
const updateDocument = async (updates) => {
const previousDocument = document;
// 乐观更新(包含版本号)
mutate(
{
...document,
...updates,
version: document.version + 1,
},
false
);
try {
const response = await fetch(`/api/documents/${documentId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...updates,
version: document.version,
}),
});
if (response.status === 409) {
// 版本冲突
const serverDocument = await response.json();
// 显示冲突解决界面
const resolved = await showConflictDialog(
previousDocument,
updates,
serverDocument
);
if (resolved) {
// 用户解决了冲突,重新提交
mutate(resolved, false);
return updateDocument(resolved);
} else {
// 用户取消,使用服务器版本
mutate(serverDocument);
}
return;
}
if (!response.ok) throw new Error('Update failed');
const updatedDocument = await response.json();
mutate(updatedDocument);
} catch (error) {
mutate(previousDocument, false);
toast.error('Failed to update document');
}
};
return (
<div>
<h1>{document?.title}</h1>
<p>Version: {document?.version}</p>
<button onClick={() => updateDocument({ title: 'New Title' })}>
Update
</button>
</div>
);
}
async function showConflictDialog(local, changes, server) {
// 显示冲突解决对话框
return new Promise((resolve) => {
// 实现冲突解决UI
// 返回解决后的数据或null
});
}最佳实践
乐观更新Hook
jsx
function useOptimisticUpdate(key, fetcher) {
const { data, mutate } = useSWR(key, fetcher);
const [isPending, setIsPending] = useState(false);
const optimisticUpdate = useCallback(
async (updateFn, options = {}) => {
const {
onSuccess,
onError,
rollbackOnError = true,
revalidate = true,
} = options;
const previousData = data;
setIsPending(true);
try {
// 获取乐观数据
const optimisticData =
typeof updateFn === 'function' ? updateFn(data) : updateFn;
// 乐观更新
mutate(optimisticData, false);
// 如果updateFn是async,等待结果
if (updateFn.constructor.name === 'AsyncFunction') {
const result = await updateFn(data);
mutate(result, revalidate);
onSuccess?.(result);
} else {
onSuccess?.(optimisticData);
}
} catch (error) {
if (rollbackOnError) {
mutate(previousData, false);
}
onError?.(error);
} finally {
setIsPending(false);
}
},
[data, mutate]
);
return {
data,
isPending,
optimisticUpdate,
};
}
// 使用
function TodoItem({ todoId }) {
const { data: todo, isPending, optimisticUpdate } = useOptimisticUpdate(
`/api/todos/${todoId}`,
fetcher
);
const toggleComplete = () => {
optimisticUpdate(
async (currentTodo) => {
const response = await fetch(`/api/todos/${todoId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !currentTodo.completed }),
});
if (!response.ok) throw new Error('Update failed');
return response.json();
},
{
onSuccess: () => toast.success('Todo updated'),
onError: () => toast.error('Failed to update'),
}
);
};
return (
<div className={isPending ? 'pending' : ''}>
<input
type="checkbox"
checked={todo?.completed}
onChange={toggleComplete}
/>
<span>{todo?.text}</span>
</div>
);
}总结
乐观更新核心要点:
- 基础模式:立即更新UI、发送请求、处理结果
- 列表操作:添加、删除、更新项目
- 复杂场景:级联更新、嵌套数据、批量操作
- 交互优化:点赞、关注、投票功能
- 错误处理:重试策略、冲突解决
- 最佳实践:封装Hook、状态管理、用户反馈
合理使用乐观更新可以显著提升应用的响应速度和用户体验。