React 19 useActionState 深度解析 & Vue 2.7 移植实战
一、useActionState 是什么?
useActionState 是 React 19 新增的 Hook,专门用于处理表单提交/异步操作的状态管理。它将「异步操作」「loading 状态」「错误处理」「结果数据」统一收敛到一个 Hook 中。
前身是
useFormState(React Canary),在 React 19 正式版中重命名为useActionState。
1.1 解决了什么痛点?
没有 useActionState 之前,处理一个表单提交你需要:
function LoginForm() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [isPending, setIsPending] = useState(false);
const handleSubmit = async (formData) => {
setIsPending(true);
setError(null);
try {
const result = await loginAPI(formData);
setData(result);
} catch (err) {
setError(err.message);
} finally {
setIsPending(false);
}
};
return (
<form onSubmit={handleSubmit}>
{isPending && <Spinner />}
{error && <p className="error">{error}</p>}
{data && <p>欢迎, {data.username}</p>}
{/* ... */}
</form>
);
}
三个 useState + try/catch + 手动管理 loading —— 模板代码太多了!
1.2 函数签名
const [state, formAction, isPending] = useActionState(
actionFn, // 异步操作函数
initialState, // 初始状态
permalink? // 可选,用于 SSR 的永久链接
);
参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
actionFn | (previousState, formData) => newState | 异步操作函数,接收上一次的 state 和表单数据 |
initialState | any | 状态初始值 |
permalink | string(可选) | SSR 场景下 JS 未加载时的回退 URL |
返回值说明:
| 返回值 | 类型 | 说明 |
|---|---|---|
state | any | 当前状态(action 执行后的返回值) |
formAction | function | 传给 <form action={}> 或按钮的 action |
isPending | boolean | 操作是否正在进行中 |
二、useActionState 使用方式详解
2.1 基础用法:表单提交
import { useActionState } from 'react';
// 异步 action 函数
async function submitForm(previousState, formData) {
const username = formData.get('username');
const password = formData.get('password');
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
return {
success: false,
message: '登录失败:' + response.statusText,
data: null
};
}
const data = await response.json();
return {
success: true,
message: '登录成功!',
data
};
} catch (err) {
return {
success: false,
message: '网络错误:' + err.message,
data: null
};
}
}
// 初始状态
const initialState = {
success: false,
message: '',
data: null,
};
function LoginForm() {
const [state, formAction, isPending] = useActionState(
submitForm,
initialState
);
return (
<form action={formAction}>
<h2>用户登录</h2>
{/* 显示操作结果 */}
{state.message && (
<div className={state.success ? 'success' : 'error'}>
{state.message}
</div>
)}
<div>
<label htmlFor="username">用户名:</label>
<input id="username" name="username" required />
</div>
<div>
<label htmlFor="password">密码:</label>
<input id="password" name="password" type="password" required />
</div>
<button type="submit" disabled={isPending}>
{isPending ? '登录中...' : '登录'}
</button>
{/* 展示登录成功后的用户信息 */}
{state.success && state.data && (
<div className="user-info">
<p>欢迎回来,{state.data.username}!</p>
<p>角色:{state.data.role}</p>
</div>
)}
</form>
);
}
2.2 非表单场景:普通按钮操作
useActionState 不仅限于表单,任何异步操作都可以用:
import { useActionState } from 'react';
async function addToCart(previousState, productId) {
try {
const res = await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId }),
headers: { 'Content-Type': 'application/json' },
});
const cart = await res.json();
return {
success: true,
itemCount: cart.items.length,
message: '已加入购物车',
};
} catch (err) {
return {
...previousState,
success: false,
message: '操作失败',
};
}
}
function ProductCard({ product }) {
const [cartState, addAction, isPending] = useActionState(
addToCart,
{ success: false, itemCount: 0, message: '' }
);
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>¥{product.price}</p>
{/* 注意:非表单场景通过手动调用 */}
<button
onClick={() => addAction(product.id)}
disabled={isPending}
>
{isPending ? '添加中...' : '加入购物车'}
</button>
{cartState.message && <p>{cartState.message}</p>}
{cartState.success && <p>购物车共 {cartState.itemCount} 件</p>}
</div>
);
}
2.3 利用 previousState 做累积操作
actionFn 的第一个参数是上一次返回的 state,这非常适合做列表追加:
async function loadMoreComments(previousState, page) {
const res = await fetch(`/api/comments?page=${page}`);
const newComments = await res.json();
return {
comments: [...previousState.comments, ...newComments],
currentPage: page,
hasMore: newComments.length === 10,
};
}
function CommentList() {
const [state, loadMore, isPending] = useActionState(
loadMoreComments,
{ comments: [], currentPage: 0, hasMore: true }
);
return (
<div>
{state.comments.map(c => (
<div key={c.id}>{c.content}</div>
))}
{state.hasMore && (
<button
onClick={() => loadMore(state.currentPage + 1)}
disabled={isPending}
>
{isPending ? '加载中...' : '加载更多'}
</button>
)}
</div>
);
}
2.4 与 useFormStatus 配合使用(完整表单方案)
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
// 子组件:自动感知表单提交状态
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '提交中...' : '提交'}
</button>
);
}
// 父组件
function ContactForm() {
const [state, formAction] = useActionState(
async (prev, formData) => {
const res = await fetch('/api/contact', {
method: 'POST',
body: formData,
});
if (res.ok) return { success: true, message: '提交成功!' };
return { success: false, message: '提交失败' };
},
{ success: false, message: '' }
);
return (
<form action={formAction}>
<input name="email" type="email" placeholder="邮箱" required />
<textarea name="message" placeholder="留言内容" required />
{state.message && (
<p style={{ color: state.success ? 'green' : 'red' }}>
{state.message}
</p>
)}
<SubmitButton />
</form>
);
}
三、useActionState 的内部原理(简化)
调用 useActionState(actionFn, initialState)
│
▼
内部创建一个 reducer:
┌────────────────────────────────┐
│ state = initialState │
│ isPending = false │
│ │
│ 当 formAction 被触发时: │
│ 1. isPending = true │
│ 2. result = await actionFn( │
│ previousState, │
│ formData/payload │
│ ) │
│ 3. state = result │
│ 4. isPending = false │
│ 5. 触发重新渲染 │
└────────────────────────────────┘
│
▼
返回 [state, formAction, isPending]
本质:useActionState ≈ useReducer + useTransition 的封装,用 transition 包裹异步操作来自动管理 pending 状态。
四、在 Vue 2.7.15 中实现 useActionState
Vue 2.7 引入了 Composition API(ref、computed、watch 等),这让我们有能力实现类似的功能。
4.1 核心实现
创建文件 src/composables/useActionState.js:
import { ref, shallowRef, readonly } from 'vue';
/**
* Vue 2.7 版本的 useActionState
* 模拟 React 19 的 useActionState Hook
*
* @param {Function} actionFn - 异步操作函数 (previousState, payload) => newState
* @param {any} initialState - 初始状态
* @param {Object} options - 可选配置
* @param {Function} options.onSuccess - 成功回调
* @param {Function} options.onError - 失败回调
* @param {boolean} options.resetOnError - 失败时是否重置为初始状态
* @returns {Object} { state, action, isPending, reset }
*/
export function useActionState(actionFn, initialState, options = {}) {
const {
onSuccess = null,
onError = null,
resetOnError = false,
} = options;
// ---- 核心状态 ----
// 使用 shallowRef 存储 state(避免深层对象的深度响应式带来的性能问题)
// 类似 React 中 useState 的 state
const state = shallowRef(
typeof initialState === 'function' ? initialState() : initialState
);
// 是否正在执行中(对应 React 的 isPending)
const isPending = ref(false);
// 内部:防止竞态条件的版本号
let actionVersion = 0;
// ---- 核心方法 ----
/**
* 触发 action(对应 React 返回的 formAction)
* @param {any} payload - 传递给 actionFn 的数据(对应 React 中的 formData)
* @returns {Promise<any>} action 的执行结果
*/
async function action(payload) {
// 递增版本号,用于处理竞态
const currentVersion = ++actionVersion;
// 设置 loading 状态
isPending.value = true;
try {
// 调用用户传入的 actionFn
// 参数1: previousState(上一次的状态,对应 React 的 previousState)
// 参数2: payload(用户传入的数据,对应 React 的 formData)
const result = await actionFn(state.value, payload);
// 竞态检查:如果在等待期间又触发了新的 action,
// 则丢弃旧的结果(只保留最新一次的结果)
if (currentVersion !== actionVersion) {
return;
}
// 更新状态为 action 的返回值
state.value = result;
// 触发成功回调
if (onSuccess && typeof onSuccess === 'function') {
onSuccess(result);
}
return result;
} catch (error) {
// 竞态检查
if (currentVersion !== actionVersion) {
return;
}
// 失败时是否重置状态
if (resetOnError) {
state.value = typeof initialState === 'function'
? initialState()
: initialState;
}
// 触发失败回调
if (onError && typeof onError === 'function') {
onError(error);
}
// 将错误继续抛出,让调用方也能 catch
throw error;
} finally {
// 竞态检查:只有最新的 action 才能关闭 loading
if (currentVersion === actionVersion) {
isPending.value = false;
}
}
}
/**
* 重置为初始状态(React 的 useActionState 没有这个,这是增强功能)
*/
function reset() {
state.value = typeof initialState === 'function'
? initialState()
: initialState;
isPending.value = false;
actionVersion++; // 取消正在进行的 action
}
// ---- 返回值 ----
// 对应 React 的 [state, formAction, isPending]
// Vue 中用对象返回,更符合 Composition API 惯例
return {
state: readonly(state), // 只读,防止外部直接修改(必须通过 action 更新)
action, // 触发操作的函数
isPending: readonly(isPending), // 只读的 loading 状态
reset, // 重置方法(增强功能)
};
}
4.2 代码逐行解析
为什么用 shallowRef 而不是 ref?
const state = shallowRef(initialState);
ref 会对对象进行深度响应式转换(递归 Proxy),如果 state 是一个包含大量数据的对象(如列表数据),会有性能开销。shallowRef 只对 .value 本身做响应式,赋新值时才触发更新——和 React 的 useState(引用比较)行为一致。
竞态处理是什么意思?
let actionVersion = 0;
async function action(payload) {
const currentVersion = ++actionVersion;
// ... await 异步操作
if (currentVersion !== actionVersion) return; // 不是最新的,丢弃
}
场景:用户快速点击了3次提交按钮,发出了3个请求。第1个请求最慢,第3个最快。如果不做竞态处理,最终 state 会被第1个请求的结果覆盖(而不是第3个),导致数据不一致。
为什么返回 readonly?
return {
state: readonly(state),
isPending: readonly(isPending),
};
模拟 React 中 state 不可直接修改的约束——状态只能通过 action 函数更新,不能在外部直接 state.value = xxx。
4.3 支持表单的增强版本
为了更好地配合 Vue 的表单处理,我们增加一个专门处理表单提交的包装:
创建文件 src/composables/useFormActionState.js:
import { useActionState } from './useActionState';
/**
* 表单专用版本,自动收集表单数据
* 模拟 React 19 中 <form action={formAction}> 的行为
*
* @param {Function} actionFn - (previousState, formDataObject) => newState
* @param {any} initialState - 初始状态
* @param {Object} options - 配置项
* @returns {Object} { state, handleSubmit, isPending, reset }
*/
export function useFormActionState(actionFn, initialState, options = {}) {
const { state, action, isPending, reset } = useActionState(
actionFn,
initialState,
options
);
/**
* 表单提交处理函数
* 可以直接绑定到 <form @submit="handleSubmit">
* 自动收集 FormData 并转换为普通对象
*
* @param {Event} event - 表单提交事件
*/
async function handleSubmit(event) {
// 阻止默认提交行为
event.preventDefault();
// 获取表单元素
const form = event.target;
// 使用 FormData API 收集表单数据
const formData = new FormData(form);
// 将 FormData 转换为普通对象(方便使用)
const formDataObject = {};
formData.forEach((value, key) => {
// 处理同名字段(如多选框)
if (formDataObject[key] !== undefined) {
if (!Array.isArray(formDataObject[key])) {
formDataObject[key] = [formDataObject[key]];
}
formDataObject[key].push(value);
} else {
formDataObject[key] = value;
}
});
// 调用 action,传入收集到的表单数据
try {
await action(formDataObject);
} catch (error) {
// 错误已在 useActionState 内部处理
console.error('[useFormActionState] 表单提交失败:', error);
}
}
return {
state,
handleSubmit, // 直接绑定到 @submit
isPending,
reset,
};
}
4.4 完整使用示例
示例1:登录表单
src/components/LoginForm.vue
<template>
<form @submit="handleSubmit" class="login-form">
<h2>用户登录</h2>
<!-- 状态提示 -->
<div v-if="state.message" :class="['alert', state.success ? 'success' : 'error']">
{{ state.message }}
</div>
<!-- 用户名 -->
<div class="form-group">
<label for="username">用户名:</label>
<input
id="username"
name="username"
type="text"
required
:disabled="isPending"
placeholder="请输入用户名"
/>
</div>
<!-- 密码 -->
<div class="form-group">
<label for="password">密码:</label>
<input
id="password"
name="password"
type="password"
required
:disabled="isPending"
placeholder="请输入密码"
/>
</div>
<!-- 记住我 -->
<div class="form-group">
<label>
<input name="remember" type="checkbox" value="true" />
记住我
</label>
</div>
<!-- 提交按钮 -->
<button type="submit" :disabled="isPending" class="btn-primary">
<span v-if="isPending" class="spinner"></span>
{{ isPending ? '登录中...' : '登录' }}
</button>
<!-- 登录成功后显示用户信息 -->
<div v-if="state.success && state.data" class="user-info">
<p>🎉 欢迎回来,{{ state.data.username }}!</p>
<p>角色:{{ state.data.role }}</p>
<button type="button" @click="reset">退出</button>
</div>
</form>
</template>
<script>
import { useFormActionState } from '@/composables/useFormActionState';
// 模拟登录 API
async function loginAPI(previousState, formData) {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 1500));
const { username, password } = formData;
// 模拟校验
if (username === 'admin' && password === '123456') {
return {
success: true,
message: '登录成功!',
data: {
username: 'admin',
role: '管理员',
token: 'mock-token-xxx',
},
};
}
// 登录失败:返回新 state(不是抛错误!)
// 这和 React 的 useActionState 设计一致——通过返回值表达状态
return {
success: false,
message: '用户名或密码错误',
data: null,
};
}
export default {
name: 'LoginForm',
setup() {
// 初始状态
const initialState = {
success: false,
message: '',
data: null,
};
// 使用 useFormActionState —— 一行搞定所有状态管理!
const { state, handleSubmit, isPending, reset } = useFormActionState(
loginAPI,
initialState,
{
onSuccess: (result) => {
if (result.success) {
console.log('登录成功,token:', result.data.token);
}
},
}
);
return {
state,
handleSubmit,
isPending,
reset,
};
},
};
</script>
<style scoped>
.login-form {
max-width: 400px;
margin: 40px auto;
padding: 24px;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: 500;
}
.form-group input[type="text"],
.form-group input[type="password"] {
width: 100%;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
.alert {
padding: 10px 14px;
border-radius: 4px;
margin-bottom: 16px;
}
.alert.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.btn-primary {
width: 100%;
padding: 10px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-primary:disabled {
background: #91caff;
cursor: not-allowed;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid #ffffff80;
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.user-info {
margin-top: 16px;
padding: 12px;
background: #f0f9ff;
border-radius: 4px;
}
</style>
示例2:加载更多列表(利用 previousState 累积)
src/components/CommentList.vue
<template>
<div class="comment-list">
<h2>评论列表</h2>
<!-- 评论列表 -->
<div
v-for="comment in state.comments"
:key="comment.id"
class="comment-item"
>
<strong>{{ comment.author }}</strong>
<p>{{ comment.content }}</p>
<span class="time">{{ comment.time }}</span>
</div>
<!-- 空状态 -->
<p v-if="!state.comments.length && !isPending">暂无评论</p>
<!-- 加载更多按钮 -->
<button
v-if="state.hasMore"
@click="loadMore(state.currentPage + 1)"
:disabled="isPending"
class="btn-load-more"
>
{{ isPending ? '加载中...' : '加载更多' }}
</button>
<p v-if="!state.hasMore && state.comments.length" class="no-more">
—— 没有更多了 ——
</p>
</div>
</template>
<script>
import { onMounted } from 'vue';
import { useActionState } from '@/composables/useActionState';
// 模拟获取评论 API
async function fetchComments(previousState, page) {
await new Promise(resolve => setTimeout(resolve, 800));
// 模拟分页数据
const pageSize = 5;
const total = 13;
const start = (page - 1) * pageSize;
const newComments = Array.from(
{ length: Math.min(pageSize, total - start) },
(_, i) => ({
id: start + i + 1,
author: `用户${start + i + 1}`,
content: `这是第 ${start + i + 1} 条评论的内容`,
time: new Date(Date.now() - (start + i) * 3600000).toLocaleString(),
})
);
return {
// 关键:利用 previousState 累积数据!
comments: [...previousState.comments, ...newComments],
currentPage: page,
hasMore: start + pageSize < total,
};
}
export default {
name: 'CommentList',
setup() {
const { state, action: loadMore, isPending } = useActionState(
fetchComments,
{
comments: [],
currentPage: 0,
hasMore: true,
}
);
// 组件挂载时自动加载第一页
onMounted(() => {
loadMore(1);
});
return {
state,
loadMore,
isPending,
};
},
};
</script>
<style scoped>
.comment-list {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.comment-item {
padding: 12px;
border-bottom: 1px solid #eee;
}
.comment-item strong {
color: #333;
}
.comment-item p {
margin: 8px 0 4px;
color: #555;
}
.time {
font-size: 12px;
color: #999;
}
.btn-load-more {
display: block;
width: 100%;
padding: 12px;
margin-top: 16px;
background: white;
border: 1px solid #1890ff;
color: #1890ff;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-load-more:disabled {
border-color: #91caff;
color: #91caff;
cursor: not-allowed;
}
.no-more {
text-align: center;
color: #999;
margin-top: 16px;
}
</style>
示例3:购物车操作(带错误处理)
src/components/ProductCard.vue
<template>
<div class="product-card">
<h3>{{ product.name }}</h3>
<p class="price">¥{{ product.price }}</p>
<button @click="addToCart(product.id)" :disabled="isPending">
{{ isPending ? '添加中...' : '加入购物车' }}
</button>
<p v-if="state.message" :class="state.success ? 'text-green' : 'text-red'">
{{ state.message }}
</p>
<p v-if="state.success" class="cart-info">
购物车共 {{ state.itemCount }} 件商品
</p>
</div>
</template>
<script>
import { useActionState } from '@/composables/useActionState';
// 模拟加入购物车 API
async function addToCartAPI(previousState, productId) {
await new Promise(resolve => setTimeout(resolve, 1000));
// 模拟 30% 概率失败
if (Math.random() < 0.3) {
throw new Error('库存不足,请稍后重试');
}
return {
success: true,
itemCount: previousState.itemCount + 1,
message: '已成功加入购物车!',
};
}
export default {
name: 'ProductCard',
props: {
product: {
type: Object,
required: true,
},
},
setup(props) {
const { state, action: addToCart, isPending, reset } = useActionState(
addToCartAPI,
{ success: false, itemCount: 0, message: '' },
{
// 失败时的回调——这里是增强功能,React 原版没有
onError: (error) => {
console.error('加入购物车失败:', error.message);
},
// 失败时不重置状态(保留之前的购物车数量)
resetOnError: false,
}
);
// 包装一下 addToCart,处理错误场景下的 state 更新
async function handleAddToCart(productId) {
try {
await addToCart(productId);
} catch (error) {
// action 内部已经处理了 isPending
// 这里我们手动更新错误信息(因为 throw 不会更新 state)
// 所以我们换一种写法——把错误处理放在 actionFn 内部
}
}
return {
state,
addToCart: handleAddToCart,
isPending,
reset,
};
},
};
</script>
<style scoped>
.product-card {
padding: 16px;
border: 1px solid #e8e8e8;
border-radius: 8px;
text-align: center;
width: 200px;
}
.price {
font-size: 20px;
color: #ff4d4f;
font-weight: bold;
}
.text-green { color: #52c41a; }
.text-red { color: #ff4d4f; }
.cart-info { color: #1890ff; font-size: 12px; }
button {
padding: 8px 20px;
background: #ff6a00;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background: #ffb980;
cursor: not-allowed;
}
</style>
⚠️ 最佳实践:对于可能失败的操作,建议把错误处理逻辑放在
actionFn内部用 try/catch 返回错误状态,而不是让它抛出异常。这样和 React 的useActionState行为完全一致:
// ✅ 推荐:在 actionFn 内部处理错误,通过返回值表达状态
async function addToCartAPI(previousState, productId) {
try {
await new Promise(resolve => setTimeout(resolve, 1000));
if (Math.random() < 0.3) throw new Error('库存不足');
return {
success: true,
itemCount: previousState.itemCount + 1,
message: '已加入购物车!',
};
} catch (error) {
return {
...previousState, // 保留之前的状态
success: false,
message: error.message,
};
}
}
示例4:Todo 应用(综合实战)
src/components/TodoApp.vue
<template>
<div class="todo-app">
<h2>待办事项</h2>
<!-- 添加表单 -->
<form @submit="handleAddSubmit" class="add-form">
<input
name="title"
placeholder="输入待办事项..."
required
:disabled="addState.isPending"
/>
<button type="submit" :disabled="addState.isPending">
{{ addState.isPending ? '添加中...' : '添加' }}
</button>
</form>
<p v-if="addState.state.message" :class="addState.state.success ? 'text-green' : 'text-red'">
{{ addState.state.message }}
</p>
<!-- 待办列表 -->
<div class="todo-list">
<div
v-for="todo in listState.state.todos"
:key="todo.id"
class="todo-item"
:class="{ done: todo.done }"
>
<span @click="toggleTodo(todo.id)">
{{ todo.done ? '✅' : '⬜' }} {{ todo.title }}
</span>
<button
@click="deleteTodo(todo.id)"
:disabled="deleteState.isPending"
class="btn-delete"
>
删除
</button>
</div>
</div>
<!-- 加载状态 -->
<p v-if="listState.isPending" class="loading">加载中...</p>
<p v-if="!listState.state.todos.length && !listState.isPending">
暂无待办事项
</p>
<!-- 统计 -->
<div v-if="listState.state.todos.length" class="stats">
共 {{ listState.state.todos.length }} 项,
已完成 {{ listState.state.todos.filter(t => t.done).length }} 项
</div>
</div>
</template>
<script>
import { onMounted } from 'vue';
import { useActionState } from '@/composables/useActionState';
import { useFormActionState } from '@/composables/useFormActionState';
// ========== 模拟 API ==========
let todosDB = [
{ id: 1, title: '学习 React 19', done: false },
{ id: 2, title: '学习 Vue Composition API', done: true },
{ id: 3, title: '写技术博客', done: false },
];
let nextId = 4;
async function delay(ms = 500) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 获取列表
async function fetchTodosAPI(previousState) {
await delay(600);
return {
todos: [...todosDB],
lastFetched: new Date().toLocaleTimeString(),
};
}
// 添加待办
async function addTodoAPI(previousState, formData) {
await delay(500);
const title = formData.title?.trim();
if (!title) {
return { success: false, message: '标题不能为空' };
}
const newTodo = { id: nextId++, title, done: false };
todosDB.push(newTodo);
return { success: true, message: `已添加:${title}` };
}
// 切换完成状态
async function toggleTodoAPI(previousState, todoId) {
await delay(300);
todosDB = todosDB.map(t =>
t.id === todoId ? { ...t, done: !t.done } : t
);
return { todos: [...todosDB], lastFetched: new Date().toLocaleTimeString() };
}
// 删除待办
async function deleteTodoAPI(previousState, todoId) {
await delay(400);
todosDB = todosDB.filter(t => t.id !== todoId);
return { todos: [...todosDB], lastFetched: new Date().toLocaleTimeString() };
}
export default {
name: 'TodoApp',
setup() {
// ---- 列表状态 ----
const listState = useActionState(fetchTodosAPI, {
todos: [],
lastFetched: '',
});
// ---- 添加操作状态 ----
const addResult = useFormActionState(addTodoAPI, {
success: false,
message: '',
});
// ---- 切换操作 ----
const toggleResult = useActionState(toggleTodoAPI, {
todos: [],
lastFetched: '',
});
// ---- 删除操作 ----
const deleteResult = useActionState(deleteTodoAPI, {
todos: [],
lastFetched: '',
});
// 加载列表
onMounted(() => {
listState.action();
});
// 添加后刷新列表
async function handleAddSubmit(event) {
await addResult.handleSubmit(event);
await listState.action(); // 重新获取列表
}
// 切换后更新列表(直接用返回值更新,不再重新请求)
async function toggleTodo(todoId) {
const result = await toggleResult.action(todoId);
// 这里我们直接把toggle结果同步到listState
// 实际项目中可以用事件总线或Vuex来协调
await listState.action();
}
// 删除后刷新列表
async function deleteTodo(todoId) {
await deleteResult.action(todoId);
await listState.action();
}
return {
listState: {
state: listState.state,
isPending: listState.isPending,
},
addState: {
state: addResult.state,
isPending: addResult.isPending,
},
deleteState: {
isPending: deleteResult.isPending,
},
handleAddSubmit,
toggleTodo,
deleteTodo,
};
},
};
</script>
<style scoped>
.todo-app {
max-width: 500px;
margin: 40px auto;
padding: 24px;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
.add-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.add-form input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
}
.add-form button {
padding: 8px 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.add-form button:disabled {
background: #91caff;
}
.todo-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
}
.todo-item span {
cursor: pointer;
user-select: none;
}
.todo-item.done span {
text-decoration: line-through;
color: #999;
}
.btn-delete {
padding: 4px 8px;
background: #ff4d4f;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.btn-delete:disabled {
background: #ffb3b3;
}
.loading { color: #1890ff; text-align: center; }
.text-green { color: #52c41a; font-size: 13px; }
.text-red { color: #ff4d4f; font-size: 13px; }
.stats {
margin-top: 16px;
text-align: center;
color: #888;
font-size: 13px;
}
</style>
五、进阶功能:为 Vue 版本添加更多能力
5.1 支持乐观更新(Optimistic Update)
React 19 还有一个配套的 useOptimistic Hook。我们也可以在 useActionState 中支持:
src/composables/useOptimisticActionState.js
import { ref, shallowRef, readonly, computed } from 'vue';
/**
* 带乐观更新的 useActionState
*
* @param {Function} actionFn - 异步操作
* @param {any} initialState - 初始状态
* @param {Object} options
* @param {Function} options.optimisticUpdate - 乐观更新函数 (currentState, payload) => optimisticState
*/
export function useOptimisticActionState(actionFn, initialState, options = {}) {
const { optimisticUpdate = null, onSuccess = null, onError = null } = options;
// 真实状态(服务器确认后的)
const confirmedState = shallowRef(
typeof initialState === 'function' ? initialState() : initialState
);
// 乐观状态(用户操作后立即显示的)
const optimisticState = shallowRef(null);
// 是否处于乐观更新中
const isOptimistic = ref(false);
const isPending = ref(false);
let actionVersion = 0;
// 对外暴露的 state:优先显示乐观状态
const state = computed(() => {
return isOptimistic.value && optimisticState.value !== null
? optimisticState.value
: confirmedState.value;
});
async function action(payload) {
const currentVersion = ++actionVersion;
isPending.value = true;
// 如果提供了乐观更新函数,立即更新 UI
if (optimisticUpdate && typeof optimisticUpdate === 'function') {
optimisticState.value = optimisticUpdate(confirmedState.value, payload);
isOptimistic.value = true;
}
try {
const result = await actionFn(confirmedState.value, payload);
if (currentVersion !== actionVersion) return;
// 服务器返回成功,用真实数据替换乐观数据
confirmedState.value = result;
optimisticState.value = null;
isOptimistic.value = false;
if (onSuccess) onSuccess(result);
return result;
} catch (error) {
if (currentVersion !== actionVersion) return;
// 失败时回滚乐观更新
optimisticState.value = null;
isOptimistic.value = false;
if (onError) onError(error);
throw error;
} finally {
if (currentVersion === actionVersion) {
isPending.value = false;
}
}
}
function reset() {
confirmedState.value = typeof initialState === 'function'
? initialState()
: initialState;
optimisticState.value = null;
isOptimistic.value = false;
isPending.value = false;
actionVersion++;
}
return {
state: readonly(state),
action,
isPending: readonly(isPending),
isOptimistic: readonly(isOptimistic),
reset,
};
}
使用示例:点赞功能
<template>
<button @click="handleLike" :disabled="isPending">
{{ state.liked ? '❤️' : '🤍' }} {{ state.likeCount }}
<span v-if="isOptimistic" style="font-size:10px">(同步中...)</span>
</button>
</template>
<script>
import { useOptimisticActionState } from '@/composables/useOptimisticActionState';
async function toggleLikeAPI(previousState, postId) {
await new Promise(resolve => setTimeout(resolve, 2000)); // 模拟慢网络
return {
liked: !previousState.liked,
likeCount: previousState.liked
? previousState.likeCount - 1
: previousState.likeCount + 1,
};
}
export default {
props: ['postId'],
setup(props) {
const { state, action, isPending, isOptimistic } = useOptimisticActionState(
toggleLikeAPI,
{ liked: false, likeCount: 42 },
{
// 乐观更新:立即反转 UI,不等服务器响应
optimisticUpdate: (currentState) => ({
liked: !currentState.liked,
likeCount: currentState.liked
? currentState.likeCount - 1
: currentState.likeCount + 1,
}),
}
);
function handleLike() {
action(props.postId);
}
return { state, isPending, isOptimistic, handleLike };
},
};
</script>
5.2 支持防抖(Debounce)
import { useActionState } from './useActionState';
/**
* 带防抖的 useActionState
*/
export function useDebouncedActionState(actionFn, initialState, delay = 300, options = {}) {
const { state, action, isPending, reset } = useActionState(
actionFn,
initialState,
options
);
let timer = null;
function debouncedAction(payload) {
return new Promise((resolve, reject) => {
if (timer) clearTimeout(timer);
timer = setTimeout(async () => {
try {
const result = await action(payload);
resolve(result);
} catch (err) {
reject(err);
}
}, delay);
});
}
function cancel() {
if (timer) {
clearTimeout(timer);
timer = null;
}
}
return {
state,
action: debouncedAction,
isPending,
reset,
cancel,
};
}
使用示例:搜索联想
<template>
<div>
<input
v-model="keyword"
@input="handleSearch"
placeholder="搜索..."
/>
<p v-if="isPending">搜索中...</p>
<ul>
<li v-for="item in state.results" :key="item.id">{{ item.name }}</li>
</ul>
</div>
</template>
<script>
import { ref, watch } from 'vue';
import { useDebouncedActionState } from '@/composables/useDebouncedActionState';
async function searchAPI(previousState, keyword) {
const res = await fetch(`/api/search?q=${keyword}`);
const data = await res.json();
return { results: data, keyword };
}
export default {
setup() {
const keyword = ref('');
const { state, action: search, isPending } = useDebouncedActionState(
searchAPI,
{ results: [], keyword: '' },
300
);
function handleSearch() {
if (keyword.value.trim()) {
search(keyword.value);
}
}
return { keyword, state, isPending, handleSearch };
},
};
</script>
六、React vs Vue 对照表
| 对比维度 | React 19 useActionState | Vue 2.7 useActionState(我们的实现) |
|---|---|---|
| 返回形式 | 数组 [state, action, isPending] | 对象 { state, action, isPending, reset } |
| state 响应性 | React 自动重渲染 | shallowRef + readonly 响应式 |
| 表单集成 | <form action={formAction}> 原生支持 | 需要 @submit + handleSubmit 封装 |
| previousState | ✅ actionFn 第一个参数 | ✅ 完全一致 |
| isPending | ✅ 内置 | ✅ 实现一致 |
| 竞态处理 | React Transition 内部处理 | 手动 actionVersion 计数 |
| 错误处理 | 通过返回值表达 | 同上 + 额外的 onError 回调 |
| reset 功能 | ❌ 无 | ✅ 增强功能 |
| 乐观更新 | 需配合 useOptimistic | 集成在 useOptimisticActionState 中 |
| SSR 支持 | permalink 参数 | 不支持(Vue 2.7 SSR 方案不同) |
| 防抖 | 需自行实现 | useDebouncedActionState 封装 |
七、完整目录结构
src/
├── composables/
│ ├── useActionState.js # 核心实现
│ ├── useFormActionState.js # 表单版本
│ ├── useOptimisticActionState.js # 乐观更新版本
│ └── useDebouncedActionState.js # 防抖版本
├── components/
│ ├── LoginForm.vue # 登录表单示例
│ ├── CommentList.vue # 加载更多示例
│ ├── ProductCard.vue # 购物车示例
│ └── TodoApp.vue # Todo综合示例
└── App.vue
八、总结
useActionState 的核心价值
以前(没有 useActionState):
3个 useState + 1个 try/catch + 手动管理loading = 15+ 行模板代码
现在(有 useActionState):
1个 useActionState = 1 行声明,全部搞定
核心设计理念
- 状态收敛:将
data、error、loading统一到一个 state 对象中 - previousState 驱动:action 函数接收上一次的状态,天然支持累积操作
- 声明式错误处理:通过返回值(而非 throw)表达操作结果
- 自动 pending 管理:开发者不再需要手动 set loading
在 Vue 2.7 中的移植要点
- 用
shallowRef模拟 React 的值比较更新策略 - 用
readonly模拟 state 的不可直接修改约束 - 用闭包 +
actionVersion实现竞态控制 - 用
FormData API+@submit模拟 React 的<form action={}> - 额外增加了
reset、onSuccess/onError、乐观更新、防抖等增强功能
这种从 React 到 Vue 的移植思路,不仅让你深入理解了两个框架的设计哲学差异,更重要的是掌握了跨框架抽象通用逻辑的能力——这才是高级前端工程师的核心竞争力。