React 19 Server Actions 接入 PHP 后端:实现无 API 化的全栈组件数据交互协议

React 19 Server Actions x PHP:逃离“传声筒”地狱,构建全栈组件的“一对一”爱恋

大家好,欢迎来到今天的“前端与后端的私奔”技术讲座。

我知道,我知道。你们心里可能在想:“React 和 PHP?这就像让周杰伦去唱京剧,或者让苏格拉底去写 Node.js 脚本。他们不是在同一个星球上的。”

别急。咱们把时间倒回两年前。那时的我们,还在为了一个简单的“点赞”功能,痛苦地在 fetch('/api/like')await res.json() 之间反复横跳。我们的前端在问:“我想要这个数据。”我们的后端在说:“我不认识你,我需要一个 API 端点。”我们写了一堆 axiosfetchaxios,感觉自己像个不知疲倦的邮递员,每天在两个服务器之间跑来跑去,传递着 JSON 字符串。

直到 React 19 出现了,它带来了 Server Actions。听起来像是什么魔法,对吧?React 说:“嘿,别再写 API 路由了,直接在组件里调用函数,数据就在那儿,像在客厅里一样。”

这听起来很美。但是,PHP 在哪儿?PHP 后端就像那个固执的老管家,守着他的 CRLF 换行符和 <?php ?> 标签,根本不懂 JavaScript 的异步世界。

今天,我们要干一件疯狂的事:用 PHP 实现 React 19 Server Actions 的协议,去掉那些烦人的中间人 API,让前端直接“降维打击”PHP 后端,实现真正的全栈组件化数据交互。

准备好了吗?系好安全带,我们要穿过数据序列化的雷区了。


第一部分:协议的本质——不是 API,是函数

首先,我们要搞清楚什么是“无 API 化”。

传统的 REST API 是基于资源的。你说 GET /users,意思是“给我用户这个资源”。你说 POST /users,意思是“创建这个资源”。

React 19 Server Actions 是基于函数的。你说 createUser(data),意思是“执行这个函数”。

所以,我们的核心挑战不是“写一个 API”,而是“让 PHP 模拟函数执行的环境”。

React 19 的 Server Action 调用,本质上是一个 HTTP POST 请求。客户端发送数据,服务器执行函数,返回结果(JSON 或 Redirect)。

我们的目标协议叫什么?RSC-Over-HTTP (React Server Components over HTTP)

这个协议的请求长这样:

{
  "action": "createPost",
  "payload": { "title": "Hello World", "content": "React loves PHP" },
  "headers": { ... }, // 认证信息、CSRF Token
  "formEntries": { ... } // 如果是 FormData,把表单转成对象
}

响应长这样:

{
  "type": "success",
  "data": { "id": 123, "title": "Hello World" },
  "revalidateTags": ["posts"]
}

没有 /api/createPost。只有 createPost。前端根本不知道有路由,它只知道有函数。


第二部分:PHP 后端——那个“虽然老但很猛”的执行器

现在,我们要在 PHP 里搭建一个“函数执行器”。我们需要一个路由器,它能根据收到的 action 字段,找到对应的 PHP 函数并执行它。

这听起来很像 FastAPI 或者 Laravel 的动作绑定,但我们要用原生 PHP 做一个轻量级的实现。

1. 定义函数注册表

首先,我们得有一个地方存放这些“动作”。PHP 是动态语言,我们可以把函数作为参数传进去。

// backend/ActionDispatcher.php
class ActionDispatcher {
    private array $actions = [];

    public function register(string $name, callable $callback): void {
        $this->actions[$name] = $callback;
    }

    public function dispatch(string $actionName, array $payload) {
        // 1. 安全检查(虽然很老套,但必须做)
        if (!isset($this->actions[$actionName])) {
            throw new RuntimeException("Action not found: $actionName");
        }

        // 2. 执行函数
        try {
            $result = call_user_func($this->actions[$actionName], $payload);
            return $this->formatSuccessResponse($result);
        } catch (Throwable $e) {
            return $this->formatErrorResponse($e);
        }
    }

    private function formatSuccessResponse($data) {
        // React 期望的 JSON 结构
        return json_encode([
            'type' => 'success',
            'data' => $data
        ]);
    }

    private function formatErrorResponse(Throwable $e) {
        return json_encode([
            'type' => 'error',
            'message' => $e->getMessage(),
            'code' => $e->getCode()
        ]);
    }
}

2. 注册业务逻辑

现在,我们要把具体的业务逻辑挂载到这个调度器上。比如,我们要创建一个博客文章。

// backend/index.php
require_once 'ActionDispatcher.php';

$dispatcher = new ActionDispatcher();

