SSGOI LogoSSGOI
← 返回博客
使用React Router创建原生应用般的Web应用

使用React Router创建原生应用般的Web应用

文大升
ssgoireact-routerreactpage-transitiontutorial

想用Web技术制作应用程序吗?

你知道吗?可以使用Web技术制作应用程序。用React Router创建Web应用,然后用Expo或Flutter的WebView包装,就可以直接发布到应用商店。

但是Web应用有一个最大的弱点。那就是页面过渡很生硬

原生应用的屏幕过渡非常自然。从列表进入详情时会滑动,点击图片时会放大并打开。但是Web呢?页面只是生硬地切换。

如果Web也能实现原生般的页面过渡呢? 那样的Web应用会让人感觉像真正的应用程序。

今天我们将使用React Router创建一个具有原生应用般页面过渡效果的Web应用

什么是SSGOI?

SSGOI(쓱오이)是一个在所有浏览器中都能运行的页面过渡库。

虽然Chrome有View Transition API,但它在Safari和Firefox中无法运行。SSGOI解决了这个问题。

SSGOI的优势:

  • 支持所有现代浏览器(Chrome、Firefox、Safari、Edge)
  • 完美兼容SSR/SSG
  • 基于弹簧的物理动画,实现自然流畅的运动
  • 多种内置过渡效果(Drill、Slide、Pinterest、Instagram等)

开始使用

让我们从SSGOI官方模板开始。

# 克隆仓库
git clone https://github.com/meursyphus/ssgoi

# 在根目录安装依赖(重要!)
pnpm install

# 进入React Router模板文件夹
cd templates/react-router

# 运行开发服务器
pnpm run dev

注意: pnpm install必须在根路径执行。因为是monorepo结构,需要在根目录安装所有依赖。

在浏览器中打开http://localhost:5173即可查看模板演示。

了解文件夹结构

templates/react-router/的文件夹结构如下。

templates/react-router/
├── app/
│   ├── root.tsx                # 根布局
│   ├── routes.ts               # 路由定义
│   ├── routes/                 # 页面路由
│   │   ├── home.tsx
│   │   ├── posts.tsx           # Drill过渡示例
│   │   ├── posts.$postId.tsx
│   │   ├── pinterest.tsx       # Pinterest过渡示例
│   │   ├── pinterest.$pinId.tsx
│   │   ├── profile.tsx         # Instagram过渡示例
│   │   ├── profile.$postId.tsx
│   │   ├── products.tsx
│   │   ├── products_.layout.tsx # Slide过渡示例
│   │   └── products_*.tsx
│   └── components/
│       ├── demo-layout.tsx     # Ssgoi配置(核心!)
│       ├── demo-wrapper.tsx    # 移动端框架UI
│       ├── posts/
│       ├── pinterest/
│       ├── profile/
│       └── products/

核心文件是demo-layout.tsx。所有过渡配置都在这里。

配置Ssgoi

打开app/components/demo-layout.tsx

import React, { useMemo } from "react";
import { Link, useLocation } from "react-router";
import { Ssgoi } from "@ssgoi/react";
import {
  drill,
  pinterest,
  instagram,
} from "@ssgoi/react/view-transitions";

export default function DemoLayout({ children }: { children: React.ReactNode }) {
  const location = useLocation();
  const pathname = location.pathname;

  const config = useMemo(
    () => ({
      transitions: [
        // Pinterest过渡
        {
          from: "/pinterest/*",
          to: "/pinterest",
          transition: pinterest(),
          symmetric: true,
        },
        // Posts - Drill过渡
        {
          from: "/posts",
          to: "/posts/*",
          transition: drill({ direction: "enter" }),
        },
        {
          from: "/posts/*",
          to: "/posts",
          transition: drill({ direction: "exit" }),
        },
        // Profile - Instagram过渡
        {
          from: "/profile",
          to: "/profile/*",
          transition: instagram(),
          symmetric: true,
        },
      ],
    }),
    [],
  );

  return (
    <div className="h-full bg-[#121212] flex z-0">
      <div className="w-full bg-[#121212] flex flex-col overflow-hidden relative">
        <main className="flex-1 w-full overflow-y-scroll overflow-x-hidden relative z-0 bg-[#121212]">
          <Ssgoi config={config}>{children}</Ssgoi>
        </main>
        {/* 导航栏 */}
      </div>
    </div>
  );
}

config配置:

  • from: 起始路径(*表示通配符)
  • to: 目标路径
  • transition: 要使用的过渡效果
  • symmetric: 反向也应用相同的过渡效果

用SsgoiTransition包装页面

每个页面都需要用SsgoiTransition包装,并赋予唯一的id

查看app/components/posts/index.tsx:

import { SsgoiTransition } from "@ssgoi/react";
import { Link } from "react-router";

export default function PostsDemo() {
  return (
    <SsgoiTransition id="/posts">
      <div className="min-h-full bg-[#121212] px-4 py-6">
        {/* 文章列表 */}
      </div>
    </SsgoiTransition>
  );
}

查看app/components/posts/detail.tsx:

export default function PostDetail({ postId }: { postId: string }) {
  return (
    <SsgoiTransition id={`/posts/${postId}`}>
      <div className="min-h-screen bg-[#121212]">{/* 文章详情 */}</div>
    </SsgoiTransition>
  );
}

核心: id必须与config中的from/to匹配,SSGOI才能应用过渡效果。

Drill过渡

从列表进入详情的过渡效果。

Drill过渡 - 从列表进入详情并返回

模板位置: app/components/posts/

import { drill } from "@ssgoi/react/view-transitions";

// 列表 → 详情(进入时)
drill({ direction: "enter" });

// 详情 → 列表(退出时)
drill({ direction: "exit" });

