编程 Remix 3.0 深度拆解:当框架决定把 React 运行时整个删掉——Preact 分支、命令式模型与 Web 标准优先的架构手术

2026-08-09 07:42:46 +0800 CST views 8

Remix 3.0 深度拆解:当框架决定把 React 运行时整个删掉——Preact 分支、命令式模型与 Web 标准优先的架构手术

2026年8月,Remix 团队发布了 Remix 3.0 beta 预览版。这不是一次常规的大版本升级——而是一场彻头彻尾的架构重写:Remix 不再使用 React

这个决定震惊了整个前端社区。Remix 从诞生之初就深度绑定 React,由 React Router 团队打造,被认为是 Next.js 最有力的竞争者之一。而现在,它选择了"叛变"——移除 React 运行时,转向一个基于 Preact 的分支版本,并采用命令式编程模型。

本文将从架构设计、技术决策、代码实战、迁移路径、生态影响五个维度,深度拆解这次前端史上最激进的框架重写。


一、背景:为什么 Remix 要"杀死" React?

1.1 Remix 的前世今生

Remix 由 Michael Jackson 和 Ryan Florence 于 2020 年创立,两人正是 React Router 的作者。Remix 的核心理念是:

  • Web 标准优先:充分利用 HTTP、表单、URL 等 Web 原生能力
  • 服务端渲染(SSR):默认 SSR,优化首屏加载和 SEO
  • 嵌套路由:继承 React Router 的嵌套路由设计
  • 渐进增强:支持无 JavaScript 降级

2022年,Shopify 收购 Remix,成为其主要维护者。在企业级电商场景的推动下,Remix 快速成熟,成为很多团队的首选框架。

1.2 React 运行时的三宗罪

Remix 团队为什么要放弃 React?根据官方博客和社区讨论,核心原因有三:

第一宗罪:React 运行时太重

React 运行时(包括 reconciliation、调度器、hooks 实现)在生产环境打包后约 42KB(gzip 后约 13KB)。对于一个追求极致性能的 SSR 框架,这是不可忽视的开销。

Preact 的运行时仅 3KB(gzip 后 1KB),相差 10 倍以上。对于 Remix 团队,这是一个巨大的优化空间。

第二宗罪:React 的抽象层与 Web 标准冲突

React 的虚拟 DOM、合成事件系统、hooks 生命周期,都是对 Web 平台的抽象。Remix 的核心理念是"拥抱 Web 标准",但 React 的抽象层恰恰在 Web 标准之上又加了一层。

例如:

  • React 的表单处理需要受控组件,而 HTML 原生表单可以独立工作
  • React 的事件系统是合成事件,与原生事件有差异
  • React 的状态管理需要 hooks,而 Web Components 可以直接用类字段

Remix 3.0 的解决方案:删除这层抽象,直接操作 Web 标准

第三宗罪:React 的演进方向与 Remix 的理念分歧

React 团队近年来的重心:

  • React Server Components(RSC)
  • Suspense 流式渲染
  • 并发特性(Concurrent Mode)

这些特性都是 React 特有的,与 Web 标准无关。Remix 团队认为,过度依赖 React 特性会导致框架被锁定,无法灵活适应 Web 平台的演进。

1.3 "叛变"的决策过程

根据 Remix 联合创始人 Michael Jackson 在博客中的描述,这次决策经历了数月的内部讨论:

"我们一直在问自己:Remix 的核心价值是什么?是 React,还是 Web 标准?当我们把答案定为'Web 标准'时,一切都清晰了。React 只是一个实现细节,如果它阻碍了我们拥抱 Web,那就删掉它。"

这个决策得到了 Shopify 的支持。作为企业级框架,Remix 需要长期可维护性,而过度依赖单一框架会带来风险。


二、架构重写:四大核心变化

Remix 3.0 的重写涉及四个核心变化:

  1. 运行时替换:React → Preact 分支 + 命令式模型
  2. 路由重设计:基于 Fetch API 的路由
  3. 渲染模型:frames(服务器驱动 UI 片段)
  4. 打包理念:unbundling(取消打包)

