Remix-路由驱动的前端开发模型

Remix 是什么

Remix 是一个面向 React 开发者的现代化全栈框架,它提供了一种新的方法来构建 web 应用程序。Remix 的核心理念是将前端和后端代码统一在一个项目中,并且通过路由和路由参数来管理页面和数据的加载。

Remix 的特点

  1. 允许或者说是鼓励全栈框架,将前后端代码整合到一个项目。
  2. 路由和路由参数来管理页面和数据的加载,使得页面之间的跳转和数据传递更简单直观。
  3. 提供了多种钩子。
  4. 内置缓存。

创建 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 });
};
  • 类型安全:可以使用 LoaderArgsuseLoaderData<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 函数定义当用户访问路由时要将哪些元素添加到页面中。

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 to="..."/> 来替换 <a href="..."/>

<Links> 组件用于呈现在路由模块中导出的 links:

export const links = () => {
  return [{ rel: "...", href: "..." }];
};

<LiveReload>

这个组件会建立一个 WebSocket 来热更新并且自动刷新浏览器

<Meta>

这个组件用于呈现在路由模块中导出的 meta

特殊的 <Link/> 标签,用于标识当前是否处于活跃状态。在处理面包屑或者一组选项卡时很有用。

<ScrollRestoration>

这个组件用于模拟浏览器滚动位置恢复,确保他在页面中只渲染一次,并且在 <Scripts/> 组件之前。