// 注册创建文章动作
$dispatcher->register('createPost', function($data) {
    // 模拟数据库插入
    $db = new PDO('sqlite:blog.db');
    $stmt = $db->prepare('INSERT INTO posts (title, content) VALUES (?, ?)');
    $stmt->execute([$data['title'], $data['content']]);

    return ['id' => $db->lastInsertId(), 'title' => $data['title']];
});

// 注册删除文章动作
$dispatcher->register('deletePost', function($data) {
    // 验证权限,删除数据...
    return ['message' => 'Deleted'];
});

// 处理 HTTP 请求
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
    header('Content-Type: application/json');
    echo $dispatcher->dispatch($_POST['action'], $_POST);
    exit;
}

这看起来很简单,对吧?但是等等,这太脆弱了。React 19 的 Server Actions 有个杀手锏:数据验证。React 用 Zod,PHP 怎么办?

3. 引入 Zod:前后端对齐的神器

既然我们要“无 API 化”,那我们前端验证了什么,后端就得验证什么。最完美的方案是共享验证逻辑

在 PHP 中,我们需要引入 webmozart/json-schema 或者直接引入 vlucas/phpdotenv。但为了演示,我们模拟一个简单的验证类。

我们希望前端发送的数据必须包含 title 且不为空。

class Validator {
    public static function validate(array $data, array $rules) {
        $errors = [];
        foreach ($rules as $field => $rule) {
            if (empty($data[$field])) {
                $errors[] = "$field is required";
            }
            // 这里可以扩展更复杂的验证,比如 Zod 的 JS 库有 PHP 移植版 php-zod
        }
        if (!empty($errors)) {
            throw new InvalidArgumentException(json_encode($errors));
        }
        return $data;
    }
}

修改我们的注册逻辑:

$dispatcher->register('createPost', function($data) {
    // 在执行前先验证
    $validated = Validator::validate($data, ['title', 'content']);

    // 真正的业务逻辑
    // ...

    return ['status' => 'ok'];
});

现在,PHP 后端准备好了。它看起来像是一个魔法盒,你扔进去一个 action 字符串和一堆数据,它就吐出一个 JSON。


第三部分:React 19 前端——那一层优雅的“抽象”

好了,后端是一堆乱糟糟的 PHP 代码。现在我们怎么在 React 组件里用?我们不能写 fetch('backend.php', ...)。那太丑了。

我们需要一个 Hook,一个类似于 React 19 内置 useServerAction 的自定义 Hook。虽然 React 19 默认对接 Node.js,但我们可以伪造它。

1. 封装 useServerAction

我们需要创建一个 Client Component,因为它需要直接发起 HTTP 请求。

// components/ServerActions.tsx
'use client';

import { useState } from 'react';

// 定义一个通用的 Action 函数类型
type ServerAction<TInput, TOutput> = (input: TInput) => Promise<TOutput>;

// 定义我们的 Hook
export function useServerAction<TInput, TOutput>(
  action: ServerAction<TInput, TOutput>
) {
  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const execute = async (input: TInput) => {
    setIsPending(true);
    setError(null);
    try {
      // 关键步骤:构造请求数据
      // 我们需要把函数名(或者标识符)和参数打包
      const response = await fetch('/api/server-action-handler', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          action: 'createPost', // 这里硬编码或者从函数名推导
          payload: input,
        }),
      });

      if (!response.ok) {
        throw new Error(`Server Error: ${response.statusText}`);
      }

      const result = await response.json();

      if (result.type === 'error') {
        throw new Error(result.message);
      }

      return result.data; // 返回服务器处理后的数据
    } catch (err) {
      setError(err as Error);
      throw err;
    } finally {
      setIsPending(false);
    }
  };

  return { execute, isPending, error };
}

2. 组件中的使用

现在,我们的 UI 组件可以干净得一尘不染。

// app/CreatePostForm.tsx
'use client';

import { useServerAction } from './components/ServerActions';
import { createPostAction } from './actions'; // 这是个假的函数,只是为了 TS 类型检查

// 假设我们定义了一个接口
interface CreatePostInput {
  title: string;
  content: string;
}

// 定义真正的实现(用于类型检查,实际逻辑在 hook 里)
const createPostAction: ServerAction<CreatePostInput, any> = async (data) => {
  // 这里不需要写 fetch,我们把类型定义提出来
  return data; 
};

export default function CreatePostForm() {
  const { execute, isPending } = useServerAction(createPostAction);

  return (
    <form 
      onSubmit={async (e) => {
        e.preventDefault();
        const formData = new FormData(e.currentTarget);
        await execute({
          title: formData.get('title') as string,
          content: formData.get('content') as string,
        });
        alert('Post created successfully!');
      }}
    >
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" required />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Submit'}
      </button>
    </form>
  );
}

看!没有 API 端点配置,没有 API 路由定义。前端只是想“创建一篇文章”,然后直接传给了后端。这就是“无 API 化”。


第四部分:进阶——流式传输与重定向

