Remix 工具

此包包含一些与 Remix.run 结合使用的简单实用函数。

安装

npm install remix-utils

可能需要其他可选依赖项,所有可选依赖项为

  • @remix-run/react(也包括 @remix-run/router,但你应该使用 React 版本)
  • @remix-run/node@remix-run/cloudflare@remix-run/deno(实际上是 @remix-run/server-runtime,但你应该使用其他其中一个)
  • crypto-js
  • is-ip
  • intl-parse-accept-language
  • react
  • zod

需要额外可选依赖项的工具在其文档中进行了说明。

如果你想安装所有依赖项,请运行

npm add crypto-js is-ip intl-parse-accept-language zod

React 和 @remix-run/* 包应该已经安装在你的项目中了。

从 Remix Utils v6 升级

如果你之前使用过 Remix Utils,你会注意到一些变化。

  1. 该包仅以 ESM 格式发布
  2. 每个工具现在在包中都有一个特定的路径,因此你需要从 remix-utils/<util-name> 中导入它们。
  3. 所有依赖项现在都是可选的,因此你需要自己安装它们。

与 CJS 结合使用

从 Remix Utils v7 开始,该包仅以 ESM 格式发布(加上类型定义)。这意味着如果你正在使用 CJS 版本的 Remix,你需要配置它来捆绑 Remix Utils。

在你的 remix.config.js 文件中,添加以下内容

module.exports = {
	serverDependenciesToBundle: [
		/^remix-utils.*/,
		// If you installed is-ip optional dependency you will need these too
		"is-ip",
		"ip-regex",
		"super-regex",
		"clone-regexp",
		"function-timeout",
		"time-span",
		"convert-hrtime",
		"is-regexp",
	],
};

如果你不确定你的应用程序是使用 ESM 还是 CJS,请检查你的 remix.config.js 文件中是否包含 serverModuleFormat 来确定。

如果你没有该配置,如果你使用的是 Remix v1,则为 CJS,如果你使用的是 Remix v2,则为 ESM。

如果你正在使用 Vite,但仍然构建为 CJS,则需要 将此包添加到 noExternal,以便将其与你的服务器代码捆绑在一起。因此,在 vite.config.ts 中的 defineConfig() 中添加以下内容

ssr: {
      noExternal: ["remix-utils"],
    },

注意 Remix Utils 中的一些可选依赖项可能仍然以 CJS 格式发布,因此你可能需要将它们也添加到 serverDependenciesToBundle 中。

如果你从 Remix v1 升级到 Remix v2,则需要考虑的另一件事是在你的 tsconfig.json 中设置 "moduleResolution": "Bundler",否则 TS 将无法解析新的导入路径。

更新后的导入路径

你需要更改你的导入以使用正确的路径。因此,不要这样做

import { eventStream, useEventSource } from "remix-utils";

你需要将其更改为

import { eventStream } from "remix-utils/sse/server";
import { useEventSource } from "remix-utils/sse/react";

这会增加更多行,但可以实现下一个更改。

可选依赖项

之前,Remix Utils 安装了一些你可能从未用过的依赖项,例如 Zod。

当前版本将每个依赖项标记为可选,因此你需要自己安装它们。

虽然这需要更多工作,但这意味着该包可以根据需要继续使用更多依赖项,而不会增加每个人的捆绑包大小。只有当你使用依赖于 Zod 的依赖项时,你才会安装并将 Zod 包含在你的捆绑包中。

每个工具都提到了它需要的依赖项,并且 安装 部分列出了完整列表,以防你希望预先安装所有依赖项。

API 参考

promiseHash

promiseHash 函数与 Remix 没有直接关系,但在处理加载器和操作时是一个有用的函数。

此函数是 Promise.all 的对象版本,它允许你传递一个包含 Promise 的对象,并获取一个具有相同键和已解析值的对象。

import { promiseHash } from "remix-utils/promise";

export async function loader({ request }: LoaderFunctionArgs) {
	return json(
		await promiseHash({
			user: getUser(request),
			posts: getPosts(request),
		}),
	);
}

你可以使用嵌套的 promiseHash 获取一个包含已解析值的嵌套对象。

import { promiseHash } from "remix-utils/promise";

export async function loader({ request }: LoaderFunctionArgs) {
	return json(
		await promiseHash({
			user: getUser(request),
			posts: promiseHash({
				list: getPosts(request),
				comments: promiseHash({
					list: getComments(request),
					likes: getLikes(request),
				}),
			}),
		}),
	);
}

timeout

timeout 函数允许你为任何 Promise 附加超时,如果 Promise 在超时前未解析或拒绝,它将使用 TimeoutError 拒绝。

import { timeout } from "remix-utils/promise";

try {
	let result = await timeout(fetch("https://example.com"), { ms: 100 });
} catch (error) {
	if (error instanceof TimeoutError) {
		// Handle timeout
	}
}

这里 fetch 需要在 100 毫秒内完成,否则将抛出 TimeoutError

如果 Promise 可以使用 AbortSignal 取消,则可以将 AbortController 传递给 timeout 函数。

import { timeout } from "remix-utils/promise";

try {
	let controller = new AbortController();
	let result = await timeout(
		fetch("https://example.com", { signal: controller.signal }),
		{ ms: 100, controller },
	);
} catch (error) {
	if (error instanceof TimeoutError) {
		// Handle timeout
	}
}

这里 100 毫秒后,timeout 将调用 controller.abort(),这将标记 controller.signal 为已中止。

cacheAssets

注意 这只能在 entry.client 中运行。

此函数允许你轻松地在 浏览器的缓存存储 中缓存 Remix 构建的每个 JS 文件。

要使用它,请打开你的 entry.client 文件并添加以下内容

import { cacheAssets } from "remix-utils/cache-assets";

cacheAssets().catch((error) => {
	// do something with the error, or not
});

该函数接收一个可选的 options 对象,其中包含两个选项

  • cacheName 是要使用的 Cache 对象 的名称,默认值为 assets
  • buildPath 是所有 Remix 构建资产的路径名前缀,默认值为 /build/,这是 Remix 本身的默认构建路径。

重要的是,如果你在 remix.config.js 中更改了构建路径,请将相同的值传递给 cacheAssets,否则它将找不到你的 JS 文件。

除非你正在向你的应用程序添加 Service Worker 并想要共享缓存,否则可以保留 cacheName 的默认值。

import { cacheAssets } from "remix-utils/cache-assets";

cacheAssests({ cacheName: "assets", buildPath: "/build/" }).catch((error) => {
	// do something with the error, or not
});

ClientOnly

注意 这依赖于 react

ClientOnly 组件允许你仅在客户端渲染子元素,避免在服务器端渲染它。

你可以提供一个在 SSR 中使用的回退组件,虽然可选,但强烈建议提供一个以避免内容布局偏移问题。

import { ClientOnly } from "remix-utils/client-only";

export default function Component() {
	return (
		<ClientOnly fallback={<SimplerStaticVersion />}>
			{() => <ComplexComponentNeedingBrowserEnvironment />}
		</ClientOnly>
	);
}

当你有某些需要浏览器环境才能工作的复杂组件(例如图表或地图)时,此组件非常方便。这样,你可以避免在服务器端渲染它,而是使用更简单的静态版本,例如 SVG 甚至加载 UI。

渲染流程将是

  • SSR:始终渲染回退组件。
  • CSR 首次渲染:始终渲染回退组件。
  • CSR 更新:更新以渲染实际组件。
  • CSR 未来渲染:始终渲染实际组件,无需再渲染回退组件。

此组件在内部使用 useHydrated 钩子。

ServerOnly

注意 这依赖于 react

ServerOnly 组件与 ClientOnly 组件相反,它允许你仅在服务器端渲染子元素,避免在客户端渲染它。