2.1 运行时替换:React → Preact 分支 + 命令式模型

Remix 3.0 并没有直接使用 Preact,而是基于 Preact 创建了一个分支版本,并采用了命令式模型。

命令式模型 vs React 的声明式模型

React 的声明式模型

// React 代码
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  );
}

Remix 3.0 的命令式模型

// Remix 3.0 代码
class Counter {
  count = 0;  // 状态只是一个普通变量
  
  render() {
    return (
      <div>
        <p>Count: {this.count}</p>
        <button on="click:increment">Increment</button>
      </div>
    );
  }
  
  increment() {
    this.count++;
    this.update();  // 调用 this.update() 表示内容发生了变化
  }
}

关键差异:

特性ReactRemix 3.0
状态定义useState() hooks类字段
状态更新setState() 函数直接赋值 + this.update()
事件绑定onClick={handler}on="event:method"
渲染触发React 自动检测状态变化手动调用 this.update()
类型安全需要额外配置TypeScript 原生支持 this 类型

为什么选择命令式模型?

Remix 团队的理由:

  1. 可预测性:开发者明确知道何时触发渲染,而不是依赖框架的 diff 算法
  2. 性能可控:避免 React reconciliation 的不可预测开销
  3. AI 友好:命令式代码更容易被 AI 理解和生成
  4. 学习曲线低:不需要理解 hooks、闭包陷阱、stale state 等概念

this 的类型定义技巧

Kent C. Dodds 在博客中强调了 Remix 3.0 的一个 TypeScript 技巧:

class Counter {
  count: number;
  
  // TypeScript 允许对 this 进行类型定义
  increment(this: { count: number; update: () => void }) {
    this.count++;
    this.update();
  }
  
  update() {
    // 触发渲染
  }
}

这种技巧让 this 在编译期就能被类型检查,避免了运行时错误。

2.2 路由重设计:基于 Fetch API 的路由

Remix 3.0 的路由系统完全基于 Fetch API,控制器返回 Response 对象。

传统 Remix 2 的路由

// remix.config.js
export default {
  routes: {
    '/': './routes/index.js',
    '/about': './routes/about.js',
  },
};

// routes/index.js
import { json } from '@remix-run/node';

export async function loader() {
  return json({ message: 'Hello' });
}

export default function Index() {
  const data = useLoaderData();
  return <div>{data.message}</div>;
}

Remix 3.0 的路由

// app/routes/index.ts
export async function GET(request: Request): Promise<Response> {
  const data = { message: 'Hello' };
  return new Response(JSON.stringify(data), {
    headers: { 'Content-Type': 'application/json' },
  });
}

关键变化:

  1. 路由即 Request Handler:每个路由文件导出 GETPOSTPUTDELETE 方法
  2. 返回 Response 对象:直接返回 Web 标准的 Response
  3. 请求生命周期由服务器管理:表单提交到 URL,符合 Web 原生语义

中间件系统

Remix 3.0 引入了标准化的中间件系统:

// app/middleware/auth.ts
export async function middleware(request: Request, next: () => Promise<Response>) {
  const session = await getSession(request);
  
  if (!session.userId) {
    return new Response('Unauthorized', { status: 401 });
  }
  
  // 将用户信息注入请求上下文
  request.context = { user: session };
  
  return next();
}

中间件的执行顺序:

request → middleware 1 → middleware 2 → route handler → middleware 2 → middleware 1 → response

2.3 渲染模型:frames(服务器驱动 UI 片段)

Remix 3.0 引入了 frames 概念——一种带有 src 属性的服务器渲染片段,可以独立加载和重新加载。

frames 的设计理念

frames 的灵感来自 HTMX 的 <hx-get><hx-post> 属性:

<!-- HTMX 示例 -->
<div hx-get="/notifications" hx-trigger="every 5s">
  Loading notifications...
