Remix-路由驱动的前端开发模型
Remix 是什么
Remix 是一个面向 React 开发者的现代化全栈框架,它提供了一种新的方法来构建 web 应用程序。Remix 的核心理念是将前端和后端代码统一在一个项目中,并且通过路由和路由参数来管理页面和数据的加载。
Remix 的特点
- 允许或者说是鼓励全栈框架,将前后端代码整合到一个项目。
- 路由和路由参数来管理页面和数据的加载,使得页面之间的跳转和数据传递更简单直观。
- 提供了多种钩子。
- 内置缓存。
创建 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 路由
在 Remix 中,路由是应用中每个页面的入口点。路由以文件匹配的方式创建,每个路由对应一个文件。
根路由
app/
├── routes/
└── root.tsx
- 根路由是整个应用的根布局,所有其他路由都在
<Outlet />
中。 - 与其他路由工作方式一样,可以导出 loader 、 action 等。
基本路由
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 中将创建一级路由。
Dynamic segments / 动态细分
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 传递。 - 有多个动态值也可以通过名称在 params 中获取:
concerts.$city.$date
=> params.date & params.city
Nested Routes / 嵌套路由 和 <Outlet />
嵌套路由是将 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 开头的路由布局,都将被 concerts.tsx 包裹。
- 当希望 URL 嵌套但不希望布局文件嵌套时,可以通过在父级片段尾部添加
_
来规避: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>
);
}
Nested Layouts without Nested URLs / 虚空嵌套路由
当希望一些路由路径嵌套(使用同一个布局入口),而不希望在 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 |
Optional Segments / 可选路由
用小括号包裹标识该路径可选
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 |
Splat Routes / 分割路由
匹配 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
Route Module API / 路由模块下的 API
action
与 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 函数,在渲染时为路由提供数据。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>
);
}
Headers
每个路由都可以定义自己的 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
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 定义了 <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 参数
shouldRevalidate
在客户端转换过程中,Remix 会优化已经渲染的路由的重载机制:不重新加载没有变化的布局路由。在其他情况下,比如表单提交或搜索参数变化,Remix 不知道哪些路由需要重载,所以为了安全起见,它会全部重载。这可以确保你的用户界面始终与你的服务器上的状态保持同步。
该函数允许按照你的规则来决定路由是否需要重载。
Component /组件
<Await>
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 组件是一种执行数据突变的声明性方式:创建、更新和删除数据。
- 无论 JavaScript 是否存在于页面上,创建的 Form 和 action 都会有效。
- 提交 Form 后,页面上的所有 loader 都将重新加载。
- Form 自动序列化表单的值。
- 没有在 Form 组件中表明 action 路径的,都会作用与相同路径下的 action。
<Form action="/projects/new" mathod="post" />
<Link>
使用 <Link to="..."/>
来替换 <a href="..."/>
<Links>
<Links>
组件用于呈现在路由模块中导出的 links:
export const links = () => {
return [{ rel: "...", href: "..." }];
};
<LiveReload>
这个组件会建立一个 WebSocket 来热更新并且自动刷新浏览器
<Meta>
这个组件用于呈现在路由模块中导出的 meta
<NavLink>
特殊的 <Link/>
标签,用于标识当前是否处于活跃状态。在处理面包屑或者一组选项卡时很有用。
<ScrollRestoration>
这个组件用于模拟浏览器滚动位置恢复,确保他在页面中只渲染一次,并且在 <Scripts/>
组件之前。