React 路由新选择:TanStack Router!很好很强大

我将在这篇文章中探讨用于 React 的新路由器 TanStack Router(1.0 版本于 2023 年 12 月发布)。

引发我好奇心的核心功能是,使用 TanStack Router,你的路由是类型安全的,可以说,TanStack Router 是一个全新的 React 类型安全路由库。比如,你编写的代码导航到了一个不存在的路由,Typescript 会告诉你。

此外,TanStack Router 还可以成为一个实用的状态管理器,你可以通过 URL 与其他人共享!对于带有大量过滤器的搜索页面或带有许多可配置部件的仪表盘来说,TanStack Router 是一个很好的工具。如果你的用户需要共享链接或将其保存在浏览器的书签中,这会非常有用。

项目初始化

项目配置

新建一个项目:

yaml 复制代码
# 1. 初始化React项目
pnpm create vite@latest tanstack-router-demo -- --template react-ts

# 2. 安装tanstack router 和 vite 插件
pnpm install @tanstack/react-router
pnpm install --save-dev @tanstack/router-vite-plugin

@tanstack/router-vite-plugin 插件会自动为你生成路由文件的定义,需要再 vite.config.ts 中进行设置,如下所示:

typescript 复制代码
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { TanStackRouterVite } from '@tanstack/router-vite-plugin';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), TanStackRouterVite()],
});

路由

现在,来创建两个路由:一个主页和一个显示用户配置文件的页面。在创建这两个页面之前,我们需要定义路由的根目录。需要注意的是:路由必须位于 src/routes 文件夹中,然后创建文件,树形结构如下:

md 复制代码
src
├── routes
│   ├── __root.tsx
│   ├── index.tsx
│   └── profile.tsx

使用 pnpm run dev 运行项目,你就会看到,一个包含路由定义的新文件src/routeTree.gen.ts。 在 index.tsxprofile.tsx 中会生成一些内容:

tsx 复制代码
// index.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/')({
  component: () => <div>Hello /!</div>
})
tsx 复制代码
// profile.tsx
import { createFileRoute } from '@tanstack/react-router';

export const Route = createFileRoute('/profile')({
  component: () => <div>Hello /profile!</div>,
});

你会发现 __root.tsx 文件还是空的。在该文件中使用 <Outlet /> 组件定义路由的根:

tsx 复制代码
import { Outlet, createRootRoute } from '@tanstack/react-router';

export const Route = createRootRoute({
  component: () => <Outlet />,
});

Provider

上面已经定义了路由,但 React 如何才能知道它们呢?在 App.tsx 文件中:

tsx 复制代码
import { RouterProvider, createRouter } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';
import './App.css';

const router = createRouter({ routeTree });

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router;
  }
}

function App() {
  return <RouterProvider router={router} />;
}

export default App;

通过 createRouter 创建了路由器,并将自动生成的路由树传给了路由器,我们扩展了 Register 接口以包含我们的类型,还创建了一个 RouterProvider,使应用程序的其他部分也能使用路由器。

导航

运行应用程序并导航至 http://localhost:5173/profile,你将看到 "Hello /profile!"。正常工作!

但如何在应用程序中导航到它们呢?让我们回到 __root.tsx 并添加几个链接:

tsx 复制代码
import { Link, Outlet, createRootRoute } from '@tanstack/react-router';

export const Route = createRootRoute({
  component: () => (
    <>
      <h1>My App</h1>
      <ul>
        <li>
          <Link to="/">Home</Link>
        </li>
        <li>
          <Link to="/profile">Profile</Link>
        </li>
      </ul>
      <Outlet />
    </>
  ),
});

现在,可以通过点击链接在两个页面之间浏览。注意,在配置 Link 组件的 to 时,你可以使用自动完成和类型安全功能。

激活路由

TanStack RouterLink 组件有一个属性 activeProps,当链接处于激活状态时,可以使用它向链接传递一些 props

tsx 复制代码
<Link to="/" activeProps={{ style: { fontWeight: 'bold' } }}>
  Home
</Link>

现在,当你在主页上时,链接将以粗体显示。

自定义激活状态