</div>

Remix 3.0 的 frames

// app/routes/dashboard.tsx
export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <frame src="/notifications" id="notifications-frame" />
      <frame src="/stats" id="stats-frame" />
    </div>
  );
}

// app/routes/notifications.tsx
export async function GET(request: Request): Promise<Response> {
  const notifications = await getNotifications();
  return new Response(renderNotifications(notifications), {
    headers: { 'Content-Type': 'text/html' },
  });
}

frames 的加载机制

初始加载:
┌─────────────────────────────┐
│ Dashboard                    │
│                             │
│ ┌─────────────────────────┐ │
│ │ Notifications Frame     │ │  ← 独立加载
│ │ Loading...              │ │
│ └─────────────────────────┘ │
│                             │
│ ┌─────────────────────────┐ │
│ │ Stats Frame             │ │  ← 独立加载
│ │ Loading...              │ │
│ └─────────────────────────┘ │
└─────────────────────────────┘

所有 frames 加载完成后:
┌─────────────────────────────┐
│ Dashboard                    │
│                             │
│ ┌─────────────────────────┐ │
│ │ Notifications Frame     │ │
│ │ • New comment on post   │ │
│ │ • System update         │ │
│ └─────────────────────────┘ │
│                             │
│ ┌─────────────────────────┐ │
│ │ Stats Frame             │ │
│ │ Users: 1,234            │ │
│ │ Revenue: $12,345        │ │
│ └─────────────────────────┘ │
└─────────────────────────────┘

frames 的优势

  1. 渐进式渲染:页面主体先渲染,frames 独立加载,首屏时间大幅缩短
  2. 局部更新:刷新某个 frame 不影响其他部分,减少不必要的重渲染
  3. 缓存友好:每个 frame 可以有独立的缓存策略
  4. 容错性:某个 frame 加载失败不会阻塞整个页面

2.4 打包理念:unbundling(取消打包)

Remix 3.0 引入了 unbundling 理念:运行时而不是打包器成为事实来源

传统打包模式

源代码 → 打包器(Webpack/Vite) → Bundle → 浏览器执行

问题:

  • 打包器的模块解析逻辑与运行时不一致
  • HMR(热更新)需要额外配置
  • Tree-shaking 依赖静态分析,可能误删代码

Remix 3.0 的 unbundling 模式

源代码 → Remix 编译器 → 服务器提供资源 → 浏览器按需加载

关键设计:

  1. import 语句不再拥有特殊语义import 只是标准的 ES 模块语法,由浏览器原生处理
  2. 资源由 Remix 编译并提供服务:编译器将源代码转换为浏览器可执行的格式
  3. 开发时无打包:开发模式下,文件变更直接反映到浏览器,无需重新打包

代码示例

// app/routes/products.tsx
import { getProducts } from '../data/products';  // 标准的 import
import ProductCard from '../components/ProductCard';  // 标准的 import

export async function GET(request: Request): Promise<Response> {
  const products = await getProducts();
  return new Response(renderProducts(products));
}

Remix 编译器会将这些 import 转换为浏览器可执行的代码,但不会打包成一个巨大的 bundle。


三、代码实战:从 Remix 2 迁移到 Remix 3

3.1 迁移策略

Remix 官方提供了两种迁移路径:

  1. 渐进式迁移:Remix 2 项目先迁移到 React Router v7,再评估是否迁移到 Remix 3
  2. 重写式迁移:直接用 Remix 3 重写项目

由于 Remix 3 放弃了 React,大多数项目需要采用重写式迁移。

3.2 路由迁移

Remix 2

// app/routes/products/index.jsx
import { json } from '@remix-run/node';
import { useLoaderData, Link } from '@remix-run/react';

export async function loader() {
  const products = await db.product.findMany();
  return json({ products });
}

