如何从0~1实现一个简版的 React Query

320 阅读3分钟

前言

Fetch, cache and update data in your React and React Native applications all without touching any "global state".

从接触到 React Query,就有种莫名的喜悦,要是早点见面,我就不会在项目里去手写 cache、retry、监听 online 等方法了。专注于 query ,相比市场上的 SWR、ahooks 等,在 fetch 上几乎不具备可比性,强烈推荐

本文适合熟悉观察者模式、Promise、React的同学。

使用方法

使用示例,参考一个最简单的 useQuery ,更多用法参考官网。

import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider, useQuery } from "react-query";

const queryClient = new QueryClient();
const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);
root.render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

/** App */
const App = () => <Demo />

/** Demo */
const asyncFetch = () => {
  return fetch(
    "https://gw.alipayobjects.com/os/bmw-prod/1d565782-dde4-4bb6-8946-ea6a38ccf184.json"
  ).then((res) => res.json());
};

export const Demo = () => {
  const { data = [], loading } = useQuery(['list'], asyncFetch);
  
  if(loading) return <Loading />
    
  return <div>Fetch Data: {data.length}</div>;
};

整体架构

简化版的架构,会有出入,仅做参考。

p5.png

简版实现

仅实现简版的 useQuery 以及对应的 cache 功能,源码地址 github.com/lxfu1/simpl…,目录结构如下:

image.png

client provider

就是一个简单的 React.createContext ,将 QueryClient 实例共享给所有子组件 。

import React, { useEffect } from "react";
import { QueryClient } from "./client";

export const ClientContext = React.createContext<QueryClient | undefined>(
  undefined
);

interface IQueryClientProvider {
  children: React.ReactNode;
  client: QueryClient;
}
export const QueryClientProvider: React.FC<IQueryClientProvider> = ({
  client,
  children,
}) => {
  useEffect(() => {
    client.mount();
    return () => client.unMount();
  }, []);

  return (
    <ClientContext.Provider value={client}>{children}</ClientContext.Provider>
  );
};

use query

从 context 中获取对应的 QueryClient 类,并初始化一个 OueryObserver(extends Observer) 用于监听数据变化,并在数据变化时 notify subScriber,并通过 useSyncExternalStore(不了解的看前面的文章) 触发组件更新, getOptimisticResult 触发接口调用,在 Retryer 发送真正的请求。

import { useSyncExternalStore, useCallback, useContext, useState } from "react";
import { QueryClient } from "./client";
import { ClientContext } from "./client-provider";

import { QueryObserver } from "./observer";

export const useQuery = (
  sign: string | (string | number)[] = [],
  queryFn: () => Promise<any>
) => {
  const queryClient = useContext(ClientContext) as QueryClient;

  const queryKey = typeof sign === "string" ? sign : sign.join("-");

  const options = {
    queryKey,
    queryFn,
  };

  const [observer] = useState(() => new QueryObserver(queryClient, options));

  const result = observer.getOptimisticResult(options);

  const subsrcibe = useCallback(
    (onStoreChange: () => void) => {
      return observer.subScribe(onStoreChange);
    },
    [observer, sign]
  );

  useSyncExternalStore(
    subsrcibe,
    () => observer.getResult(),
    () => observer.getResult()
  );

  return result;
};

client

提供 fetchQuery、preFetchQuery(未实现) 等方法,通过 this.queryCache.build 创建真正的 Query ,并挂载到 QueryCache 上,当 queryKey 相同时,直接返回 cache 的 Query。

import { QueryCache } from "./cache";
import { QueryOptions } from "./type";
export class QueryClient {
  private queryCache: QueryCache;
  constructor() {
    this.queryCache = new QueryCache();
  }
  mount() {}
  unMount() {}
  getQueryCache(): QueryCache {
    return this.queryCache;
  }
  fetchQuery(options: QueryOptions) {
    const query = this.queryCache.build(this, options);
    return query.fetch();
  }
}

cache

缓存 Query 实例,当存在时直接返回,不存在时创建。

import { Subscribable } from "./subscribable";
import { Query } from "./query";
import { QueryClient } from "./client";
import { QueryOptions } from "./type";

type QueriesMap = Map<string, Query>;

export class QueryCache {
  private queriesMap: QueriesMap;
  constructor() {
    this.queriesMap = new Map();
  }
  build(client: QueryClient, options: QueryOptions) {
    const { queryFn, queryKey } = options;
    let query = this.get(queryKey);

    if (!query) {
      query = new Query({
        cache: this,
        queryFn,
        queryKey,
        client,
      });
      this.add(queryKey, query);
    }

    return query;
  }
  get(queryKey: string) {
    return this.queriesMap.get(queryKey);
  }
  add(queryKey: string, query: Query) {
    this.queriesMap.set(queryKey, query);
  }
  remove() {}
  find() {}
  clear() {}
}

