【翻译】在 React Router 中理清对话框

在 React Router 中理清对话框

对话框看起来很简单......直到你要决定何时何地加载数据、处理错误与成功反馈,并最终不可避免地祭出 useEffect。这篇文章里,我会拆解如何用 React Router 7 实现模态对话框:嵌套路由、loader 请求优化、反馈提示、程序化关闭对话框,以及保留动画......而且全程 不使用任何 useEffect

项目搭建

我会带你走一遍一个简单的演示应用。这个应用用来管理一个 Ollama 实例。Ollama 是一个开源工具,让你可以在自己的电脑或服务器上本地运行大语言模型(LLM)。我们的应用会有一个路由列出已安装的模型,允许用户安装模型,也允许用户卸载模型。安装与卸载会放在对话框里完成。

我们会使用 framework 模式 下的 React Router、Tailwind,以及 shadcn 组件。

它大概长这样(Stackblitz 演示见这里):

第一次尝试

你(和我一样)的第一直觉,可能是在 app/routes/models.tsx 这个路由里直接放一两个 <Dialog>安装模型 的对话框需要一些数据:我们得发请求拿到可用且未安装的模型列表。接下来很自然会想到 useFetcher。但这样一来,我们还需要一个 resource route 来提供可选模型列表;这里先假设它已经存在。models 路由组件可能长得像这样:

javascript 复制代码
export async function loader() {
  const models = await listInstalledModels()
  return data({ models })
}

export default function ModelsPage({ loaderData }: Route.ComponentProps) {
  const { models } = loaderData
  const loadModelsFetcher = useFetcher()

  const onInstallClick = () => {
    loadModelsFetcher.load(href("/resources/models/installable"));
  }

  return (
    <>
      ...page header...

      <Dialog>
        <DialogTrigger asChild onClick={onInstallClick}>
          <Button type='button'>Install</Button>
        </DialogTrigger>
        <DialogContent>
          <Form method="post">
            ....
            <ModelSelect models={loadModelsFetcher.data ?? []} />
            ....
          </installModelFetcher.Form>
        </DialogContent>
      </Dialog>

      ...models list...
    </>
  );
}

我们还需要一个 <Dialog> 来做 卸载模型 。它同样可以塞进这个 ModelsPage 路由组件,或者塞进 <ModelsTable> 组件里。随你选。有没有"唯一正确"的答案?🤷‍♂️ 但我们得在同一个路由的 action 里同时处理安装与卸载,这意味着得用表单字段区分意图:

ini 复制代码
<input type="hidden" name="intent" value="install" />
// 或者
<input type="hidden" name="intent" value="uninstall" />

哦等等,我们还希望在提交成功后把对话框关掉。这意味着要能控制对话框的 open 状态。于是至少得加一个 useState

嗯,那我们要怎么在 成功 提交后关闭对话框?我们可以 await useSubmitfetcher.submit(),但它们返回的是 void,并不是提交结果。我们也可以乐观地直接关掉,但如果返回了错误,体验会很糟。我猜我们需要一个 useEffect 去"监听"actionData 再切换状态......?😩

scss 复制代码
const [open, setOpen] = useState(false);

useEffect(() => {
  if (actionData?.success) {
    setOpen(false);
  }
}, [actionData])

// ...

<Dialog open={open} onOpenChange={setOpen}>
...
</Dialog>

卸载模型 对话框还需要一些状态:对话框是否打开,以及要卸载哪个模型。

我们的 ModelsPage 路由很快就变复杂了:它要加载模型、安装、卸载、fetcher、对话框状态、还要同步提交状态。事情很多。

一定有更好的办法!

第二次尝试

与其把对话框直接写进 app/routes/models.tsx,不如让这个路由变成父路由:在模型表格后面渲染 <Outlet />,再为安装与卸载各建一个独立路由,让它们渲染进 <Outlet />

bash 复制代码
app/routes/models.tsx
app/routes/models.install.tsx
app/routes/models.uninstall.$name.tsx

只看路由文件,我们就能立刻更清楚地知道应用里"能做哪些事"。不过我以前这么做过,也踩过坑。好在 React Router 最近加了一些特性,可能会派上用场。我们来看看能走多远。

回到基础

app/routes/models.tsx 路由里,先把对话框相关的东西都撕掉:

  • Dialog 相关 JSX
  • 对话框相关的 useState
  • fetcher
  • useEffect
  • resource route