config配置:

{
  from: "/posts",
  to: "/posts/*",
  transition: drill({ direction: "enter" }),
},
{
  from: "/posts/*",
  to: "/posts",
  transition: drill({ direction: "exit" }),
},

选项:

  • direction: "enter" | "exit"
  • opacity: 设为true时添加淡入淡出效果

Slide过渡

在选项卡UI中左右滑动的效果。

Slide过渡 - 点击选项卡时左右滑动

模板位置: app/routes/products_.layout.tsx

import { slide } from "@ssgoi/react/view-transitions";

// 向左滑动
slide({ direction: "left" });

// 向右滑动
slide({ direction: "right" });

products_.layout.tsx中使用嵌套的Ssgoi来仅对选项卡区域应用过渡:

import { Outlet, useLocation } from "react-router";
import { Ssgoi, SsgoiTransition } from "@ssgoi/react";
import { slide } from "@ssgoi/react/view-transitions";

export default function ProductsLayout() {
  const location = useLocation();
  const pathname = location.pathname;

  const config = useMemo(
    () => ({
      transitions: [
        {
          from: "/products/tab/left",
          to: "/products/tab/right",
          transition: slide({ direction: "left" }),
        },
        {
          from: "/products/tab/right",
          to: "/products/tab/left",
          transition: slide({ direction: "right" }),
        },
      ],
      middleware: (from: string, to: string) => {
        const fromIndex = categories.findIndex((c) => c.path === from);
        const toIndex = categories.findIndex((c) => c.path === to);

        if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
          if (fromIndex < toIndex) {
            return { from: "/products/tab/left", to: "/products/tab/right" };
          } else {
            return { from: "/products/tab/right", to: "/products/tab/left" };
          }
        }

        return { from, to };
      },
    }),
    [],
  );

  return (
    <SsgoiTransition id="/products">
      {/* 头部、选项卡按钮 */}
      <div className="flex-1 overflow-hidden">
        <Ssgoi config={config}>
          <Outlet />
        </Ssgoi>
      </div>
    </SsgoiTransition>
  );
}

Pinterest过渡

画廊图片放大并过渡到详情的效果。

Pinterest过渡 - 图片放大并过渡到详情

模板位置: app/components/pinterest/

设置Data属性(必需!)

Pinterest过渡需要通过data属性来指定哪些图片之间建立连接。

app/components/pinterest/index.tsx(画廊):

function PinCard({ item }: { item: PinterestItem }) {
  return (
    <Link to={`/pinterest/${item.id}`}>
      <div className="relative" style={{ aspectRatio: item.aspectRatio }}>
        <img
          src={item.image}
          alt={item.title}
          className="w-full h-full object-cover"
          data-pinterest-gallery-key={item.id}
        />
      </div>
    </Link>
  );
}

app/components/pinterest/detail.tsx(详情):

export default function PinterestDetail({ pinId }: { pinId: string }) {
  const item = getPinterestItem(pinId);

  return (
    <SsgoiTransition id={`/pinterest/${pinId}`}>
      <img
        src={item.image}
        alt={item.title}
        style={{ aspectRatio: item.aspectRatio }}
        data-pinterest-detail-key={item.id}
      />
    </SsgoiTransition>
  );
}

核心: data-pinterest-gallery-keydata-pinterest-detail-key必须使用相同的id,SSGOI才能识别连接关系。

config配置:

{
  from: "/pinterest/*",
  to: "/pinterest",
  transition: pinterest(),
  symmetric: true,
}

Instagram过渡

从个人资料feed网格过渡到详情的效果。与Pinterest类似,但画廊保持不变。

Instagram过渡 - 从网格过渡到详情,画廊保持不变

模板位置: app/components/profile/

设置Data属性

app/components/profile/feed.tsx(网格):

function PostCard({ post }: { post: Post }) {
  return (
    <Link to={`/profile/${post.id}`}>
      <img
        src={post.coverImage.url}
        alt={post.title}
        className="w-full h-auto object-cover"
        data-instagram-gallery-key={post.id}
      />
    </Link>
  );
}

app/components/profile/feed-detail.tsx(详情):

export default function FeedDetail({ postId }: { postId: string }) {
  const post = getPost(postId);

  return (
    <SsgoiTransition id={`/profile/${postId}`}>
      <img
        src={post.coverImage.url}
        alt={post.title}
        data-instagram-detail-key={post.id}
      />
    </SsgoiTransition>
  );
}

config配置:

{
  from: "/profile",
  to: "/profile/*",
  transition: instagram(),
  symmetric: true,
}

使用Spring选项调整时序

所有过渡都使用基于弹簧的物理动画。可以通过spring选项来调整速度和感觉。

drill({
  direction: "enter",
  spring: {
    stiffness: 200, // 越高越快
    damping: 20, // 越高越少弹跳
    doubleSpring: true, // 开启ease-in-out效果
  },
});

Spring选项说明

选项说明效果
stiffness刚度越高越快且反应更快
damping阻尼越高弹跳越少且更平滑
doubleSpring双重弹簧true/false - ease-in-out效果

什么是doubleSpring? 类似CSS的ease-in-out,开始和结束都很平滑的效果。详细内容请参考Double Spring博客文章

实用选项

symmetric选项

自动设置双向过渡。

// 只需设置这一个
{
  from: "/pinterest/*",
  to: "/pinterest",
  transition: pinterest(),
  symmetric: true,
}

// 反向也会自动应用

通配符路由

使用*匹配所有子路径。

// 匹配/posts/1、/posts/abc等
{ from: "/posts", to: "/posts/*", ... }

总结

现在你也可以在React Router Web应用中应用原生般的页面过渡效果了!

资源

用WebView包装它,制作成真正的应用程序吧!