Remix

技术栈
前端框架
reactfullstackweb-standardsloader-actionshopify

概览

Remix 技术栈概览

Remix 是由 React Router 团队创建的全栈 Web 框架,后被 Shopify 收购。它基于 Web 标准(Fetch API、FormData),通过 loader/action 模式实现服务端渲染和数据变更,无需手动管理状态。

Remix 是什么?

  • 基于 React Router 的全栈框架
  • Loader(数据加载)+ Action(数据变更)+ 自动重新验证
  • 拥抱 Web 标准:Request / Response / FormData
  • 嵌套路由 + 并行数据加载

解决什么问题?

  • React 全栈应用的状态管理复杂度
  • 表单提交和乐观更新
  • 需要 SSR + SPA 混合体验
  • 毕设中需要全栈能力但不想同时学前后端两套框架

关键特性:

  • loader / action / useLoaderData / useActionData
  • 嵌套路由 + Outlet
  • Error Boundary 内置
  • 表单不需 useState(直接用 FormData)
  • 部署灵活:Edge / Node / Cloudflare

安装

环境准备

  • 操作系统:macOS / Linux / Windows(WSL 推荐)
  • Node.js:>= 18.x(推荐 20 LTS)
  • 包管理器:npm / yarn / pnpm
  • 编辑器:VS Code(推荐安装 ESLint 和 Prettier 插件)

安装命令

创建新项目

# 使用官方脚手架
npx create-remix@latest

# 交互式选择:
# ┌  Where would you like to create your app?
# │  ./my-remix-app
# ├  What type of app do you want to create?
# │  ● Remix App Server (推荐入门)
# │  ○ Express Server
# ├  TypeScript or JavaScript?
# │  ● TypeScript
# ├  Do you want me to run `npm install`?
# │  ● Yes

使用特定模板

# Vite + Remix(最新推荐)
npx create-remix@latest --template remix-run/remix/templates/vite

# 带 Tailwind CSS
npx create-remix@latest --template remix-run/remix/templates/remix-tutorial

现有项目手动安装

npm install @remix-run/node @remix-run/react @remix-run/serve
npm install -D @remix-run/dev

常见安装问题

1. 端口被占用

  • 现象Error: listen EADDRINUSE :::3000
  • 解决npx remix dev --port 3001 或修改 package.json 中 dev 脚本

2. TypeScript 配置报错

  • 解决:确保 tsconfig.json 包含 "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"]

3. 旧版 Remix(1.x)迁移到 2.x

  • 关键变化server.jscreateRequestHandler 改为 createRemixRequestHandler@remix-run/vercel 等适配器也需升级

4. Windows 上 npx create-remix 失败

  • 解决:使用 WSL 或 PowerShell 管理员模式;确保 Node.js 路径不包含空格

示例

Remix Hello World — 博客首页

目标

创建一个最简单的 Remix 博客首页,展示 loader 数据加载和页面渲染。

完整代码

路由文件 app/routes/_index.tsx

import { json } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";

// 模拟数据
const posts = [
  { id: 1, title: "Remix 入门指南", author: "张三", date: "2024-01-15" },
  { id: 2, title: "为什么选择 Remix", author: "李四", date: "2024-02-20" },
  { id: 3, title: "Web 标准的力量", author: "王五", date: "2024-03-10" },
];

// loader 在服务端运行,为组件提供数据
export function loader() {
  return json({ posts });
}

export default function Index() {
  const { posts } = useLoaderData<typeof loader>();

  return (
    <main style={mainStyle}>
      <h1>📝 我的博客</h1>
      <p style={{ color: "#666" }}>基于 Remix 构建 — 拥抱 Web 标准</p>

      <ul style={{ listStyle: "none", padding: 0 }}>
        {posts.map((post) => (
          <li key={post.id} style={cardStyle}>
            <Link to={`/post/${post.id}`} style={linkStyle}>
              {post.title}
            </Link>
            <div style={{ fontSize: "0.85rem", color: "#888", marginTop: 4 }}>
              {post.author} · {post.date}
            </div>
          </li>
        ))}
      </ul>
    </main>
  );
}

const mainStyle: React.CSSProperties = {
  maxWidth: 640, margin: "60px auto", padding: "0 16px",
  fontFamily: "-apple-system, 'Microsoft YaHei', sans-serif",
};

const cardStyle: React.CSSProperties = {
  padding: "16px 0",
  borderBottom: "1px solid #eee",
};

const linkStyle: React.CSSProperties = {
  color: "#6366f1", textDecoration: "none",
  fontSize: "1.1rem", fontWeight: 600,
};