你可以提供一个在 CSR 中使用的回退组件,虽然可选,但强烈建议提供一个以避免内容布局偏移问题,除非你只渲染视觉上隐藏的元素。

import { ServerOnly } from "remix-utils/server-only";

export default function Component() {
	return (
		<ServerOnly fallback={<ComplexComponentNeedingBrowserEnvironment />}>
			{() => <SimplerStaticVersion />}
		</ServerOnly>
	);
}

此组件非常方便,可以仅在服务器端渲染某些内容,例如稍后可用于了解 JS 是否已加载的隐藏输入。

将其视为 <noscript> HTML 标签,但即使 JS 加载失败但在浏览器中启用时也可以工作。

渲染流程将是

  • SSR:始终渲染子元素。
  • CSR 首次渲染:始终渲染子元素。
  • CSR 更新:更新以渲染回退组件(如果已定义)。
  • CSR 未来渲染:始终渲染回退组件,无需再渲染子元素。

此组件在内部使用 useHydrated 钩子。

CORS

CORS 函数允许你在加载器和操作中实现 CORS 标头,以便你可以将它们用作其他客户端应用程序的 API。

使用 cors 函数主要有两种方法。

  1. 在每个你想要启用它的加载器/操作中使用它。
  2. 在 entry.server 的 handleRequest 和 handleDataRequest 导出中全局使用它。

如果你想在每个加载器/操作中使用它,你可以这样做

import { cors } from "remix-utils/cors";

export async function loader({ request }: LoaderFunctionArgs) {
	let data = await getData(request);
	let response = json<LoaderData>(data);
	return await cors(request, response);
}

你也可以在一行代码中执行 jsoncors 调用。

import { cors } from "remix-utils/cors";

export async function loader({ request }: LoaderFunctionArgs) {
	let data = await getData(request);
	return await cors(request, json<LoaderData>(data));
}

并且由于 cors 会修改响应,你也可以调用它并在稍后返回。

import { cors } from "remix-utils/cors";

export async function loader({ request }: LoaderFunctionArgs) {
	let data = await getData(request);
	let response = json<LoaderData>(data);
	await cors(request, response); // this mutates the Response object
	return response; // so you can return it here
}

如果你想在 entry.server 中全局设置一次,你可以这样做

import { cors } from "remix-utils/cors";

const ABORT_DELAY = 5000;

export default function handleRequest(
	request: Request,
	responseStatusCode: number,
	responseHeaders: Headers,
	remixContext: EntryContext,
) {
	let callbackName = isbot(request.headers.get("user-agent"))
		? "onAllReady"
		: "onShellReady";

	return new Promise((resolve, reject) => {
		let didError = false;

		let { pipe, abort } = renderToPipeableStream(
			<RemixServer context={remixContext} url={request.url} />,
			{
				[callbackName]: () => {
					let body = new PassThrough();

					responseHeaders.set("Content-Type", "text/html");

					cors(
						request,
						new Response(body, {
							headers: responseHeaders,
							status: didError ? 500 : responseStatusCode,
						}),
					).then((response) => {
						resolve(response);
					});

					pipe(body);
				},
				onShellError: (err: unknown) => {
					reject(err);
				},
				onError: (error: unknown) => {
					didError = true;

					console.error(error);
				},
			},
		);

		setTimeout(abort, ABORT_DELAY);
	});
}

export let handleDataRequest: HandleDataRequestFunction = async (
	response,
	{ request },
) => {
	return await cors(request, response);
};

选项

此外,cors 函数接受一个 options 对象作为第三个可选参数。以下是选项。

  • origin:配置 Access-Control-Allow-Origin CORS 标头。可能的值为
    • true:为任何来源启用 CORS(与“*”相同)
    • false:不设置 CORS
    • string:设置为特定来源,如果设置为“*”,则允许任何来源
    • RegExp:设置为一个 RegExp 以匹配来源
    • Array<string | RegExp>:设置为一个来源数组以匹配字符串或 RegExp
    • Function:设置为一个函数,该函数将使用请求来源调用,并应返回一个布尔值,指示来源是否被允许。默认值为 true
  • methods:配置 Access-Control-Allow-Methods CORS 标头。默认值为 ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"]
  • allowedHeaders:配置 Access-Control-Allow-Headers CORS 标头。
  • exposedHeaders:配置 Access-Control-Expose-Headers CORS 标头。
  • credentials:配置 Access-Control-Allow-Credentials CORS 标头。
  • maxAge:配置 Access-Control-Max-Age CORS 标头。

CSRF

注意 这依赖于 reactcrypto-js 和 Remix 服务器运行时。

与 CSRF 相关的函数允许你在应用程序中实现 CSRF 保护。

Remix Utils 的这部分需要 React 和服务器端代码。

首先创建一个新的 CSRF 实例。

// app/utils/csrf.server.ts
import { CSRF } from "remix-utils/csrf/server";
import { createCookie } from "@remix-run/node"; // or cloudflare/deno

export const cookie = createCookie("csrf", {
	path: "/",
	httpOnly: true,
	secure: process.env.NODE_ENV === "production",
	sameSite: "lax",
	secrets: ["s3cr3t"],
});

export const csrf = new CSRF({
	cookie,
	// what key in FormData objects will be used for the token, defaults to `csrf`
	formDataKey: "csrf",
	// an optional secret used to sign the token, recommended for extra safety
	secret: "s3cr3t",
});

然后你可以使用 csrf 生成一个新令牌。

import { csrf } from "~/utils/csrf.server";

export async function loader({ request }: LoaderFunctionArgs) {
	let token = csrf.generate();
}

你可以通过传递字节大小来自定义令牌大小,默认值为 32 字节,编码后将得到长度为 43 的字符串。

let token = csrf.generate(64); // customize token length

你需要将此令牌保存在 cookie 中,并从加载器中返回它。为了方便起见,你可以使用 CSRF#commitToken 辅助函数。

import { csrf } from "~/utils/csrf.server";

export async function loader({ request }: LoaderFunctionArgs) {
	let [token, cookieHeader] = await csrf.commitToken();
	return json({ token }, { headers: { "set-cookie": cookieHeader } });
}

注意 你可以在任何路由上执行此操作,但我建议你在 root 加载器上执行此操作。

现在你已返回令牌并将其设置在 cookie 中,你可以使用 AuthenticityTokenProvider 组件将令牌提供给你的 React 组件。

import { AuthenticityTokenProvider } from "remix-utils/csrf/react";

let { csrf } = useLoaderData<LoaderData>();
return (
	<AuthenticityTokenProvider token={csrf}>
		<Outlet />
	</AuthenticityTokenProvider>
);

在你的 root 组件中渲染它,并用它包装 Outlet

当你在某个路由中创建表单时,你可以使用 AuthenticityTokenInput 组件将真实性令牌添加到表单中。

import { Form } from "@remix-run/react";
import { AuthenticityTokenInput } from "remix-utils/csrf/react";

export default function Component() {
	return (
		<Form method="post">
			<AuthenticityTokenInput />
			<input type="text" name="something" />
		</Form>
	);
}

请注意,身份验证令牌实际上仅在某种程度上修改数据的表单中需要。如果您有一个发出 GET 请求的搜索表单,则无需在其中添加身份验证令牌。

AuthenticityTokenInput 将从 AuthenticityTokenProvider 组件获取身份验证令牌,并将其作为名为 csrf 的隐藏输入的值添加到表单中。您可以使用 name 属性自定义字段名称。

<AuthenticityTokenInput name="customName" />

仅当您也更改了 createAuthenticityToken 中的名称时,才应自定义名称。

如果您需要使用 useFetcher(或 useSubmit)而不是 Form,则还可以使用 useAuthenticityToken 钩子获取身份验证令牌。