你甚至可以通过向链接组件传递一个函数来进一步自定义活动状态,因为默认情况下,你可以使用一个 isActive 参数来检查链接是否处于活动状态。例如

tsx 复制代码
<Link to="/profile">{({ isActive }) => <>Profile {isActive && '~'}</>}</Link>

在这种情况下,只有当链接处于活动状态时,会看到 ~ 字符。重构一下 __root.tsx 文件:

tsx 复制代码
import { Link, Outlet, createRootRoute } from '@tanstack/react-router';

const activeProps = {
  style: {
    fontWeight: 'bold',
  },
};

export const Route = createRootRoute({
  component: () => (
    <>
      <h1>My App</h1>
      <ul>
        <li>
          <Link to="/" activeProps={activeProps}>
            Home
          </Link>
        </li>
        <li>
          <Link to="/profile" activeProps={activeProps}>
            {({ isActive }) => <>Profile {isActive && '~'}</>}
          </Link>
        </li>
      </ul>
      <Outlet />
    </>
  ),
});

Path 参数和 Loader

Path 参数

比如,我们通常访问一个链接:https://www.example.com/user/yourusernameyourusername 部分就是一个路径参数,用于告诉应用程序你想查看该特定用户的页面。

增加一个新路由

src/routes/pokemon/$id.tsx 中创建一个新文件。你已经看到路径中有一个 $id,它将在 URL 中被替换为 id

获取 Path 参数

现在,我们可以通过 useParams 读取参数:

tsx 复制代码
export const Route = createFileRoute('/pokemon/$id')({
  component: Pokemon,
});

function Pokemon() {
  const { id } = Route.useParams();
  return <div>Pokemon {id}</div>;
}

如果你现在导航到 http://localhost:5173/pokemon/1,应该会在屏幕上看到 "口袋妖怪 1"。

Loader

假设我们想在渲染页面之前从应用程序接口获取pokemon数据。为此,我们可以使用 loader

获取数据

你可以在 src/api/pokemon.ts 文件中创建一个 API

tsx 复制代码
type PokemonDetail = {
  name: string;
  id: number;
  height: number;
  weight: number;
  sprites: {
    front_default: string;
  };
};

