在 Apollo 缓存中存储本地数据
像大多数 Web 应用程序一样,我们的应用程序依赖于远程获取的数据和本地存储的数据的组合。我们可以使用 Apollo Client 管理两种数据类型,使其成为我们应用程序状态的单一可靠来源。我们甚至可以在单个 操作中与这两种数据类型交互。我们来学习一下如何进行!
定义客户端模式
默认情况下,以下代码块使用 TypeScript。你可以使用每个代码块上方的下拉菜单切换到 JavaScript。
如果你使用 JavaScript,请在出现 `.ts` 和 `.tsx` 的地方使用 `.js` 和 `.jsx` 文件扩展名。
首先,我们来定义一个 特定于我们的应用程序客户端的客户端 GraphQL 模式。这不是管理本地状态所必需的,但它启用有用的开发者工具并帮助我们推理我们的数据。
在初始化ApolloClient
前,将以下定义添加到src/index.tsx
,在ApolloClient
初始化之前:
export const typeDefs = gql`extend type Query {isLoggedIn: Boolean!cartItems: [ID!]!}`;
还需将gql
添加到从@apollo/client
导入的符号列表中:
import {ApolloClient,NormalizedCacheObject,ApolloProvider,gql,} from "@apollo/client";
正如你所料,这非常像我们服务器上的模式的定义,区别在于:我们扩展类型查询
。可以扩展在其他位置定义的GraphQL类型,为该类型添加字段。
在此处,我们向查询
中添加两个字段:
isLoggedIn
,以跟踪用户是否进行了有效会话cartItems
,以跟踪用户启动添加到其购物车中的
最后,修改ApolloClient
的构造函数以提供我们的客户端模式:
const client: ApolloClient<NormalizedCacheObject> = new ApolloClient({cache,uri: "https://127.0.0.1:4000/graphql",headers: {authorization: localStorage.getItem("token") || "",},typeDefs,});
接下来,我们需要定义我们如何在客户端中存储这些本地字段的值。
初始化反应变量
与服务器端类似,我们可以使用来自任何我们想要的数据源的字段填充客户端模式Apollo Client为此提供了一些有用的内置选项:
- 存储服务器端查询结果的同一内存缓存
- 响应变量,在缓存外部,它可以储存任意数据,同时还能更新依赖它们的查询
这两种选择都适用于大多数用例。我们将使用响应变量,因为它们更易于上手。
打开 src/cache.ts
。更新其 import
语句,以包含 makeVar
函数:
import { InMemoryCache, Reference, makeVar } from "@apollo/client";
然后,在文件的底部添加以下内容
// Initializes to true if localStorage includes a 'token' key,// false otherwiseexport const isLoggedInVar = makeVar<boolean>(!!localStorage.getItem("token"));// Initializes to an empty arrayexport const cartItemsVar = makeVar<string[]>([]);
在这里,我们定义两个响应变量,一个对应于我们的每个客户端架构字段。我们提供给每个 makeVar
调用的值设置变量的初始值。
的值 isLoggedInVar
和 cartItemsVar
是函数:
- 如果你使用零个参数(例如,
isLoggedInVar()
)调用响应变量函数,则它返回变量的当前值。 - 如果你使用一个 参数(例如,
isLoggedInVar(false)
)调用该函数,则它替换 变量的当前值,为其提供的值。
更新登录逻辑
现在,我们使用响应变量表示登录状态,我们需要更新该变量,每当用户登录时。
让我们回到 login.tsx
中并导入我们新的 变量:
import { isLoggedInVar } from "../cache";
现在,我们还可以在用户每次登录时更新该 变量。修改 onCompleted
对 LOGIN_USER
突变 的回调,以将 isLoggedInVar
设置为 true
:
onCompleted({ login }) {if (login) {localStorage.setItem('token', login.token as string);localStorage.setItem('userId', login.id as string);isLoggedInVar(true);}}
我们现在有了客户端架构和我们客户端的 数据源。在服务器端,下一步我们定义 解析器 来连接这两个。然而,在客户端,我们定义 字段策略。
定义字段策略
字段 策略指定了 GraphQL 中的单个 Apollo Client 缓存中的读取和写入方式。大多数服务器端架构字段不需要字段策略,因为默认策略能正确执行:它将 查询 结果直接写入到缓存并返回这些结果,不作任何修改。
而我们的客户端 字段 并不是 存储 在缓存中!我们需要定义 字段 策略来告诉 Apollo Client 如何 查询 这些字段。
在 src/cache.ts
中,查看 InMemoryCache
的构造函数:
export const cache: InMemoryCache = new InMemoryCache({typePolicies: {Query: {fields: {launches: {// ...field policy definitions...},},},},});
您可能还记得我们已经在此定义了一个字段策略,具体适用于Query.launches
字段,当我们为向GET_LAUNCHES
查询添加分页支持。
我们为Query.isLoggedIn
和Query.cartItems
添加字段策略:
export const cache: InMemoryCache = new InMemoryCache({typePolicies: {Query: {fields: {isLoggedIn: {read() {return isLoggedInVar();},},cartItems: {read() {return cartItemsVar();},},launches: {// ...field policy definitions...},},},},});
我们的两个字段策略各自包含一个字段:一个read
函数。 Apollo Client在查询字段时会调用字段的read
函数。 查询结果将函数的返回值用作字段的值,而不管缓存中或您的GraphQL 服务器中的任何值如何。
现在,每当我们查询一个客户端架构字段时,我们的相应响应变量值就会返回。让我们写一个查询来试用一下!
查询本地字段
您可以将客户端字段包括在您编写的任何GraphQL 查询中。为此,您可以向@client
指令添加到查询中的每个客户端字段。这告诉Apollo 客户端 不从您的服务器获取该字段的值。
登录状态
让我们定义一个查询,其中包含我们的新isLoggedIn
字段。将以下定义添加到index.tsx
中:
const IS_LOGGED_IN = gql`query IsUserLoggedIn {isLoggedIn @client}`;function IsLoggedIn() {const { data } = useQuery(IS_LOGGED_IN);return data.isLoggedIn ? <Pages /> : <Login />;}
还添加下面缺少的导入
import {ApolloClient,NormalizedCacheObject,ApolloProvider,gql,useQuery,} from "@apollo/client";import Login from "./pages/login";
该IsLoggedIn
组件执行IS_LOGGED_IN
查询并根据结果呈现不同的组件:
- 如果用户未登录,组件将显示我们应用程序的登录屏幕。
- 否则,组件将显示我们应用程序的主页。
由于该查询的所有字段都是本地字段,因此我们不必担心显示任何加载状态。
最后,让我们更新ReactDOM.render
调用以使用我们新的IsLoggedIn
组件:
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
并用以下内容替换其内容:
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
。用以下内容替换其内容:
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 (<StyledButtondata-testid="logout-button"onClick={() => {// Evict and garbage-collect the cached user objectclient.cache.evict({ fieldName: "me" });client.cache.gc();// Remove user details from localStoragelocalStorage.removeItem("token");localStorage.removeItem("userId");// Set the logged-in status to falseisLoggedInVar(false);}}><ExitIcon />Logout</StyledButton>);};export default LogoutButton;const StyledButton = styled("button")([menuItemClassName,{background: "none",border: "none",padding: 0,},]);
这段代码的重要部分是注销按钮的 onClick
处理程序。它执行以下操作:
- 它使用
evict
和gc
方法清除Query.me
字段,使其从我们的内存缓存中剔除。此字段包括与已登录用户特定的数据,所有这些数据都应在注销时予以删除。 - 它清空
localStorage
,在这里我们持久保存已登录用户的 ID 和会话令牌,以便在访问期间使用。 - 它将我们的
isLoggedInVar
响应式 变量的值设置为false
。
响应式 变量的值发生更改时,该更改会自动广播到每个 查询,该查询依赖于变量的值(具体来说,是我们之前定义的 IS_LOGGED_IN
查询)。
因此,当用户单击注销按钮时,我们的 isLoggedIn
组件更新以显示登录屏幕。
启用行程预订
我们允许我们的用户在客户端预订行程。我们等了这么久才实现此核心功能,因为它需要与本地数据(用户的购物车)和远程数据进行交互。现在我们知道如何同时进行这两项操作了!
打开 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) {successmessagelaunches {idisBooked}}}`;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>) : (<ButtononClick={async () => {await bookTrips();cartItemsVar([]);}}data-testid="book-button">Book All</Button>);};export default BookTrips;
当单击 预订全部 按钮时,此组件执行 BOOK_TRIPS
突变。 突变需要 launchIds
列表,它从用户本地存储的购物车(作为属性传递)中获取该列表。
“bookTrips
”函数返回后,我们调用 cartItemsVar([])
来清除用户的购物车,因为购物车中的旅行已预订。
现在,用户可以预订购物车中的所有旅行,但他们还不能 添加任何旅行到自己的购物车里!让我们加入最后一条内容即可。
启用购物车和预订修改
打开 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) {successmessagelaunches {idisBooked}}}`;interface ActionButtonPropsextends 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><ButtononClick={() => {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
mutation,它将 launchId
作为 variable(指示要取消的已预订旅行)。
在我们调用 useMutation
时,我们包含 update
函数。在 mutation 完成后调用此函数,使我们可以更新缓存以反映服务器端取消操作。
我们的 update
函数从 mutation 结果(传给函数)中获取已取消的旅行,然后使用 modify
方法 of InMemoryCache
将该旅行从我们缓存的 trips
field 中的 User
对象中筛选出去。
The cache.modify
方法是与缓存数据交互的强大且灵活的工具。如需了解更多信息,请参阅 cache.modify
.
添加和移除购物车项目
The ToggleTripButton
组件不执行任何 GraphQL 操作,因为该组件可以直接与 cartItemsVar
响应式 变量交互。
单击时,如果购物车中没有该按钮关联的行程,则该按钮会将其添加到购物车中,否则就会将其移除。
结束
我们的应用程序已经完成了!如果您尚未启动服务器和客户端,请启动并测试我们刚刚添加的所有功能。
您还可以启动 final/client
中的客户端版本,将其与您的版本进行比较。
恭喜! 🎉 您已经完成了 Apollo 全栈速成课程。
想与全世界分享您新的技能吗?通过参加我们的 认证考试来运用您的知识!通过考试,您将获得 Apollo 图形开发 - 助理认证,展示您对 GraphQL 和 Apollo 工具套件的坚实基础理解。