在不失去 Suspense 的好处的情况下寻找一种模式来规范 Recoil 中的状态

Looking for a pattern to normalize state in Recoil without losing the benefit of Suspense

在 RecoilJS 中,与异步选择器的 React Suspense 无缝集成是一大优势。但是,我 运行 正在尝试规范化缓存在 Recoil 中的数据,同时仍然使用 Suspense。

为了通过示例解释问题,用户可能有 collection 本书。查询使用单个 API 调用填充 collection 以获取所有用户的“收藏夹”书籍。稍后的查询可能只请求一本书,这可能已经通过收藏夹查询检索到,也可能还没有。

我想做的是维护标准化的书籍缓存,例如在由 bookId 键入的 AtomFamily 中,因此我没有通过不同查询提取的两本书副本。但是,我 运行 遇到了一个问题,那就是我想对检索一本或多本书的任何一个查询使用 Suspense。使用 Recoil 执行此操作的自然方法是使用异步选择器。但我没有看到,如果有一种方法可以规范化通过异步选择器获取的数据。

有没有我忽略的模式,它允许我使用异步选择器来表示由共享的规范化 AtomFamily 支持的不同查询?

例如,如果我有这个 BAD 代码,它在我的状态下创建了重复的 objects,我该如何修改它以维护实际书籍 objects 的共享缓存,并且仍然使如果在使用此状态的组件呈现时仍在获取查询,则使用 Suspense?

查询一:通过选择器获取一组图书:

const favoriteBooksSelector = selector({
  key: 'MyFavoriteBooks',
  get: async ({ get }) => {
    const response = await allMyFavorityBooksDBQuery({
      userID: get(currentUserIDState)
    });
    return response.books;
  },
});

查询 2:得到一本书,看起来像:

  export const singleBookSelector = selectorFamily({
    key: 'singleBookSelector',
    get: (bookId: string) => async ({ get }) => {
        const response = await singleBookDBQuery({
            userID: get(currentUserIDState)
          });
          return response.book;
    }
  });

要使用缓存,必须对其进行索引(键控)。对于您的示例案例,通过图书 ID 对缓存进行键控是明智的,因此 KV 缓存是一个合理的选择。在JavaScript中,Map是这种缓存的自然选择。

下面,我编写了一个 fully-functional 示例,说明如何将此类缓存实现为某些 Recoil atomFamily 实例的主要来源。代码有注释,有不明白的地方我可以再解释一下。

An increasing query count is displayed as proof of the effectiveness of the cache. I have also included a link to the code in the TypeScript Playground for evaluation. If you would like to modify the code, all you need to do is copy it into a new answer (or just copy and paste it into local text editor and save it as an HTML file, and then serve it via a local http server).

TS Playground

<div id="root"></div><script src="https://unpkg.com/react@17.0.2/umd/react.development.js"></script><script src="https://unpkg.com/react-dom@17.0.2/umd/react-dom.development.js"></script><script src="https://unpkg.com/recoil@0.6.1/umd/recoil.min.js"></script><script src="https://unpkg.com/@babel/standalone@7.17.6/babel.min.js"></script><script>Babel.registerPreset('tsx', {presets: [[Babel.availablePresets['typescript'], {allExtensions: true, isTSX: true}]]});</script>
<script type="text/babel" data-type="module" data-presets="tsx,react">

// import ReactDOM from 'react-dom';
// import {default as React, Suspense, useEffect, useState, type ReactElement, type ReactNode} from 'react';
// import {atomFamily, RecoilRoot, useRecoilValue} from 'recoil';

// This Stack Overflow snippet demo uses UMD modules instead of the above import statments
const {Suspense, useEffect, useState} = React;
const {atomFamily, RecoilRoot, useRecoilValue} = Recoil;

type Book = {
  author: string;
  id: string;
  title: string;
};


// Database simulation:

// The simulated database
const db = new Map<string, Book>();
// Scraped from https://www.penguin.co.uk/articles/2018/100-must-read-classic-books.html#100
(JSON.parse(`[{"author":"Jane Austen","title":"Pride and Prejudice","id":"BnuQKALlW6B6sZNU4bdaB"},{"author":"Harper Lee","title":"To Kill a Mockingbird","id":"UM3ms9hlnTbEmx44JknKc"},{"author":"F. Scott Fitzgerald","title":"The Great Gatsby","id":"hBl51iaNCQ8qZw5iec8hD"},{"author":"Gabriel García Márquez","title":"One Hundred Years of Solitude","id":"CC9hIXCdEHR0beJlbMF_y"},{"author":"Truman Capote","title":"In Cold Blood","id":"l0iJfZNmNBfioHDnHARWQ"},{"author":"Jean Rhys","title":"Wide Sargasso Sea","id":"D0UY9kmrV6HbqlIMspVwn"},{"author":"Aldous Huxley","title":"Brave New World","id":"rK2ks0GbZBDQPns-ZDEyW"},{"author":"Dodie Smith","title":"I Capture The Castle","id":"flTB4dqKfg1PWcUI6KtH2"},{"author":"Charlotte Bronte","title":"Jane Eyre","id":"3x-S6EsNUTZ5l_sESamF_"},{"author":"Fyodor Dostoevsky","title":"Crime and Punishment","id":"ntH3G63fMVKUud6rRhDbY"},{"author":"Donna Tartt","title":"The Secret History","id":"ubrxbS1-7NEr_lml6I8Q3"},{"author":"Jack London","title":"The Call of the Wild","id":"friqBlVlEY3eg2cpkgUET"},{"author":"John Wyndham","title":"The Chrysalids","id":"wRMQGG1QYaeVXXP_ghl-x"},{"author":"Jane Austen","title":"Persuasion","id":"YoMqTM9PhAfctMBqSdz6P"},{"author":"Herman Melville","title":"Moby-Dick","id":"Kd0Oggfkf5AQPGBqpw_iE"},{"author":"C.S. Lewis","title":"The Lion, the Witch and the Wardrobe","id":"-jD0Ujt-r54xbKZ_7Jv59"},{"author":"Virginia Woolf","title":"To the Lighthouse","id":"1TJQYcP6_hwm2syHUH8Dv"},{"author":"Elizabeth Bowen","title":"The Death of the Heart","id":"dl1qbyM0cHdmYUHKhTyZk"},{"author":"Thomas Hardy","title":"Tess of the d'Urbervilles","id":"_i6SLfaMpXRuhVqEH5Jhp"},{"author":"Mary Shelley","title":"Frankenstein","id":"ZPL-swiUogF-_gdabf9qv"},{"author":"Mikhail Bulgakov","title":"The Master and Margarita","id":"x0pw07n3o2KljHZM11isw"},{"author":"L. P. Hartley","title":"The Go-Between","id":"l0jHUSb4bY64k-l9Qed5Z"},{"author":"Ken Kesey","title":"One Flew Over the Cuckoo's Nest","id":"SCKsZTWD2QMsNomUie_Vf"},{"author":"George Orwell","title":"Nineteen Eighty-Four","id":"JscV73l2tSdm5W4kZSvZn"},{"author":"Thomas Mann","title":"Buddenbrooks","id":"f0XqwYfsWJ-w9J18b5FCD"},{"author":"John Steinbeck","title":"The Grapes of Wrath","id":"OnXfkmQEAL7sSQ3PgSV9z"},{"author":"Toni Morrison","title":"Beloved","id":"n3_aZgBlQkphqPTvmJGr6"},{"author":"P. G. Wodehouse","title":"The Code of the Woosters","id":"TzD6k5flXf8HMdfgSacMT"},{"author":"Bram Stoker","title":"Dracula","id":"_WPS6E_6uXVKWX0r2Sop6"},{"author":"J. R. R. Tolkien","title":"The Lord of the Rings","id":"bIzyksKmB0plzGwWI6h7l"},{"author":"Mark Twain","title":"The Adventures of Huckleberry Finn","id":"ctQZfUT_tsujBCdYkv4HA"},{"author":"Charles Dickens","title":"Great Expectations","id":"ULj9NAatfo8tCCe39YZTY"},{"author":"Joseph Heller","title":"Catch-22","id":"bOOUBZK7oFVDRrevxApvN"},{"author":"Edith Wharton","title":"The Age of Innocence","id":"ZJ8y0y-BbnaH5A9TulxgN"},{"author":"Chinua Achebe","title":"Things Fall Apart","id":"eahxg8sFYsudKEl9hocJv"},{"author":"George Eliot","title":"Middlemarch","id":"TLNUskf7TspVe3AOEV4nX"},{"author":"Salman Rushdie","title":"Midnight's Children","id":"0_DeHTlQpW4ffy-liu2R-"},{"author":"Homer","title":"The Iliad","id":"D9cyf2yCAwhnASsxGxtTd"},{"author":"William Makepeace Thackeray","title":"Vanity Fair","id":"YmXxLcLMYmuFkp39Q1aAa"},{"author":"Evelyn Waugh","title":"Brideshead Revisited","id":"p3D_ZtFdhT2Eytv7swOAZ"},{"author":"J.D. Salinger","title":"The Catcher in the Rye","id":"3Sf-5_lsdGVeiWJeSZZQI"},{"author":"Lewis Carroll","title":"Alice’s Adventures in Wonderland","id":"TJJ6J8OHF5PRaiHLEcPdq"},{"author":"George Eliot","title":"The Mill on the Floss","id":"F6S5twxijUt7cSvuoSeKH"},{"author":"Anthony Trollope","title":"Barchester Towers","id":"0jYVd6dhiSF1tJYuIU8az"},{"author":"James Baldwin","title":"Another Country","id":"xRjGwu2vOQObLqbFccnw_"},{"author":"Victor Hugo","title":"Les Miserables","id":"GR24l64YVjFagi-SB1Y-H"},{"author":"Roald Dahl","title":"Charlie and the Chocolate Factory","id":"CAoAoALD3T8wxX0Eevabi"},{"author":"S. E. Hinton","title":"The Outsiders","id":"XYhNMkKTKsh9aNGh24fvZ"},{"author":"Alexandre Dumas","title":"The Count of Monte Cristo","id":"Igcm-Wxq2Uf8vKjBr-D7j"},{"author":"James Joyce","title":"Ulysses","id":"GiianKDQPQVTIaFoFhy6H"},{"author":"John Steinbeck","title":"East of Eden","id":"belUus-Sta74zWfjTiuMW"},{"author":"Fyodor Dostoyevsky","title":"The Brothers Karamazov","id":"wp9JOJ0B8lKmxG0siRuR4"},{"author":"Vladimir Nabokov","title":"Lolita","id":"tvnoXyLsd-PtVmiwZLnM8"},{"author":"Frances Hodgson Burnett","title":"The Secret Garden","id":"VZyJI95JMwkj4rJOJbzzn"},{"author":"Evelyn Waugh","title":"Scoop","id":"QYgFDNe1S0x5V_ub-Vc-S"},{"author":"Charles Dickens","title":"A Tale of Two Cities","id":"G0FUeqOiLuNnBNEr4XPD2"},{"author":"George Grossmith and Weedon Grossmith","title":"Diary of a Nobody","id":"PLi0tMjdAZI54P3U02B2N"},{"author":"Leo Tolstoy","title":"Anna Karenina","id":"E0OlPZ9F8Z3rsEmGihW-0"},{"author":"Alessandro Manzoni","title":"The Betrothed","id":"hPHRkfbcMUeJUejXy7spa"},{"author":"Virginia Woolf","title":"Orlando","id":"FSzptVHC-ICRl0tlPhS-O"},{"author":"Ayn Rand","title":"Atlas Shrugged","id":"CdzIlNo9jp5CDAP5BEwLi"},{"author":"H. G. Wells","title":"The Time Machine","id":"dQn4oEs0hqgfuaFR13S-o"},{"author":"Sun-Tzu","title":"The Art of War","id":"LZwoJLEtLv4Dx2QnUBvwM"},{"author":"John Galsworthy","title":"The Forsyte Saga","id":"p9hOPd4gC7PKX9bbp8JVZ"},{"author":"John Steinbeck","title":"Travels with Charley","id":"c3LtQi5_p-XSF2JSfPOjq"},{"author":"Henry Miller","title":"Tropic of Cancer","id":"iFILNdFzltGXugvwpUjSS"},{"author":"D. H. Lawrence","title":"Women in Love","id":"gYf7mAVCM_SX5e3NDwc9y"},{"author":"Paul Scott","title":"Staying On","id":"gZYOkRz4APlcDGNH5onYD"},{"author":"Kenneth Grahame","title":"The Wind in the Willows","id":"epTCvsskVjm3vnomZCPRw"},{"author":"Willa Cather","title":"My Ántonia","id":"wWoBKiKEQ6KpwigH2RtMQ"},{"author":"Emily Brontë","title":"Wuthering Heights","id":"8Feh8HOHmfFZXwhkclUmj"},{"author":"Patrick Süskind","title":"Perfume","id":"JJntMbxqiKvuryEO82VAX"},{"author":"Leo Tolstoy","title":"War and Peace","id":"CPfDnuxwDYeLvzqLPJzXJ"},{"author":"Somerset Maugham","title":"Of Human Bondage","id":"h4IW8mQUmLTJ9uyfVe2qe"},{"author":"Charles Dickens","title":"Bleak House","id":"NPkSH3PieOiq_gE0svlxB"},{"author":"Honoré de Balzac","title":"Lost Illusions","id":"0Ckpg5CMzAYIUbCjWZXPt"},{"author":"Kurt Vonnegut","title":"Breakfast of Champions","id":"Lydqp4eMEkYL3YVkg0krr"},{"author":"Charles Dickens","title":"A Christmas Carol","id":"ApOCi4LPkvoN2R47C1frw"},{"author":"George Eliot","title":"Silas Marner","id":"5CUwpkfRyLjTBBmJHc0Ic"},{"author":"Virginia Woolf","title":"Mrs Dalloway","id":"9Pdh2b7of93bT-Xp1egBB"},{"author":"Louisa May Alcott","title":"Little Women","id":"095_BrLfJD-pI2nOtqJII"},{"author":"Iris Murdoch","title":"The Sea, The Sea","id":"5V4JjZvcqWhiLTdpYjc5r"},{"author":"Mario Puzo","title":"The Godfather","id":"cK1YXvMZ4xRZVFyQDKcG3"},{"author":"Franz Kafka","title":"The Castle","id":"bV5hrXcPzSfPhLPITPlj7"},{"author":"Robert Graves","title":"I, Claudius","id":"2FFaA72V-Pp74A6mZajR7"},{"author":"J.M. Barrie","title":"Peter Pan","id":"6vwOgrhQTp60ISU-KIxoQ"},{"author":"John Kennedy Toole","title":"A Confederacy of Dunces","id":"zZwqEBfR72Ht_Uwa25blx"},{"author":"W. Somerset Maugham","title":"The Razor's Edge","id":"uL-eIpi0xf11BDmpxfxYQ"},{"author":"Flora Thompson","title":"Lark Rise to Candleford","id":"wISh6hRf-rIOXzGV9pReU"},{"author":"Thomas Hardy","title":"The Return of the Native","id":"ouX9cTm5gF36zX95SfOaE"},{"author":"James Joyce","title":"A Portrait of the Artist as a Young Man","id":"dX6B1SNtZH_Kij9ZdQ3cx"},{"author":"Joseph Conrad","title":"Heart of Darkness","id":"uQk4tRerBAtFtZwh-Xyx3"},{"author":"Elizabeth Gaskell","title":"North and South","id":"8bRGCx_5Pk3i4-RNXlley"},{"author":"Margaret Atwood","title":"The Handmaid's Tale","id":"E0tJsPHR6JnnoQ9UKtKHE"},{"author":"Irene Nemirovsky","title":"Suite Francaise","id":"0lq5lUjV7A0SMvUF-ucmv"},{"author":"Alexander Solzhenitsyn","title":"One Day in the Life of Ivan Denisovich","id":"3Qik1V1BoZZDyPphzedzb"},{"author":"Jonathan Coe","title":"What A Carve Up!","id":"UhNcCOU_TzUDbTOvxzUPU"},{"author":"Robert Pirsig","title":"Zen and the Art of Motorcycle Maintenance","id":"Alpfu_s-Ee8L6G1s7-WD2"},{"author":"Fyodor Dostoyevsky","title":"White Nights","id":"Lr3KmI-pOxer7rSsF8MhE"},{"author":"Charles Dickens","title":"Hard Times","id":"OrxuKkQoEgg2cSDQcyyPc"}]`) as Book[])
  .forEach(book => db.set(book.id, book));