啊,是不是舒服多了?一个路由只做一件事:列出模型。

安装 按钮加回来,但让它链接到我们要新建的路由。还要确保这个路由里渲染了 <Outlet />

ini 复制代码
<Button className="mt-6" asChild>
  <Link
    to={href("/models/install")}
    unstable_defaultShouldRevalidate={false}
    preventScrollReset
  >
    <Download className="mr-1" />
    Install
  </Link>
</Button>

...models list...

<Outlet />

等等,<Link> 上那些 props 都是啥???

unstable_defaultShouldRevalidate 是我在 11 月给框架贡献的新能力。你可以 在这里读更多介绍。基本上,它允许你在某次导航中关闭对"当前仍激活路由"的重新校验(revalidation)。因为点击这个链接会发生导航,React Router 通常会重新校验所有激活路由。但这里我们不需要 app/root.tsxloader(如果有的话)以及 app/routes/models.tsxloader 重新跑一遍。与其在这些路由里各自实现 shouldRevalidate,不如在调用点直接改行为。用来优化网络请求时很方便。

preventScrollReset 就是字面意思:既然我们希望 UX 像是"在现有内容之上打开一个覆盖层式的对话框",就可以加这个 prop,让 React Router 别在用户导航时自动把页面滚到顶部。过渡会更顺滑。

我们也会把 卸载 按钮改成链接到对应路由。

ini 复制代码
<Button variant="destructive" size="sm" asChild>
  <Link
    to={href("/models/uninstall/:name", model)}
    unstable_defaultShouldRevalidate={false}
    preventScrollReset
  >
    <Trash2 />
    Uninstall
  </Link>
</Button>

第一个对话框

我们会先实现卸载路由/对话框。既然它在独立路由里,就可以用朴素的 loaderaction,而不必用 fetcher

javascript 复制代码
// 路径:app/routes/models.install.tsx

export async function loader() {
  const models = await listUninstalledModels();
  return data({ models });
}

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const name = formData.get("name") as string;

  if (name.includes('qwen')) {
      return data({ error: "Something went wrong :("}, { status: 400 })
  }

  const model = await searchModel(name);

  await installModel(model);

  throw redirect(href("/models"));
}

export default function ModelInstall({
    loaderData,
    actionData
}: Route.ComponentProps) {
  const navigate = useNavigate();
  return (
    <Dialog
      open
      onOpenChange={(open) => {
        if (!open) {
          navigate(href("/models"), {
            unstable_defaultShouldRevalidate: false,
            preventScrollReset: true,
            replace: true,
          });
        }
      }}
    >
      ...

      {actionData?.error ? (
          <Alert variant="destructive">
            <AlertCircleIcon />
            <AlertTitle>Unable to install model</AlertTitle>
            <AlertDescription>{actionData.error}</AlertDescription>
          </Alert>
        ) : null}

      <Form method="post" preventScrollReset replace>
        ...
      </Form>

    </Dialog>
  );
}

这看起来就像任何别的路由一样,很好。朴素是好事。

因为要考虑用户关闭对话框,我们需要加 onOpenChange:监听关闭,然后导航回 /models 路由。模型安装成功后也会在 action 里导航离开。

<Form>replace 是为了清理浏览器历史:我们不太希望对话框路由还能靠"后退"一步步走回去,所以用 replace/models/install 这条栈记录替换成 /models

关闭对话框时我们仍然不想触发路由重新校验,所以继续用 unstable_defaultShouldRevalidate;也不希望关闭时页面跳动,所以 preventScrollReset 也保留。

我这里硬编码了一个已知条件来模拟错误处理,这样你能看到错误展示长什么样。错误会直接显示在对话框里。

问题

到目前为止还不错,但还有两个点没覆盖到:

  • 成功后的反馈提示
  • 关闭对话框时的动画
成功反馈

提交成功后我们会导航回 /models 路由。路由会重新校验,所以新安装的模型会出现在列表里------但除此之外,没有明确反馈告诉用户"成功了"。列表里突然多一行其实很容易被忽略。我们加个 toast。

app/root.tsx 里加上 toast 容器组件:

xml 复制代码
<body>
  {children}
  <Toaster position="top-center" />
  <ScrollRestoration />
  <Scripts />
</body>

接下来要想办法真正触发 toast。我们 可以 在导航里塞查询参数,但那并不是我们想要的 URL 形态。

csharp 复制代码
const params = new URLSearchParams({
  message: "Model installed successfully."
})
throw redirect(href("/models")} + `?${params}`);

