10. 管理本地状态
20m

在 Apollo 缓存中存储本地数据

像大多数 Web 应用程序一样,我们的应用程序依赖于远程获取的数据和本地存储的数据的组合。我们可以使用 管理两种数据类型,使其成为我们应用程序状态的单一可靠来源。我们甚至可以在单个 中与这两种数据类型交互。我们来学习一下如何进行!

定义客户端模式

默认情况下,以下代码块使用 TypeScript。你可以使用每个代码块上方的下拉菜单切换到 JavaScript。


如果你使用 JavaScript,请在出现 `.ts` 和 `.tsx` 的地方使用 `.js` 和 `.jsx` 文件扩展名。

首先,我们来定义一个 特定于我们的应用程序客户端的客户端 GraphQL 模式。这不是管理本地状态所必需的,但它启用有用的开发者工具并帮助我们推理我们的数据。

在初始化ApolloClient前,将以下定义添加到src/index.tsx,在ApolloClient初始化之前:

client/src/index.tsx
export const typeDefs = gql`
extend type Query {
isLoggedIn: Boolean!
cartItems: [ID!]!
}
`;

还需将gql添加到从@apollo/client导入的符号列表中:

client/src/index.tsx
import {
ApolloClient,
NormalizedCacheObject,
ApolloProvider,
gql,
} from "@apollo/client";

正如你所料,这非常像我们服务器上的模式的定义,区别在于:我们扩展类型查询。可以扩展在其他位置定义的类型,为该类型添加

在此处,我们向查询中添加两个

  • isLoggedIn,以跟踪用户是否进行了有效会话
  • cartItems,以跟踪用户添加到其购物车中的

最后,修改ApolloClient的构造函数以提供我们的客户端模式:

client/src/index.tsx
const client: ApolloClient<NormalizedCacheObject> = new ApolloClient({
cache,
uri: "https://127.0.0.1:4000/graphql",
headers: {
authorization: localStorage.getItem("token") || "",
},
typeDefs,
});
任务!

接下来,我们需要定义我们如何在客户端中存储这些本地的值。

初始化反应变量

与服务器端类似,我们可以使用来自任何我们想要的数据源的填充客户端模式为此提供了一些有用的内置选项:

  • 存储服务器端查询结果的同一内存缓存
  • 响应变量在缓存外部,它可以储存任意数据,同时还能更新依赖它们的查询

这两种选择都适用于大多数用例。我们将使用响应,因为它们更易于上手。

打开 src/cache.ts。更新其 import语句,以包含 makeVar函数:

client/src/cache.ts
import { InMemoryCache, Reference, makeVar } from "@apollo/client";

然后,在文件的底部添加以下内容

client/src/cache.ts
// Initializes to true if localStorage includes a 'token' key,
// false otherwise
export const isLoggedInVar = makeVar<boolean>(!!localStorage.getItem("token"));
// Initializes to an empty array
export const cartItemsVar = makeVar<string[]>([]);

在这里,我们定义两个响应,一个对应于我们的每个客户端架构。我们提供给每个 makeVar调用的值设置的初始值。

的值 isLoggedInVarcartItemsVar函数

  • 如果你使用零(例如,isLoggedInVar())调用响应函数,则它返回的当前值。
  • 如果你使用一个 (例如,isLoggedInVar(false))调用该函数,则它替换 的当前值,为其提供的值。

更新登录逻辑

现在,我们使用响应表示登录状态,我们需要更新,每当用户登录时。

让我们回到 login.tsx 中并导入我们新的 :

client/src/pages/login.tsx
import { isLoggedInVar } from "../cache";

现在,我们还可以在用户每次登录时更新该 。修改 onCompletedLOGIN_USER 的回调,以将 isLoggedInVar 设置为 true:

client/src/pages/login.tsx
onCompleted({ login }) {
if (login) {
localStorage.setItem('token', login.token as string);
localStorage.setItem('userId', login.id as string);
isLoggedInVar(true);
}
}

我们现在有了客户端架构和我们客户端的 。在服务器端,下一步我们定义 来连接这两个。然而,在客户端,我们定义 字段策略

定义字段策略

