最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • Apollo入门引导(九):管理本地状态

    正文概述 掘金(林景宜)   2020-11-30   703

    接上篇 —— 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 中:

    export const typeDefs = gql`
      extend type Query {
        isLoggedIn: Boolean!
        cartItems: [ID!]!
      }
    `;
    

    同样也从 @apollo/client 给导入列表添加 gql

    import {
      ApolloClient,
      NormalizedCacheObject,
      ApolloProvider,
      gql, // highlight-line
    } from '@apollo/client';
    

    如你所见,这看起来很像是 服务端 schema 中的定义,区别在于扩展Query 类型。可以扩展在其他地方定义的 GraphQL 类型,以便向该类型添加字段。

    本例中向 Query 添加了两个字段:

    • isLoggedIn,用于跟踪用户是否有活动 session
    • cartItems,跟踪用户已添加到购物车的发射

    最后,修改 ApolloClient 的构造函数以提供客户端侧 schema:

    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 函数:

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

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

    // 当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 并导入新变量:

    import { isLoggedInVar } from '../cache';
    

    现在,无论用户什么时候登录都会更新变量。修改 LOGIN_USER 变更的 onCompleted 回调函数,将 isLoggedInVar 设为 true

    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 的构造函数:

    export const cache: InMemoryCache = new InMemoryCache({
      typePolicies: {
        Query: {
          fields: {
            launches: {
              // ...字段策略定义...
            },
          },
        },
      },
    });
    

    你可能还记得之前在这里已经定义了一个字段策略,就是当 GET_LAUNCHES 查询中添加了分页支持时,专门针对 Query.launches 字段的策略。

    接下来为 Query.isLoggedInQuery.cartItems 添加字段策略:

    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 中:

    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, // highlight-line
    } from '@apollo/client';
    import Login from './pages/login'; // highlight-line
    

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

    • 如果用户未登录,则该组件将显示应用的登录页。
    • 否则,该组件将显示应用的主页。

    因为查询的所有字段都是本地字段,所以不必考虑显示任何加载状态。

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

    ReactDOM.render(
      <ApolloProvider client={client}>
        <IsLoggedIn />
      </ApolloProvider>,
      document.getElementById('root')
    );
    

    购物车内容

    接下来实现一个客户端购物车,用于存储用户想要预订的发射。

    打开 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 { 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,将其内容替换为以下内容:

    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。替换为以下内容:

    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;
    

    This component executes the BOOK_TRIPS mutation when the Book All button is clicked. The mutation requires a list of launchIds, which it obtains from the user's locally stored cart (passed as a prop).

    After the bookTrips function returns, we call cartItemsVar([]) to clear the user's cart because the trips in the cart have been booked.

    A user can now book all the trips in their cart, but they can't yet add any trips to their cart! Let's apply that last touch.

    Enable cart and booking modifications

    Open src/containers/action-button.tsx. Replace its contents with the following:

    单击“ Book All 按钮时,此组件将执行 BOOK_TRIPS 变更。变更需要一个从用户本地存储的购物车中获得的(作为 prop 传递) 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) {
          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 字段中筛选出该行程。

    添加和删除购物车

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

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

    完成

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

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

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


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

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

    • 微信公众号: 林景宜的记事本
    • 博客:林景宜的记事本
    • 掘金专栏:林景宜的记事本
    • 知乎专栏: 林景宜的记事本
    • Github: MageeLin


    起源地下载网 » Apollo入门引导(九):管理本地状态

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元