上面那个方案是“干巴巴”的 JSON 交互。React 19 Server Actions 的真正强大之处在于流式传输重定向

在 PHP 中,我们怎么处理流?

PHP 早就支持了。header('Content-Type: text/event-stream')

1. 模拟流式输出

假设我们的后端处理需要几秒钟(比如生成一个复杂的 PDF 或处理 AI)。

// backend/index.php
// ... Dispatcher 代码 ...

$dispatcher->register('generateReport', function($data) {
    // 开启输出缓冲
    ob_start();

    // 开始流
    header('Content-Type: text/event-stream');
    header('Cache-Control: no-cache');
    header('Connection: keep-alive');

    // 模拟进度
    for ($i = 0; $i <= 100; $i += 10) {
        echo "data: " . json_encode(['progress' => $i]) . "nn";
        ob_flush();
        flush();
        sleep(1);
    }

    // 结束
    echo "data: " . json_encode(['status' => 'done', 'file' => 'report.pdf']) . "nn";

    // 我们不能直接 return,因为流已经开始了
    // React 的 RSC 消费器会等待流结束
});

React 客户端怎么收?

我们需要修改我们的 useServerAction Hook,增加对 response.body 的读取能力。

export function useServerAction<TInput, TOutput>(action) {
  // ... 前面的代码 ...

  const execute = async (input: TInput) => {
    const response = await fetch('/api/server-action-handler', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ action: 'generateReport', payload: input }),
    });

    const reader = response.body?.getReader();
    const decoder = new TextDecoder();

    while (true) {
      const { done, value } = await reader!.read();
      if (done) break;

      const chunk = decoder.decode(value);
      // 处理 Server-Sent Events (SSE) 数据
      const lines = chunk.split('n');
      for (const line of lines) {
        if (line.startsWith('data: ')) {
          const data = JSON.parse(line.substring(6));
          // 这里可以更新一个 state,实时显示进度条
          console.log('Progress:', data.progress); 
        }
      }
    }

    // 最后读取 JSON 响应体作为最终结果
    const finalJson = await response.json();
    return finalJson.data;
  };

  return { execute };
}

2. 重定向 (Redirect)

这是最“反直觉”的一点。Server Action 返回一个对象,里面包含 redirect: '/dashboard',然后浏览器自动跳转。

在 PHP 中,我们直接输出重定向头就行。

$dispatcher->register('login', function($data) {
    // 验证用户
    if ($data['password'] === 'secret') {
        // PHP 发起重定向
        header('Location: /dashboard');
        exit; // 必须退出,否则会输出 JSON
    }
    throw new Exception('Invalid password');
});

React 的 useActionState 会自动检测这个重定向头(或者更准确地说,响应状态码是 302),并执行页面跳转。


第五部分:状态管理与缓存——数据一致性

如果我们有 10 个 Server Actions,它们之间怎么互相调用?比如,createPost 之后,需要更新 sidebar 组件里的文章列表。

传统 API 需要你手动 refetch('/api/posts')。Server Actions 原生支持 Revalidation

1. 标签系统

我们给数据打上标签。

PHP 端:

$dispatcher->register('createPost', function($data) {
    // ... 保存数据 ...
    // 返回一个包含 tag 的数据结构
    return [
        'id' => 1,
        'tags' => ['posts', 'latest'] // 告诉前端,'posts' 这个标签脏了,下次请求要重新缓存
    ];
});

2. React 端的 Revalidation

我们需要一个 useRevalidator hook。

export function useRevalidator() {
  const [, forceUpdate] = useReducer(x => x + 1, 0);

  const revalidate = () => forceUpdate();

  return { revalidate };
}

// 在组件中使用
export default function Dashboard() {
  const { execute, isPending } = useServerAction(createPostAction);
  const { revalidate } = useRevalidator();

  const handleSubmit = async (data) => {
    await execute(data);
    revalidate(); // 强制重新获取依赖 'posts' 标签的数据
  };

  // ...
}

这实现了乐观 UI 的基础。当 createPost 成功返回后,我们不需要手动去请求列表,只要告诉 React “把 posts 相关的数据刷新一下”,React 会自动去寻找相关的 Server Component 并重新渲染。


第六部分:安全性——别让黑帽黑客钻空子

Server Actions 看起来很美,但如果你直接在 URL 里暴露这些函数,任何人都可以发送 POST 请求来调用它们。我们需要认证。

1. 请求签名

React 19 Server Actions 有一个机制,它会在请求体里加一个签名,客户端无法伪造,除非你有私钥。

在 PHP 中,我们模拟这个。

$dispatcher->register('deletePost', function($data) {
    // 1. 检查 Headers 里的 Authorization
    // 2. 验证 CSRF Token
    // 3. 检查用户是否是 Admin
    if (!isset($_SERVER['HTTP_X_USER_ID'])) {
        throw new Exception('Unauthorized');
    }
    return ['deleted' => true];
});