策略指定了 中的单个 缓存中的读取和写入方式。大多数服务器端架构字段不需要字段策略,因为默认策略能正确执行:它将 结果直接写入到缓存并返回这些结果,不作任何修改。

而我们的客户端 并不是 存储 在缓存中!我们需要定义 策略来告诉 如何 这些字段。

src/cache.ts 中,查看 InMemoryCache 的构造函数:

client/src/cache.ts
export const cache: InMemoryCache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
launches: {
// ...field policy definitions...
},
},
},
},
});

您可能还记得我们已经在此定义了一个策略,具体适用于Query.launches ,当我们GET_LAUNCHES 添加分页支持。

我们为Query.isLoggedInQuery.cartItems添加策略:

client/src/cache.ts
export const cache: InMemoryCache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
isLoggedIn: {
read() {
return isLoggedInVar();
},
},
cartItems: {
read() {
return cartItemsVar();
},
},
launches: {
// ...field policy definitions...
},
},
},
},
});

我们的两个策略各自包含一个字段:一个read 函数在查询时会调用read函数。 结果将函数的返回值用作的值,而不管缓存中或您的中的任何值如何。

现在,每当我们一个客户端架构时,我们的相应响应值就会返回。让我们写一个查询来试用一下!

查询本地字段

您可以将客户端包括在您编写的任何 中。为此,您可以向@client 添加到中的每个客户端。这告诉 从您的服务器获取该的值。

登录状态

让我们定义一个,其中包含我们的新isLoggedIn 。将以下定义添加到index.tsx中:

client/src/index.tsx
const IS_LOGGED_IN = gql`
query IsUserLoggedIn {
isLoggedIn @client
}
`;
function IsLoggedIn() {
const { data } = useQuery(IS_LOGGED_IN);
return data.isLoggedIn ? <Pages /> : <Login />;
}

还添加下面缺少的导入

client/src/index.tsx
import {
ApolloClient,
NormalizedCacheObject,
ApolloProvider,
gql,
useQuery,
} from "@apollo/client";
import Login from "./pages/login";

IsLoggedIn组件执行IS_LOGGED_IN 并根据结果呈现不同的组件:

  • 如果用户登录,组件将显示我们应用程序的登录屏幕。
  • 否则,组件将显示我们应用程序的主页。

由于该的所有都是本地字段,因此我们不必担心显示任何加载状态。

最后,让我们更新ReactDOM.render调用以使用我们新的IsLoggedIn组件:

client/src/index.tsx
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Failed to find the root element");
const root = ReactDOM.createRoot(rootElement);
root.render(
<ApolloProvider client={client}>
<IsLoggedIn />
</ApolloProvider>
);

购物车项目

接下来,我们实现一个用于存储的客户端购物车,用户希望预订该

打开 client/src/pages/cart.tsx并用以下内容替换其内容:

client/src/pages/cart.tsx
import React, { Fragment } from "react";
import { gql, useQuery } from "@apollo/client";
import { Header, Loading } from "../components";
import { CartItem, BookTrips } from "../containers";
import { GetCartItems } from "./__generated__/GetCartItems";
export const GET_CART_ITEMS = gql`
query GetCartItems {
cartItems @client
}
`;
interface CartProps {}
const Cart: React.FC<CartProps> = () => {
const { data, loading, error } = useQuery<GetCartItems>(GET_CART_ITEMS);
if (loading) return <Loading />;
if (error) return <p>ERROR: {error.message}</p>;
return (
<Fragment>
<Header>My Cart</Header>
{data?.cartItems.length === 0 ? (
<p data-testid="empty-message">No items in your cart</p>
) : (
<Fragment>
{data?.cartItems.map((launchId: any) => (
<CartItem key={launchId} launchId={launchId} />
))}
<BookTrips cartItems={data?.cartItems || []} />
</Fragment>
)}
</Fragment>
);
};
export default Cart;

再次,我们一个客户端并使用该查询结果填充我们的 UI。 @client 是我们唯一用于区分查询远程的代码和查询客户端的代码的部分内容。

尽管上面的两个查询 客户端,但单个查询可以查询客户端字段和服务器端字段。