export async function getPokemon(id: string): Promise<PokemonDetail> {
  const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`);
  return await response.json();
}

这里写了一个函数 getPokemon,我们可以用它来获取特定 pokemon 的数据。

使用 loader

createFileRoute 函数的参数之一是 loader。这是一个在渲染路由之前会被调用的函数,可用于获取数据。可以更新路由对象,使其包含 loader

tsx 复制代码
import { createFileRoute } from '@tanstack/react-router';
import { getPokemon } from '../../api/pokemon';

export const Route = createFileRoute('/pokemon/$id')({
  component: Pokemon,
  loader: async ({ params }) => await getPokemon(params.id),
});

function Pokemon() {
  const { id } = Route.useParams();
  return <div>Pokemon {id}</div>;
}

如果现在刷新页面,就可以在网络选项卡中看到数据正从应用程序接口获取,Route 也提供了一个 useLoaderData 钩子在组件中使用数据。

tsx 复制代码
const pokemon = Route.useLoaderData();

现在,你可以使用口袋妖怪对象在屏幕上渲染口袋妖怪数据,下面是一个例子:

tsx 复制代码
function Pokemon() {
  const { id } = Route.useParams();
  const pokemon = Route.useLoaderData();
  console.log(pokemon);
  return (
    <div>
      <h2>
        ({id}) {pokemon.name}
      </h2>
      <img src={pokemon.sprites.front_default} alt={pokemon.name} />
      <dl>
        <dt>Height</dt>
        <dd>{pokemon.height}</dd>
        <dt>Weight</dt>
        <dd>{pokemon.weight}</dd>
      </dl>
    </div>
  );
}

导航到带参数的路由

当你试图导航到一个带参数的路由时,你不能直接设置一个类似 /pokemon/1/pokemon/${pokemonId}url。例如,TanStack Router 会强制你以 /pokemon/$id 的形式传递路由定义,然后在 params 对象中指定参数:

❌ WRONG:

tsx 复制代码
<Link to={`/pokemon/${pokemon.id}`}>{pokemon.name}</Link>

✅ CORRECT:

tsx 复制代码
<Link to={'/pokemon/$id'} params={{ id: pokemon.id }}>
  {pokemon.name}
</Link>

这能确保你传递正确的参数,同时还能确保自动完成和类型安全。

查询参数和校验器

我们将了解如何正确解析和处理查询参数(又称搜索参数),以及如何将它们有效地用作状态管理器。此外,想象一下只需传递 URL 就能共享状态,多方便啊!

定义和验证查询参数

想象这么一个场景:在一个搜索页面或表格中,有很多用于过滤数据的输入,或者在一个仪表盘中,有很多可配置的部件。你可以通过设置这些筛选器来创建自己的自定义视图,但只要你刷新页面,这些筛选器的状态就会被重置为初始值。

当然,你也可以将它们保存在本地存储或浏览器中的某个地方,但是... 如果你想将完全相同的配置发送给其他人呢?朋友?同事?直接将设置作为 URL 中的查询参数可能是最简单的方法,但手动处理这些设置又有点麻烦。

TanStack Router 在设计时就考虑到了这种情况,让你可以很好地控制查询参数(又称搜索参数)。

创建路由

我们先创建一个 /search.tsx 空页面,然后在 __root.tsx 中添加指向它的链接。

tsx 复制代码
<Link to="/search" activeProps={activeProps}>
  Search
</Link>

定义类型

现在,如果我们想让 typescript 帮助我们执行和验证正确的类型,就需要创建它们。

tsx 复制代码
type ItemFilters = {
  query: string;
  hasDiscount: boolean;
  categories: Category[];
};

type Category = 'electronics' | 'clothing' | 'books' | 'toys';

写校验函数

定义好这些参数后,我们应该告诉 TanStack Router 我们的 /search 路由包含这些参数。我们可以通过实现和 validateSearch 函数来做到这一点。

search.tsx 文件中:

tsx 复制代码
export const Route = createFileRoute("/search")({
  component: Search,
  validateSearch: (search: Record<string, unknown>): ItemFilters => {
    return {
      query: search.query as string,
      hasDiscount: search.hasDiscount === 'true',
      categories: search.categories as Category[],
    };
  },
});

function Search() {
    return (<div>Hello /search!</div>);
}

读取参数

可以在组件中使用 Route 对象,并从中获取一些钩子。更新你的搜索组件:

tsx 复制代码
function Search() {
  const { query, hasDiscount, categories } = Route.useSearch();

  return <pre>{JSON.stringify({ query, hasDiscount, categories }, null, 2)}</pre>;
}

带参数导航

现在,如果你注意到了,___root.tsx 上应该有一个错误,意思是 Path 有一些必须提供的参数,修改一下:

tsx 复制代码
<Link
  to="/search"
  activeProps={activeProps}
  search={{
    query: 'hello',
    hasDiscount: true,
    categories: ['electronics', 'clothing'],
  }}
>
  Search
</Link>

错误已消除!现在可以在浏览器上点击链接了。现在你可以看到一个页面,其中的参数已渲染为 JSON 格式。

用校验库来做校验

我们定义的这种验证函数是有效的,但我们可以通过使用现有的验证库(如 zodvalibot)做得更好,这里我们以 valibot 为例。

先安装:

tsx 复制代码
pnpm i valibot@0.29.0

定义 schema

现在可以用 valibot 架构替换你的类型:

tsx 复制代码
import * as v from 'valibot';

const Category = v.union([v.literal('electronics'), v.literal('clothing'), v.literal('books'), v.literal('toys')]);

const ItemFilters = v.object({
  query: v.optional(v.string()),
  hasDiscount: v.optional(v.boolean()),
  categories: v.optional(v.array(Category)),
});

type ItemFilters = v.Output<typeof ItemFilters>;
type Category = v.Output<typeof Category>;

这样,验证功能就会变得非常简单:

tsx 复制代码
validateSearch: (search) => v.parse(ItemFilters, search),

查询参数作为应用程序状态

TanStack Router 很好的一个功能是,可以将查询参数用作应用程序状态。到目前为止,我们只能读取它们,但你也可以在应用程序中写入它们,并在 URL 中看到它们的实时更新。

只需要在使用导航函数的时候将我们的参数设置为新状态,与我们的输入同步:

tsx 复制代码
function Search() {
  const { query, hasDiscount, categories } = Route.useSearch();
  const navigate = useNavigate({ from: Route.fullPath });

  const updateFilters = (name: keyof ItemFilters, value: unknown) => {
    navigate({ search: (prev) => ({ ...prev, [name]: value }) });
  };

  return (
    <div>
      <h2>Search</h2>
      You searched for: <input
        value={query}
        onChange={(e) => {
          updateFilters('query', e.target.value);
        }}
      />
      <input type="checkbox" checked={hasDiscount} onChange={(e) => updateFilters('hasDiscount', e.target.checked)} />
      <select
        multiple
        onChange={(e) => {
          updateFilters(
            'categories',
            Array.from(e.target.selectedOptions, (option) => option.value)
          );
        }}
        value={categories}
      >
        {['electronics', 'clothing', 'books', 'toys'].map((category) => (
          <option key={category} value={category}>
            {category}
          </option>
        ))}
      </select>
      <pre>{JSON.stringify({ query, hasDiscount, categories }, null, 2)}</pre>
    </div>
  );
}

现在,我们可以编辑所有查询参数,查看 URL 中的更改,并只需......向其他人发送链接即可轻松共享状态!

认证路由和守卫

还有一个问题:如何使用 guard 保护应用程序的某些路由(页面)?

定义一个守卫

创建登录页面

比如,我们希望 /profile 页面只向登录用户显示,首先创建一个基本登录页面 login.tsx

tsx 复制代码
import { createFileRoute, useRouter } from '@tanstack/react-router';
import { isAuthenticated, signIn, signOut } from '../utils/auth';

export const Route = createFileRoute('/login')({
  component: Login,
});

function Login() {
  const router = useRouter();

  return (
    <>
      <h2>Login</h2>
      {isAuthenticated() ? (
        <>
          <p>Hello user!</p>
          <button
            onClick={async () => {
              signOut();
              router.invalidate();
            }}
          >
            Sign out
          </button>
        </>
      ) : (
        <button
          onClick={async () => {
            signIn();
            router.invalidate();
          }}
        >
          Sign in
        </button>
      )}
    </>
  );
}

在定义三个认证函数 src/utils/auth.tsx

tsx 复制代码
export function isAuthenticated() {
  return localStorage.getItem('isAuthenticated') === 'true';
}

export async function signIn() {
  localStorage.setItem('isAuthenticated', 'true');
}

export async function signOut() {
  localStorage.removeItem('isAuthenticated');
}

保护路由

createFileRoute 函数需要一个名为 beforeLoad 的额外参数:

tsx 复制代码
export const Route = createFileRoute('/login')({
  beforeLoad: async () => {
    if (!isAuthenticated()) {
      throw redirect({ to: '/login' });
    }
  },
  component: Login,
});

使用钩子中的数据(TanStack Router Context)

在一个更贴近实际工作中的例子,你可能会有这样一个钩子(但愿不是存储在 localStorage 上,而是使用来自 API 的数据),新建一个文件 src/hooks/useAuth.ts

tsx 复制代码
export const useAuth = () => {
  const signIn = () => {
    localStorage.setItem('isAuthenticated', 'true');
  };

  const signOut = () => {
    localStorage.removeItem('isAuthenticated');
  };

  const isLogged = () => localStorage.getItem('isAuthenticated') === 'true';

  return { signIn, signOut, isLogged };
};

export type AuthContext = ReturnType<typeof useAuth>;

现在,我们需要使用上下文将这些信息告知 TanStack Router。

定义 Context

定义上下文的第一步显然是定义其类型,在 src/__rout.tsx 中(也可以放在独立的类型文件中):

tsx 复制代码
type RouterContext = {
  authentication: AuthContext;
};

之前是使用 createRootRoute,现在可以用 createRootRouteWithContext

tsx 复制代码
import {
  Link,
  Outlet,
  createRootRouteWithContext,
} from "@tanstack/react-router";
import { AuthContext } from "../hooks/useAuth";

const activeProps = {
  style: {
    fontWeight: "bold",
  },
};

type RouterContext = {
  authentication: AuthContext;
};

export const Route = createRootRouteWithContext<RouterContext>()({
  component: () => (
    <>
      <h1>My App</h1>
      <ul>
        <li>
          <Link to="/" activeProps={activeProps}>
            Home
          </Link>
        </li>
        <li>
          <Link to="/profile" activeProps={activeProps}>
            {({ isActive }) => <>Profile {isActive && "~"}</>}
          </Link>
        </li>
        <li>
          <Link to="/pokemon" activeProps={activeProps}>
            Pokemons
          </Link>
        </li>
        <li>
          <Link to="/search" activeProps={activeProps}>
            Search
          </Link>
        </li>
        <li>
          <Link to="/login" activeProps={activeProps}>
            Login
          </Link>
        </li>
      </ul>
      <Outlet />
    </>
  ),
});

注意:别忘了在最后多加一个()

更新 RouterProvider

现在你应该会在定义 RouterProvider 的文件 App.tsx 中看到一个错误,因为你需要传递新定义的上下文:

tsx 复制代码
const router = createRouter({
  routeTree,
  context: { authentication: undefined! },
});

现在路由器知道了上下文,但它是空的...

tsx 复制代码
function App() {
  const authentication = useAuth();
  return <RouterProvider router={router} context={{ authentication }} />;
}

读取 context

现在你可以像这样更新受保护的页面,比如 profile.tsx

tsx 复制代码
import { createFileRoute, redirect } from "@tanstack/react-router";

export const Route = createFileRoute("/profile")({
  beforeLoad: ({ context }) => {
    const { isLogged } = context.authentication;
    if (!isLogged()) {
      throw redirect({
        to: "/login",
      });
    }
  },
  component: () => <div>Hello /profile!</div>,
});

不同之处在于,现在可以读取 { context },在其中找到钩子中的数据。

同时保护多个路由

保护多个路由的直接方法是创建一个同名文件,只定义 beforeLoad 函数。让我们创建几个需要保护的路由:

txt 复制代码
src/
  routes/
    authenticated/
      dashboard.tsx
      profile.tsx

现在,新建 src/routes/_authenticated.ts 文件,内容如下:

tsx 复制代码
import { createFileRoute, redirect } from '@tanstack/react-router';

export const Route = createFileRoute('/_authenticated')({
  beforeLoad: async ({ context }) => {
    const { isLogged } = context.authentication;
    if (!isLogged()) {
      throw redirect({ to: '/login' });
    }
  },
});

就是这样!/authenticated 文件夹内的所有文件都将受到保护。

删除 URL 中的 /authenticated

你很可能不想在 URL 中看到父路由,这也是合理的。只需在路由中添加下划线 _,并将其重命名为 _authenticated。现在,你可以导航到 https://localhost:5173/dashboard,如果你已登录,就可以访问该页面!

嵌套路由和404页面

嵌套路由

在基于文件的方法中,将页面嵌套在某些文件夹内会自动创建嵌套路由。这是一个很棒的功能,可以让你以更有条理的方式组织你的项目。

例如,src/routes/admin/dashboard.tsx 中的文件可以在 /admin/dashboard 中访问。

不过,在某些情况下,你可能不希望某些文件夹成为 URL 的一部分。那应该怎么做?

隐藏路径(Hidden path)

这是最简单的方法,假设你只想将一些页面分组,但不想在 URL 中显示文件夹名称:确保重新命名你的文件夹,并用括号括起来。

例如,如果你有一个文件夹 src/routes/(admin)/dashboard.tsx,路由将是 /dashboard

通用布局(可见)

在某些情况下,将路由分组到文件夹中不仅是为了组织代码,也是为了将所有路由封装到一个通用布局中,例如导航栏或某种框架。

举个例子:

tsx 复制代码
// src/routes/(hidden-folder)/layouts/visibleLayout.tsx
export const Route = createFileRoute('/(hidden-folder)/layouts/visibleLayout')({
  component: () => (
    <div>
      <p>This layout is visible in the URL 👀</p>
      <Link to="/layouts/visibleLayout/foo">Foo</Link> <Link to="/layouts/visibleLayout/bar">Bar</Link>
      <hr />
      <Outlet />
    </div>
  ),
});

这是 URL 中可见的布局,它将封装子路由。

tsx 复制代码
// src/routes/(hidden-folder)/layouts/visibleLayout/foo.tsx
export const Route = createFileRoute('/(hidden-folder)/layouts/visibleLayout/foo')({
  component: () => <div>Hello /(hidden-folder)/layouts/visibleLayout/foo!</div>,
});
tsx 复制代码
// src/routes/(hidden-folder)/layouts/visibleLayout/bar.tsx
export const Route = createFileRoute('/(hidden-folder)/layouts/visibleLayout/bar')({
  component: () => <div>Hello /(hidden-folder)/layouts/visibleLayout/bar!</div>,
});

在这种情况下,路由将是 /layouts/visibleLayout/foo/layouts/visibleLayout/bar,它们将共享两个链接,以便在它们之间导航。

通用布局(隐藏)

如果想拥有一个通用布局,但又不想让它在 URL 中可见,可以使用与之前相同的方法,但要确保在文件夹名称和布局名称前添加下划线 _

tsx 复制代码
src/routes/(hidden-folder)/layouts/_hiddenLayout.tsx
src/routes/(hidden-folder)/layouts/_hiddenLayout/foo.tsx
src/routes/(hidden-folder)/layouts/_hiddenLayout/bar.tsx

在这种情况下,路由将是 layouts/foolayouts/bar

圆点符号

将所有路由嵌套在文件夹内对某些人来说很方便,但如果有多层嵌套,而这些嵌套只是为了获得某个网址,则可能会造成混乱。

例如,你可能想要实现 /admin/new/dashboard/overview 路由,但又不想在项目结构中包含所有这些文件夹。

你的文件名可以是:

tsx 复制代码
src/routes/admin.new.dashboard.overview.tsx

你不必在平面结构和嵌套结构之间做出选择,你可以随意混合它们。

404 页面

对页面进行分组和嵌套很酷,但如果用户点击的网址没有映射到任何路由,会发生什么情况呢?你可以自定义 404 页面!

你可能会在 App.tsx 中使用 createRouter 函数的一个属性来创建你的路由器 defaultNotFoundComponent

顾名思义,你可以在这里传递一个组件,当用户访问一个无法映射到任何现有路由的路由时,该组件将被渲染。

专用 404 页面

我们刚才看到的是一个全局 404 页面,但也可以为特定路径创建专用 404 页面。在我们的布局示例中,假设用户访问 /layouts/visibleLayout/unknown 时,我们需要一个自定义 404 页面。

tsx 复制代码
// src/routes/(hidden-folder)/layouts/visibleLayout.tsx
export const Route = createFileRoute('/(hidden-folder)/layouts/visibleLayout')({
  component: () => (
    <div>
      <p>This layout is visible in the URL 👀</p>
      <Link to="/layouts/visibleLayout/foo">Foo</Link> <Link to="/layouts/visibleLayout/bar">Bar</Link>
      <hr />
      <Outlet />
    </div>
  ),
  notFoundComponent: () => <div>I'm the Not found page, inside /visibleLayout</div>,
});

在路由中添加一个 notFoundComponent 属性后,工作就完成了!

访问 /non-existing-page 将呈现你在 createRouter 函数中定义的默认 notFoundComponent,而访问 /layouts/visibleLayout/non-existing-page 将呈现你在上面定义的自定义 404 页面。


相关推荐
加班是不可能的,除非双倍日工资4 小时前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi5 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip5 小时前
vite和webpack打包结构控制
前端·javascript
excel5 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国6 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼6 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy6 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
ZXT6 小时前
promise & async await总结
前端
Jerry说前后端6 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化
画个太阳作晴天6 小时前
A12预装app
linux·服务器·前端