function delay (ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function randomInt (min = 0, max = 1): number {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

// Simulated db methods
const booksDb = {
  async getOne (id: string): Promise<Book | undefined> {
    return (await this.getMany([id]))[0];
  },
  async getMany (ids: string[]): Promise<(Book | undefined)[]> {
    await delay(randomInt(50, 500));
    return ids.map(id => {
      const book = db.get(id);
      // Simulate getting a copy every time
      return book ? {...book} : undefined;
    });
  },
};


// Recoil state:

// Cached results
const booksCache = new Map<string, Book>();

// Just for this demo, maintain a query count
let dbQueryCount = 0;

// Inspired by effector, I prefix recoil-related variables with $ to simplify naming
const $book = atomFamily<Book | undefined, string>({
  key: 'book',
  default: async (id) => {
    // Return from cache, querying db only if unavailable
    if (!booksCache.has(id)) {
      dbQueryCount += 1;
      const book = await booksDb.getOne(id);
      if (book) booksCache.set(id, book);
    }
    return booksCache.get(id);
  },
});

const $books = atomFamily<(Book | undefined)[], string[]>({
  key: 'books',
  default: async (ids) => {
    const books: (Book | undefined)[] = [];
    const available: [index: number, id: string][] = [];
    const unavailable: [index: number, id: string][] = [];

    // Split query into collections of available in cache or not
    for (const [index, id] of ids.entries()) {
      const isAvailable = booksCache.has(id);
      (isAvailable ? available : unavailable).push([index, id]);
    }

    // Get cached results
    for (const [index, id] of available) {
      books[index] = booksCache.get(id);
    }

    // Query the remaining with a single network request
    dbQueryCount += 1;
    const booksFromDb = await booksDb.getMany(unavailable.map(([, id]) => id));

    // Update cache and finalize
    for (const [index, id] of unavailable) {
      const book = booksFromDb[index];
      if (book) booksCache.set(id, book);
      books[index] = booksCache.get(id);
    }

    return books;
  },
});


// Components:

function BookComponent ({book}: { book: Book | undefined }): ReactElement {
  if (!book) return (<div>Book is not availble</div>);
  return (
    <div>
      <em>{book.title}</em> by <span>{book.author}</span>
    </div>
  );
}

function BookFromId ({id}: { id: string }): ReactElement {
  const book = useRecoilValue($book(id));
  return <BookComponent {...{book}} />;
}

function BookCollection ({ids}: { ids: string[]; }): ReactElement {
  // To see these loaded individually, uncomment the following lines:

  // return (<div>{ids.map((id, index) => (
  //   <BookFromId {...{id, key: `${index}-${id}`}} />
  // ))}</div>);

  const books = useRecoilValue($books(ids));

  return (<div>{books.map((book, index) => (
    <BookComponent {...{book, key: `${index}-${book?.id}`}} />
  ))}</div>);
}

function LoadingDiv ({children}: { children?: ReactNode }): ReactElement {
  return (<div>{children}</div>);
}

const collections: [title: string, ids: string[]][] = [
  ['Titles starting with A', ['ApOCi4LPkvoN2R47C1frw', 'zZwqEBfR72Ht_Uwa25blx', 'dX6B1SNtZH_Kij9ZdQ3cx', 'G0FUeqOiLuNnBNEr4XPD2', 'TJJ6J8OHF5PRaiHLEcPdq', 'E0OlPZ9F8Z3rsEmGihW-0', 'xRjGwu2vOQObLqbFccnw_', 'CdzIlNo9jp5CDAP5BEwLi']],
  ['Titles starting with B', ['0jYVd6dhiSF1tJYuIU8az', 'n3_aZgBlQkphqPTvmJGr6', 'NPkSH3PieOiq_gE0svlxB', 'rK2ks0GbZBDQPns-ZDEyW', 'Lydqp4eMEkYL3YVkg0krr', 'p3D_ZtFdhT2Eytv7swOAZ', 'f0XqwYfsWJ-w9J18b5FCD']],
  ['Titles starting with C', ['bOOUBZK7oFVDRrevxApvN', 'CAoAoALD3T8wxX0Eevabi', 'ntH3G63fMVKUud6rRhDbY']],
];

type OrPromise<T> = T | Promise<T>;

function useLazyValue <T>(initialValue: T, producer: () => OrPromise<T>): T {
  const [value, setValue] = useState(initialValue);

  const updateValue = async () => {
    const result = await producer();
    if (value !== result) setValue(result);
  };

  useEffect(() => void updateValue());
  return value;
}

function App (): ReactElement {
  const [collectionIndex, setCollectionIndex] = useState(0);
  const collectionIds = collections[collectionIndex]![1];
  const queryCount = useLazyValue(0, () => dbQueryCount);

  const booksLoading = <LoadingDiv>The collection is loading...</LoadingDiv>;

  return (
    <div style={{
      display: 'flex',
      flexDirection: 'column',
      gap: '0.5rem',
      fontFamily: 'sans-serif',
    }}>
      <h1>Recoil book cache</h1>

      <div>Query count: {queryCount}</div>

      <label>
        <div>Select a collection:</div>
        <select
          onChange={ev => setCollectionIndex(Number(ev.target.value))}
          value={collectionIndex}
        >{collections.map(([title], index) => (
          <option key={`${index}-${title}`} value={index}>{title}</option>
        ))}</select>
      </label>

      <Suspense fallback={booksLoading}>
        <BookCollection ids={collectionIds} />
      </Suspense>
    </div>
  );
}

function AppRoot (): ReactElement {
  return (
    <RecoilRoot>
      <App />
    </RecoilRoot>
  );
}

ReactDOM.render(<AppRoot />, document.getElementById('root'));


</script>