— 2 min read
最近在搞 GraphQL 相关的东西, 发现社区里的东西真的五花八门, 玩都玩不过来, 举几个例子:
下一代 ORM Prsima, 挺神奇的一个工具, "不仅仅是 ORM", 和 GraphQL 倒不是强关联的, 但放一起就是异样的合拍.
Apollo-Server-Vercel, 让你可以直接编写 GraphQL API 然后部署到 Vercel Functions, 源码比较简单, 见[], 例子可以直接参见作者的 demo.
PostGraphile, 结合 PostgreSQL 和 GraphQL 的工具, 主要特点是直接抹消了 GraphQL 的 N+1 问题, 以及直接生成 CRUD 的 mutation 操作(简直 amazing), 具体的特点由于我还没开始玩, 暂时不太清楚.
BlitzJS, 这个框架我是在知乎上 下一代前端框架会去解决什么问题? 的回答里看到的, 前端基于 NextJS, 后端基于 Prisma2 的一个一体化开发框架, 很神奇的地方是直接把中间的 GraphQL 层隐藏掉了, 作者提供的 useQuery 和 useMutation 方法(不同于 Apollo-Client 的同名方法)直接隐藏掉了 http 请求调用的过程.
关于一体化框架, 可以简单理解为, 在开发时前后端在同一个目录下, 前端直接从后端导入函数, 然后在构建时来进行剥离, 使得前端调用后端函数的行为变为 http 请求. 目前国内也有此类方案, 比如[淘系前端 NodeJS 架构组]的作品[Midway-Serverless]
TypeGraphQL, 应该是 GraphQL 生态中我最喜爱的一样工具, 主要能力就是让你通过 TypeScript 装饰器的形式来定义 GraphQL Schema, 并且提供了中间件/鉴权/拦截器等等能力, 有兴趣的同学可以看一看我写的这个 Demo: GraphQL-Explorer-Server
Next-Apollo, 就是本篇文章的主角, 使得你能够在 NextJS 应用中使用 Apollo-Client. 如果你对于二者都不太了解也没关系, 在开始前我会简单介绍二者.
[Apollo-GraphQL]是目前 GraphQL NodeJS 社区的中流砥柱之一, 他们的作品涵盖了几乎所有场景, 服务端框架 Apollo-Server(包含 Express/Koa/Hapi/Fastify 等实现), 网关层 Apollo-Federation, 前端 React 与 Vue 的集成(目前 Apollo-Client 只指 React 实现, Vue 实现已经交由 Vue 社区维护, 见Vue-Apollo), 以及安卓/IOS 客户端的对应实现, 甚至还包括一个 GraphQL 视图管理工具 Apollo-Studio, 说 Apollo 撑起了 GraphQL NodeJS 社区的半边天也不为过.
GraphQL 的提出者 FaceBook 也有自己的 GraphQL Client 方案 Relay, 但用起来有点繁琐...
你可能会想, 为什么用 GraphQL 还需要前端用专门的 client? 这东西不是后端改造就行了嘛? 前端直接 React/Vue 简简单单少点套路不好吗?
实际上你完全可以在前端只使用请求库如 axios/swr/react-query 等等来进行和 GraphQL Server 的通信, 这样是完全 OK 的, 但是这意味着, 你需要自己处理 Apollo-Client 开发中最重要的几个问题:
前面两点都涉及到 GraphQL 的图式结构, 一个典型的 GraphQL 查询语句可能是这样的:
1query UserQuery {2 User {3 UserProfile {4 name5 description6 level7 PreferItems {8 Book {9 bookId10 bookName11 bookPublishDate12 }13 Author {14 authorName15 AuthorWorks {16 bookName17 bookPublishDate18 }19 }20 }21 }22 UserOrders {23 commodityId24 commodityPrice25 commodityDate26 commodityType {27 isOnSale28 category29 }30 }31 }32}
你也可以阅读 GitHub GraphQL API 文档 来了解更多复杂 Schame 架构
可以看到
关于 NextJS, 应该很多人都使用过, 直接简单介绍下他的特性即可:
知道了 NextJS 和 Apollo-Client 的作用后, Next-Apollo 的出现也就理所当然了, NextJS 对你的后端 API 没有任何要求, 如果恰好你的后端 API 是 GraphQL, 那么此时再加上 Apollo-Client 无疑能够让你的开发更加畅快.
先简单看看如何使用:
完整例子见作者的 Next-Apollo-Example
1// lib/apollo.js2import { withApollo } from "next-apollo";3import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";4
5const apolloClient = new ApolloClient({6 ssrMode: typeof window === "undefined",7 link: new HttpLink({8 uri: "https://your-graphql-server.com/graphql",9 }),10 cache: new InMemoryCache(),11});12
13export default withApollo(apolloClient);
简单说一下选项:
- link 和 HttpLink, 之所以设计成这样是因为你可以更自由的定制 client 与 server 之间的数据流, 比如使用多个不同职责的 Link 实例来传递数据, 以及为每个 Link 配置不同的请求头等信息.
- cache 和 InMemoryCache, 缓存之所以这么重要, 就是因为我们上面说的 GraphQL 的特殊性. Apollo-Client 提供的缓存是非常强大的, 包括读写缓存以及字段级别的缓存控制.
- ssrMode, 我们知道 NextJS 的重要功能之一就是 SSR, Apollo-Client 的 SSR 主要侧重于避免冗余的对 Server 的查询操作, 以及启用
getDataFromTree
这一 API, 关于 SSR 以及相关处理是 Next-Apollo 源码中的重要部分, 我们会在后面讲到.
可以看到 withApollo 实际上就是一个 HOC 即高阶组件, 它接收一个 ApolloClient 实例, 并且很明显的, 这个实例会是全局唯一的. 注意, 这里导出的withApollo(apolloClient)
仍然是一个接收参数的高阶组件, 使用是这样的:
1// pages/index.js2import Main from "../lib/layout";3import Header from "../components/Header";4import Submit from "../components/Submit";5import PostList from "../components/PostList";6import withApollo from "../lib/apollo";7
8const Home = props => {9 return (10 <Main>11 <Header />12 <Submit />13 <PostList />14 </Main>15 );16};17
18export default withApollo({ ssr: true })(Home);
<Header />
<Submit />
<PostList />
组件内都可以直接调用 Apollo-Client 的 useQuery 和 useMutation 方法, 它们的缓存控制总线即是最初传入的 ApolloClient 实例, 以比较简单的<PostList />
为例:
1import { gql, useMutation } from "@apollo/client";2import { Button } from "./styles";3
4const UPDATE_POST = gql`5 mutation votePost($id: String!) {6 votePost(id: $id) {7 id8 votes9 __typename10 }11 }12`;13
14export default function PostUpvoter({ id, votes }) {15 const [updatePost, { error, data }] = useMutation(UPDATE_POST, {16 variables: { id, votes: votes + 1 },17 optimisticResponse: {18 __typename: "Mutation",19 votePost: {20 __typename: "Post",21 id,22 votes: votes + 123 }24 }25 });26 return <Button onClick={() => updatePost()}>{votes}</Button>;27}
这个组件比较简单, 并不涉及缓存相关的操作, 在useMutation
中直接做了乐观响应处理.
接下来我们可以开始看看 Next-Apollo 的源码了, 源码其实只有一个两百多行的withApollo.tsx
文件, 并且贴心的作者还仔细写了几十行注释. 所以看懂是没有啥压力的, 重点是作者的思路值得借鉴, 比如你可以试试用 NuxtJS + Vue-Apollo 写一个 Nuxt-Apollo 这种.
从作为默认导出的withApollo
函数开始:
1type ApolloClientParam =2 | ApolloClient<NormalizedCacheObject>3 | ((ctx?: NextPageContext) => ApolloClient<NormalizedCacheObject>);4
5export default function withApollo<P, IP>(ac: ApolloClientParam) {6 return ({ ssr = false } = {}) =>7 (PageComponent: NextPage<P, IP>) => {8 const WithApollo = (pageProps: P & WithApolloOptions) => {9 let client: ApolloClient<NormalizedCacheObject>;10 if (pageProps.apolloClient) {11 client = pageProps.apolloClient;12 } else {13 client = initApolloClient(ac, pageProps.apolloState, undefined);14 }15
16 return (17 <ApolloProvider client={client}>18 <PageComponent {...pageProps} />19 </ApolloProvider>20 );21 };22
23 if (process.env.NODE_ENV !== "production") {24 const displayName =25 PageComponent.displayName || PageComponent.name || "Component";26 WithApollo.displayName = `withApollo(${displayName})`;27 }28
29 if (ssr || PageComponent.getInitialProps) {30 WithApollo.getInitialProps = async (ctx: ContextWithApolloOptions) => {31 const inAppContext = Boolean(ctx.ctx);32 const { apolloClient } = initOnContext(ac, ctx);33
34 let pageProps = {};35 if (PageComponent.getInitialProps) {36 pageProps = await PageComponent.getInitialProps(ctx);37 } else if (inAppContext) {38 pageProps = await App.getInitialProps(ctx);39 }40
41 if (typeof window === "undefined") {42 const { AppTree } = ctx;43 if (ctx.res && ctx.res.writableEnded) {44 return pageProps;45 }46
47 if (ssr && AppTree) {48 try {49 const { getDataFromTree } = await import(50 "@apollo/client/react/ssr"51 );52 let props;53 if (inAppContext) {54 props = { ...pageProps, apolloClient };55 } else {56 props = { pageProps: { ...pageProps, apolloClient } };57 }58 // @ts-ignore59 await getDataFromTree(<AppTree {...props} />);60 } catch (error) {61 console.error("Error while running `getDataFromTree`", error);62 }63 Head.rewind();64 }65 }66
67 return {68 ...pageProps,69 apolloState: apolloClient.cache.extract(),70 apolloClient: ctx.apolloClient,71 };72 };73 }74
75 return WithApollo;76 };77}
说实话, 我觉得这种
1export default function withApollo() {2 return (options) => (Component) => {3 const withApollo = (pageProps) => {4 return withApollo;5 };6 };7}
函数直接套函数的代码可读性挺差的, 虽然真的很简洁, 但就是有一种诡异感觉. 将整体逻辑简化到这里, 就很容易明白其使用方式
1withApollo({ ssr: true })(Home);
代表的含义了.
这个函数代码可以分成这么几块:
1export default function withApollo() {2 return (options) => (PageComponent) => {3 const WithApollo = (pageProps) => {4 // ...5 return withApollo;6 };7
8 if (process.env.NODE_ENV !== "production") {9 // ...10 }11
12 if (ssr | PageComponent.getInitialProps) {13 WithApollo.getInitialProps = async (ctx) => {14 // ...15 };16 }17 };18}
拆开来看很容易理解各个部分:
1type WithApolloOptions = {2 apolloClient: ApolloClient<NormalizedCacheObject>;3 apolloState: NormalizedCacheObject;4};5
6const WithApollo = (pageProps: P & WithApolloOptions) => {7 let client: ApolloClient<NormalizedCacheObject>;8
9 if (pageProps.apolloClient) {10 client = pageProps.apolloClient;11 } else {12 client = initApolloClient(ac, pageProps.apolloState, undefined);13 }14
15 return (16 <ApolloProvider client={client}>17 <PageComponent {...pageProps} />18 </ApolloProvider>19 );20};
WithApollo
就像前面说的一样是个高阶组件, 它确保了当前这个页面被<ApolloProvider >
包裹, 同时其传入了 client.
这里的 pageProps, 即是你的组件的属性. 注意这里的pageProps.apolloClient
判断, 为 true 的情况发生在 SSR 的情况下, 因为此时 apolloClient 已经完成初始化被注入到页面属性中, 直接获取即可. false 时发生在 CSR 阶段, 此时需要调用initApolloClient
方法来创建 client, 来看看这个方法:
1let globalApolloClient: ApolloClient<NormalizedCacheObject> | null = null;2
3type ApolloClientParam =4 | ApolloClient<NormalizedCacheObject>5 | ((ctx?: NextPageContext) => ApolloClient<NormalizedCacheObject>);6
7const initApolloClient = (8 acp: ApolloClientParam,9 initialState: NormalizedCacheObject,10 ctx: NextPageContext | undefined11) => {12 const apolloClient =13 typeof acp === "function"14 ? acp(ctx)15 : (acp as ApolloClient<NormalizedCacheObject>);16
17 if (typeof window === "undefined") {18 return createApolloClient(apolloClient, initialState, ctx);19 }20
21 if (!globalApolloClient) {22 globalApolloClient = createApolloClient(apolloClient, initialState, ctx);23 }24
25 return globalApolloClient;26};
在 withApollo.tsx 的第一行逻辑代码就是全局变量 globalApolloClient 的声明, 目的就是为了在客户端保持 client 唯一(不然在你切换页面时就会重新初始化 client).
至于 createApolloClient 方法也很简单:
1const createApolloClient = (2 acp: ApolloClientParam,3 initialState: NormalizedCacheObject,4 ctx: NextPageContext | undefined5) => {6 const apolloClient =7 typeof acp === "function"8 ? acp(ctx)9 : (acp as ApolloClient<NormalizedCacheObject>);10
11 (12 apolloClient as ApolloClient<NormalizedCacheObject> & {13 ssrMode: boolean;14 }15 ).ssrMode = Boolean(ctx);16
17 apolloClient.cache.restore(initialState);18
19 return apolloClient;20};
在这里去控制创建 ApolloClient 时的 ssrMode 属性, 同时使用 initialState 来写入当前 client 缓存.
withApollo 的第二部分很简单, 就是判断是否在生产环境然后决定是否展示组件名:
1if (process.env.NODE_ENV !== "production") {2 const displayName =3 PageComponent.displayName || PageComponent.name || "Component";4 WithApollo.displayName = `withApollo(${displayName})`;5}
最后一个部分:
1if (ssr || PageComponent.getInitialProps) {2 WithApollo.getInitialProps = async (ctx: ContextWithApolloOptions) => {3 const inAppContext = Boolean(ctx.ctx);4 const { apolloClient } = initOnContext(ac, ctx);5
6 let pageProps = {};7 if (PageComponent.getInitialProps) {8 pageProps = await PageComponent.getInitialProps(ctx);9 } else if (inAppContext) {10 pageProps = await App.getInitialProps(ctx);11 }12
13 if (typeof window === "undefined") {14 const { AppTree } = ctx;15 if (ctx.res && ctx.res.writableEnded) {16 return pageProps;17 }18
19 // Only if dataFromTree is enabled20 if (ssr && AppTree) {21 try {22 const { getDataFromTree } = await import("@apollo/client/react/ssr");23
24 let props;25 if (inAppContext) {26 props = { ...pageProps, apolloClient };27 } else {28 props = { pageProps: { ...pageProps, apolloClient } };29 }30 // @ts-ignore31 await getDataFromTree(<AppTree {...props} />);32 } catch (error) {33 console.error("Error while running `getDataFromTree`", error);34 }35 Head.rewind();36 }37 }38
39 return {40 ...pageProps,41 apolloState: apolloClient.cache.extract(),42 apolloClient: ctx.apolloClient,43 };44 };45}
写不动了 改天再写