Remix 3 Beta Preview 做了一次无法用“常规升级”概括的架构转向:框架不再以 React 为核心,而是围绕 Web 平台原语组织路由、请求处理和 UI,并在前端采用一个 fork 版本的 Preact。更关键的变化是,请求生命周期重新由服务器主导。
这不只是换掉渲染库。对于现有 Remix 2 项目,它会影响路由模块的职责、数据流向、服务端与浏览器的边界,以及团队原本依赖的 React 生态。迁移前最重要的问题,不是“新版本快不快”,而是“现有应用有多少设计建立在 React 和旧版 Remix 约定之上”。
变化的核心不是 Preact,而是请求所有权
把注意力只放在“React 换成了 Preact”上,很容易低估 Remix 3 的变化。根据预览版所展示的方向,框架更强调三个部分在同一套结构中协作:
- 路由决定哪个模块接管当前 URL;
- 请求处理器直接面对 Web 风格的
Request; - UI 组件消费服务端产生的数据,并生成响应内容。
这里真正重要的是服务器掌握请求生命周期。一次导航可以被理解为:
HTTP Request
-> route matching
-> request handler / data loading
-> UI rendering
-> HTTP Response
这与“浏览器先启动一个 React 应用,再由客户端接管数据请求”的思路不同。服务端可以更自然地决定状态码、响应头、重定向、缓存策略和最终正文,而不是把 HTTP 细节藏在组件状态或客户端副作用里。
Web 标准原语也让代码的边界更清楚。例如,业务函数接收 Request、返回 Response,意味着它更容易脱离特定服务器适配器进行测试:
const request = new Request('https://example.test/products?limit=10', {
headers: { Accept: 'text/html' }
});
const response = await handleRequest(request);
console.log(response.status);
console.log(response.headers.get('content-type'));
console.log(await response.text());
当然,“使用 Web 标准”并不等于完全没有框架约定。路由发现、构建、服务端渲染和客户端交互仍然需要框架提供组织方式,只是这些约定更靠近浏览器和服务器共同理解的接口。
一个可运行的最小模型:把路由、处理器和 UI 放在一起
下面不是 Remix 3 Beta 的正式 API,而是一个可以直接运行的教学项目,用来演示预览方向中的核心关系:路由模块接收标准 Request,调用 UI 组件,并返回标准 Response。实际采用时应以对应 Beta 版本文档为准。
先创建项目,要求使用 Node.js 20 或更高版本:
mkdir web-standard-route-demo
cd web-standard-route-demo
npm init -y
npm pkg set type=module
npm pkg set scripts.start="node server.js"
npm install preact preact-render-to-string
创建 product-route.js:
import { h } from 'preact';
import render from 'preact-render-to-string';
function ProductPage({ product }) {
return h('main', null,
h('h1', null, product.name),
h('p', null, `Price: $${product.price}`),
h('a', { href: '/products/42?currency=EUR' }, 'Show EUR price')
);
}
export async function handleProductRequest(request) {
const url = new URL(request.url);
const match = url.pathname.match(/^\/products\/(\d+)$/);
if (!match) {
return new Response('Not Found', { status: 404 });
}
const currency = url.searchParams.get('currency') ?? 'USD';
const basePrice = 29;
const product = {
id: match[1],
name: `Product ${match[1]}`,
price: currency === 'EUR' ? Math.round(basePrice * 0.92) : basePrice
};
const app = render(h(ProductPage, { product }));
const html = `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>${product.name}</title></head>
<body>${app}</body>
</html>`;
return new Response(html, {
status: 200,
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'private, max-age=30'
}
});
}
再创建 server.js,它只负责把 Node.js HTTP 请求适配成 Web Request,并把 Web Response 写回客户端:
import http from 'node:http';
import { handleProductRequest } from './product-route.js';
const server = http.createServer(async (req, res) => {
try {
const origin = `http://${req.headers.host ?? 'localhost:3000'}`;
const request = new Request(new URL(req.url ?? '/', origin), {
method: req.method,
headers: req.headers
});
const response = await handleProductRequest(request);
const body = Buffer.from(await response.arrayBuffer());
res.writeHead(response.status, Object.fromEntries(response.headers));
res.end(body);
} catch (error) {
console.error(error);
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Internal Server Error');
}
});
server.listen(3000, () => {
console.log('Open http://localhost:3000/products/42');
});
启动并验证响应:
npm start
curl -i 'http://localhost:3000/products/42?currency=EUR'
这个例子刻意保持简单,没有实现 Remix 3 的构建、嵌套路由、客户端交互或错误边界。它展示的是一种可测试的职责划分:服务器适配器处理运行时差异,路由模块拥有请求语义,UI 只是响应生成过程的一部分。
从 React 切换出去,影响会落在哪里
Remix 3 使用 fork 版本的 Preact,并不意味着已有 React 代码可以无条件原样运行。即使 JSX 看起来相似,项目仍应逐项检查兼容性。
React 专属依赖
组件库、状态管理工具、表单库和监控 SDK 可能直接导入 React,或者依赖 React 特定的运行时行为。需要检查的不只是 package.json 中的直接依赖,还包括传递依赖和构建插件。
可以先做一次静态搜索:
rg "from ['\"]react|from ['\"]react-dom|require\(['\"]react" app src package.json
npm ls react react-dom
搜索结果不能自动判断兼容性,但可以快速暴露迁移面。尤其要关注:
- 直接使用 React DOM API 的入口文件;
- 建立在 React Context 或特定 Hook 行为上的基础设施;
- 依赖 React Server Components 等专属能力的代码;
- 假设旧版 Remix 数据加载和导航行为的组件;
- 只提供 React 适配器的第三方 UI 或可观测性工具。
HTTP 语义是否被组件状态掩盖
如果应用通过 useEffect 发起关键数据请求,再在组件中决定错误页面或跳转,那么它与“服务器拥有请求生命周期”的方向并不一致。迁移时应考虑把这些决定上移到请求处理层:
export async function requireUser(request) {
const cookie = request.headers.get('cookie');
const user = await findUserFromCookie(cookie);
if (!user) {
return {
response: new Response(null, {
status: 302,
headers: { Location: '/login' }
})
};
}
return { user };
}
这段代码同样是基于 Web 标准的实践示意,而不是对 Remix 3 正式接口的声明。它表达的原则是:身份验证失败应该产生明确的 HTTP 重定向,而不是等页面渲染后再由客户端修正。
测试策略需要调整
当处理器接收 Request 并返回 Response 时,一部分测试可以不启动浏览器,也不必模拟组件内部状态:
import assert from 'node:assert/strict';
import { handleProductRequest } from './product-route.js';
const response = await handleProductRequest(
new Request('https://example.test/products/42?currency=EUR')
);
assert.equal(response.status, 200);
assert.match(await response.text(), /Price: \$27/);
console.log('route test passed');
可以将它保存为 product-route.test.js,然后执行:
node product-route.test.js
这种测试覆盖的是 HTTP 输入、业务处理与 HTML 输出之间的契约。浏览器端交互仍然需要端到端测试,两类测试不能互相替代。
不要把 Remix 2 升级当成改版本号
来源信息已经明确指出,从 Remix 2 迁移并不直接,现有应用需要修改。更稳妥的方式是把 Remix 3 视为一次架构评估,而不是一次依赖升级。
可以按以下顺序推进:
- 建立依赖清单:标记 React 专属库、旧版 Remix API、构建插件和部署适配器。
- 选择垂直切片:挑一个包含路由、数据读取、表单提交和错误处理的非核心页面做验证。
- 确认 HTTP 行为:对比状态码、重定向、Cookie、缓存头和错误响应,而不只是截图是否一致。
- 测试运行环境:确认目标 Node、边缘运行时或 Serverless 平台对
Request、Response和流式正文的支持情况。 - 衡量生态成本:如果关键组件库或监控工具依赖 React,需要决定替换、包装还是延后迁移。
- 保留回退路径:Beta 阶段不宜让核心业务只能依赖尚未稳定的接口。
Remix 3 的价值判断最终取决于团队是否认同它的核心边界:让服务器接管请求,让路由模块表达 HTTP 语义,让 UI 回到响应生成的一环。对于新项目,这种模型值得用一个小型服务验证;对于成熟的 Remix 2 应用,最可靠的策略不是全量重写,而是先用真实业务切片测量兼容性、性能和维护成本。