👎

这很适合用 session 状态。我们新建一个文件来配置基于 cookie 的 session storage。

typescript 复制代码
// 路径:app/session.server.ts
import { createCookieSessionStorage } from "react-router";

type SessionData = {
  userId: string;
};

type SessionFlashData = {
  toast: string;
};

const {
  getSession: getSessionBase,
  commitSession,
  destroySession,
} = createCookieSessionStorage<SessionData, SessionFlashData>({
  cookie: {
    name: "___session",
    httpOnly: true,
    maxAge: 60,
    path: "/",
    sameSite: "lax",
    secrets: ["s3cret1"],
    secure: true,
  },
});

async function getSession(request: Request) {
  return getSessionBase(request.headers.get("Cookie"));
}

export { getSession, commitSession, destroySession };

这大体来自官方文档,但我写了一个更顺手的 getSession,并为 session 数据与 flash 数据补了类型。

Session 数据会在用户使用应用期间一直存在,比如当前用户详情。Flash session 数据是临时的:读一次就会被清掉。这正适合我们的场景。

现在可以在 action 里用它了:

csharp 复制代码
// ...

await installModel(model);

const session = await getSession(request);
session.flash("toast", "Model installed successfully.");

throw redirect(href("/models"), {
  headers: {
    "Set-Cookie": await commitSession(session),
  },
});

我们把消息 flash 进 session,并通过响应头 commit

/models 路由消费它。先在 loader 里读出来:

javascript 复制代码
// 路径:app/routes/models.tsx
export async function loader({ request }: Route.LoaderArgs) {
  const models = await listUninstalledModels();
  const session = await getSession(request)
  const toast = session.get('toast')
  return data({ models, toast }, {
    headers: {
        'Set-Cookie': await commitSession(session)
    }
  });
}

注意! 读完 flash 数据后,你 必须 commit session,它才会真的被清掉!

好,现在数据从 session 里出来了,但这发生在服务端。客户端还得做点什么。在组件里写 useEffect?不要!

我们可以实现 clientLoader,在数据进入路由组件之前拦截它:

javascript 复制代码
// 路径:app/routes/models.tsx
export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) {
  const result = await serverLoader();
  if (result.toast) {
    toast.success(result.toast);
  }
  return result;
}

组件仍然不会被 useEffect 以及任何 toast 相关逻辑"污染"。完美。

不喜欢 toast?也没问题。我们仍然把 toast 文案作为 loaderData 传给 /models 路由组件。你更喜欢的话,也可以直接在模型列表上方渲染它。

退场动画

现在要解决退场动画。问题是:当用户从对话框路由导航回父级 /models 路由时,对话框相关的 DOM 会 立刻 被移除。对话框会瞬间消失。不像打开时那样,有一个顺滑的淡出/缩放退场。

对话框出现时你能做动画,因为......它确实存在于 DOM 里。反过来就麻烦了:元素已经不存在了,还要给它做动画......通常很难。平常用 shadcn 的 Dialog 时,进/退场动画会在底层替你处理,你基本不用操心。它会做一些"等到动画结束再卸载 DOM"的技巧。

一旦我们把对话框绑到路由上,就没有这种"等待"了。元素会立刻被移除。

幸运的是,浏览器实现了一个 React Router 也支持的能力,我们可以借它一用。

View Transitions(视图过渡)

View Transitions 能让浏览器在不同 DOM 状态之间顺滑过渡。对我们的场景来说,就是"对话框存在"到"对话框消失"。

我们需要给要做动画的元素打上目标。在 shadcn 的 dialog 组件文件里更新 overlay 与 content:

ini 复制代码
// 路径:app/components/ui/dialog.tsx

<DialogPrimitive.Overlay
    data-slot="dialog-overlay"
    style={{ viewTransitionName: "dialog-overlay" }}

// ...

<DialogPrimitive.Content
    data-slot="dialog-content"
    style={{ viewTransitionName: "dialog-content" }}

我们还需要去掉 overlay 上的模糊效果;在这里它似乎和 view transitions 不太合拍。

css 复制代码
-- bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs
++ bg-black/40 duration-100

接下来要写一点 view transition 的 CSS,让它尽量贴合这些组件上原有的动画 class。

css 复制代码
/* 路径:app/app.css */

