项目作者: iWeltAG

项目描述 :
Pinia store extension that allows transparent caching of state in local storage.
高级语言: TypeScript
项目地址: git://github.com/iWeltAG/pinia-cached-store.git
创建时间: 2021-08-09T07:36:35Z
项目社区:https://github.com/iWeltAG/pinia-cached-store

开源协议:MIT License

下载


Cached Pinia Stores

This module defines a factory function that creates read-only
Pinia stores which automatically copy their state to
local storage and can load it from there, omitting the need for subsequent
refetches. The pattern enables a lightweight dataloading system with a global
cache, somewhat similar to what
React Query,
Apollo or other
libraries provide. Another use case is to cache expensive calculations in a
store. These can then be re-instantiated quickly when the page gets refreshed
without needing to recompute.

As an example of the second aforementioned use case, the following store
calculates Pi to a given precision and stores the value:

  1. import { defineCachedStore } from 'pinia-cached-store';
  2. const usePiStore = defineCachedStore({
  3. id: 'pi',
  4. state: () => ({
  5. value: 3.14,
  6. }),
  7. async refresh({ iterations }) {
  8. // This is a simple (and pretty inefficient) Monte Carlo algorithm that
  9. // estimates Pi. A fixed number of points is randomly distributed inside the
  10. // square from the origin to [1,1]. We check how many of these points fall
  11. // into the corresponding quarter-circle and use that to calculate pi.
  12. let hits = 0;
  13. for (let i = 0; i < iterations; i++) {
  14. const x = Math.random();
  15. const y = Math.random();
  16. if (x * x + y * y <= 1) {
  17. hits += 1;
  18. }
  19. }
  20. // For the quarter-circle's area A:
  21. // A = pi (since r=1)
  22. // A / 4 = hits / iterations
  23. this.value = (4 * hits) / iterations;
  24. },
  25. });

Later in our app, the store can be used like this:

  1. const pi = usePiStore();
  2. // Make sure you are in an async function. Also, feel free to play around with
  3. // this paramter and get different values:
  4. await pi.$load({ iterations: 99999 });
  5. // Somewhere else:
  6. const doublePi = pi.value * 2;
  7. // Or in templates:
  8. // <span>Pi: {{ pi.value }}</span>

Note that since a cached store is read-only, it is no longer possible to
define actions. That means the store’s sole purpose is to merely reflect data
on a backend or store the output of some computation – the source of truth
will never be the store itself. Using
getters (computed
properties) is fine, though.

Installation

Install the package as follows:

  1. npm install --save-dev pinia-cached-store
  2. # or
  3. yarn add --dev pinia-cached-store

Both Vue and Pinia 2 should also be installed as dependencies in your project.

Usage

For this guide, we will look at the following example store. It takes the name
of a Pizza dish as an input and stores all the required ingredients for making
it. A cached store is defined much like a
regular Pinia store,
and any options can be here as well, except for actions, since caching makes a
store read-only. For completeness’ sake, we will directly define it in
Typescript. Here is the code for the complete store definition:

  1. import { defineCachedStore } from 'pinia-cached-store';
  2. import pizzaApi from 'awesome-pizza-place';
  3. // Users of the store will pass these options when initializing it. We can use
  4. // them to pass around information relevant to what data we want to fetch.
  5. // As another example, a store containing customer data would take something
  6. // like the customer's ID in this options object.
  7. interface PizzaRefreshOptions {
  8. name: string;
  9. extraCheese?: boolean;
  10. }
  11. export const usePizzaStore = defineCachedStore({
  12. id: 'pizza',
  13. state: () => ({
  14. toppings: [] as string[],
  15. sauce: null as string | null,
  16. cheese: false as boolean | 'extra',
  17. }),
  18. async refresh({ name, extraCheese }: PizzaRefreshOptions) {
  19. this.$state = await pizzaApi.getPizzaByName(name);
  20. if (this.cheese && extraCheese) {
  21. this.cheese = 'extra';
  22. }
  23. },
  24. });

The refresh callback

A cached store must define an asynchronous function named refresh in the top
level. Although it looks and feels like an action, it is explicitly not placed
inside the actions object, and you won’t be able to call it directly later,
either. The purpose of this function is to populate the state with the correct
data when a user requests it. You must define exactly one argument to this
function, which are the options that you will receive from the user (see
the using section below). This will only be called when
the cache doesn’t already contain an entry for the requested options, so you
don’t need to do any cache checking here.

Using this works exactly the same way as it would inside an action. That means
that you can use methods like
\$patch to
overwrite (parts of) the state, as well as setting this.$state directly.
Setting individual keys in the state also works as expected.

Using the store

Once the store is created, you can use it just like any other Pinia store. By
default, cache-enabled stores are created from the state() function in their
definition. You can then use the $load action to populate the state with data,
depending on what you currently need. This method takes the exact same parameter
object you defined for refresh and will update the state accordingly:

  1. // Inside setup() of a component:
  2. const pizza = usePizzaStore();
  3. onMounted(async () => {
  4. await pizza.$load({ pizzaName: 'hawaii' });
  5. });

Multiple $load() calls (either consecutively or after a page refresh) will
prioritize cached content. Cache entries are considered applicable if they were
created from the same options object as the current call:

  1. // This will call refresh() with our objects object:
  2. await pizza.$load({ pizzaName: 'hawaii' });
  3. // Here, refresh() is called again, since we have a new options object:
  4. await pizza.$load({ pizzaName: 'hawaii', extraCheese: true });
  5. // Again, this will call refresh():
  6. await pizza.$load({ pizzaName: 'hawaii' });
  7. // Since this object was passed before, this is loaded from cache:
  8. await pizza.$load({ extraCheese: true, pizzaName: 'hawaii' });

