Graphql实践——像axios一样使用Graphql

5,718 阅读6分钟

Graphql尝鲜

在只学习graphql client端知识的过程中,我们常常需要一个graphql ide来提示graphql语法,以及实现graphql的server端来进行练手。 graphql社区提供了graphiql让我们使用

graphiql (npm):一个交互式的运行于浏览器中的 GraphQL IDE.

但graphiql提供的live demo基本打不开,难道刚接触graphql就要自己实现graphql的server端? 好在github用graphql写了一套api,我们可以去这里,登陆后即可体验一把graphql。

关于graphql的基础知识可以去这里看看

graphql client端选择

graphql在前端实现有以下方案。

Relay (github) (npm):Facebook 的框架,用于构建与 GraphQL 后端交流的 React 应用。
Apollo Client (github):一个强大的 JavaScript GraphQL 客户端,设计用于与 React、React Native、Angular 2 或者原生 JavaScript 一同工作。
graphql-request:一个简单的弹性的 JavaScript GraphQL 客户端,可以运行于所有的 JavaScript 环境(浏览器,Node.js 和 React Native)—— 基本上是 fetch 的轻度封装。
Lokka:一个简单的 JavaScript GraphQL 客户端,可以运行于所有的 JavaScript 环境 —— 浏览器,Node.js 和 React Native。
nanogql:一个使用模板字符串的小型 GraphQL 客户端库。

从npm download数量上看Apollo Client是最多的,并且Apollo也有服务端的解决方案,所以这里选择Apollo Client作为graphql的client端 apollo client对于web 框架都有具体的实现,但是我更希望能像axios那样去使用graphql,而不是每套web框架都要去学一下具体实现,那样会折腾死自己。

初始化项目

// 使用vue-cli初始化项目
vue init webpack-simple my-project
npm i

安装graphql

npm i apollo-cache-inmemory apollo-client apollo-link apollo-link-http 
npm i graphql graphql-tag

项目结构如下

.
├── index.html
├── package.json
├── package-lock.json
├── README.md
├── src
│   ├── App.vue
│   ├── graphql                            // 接口
│   │   ├── search.graphql
│   │   └── search.js
│   ├── main.js
│   └── utils
│       └── graphql.js                    // 对Apollo-client封装
└── webpack.config.js

apollo-client

接下来对apollo-client进行封装,加上中间件(实现类似于axios拦截器的效果)。 graphql.js

import ApolloClient from 'apollo-client'
import { InMemoryCache } from 'apollo-cache-inmemory'
import { HttpLink } from 'apollo-link-http'
import { onError } from 'apollo-link-error'
import { ApolloLink, from } from 'apollo-link'

const token = '598ffa46592d1c7f57ccf8173e47290c6db0d549'

const Middleware = new ApolloLink((operation, forward) => {
  // request时对请求进行处理
  console.log('Middleware', operation, forward)
})
const Afterware = new ApolloLink((operation, forward) => {
  return forward(operation).map(response => {
    // 服务器返回数据
    console.log('Afterware--response', response)
    return response
  })
})
const errorLink = onError(({ graphQLErrors, networkError }) => {
  if (graphQLErrors)
    graphQLErrors.map(({ message, locations, path }) =>
      console.log(
        `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`,
      ),
    );
  if (networkError) console.log(`[Network error]: ${networkError}`);
});

const httpLink = new HttpLink({
  uri: 'https://api.github.com/graphql', // 配置请求url 
  headers: {                             // 配置header
    Authorization: `Bearer ${token}`
  }
})
const cache = new InMemoryCache()       // 缓存
export default new ApolloClient({
  link: from([Middleware, Afterware, errorLink, httpLink]),
  cache
})

配置webpack支持.graphql文件

     // 在rules下添加以下规则
      {
        test: /\.(graphql|gql)$/,
        exclude: /node_modules/,
        loader: 'graphql-tag/loader',
      }

search.graphql

query searchR ($keyword: String!) {
    search (query: $keyword , type: REPOSITORY){
        userCount
    }
}

search.js

import client from '../utils/graphql'
// import gql from 'graphql-tag'
import { searchR } from './search.graphql'
export const search = (params) => client.query({
  query: searchR,
  variables: params
})

到这里我们已经可以直接调用/graphql/下导出的function

使用github接口实现一个简单的搜索功能

具体实现就不贴出来了,全部代码已经放到github,欢迎star。 run的时候有记得把token换成自己的,因为我的token有可能已经失效。

Graphql分页

graphql实现分页有以下两种方式:

  1. 基于偏移量,需要提供第几页, 每页的数量
  2. 基于游标或者id,提供每页数量,与 游标/id。
    对于游标分页Relay(Facebook家的Graphql库) 定了一套规范 Relay-style cursor pagination

