React 19 useActionState 深度解析 & Vue 2.7 移植实战

React 19 useActionState 深度解析 & Vue 2.7 移植实战

一、useActionState 是什么?

useActionState 是 React 19 新增的 Hook,专门用于处理表单提交/异步操作的状态管理。它将「异步操作」「loading 状态」「错误处理」「结果数据」统一收敛到一个 Hook 中。

前身是 useFormState(React Canary),在 React 19 正式版中重命名为 useActionState

1.1 解决了什么痛点?

没有 useActionState 之前,处理一个表单提交你需要:

jsx 复制代码
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 函数签名

typescript 复制代码
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 基础用法:表单提交

jsx 复制代码
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 不仅限于表单,任何异步操作都可以用:

jsx 复制代码
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,这非常适合做列表追加:

jsx 复制代码
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 配合使用(完整表单方案)

jsx 复制代码
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 的内部原理(简化)

ini 复制代码
调用 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]

本质useActionStateuseReducer + useTransition 的封装,用 transition 包裹异步操作来自动管理 pending 状态。


四、在 Vue 2.7.15 中实现 useActionState

Vue 2.7 引入了 Composition API(refcomputedwatch 等),这让我们有能力实现类似的功能。

4.1 核心实现

创建文件 src/composables/useActionState.js

javascript 复制代码
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

javascript 复制代码
const state = shallowRef(initialState);

ref 会对对象进行深度响应式转换 (递归 Proxy),如果 state 是一个包含大量数据的对象(如列表数据),会有性能开销。shallowRef 只对 .value 本身做响应式,赋新值时才触发更新------和 React 的 useState(引用比较)行为一致。

竞态处理是什么意思?

javascript 复制代码
let actionVersion = 0;

async function action(payload) {
  const currentVersion = ++actionVersion;
  // ... await 异步操作
  if (currentVersion !== actionVersion) return; // 不是最新的,丢弃
}

场景:用户快速点击了3次提交按钮,发出了3个请求。第1个请求最慢,第3个最快。如果不做竞态处理,最终 state 会被第1个请求的结果覆盖(而不是第3个),导致数据不一致。

为什么返回 readonly

javascript 复制代码
return {
  state: readonly(state),
  isPending: readonly(isPending),
};

模拟 React 中 state 不可直接修改的约束------状态只能通过 action 函数更新,不能在外部直接 state.value = xxx


4.3 支持表单的增强版本

为了更好地配合 Vue 的表单处理,我们增加一个专门处理表单提交的包装:

创建文件 src/composables/useFormActionState.js

javascript 复制代码
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

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

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>
相关推荐
远山枫谷2 小时前
Vue2 vs Vue3 全面对比(含代码示例+迁移指南)
前端·vue.js
z止于至善2 小时前
服务器发送事件(SSE):前端实时通信的轻量解决方案
前端·web·服务器通信
小小小小宇2 小时前
React useState 深度源码原理解析
前端
前端小棒槌2 小时前
TypeScript 核心知识点
前端
Selicens2 小时前
turbo迁移vite+(vite-plus)实践
前端·javascript·vite
答案answer2 小时前
我的Three.js3D场景编辑器免费开源啦🎉🎉🎉
前端·github·three.js
欧阳天羲2 小时前
AI 时代前端工程师发展路线
前端·人工智能·状态模式
Moment2 小时前
从爆红到被嫌弃,MCP 为什么开始失宠了
前端·后端·面试
code202 小时前
microapp 通过链接区分主子应用步骤
前端