Skip to content
Linbudu's Blog

【outdated】Next-Apollo 源码解析

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-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(Local Schema)

前面两点都涉及到 GraphQL 的图式结构, 一个典型的 GraphQL 查询语句可能是这样的:

1query UserQuery {
2 User {
3 UserProfile {
4 name
5 description
6 level
7 PreferItems {
8 Book {
9 bookId
10 bookName
11 bookPublishDate
12 }
13 Author {
14 authorName
15 AuthorWorks {
16 bookName
17 bookPublishDate
18 }
19 }
20 }
21 }
22 UserOrders {
23 commodityId
24 commodityPrice
25 commodityDate
26 commodityType {
27 isOnSale
28 category
29 }
30 }
31 }
32}

你也可以阅读 GitHub GraphQL API 文档 来了解更多复杂 Schame 架构

可以看到

NextJS

关于 NextJS, 应该很多人都使用过, 直接简单介绍下他的特性即可:

  • 屏蔽了 Webpack 配置, 内置的配置已经经过一系列调优, 包括 Babel 和 TSConfig, 代码分割等
  • 服务端渲染 SSR 和静态页面生成 SSG
  • 约定式路由, 但如果是动态路由还是最好用自定义服务器来直出页面
  • 支持自定义服务器集成, 可以换成任意你喜欢的 NodeJS 服务框架
  • API 路由, 这个特性在我最初使用 NextJS 的时候似乎并没有, 我是在 BlitzJS 的文档中看到才发现这个新功能的, 个人感觉用处主要是做 BFF 一类的, 因为虽然你能在这个前端项目中构建 API, 但通常不会在这个纯前端项目中去连接数据库啥的, 如果你有这种需求, 还是用 BlitzJS 吧~ 也就说这里的 API 应该主要是调用已存在的后端服务, 裁剪/清洗/聚合后再返回给前端.

Next-Apollo

知道了 NextJS 和 Apollo-Client 的作用后, Next-Apollo 的出现也就理所当然了, NextJS 对你的后端 API 没有任何要求, 如果恰好你的后端 API 是 GraphQL, 那么此时再加上 Apollo-Client 无疑能够让你的开发更加畅快.

先简单看看如何使用:

完整例子见作者的 Next-Apollo-Example

1// lib/apollo.js
2import { 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.js
2import 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 id
8 votes
9 __typename
10 }
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 + 1
23 }
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-ignore
59 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 | undefined
11) => {
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).

  • 首先获取 apolloClient
  • 对于来自服务端的请求, 需要为每次请求创建一个新的 client
  • 对于客户端, 则直接使用当前已有的 client(如果没有就先创建一个)

至于 createApolloClient 方法也很简单:

1const createApolloClient = (
2 acp: ApolloClientParam,
3 initialState: NormalizedCacheObject,
4 ctx: NextPageContext | undefined
5) => {
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 enabled
20 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-ignore
31 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}

写不动了 改天再写