这是一个发布news链接的应用,采用了react,Apollo Client,graphQL技术栈,可作为GraphQL的练手项目。
主要包括以下6个功能:
- 显示链接列表
- 注册和身份验证
- 创建新的链接
- 对链接进行投票
- 订阅实时更新
- 分页
项目搭建
-
npm创建react项目
-
下载 Apollo Client和graphQL
- Apollo Client提供了provider(用来包裹App)和一些hooks,如useQuery
-
配置ApolloClient
-
引入依赖
import { ApolloProvider, ApolloClient, createHttpLink, InMemoryCache, split, } from "@apollo/client"; -
//1. 创建http链接来连接GraphQL API和ApolloClient实例 const httpLink = createHttpLink({ uri: "http://localhost:4000", }); //2. 初始化ApolloClient const client = new ApolloClient({ link, //C.确保用正确的link来实例化ApolloClient cache: new InMemoryCache(), }); //... //3.包裹组件 <ApolloProvider client={client}> <App /> </ApolloProvider>
-
-
后端
在git中执行
curl https://codeload.github.com/howtographql/react-apollo/tar.gz/starter | tar -xz --strip=1 react-apollo-starter/server
下载服务端代码,在项目中就会多出server文件夹,其中server/src/schema.graphql定义了schema,与前端密切相关,部分代码如下:
type Query {
info: String!
feed(filter: String, skip: Int, take: Int, orderBy: LinkOrderByInput): Feed!
//在后端检索所有链接,该查询允许使用filter过滤 orderBy排序 分页参数skip take
}
type Feed {
id: ID!
links: [Link!]!
count: Int!
}
type Mutation {
post(url: String!, description: String!): Link!//创建新链接
signup(email: String!, password: String!, name: String!): AuthPayload//为新用户创建账号
login(email: String!, password: String!): AuthPayload//登录现有用户
vote(linkId: ID!): Vote//允许已认证的用户投票
}
type Subscription {
newLink: Link//在创建新链接时接收实时更新
newVote: Vote//在投票提交时接收实时更新
}
比如,我们可以在playground中发送如下代码获取前10条links
{
feed(skip:0,take:10){
links {
description
url
postedBy {
name
}
}
}
}
或使用Mutation中的signup创建一个新用户
mutation {
signup(name:"Sarah",email: "sarah@prisma.io", password: "graphql"){
token
user {
id
}
}
}
- 在开启服务器前先,创建数据库并生成Prisma客户端
cd server
npm install
npx prisma generate
npm run dev //启动服务器并打开一个playground让我们去测试API,这个playground类似Postman
playground在http://localhost:4000/
使用Apollo发送查询请求:useQuery
const FEED_QUERY = gql`
{
feed {
id
links {
id
createdAt
url
description
}
}
}
`;
// LinkList组件初次渲染的时候 data变量没有信息
// 一旦GraphQL解析了一些数据 LinkList组件就会重新渲染
const LinkList = () => {
//useQuery返回3个元素
// loading 当请求正在进行loading值为true
//data 是从服务端接收到的实际数据
const { data } = useQuery(FEED_QUERY);
Mutations: Creating Links
本小节将使用Apollo发送Mutations。和query语法类似
-
用gql写mutation
const CREATE_LINK_MUTATION = gql` mutation PostMutation($description: String!, $url: String!) { post(description: $description, url: $url) { id createdAt url description } } `;
-
将CREATE_LINK_MUTATION发送给useMutation hook,使用useMutation hook发送mutation给服务器
//解构出createLink函数 const [createLink] = useMutation(CREATE_LINK_MUTATION, { variables: { description: formState.descrition, url: formState.url, }, });
-
调用解构出的函数
<form onSubmit={(e) => { e.preventDefault(); createLink(); //每当提交表单就调用createLink函数,创建新的连接 }} >
使用Router和Apollo实现导航
-
在Header中写Link导航
-
在App.js中使用Routes和Route配置路由关系
-
在Index.js中使用BrowserRouter包裹App
-
添加新的连接后重定向到LinkList页面:
- 导入router中的useNavigate
-
const navigate = useNavigate(); const [createLink] = useMutation(CREATE_LINK_MUTATION, { variables: { description: formState.descrition, url: formState.url, }, //这个函数会在mutation完成后触发 onCompleted: () => navigate("/"), });
- 重定向后能看到新添加的数据
登录身份验证
-
创建Login组件 ,根据是否已经登录,条件渲染不同的UI
-
设置常量AUTH_TOKEN,保存本地存储
-
在header组件中添加Login的路由
-
使用认证的mutation
-
因为在服务器中定义了
type Mutation { signup(email: String!, password: String!, name: String!): AuthPayload login(email: String!, password: String!): AuthPayload }所以如此定义SIGNUP_MUTATION:
const SIGNUP_MUTATION = gql` mutation SignupMutation($email: String!, $password: String!, $name: String!) { signup(email: $email, password: $password, name: $name) { token }// 这个mutation返回了一个token 我们可以将其附加到后续请求,来验证用户身份 } `; -
解构出login函数,在点击登录按钮的时候调用该函数
const [login] = useMutation(LOGIN_MUTATION, { //variables是来自表单的变量 variables: { email: formState.email, password: formState.password, }, onCompleted: ({ login }) => { localStorage.setItem(AUTH_TOKEN, login.token); //设置本地存储 navigate("/"); }, }); -
将token附加到所有请求的API: 通过认证的token配置Apollo
因为请求都是ApolloClient发送的,所以要让ApolloClient知道token存在。
ApolloClient有中间件的概念,这个中间件的实现就是Apollo Link,它可以对所有请求进行身份验证
这个中间件将在每次ApolloClient发送请求时被调用,Apollo Link允许我们创建中间件,在请求发送到服务器之前修改请求。
让ApolloClient对所有请求做验证:
const authLink = setContext((_, { headers }) => { const token = localStorage.getItem(AUTH_TOKEN); //a.从本地获取token return { headers: { ...headers, authorization: token ? `Bearer ${token}` : "", //b.将token拼贴到headers }, }; }); const client = new ApolloClient({ link: authLink.concat(httpLink), //c.确保用正确的link来实例化ApolloClient cache: new InMemoryCache(), });
投票功能
创建timeDifferenceForDate函数,获取从创建链接到现在的时间
修改Link组件
修改FEED_QUERY查询的字段
实现投票的mutation功能:
-
写gql语句,如果gql字段写错 Apollo服务器会报错400
const VOTE_MUTATION = gql` mutation VoteMutation($linkId: ID!) { vote(linkId: $linkId) { id link { id votes { id user { id } } } user { id } } } `; -
使用useMutation做投票
const [vote] = useMutation(VOTE_MUTATION, { variables: { linkId: link.id, }, });
-
在触发函数调用的时候使用onClick={vote},不用箭头函数 <div className="ml1 gray f11" style={{ cursor: "pointer" }} onClick={vote} >
新增投票数后更新缓存
执行影响数据列表的操作时,需要调用useMutation的update函数来更新缓存
const [vote] = useMutation(VOTE_MUTATION, {
variables: {
linkId: link.id,
},
// 在mutation完成后,允许读取缓存,修改,提交改变
update: (cache, { data: { vote } }) => {
const { feed } = cache.readQuery({
query: FEED_QUERY,
}); //读取缓存
//创建一个包含刚刚进行投票的新数据数组
const updatedLinks = feed.links.map((feedLink) => {
if (feedLink.id === link.id) {
return {
...feedLink,
votes: [...feedLink.votes, vote],
};
}
return feedLink;
});
// 拥有投票列表后,使用writeQuery将更改提交到缓存中
cache.writeQuery({
query: FEED_QUERY,
data: {
feed: {
links: updatedLinks,
},
},
});
},
});
新增Link之后也要调用update函数,在CreateLink的useMutation的第二个对象参数里添加update属性,和vote同理
搜索--使用到graphQL的过滤功能
-
新建Search组件 并添加到App.js
-
定义gql查询语句 gql查询语句写在文件的顶部,import语句下
//和LinkList组件的查询类似 不过多了参数来过滤 const FEED_SEARCH_QUERY = gql` query FeedSearchQuery($filter: String!) { feed(filter: $filter) { id links { id url description createdAt postedBy { id name } votes { id user { id } } } } } `; -
在用户点击ok按钮时再加载数据。而不是组件初始加载时:使用useLazyQuery
const [executeSearch, { data }] = useLazyQuery(FEED_SEARCH_QUERY); //... <button onClick={() => { executeSearch({ variables: { filter: searchFilter } }); }} > OK </button> {/*useLazyQuery 从服务器拿到的data里面有Link ,记得要return*/} {data && data.feed.links.map((link, index) => { return <Link key={link.id} link={link} index={index} />; })}
使用GraphQL的Subscriptions(订阅)实现实时更新
Subscriptions(订阅)允许服务器在特定事件发生时,主动将数据发送给客户端,无需客户端请求。订阅是通过webSocket实现的
使用Apollo订阅
配置ApolloClient
-
安装
npm install subscriptions-transport-ws -
在index.js中配置ApolloClient
import { WebSocketLink, webSocketLink } from "@apollo/client/link/ws"; import { getMainDefinition } from "@apollo/client/utilities"; //... // A.实例化WebSocketLink, const wsLink = new WebSocketLink({ uri: `ws://localhost:4000/graphql`, //订阅断电 options: { reconnect: true, connectionParams: { authToken: localStorage.getItem(AUTH_TOKEN), //身份验证 }, }, }); //B. split用于将请求路由到特定的中间件链接,接收3个参数 const link = split( ({ query }) => { const { kind, operation } = getMainDefinition(query); return kind === "OperationDefinition" && operation === "subscription"; }, //测试函数,返回布尔值,如果为true,将请求转发到第二个参数彻底的链接;为false则转发到第三个参数 wsLink, authLink.concat(httpLink) ); //... const client = new ApolloClient({ link, //C.确保用正确的link来实例化ApolloClient cache: new InMemoryCache(), });订阅Subscriptions使用的是webSocket协议,而Query/Mutations使用Http。
添加新链接实时更新LinkList:在LinkList组件中实现订阅
-
从useQuery中解构出subscribToMore函数
const { data, loading, error, subscribeToMore } = useQuery(FEED_QUERY, { //... }); -
subscribToMore接收一个对象作为参数,在这个对象要配置如何监听和响应订阅
subscribeToMore({ document: NEW_LINKS_SUBSCRIPTION, //NEW_LINKS_SUBSCRIPTION会监听新创建的链接 //更新缓存 updateQuery: (prev, { subscriptionData }) => { if (!subscriptionData.data) return prev; const newLink = subscriptionData.data.newLink; const exists = prev.feed.links.find(({ id }) => id === newLink.id); if (exists) return prev; return Object.assign({}, prev, { feed: { links: [newLink, ...prev.feed.links], count: prev.feed.links.length + 1, __typename: prev.feed.__typename, }, }); }, }); -
在http://localhost:4000/中post新增link,http://localhost:3000/ 会实时显示新的链接
mutation { post(url: "www.graphqlweekly.com", description: "A weekly newsletter about GraphQL") { id } }
订阅新的投票
-
在LinkList中添加另一个subscribeToMore
subscribeToMore({ document: NEW_VOTES_SUBSCRIPTION, }); -
NEW_VOTES_SUBSCRIPTION是一个订阅,请求新的投票
Pagination分页
-
配置Route使得首页重定向到/new/1,而/new/:page重定向到LinkList
-
在header新增Top标签
-
重新定义LinkList中的FEED_QUERY,这次不再是简单拿到links,而是要传入take,skip和orderBy变量,根据getQueryVariables函数的条件过滤出links
const getQueryVariables = (isNewPage, page) => { const skip = isNewPage ? (page - 1) * LINKS_PER_PAGE : 0; const take = isNewPage ? LINKS_PER_PAGE : 100; const orderBy = { createdAt: "desc" }; //确保最新的链接先展示 return { take, skip, orderBy }; };在调用useQuery的时候传入变量variables,好能够让FEED_QUERY 使用这些变量
const { data, loading, error, subscribeToMore } = useQuery(FEED_QUERY, { variables: getQueryVariables(isNewPage, page), //... }); -
编写Pagination组件,使用navigate hook来导航
-
获取需要渲染的Links,在data.feed.links中拿到链接,再按投票的数量,按顺序显示
{getLinksToRender(isNewPage, data).map((link, index) => { console.log("indexpageIndex", index + pageIndex); return <Link key={link.id} link={link} index={index + pageIndex} />; })} -
bug修复:update没有作用:在新增链接的时候,需要给Links和CreateLink中的useMutation中的update的readQuery和writeQuery新增variables
遇到的困难:
我有7条数据,每个分页展示5条,刷新http://localhost:3000/new/2的时候总是展示2条,这是对的,当我返回第一页,再点击next返回第二页的时候,就展示5条,和第一页一样,这是为什么
点击Next切换到第二页时,Apollo Client会从缓存中获取数据,而缓存中的数据是第一页的数据,所以第二页的数据和第一页一样
可以在切换页面的时候让Client发起新的网络请求,而不是使用缓存数据,可以在useQuery新增属性fetchPolicy
const { data, loading, error, subscribeToMore } = useQuery(FEED_QUERY, {
variables: getQueryVariables(isNewPage, page),
fetchPolicy: "network-only",
});