import { useFetcher } from "@remix-run/react";
import { useAuthenticityToken } from "remix-utils/csrf/react";

export function useMarkAsRead() {
	let fetcher = useFetcher();
	let csrf = useAuthenticityToken();
	return function submit(data) {
		fetcher.submit(
			{ csrf, ...data },
			{ action: "/api/mark-as-read", method: "post" },
		);
	};
}

最后,您需要在接收请求的操作中验证身份验证令牌。

import { CSRFError } from "remix-utils/csrf/server";
import { redirectBack } from "remix-utils/redirect-back";
import { csrf } from "~/utils/csrf.server";

export async function action({ request }: ActionFunctionArgs) {
	try {
		await csrf.validate(request);
	} catch (error) {
		if (error instanceof CSRFError) {
			// handle CSRF errors
		}
		// handle other possible errors
	}

	// here you know the request is valid
	return redirectBack(request, { fallback: "/fallback" });
}

如果您需要自己将主体解析为 FormData(例如,以支持文件上传),则还可以使用 FormData 和 Headers 对象调用 CSRF#validate

let formData = await parseMultiPartFormData(request);
try {
	await csrf.validate(formData, request.headers);
} catch (error) {
	// handle errors
}

警告 如果您使用请求实例调用 CSRF#validate,但您已经读取了其主体,则它将抛出错误。

如果 CSRF 验证失败,它将抛出 CSRFError,该错误可用于针对可能抛出的其他错误正确识别它。

可能的错误消息列表如下:

  • missing_token_in_cookie:请求中缺少 Cookie 中的 CSRF 令牌。
  • invalid_token_in_cookie:CSRF 令牌无效(不是字符串)。
  • tampered_token_in_cookie:CSRF 令牌与签名不匹配。
  • missing_token_in_body:请求中缺少主体(FormData)中的 CSRF 令牌。
  • mismatched_token:Cookie 和主体中的 CSRF 令牌不匹配。

您可以使用 error.code 检查上述错误代码之一,并使用 error.message 获取对用户友好的描述。

警告 不要将这些错误消息发送给最终用户,它们仅用于调试目的。

现有搜索参数

import { ExistingSearchParams } from "remix-utils/existing-search-params";

注意 这依赖于 react@remix-run/react

提交 GET 表单时,浏览器将使用表单数据替换 URL 中的所有搜索参数。此组件将现有搜索参数复制到隐藏输入中,以便不会覆盖它们。

exclude 属性接受一个搜索参数数组,以将其从隐藏输入中排除。

  • 将此表单处理的参数添加到此列表中。
  • 添加来自其他表单的参数,您希望在提交时清除这些参数。

例如,假设一个数据表具有用于分页、过滤和搜索的单独表单组件。更改页码不应影响搜索或筛选参数。

<Form>
	<ExistingSearchParams exclude={["page"]} />
	<button type="submit" name="page" value="1">
		1
	</button>
	<button type="submit" name="page" value="2">
		2
	</button>
	<button type="submit" name="page" value="3">
		3
	</button>
</Form>

通过从搜索表单中排除 page 参数,用户将返回到搜索结果的第一页。

<Form>
	<ExistingSearchParams exclude={["q", "page"]} />
	<input type="search" name="q" />
	<button type="submit">Search</button>
</Form>

外部脚本

注意 这依赖于 react@remix-run/react 和 Remix 服务器运行时。

如果您需要在某些路由上加载不同的外部脚本,则可以使用 ExternalScripts 组件以及 ExternalScriptsFunctionScriptDescriptor 类型。

在您想要加载脚本的路由中,添加一个 handle 导出,其中包含一个 scripts 方法,将 handle 的类型设置为 ExternalScriptsHandle。此接口允许您将 scripts 定义为函数或数组。

如果您想根据加载器数据定义要加载哪些脚本,可以使用 scripts 作为函数。

import { ExternalScriptsHandle } from "remix-utils/external-scripts";

type LoaderData = SerializeFrom<typeof loader>;

export let handle: ExternalScriptsHandle<LoaderData> = {
  scripts({ id, data, params, matches, location, parentsData }) {
    return [
      {
        src: "https://unpkg.com/[email protected]",
        integrity: "sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni",
        crossOrigin: 'anonymous"
      }
    ];
  },
};

如果要加载的脚本列表是静态的,可以直接将 scripts 定义为数组。

import { ExternalScriptsHandle } from "remix-utils/external-scripts";

export let handle: ExternalScriptsHandle = {
  scripts: [
    {
      src: "https://unpkg.com/[email protected]",
      integrity: "sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni",
      crossOrigin: 'anonymous",
      preload: true, // use it to render a <link rel="preload"> for this script
    }
  ],
};

您还可以自己导入 ExternalScriptsFunctionScriptDescriptor 接口以构建自定义处理类型。

import {
	ExternalScriptsFunction,
	ScriptDescriptor,
} from "remix-utils/external-scripts";

interface AppHandle<LoaderData = unknown> {
	scripts?: ExternalScriptsFunction<LoaderData> | ScriptDescriptor[];
}

export let handle: AppHandle<LoaderData> = {
	scripts, // define scripts as a function or array here
};

或者您可以扩展 ExternalScriptsHandle 接口。

import { ExternalScriptsHandle } from "remix-utils/external-scripts";

interface AppHandle<LoaderData = unknown>
	extends ExternalScriptsHandle<LoaderData> {
	// more handle properties here
}

export let handle: AppHandle<LoaderData> = {
	scripts, // define scripts as a function or array here
};

然后,在根路由中,在某个地方添加 ExternalScripts 组件,通常您希望将其加载到 <head> 内部或 <body> 底部,在 Remix 的 <Scripts> 组件之前或之后。

<ExternalScripts /> 放在哪里取决于您的应用程序,但安全的位置是在 <body> 的末尾。

import { Links, LiveReload, Meta, Scripts, ScrollRestoration } from "remix";
import { ExternalScripts } from "remix-utils/external-scripts";

type Props = { children: React.ReactNode; title?: string };

export function Document({ children, title }: Props) {
	return (
		<html lang="en">
			<head>
				<meta charSet="utf-8" />
				<meta name="viewport" content="width=device-width,initial-scale=1" />
				{title ? <title>{title}</title> : null}
				<Meta />
				<Links />
			</head>
			<body>
				{children}
				<ScrollRestoration />
				<ExternalScripts />
				<Scripts />
				<LiveReload />
			</body>
		</html>
	);
}

现在,您在 ScriptsFunction 中定义的任何脚本都将添加到 HTML 中。

您可以将此实用程序与 useShouldHydrate 一起使用,以在某些路由中禁用 Remix 脚本,但仍加载分析或需要 JS 但不需要启用完整应用程序 JS 的小型功能的脚本。

let shouldHydrate = useShouldHydrate();

return (
	<html lang="en">
		<head>
			<meta charSet="utf-8" />
			<meta name="viewport" content="width=device-width,initial-scale=1" />
			{title ? <title>{title}</title> : null}
			<Meta />
			<Links />
		</head>
		<body>
			{children}
			<ScrollRestoration />
			{shouldHydrate ? <Scripts /> : <ExternalScripts />}
			<LiveReload />
		</body>
	</html>
);

useGlobalNavigationState

注意 这依赖于 react@remix-run/react

此钩子允许您读取 transition.state、应用程序中的每个 fetcher.staterevalidator.state 的值。

import { useGlobalNavigationState } from "remix-utils/use-global-navigation-state";

export function GlobalPendingUI() {
	let states = useGlobalNavigationState();

	if (state.includes("loading")) {
		// The app is loading.
	}

	if (state.includes("submitting")) {
		// The app is submitting.
	}

	// The app is idle
}

