0%

Apollo入门引导(九):管理本地状态

接上篇 —— Apollo 入门引导(八):通过变更修改数据 —— 继续翻译 Apollo 的官网入门引导

在 Apollo 缓存中存储和查询本地数据。

Apollo 入门引导 - 目录:

  1. 介绍
  2. 构建 schema
  3. 连接数据源
  4. 编写查询解析器
  5. 编写变更解析器
  6. 连接 Apollo Studio
  7. 创建 Apollo 客户端
  8. 通过查询获取数据
  9. 通过变更修改数据
  10. 管理本地状态

完成时间: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, // highlight-line
} 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, // highlight-line
});

接下来需要定义如何在客户端上存储这些本地字段的值。

初始化响应式变量

和服务端侧类似,可以使用来自任何所需来源的数据填充客户端侧的 schema 字段。Apollo 客户端为此提供了两个有用的内置选项:

  • 相同的内存缓存,用于存储服务端查询的结果
  • 响应式变量(Reactive variable),可以在缓存外部存储任意数据,同时仍更新对应的的查询

这两个选项都适用于大多数情况。我们将使用响应式变量,因为它们上手更快。

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

1
import { InMemoryCache, Reference, makeVar } from '@apollo/client';

接下来,在文件底部添加以下代码:

1
2
3
4
5
// 当localStorage中包含 token 键时初始化为true,否则为false
export const isLoggedInVar = makeVar<boolean>(!!localStorage.getItem('token'));

// 初始化为空数组
export const cartItemsVar = makeVar<string[]>([]);

在这里定义了两个响应式变量,分给每个客户端 schema 字段一个。将每个 makeVar 调用提供的值设置变量的初始值。

isLoggedInVarcartItemsVar 的值是 函数 类型:

  • 如果不传参数调用响应式变量函数(例如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); // highlight-line
}

现在有了客户端 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.isLoggedInQuery.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, // highlight-line
} from '@apollo/client';
import Login from './pages/login'; // highlight-line

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 中移除用户详情信息
localStorage.removeItem('token');
localStorage.removeItem('userId');

// 将登录状态设为 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 和会话 token。
  3. 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 函数。然后使用 InMemoryCachemodify 方法从该缓存的 User 对象的 trips 字段中筛选出该行程。

cache.modify 方法是一种强大且灵活的用于与缓存的数据进行交互的工具。要了解更多信息,查看cache.modify

添加和删除购物车

ToggleTripButton 组件不执行任何 GraphQL 操作,因为它可以直接与 cartItemsVar 响应式变量交互。

在单击时,按钮将其关联的行程添加到购物车(如果缺少)或将其删除(如果存在)。

完成

我们的应用已经完成!启动服务和客户端,并测试一下我们刚刚添加的所有功能,包括:

  • 登录和注销
  • 从购物车添加和删除行程
  • 预订行程
  • 取消已预订行程

也可以在 final/client 的版本中启动客户端,并将其与你写的版本进行比较。


恭喜!🎉 你已经完成了 Apollo 全栈教程。你已经有能力深入研究 Apollo 平台的各个部分。返回文档主页,这里能快速链接到每一部分的文档以及可以帮助你的”推荐练习(recommended workouts)”。

前端记事本,不定期更新,欢迎关注!


👆 全文结束,棒槌时间到 👇