根布局 app/root.tsx

import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";

export default function App() {
  return (
    <html lang="zh-CN">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

文章详情页 app/routes/post.$postId.tsx

import { json } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";

const posts = {
  "1": { title: "Remix 入门指南", content: "Remix 是基于 Web 标准的全栈框架...", author: "张三" },
  "2": { title: "为什么选择 Remix", content: "嵌套路由、数据加载、表单处理...", author: "李四" },
  "3": { title: "Web 标准的力量", content: "Fetch API、FormData、Response...", author: "王五" },
};

export function loader({ params }: { params: { postId: string } }) {
  const post = posts[params.postId as keyof typeof posts];
  if (!post) throw new Response("Not Found", { status: 404 });
  return json({ post });
}

export default function Post() {
  const { post } = useLoaderData<typeof loader>();

  return (
    <main style={{ maxWidth: 640, margin: "60px auto", padding: "0 16px" }}>
      <Link to="/" style={{ color: "#6366f1" }}>← 返回首页</Link>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <p style={{ color: "#888" }}>作者:{post.author}</p>
    </main>
  );
}

运行步骤

npm run dev
# 访问 http://localhost:3000

预期输出

  • 首页显示 3 篇文章列表
  • 点击文章标题进入详情页
  • 详情页有返回链接
  • 所有页面均为服务端渲染(查看源代码可见 HTML)

Remix Loader/Action 联系人管理

目标

展示 Remix 核心模式:loader 加载数据、action 处理表单、无需 useState 管理状态。

完整代码

app/routes/_index.tsx

import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from '@remix-run/node';
import { Form, useLoaderData, useActionData, useNavigation } from '@remix-run/react';

type Contact = { id: number; name: string; phone: string; email: string };
let contacts: Contact[] = [
  { id: 1, name: '张三', phone: '13800138000', email: 'zs@example.com' },
  { id: 2, name: '李四', phone: '13900139000', email: 'ls@example.com' },
];
let nextId = 3;

export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const q = url.searchParams.get('q') || '';
  const filtered = q ? contacts.filter(c => c.name.includes(q) || c.phone.includes(q)) : contacts;
  return json({ contacts: filtered, search: q });
}

export async function action({ request }: ActionFunctionArgs) {
  const fd = await request.formData();
  const intent = fd.get('_intent');

  if (intent === 'delete') {
    const id = Number(fd.get('id'));
    contacts = contacts.filter(c => c.id !== id);
    return json({ ok: true, msg: '已删除' });
  }

  if (intent === 'add') {
    const name = fd.get('name') as string;
    const phone = fd.get('phone') as string;
    const email = (fd.get('email') as string) || '';
    if (!name || !phone) {
      return json({ error: '姓名和电话为必填项' }, { status: 400 });
    }
    contacts.push({ id: nextId++, name, phone, email });
    return json({ ok: true, msg: '添加成功' });
  }
  return null;
}

export default function Index() {
  const { contacts: list, search } = useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();
  const nav = useNavigation();
  const busy = nav.state === 'submitting';

  return (
    <main style={{ maxWidth: 600, margin: '40px auto', fontFamily: 'system-ui' }}>
      <h1>📞 联系人管理</h1>

      <Form method="get" style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
        <input type="text" name="q" defaultValue={search} placeholder="搜索..." style={inputStyle} />
        <button type="submit" style={btnStyle}>搜索</button>
      </Form>

      {actionData?.ok && <p style={{ color: 'green' }}>{actionData.msg}</p>}
      {actionData?.error && <p style={{ color: 'red' }}>{actionData.error}</p>}

      <Form method="post" style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
        <input type="hidden" name="_intent" value="add" />
        <input name="name" placeholder="姓名*" required style={inputStyle} />
        <input name="phone" placeholder="电话*" required style={inputStyle} />
        <input name="email" placeholder="邮箱" style={inputStyle} />
        <button type="submit" disabled={busy} style={btnPrimaryStyle}>
          {busy ? '...' : '添加'}
        </button>
      </Form>

      <div>
        {list.map(c => (
          <div key={c.id} style={{ display: 'flex', justifyContent: 'space-between', padding: 12, background: 'white', borderRadius: 8, marginBottom: 8, boxShadow: '0 1px 3px rgba(0,0,0,.1)' }}>
            <div>
              <strong>{c.name}</strong>
              <div style={{ color: '#666', fontSize: '0.9em' }}>{c.phone} · {c.email}</div>
            </div>
            <Form method="post">
              <input type="hidden" name="_intent" value="delete" />
              <input type="hidden" name="id" value={c.id} />
              <button type="submit" style={{ background: '#ef4444', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>删除</button>
            </Form>
          </div>
        ))}
      </div>
    </main>
  );
}

const inputStyle = { flex: 1, padding: 8, border: '1px solid #ddd', borderRadius: 4 };
const btnStyle = { padding: '8px 16px', background: '#6b7280', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' };
const btnPrimaryStyle = { padding: '8px 16px', background: '#3b82f6', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' };

运行步骤

npx create-remix@latest remix-contacts
cd remix-contacts
npm run dev

预期输出

  • 页面显示预设联系人,搜索框实时过滤
  • 填写表单添加联系人,提交后自动刷新(无需手动 setState)
  • 删除按钮直接提交 Form,Remix 自动重新验证 loader

教程

Remix 入门教程:全栈 Web 应用开发

一、Remix 是什么?

Remix 是一个基于 Web 标准的全栈框架,核心理念是"浏览器就是你的运行时"。它由 React Router 团队创建,后被 Shopify 收购。与 Next.js 不同,Remix 拥抱 window.fetchFormDataResponse 等原生 API,让你写更少的"框架魔法代码"。

Remix 的核心优势

  • 嵌套路由:页面自然分层,并行加载数据,无需手动管理 loading
  • Mutation 即表单:用 HTML <form> 做数据变更,无需 useState + fetch
  • 错误边界:每层路由都有独立 error boundary,一个模块崩溃不影响其他
  • 渐进增强:禁用 JS 时,表单仍能提交(传统 HTML 方式)

二、路由系统

Remix 使用文件系统路由,app/routes/ 下的文件对应 URL:

app/routes/
├── _index.tsx          → /
├── about.tsx           → /about
├── posts.$postId.tsx   → /posts/:postId(动态参数)
├── admin/
│   ├── _index.tsx      → /admin
│   └── users.tsx       → /admin/users
└── _layout.tsx         → 包裹子路由的布局(不参与 URL)

关键约定

文件名 URL 说明
_index.tsx 父路径的 index 下划线表示"不参与 URL"
$param.tsx 动态段 $ 前缀表示动态参数
_.tsx 通配 捕获所有剩余路径

三、数据加载(Loader)

Loader 只在服务端运行,组件渲染前数据已就绪:

// app/routes/products.tsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export async function loader({ request, params }) {
  const url = new URL(request.url);
  const page = url.searchParams.get("page") || "1";

  const products = await db.product.findMany({
    skip: (Number(page) - 1) * 20,
    take: 20,
  });

  return json({ products, page });
}

export default function Products() {
  const { products, page } = useLoaderData<typeof loader>();
  // 数据已就绪,不会出现 loading 闪烁
  return <ProductGrid items={products} />;
}

四、数据变更(Action)

用原生 <form> 标签,无需 JavaScript:

import { redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";

export async function action({ request }) {
  const formData = await request.formData();
  const title = formData.get("title");
  const content = formData.get("content");

  // 服务端校验
  if (!title || title.length < 3) {
    return json({ error: "标题至少3个字符" }, { status: 400 });
  }

  await db.post.create({ data: { title, content } });
  return redirect("/posts");
}

export default function NewPost() {
  const actionData = useActionData<typeof action>();

  return (
    <Form method="post">
      <input name="title" placeholder="标题" />
      {actionData?.error && <p style={{color:'red'}}>{actionData.error}</p>}
      <textarea name="content" placeholder="内容" />
      <button type="submit">发布</button>
    </Form>
  );
}

五、嵌套布局与 Outlet

// app/routes/_layout.tsx
export default function Layout() {
  return (
    <div>
      <Navbar />       {/* 所有子路由共享 */}
      <Sidebar />
      <main>
        <Outlet />     {/* 子路由内容在这里渲染 */}
      </main>
    </div>
  );
}

六、错误处理

// 每个路由都可以导出 ErrorBoundary
export function ErrorBoundary() {
  const error = useRouteError();

  return (
    <div style={{ textAlign: "center", padding: 40 }}>
      <h1>出错了</h1>
      <p>{error.message}</p>
      <Link to="/">回到首页</Link>
    </div>
  );
}

七、最佳实践

  1. 充分利用表单——不要手动 fetch + useState 做 CRUD
  2. 善用嵌套路由——避免全局 loading,每个模块独立加载
  3. SEO 优化:Remix 默认 SSR,用 meta 导出函数设置 <title>
  4. 环境变量通过 process.env 在 loader/action 中访问,不会泄漏到客户端

八、思考题

  1. Remix 的 loader 和 React 的 useEffect 数据加载有什么本质区别?
  2. 为什么 Remix 推荐用 HTML <form> 而不是 onClick + fetch 做数据变更?
  3. 嵌套路由中,子路由报错如何不影响父布局?

Remix 全栈开发思维入门

背景

Remix 是 React Router 团队打造的全栈 Web 框架,后被 Shopify 收购。它的核心理念是"拥抱 Web 标准"——用原生 FormData、Request/Response 替代复杂的状态管理。如果你已学 React 但觉得全栈开发割裂(前后端分离需要两套心智模型),Remix 会让你眼前一亮。


核心概念

Loader + Action 模式

这是 Remix 最核心的设计:

用户请求 /students
        ↓
    loader() 在服务端获取数据
        ↓
    渲染组件(数据通过 useLoaderData 获取)
        ↓
    用户提交表单
        ↓
    action() 在服务端处理数据
        ↓
    Remix 自动重新执行所有 loader() 获取最新数据
        ↓
    组件自动更新

对比传统模式

// 传统 React SPA
const [data, setData] = useState([]);
useEffect(() => { fetch('/api/students').then(r => r.json()).then(setData); }, []);
const handleSubmit = async (e) => {
  e.preventDefault();
  await fetch('/api/students', { method: 'POST', body: JSON.stringify(form) });
  // 手动重新获取数据...
};

// Remix
export async function loader() { return json(await db.students.findMany()); }
export async function action({ request }) {
  const formData = await request.formData();
  await db.students.create({ data: Object.fromEntries(formData) });
  return null;  // Remix 自动重新验证 loader
}
export default function Page() {
  const data = useLoaderData<typeof loader>();  // 数据直接用,不用 useEffect!
  return <Form method="post">...</Form>;       // 提交不用 e.preventDefault()!
}

分步操作

第一步:创建项目

npx create-remix@latest my-app
cd my-app
npm run dev

第二步:理解文件系统路由

app/routes/
├── _index.tsx        → /
├── about.tsx          → /about
├── students.tsx       → /students
├── students.$id.tsx   → /students/123
└── admin._index.tsx   → /admin

第三步:编写 loader

// app/routes/students.tsx
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';

export async function loader() {
  const students = [
    { id: 1, name: '张三', grade: 89 },
    { id: 2, name: '李四', grade: 92 },
  ];
  return json({ students });
}

export default function Students() {
  const { students } = useLoaderData<typeof loader>();
  return (
    <ul>
      {students.map(s => <li key={s.id}>{s.name} - {s.grade}分</li>)}
    </ul>
  );
}

第四步:编写 action

import { Form, useActionData } from '@remix-run/react';

export async function action({ request }) {
  const fd = await request.formData();
  const name = fd.get('name');
  if (!name) return json({ error: '姓名必填' }, { status: 400 });
  // 存入数据库...
  return json({ ok: true });
}

export default function AddStudent() {
  const actionData = useActionData<typeof action>();
  return (
    <Form method="post">
      {actionData?.error && <p style={{color:'red'}}>{actionData.error}</p>}
      <input name="name" placeholder="姓名" />
      <button type="submit">添加</button>
    </Form>
  );
}

毕设推荐场景

管理系统

Remix 的表单处理天然适合 CRUD 管理后台——不用写一行 useEffect 和 useState。

毕设全栈

一个项目搞定前后端,不用单独写 REST API,loader/action 就是接口。

部署到 Edge

Remix 支持 Cloudflare Workers / Vercel Edge,全球秒开。


思考题

  1. Remix 的 action 提交后为什么不需要手动刷新数据?
  2. Form 组件和原生 <form> 有什么区别?
  3. 如果不用 Remix,用 React + Express 实现同样的 loader/action 模式需要多少额外代码?

小结

Remix 重新定义了 React 全栈开发——用 Web 标准替代状态管理,用 loader/action 替代 REST API。如果你追求开发效率和代码优雅度,Remix 会让你的毕设代码比同学少写 50%。

参考资料

  1. [1] Remix Team / Shopify. Remix Documentation. 2024. https://remix.run/docs
  2. [2] Ryan Florence. Rethinking Web Application Design with Remix. 2022.
  3. [3] Michael Jackson. Web Standards for Web Apps. 2021.
  4. [4] Remix 团队. Remix 官方文档. 2024. https://remix.run/docs/en/main
  5. [5] Remix 团队. Remix 教程(30分钟上手). 2024. https://remix.run/docs/en/main/start/tutorial
  6. [6] Ryan Florence. Remix 哲学(Ryan Florence). 2023. https://remix.run/blog/remix-philosophy