useGlobalNavigationState 的返回值可以是 "idle""loading""submitting"

注意 下面的钩子使用它来确定应用程序是否正在加载、提交或两者兼而有之(挂起)。

useGlobalPendingState

注意 这依赖于 react@remix-run/react

此钩子可让您知道全局导航、任何活动获取器之一是否正在加载或提交,或者重新验证器是否正在运行。

import { useGlobalPendingState } from "remix-utils/use-global-navigation-state";

export function GlobalPendingUI() {
  let globalState = useGlobalPendingState();

  if (globalState === "idle") return null;
  return <Spinner />;
}

useGlobalPendingState 的返回值为 "idle""pending"

注意:此钩子结合了 useGlobalSubmittingStateuseGlobalLoadingState 钩子来确定应用程序是否处于挂起状态。

注意pending 状态是此钩子引入的 loadingsubmitting 状态的组合。

useGlobalSubmittingState

注意 这依赖于 react@remix-run/react

此钩子可让您知道全局转换或任何活动获取器之一是否正在提交。

import { useGlobalSubmittingState } from "remix-utils/use-global-navigation-state";

export function GlobalPendingUI() {
  let globalState = useGlobalSubmittingState();

  if (globalState === "idle") return null;
  return <Spinner />;
}

useGlobalSubmittingState 的返回值为 "idle""submitting"

useGlobalLoadingState

注意 这依赖于 react@remix-run/react

此钩子可让您知道全局转换、任何活动获取器之一是否正在加载,或者重新验证器是否正在运行。

import { useGlobalLoadingState } from "remix-utils/use-global-navigation-state";

export function GlobalPendingUI() {
  let globalState = useGlobalLoadingState();

  if (globalState === "idle") return null;
  return <Spinner />;
}

useGlobalLoadingState 的返回值为 "idle""loading"

useHydrated

注意 这依赖于 react

此钩子可让您检测您的组件是否已完成水合。这意味着客户端加载了元素的 JS,并且 React 正在运行。

使用 useHydrated,您可以在服务器和客户端上呈现不同的内容,同时确保水合不会出现 HTML 不匹配的情况。

import { useHydrated } from "remix-utils/use-hydrated";

export function Component() {
  let isHydrated = useHydrated();

  if (isHydrated) {
    return <ClientOnlyComponent />;
  }

  return <ServerFallback />;
}

在执行 SSR 时,isHydrated 的值将始终为 false。第一次客户端渲染 isHydrated 仍将为 false,然后它将更改为 true

在第一次客户端渲染之后,将来调用此钩子呈现的组件将接收 true 作为 isHydrated 的值。这样,您的服务器回退 UI 永远不会在路由转换中呈现。

useLocales

注意 这依赖于 react

此钩子可让您获取根加载器返回的语言环境。它遵循一个简单的约定,您的根加载器返回值应为一个键为 locales 的对象。

您可以将其与 getClientLocal 结合使用,以在根加载器上获取语言环境并返回该语言环境。useLocales 的返回值为 Locales 类型,即 string | string[] | undefined

import { useLocales } from "remix-utils/locales/react";
import { getClientLocales } from "remix-utils/locales/server";

// in the root loader
export async function loader({ request }: LoaderFunctionArgs) {
  let locales = getClientLocales(request);
  return json({ locales });
}

// in any route (including root!)
export default function Component() {
  let locales = useLocales();
  let date = new Date();
  let dateTime = date.toISOString;
  let formattedDate = date.toLocaleDateString(locales, options);
  return <time dateTime={dateTime}>{formattedDate}</time>;
}

useLocales 的返回类型可直接用于 Intl API。

useShouldHydrate

注意 这依赖于 @remix-run/reactreact

如果您正在构建一个大多数路由都是静态的 Remix 应用程序,并且想要避免加载客户端 JS,则可以使用此钩子以及一些约定来检测一个或多个活动路由是否需要 JS,并在这种情况下仅呈现 Scripts 组件。

在您的文档组件中,您可以调用此钩子以根据需要动态呈现 Scripts 组件。

import type { ReactNode } from "react";
import { Links, LiveReload, Meta, Scripts } from "@remix-run/react";
import { useShouldHydrate } from "remix-utils/use-should-hydrate";

interface DocumentProps {
	children: ReactNode;
	title?: string;
}

export function Document({ children, title }: DocumentProps) {
	let shouldHydrate = useShouldHydrate();
	return (
		<html lang="en">
			<head>
				<meta charSet="utf-8" />
				<link rel="icon" href="/favicon.png" type="image/png" />
				{title ? <title>{title}</title> : null}
				<Meta />
				<Links />
			</head>
			<body>
				{children}
				{shouldHydrate && <Scripts />}
				<LiveReload />
			</body>
		</html>
	);
}

现在,您可以在任何路由模块中导出一个 handle 对象,其中 hydrate 属性为 true

export let handle = { hydrate: true };

这将标记路由为需要 JS 水合。

在某些情况下,路由可能需要基于加载器返回的数据的 JS。例如,如果您有一个购买产品的组件,但只有经过身份验证的用户才能看到它,那么在用户经过身份验证之前,您不需要 JS。在这种情况下,您可以使 hydrate 成为一个接收加载器数据的函数。

export let handle = {
	hydrate(data: LoaderData) {
		return data.user.isAuthenticated;
	},
};

useShouldHydrate 钩子将检测 hydrate 作为函数并使用路由数据调用它。

getClientIPAddress

注意 这依赖于 is-ip

此函数接收 Request 或 Headers 对象,并将尝试获取发起请求的客户端(用户)的 IP 地址。

import { getClientIPAddress } from "remix-utils/get-client-ip-address";

export async function loader({ request }: LoaderFunctionArgs) {
	// using the request
	let ipAddress = getClientIPAddress(request);
	// or using the headers
	let ipAddress = getClientIPAddress(request.headers);
}

如果找不到 IP 地址,则返回值将为 null。请记住,在使用它之前检查它是否能够找到它。

该函数按优先级顺序使用以下标头列表:

  • X-Client-IP
  • X-Forwarded-For
  • HTTP-X-Forwarded-For
  • Fly-Client-IP
  • CF-Connecting-IP
  • Fastly-Client-Ip
  • True-Client-Ip
  • X-Real-IP
  • X-Cluster-Client-IP
  • X-Forwarded
  • Forwarded-For
  • Forwarded
  • DO-Connecting-IP
  • oxygen-buyer-ip

找到包含有效 IP 地址的标头后,它将返回而不检查其他标头。

警告 在本地开发中,该函数很可能返回 null。这是因为浏览器没有发送任何上述标头,如果您想模拟这些标头,则需要将其添加到您的 HTTP 服务器中接收的请求中,或者运行像 NGINX 这样的反向代理,它可以为您添加这些标头。

getClientLocales

注意 这依赖于 intl-parse-accept-language

此函数可让您获取发起请求的客户端(用户)的语言环境。

import { getClientLocales } from "remix-utils/locales/server";

export async function loader({ request }: LoaderFunctionArgs) {
	// using the request
	let locales = getClientLocales(request);
	// or using the headers
	let locales = getClientLocales(request.headers);
}

返回值为 Locales 类型,即 string | string[] | undefined

返回的语言环境可以在格式化日期、数字等时直接用于 Intl API。

import { getClientLocales } from "remix-utils/locales/server";
export async function loader({ request }: LoaderFunctionArgs) {
	let locales = getClientLocales(request);
	let nowDate = new Date();
	let formatter = new Intl.DateTimeFormat(locales, {
		year: "numeric",
		month: "long",
		day: "numeric",
	});
	return json({ now: formatter.format(nowDate) });
}

加载器也可以返回值,并在 UI 上使用它,以确保在服务器和客户端格式化日期时都使用用户的语言环境。

