接上篇 —— Apollo 入门引导(八):通过变更修改数据 —— 继续翻译 Apollo 的官网入门引导。
在 Apollo 缓存中存储和查询本地数据。
Apollo 入门引导 - 目录:
- 介绍
- 构建 schema
- 连接数据源
- 编写查询解析器
- 编写变更解析器
- 连接 Apollo Studio
- 创建 Apollo 客户端
- 通过查询获取数据
- 通过变更修改数据
- 管理本地状态
完成时间:20 分钟
像大多数网络应用一样,我们的应用依赖于远程获取和本地存储的数据的组合。可以使用 Apollo 客户端管理两种类型的数据,使客户端成为应用状态的唯一真实来源。甚至可以通过一次操作与两种类型的数据进行交互。
定义客户端 schema
首先,定义一个特定于我们的应用客户端的client-side GraphQL schema。这不是管理本地状态所必需的,但是它启用了能帮助推断数据的开发者工具。
在初始化 ApolloClient
之前,将以下定义添加到 src/index.tsx
中:
1 2 3 4 5 6
| export const typeDefs = gql` extend type Query { isLoggedIn: Boolean! cartItems: [ID!]! } `;
|
同样也从 @apollo/client
给导入列表添加 gql
。
1 2 3 4 5 6
| import { ApolloClient, NormalizedCacheObject, ApolloProvider, gql, } from '@apollo/client';
|
如你所见,这看起来很像是 服务端 schema 中的定义,区别在于扩展了 Query
类型。可以扩展在其他地方定义的 GraphQL 类型,以便向该类型添加字段。
本例中向 Query
添加了两个字段:
isLoggedIn
,用于跟踪用户是否有活动 session
cartItems
,跟踪用户已添加到购物车的发射
最后,修改 ApolloClient
的构造函数以提供客户端侧 schema:
1 2 3 4 5 6 7 8
| const client: ApolloClient<NormalizedCacheObject> = new ApolloClient({ cache, uri: 'http://localhost:4000/graphql', headers: { authorization: localStorage.getItem('token') || '', }, typeDefs, });
|
接下来需要定义如何在客户端上存储这些本地字段的值。
初始化响应式变量
和服务端侧类似,可以使用来自任何所需来源的数据填充客户端侧的 schema 字段。Apollo 客户端为此提供了两个有用的内置选项:
- 相同的内存缓存,用于存储服务端查询的结果
- 响应式变量(Reactive variable),可以在缓存外部存储任意数据,同时仍更新对应的的查询
这两个选项都适用于大多数情况。我们将使用响应式变量,因为它们上手更快。
打开 src/cache.ts
。更新其 import
语句以包含 makeVar
函数:
1
| import { InMemoryCache, Reference, makeVar } from '@apollo/client';
|
接下来,在文件底部添加以下代码:
1 2 3 4 5
| export const isLoggedInVar = makeVar<boolean>(!!localStorage.getItem('token'));
export const cartItemsVar = makeVar<string[]>([]);
|
在这里定义了两个响应式变量,分给每个客户端 schema 字段一个。将每个 makeVar
调用提供的值设置变量的初始值。
isLoggedInVar
和 cartItemsVar
的值是 函数 类型:
- 如果不传参数调用响应式变量函数(例如
isLoggedInVar()
),则该函数将返回变量的当前值。
- 如果传 一个 参数(例如
isLoggedInVar(false)
)”调用该函数,则会使用提供的值替换该变量的当前值。
更新登录逻辑
现在用响应式变量表示登录状态,每当用户登录时都需要 更新 该变量。
回到 login.tsx
并导入新变量:
1
| import { isLoggedInVar } from '../cache';
|
现在,无论用户什么时候登录都会更新变量。修改 LOGIN_USER
变更的 onCompleted
回调函数,将 isLoggedInVar
设为 true
:
1 2 3 4 5
| onCompleted({ login }) { localStorage.setItem('token', login.token as string); localStorage.setItem('userId', login.id as string); isLoggedInVar(true); }
|
现在有了客户端 schema 和客户端数据源。接下来将在服务端定义解析器以连接两者。但是,在客户端,我们定义了 字段策略。
定义字段策略
字段策略指定如何读取和写入 Apollo 客户端缓存中的单个 GraphQL 字段。大多数服务端 schema 字段都不需要字段策略,因为默认策略是将查询结果直接写到缓存中,并返回这些结果而无需进行任何修改。。
但是客户端字段不在缓存中!需要定义字段策略以告诉 Apollo 客户端如何查询这些字段。
在 src/cache.ts
中查看 InMemoryCache
的构造函数:
1 2 3 4 5 6 7 8 9 10 11
| export const cache: InMemoryCache = new InMemoryCache({ typePolicies: { Query: { fields: { launches: { }, }, }, }, });
|
你可能还记得之前在这里已经定义了一个字段策略,就是当 GET_LAUNCHES
查询中添加了分页支持时,专门针对 Query.launches
字段的策略。
接下来为 Query.isLoggedIn
和 Query.cartItems
添加字段策略:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| export const cache: InMemoryCache = new InMemoryCache({ typePolicies: { Query: { fields: { isLoggedIn: { read() { return isLoggedInVar(); }, }, cartItems: { read() { return cartItemsVar(); }, }, launches: { }, }, }, }, });
|
两个字段策略每个都包含一个字段:read
函数。每当查询该字段时,Apollo 客户端就会调用该字段的 read
函数。不用再考虑是用缓存还是用 GraphQL 服务上的值,查询结果直接将函数的返回值用作 字段 值。
现在,无论何时查询客户端 schema 字段,都将返回对应响应式变量的值。编写一个查询来尝试一下!
查询本地字段
可以在编写的任何 GraphQL 查询中包括客户端字段。为此可以在查询中的每个客户端字段中添加 @client
指令。这将会通知 Apollo 客户端 不 从你的服务中获取该字段的值。
登录状态
定义一个包含新的isLoggedIn
字段的查询。将以下定义添加到 index.tsx
中:
1 2 3 4 5 6 7 8 9 10
| const IS_LOGGED_IN = gql` query IsUserLoggedIn { isLoggedIn @client } `;
function IsLoggedIn() { const { data } = useQuery(IS_LOGGED_IN); return data.isLoggedIn ? <Pages /> : <Login />; }
|
同时添加缺失的导入代码:
1 2 3 4 5 6 7 8
| import { ApolloClient, NormalizedCacheObject, ApolloProvider, gql, useQuery, } from '@apollo/client'; import Login from './pages/login';
|
IsLoggedIn
组件执行 IS_LOGGED_IN
查询,并根据结果呈现不同的组件:
- 如果用户未登录,则该组件将显示应用的登录页。
- 否则,该组件将显示应用的主页。
因为查询的所有字段都是本地字段,所以不必考虑显示任何加载状态。
最后更新 ReactDOM.render
调用以使用新的 IsLoggedIn
组件:
1 2 3 4 5 6
| ReactDOM.render( <ApolloProvider client={client}> <IsLoggedIn /> </ApolloProvider>, document.getElementById('root') );
|
购物车内容
接下来实现一个客户端购物车,用于存储用户想要预订的发射。
打开 src/pages/cart.tsx
并将其内容替换为以下内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| import React, { Fragment } from 'react'; import { gql, useQuery } from '@apollo/client';
import { Header, Loading } from '../components'; import { CartItem, BookTrips } from '../containers'; import { RouteComponentProps } from '@reach/router'; import { GetCartItems } from './__generated__/GetCartItems';
export const GET_CART_ITEMS = gql` query GetCartItems { cartItems @client } `;
interface CartProps extends RouteComponentProps {}
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
指令是唯一可以区分此代码和查询远程字段的代码的通道。
尽管上方的两个查询都 只 查询客户端字段,但是单个查询可以查询客户端字段和服务端字段。
修改本地字段
当想要修改服务端 schema 字段时,执行由服务解析器处理的变更。修改 本地 字段更为简单,因为可以直接访问该字段的源数据(本例中是一个响应式变量)。
注销
已登录的用户也需要能够注销登录。示例应用可以完全在本地执行注销,因为登录状态由 localStorage
中是否存在 token
的键来确定。
打开src/containers/logout-button.tsx
,将其内容替换为以下内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| import React from 'react'; import styled from 'react-emotion'; 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={() => { client.cache.evict({ fieldName: 'me' }); client.cache.gc();
localStorage.removeItem('token'); localStorage.removeItem('userId');
isLoggedInVar(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 和会话 token。
- 将
isLoggedInVar
响应式变量的值设置为 false
。
当响应式变量的值变更时,将自动广播到每个依赖于变量值的查询(确切地说,是之前定义的 IS_LOGGED_IN
查询)。
因此,当用户单击注销按钮时,isLoggedIn
组件将更新以显示登录页面。
启用行程预订
接下来实现让用户预订行程的功能。为了实现此核心功能已经等了很久,因为它需要与本地数据(用户的购物车)和远程数据进行交互。现在我们已经知道了如何交互!
打开src/containers/book-trips.tsx
。替换为以下内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| 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 All 按钮时,此组件将执行 BOOK_TRIPS
变更。变更需要一个从用户本地存储的购物车中获得的(作为 prop 传递) launchIds
列表。
在 bookTrips
函数返回后,由于购物车中的行程已被预订,所以调用 cartItemsVar([])
来清除用户的购物车。
用户现在可以预订其购物车中的所有行程,但还不能任选行程添加到其购物车中!
启用购物车和预订修改
打开src/containers/action-button.tsx
。替换为以下内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
| 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 } }) { const launch = cancelTrip.launches[0]; cache.modify({ id: cache.identify({ __typename: 'User', id: localStorage.getItem('userId'), }), fields: { trips(existingTrips) { const launchRef = cache.writeFragment({ data: launch, fragment: gql` fragment RemoveLaunch on Launch { id } `, }); return existingTrips.filter( (tripRef: Reference) => tripRef === launchRef ); }, }, }); }, });
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
函数。然后使用 InMemoryCache
的 modify
方法从该缓存的 User
对象的 trips
字段中筛选出该行程。
cache.modify
方法是一种强大且灵活的用于与缓存的数据进行交互的工具。要了解更多信息,查看cache.modify
。
添加和删除购物车
ToggleTripButton
组件不执行任何 GraphQL 操作,因为它可以直接与 cartItemsVar
响应式变量交互。
在单击时,按钮将其关联的行程添加到购物车(如果缺少)或将其删除(如果存在)。
完成
我们的应用已经完成!启动服务和客户端,并测试一下我们刚刚添加的所有功能,包括:
- 登录和注销
- 从购物车添加和删除行程
- 预订行程
- 取消已预订行程
也可以在 final/client
的版本中启动客户端,并将其与你写的版本进行比较。
恭喜!🎉 你已经完成了 Apollo 全栈教程。你已经有能力深入研究 Apollo 平台的各个部分。返回文档主页,这里能快速链接到每一部分的文档以及可以帮助你的”推荐练习(recommended workouts)”。
前端记事本,不定期更新,欢迎关注!