【翻译】在 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 上可取,如果你想看所有细节如何串起来。

相关推荐
光影少年6 小时前
react的 useState 原理、批量更新机制
前端·react.js·掘金·金石计划
Swift社区6 小时前
Flutter / React / ArkUI:在鸿蒙 PC 上怎么选?
flutter·react.js·harmonyos
学习论之费曼学习法6 小时前
ReAct框架深度解析:让Agent会思考再行动
前端·react.js·前端框架
openKaka_7 小时前
completeWork:真实 DOM 是在哪里被创建的
前端·javascript·react.js
Highcharts.js7 小时前
Highcharts React v5版本迁移的核心注意事项和步骤清单
开发语言·javascript·react.js·前端框架·highcharts
淑子啦8 小时前
TS 和组件绑定深耕(泛型表格)
前端·javascript·react.js
诚实可靠王大锤18 小时前
React Native 输入框与按钮焦点冲突解决方案(rn版本0.70.3)
前端·javascript·react native·react.js
openKaka_1 天前
reconcileChildren 深入:React 如何根据 ReactElement 构建子 Fiber
前端·javascript·react.js
Highcharts.js1 天前
Highcharts React v5升级三问|最大的升级方向是什么?需要注意什么?有什么优化?
前端·javascript·react.js·前端框架·highcharts·大数据渲染·前端性能