isPrefetch

此函数可让您识别请求是否是因为使用 <Link prefetch="intent"><Link prefetch="render"> 触发的预取而创建的。

这将允许您仅对预取请求实现短缓存,以便您避免双重数据请求

import { isPrefetch } from "remix-utils/is-prefetch";

export async function loader({ request }: LoaderFunctionArgs) {
	let data = await getData(request);
	let headers = new Headers();

	if (isPrefetch(request)) {
		headers.set("Cache-Control", "private, max-age=5, smax-age=0");
	}

	return json(data, { headers });
}

响应

重定向回

此函数是 Remix 中 redirect 帮助器的包装器。与 Remix 的版本不同,此版本接收整个请求对象作为第一个值,以及一个包含响应初始化和回退 URL 的对象。

使用此函数创建的响应将具有指向请求中 Referer 标头的 Location 标头,或者如果不可用,则指向第二个参数中提供的回退 URL。

import { redirectBack } from "remix-utils/redirect-back";

export async function action({ request }: ActionFunctionArgs) {
	throw redirectBack(request, { fallback: "/" });
}

此帮助器在用于通用操作将用户发送到其之前所在的同一 URL 时最有用。

未修改

用于创建不带主体和任何标头的未修改 (304) 响应的帮助器函数。

import { notModified } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
	return notModified();
}

JavaScript

用于创建具有任何标头的 JavaScript 文件响应的帮助器函数。

这对于基于资源路由内的创建 JS 文件很有用。

import { javascript } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
	return javascript("console.log('Hello World')");
}

样式表

辅助函数,用于创建带有任何 header 的 CSS 文件响应。

这对于根据资源路由内的数据创建 CSS 文件很有用。

import { stylesheet } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
	return stylesheet("body { color: red; }");
}

PDF

辅助函数,用于创建带有任何 header 的 PDF 文件响应。

这对于根据资源路由内的数据创建 PDF 文件很有用。

import { pdf } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
	return pdf(await generatePDF(request.formData()));
}

HTML

辅助函数,用于创建带有任何 header 的 HTML 文件响应。

这对于根据资源路由内的数据创建 HTML 文件很有用。

import { html } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
	return html("<h1>Hello World</h1>");
}

XML

辅助函数,用于创建带有任何 header 的 XML 文件响应。

这对于根据资源路由内的数据创建 XML 文件很有用。

import { xml } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
	return xml("<?xml version='1.0'?><catalog></catalog>");
}

纯文本

辅助函数,用于创建带有任何 header 的 TXT 文件响应。

这对于根据资源路由内的数据创建 TXT 文件很有用。

import { txt } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
	return txt(`
    User-agent: *
    Allow: /
  `);
}

类型化 Cookie

注意 这依赖于 zod 和 Remix 服务器运行时。

Remix 中的 Cookie 对象允许任何类型,Remix Utils 中的类型化 Cookie 允许您使用 Zod 解析 Cookie 值并确保它们符合模式。

import { createCookie } from "@remix-run/node";
import { createTypedCookie } from "remix-utils/typed-cookie";
import { z } from "zod";

let cookie = createCookie("returnTo", cookieOptions);
// I recommend you to always add `nullable` to your schema, if a cookie didn't
// come with the request Cookie header Remix will return null, and it can be
// useful to remove it later when clearing the cookie
let schema = z.string().url().nullable();

// pass the cookie and the schema
let typedCookie = createTypedCookie({ cookie, schema });

// this will be a string and also a URL
let returnTo = await typedCookie.parse(request.headers.get("Cookie"));

// this will not pass the schema validation and throw a ZodError
await typedCookie.serialize("a random string that's not a URL");
// this will make TS yell because it's not a string, if you ignore it it will
// throw a ZodError
await typedCookie.serialize(123);

您还可以将类型化 Cookie 与 Remix 中的任何 sessionStorage 机制一起使用。

let cookie = createCookie("session", cookieOptions);
let schema = z.object({ token: z.string() }).nullable();

let sessionStorage = createCookieSessionStorage({
	cookie: createTypedCookie({ cookie, schema }),
});

// if this works then the correct data is stored in the session
let session = sessionStorage.getSession(request.headers.get("Cookie"));

session.unset("token"); // remove a required key from the session

// this will throw a ZodError because the session is missing the required key
await sessionStorage.commitSession(session);

现在 Zod 将确保您尝试保存到 session 的数据有效,删除任何额外的字段,并在您未在 session 中设置正确数据时抛出错误。

注意 session 对象实际上没有类型化,因此执行 session.get 将不会返回正确的类型,您可以执行 schema.parse(session.data) 以获取 session 数据的类型化版本。

您还可以在模式中使用异步细化,因为类型化 Cookie 使用 Zod 的 parseAsync 方法。

let cookie = createCookie("session", cookieOptions);

let schema = z
	.object({
		token: z.string().refine(async (token) => {
			let user = await getUserByToken(token);
			return user !== null;
		}, "INVALID_TOKEN"),
	})
	.nullable();

let sessionTypedCookie = createTypedCookie({ cookie, schema });

// this will throw if the token stored in the cookie is not valid anymore
sessionTypedCookie.parse(request.headers.get("Cookie"));

最后,要能够删除 Cookie,您可以在模式中添加 .nullable() 并将其序列化为值为 null

// Set the value as null and expires as current date - 1 second so the browser expires the cookie
await typedCookie.serialize(null, { expires: new Date(Date.now() - 1) });

如果您没有在模式中添加 .nullable(),则需要提供一个模拟值并将过期日期设置为过去。

let cookie = createCookie("returnTo", cookieOptions);
let schema = z.string().url().nullable();

let typedCookie = createTypedCookie({ cookie, schema });

await typedCookie.serialize("some fake url to pass schema validation", {
	expires: new Date(Date.now() - 1),
});

类型化 Session

注意 这依赖于 zod 和 Remix 服务器运行时。

Remix 中的 Session 对象允许任何类型,Remix Utils 中的类型化 Session 允许您使用 Zod 解析 Session 数据并确保它们符合模式。

import { createCookieSessionStorage } from "@remix-run/node";
import { createTypedSessionStorage } from "remix-utils/typed-session";
import { z } from "zod";

let schema = z.object({
	token: z.string().optional(),
	count: z.number().default(1),
});

// you can use a Remix's Cookie container or a Remix Utils' Typed Cookie container
let sessionStorage = createCookieSessionStorage({ cookie });

// pass the session storage and the schema
let typedSessionStorage = createTypedSessionStorage({ sessionStorage, schema });

现在您可以使用 typedSessionStorage 作为普通 sessionStorage 的直接替换。

let session = typedSessionStorage.getSession(request.headers.get("Cookie"));

session.get("token"); // this will be a string or undefined
session.get("count"); // this will be a number
session.get("random"); // this will make TS yell because it's not in the schema

session.has("token"); // this will be a boolean
session.has("count"); // this will be a boolean

// this will make TS yell because it's not a string, if you ignore it it will
// throw a ZodError
session.set("token", 123);

现在 Zod 将通过不允许您获取、设置或取消设置数据来确保您尝试保存到 session 的数据有效。

注意 请记住,您需要将字段标记为可选或在模式中设置默认值,否则将无法调用 getSession 来获取新的 session 对象。

您还可以在模式中使用异步细化,因为类型化 Session 使用 Zod 的 parseAsync 方法。

let schema = z.object({
	token: z
		.string()
		.optional()
		.refine(async (token) => {
			if (!token) return true; // handle optionallity
			let user = await getUserByToken(token);
			return user !== null;
		}, "INVALID_TOKEN"),
});

let typedSessionStorage = createTypedSessionStorage({ sessionStorage, schema });