基于偏移量的分页实现简单,但存在以下问题:

  • 性能问题,虽然可以使用 “延迟关联” 解决,但会使sql语句变得复杂

    # 假设 有一个 product商品表,当商品表数量足够多时,这个查询会变得非常缓慢,
    SELECT id, name FROM product LIMIT 1000, 20;
    # 如果我们提供一个边界值,比如id,无论翻页到多么后面,其性能都会很好
    SELECT id, name FROM product WHERE id > 1000 LIMIT 20;
    
  • 删除列表数据时,导致获取下一页的数据缺失

    # 假设 总共有11条数据,一页显示10条,总页数为 2 页。
    # 当调用接口删除 第 1 页的 1 条数据,然后进行翻页时,因为只剩下10条数据,所以下面的sql会查不到数据。
    SELECT id, name FROM product LIMIT 10, 10;
    

基于游标/ID 的分页,也存在硬伤:

  • 如何实现跳往第 n 页的功能
    难道要获取 相应的游标再进行翻页?:joy: 所以它更适用于无限加载,或者只有 上一页/下一页 的情景上,对于跳往第n页还是需要用到基于偏移量的分页。

所以我们需要同时支持这两种分页。

Relay 式的游标分页

Relay 定义了 PageInfoEdgesEdge TypesNodeCursor等对象 用于实现灵活的分页。👇是Relay给出的一个query例子。

{
  user {
    id
    name
    friends(first: 10, after: "opaqueCursor") {
      edges {
        cursor
        node {
          id
          name
        }
      }
      pageInfo {
        hasNextPage
      }
    }
  }
}

friends 连接会返回一个对象,这个对象的名称会以 Connection结尾,如friendConnection, Connection中必需包含PageInfoEdges

PageInfo

Relay 在返回的游标连接上提供了一个 PageInfo 对象,其必需包含 hasPreviousPage, hasNextPage。

  • startCursor : 列表中 第一个项的游标id,字符串
  • endCursor : 最后一个项的游标id
  • hasPreviousPage : 是否有上一页,布尔值
  • hasNextPage :是否有下一页

游标是不透明的,并且它们的格式不应该被依赖,建议用 base64 编码它们。

Edges

Edges:类型为 LIST ,必需包含Edge Types
Edge Types:类型为 Object,必需包含 NodeCursor
Node: 类型可以为 标量,枚举,对象,接口,联合类型,此字段无法返回列表。 Cursor: 类型为String

通过Edges,列表数据中每一项都包含一个Cursor、Node,但我们基本很少需要Cursor。

上一页/下一页

下一页分页,需要两个参数。

  • first 页数大小,必需为一个非负整数。
  • after 游标id。 服务器最多返回 first 条 游标id之后(不包括游标id) 的数据。

上一页分页,需要两个参数。

  • last 页数大小,必需为一个非负整数。
  • before 游标id。 服务器最多返回 last 条 游标id之前(不包括游标id) 的数据。

first跟last不应该同时使用,这会使判断 上一页/下一页 变得麻烦。

支持基于偏移量的分页

我们需要在query时,把跳过多少条记录 这个参数给到 service 端,后端根据这个值 是否 存在 去使用不同分页方式。Prisma把这个参数命名为 skip,这里我们与其保持一致。

{
  user {
    id
    name
    friends(first: 10, after: "opaqueCursor", skip: 1) {
      edges {
        cursor
        node {
          id
          name
        }
      }
      pageInfo {
        hasNextPage
      }
    }
  }
}

基于egg.js实现基于偏移量 与 Relay 式的游标分页

👉分页例子:包含前后端
后端项目目录结构如👇

./app/
├── extend
├── graphql
│   ├── common                  # 定义公用的 Schema 和类型,如pageInfo
│   │   ├── resolver.js
│   │   ├── scalars
│   │   │   └── cursor.js       # 定义cursor数据类型
│   │   └── schema.graphql
│   ├── mutation
│   │   └── schema.graphql
│   ├── query
│   │   └── schema.graphql
│   └── user                    # user
│       ├── connector.js
│       ├── resolver.js
│       └── schema.graphql

定义 PageInfo对象,Cursor标量 。

# graphql/common/schema.graphql
scalar Cursor
type PageInfo {
    hasNextPage: Boolean!
    hasPreviousPage: Boolean!
    startCursor: Cursor
    endCursor: Cursor
}

PageInforesolver

// graphql/common/resolver
'use strict';
module.exports = {
  Cursor: require('./scalars/cursor'), // eslint-disable-line
  PageInfo: {
    hasNextPage(root) {
      // 在Connector层(如UserConnector)返回PageInfo对象时,我们可以返回 function 或 boolean,function能够支持更加复杂的判断
      if (typeof root.hasNextPage === 'function') return root.hasNextPage();
      return root.hasNextPage;
    },
    hasPreviousPage(root) {
      if (typeof root.hasPreviousPage === 'function') return root.hasPreviousPage();
      return root.hasPreviousPage;
    },
  },
};