Remix 是一个面向 React 开发者的现代化全栈框架,它提供了一种新的方法来构建 web 应用程序。 Remix 的核心理念是将前端和后端代码统一在一个项目中,并且通过路由和路由参数来管理页面和数据的加载。
以下内容均基于 v2 版本,旧版本内容不会提及。更多信息可以查阅 官方文档 。
创建一个简单的 Remix 模板项目
npx create-remix@latest --template remix-run/indie-stack blog-tutorial
// remix.config.js
module.exports = {
future: {
v2_routeConvention: true, // 标识使用 v2 版本的路由命名约定。
v2_meta: true, // 标识使用 v2 版本的 meta。
},
};
在 Remix 中,路由是应用中每个页面的入口点。路由以文件匹配的方式创建,每个路由对应一个文件。
app/
├── routes/
└── root.tsx
<Outlet />
中。 app/
├── routes/
│ ├── _index.tsx
│ └── about.tsx
└── root.tsx
URL | Matched Routes |
---|---|
/ | _index.tsx |
/about | about.tsx |
app/routes/
文件内的 JavaScript 或 TypeScript 都将成为一个路由。文件名映射到 URL 路径名。除了 _index.tsx ,这是根路由的索引路由。 app/
├── routes/
│ ├── _index.tsx
│ ├── concerts.trending.tsx
│ └── concerts.san-diego.tsx
└── root.tsx
URL | Matched Routes |
---|---|
/concerts/trending | concerts.trending.tsx |
/concerts/san-diego | concerts.san-diego.tsx |
.
分割时,在 URL 中将创建一级路由。 app/
├── routes/
│ ├── _index.tsx
│ ├── concerts.trending.tsx
│ └── concerts.$city.tsx
└── root.tsx
URL | Matched Routes |
---|---|
/concerts/trending | concerts.trending.tsx |
/concerts/san-diego | concerts.$city.tsx |
$
前缀表示匹配 URL 中某段值,在 Remix 解析时将会通过 params 传递。 concerts.$city.$date
=> params.date & params.city 嵌套路由是将 URL 耦合到组件层次结构的思想。所有子路由都将被父路由的 outlet 包裹。
app/
├── routes/
│ ├── _index.tsx
│ ├── concerts._index.tsx
│ ├── concerts.$city.tsx
│ ├── concerts.trending.tsx
│ └── concerts.tsx
└── root.tsx
URL | Matched Routes | Layout |
---|---|---|
/ | _index.tsx | root.tsx |
/concerts | concerts._index.tsx | concerts.tsx |
/concerts/trending | concerts.trending.tsx | concerts.tsx |
/concerts/san-diego | concerts.$city.tsx | concerts.tsx |
_
来规避:concerts_.mine.tsx Remix 支持将子路由渲染在父布局中,这样做的好处是当子路由出错时,不会影响整个页面,并且可以加强组件的复用。
import { Outlet } from "@remix-run/react";
// root.tsx
export default function App() {
return (
<html lang="en">
<body>
<div id="sidebar">{/* other elements */}</div>
<div id="detail">
<Outlet />
</div>
</body>
</html>
);
}
当希望一些路由路径嵌套(使用同一个布局入口),而不希望在 URL 中展示对应的路径时可以使用 _
在片段头部标识
app/
├── routes/
│ ├── _auth.login.tsx
│ ├── _auth.register.tsx
│ ├── _auth.tsx
│ └── _index.tsx
└── root.tsx
URL | Matched Routes | Layout |
---|---|---|
/ | _index.tsx | root.tsx |
/login | _auth.login.tsx | _auth.tsx |
/register | _auth.register.tsx | _auth.tsx |
用小括号包裹标识该路径可选
app/
├── routes/
│ ├── ($lang)/
│ │ ├── $productId.tsx
│ │ └── categories.tsx
│ └── _index.tsx
└── root.tsx
URL | Matched Routes |
---|---|
/ | _index.tsx |
/categories | categories.tsx |
/en/categories | categories.tsx |
/american-flag-speedo | $productId.tsx |
/en/american-flag-speedo | $productId.tsx |
匹配 URL 其余片段
app/
├── routes/
│ ├── _index.tsx
│ ├── $.tsx
│ ├── about.tsx
│ └── files.$.tsx
└── root.tsx
URL | Matched Routes |
---|---|
/ | _index.tsx |
/beef/and/cheese | $.tsx |
/files | files.$.tsx |
/files/talks/remix-conf_old.pdf | files.$.tsx |
/files/talks/remix-conf_final.pdf | files.$.tsx |
/files/talks/remix-conf-FINAL-MAY_2022.pdf | files.$.tsx |
如果希望保留特殊的转义字符,可以使用 []
包裹:routes/sitemap[.]xml.tsx
=> /sitemap.xml
与 loader 一样, action 是一个仅限服务器调用的函数。用于处理数据变动和其他操作。
This enables you to co-locate everything about a data set in a single route module: the data read, the component that renders the data, and the data writes:
import type { ActionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
import { Form } from "@remix-run/react";
import { TodoList } from "~/components/TodoList";
import { fakeCreateTodo, fakeGetTodos } from "~/utils/db";
export async function loader() {
return json(await fakeGetTodos()); // 抛出
}
export async function action({ request }: ActionArgs) {
const body = await request.formData();
const todo = await fakeCreateTodo({
title: body.get("title"), // 处理
});
return redirect(`/todos/${todo.id}`);
}
export default function Todos() {
const data = useLoaderData<typeof loader>(); // 获取
return (
<div>
<TodoList todos={data} />
<Form method="post"> {/* 抛出 */}
<input type="text" name="title" />
<button type="submit">Create Todo</button>
</Form>
</div>
);
}
每个路由都可以定义一个 loader 函数,在渲染时为路由提供数据。loader 只在服务器上运行,在初始服务器渲染中, 他将为 HTML 文档提供数据,在浏览器导航中,Remix 将通过从浏览器中获取的方式调用该函数。
import { json } from "@remix-run/node";
export const loader = async () => {
return json({ ok: true });
};
类型安全:可以使用 LoaderArgs
和 useLoaderData<typeof loader>
为你的 loader 和组件提供类型安全。
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader(args: LoaderArgs) {
return json({ name: "Ryan", date: new Date() });
}
export default function SomeRoute() {
const data = useLoaderData<typeof loader>();
}
data.name
将会被推断为 string;data.date
也会被推断为 string,即使是用 Date 对象赋值,
当 data 通过网络序列化之后(JSON.stringify),仍然会被推断为 string。
params:路由参数,在路由文件名时定义,匹配以 $
开头的片段,并将 URL 中对应的片段返回。
request:用来表示资源请求,一般在 loader 中用来获取 header,url searchParams
export async function loader({ request }: LoaderArgs) {
// read a cookie
const cookie = request.headers.get("Cookie");
// parse the search params for `?q=`
const url = new URL(request.url);
const query = url.searchParams.get("q");
}
context: loader 中用来传递上下文。
返回响应实例:loader 需要返回一个响应实例:return new Response(body, {headers:{...}})
,
可以通过 json helper 来简写。return json({...})
import { json } from "@remix-run/node";
export const loader = async ({ params }: LoaderArgs) => {
const user = await fakeDb.project.findOne({
where: { id: params.id },
});
if (!user) {
return json("Project not found", { status: 404 });
}
return json(user);
};
抛出响应:除了 return response 也可以抛出。
ErrorBoundary 也是个 React 组件,只要在路由上任何一个地方报错,无论是在渲染期间还是在数据加载期间,它都会被渲染。
node: 该错误表示意料之外的错误;不同于可以处理的错误。例如:404 错误,当错误发生时可以自行处理来展示 404 页面。
export function ErrorBoundary({ error }) {
return (
<div>
<h1>Error</h1>
<p>{error.message}</p>
<p>The stack trace is:</p>
<pre>{error.stack}</pre>
</div>
);
}
每个路由都可以定义自己的 HTTP headers。
export function headers({ loaderHeaders }: { loaderHeaders: Headers }) {
return {
"X-Stretchy-Pants": "its for fun",
"Cache-Control": loaderHeaders.get("Cache-Control"),
};
}
当有嵌套路由存在时,使用最深的路由定义的 headers。可以只在子路由定义 headers 来避免合并 headers 时发生的一些意外。
import parseCacheControl from "parse-cache-control";
export function headers({
loaderHeaders,
parentHeaders,
}: {
loaderHeaders: Headers,
parentHeaders: Headers,
}) {
const loaderCache = parseCacheControl(loaderHeaders.get("Cache-Control"));
const parentCache = parseCacheControl(parentHeaders.get("Cache-Control"));
// take the most conservative between the parent and loader, otherwise
// we'll be too aggressive for one of them.
const maxAge = Math.min(loaderCache["max-age"], parentCache["max-age"]);
return {
"Cache-Control": `max-age=${maxAge}`,
};
}
在 entry.server
中可以定义适用于全局的 headers
import type {EntryContext} from '@shopify/remix-oxygen';
import {RemixServer} from '@remix-run/react';
import isbot from 'isbot';
import {renderToReadableStream} from 'react-dom/server';
import {createContentSecurityPolicy} from '@shopify/hydrogen';
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
) {
const {nonce, header, NonceProvider} = createContentSecurityPolicy({
defaultSrc: ["'self'"], // add csp rules here
});
const body = await renderToReadableStream(
<NonceProvider>
<RemixServer context={remixContext} url={request.url} />
</NonceProvider>,
{
nonce,
signal: request.signal,
onError(error) {
console.error(error);
responseStatusCode = 500;
},
},
);
if (isbot(request.headers.get('user-agent'))) {
await body.allReady;
}
responseHeaders.set('Content-Type', 'text/html');
responseHeaders.set('Content-Security-Policy', header);
return new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
});
}
links 函数定义当用户访问路由时要将哪些元素添加到页面中。
import type { LinksFunction } from "@remix-run/node";
export const links: LinksFunction = () => [
{
rel: 'icon',
href: '/favicon.png',
type: 'image/png',
},
{
rel: 'stylesheet',
href: 'https://example.com/some/styles.css',
},
{page: '/users/123'},
{
rel: 'preload',
href: '/images/banner.jpg',
as: 'image',
},
];
meta 定义了 <meta>
路由标签表示。这将有利于 SEO、浏览器行为等。
import type { V2_MetaFunction } from "@remix-run/node";
export const meta: V2_MetaFunction = () => [
{ title: 'New Remix App' },
{
name: 'description',
content: 'This app is a wildly dynamic web app',
},
]
在根路由中建议使用 <meta>
标签而不是 meta 函数,这样可以避免子级覆盖父级的问题。
matches:返回当前路由匹配的列表,这在将父级 meta 合并到子级 meta 时很有用(子级 meta 将覆盖父级 meta)。
export const meta: V2_MetaFunction = ({ matches }) => {
let parentMeta = matches.map((match) => match.meta ?? []);
return [...parentMeta, { title: "Projects" }];
};
data:loader 中的数据。
parentsData:父级路由的数据,通过路由 ID 查找
import type { loader as projectDetailsLoader } from "../../../$pid";
export async function loader({ params }: LoaderArgs) {
return json({ task: await getTask(params.tid) });
}
export const meta: V2_MetaFunction<
typeof loader,
{ "routes/project/$pid": typeof projectDetailsLoader }
> = ({ data, parentsData }) => {
let project = parentsData["routes/project/$pid"].project;
let task = data.task;
return [{ title: `${project.name}: ${task.name}` }];
};
params:URL 参数
在客户端转换过程中,Remix 会优化已经渲染的路由的重载机制:不重新加载没有变化的布局路由。在其他情况下, 比如表单提交或搜索参数变化,Remix 不知道哪些路由需要重载,所以为了安全起见,它会全部重载。 这可以确保你的用户界面始终与你的服务器上的状态保持同步。
该函数允许按照你的规则来决定路由是否需要重载。
await 标签用于处理 promises 数据,和 loader 中的 defer() 配套使用。
<Suspense>
<Await resolve={deferredValue}>{(data) => <p>{data}</p>}</Await>
</Suspense>
也可以通过 useAsyncValue 钩子获取
function Accessor() {
const value = useAsyncValue();
return <p>{value}</p>;
}
// ...
<Suspense>
<Await resolve={deferredValue}>
<Accessor />
</Await>
</Suspense>;
form 组件是一种执行数据突变的声明性方式:创建、更新和删除数据。
<Form action="/projects/new" mathod="post" />
使用 <Link to="..."/>
来替换 <a href="..."/>
<Links>
组件用于呈现在路由模块中导出的 links:
export const links = () => {
return [{ rel: "...", href: "..." }];
};
这个组件会建立一个 WebSocket 来热更新并且自动刷新浏览器
这个组件用于呈现在路由模块中导出的 meta
特殊的 <Link/>
标签,用于标识当前是否处于活跃状态。在处理面包屑或者一组选项卡时很有用。
这个组件用于模拟浏览器滚动位置恢复,确保他在页面中只渲染一次,并且在 <Scripts/>
组件之前。