// this will throw if the token stored in the session is not valid anymore
typedSessionStorage.getSession(request.headers.get("Cookie"));

服务器发送事件

注意 这依赖于 react

服务器发送事件是一种在无需客户端请求的情况下从服务器向客户端发送数据的方式。这对于聊天应用程序、实时更新等很有用。

提供了两个实用程序来帮助在 Remix 中使用。

  • eventStream
  • useEventSource

eventStream 函数用于创建新的事件流响应,该响应需要向客户端发送事件。这必须位于 资源路由 中。

// app/routes/sse.time.ts
import { eventStream } from "remix-utils/sse/server";
import { interval } from "remix-utils/timers";

export async function loader({ request }: LoaderFunctionArgs) {
	return eventStream(request.signal, function setup(send) {
		async function run() {
			for await (let _ of interval(1000, { signal: request.signal })) {
				send({ event: "time", data: new Date().toISOString() });
			}
		}

		run();
	});
}

然后,在任何组件内部,您可以使用 useEventSource 钩子连接到事件流。

// app/components/counter.ts
import { useEventSource } from "remix-utils/sse/react";

function Counter() {
	// Here `/sse/time` is the resource route returning an eventStream response
	let time = useEventSource("/sse/time", { event: "time" });

	if (!time) return null;

	return (
		<time dateTime={time}>
			{new Date(time).toLocaleTimeString("en", {
				minute: "2-digit",
				second: "2-digit",
				hour: "2-digit",
			})}
		</time>
	);
}

事件流和钩子中的 event 名称是可选的,在这种情况下,它将默认为 message,如果已定义,则必须在两侧使用相同的事件名称,这也允许您从同一个事件流发出不同的事件。

为了使服务器发送事件正常工作,您的服务器必须支持 HTTP 流。如果您无法使 SSE 工作,请检查您的部署平台是否支持它。

由于 SSE 计入每个域的 HTTP 连接限制,因此 useEventSource 钩子根据提供的 URL 和选项维护连接的全局映射。只要它们相同,钩子就会打开单个 SSE 连接并在钩子的实例之间共享它。

一旦不再有钩子实例重用连接,它将被关闭并从映射中删除。

您可以使用 <EventSourceProvider /> 组件来控制映射。

let map: EventSourceMap = new Map();
return (
	<EventSourceProvider value={map}>
		<YourAppOrPartOfIt />
	</EventSourceProvider>
);

这样,您可以为应用程序的特定部分覆盖映射。请注意,此提供程序是可选的,如果您不提供,将使用默认映射。

滚动 Cookie

注意 这依赖于 zod 和 Remix 服务器运行时。

滚动 Cookie 允许您通过更新每个 Cookie 的过期日期来延长 Cookie 的有效期。

rollingCookie 函数准备在 entry.server 导出的函数中使用,以更新 Cookie 的过期日期(如果加载程序未设置)。

对于文档请求,您可以在 handleRequest 函数中使用它。

import { rollingCookie } from "remix-utils/rolling-cookie";

import { sessionCookie } from "~/session.server";

export default function handleRequest(
	request: Request,
	responseStatusCode: number,
	responseHeaders: Headers,
	remixContext: EntryContext,
) {
	await rollingCookie(sessionCookie, request, responseHeaders);

	return isbot(request.headers.get("user-agent"))
		? handleBotRequest(
				request,
				responseStatusCode,
				responseHeaders,
				remixContext,
		  )
		: handleBrowserRequest(
				request,
				responseStatusCode,
				responseHeaders,
				remixContext,
		  );
}

对于数据请求,您可以在 handleDataRequest 函数中使用它。

import { rollingCookie } from "remix-utils/rolling-cookie";

export let handleDataRequest: HandleDataRequestFunction = async (
	response: Response,
	{ request },
) => {
	let cookieValue = await sessionCookie.parse(
		responseHeaders.get("set-cookie"),
	);
	if (!cookieValue) {
		cookieValue = await sessionCookie.parse(request.headers.get("cookie"));
		responseHeaders.append(
			"Set-Cookie",
			await sessionCookie.serialize(cookieValue),
		);
	}

	return response;
};

注意 > 阅读有关 Remix 中滚动 Cookie 的更多信息

命名操作

注意 这依赖于 Remix 服务器运行时。

通常需要在同一路由中处理多个操作,这里有很多选项,例如 将表单发送到资源路由 或使用 操作 reducernamedAction 函数使用一些约定来实现操作 reducer 模式。

import { namedAction } from "remix-utils/named-action";

export async function action({ request }: ActionFunctionArgs) {
	return namedAction(request, {
		async create() {
			// do create
		},
		async update() {
			// do update
		},
		async delete() {
			// do delete
		},
	});
}

export default function Component() {
	return (
		<>
			<Form method="post" action="?/create">
				...
			</Form>

			<Form method="post" action="?/update">
				...
			</Form>

			<Form method="post" action="?/delete">
				...
			</Form>
		</>
	);
}

此函数可以遵循许多约定。

您可以将 FormData 对象传递给 namedAction,然后它将尝试

  • 查找名为 /something 的字段并将其用作操作名称,删除 /
  • 查找名为 intent 的字段并将其值用作操作名称。
  • 查找名为 action 的字段并将其值用作操作名称。
  • 查找名为 _action 的字段并将其值用作操作名称。

您可以将 URLSearchParams 对象传递给 namedAction,然后它将尝试

  • 查找名为 /something 的查询参数并将其用作操作名称,删除 /
  • 查找名为 intent 的查询参数并将其值用作操作名称。
  • 查找名为 action 的查询参数并将其值用作操作名称。
  • 查找名为 _action 的查询参数并将其值用作操作名称。

您可以将 URL 对象传递给 namedAction,它的行为将与 URLSearchParams 对象相同。

您可以将 Request 对象传递给 namedAction,然后它将尝试

  • 调用 new URL(request.url) 并将其用作 URL 对象。
  • 调用 request.formData() 并将其用作 FormData 对象。

如果在任何情况下都找不到操作名称,则库将尝试调用名为 default 的操作,类似于 JavaScript 中的 switch

如果未定义 default,它将抛出带有消息 Action "${name}" not found 的 ReferenceError。

如果库根本找不到名称,它将抛出带有消息 Action name not found 的 ReferenceError。

预加载路由资源

[!注意] 这可能会创建巨大的 Link header 并导致极难调试的问题。某些提供商的负载均衡器已为解析传出响应的 header 设置了某些缓冲区,并且由于 preloadRouteAssets,您可以在中等规模的应用程序中轻松达到该缓冲区。您的负载均衡器可能会随机停止响应或开始抛出 502 错误。要克服此问题,请不要使用 preloadRouteAssets,如果您拥有负载均衡器,请设置更大的 header 处理缓冲区,或者在 Vite 配置中使用 experimentalMinChunkSize 选项(这不会永久解决问题,只会延迟它)。

Link header 允许响应将文档所需的资源推送到浏览器,这对于通过更早地发送这些资源来提高应用程序性能很有用。

一旦支持早期提示,这还将允许您在文档准备好之前发送资源,但目前,您可以从中受益,在浏览器解析 HTML 之前发送资源进行预加载。

您可以使用 preloadRouteAssetspreloadLinkedAssetspreloadModuleAssets 函数来执行此操作。

所有函数都遵循相同的签名。

import { preloadRouteAssets, preloadLinkedAssets, preloadModuleAssets } from "remix-utils/preload-route-assets";

// entry.server.tsx
export default function handleRequest(
  request: Request,
  statusCode: number,
  headers: Headers,
  context: EntryContext,
) {
  let markup = renderToString(
    <RemixServer context={context} url={request.url} />,
  );
  headers.set("Content-Type", "text/html");

  preloadRouteAssets(context, headers); // add this line
  // preloadLinkedAssets(context, headers);
  // preloadModuleAssets(context, headers);

  return new Response("<!DOCTYPE html>" + markup, {
    status: statusCode,
    headers: headers,
  });
}