Cache clearing

To remove all entries from local storage corresponding a store, call the
$clearCache action. Since this will reset the store to the initial state (the
one returned by the state function in the store’s definition), you probably
want to load some data again afterwards:

  1. pizza.$clearCache();
  2. await pizza.$load({ pizzaName: 'margherita' });

Note that this will only clear that specific store’s cache — those of other
stores are left untouched.

Manual cache writes and SSR

Every cached store has a $flushCache method which you can call if you would
like to force the cache to be written to storage. You don’t need to do this when
calling $load, but there might be other circumstances.

For example, in an SSR environment like Nuxt you might want to calculate the
store’s content on the server and store it in the client’s local storage while
hydrating. You can use this snippet to start:

  1. const useComplicatedStore = defineCachedStore({
  2. id: 'magic',
  3. caching: {
  4. // Don't write to a cache while performing SSR. You'll need to check your
  5. // SSR framework's docs to find out how to check if SSR is currently
  6. // running.
  7. storage: import.meta.env.SSR ? null : window.localStorage,
  8. },
  9. state: () => ({ value: 1 }),
  10. async refresh() {
  11. this.value = calculateValue();
  12. },
  13. // Don't just copy this, read the note below.
  14. hydrate(storeState, initialState) {
  15. this.$flushCache();
  16. },
  17. });

Note that you might not even need hydrate. If the store is pre-filled from
rendering server-side, there isn’t really a need to blindly copy that data to
the user’s local storage. An exception to this suggestion would be if you
$load() different values into the store during the lifecycle of you app. In
that case it might be beneficial to cache the server-rendered dataset on the
client as well.

You might also want to check out
Vue’s hydration guide,
the corresponding Pinia guide, and
the docs on Pinia’s hydrate option.

Secondary payloads

The $load method accepts a second argument which will be passed verbatim to
your refresh method. You can use this to provide data when refreshing that is
not factored in when calculating a key for caching. Since caches are stored
under a key that depends on what you pass as the first argument, you need to use
the second payload if you have data that isn’t part of the state key. This
payload is also passed to checkValidity, if provided.

As an example, we could rewrite the pi store from above to use this argument for
the number of iterations. That way we only get one local storage entry if we
call it multiple times (this time with TypeScript):

  1. import { defineCachedStore } from 'pinia-cached-store';
  2. const usePiStore = defineCachedStore({
  3. id: 'pi',
  4. state: () => ({
  5. value: 3.14,
  6. iterations: 0,
  7. }),
  8. async refresh(options: {}, iterations: number) {
  9. this.value = thePiAlgorithmFromAbove(iterations);
  10. this.iterations = iterations;
  11. },
  12. caching: {
  13. checkValidity(data: { iterations: number }, requestedIterations: number) {
  14. // Only recalculate when more iterations are requested:
  15. return iterations >= requestedIterations;
  16. },
  17. },
  18. });

When calling the store now, a recalculation will only be performed if the number
of iterations requested is higher than that from the last calculation. Further,
local storage will only contain one entry for this store.

Caching options

The object passed to the store factory function may also contain a set of
options to control the caching behaviour:

  1. export const store = defineCachedStore({
  2. // ...
  3. caching: {
  4. keyPrefix: 'myStore',
  5. },
  6. });

Following options are supported (all are optional):

  • keyPrefix — By default, cache entries are stored into local storage
    entries named store followed by the store ID and a base64 representation of
    the provided options. This option can be used to change that prefix to
    something else.
  • refreshSpecificKey — Set this to false to use one cache key for the
    entire store. By default (true), different cache entries are created
    depending on the options which $load is called with (as described in the
    guide above). Note that disabling this behaviour will effectively invalidate
    options you give to $load in a second call because then the cache will be
    used instead (which may have been populated with other arguments).
  • maxAge — This is the maximum age a cache entry may have before it is
    considered stale and needs to be refetched. Provide this as a number, in
    milliseconds (the default is 86400000, or a day).
  • checkValidity — In addition to maxAge, this option can be used to modify
    how existing cache entries get loaded. It is set to a function that receives
    the (old) data and returns either true or false, depending on whether it
    should be loaded or discarded and refetched.
  • loadingKey — Set this to the name of a boolean property defined in state
    and it will automatically be set to true when loading starts. After loading
    is finished (with or without errors), it will be set back to false. The
    property will not be created, it needs to exist in the state already.
  • storage — Use this option to use a different
    Storage object to
    save the cache. By default, the browser’s local storage is used. Make sure to
    handle SSR somehow if you set this option. If you set this to null, no
    storage is used and the cache is effectively bypassed.

You can get final caching key that is actually used from the stores’
computedCacheKey property. It is set after calling $load.

Error handling

For error logging / handling, you can use Pinia’s
subscription mechanism.
Using a subscription, you can perform additional actions before and after the
load process runs:

  1. let isLoading = false;
  2. store.$onAction(({ name, after, onError }) => {
  3. if (name !== '$load') return;
  4. isLoading = true;
  5. after(() => (isLoading = false));
  6. onError((error) => {
  7. isLoading = false;
  8. console.warn(`Error while loading store: ${error}`);
  9. });
  10. });

See the aforementioned documentation for more details. In particular, note that
the subscription lives as long as the component it was created in. Further,
$onAction returns a function that can be used to manually terminate the
subscription if necessary.

License

MIT