在我们的 React Hook 里,我们需要自动把 Cookies 和 Headers 传过去。

const execute = async (input: TInput) => {
  const response = await fetch('/api/server-action-handler', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Cookie': document.cookie, // 自动携带
      // 'X-CSRF-Token': await getCsrfToken() // 模拟
    },
    body: JSON.stringify({ action: 'deletePost', payload: input }),
  });
  // ...
};

第七部分:实战演示——构建一个全栈留言板

让我们把所有东西串起来。一个简单的留言板,用户可以发帖,帖子会实时出现在列表里。

后端 (PHP):

<?php
// entry.php
session_start();
require_once 'ActionDispatcher.php';

$dispatcher = new ActionDispatcher();

// 模拟数据库
$posts = [];

$dispatcher->register('addPost', function($data) use (&$posts) {
    $newPost = [
        'id' => uniqid(),
        'name' => $data['name'],
        'message' => $data['message'],
        'created_at' => date('Y-m-d H:i:s')
    ];
    array_unshift($posts, $newPost); // 最新在最前
    return ['status' => 'success', 'count' => count($posts)];
});

// 获取列表(这是 Server Component,不是 Action)
$dispatcher->register('getPosts', function() use (&$posts) {
    return $posts;
});

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
    header('Content-Type: application/json');
    echo $dispatcher->dispatch($_POST['action'], $_POST);
    exit;
}

// 如果是 GET 请求,返回 HTML 页面(模拟 RSC 的 HTML Stream)
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
    $postsData = $dispatcher->dispatch('getPosts', []);
    // 这里应该有一个模板引擎渲染 HTML,但为了演示,我们返回一个简单的 HTML
    echo <<<HTML
    <!DOCTYPE html>
    <html>
    <head><title>React-PHP Love</title></head>
    <body>
        <h1>Guestbook</h1>
        <div id="app">Loading...</div>
        <script src="/bundle.js"></script>
    </body>
    </html>
    HTML;
}

前端 (React):

// actions.ts
import { useServerAction } from './components/ServerActions';

export const addPostAction = (data: { name: string; message: string }) => data;

// components/Guestbook.tsx
'use client';
import { useServerAction } from './ServerActions';
import { useState } from 'react';

export function Guestbook() {
  const { execute: addPost } = useServerAction(addPostAction);
  const [posts, setPosts] = useState<any[]>([]);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget as HTMLFormElement);
    await addPost({
      name: formData.get('name') as string,
      message: formData.get('message') as string,
    });
    // 成功后刷新列表(模拟)
    fetch('/entry.php?action=getPosts') 
      .then(r => r.json())
      .then(data => setPosts(data)); // 注意:这是演示,生产环境应使用 revalidate
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input name="name" placeholder="Name" required />
        <textarea name="message" placeholder="Message" required />
        <button type="submit">Post</button>
      </form>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.name}: {post.message}</li>
        ))}
      </ul>
    </div>
  );
}

第八部分:总结与展望——拥抱混乱

好了,各位。

我们刚刚做了一件极其疯狂的事。我们抛弃了 RESTful 的优雅(其实 REST 也很累人),抛弃了 GraphQL 的复杂度,直接把后端变成了函数的集合。

React 19 Server Actions 接入 PHP 的核心逻辑就是:

  1. 前端: 定义一系列函数类型。useServerAction 捕获这些函数的元数据(名称、参数类型),将其序列化为 HTTP POST 请求发送给后端。
  2. 后端: 拥有一个全局的 ActionDispatcher。它监听所有请求,根据请求体中的 action 字段,动态调用注册在里面的 PHP 回调函数。
  3. 交互: PHP 执行函数,验证数据,处理业务逻辑,最后输出 JSON 或流。

这种方式解决了什么问题?
它消除了数据契约的维护成本。在 REST API 里,你改了一个字段名,前端报错,后端报错,API 文档过期。在 Server Actions 里,类型系统(TypeScript + PHP Zod)是你唯一的真理来源。

它带来了什么体验?
它带来了交互的流畅性。表单提交不需要等待漫长的网络往返,数据直接在组件内部流动。

当然,这也有代价。你需要维护一个巨大的 PHP 路由表,你失去了 REST 风格的资源层级带来的直观感,你可能需要处理更多的边缘情况(比如 PHP 的超时设置、输出缓冲问题)。

但是,看着那些不再被 axios 污染的代码,看着那些像调用本地函数一样调用远程服务器的流畅感,你会觉得,这一切都是值得的。

这就是全栈的未来。没有 API,只有数据。没有前端后端,只有组件。

祝大家编码愉快!愿你们的 PHP 配置永远不会因为内存溢出而崩溃,愿你们的 React 渲染永远如丝般顺滑!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注