preloadRouteAssetspreloadLinkedAssetspreloadModuleAssets 的组合,因此您可以使用它来预加载路由的所有资源,如果您使用此函数,则不需要另外两个函数。

preloadLinkedAssets 函数将预加载任何带有 rel: "preload" 的链接(使用 Remix 的 LinkFunction 添加),因此您可以在路由中配置要预加载的资源并自动在 header 中发送它们。此外,它还会预加载任何链接的样式表文件(带有 rel: "stylesheet"),即使未预加载,因此加载速度也会更快。

preloadModuleAssets 函数将预加载 Remix 在对其进行水合时添加到页面中的所有 JS 文件,Remix 已经在每个 <script type="module"> 之前渲染了一个 <link rel="modulepreload"> 用于启动应用程序,这将使用 Link header 来预加载这些资源。

安全重定向

执行重定向时,如果 URL 是用户提供的,我们不能信任它,如果您这样做,您将打开一个漏洞,允许恶意行为者将用户重定向到恶意网站,从而进行网络钓鱼诈骗。

https://remix.utills/?redirectTo=https://malicious.app

为了帮助您防止这种情况,Remix Utils 提供了一个 safeRedirect 函数,可用于检查 URL 是否“安全”。

注意 在此上下文中,“安全”表示 URL 以 / 开头但不以 // 开头,这意味着 URL 是同一应用程序内的路径名,而不是外部链接。

import { safeRedirect } from "remix-utils/safe-redirect";

export async function loader({ request }: LoaderFunctionArgs) {
	let { searchParams } = new URL(request.url);
	let redirectTo = searchParams.get("redirectTo");
	return redirect(safeRedirect(redirectTo, "/home"));
}

safeRedirect 的第二个参数是默认重定向,在未配置时为 /,这允许您告诉 safeRedirect 在值不安全时将用户重定向到哪里。

JSON 哈希响应

loader 函数返回 json 时,您可能需要从不同的数据库查询或 API 请求中获取数据,通常您会执行以下操作。

export async function loader({ params }: LoaderData) {
	let postId = z.string().parse(params.postId);
	let [post, comments] = await Promise.all([getPost(), getComments()]);
	return json({ post, comments });

	async function getPost() {
		/* … */
	}
	async function getComments() {
		/* … */
	}
}

jsonHash 函数允许您直接在 json 中定义这些函数,从而减少创建额外函数和变量的需要。

import { jsonHash } from "remix-utils/json-hash";

export async function loader({ params }: LoaderData) {
	let postId = z.string().parse(params.postId);
	return jsonHash({
		async post() {
			// Implement me
		},
		async comments() {
			// Implement me
		},
	});
}

它还使用Promise.all调用您的函数,因此您可以确保并行检索数据。

此外,您可以传递非异步函数、值和 Promise。

import { jsonHash } from "remix-utils/json-hash";

export async function loader({ params }: LoaderData) {
	let postId = z.string().parse(params.postId);
	return jsonHash({
		postId, // value
		comments: getComments(), // Promise
		slug() {
			// Non-async function
			return postId.split("-").at(1); // get slug from postId param
		},
		async post() {
			// Async function
			return await getPost(postId);
		},
	});

	async function getComments() {
		/* … */
	}
}

jsonHash的结果是TypedResponse,并且其类型正确,因此将其与typeof loader一起使用可以完美运行。

export default function Component() {
	// all correctly typed
	let { postId, comments, slug, post } = useLoaderData<typeof loader>();

	// more code…
}

将锚点委托给 Remix

在使用 Remix 时,您可以使用<Link>组件在页面之间导航。但是,如果您有一个链接到应用中页面的<a href>,它会导致完整的页面刷新。这可能是您想要的,但有时您希望在此处使用客户端导航。

useDelegatedAnchors钩子允许您向应用一部分中的锚标记添加客户端导航。当处理来自 CMS 的动态内容(如 HTML 或 Markdown)时,这可能特别有用。

import { useDelegatedAnchors } from "remix-utils/use-delegated-anchors";

export async function loader() {
	let content = await fetchContentFromCMS();
	return json({ content });
}

export default function Component() {
	let { content } = useLoaderData<typeof loader>();

	let ref = useRef<HTMLDivElement>(null);
	useDelegatedAnchors(ref);

	return <article ref={ref} dangerouslySetInnerHTML={{ __html: content }} />;
}

预取锚点

此外,如果您希望能够预取锚点,可以使用PrefetchPageAnchors组件。

此组件使用锚点包装您的内容,它检测任何悬停的锚点以预取它,并将它们委托给 Remix。

import { PrefetchPageAnchors } from "remix-utils/use-delegated-anchors";

export async function loader() {
	let content = await fetchContentFromCMS();
	return json({ content });
}

export default function Component() {
	let { content } = useLoaderData<typeof loader>();

	return (
		<PrefetchPageAnchors>
			<article ref={ref} dangerouslySetInnerHTML={{ __html: content }} />
		</PrefetchPageAnchors>
	);
}

现在,您可以在 DevTools 中看到,当用户将鼠标悬停在锚点上时,它将预取该锚点,而当用户点击它时,它将执行客户端导航。

去抖动获取器和提交

注意 这依赖于 react@remix-run/react

useDebounceFetcheruseDebounceSubmituseFetcheruseSubmit的包装器,添加了去抖动支持。

这些钩子基于@JacobParis文章

import { useDebounceFetcher } from "remix-utils/use-debounce-fetcher";

export function Component({ data }) {
	let fetcher = useDebounceFetcher<Type>();

	function handleClick() {
		fetcher.submit(data, { debounceTimeout: 1000 });
	}

	return (
		<button type="button" onClick={handleClick}>
			Do Something
		</button>
	);
}

useDebounceSubmit一起使用的方式类似。

import { useDebounceSubmit } from "remix-utils/use-debounce-submit";

export function Component({ name }) {
	let submit = useDebounceSubmit();

	return (
		<input
			name={name}
			type="text"
			onChange={(event) => {
				submit(event.target.form, {
					navigate: false, // use a fetcher instead of a page navigation
					fetcherKey: name, // cancel any previous fetcher with the same key
					debounceTimeout: 1000,
				});
			}}
			onBlur={() => {
				submit(event.target.form, {
					navigate: false,
					fetcherKey: name,
					debounceTimeout: 0, // submit immediately, canceling any pending fetcher
				});
			}}
		/>
	);
}

派生获取器类型

注意 这依赖于@remix-route/react

从获取器和导航数据中派生已弃用的fetcher.type的值。

import { getFetcherType } from "remix-utils/fetcher-type";

function Component() {
	let fetcher = useFetcher();
	let navigation = useNavigation();
	let fetcherType = getFetcherType(fetcher, navigation);
	useEffect(() => {
		if (fetcherType === "done") {
			// do something once the fetcher is done submitting the data
		}
	}, [fetcherType]);
}

您还可以使用 React Hook API,它可以让您避免调用useNavigation

import { useFetcherType } from "remix-utils/fetcher-type";

function Component() {
	let fetcher = useFetcher();
	let fetcherType = useFetcherType(fetcher);
	useEffect(() => {
		if (fetcherType === "done") {
			// do something once the fetcher is done submitting the data
		}
	}, [fetcherType]);
}

如果您需要传递获取器类型,还可以导入FetcherType类型。

import { type FetcherType } from "remix-utils/fetcher-type";

function useCallbackOnDone(type: FetcherType, cb) {
	useEffect(() => {
		if (type === "done") cb();
	}, [type, cb]);
}

用于内容协商的 respondTo