query

每个请求一个 Query ,用于存储 query 返回的的 state ,并在有数据更新时通知 observer 。

import { createRetryer } from "./retryer";
import { QueryObserver } from "./observer";
import type { IAction, QueryOptions, IState, Retryer } from "./type";

export class Query {
  queryOptions: QueryOptions;
  state: IState = { loading: true };
  observers: QueryObserver[] = [];
  promise?: Promise<any>;
  private retryer?: Retryer;
  constructor(config: any) {
    this.queryOptions = config;
  }

  addObserver(observer: QueryObserver) {
    this.observers.push(observer);
  }
  fetch() {
    if (!this.retryer) {
      this.retryer = createRetryer({
        fn: this.queryOptions.queryFn,
        onSuccess: (data) => {
          this.dispatch({
            data,
            type: "success",
          });
        },
        onFail: (data) => {
          this.dispatch({
            data,
            type: "failed",
          });
        },
      });
    }
    this.promise = this.retryer.promise;
    return this;
  }
  private dispatch(action: IAction) {
    const reducer = (state: IState = {}) => {
      switch (action.type) {
        case "failed":
          return {
            ...state,
            loading: false,
            status: "failed" as const,
          };
        case "success":
          return {
            ...state,
            loading: false,
            data: action.data,
            status: "success" as const,
          };
      }
    };
    this.state = reducer(this.state);
    this.observers.forEach((observer) => {
      observer.onQueryUpdate(action);
    });
  }
}

retryer

请求发起者,请求成功或时候时调用对应的 resolve 、 reject 。

import { Retryer, IRetry } from "./type";

export function createRetryer<TData = unknown, TError = unknown>(
  config: IRetry
): Retryer {
  let isResolved = false;
  let promiseResolve: (data: TData) => void;
  let promiseReject: (error: TError) => void;

  const promise = new Promise<TData>((outerResolve, outerReject) => {
    promiseResolve = outerResolve;
    promiseReject = outerReject;
  });

  const resolve = (value: any) => {
    if (!isResolved) {
      isResolved = true;
      config.onSuccess?.(value);
      promiseResolve(value);
    }
  };

  const reject = (value: any) => {
    if (!isResolved) {
      isResolved = true;
      config.onFail?.(value);
      promiseReject(value);
    }
  };

  // Execute query
  const run = () => {
    if (isResolved) {
      return;
    }

    let promiseOrValue: any;
    
    try {
      promiseOrValue = config.fn();
    } catch (error) {
      promiseOrValue = Promise.reject(error);
    }

    Promise.resolve(promiseOrValue)
      .then(resolve)
      .catch((error) => {
        if (isResolved) {
          return;
        }
        reject(error);
      });
  };

  run();

  return {
    promise,
  };
}

示例

#demo1

import { useQuery } from "../react-query";

const asyncFetch = () => {
  return fetch(
    "https://gw.alipayobjects.com/os/bmw-prod/1d565782-dde4-4bb6-8946-ea6a38ccf184.json"
  ).then((res) => res.json());
};

export const Demo1 = ({ tag }: { tag: string }) => {
  const { data = [], loading } = useQuery([tag], asyncFetch);
  return <div style={{ color: "green" }}>component(1): {data.length}</div>;
};

#demo2

import { useQuery } from "../react-query";

const asyncFetch = () => {
  return fetch(
    "https://gw.alipayobjects.com/os/bmw-prod/1d565782-dde4-4bb6-8946-ea6a38ccf184.json"
  ).then((res) => res.json());
};

export const Demo2 = ({ tag }: { tag: string }) => {
  const { data = [], loading } = useQuery([tag], asyncFetch);
  return <div>component(2): {data.length}</div>;
};

#app 默认分别生成 3 个 demo 组件

import React from "react";
import { Demo1 } from "./examples/demo1";
import { Demo2 } from "./examples/demo2";

function App() {
  return (
    <div className="App">
      {new Array(3).fill("").map((_, index) => {
        return (
          <React.Fragment key={index}>
            <Demo1 tag={`list${index % 2 == 0 ? "1" : "2"}`} />
            <Demo2 tag={`list${index % 2 == 0 ? "1" : "2"}`} />
          </React.Fragment>
        );
      })}
    </div>
  );
}

export default App;

#效果 虽然总共渲染了 6 次 Demo ,但由于只存在 2 个不同的 tag ,所以只发送了2次请求。

p4.png