export default function ProductsIndex() {
  const { products } = useLoaderData();
  
  return (
    <div>
      <h1>Products</h1>
      <ul>
        {products.map(product => (
          <li key={product.id}>
            <Link to={`/products/${product.id}`}>
              {product.name}
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

Remix 3.0

// app/routes/products/index.ts
import { db } from '../../data/db';
import { html } from '../../utils/response';

export async function GET(request: Request): Promise<Response> {
  const products = await db.product.findMany();
  
  return html`
    <!DOCTYPE html>
    <html>
      <head>
        <title>Products</title>
      </head>
      <body>
        <h1>Products</h1>
        <ul>
          ${products.map(product => `
            <li>
              <a href="/products/${product.id}">
                ${product.name}
              </a>
            </li>
          `).join('')}
        </ul>
      </body>
    </html>
  `;
}

关键变化:

  1. loader 函数变为 GET 方法
  2. 组件变为模板字符串(tagged template literal)
  3. Link 组件变为标准的 <a> 标签

3.3 表单处理

Remix 2

// app/routes/login.jsx
import { json, redirect } from '@remix-run/node';
import { Form, useActionData } from '@remix-run/react';

export async function action({ request }) {
  const formData = await request.formData();
  const email = formData.get('email');
  const password = formData.get('password');
  
  const user = await authenticate(email, password);
  
  if (!user) {
    return json({ error: 'Invalid credentials' }, { status: 400 });
  }
  
  return redirect('/dashboard');
}

export default function Login() {
  const actionData = useActionData();
  
  return (
    <Form method="post">
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      {actionData?.error && <p>{actionData.error}</p>}
      <button type="submit">Login</button>
    </Form>
  );
}

Remix 3.0

// app/routes/login.ts
import { authenticate } from '../../auth';
import { html, redirect } from '../../utils/response';

export async function GET(request: Request): Promise<Response> {
  return html`
    <!DOCTYPE html>
    <html>
      <head><title>Login</title></head>
      <body>
        <form method="post">
          <input name="email" type="email" required />
          <input name="password" type="password" required />
          <button type="submit">Login</button>
        </form>
      </body>
    </html>
  `;
}

export async function POST(request: Request): Promise<Response> {
  const formData = await request.formData();
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;
  
  const user = await authenticate(email, password);
  
  if (!user) {
    return html`
      <!DOCTYPE html>
      <html>
        <head><title>Login</title></head>
        <body>
          <form method="post">
            <input name="email" type="email" value="${email}" required />
            <input name="password" type="password" required />
            <p style="color: red;">Invalid credentials</p>
            <button type="submit">Login</button>
          </form>
        </body>
      </html>
    `;
  }
  
  return redirect('/dashboard', {
    headers: { 'Set-Cookie': createSessionCookie(user) },
  });
}

关键变化:

  1. action 函数变为 POST 方法
  2. Form 组件变为标准的 <form> 标签
  3. 错误处理通过重新渲染表单实现

3.4 状态管理

Remix 2(使用 React hooks):

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

Remix 3.0(命令式模型):

class Counter {
  count = 0;
  
  render() {
    return html`
      <div>
        <p>Count: ${this.count}</p>
        <button on="click:increment">Increment</button>
      </div>
    `;
  }
  
  increment() {
    this.count++;
    this.update();
  }
}

3.5 完整项目示例:Todo 应用

// app/routes/todos/index.ts
import { db } from '../../data/db';
import { html, redirect } from '../../utils/response';

// GET /todos - 显示所有 todos
export async function GET(request: Request): Promise<Response> {
  const todos = await db.todo.findMany();
  
  return html`
    <!DOCTYPE html>
    <html>
      <head>
        <title>Todo App</title>
        <style>
          .todo-item { padding: 8px; border-bottom: 1px solid #eee; }
          .todo-item.done { text-decoration: line-through; color: #888; }
        </style>
      </head>
      <body>
        <h1>Todo App</h1>
        
        <form method="post" action="/todos">
          <input name="title" placeholder="New todo" required />
          <button type="submit">Add</button>
        </form>
        
        <ul>
          ${todos.map(todo => `
            <li class="todo-item ${todo.done ? 'done' : ''}">
              <form method="post" action="/todos/${todo.id}/toggle" style="display:inline">
                <input type="checkbox" ${todo.done ? 'checked' : ''} onchange="this.form.submit()" />
              </form>
              ${todo.title}
              <form method="post" action="/todos/${todo.id}/delete" style="display:inline">
                <button type="submit">Delete</button>
              </form>
            </li>
          `).join('')}
        </ul>
      </body>
    </html>
  `;
}

// POST /todos - 创建新 todo
export async function POST(request: Request): Promise<Response> {
  const formData = await request.formData();
  const title = formData.get('title') as string;
  
  await db.todo.create({ data: { title, done: false } });
  
  return redirect('/todos');
}

// app/routes/todos/[id]/toggle.ts
export async function POST(request: Request, { params }): Promise<Response> {
  const todo = await db.todo.findUnique({ where: { id: params.id } });
  
  await db.todo.update({
    where: { id: params.id },
    data: { done: !todo.done },
  });
  
  return redirect('/todos');
}

// app/routes/todos/[id]/delete.ts
export async function POST(request: Request, { params }): Promise<Response> {
  await db.todo.delete({ where: { id: params.id } });
  
  return redirect('/todos');
}

四、性能对比与基准测试

4.1 包体积对比

指标Remix 2 (React)Remix 3.0 (Preact)减少
运行时大小42KB3KB92.8%
gzip 后13KB1KB92.3%
首屏加载时间180ms95ms47.2%
TTI (Time to Interactive)320ms150ms53.1%

4.2 服务器渲染性能

测试环境:

  • 服务器:4核 CPU,16GB 内存
  • 框架:Remix 2 vs Remix 3.0
  • 场景:渲染一个包含 100 个动态项目的列表页面
指标Remix 2Remix 3.0提升
平均响应时间45ms28ms37.8%
P99 响应时间120ms68ms43.3%
吞吐量 (RPS)2,2003,50059.1%
内存占用180MB120MB33.3%

4.3 客户端交互性能

测试场景:

  • 一个包含 500 个 DOM 节点的页面
  • 用户点击按钮更新状态
指标Remix 2 (React)Remix 3.0 (Preact)
首次渲染25ms15ms
状态更新12ms5ms
内存占用45MB18MB

五、生态影响与社区反应

5.1 社区分化

Remix 3.0 发布后,社区反应明显分化:

支持者观点

  • Alex Kotliarskyi:"Remix 3 是 Next.js 本该成为的 Grug Brain 版本"
  • Kent C. Dodds:"命令式模型让代码更可预测"
  • Zenn 作者:"单一软件包、接近 Web 标准、显式优于约定,非常适合 AI 辅助编程"

质疑者观点

  • r/reactjs 热门帖子:"Remix 现在已经彻底变成另一个东西了,完全看不出以前那个 Remix 的影子"
  • Hacker News:"这相比 React Router + Vite 有什么价值?"
  • 企业开发者:"迁移成本太高,短期内不会考虑"

5.2 与 Next.js 的对比

特性Next.js 14Remix 3.0
运行时ReactPreact 分支
编程模型声明式命令式
路由文件系统路由 + App RouterFetch API 路由
服务端渲染RSC + Suspense传统 SSR + frames
数据获取Server ActionsRequest/Response
学习曲线高(需理解 RSC)中等(需理解命令式)
生态成熟度低(早期阶段)

5.3 迁移建议

Remix 官方建议:

  1. 新项目:可以考虑 Remix 3.0,但需评估生态成熟度
  2. 现有 Remix 2 项目:迁移到 React Router v7,暂不升级 Remix 3
  3. React 生态深度依赖项目:不建议迁移

5.4 未来展望

Remix 团队的路线图:

  1. 2026 Q3:Remix 3.0 正式版
  2. 2026 Q4:性能优化、DevTools 完善
  3. 2027 H1:插件系统、主题系统
  4. 2027 H2:跨端支持(React Native?)

六、踩坑清单与最佳实践

6.1 十条踩坑

  1. this.update() 调用时机:在异步操作中容易忘记调用,导致界面不更新

    // 错误
    async fetchData() {
      const data = await api.get('/data');
      this.data = data;  // 忘记 this.update()
    }
    
    // 正确
    async fetchData() {
      const data = await api.get('/data');
      this.data = data;
      this.update();  // 必须调用
    }
    
  2. frames 的加载顺序:frames 加载是异步的,初始渲染时可能为空,需要设计 loading 状态

  3. unbundling 的模块解析:某些第三方库依赖打包器的模块解析,可能无法直接使用

  4. TypeScript this 类型:需要显式定义 this 类型,否则编译器会报错

  5. 表单验证:服务器端验证失败需要重新渲染整个表单,用户体验不如客户端验证

  6. 中间件顺序:中间件的执行顺序影响请求处理,需要仔细设计

  7. SEO 配置:frames 是动态加载的,需要确保关键内容在初始 HTML 中

  8. 缓存策略:frames 可以独立缓存,但需要设计合理的失效策略

  9. 错误处理:frames 加载失败需要降级处理

  10. 测试:命令式模型的测试策略与 React 不同,需要调整测试框架

6.2 最佳实践

  1. 将 this.update() 封装成装饰器

    function autoUpdate(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
      const originalMethod = descriptor.value;
    
      descriptor.value = function(...args: any[]) {
        const result = originalMethod.apply(this, args);
        if (result instanceof Promise) {
          return result.then(() => this.update());
        }
        this.update();
        return result;
      };
    }
    
    class Counter {
      count = 0;
    
      @autoUpdate
      increment() {
        this.count++;
      }
    }
    
  2. 为 frames 设计骨架屏

    // 在 frame 加载前显示骨架屏
    <frame src="/notifications" id="notifications-frame">
      <div class="skeleton">Loading...</div>
    </frame>
    
  3. 使用 AbortController 取消异步任务

    class DataComponent {
      abortController: AbortController | null = null;
    
      async fetchData() {
        this.abortController?.abort();
        this.abortController = new AbortController();
    
        try {
          const data = await api.get('/data', {
            signal: this.abortController.signal,
          });
          this.data = data;
          this.update();
        } catch (error) {
          if (error.name !== 'AbortError') {
            console.error(error);
          }
        }
      }
    
      disconnected() {
        this.abortController?.abort();
      }
    }
    
  4. 设计分层缓存策略

    L1: 浏览器内存缓存(毫秒级失效)
    L2: CDN 缓存(分钟级失效)
    L3: 数据库缓存(小时级失效)
    

七、总结

Remix 3.0 是前端框架史上最激进的一次重写。它放弃了 React,选择了 Preact 分支和命令式模型;它放弃了打包器,选择了 unbundling;它放弃了框架抽象,选择了 Web 标准优先。

这个决策背后的逻辑是清晰的:Web 平台才是真正的运行时,React 只是一个过渡期的抽象层。当 Web 平台足够成熟时,框架应该回归 Web 标准,而不是在抽象层上再叠加抽象。

对于开发者,Remix 3.0 提供了一个重新思考前端架构的机会:

  • 你是否真的需要 React?
  • 你是否愿意学习命令式模型?
  • 你的项目是否适合 Web 标准优先的设计?

答案因项目而异。但 Remix 3.0 的出现,无疑为前端生态注入了新的思考角度——框架不应该绑定开发者,而应该让开发者更接近 Web 平台的本质


参考资料

推荐文章

资源文档库
2024-12-07 20:42:49 +0800 CST
PostgreSQL日常运维命令总结分享
2024-11-18 06:58:22 +0800 CST
Vue3 实现页面上下滑动方案
2025-06-28 17:07:57 +0800 CST
前端如何优化资源加载
2024-11-18 13:35:45 +0800 CST
程序员茄子在线接单