修改本地字段

当我们希望修改服务器端架构时,我们会执行由我们服务器的处理的。修改 更加简单,因为我们可以直接访问字段的源数据(在本例中,是响应式)。

启用注销

已登录用户还需要能够从我们的客户端注销。我们的示例应用可以完全本地执行注销,因为已登录状态由localStorage中的 token键的存在来确定。

打开 client/src/containers/logout-button.tsx。用以下内容替换其内容:

client/src/containers/logout-button.tsx
import React from "react";
import styled from "@emotion/styled";
import { useApolloClient } from "@apollo/client";
import { menuItemClassName } from "../components/menu-item";
import { isLoggedInVar } from "../cache";
import { ReactComponent as ExitIcon } from "../assets/icons/exit.svg";
const LogoutButton = () => {
const client = useApolloClient();
return (
<StyledButton
data-testid="logout-button"
onClick={() => {
// Evict and garbage-collect the cached user object
client.cache.evict({ fieldName: "me" });
client.cache.gc();
// Remove user details from localStorage
localStorage.removeItem("token");
localStorage.removeItem("userId");
// Set the logged-in status to false
isLoggedInVar(false);
}}
>
<ExitIcon />
Logout
</StyledButton>
);
};
export default LogoutButton;
const StyledButton = styled("button")([
menuItemClassName,
{
background: "none",
border: "none",
padding: 0,
},
]);

这段代码的重要部分是注销按钮的 onClick 处理程序。它执行以下操作:

  1. 它使用 evictgc 方法清除 Query.me ,使其从我们的内存缓存中剔除。此字段包括与已登录用户特定的数据,所有这些数据都应在注销时予以删除。
  2. 它清空 localStorage,在这里我们持久保存已登录用户的 ID 和会话令牌,以便在访问期间使用。
  3. 它将我们的 isLoggedInVar 响应式 的值设置为 false

响应式 的值发生更改时,该更改会自动广播到每个 ,该查询依赖于变量的值(具体来说,是我们之前定义的 IS_LOGGED_IN )。

因此,当用户单击注销按钮时,我们的 isLoggedIn 组件更新以显示登录屏幕。

启用行程预订

我们允许我们的用户在客户端预订行程。我们等了这么久才实现此核心功能,因为它需要与本地数据(用户的购物车)和远程数据进行交互。现在我们知道如何同时进行这两项操作了!

打开 src/containers/book-trips.tsx。将以下内容替换为其内容:

client/src/containers/book-trips.tsx
import React from "react";
import { gql, useMutation } from "@apollo/client";
import Button from "../components/button";
import { cartItemsVar } from "../cache";
import * as GetCartItemsTypes from "../pages/__generated__/GetCartItems";
import * as BookTripsTypes from "./__generated__/BookTrips";
export const BOOK_TRIPS = gql`
mutation BookTrips($launchIds: [ID]!) {
bookTrips(launchIds: $launchIds) {
success
message
launches {
id
isBooked
}
}
}
`;
interface BookTripsProps extends GetCartItemsTypes.GetCartItems {}
const BookTrips: React.FC<BookTripsProps> = ({ cartItems }) => {
const [bookTrips, { data }] = useMutation<
BookTripsTypes.BookTrips,
BookTripsTypes.BookTripsVariables
>(BOOK_TRIPS, {
variables: { launchIds: cartItems },
});
return data && data.bookTrips && !data.bookTrips.success ? (
<p data-testid="message">{data.bookTrips.message}</p>
) : (
<Button
onClick={async () => {
await bookTrips();
cartItemsVar([]);
}}
data-testid="book-button"
>
Book All
</Button>
);
};
export default BookTrips;

当单击 预订全部 按钮时,此组件执行 BOOK_TRIPS 需要 launchIds列表,它从用户本地存储的购物车(作为属性传递)中获取该列表。

bookTrips”函数返回后,我们调用 cartItemsVar([]) 来清除用户的购物车,因为购物车中的旅行已预订。

现在,用户可以预订购物车中的所有旅行,但他们还不能 添加任何旅行到自己的购物车里!让我们加入最后一条内容即可。