@keyframes dialog-content-in {
  from {
    opacity: 0;
    transform: scale(0.95);
  }
}
@keyframes dialog-content-out {
  to {
    opacity: 0;
    transform: scale(0.95);
  }
}
::view-transition-old(dialog-content) {
  animation: dialog-content-out 100ms ease forwards;
}
::view-transition-new(dialog-content) {
  animation: dialog-content-in 100ms ease forwards;
}
::view-transition-old(dialog-overlay),
::view-transition-new(dialog-overlay) {
  animation-duration: 100ms;
}

最后一步:在导航时真正启动 view transition。回到 app/routes/models.install.tsx 组件:

php 复制代码
<Dialog
  open
  onOpenChange={(open) => {
    if (!open) {
      navigate(href("/models"), {
        unstable_defaultShouldRevalidate: false,
        preventScrollReset: true,
        replace: true,
        viewTransition: true,
      });
    }
  }}
>
...
<Form method="post" preventScrollReset replace>

有两种情况需要设置 viewTransition 选项:它们都会导航回 /models 路由,并让对话框从 DOM 中消失。

成功!现在对话框退场动画能和"进入"动画对上了。

我可能说了大话......

如果你跟着做,可能会发现:一旦返回错误,对话框会有点"弹跳"。这是因为整体包在 view transition 里,再叠加我们的 CSS:DOM 变化(错误 UI 渲染)时也会触发淡入淡出与缩放。看起来就像对话框乐观地要关掉了,但错误又阻止了它真正卸载,于是它又弹回来。

我觉得这是可接受的取舍;但如果你对话框经常返回错误,可能就不会同意。我们可以引入 useEffect 来解决。

首先,action 需要改成始终返回 action data,而不是 throw redirect

csharp 复制代码
// 路径:app/routes/models.install.tsx

// ...

const session = await getSession(request);
session.flash("toast", "Model installed successfully.");

return data(
  { error: false },
  {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  },
);

接下来,从 <Form> 上移除 viewTransition prop,这样错误更新 DOM 时就不会触发动画。

ini 复制代码
// 路径:app/routes/models.install.tsx

<Form method="post" preventScrollReset replace>

最后,加一个 useEffect:监听 error 明确为 false 时,再用带 viewTransition 的选项去导航。

php 复制代码
// 路径:app/routes/models.install.tsx

useEffect(() => {
  if (actionData?.error === false) {
    navigate(href("/models"), {
      unstable_defaultShouldRevalidate: false,
      preventScrollReset: true,
      replace: true,
      viewTransition: true,
    });
  }
}, [actionData]);

这能避免错误场景下的"弹跳",对话框仍然与路由同步,但我们又回到了那个令人畏惧的 useEffect。取舍。⚖️

收尾

一开始那团 useEffectfetcher 和对话框状态,现在变成了相当直白的东西:每个对话框都在自己的路由里,职责单一。

真正的收获不只是"避开 useEffect",而是代码终于更贴近我们思考应用的方式:想知道能做哪些操作?去看路由。

View transitions 解决了让"基于路由的对话框"一直显得很糙的退场动画问题。再叠上 unstable_defaultShouldRevalidate 做性能优化、用 flash session 做反馈,这套模式终于变得顺手。

我跟对话框实现搏斗了很多年,试过无数种想法。这是第一次让我觉得真的 好用:框架扛重活,我们只需要把现有工具用到位。

如果你也在 React Router 应用里被对话框状态折磨,不妨试试这个模式。完整演示代码在 GitHub 上可取,如果你想看所有细节如何串起来。

相关推荐
vim怎么退出6 小时前
Dive into React——Hooks 原理
react.js·源码阅读
光影少年9 小时前
react的useMemo 如何优化?
前端·react.js·掘金·金石计划
YFF菲菲兔9 小时前
React 核心流程总述
react.js
光影少年10 小时前
react状态管理
前端·react.js·前端框架
珎珎啊10 小时前
React 和 Vue 3的区别
前端·vue.js·react.js
Bigger11 小时前
mini-cc 终端 UI:用 React 写 CLI 是什么体验
前端·react.js·ai编程
吹个口哨写代码11 小时前
IIS 部署 Vue/React 单页应用 (SPA) 刷新页面 404/403.18 报错原因及终极解决方案
前端·vue.js·react.js
喵个咪1 天前
基于 Taro 的 Headless CMS 多端前端架构:技术解析与二次开发导引
前端·react.js·taro
假如让我当三天老蒯1 天前
React+TS 项目结构(自学项目用)
前端·react.js