如果您正在构建资源路由并希望根据客户端请求的内容类型发送不同的响应(例如,将相同的数据作为 PDF 或 XML 或 JSON 发送),则需要实现内容协商,这可以通过respondTo标头来完成。

import { respondTo } from "remix-utils/respond-to";

export async function loader({ request }: LoaderFunctionArgs) {
  // do any work independent of the response type before respondTo
  let data = await getData(request);

  let headers = new Headers({ vary: "accept" });

  // Here we will decide how to respond to different content types
  return respondTo(request, {
    // The handler can be a subtype handler, in `text/html` html is the subtype
    html() {
      // We can call any function only really need to respond to this
      // content-type
      let body = ReactDOMServer.renderToString(<UI {...data} />);
      headers.append("content-type", "text/html");
      return new Response(body, { headers });
    },
    // It can also be a highly specific type
    async "application/rss+xml"() {
      // we can do more async work inside this code if needed
      let body = await generateRSSFeed(data);
      headers.append("content-type", "application/rss+xml");
      return new Response(body, { headers });
    },
    // Or a generic type
    async text() {
      // To respond to any text type, e.g. text/plain, text/csv, etc.
      let body = generatePlain(data);
      headers.append("content-type", "text/plain");
      return new Response(body, { headers });
    },
    // The default will be used if the accept header doesn't match any of the
    // other handlers
    default() {
      // Here we could have a default type of response, e.g. use json by
      // default, or we can return a 406 which means the server can't respond
      // with any of the requested content types
      return new Response("Not Acceptable", { status: 406 });
    },
  });
}

现在,respondTo函数将检查Accept标头并调用正确的处理程序,为了知道调用哪个处理程序,它将使用同样从 Remix Utils 导出的parseAcceptHeader函数。

import { parseAcceptHeader } from "remix-utils/parse-accept-header";

let parsed = parseAcceptHeader(
	"text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, image/*, */*;q=0.8",
);

结果是一个包含类型、子类型和额外参数(例如q值)的数组。顺序将与标头中遇到的顺序相同,在上面的示例中,text/html将是第一个,其次是application/xhtml+xml

这意味着respondTo帮助程序将优先处理与text/html匹配的任何处理程序,在上面的示例中,将是html处理程序,但如果我们将其删除,则将改为调用text处理程序。67

表单蜜罐

注意 这依赖于reactcrypto-js

蜜罐是一种简单的技术,可以防止垃圾邮件机器人提交表单。它的工作原理是在表单中添加一个隐藏字段,机器人会填写该字段,但人类不会填写。

Remix Utils 中有一对实用程序可以帮助您实现此功能。

首先,创建一个honeypot.server.ts,您将在其中实例化和配置您的蜜罐。

import { Honeypot } from "remix-utils/honeypot/server";

// Create a new Honeypot instance, the values here are the defaults, you can
// customize them
export const honeypot = new Honeypot({
	randomizeNameFieldName: false,
	nameFieldName: "name__confirm",
	validFromFieldName: "from__confirm", // null to disable it
	encryptionSeed: undefined, // Ideally it should be unique even between processes
});

然后,在您的app/root加载程序中,调用honeypot.getInputProps()并返回它。

// app/root.tsx
import { honeypot } from "~/honeypot.server";

export async function loader() {
	// more code here
	return json({ honeypotInputProps: honeypot.getInputProps() });
}

并在app/root组件中渲染HoneypotProvider组件,以包装其余的 UI。

import { HoneypotProvider } from "remix-utils/honeypot/react";

export default function Component() {
	// more code here
	return (
		// some JSX
		<HoneypotProvider {...honeypotInputProps}>
			<Outlet />
		</HoneypotProvider>
		// end that JSX
	);
}

现在,在您想要防止垃圾邮件的每个公共表单(如登录表单)中,渲染HoneypotInputs组件。

import { HoneypotInputs } from "remix-utils/honeypot/react";

function SomePublicForm() {
	return (
		<Form method="post">
			<HoneypotInputs label="Please leave this field blank" />
			{/* more inputs and some buttons */}
		</Form>
	);
}

注意 上面的标签值是默认值,使用它可以允许本地化标签,或者如果您不想更改它,则将其删除。

最后,在表单提交到的操作中,您可以调用honeypot.check

import { SpamError } from "remix-utils/honeypot/server";
import { honeypot } from "~/honeypot.server";

export async function action({ request }) {
	let formData = await request.formData();
	try {
		honeypot.check(formData);
	} catch (error) {
		if (error instanceof SpamError) {
			// handle spam requests here
		}
		// handle any other possible error here, e.g. re-throw since nothing else
		// should be thrown
	}
	// the rest of your action
}

Sec-Fetch 解析器

注意 这依赖于zod

Sec-Fetch标头包含有关请求的信息,例如数据将用于何处,或是否由用户发起。

您可以使用remix-utils/sec-fetch实用程序来解析这些标头并获取所需的信息。

import {
	fetchDest,
	fetchMode,
	fetchSite,
	isUserInitiated,
} from "remix-utils/sec-fetch";

Sec-Fetch-Dest

Sec-Fetch-Dest标头指示请求的目标,例如documentimagescript等。

如果值为empty,则表示它将由fetch调用使用,这意味着您可以通过检查它是否为document(无 JS)或empty(启用 JS)来区分使用和不使用 JS 发起的请求。

import { fetchDest } from "remix-utils/sec-fetch";

export async function action({ request }: ActionFunctionArgs) {
	let data = await getDataSomehow();

	// if the request was made with JS, we can just return json
	if (fetchDest(request) === "empty") return json(data);
	// otherwise we redirect to avoid a reload to trigger a new submission
	return redirect(destination);
}

Sec-Fetch-Mode

Sec-Fetch-Mode标头指示请求的启动方式,例如,如果值为navigate,则表示它是用户加载页面触发的,如果值为no-cors,则可能是正在加载的图像。

import { fetchMode } from "remix-utils/sec-fetch";

export async function loader({ request }: LoaderFunctionArgs) {
	let mode = fetchMode(request);
	// do something based on the mode value
}

Sec-Fetch-Site

Sec-Fetch-Site标头指示请求的发出位置,例如same-origin表示请求发往同一域名,cross-site表示请求发往不同的域名。

import { fetchSite } from "remix-utils/sec-fetch";

export async function loader({ request }: LoaderFunctionArgs) {
	let site = fetchSite(request);
	// do something based on the site value
}

Sec-Fetch-User

Sec-Fetch-User标头指示请求是否由用户发起,这可以用于区分用户发起的请求和浏览器发起的请求,例如,浏览器加载图像发起的请求。

import { isUserInitiated } from "remix-utils/sec-fetch";

export async function loader({ request }: LoaderFunctionArgs) {
	let userInitiated = isUserInitiated(request);
	// do something based on the userInitiated value
}

计时器

计时器实用程序为您提供了一种在执行某些操作之前等待特定时间或每隔一段时间运行某些代码的方法。

使用wait结合AbortSignal,我们可以取消超时,如果用户从页面导航离开。

import { wait } from "remix-utils/timers";

export async function loader({ request }: LoaderFunctionArgs) {
	await wait(1000, { signal: request.signal });
	// do something after 1 second
}

使用interval结合eventStream,我们可以每隔一段时间向客户端发送一个值。并确保在连接关闭时取消间隔。

import { eventStream } from "remix-utils/sse/server";
import { interval } from "remix-utils/timers";

export async function loader({ request }: LoaderFunctionArgs) {
	return eventStream(request.signal, function setup(send) {
		async function run() {
			for await (let _ of interval(1000, { signal: request.signal })) {
				send({ event: "time", data: new Date().toISOString() });
			}
		}

		run();
	});
}

作者

许可证

  • MIT 许可证