一个增强版的减少器,但如果减少器具有撤销/重做和时间旅行功能。
Welcome to Crystalize.js, where state management gets a transformative twist. This isn't just another reducer; it's a game-changer that lets you retain, navigate, and selectively aggregate your data.
Here, 'crystals' are your final state, 'shards' are the elements you feed in, and—here's the kicker—the 'base crystal' is your initial point, accumulating the shards you don't need immediate access to, while also maintaining your prior aggregation (or state).
What are 'Crystals' and 'Shards'? And why?
Basic operation is easy and intuitive, using .with() to add shards to your crystalizer, and .take(N) to take your final crystal (state), N count of shards, and your 'base crystal'.
Feeling selective? Use .without() to filter out shards. Want to navigate through your state's history? Meet .leave() and .focus(), your time-traveling tools.
Initialization is a breeze with options to sort, map, timestamp, and even set shard retention limits.
So, are you ready to rewrite the rules of state management? Dive into Crystalize.js and discover the future, today!
Here's how to harness the transformative power of Crystalize.js for your projects:
npm i -D crystalize.jsimport Crystalizer from 'crystalize.js';crystalize.js / Exports
Sample apps, as built, will be placed here and linked to ./samples.
A crystalizer is, in essence, a reducer. With default settings, you get something that closely resembles state management from things like Redux. Which, of course, is just a normal reduce function used in a particular way. So, you might wonder what the names are for, and why not just use the colloquial names 'actions' and 'reducers'?
I'll answer that now, and also give an introduction to Crystalize.js.
Crystalize.js, while it is essentially a reducer, serves a different purpose. A reducer simply reduces a collection of elements into a single aggregate. But, what Crystalize.js sets out to do is a little bit different. What if you want to keep the collection you passed in? What if you want variable amounts of that collection aggregated, or to be able to rewind to different points of that aggregation to see what it was at that point?
It's fair to think of a 'crystal' as an accumulator, and a 'shard' as an element. And that's really what they are. But that doesn't capture the goal of Crystalize.js, either.
They could likewise be called 'state' and 'actions', and that's really what they are, when Crystalize.js is used in that way. But, Crystalize.js sets out to serve more use-cases than actions and state.
Thus, the names are chosen to reflect better what Crystalize.js is doing, in verb form. Shards are crystalized into an accumulated state, and the name calls that out to reflect the control and choice you have in how that process takes place.
To illustrate, here's the flow of an action+reducer:
┌────────────┐ ┌───────────┐ ┌───────────┐
│ │ │ │ │ │
│ state ◄───┤ reduce ◄───┤ action │
│ │ │ │ │ │
└─────▲──────┘ └───────────┘ └───────────┘
│
readableYou pass actions into the reducer, and then they're aggregated into the accumulator, in this case, your app state. You have your state, which is great, but your action is gone. It cannot be replayed, and timing data about that action is lost, unless you add additional state to track that information.
Here's the flow of Crystalize.js:
…You add shards (colloquially, 'actions'), via the .with() method. You get the state via the take() method. But, you can also do more than just get the final state. You also get N count of the most recent shards that were added via .with, and the crystal that is the aggregate of the shards you did not take.
Putting this together, let's say you called .with() and added 5 shards. Then, you called .take(3). You'll get: 1) The final crystal, 2) The 3 most recently added shards, 3) The crystal that is the aggregate of the 2 oldest shards.
Let's bring that home with a code example:
let crystalizer = Crystalizer({
initial: { total: 0 },
reduce: (crystal, shard) => ({ total: crystal.total + shard.value }),
});
crystalizer = crystalizer.with([
{ value: 1 },
{ value: 1 },
{ value: 1 },
{ value: 1 },
{ value: 1 },
]);
const [crystal, shards, base] = crystalizer.take(3);
console.log(crystal); // { total: 5 }
console.log(shards); // [ { value: 1 }, { value: 1 }, { value: 1 } ]
console.log(base); // { total: 2 }You can call this multiple times in a row without losing any data:
(calling take() with no arguments is equivalent to take(Infinity))
…You can also remove shards by using .without(). It's just an inverse filter function, so return true for a shard to be removed.
crystalizer = crystalizer.with([
{ value: 1 },
{ value: 2 },
{ value: 2 },
{ value: 3 },
]);
crystalizer = crystalizer.without((shard) => shard.value == 2);
const [, shards] = crystalizer.take();
console.log(shards); // [{ value: 1 }, { value: 3 }];Crystalizer's keep an internal pointer to the L'th most recent shard that we are currently interested in. L is the number of shards left inside the crystalizer, and not counted when calling take().
Ordinarily, the pointer is at 0. To move it to the next most recent shard, we'd set it to 1. Third most recent, 2, and so on.
The simplest way to do this is with the .leave(L) method, which we'll look at first. If we know a specific shard that we are interested in, we can do that via the .focus method, which we'll look at a little later.
First, let's add .leave(L) to our above diagram:
…And some code:
let crystalizer = new Crystalizer({
initial: { total: 0 },
reduce: (crystal, shard) => ({ total: crystal.total + shard.value }),
});
crystalizer = crystalizer.with([
{ id: 1, value: 1 },
{ id: 2, value: 1 },
{ id: 3, value: 1 },
{ id: 4, value: 1 },
{ id: 5, value: 1 },
]);
const [crystal, shards, base] = crystalizer.leave(2).take(1);
console.log(crystal); // { total: 3 }
console.log(shards); // [{ id: 3, value: 1 }]
console.log(base); // { total: 2 }Let's step through what`s happening here.
.leave(2), so shards with id 4 and 5 are excluded from here on..take(1), so we're only interested in keeping the next most recend shard, id 3crystal contains the aggregate of all the shards we didn't leave: 1, 2, and 3base contains only the aggregate of the shards we didn't take or leave. In this case, that's 1 & 2.The value L is reset if you call .with() or .without(), and all shards that were left will not be part of the next crystalizer object:
let crystalizer2 = crystalizer.leave(4).with([
{ id: 7, value: 1 },
{ id: 8, value: 1 },
]);
const [, shards] = crystalizer2.take();
console.log(shards); // [{ id: 1, value: 1}, { id: 7, value: 1}, { id: 7, value: 1}]
// the old shards aren't lost forever, they're just not part of the new crystalizer
const [, oldShards] = crystalizer.take();
console.log(oldShards); // { ... ids 1, 2, 3, 4, 5 ... }You can also call .leave() with a callback that takes the current L value and return a new one. This is useful for undo/redo behavior:
// undo
crystalizer = crystalizer.leave((l) => l + 1);
// redo
crystalizer = crystalizer.leave((l) => l - 1);The .leave() method is fine if you either know the historic index you want to backtrack to, or you simple want to increment the current one (undo/redo).
But, there might be times where you want to focus on a specific shard and calculate both crystals as though that shard is the most recent shard.
You can use .focus() to accomplish that.
crystalizer = crystalizer.with([
{ id: 1, value: 1 },
{ id: 2, value: 1 },
{ id: 3, value: 1 },
{ id: 4, value: 1 },
{ id: 5, value: 1 },
]);
crystalizer = crystalizer.focus((shard) => shard.id == 3);Note that unlike .leave(), the internal pointer is NOT reset when you call .with() or .without(). Instead, the pointer is updated for each call of .with() or .without() per the seek function.
You can also use .focus() for a chronological value, such as T timestamp.
crystalizer = crystalizer.focus((shard) => shard.ts >= Date.now() - WEEK);However, this relies on the shards being sorted by that value. We'll get into sorting as well in the next section, but there's also builtin ways to handle timestamps in Crystalize.js (see Timestamp).
You can initialize a crystalizer with any number of sorts. You can either sort by a property of your shards, or use a function to do something more custom.
(let's pretend values 1-10 are timestamps that make sense)
…If you only need 1 sort, you can just pass it like so:
new Crystalizer({
...
sort: ['asc', 'timestamp'],
});You might wish to automatically add or change certain keys to every shard. Id's are a great example of this. You can do so by specifying the map option, which takes a simple map function:
import { ulid } from 'ulid';
let crystalizer = new Crystalizer({
initial: { total: 0 },
reduce: (crystal, shard) => ({ total: crystal.total + shard.value }),
map: (shard) => ({ id: ulid(), ...shard }),
});Now, all your shards will have a unique id from ulid if they didn't already have one.
We have enough building blocks to ensure every shard has a timestamp, and are ordered by those timestamps.
import { ulid } from 'ulid';
let crystalizer = new Crystalizer({
initial: { total: 0 },
reduce: (crystal, shard) => ({ total: crystal.total + shard.value }),
map: (shard) => ({ id: ulid(), ts: Dat暂无开放 Issues,或尚未同步最近议题。