启用购物车和预订修改

打开 src/containers/action-button.tsx。将其内容替换为以下内容:

client/src/containers/action-button.tsx
import React from "react";
import { gql, useMutation, useReactiveVar, Reference } from "@apollo/client";
import { GET_LAUNCH_DETAILS } from "../pages/launch";
import Button from "../components/button";
import { cartItemsVar } from "../cache";
import * as LaunchDetailTypes from "../pages/__generated__/LaunchDetails";
export { GET_LAUNCH_DETAILS };
export const CANCEL_TRIP = gql`
mutation cancel($launchId: ID!) {
cancelTrip(launchId: $launchId) {
success
message
launches {
id
isBooked
}
}
}
`;
interface ActionButtonProps
extends Partial<LaunchDetailTypes.LaunchDetails_launch> {}
const CancelTripButton: React.FC<ActionButtonProps> = ({ id }) => {
const [mutate, { loading, error }] = useMutation(CANCEL_TRIP, {
variables: { launchId: id },
update(cache, { data: { cancelTrip } }) {
// Update the user's cached list of trips to remove the trip that
// was just canceled.
const launch = cancelTrip.launches[0];
cache.modify({
id: cache.identify({
__typename: "User",
id: localStorage.getItem("userId"),
}),
fields: {
trips(existingTrips: Reference[], { readField }) {
return existingTrips.filter(
(tripRef) => readField("id", tripRef) !== launch.id
);
},
},
});
},
});
if (loading) return <p>Loading...</p>;
if (error) return <p>An error occurred</p>;
return (
<div>
<Button onClick={() => mutate()} data-testid={"action-button"}>
Cancel This Trip
</Button>
</div>
);
};
const ToggleTripButton: React.FC<ActionButtonProps> = ({ id }) => {
const cartItems = useReactiveVar(cartItemsVar);
const isInCart = id ? cartItems.includes(id) : false;
return (
<div>
<Button
onClick={() => {
if (id) {
cartItemsVar(
isInCart
? cartItems.filter((itemId) => itemId !== id)
: [...cartItems, id]
);
}
}}
data-testid={"action-button"}
>
{isInCart ? "Remove from Cart" : "Add to Cart"}
</Button>
</div>
);
};
const ActionButton: React.FC<ActionButtonProps> = ({ isBooked, id }) =>
isBooked ? <CancelTripButton id={id} /> : <ToggleTripButton id={id} />;
export default ActionButton;

此代码定义了两个复杂组件

  • 一个 CancelTripButton,仅显示在用户已预订的旅行中
  • 一个 ToggleTripButton,使用户可以将旅行添加到购物车中或从购物车中删除

让我们分别介绍每个部分。

取消旅行

CancelTripButton组件执行 CANCEL_TRIP ,它将 launchId 作为 (指示要取消的已预订旅行)。

在我们调用 useMutation时,我们包含 update 函数。在 完成后调用此函数,使我们可以更新缓存以反映服务器端取消操作。

我们的 update 函数从 结果(传给函数)中获取已取消的旅行,然后使用 modify 方法 of InMemoryCache 将该旅行从我们缓存的 trips 中的 User 对象中筛选出去。

The cache.modify 方法是与缓存数据交互的强大且灵活的工具。如需了解更多信息,请参阅 cache.modify.

添加和移除购物车项目

The ToggleTripButton 组件不执行任何 ,因为该组件可以直接与 cartItemsVar 响应式 交互。

单击时,如果购物车中没有该按钮关联的行程,则该按钮会将其添加到购物车中,否则就会将其移除。

结束

我们的应用程序已经完成了!如果您尚未启动服务器和客户端,请启动并测试我们刚刚添加的所有功能。

任务!
任务!
任务!

您还可以启动 final/client 中的客户端版本,将其与您的版本进行比较。

恭喜! 🎉 您已经完成了 Apollo 全栈速成课程。

想与全世界分享您新的技能吗?通过参加我们的 认证考试来运用您的知识!通过考试,您将获得 Apollo 图形开发 - 助理认证,展示您对 和 Apollo 工具套件的坚实基础理解。

上一步