qim

阅读时长 66 分钟读完

Immutable/functional select/update queries for plain JS.

qim

Immutable/functional select/update queries for plain JS.

WARNING: Qim is already useful, but it's still considered experimental. It might have some rough edges, and the API might change!

Qim makes it simple to reach in and modify complex nested JS objects. This is possible with a query path that is just a simple JS array, much like you might use with set and update from Lodash, but with a more powerful concept of "navigators" (borrowed from Specter, a Clojure library). Instead of just string keys, Qim's navigators can act as predicates, wildcards, slices, and other tools. Those same navigators allow you to reach in and select parts of JS objects as well.

Qim's updates are immutable, returning new objects, but those objects share any unchanged parts with the original object.

Qim's API is curried and data last, so it should fit well with other functional libraries like Lodash/fp and ramda.

And Qim does its best to stay performant!

Contents

A simple (kind-of-contrived) example

Let's start with some data like this:

-- -------------------- ---- -------
----- ----- - -
  ------ -
    ---- -
      ----- -
        ------ ------
        ----- -----
      --
      ------ -------
    --
    ----- -
      ----- -
        ------ -------
        ----- -----
      --
      ------ -------
    -
  --
  ------ -------
--

Let's import a couple things from Qim:

Now let's grab all the first names:

(We'll explain $each a little more later, but you can probably guess: it's like a wildcard.)

firstNames now looks like:

Let's import a couple more things:

And now we can upper-case all our first names.

Notice we used the same path from our select but added an $apply to do a transformation. (Again, we'll explain $apply better in the next section.)

After that, newState looks like:

-- -------------------- ---- -------
----- ----- - -
  ------ -
    ---- -
      ----- -
        ------ ------
        ----- -----
      --
      ------ -------
    --
    ----- -
      ----- -
        ------ -------
        ----- -----
      --
      ------ -------
    -
  --
  ------ -------
--

Just for comparison, let's grab the first names with plain JS:

That's not too bad, but this is a very simple example. The $each from Qim makes things a lot more expressive. Let's look at Lodash/fp and Ramda too:

Lodash/fp is nice and expressive, but it costs a lot in terms of performance.

Test Ops/Sec
native 2,223,356
lodash/fp flow 17,110
Ramda pipe 278,705
qim select 953,949

Ramda performs a lot better, but it's a little less concise.

Qim is slower than native, but it's doing more than the native equivalent, because it's accounting for things like missing keys. And as you'll see soon, it has a lot more expressive power.

That update in plain JS is a lot more verbose, even for this really simple example:

-- -------------------- ---- -------
----- -------- - -
  ---------
  ------ ------------------------
    --------------- --------- -- -
      ----- ---- - ----------------------
      --------------- - -
        --------
        ----- -
          -------------
          ------ -----------------------------
        -
      --
      ------ ------
    -- ---
--

So we go with something like Lodash/fp:

Or Ramda:

Again, performance is going to take a hit for Lodash/fp.

Test Ops/Sec
native 300,219
lodash/fp update 16,663
Ramda update 117,961
qim update 176,196

Ramda is much faster, but we've had to start using lenses. Again, native is the fastest, but at a cost of being awfully unreadable. Qim's main goal isn't to be performant but rather to be expressive. Lodash/fp looks pretty nice, but remember how closely the update resembled the select with Qim? With Lodash/fp, an update is a different animal. With Ramda, we've had to switch to completely different concepts. As we'll see with a more complex example, Qim will retain its simple, expressive query power for updates while Lodash/fp and Ramda are going to get more complicated.

A more complex (not-too-contrived) example

Let's start with some data like this:

-- -------------------- ---- -------
----- ----- - -
  ------- -
    -------- -
      ---- -
        ------ ------
        ----- ----------
        -------- --
      --
      ---- -
        ------ -------
        ----- ----------
        -------- ----
      --
      ---- -
        ------ ------
        ----- -----------
        -------- --
      -
    -
  -
--

Let's say we want to change our state so that for every savings account, we:

  1. Add 5% interest to any balance > 1000.
  2. Subtract 10 from any balance < 100. Cause fees are how banks make money, right?

(And I know banks should have transactions, yada, yada.)

Okay, drum roll... with Qim, we can do that like this:

Even without any explanation, hopefully you have a rough idea of what's going on. Like we saw in the simple example with $each and $apply, instead of only accepting an array of strings for a path, Qim's update function accepts an array of navigators. Using different types of navigators together creates a rich query path for updating a nested object. We'll look closer at this particular query in a bit, but first let's try the same thing with vanilla JS.

-- -------------------- ---- -------
----- -------- - -
  ---------
  ------- -
    ----------------
    -------- ------------------------------------------------- --- -- -
      ----- ------- - -------------------------
      -- ------------- --- ---------- -
        -- ---------------- -- ----- -
          ---------- - -
            -----------
            -------- --------------- - ----
          --
          ------ -------
        -
        -- ---------------- - ---- -
          ---------- - -
            -----------
            -------- --------------- - --
          --
          ------ -------
        -
      -
      ---------- - --------
      ------ -------
    -- ---
  -
--

Yuck. That is ugly. Lots of references to things we don't really care about. Okay, hopefully nobody writes code like that. Let's use Lodash/fp to clean that up.

-- -------------------- ---- -------
------ -- ---- ------------

----- -------- - -------------------- ----------- -------------------- --
  ------------ --- --------- - -
    -------------------- ---------
      ---- -- --- -- ----- --- -- --- - ------
      ---- -- --- - ---- --- -- --- - ----
      ------------- --- -- ----
    --- --------
  - - -------
-- ------

Okay, that's a lot more concise, but there are still some problems:

  1. We have to return account in the case where it's not a savings account. Our original requirement was really to filter out savings accounts and operate on those, but we can't really do that, because we want to modify the whole state. Using a filter would strip out the accounts we don't modify.
  2. Similarly, we have to return the balance even if it's not a low or high balance that we want to change.
  3. If we nest deeper and break out of point-free style, it gets pretty awkward to write or read the code. We could clean that up by splitting this into multiple functions, but remember how concise the requirement is vs the resulting code complexity.
  4. If none of our accounts actually match these criteria, we'll still end up with a new state object.

Qim boils this down to the essential declarative parts, using an expressive query path, and it avoids unnecessary mutations.

Let's stretch out the previous example to take a closer look at some of the navigators used.

-- -------------------- ---- -------
----- -------- - --------
  -- - ------ --------- -- ---- --- -- --- -------
  --------- ----------
  -- ----- -- ---- - -------- ---- ------- ---- ----- -- -- ------ -- ------
  ------
  -- --------- --- ---- ---------- --- -------- ---- -- -- --------
  ------- -- ------------ --- ----------
  -- ------- --- ----------
  ----------
  -- ------ --- ---- ------ ------- --- ---- ----------
  -
    -- ------- --------- -- ---- --- - ---- --------
    --- -- --- -- -----
    -- ------ -- ---- -- --------- ---- ---- -- --- -------
    ---------- -- --- - -----
  --
  -- ------ ---- ------
  -
    -- ------- --------- -- ---- --- - --- --------
    --- -- --- - ----
    -- ------ --- --------- -------- ------ --- ----- ---- ------
    -- -- -- -- -------- --------------- -- --------- ------
    ---------- -- --- - ---
  -
-- -------

Because navigators are only ever working on the part of the object that you've navigated to, you don't ever have to worry about the parts of the object that you don't touch. Those parts remain intact.

These modifications are immutable, and they share unmodified branches:

Changing something to its current value is a no-op:

And of course these navigators are useful for selecting data too. Instead of modifying an object, the select method navigates to each matching part of the query and returns all the matching parts in an array.

Let's get a little more fancy. Let's grab all the usernames of people that have high balances.

has checks if a selection returns anything. We use currying to create a function for checking if an account balance is high, and we use that as a predicate to select the owners with a high balance.

Cool, huh?

Installation

Usage

All functions and navigators are available as named exports:

Or of course you can just import everything:

You can also import individual functions. If you import from qim, then don't do this! qim points to a bundle that includes all the individual modules, so importing individual modules will import them again.

If you npm install qim, you'll have UMD builds in node_modules/qim/build/umd/qim.js and node_modules/qim/build/umd.min.js.

API

All methods in this section are curried, meaning if you leave off any of the arguments, you'll get a function that takes the remaining arguments. Specifically, this is most useful for the last object parameter. For example:

apply(query, transform, object)

Just a convenience method for updating with a single transform ($apply) function.

find(query, object)

Like select, but only returns a single result. If many results would be returned from a select, it will return the first result.

Generally, this will perform much better than taking the first item of the array returned by a select.

has(query, object)

Returns true if an object has a matching result.

select(query, object)

Returns an array of selected results from an object.

set(query, value, object)

Just a convenience method to set a query path to a constant value.

update(query, object)

Returns a mutation of an object without changing the original object.

Navigators

Built-in, type-based navigators

Key (string/integer)

Navigates to that key of an object/array.

Predicate (function)

Passes the currently navigated value to the function and continues navigating if the function returns true.

Nested (array)

Branches and performs a sub-query. Mainly useful for update, since you may want to update different branches of an object in different ways. You can branch with select, but this is less useful since you typically want to select homogenous value types.

Stop (undefined/null)

Stops navigation. This is most useful inside $nav or custom path navigators to stop navigation for certain values.

For nested queries, undefined/null only stops the current nested query, not the whole query.

Named navigators

By convention, all navigators are prefixed with a $. This is mainly intended to visually distinguish them in a query path. But it also is meant to distinguish them from normal functions. Navigators are declarative, meaning they represent a navigation to be performed, rather than actually doing an operation.

$apply(fn)

Transforms the currently navigated value using the provided function. Typically used for update, but can be used for select to transform the values selected.

$begin

Navigates to an empty list at the beginning of an array. Useful for adding things to the beginning of a list.

$default(value)

By default, Qim will create missing objects when you try to update a path that doesn't exist. You can use $default to change this behavior and provide your own default value.

$each

Navigates to each value of an array or object.

$eachKey

Navigates to each key of an array or object.

$eachPair

Navigates to each key/value pair of an array or object. A key/value pair is just an array of [key, value].

$end

Navigates to an empty list at the end of an array. Useful for adding things to the end of a list.

$first

Navigates to the first value of an array or object.

-- -------------------- ---- -------
---------------- --- -- ---
-- ---

---------------- --- -- -- -- -- ---
-- ---

--------------- --------------- --- -- ---
-- --------- -- --

--------------- --------------- --- -- -- -- -- ---
-- --- -------- -- -- -- --

$last

Navigates to the last value of an array or object.

-- -------------------- ---- -------
--------------- --- -- ---
-- ---

--------------- --- -- -- -- -- ---
-- ---

-------------- -------------- --- -- ---
-- --- -- -------

-------------- -------------- --- -- -- -- -- ---
-- --- -- -- -- -- -------

$lens(fn, fromFn)

Lens is like $apply, and the first function behaves identically, in that it transforms the current value using the provided function. But it also takes a second function that can be used to apply the result of a transformation to the current object during an update.

-- -------------------- ---- -------
----- ---- - ------
  -- --- ----- -------- ---------- --- ------- ---- ---- -------
  -- ---- ------ ------- ---------- - ------- ----- -- --- ---------- -----------
  - -- - - ----
  -- --- ------ -------- ------- --- -------------- -- --- --- -----
  -- ---- ------ ------- ---------- - ---------- ----- -- --- ------- -----------
  --- -- --- - ---
--

-------
  ----- ----- --- -- --- - --- ---------- -- --- - ----
  --- ----
-
-- --- ----

See custom navigators for more about $lens.

$merge(spec)

Similar to $apply(object => ({...object, ...spec})) except:

  • Does not create a new object if object already has the same keys/values as spec.
  • Will create a new array if object is an array so can be used to merge arrays.

$mergeDeep(spec)

Deep merging version of $merge. Typically, deep merging is better handled with nested queries. But if you must...

$nav(path, ...morePaths)

Given a query path, $nav navigates as if that query was a single selector. This is useful for using queries as navigators (instead of nested queries). This has the same affect as spreading (...) a query into another query.

-- -------------------- ---- -------
----- --------- - -------------- --------

-------
  ----------- --------
  -
    ------ -
      ---- -
        ----- -----
      --
      ----- -
        ----- ------
      -
    -
  -
-
-- ------- -------
-- -------------------- ---- -------
----- --------- - -------------- --------

-------
  ----------- ------- ----------- -- ---------------------
  -
    ------ -
      ---- -
        ----- -----
      --
      ----- -
        ----- ------
      -
    -
  -
-
-- ------- ----- ------ ------- ----- ------ ---------

You can pass multiple paths to $nav to navigate to all of those paths.

-- -------------------- ---- -------
-------
  --------- ------------- ---------- ------- ----------- -- ---------------------
  -
    ------ -
      ---- -
        ----- -----
      --
      ----- -
        ----- ------
      -
    -
  -
-
-- ------- ----- ------ ------- ----- ------ ---------

If path is a function, it will be passed the current object, and it can return a dynamic query. This can be used to inline a custom navigator or just to get the current value in scope.

-- -------------------- ---- -------
-------
  -
    ------ -----
      --- -- ----------- ---------- --- -------
    -
  --
  ---- -- -- --- --- -- -- ---
-
-- -
--   --- -- -- -- -------- ------
--   --- -- -- -- -------- ------
-- -

See custom navigators for more about $nav.

$none

Navigates to nothing so you can delete properties from objects and items from arrays. It would be great to use undefined for this, but technically undefined is a value in JS, so $none exists to allow for the edge case of being able to set a property or item to undefined.

$pick(keys, ...keys)

Navigates to a subset of an object, using the provided keys (or arrays of keys).

$pushContext(key, (obj, context) => contextValue)

This is a convenient form of $setContext where the current value for the key is assumed to be an array and the new value is pushed onto that array. This is especially useful for recursive queries where you want to retain parent context values.

-- -------------------- ---- -------
-------
  -
    ---------- -------------------- --------- --
    ---------- -------------------- --------- --
    ---------------- ---- -- ------- --------- ----------
  --
  ------- ----- ---- ---- ----- -------- ----- ---- ---- -----
-
-- -
--   ------ --------- ------- -------- -----
--   ------ --------- ------- -------- -----
--   ------ ----------- ------- -------- -----
--   ------ ----------- ------- -------- ----
-- -

$set(value)

Just a convenience for setting a value, rather than using $apply(() => value). (Also a teensy bit more performant.)

$setContext(key, (value, context) => contextValue)

Sets a context value for later retrieval in an $apply or $nav.

Context is a way to grab a piece of data at one point in a query and use it at a later point in a query. A good example is grabbing a key from a key/value pair and retrieving that key along with a descendant value so that it can be returned in a selection or used in an update. You could use a $nav function to pull that key into scope, but for multiple levels of nesting, this can get unwieldy. And for recursive queries, this is impossible without creating some kind of complex enveloping mechanism.

$setContext takes a key and a function as arguments. The key is just a way of keeping that piece of context separate from other pieces of context. The function takes the current value and context and returns a value to store for that piece of context. Note that context is never mutated, so the new context only applies to the rest of the query.

-- -------------------- ---- -------
-------
  -
    ---------- -------------------- --------- --
    ---------- ------------------ --------- --
    ---------------- ---- -- -------- ---------- ---- -------- ----------
  --
  ------- ----- ---- ---- ----- -------- ----- ---- ---- -----
-
-- -
--   ------- -------- ---- ------ -------- -----
--   ------- -------- ---- ------ -------- -----
--   ------- ---------- ---- ------ -------- -----
--   ------- ---------- ---- ------ -------- ----
-- -
-- -------------------- ---- -------
-------
  -
    ------------------------ -------------------- ---------
    ----------
    ------------ ---- -- ------------------------
    ------ ------
  --
  -
    -------- -
      -- ------ -------
      -- ------ ---------
      -- ------ --------
    --
    ---------- ----- ----
  -
-
-- ------- --------

$slice(begin, end)

Navigates to a slice of an array from begin to end index.

$traverse({select, update})

See custom navigators.

Custom navigators

Custom navigators are simply composed of other navigators. For example, you could create a $toggle navigator that flips boolean values like this:

In particular though, the important navigators for building other navigators are $nav, $lens, and $traverse.

$nav lets you build "path" navigators. A path navigator is the highest level option. If you want to abstract away multiple other navigators, or you want to dynamically choose a navigator based on the current object being navigated to, or you want to do a recursive query, you probably want a path navigator.

$lens lets you build "lens" navigators. A lens navigator is generally going to be simpler than a core navigator and will be almost as performant, but doesn't offer quite as much control. Think of a lens navigator as a two-way $apply. You transform the current object for the rest of the query, and for an update, you define a transformation to be applied to the object. If you always want to call the rest of the query, and you only want to call the rest of the query once, then a lens navigator is a good fit.

$traverse lets you build, for lack of a better term, "core" navigators. It's the lowest-level option and gives you complete control of data selection and updates. Generally, core navigators are going to be the most performant, but they're also going to require the most code. If you need to maybe call the rest of the query, or you want to call the rest of the query many times, you probably need a core navigator.

Path navigators

The simplest path navigator just points to a query path. This is useful for using queries as navigators (instead of nested queries). This has the same affect as spreading (...) a query into another query.

-- -------------------- ---- -------
----- --------- - -------------- --------

-------
  ----------- --------
  -
    ------ -
      ---- -
        ----- -----
      --
      ----- -
        ----- ------
      -
    -
  -
-
-- ------- -------

-------
  ----------- ------- ----------- -- ---------------------
  -
    ------ -
      ---- -
        ----- -----
      --
      ----- -
        ----- ------
      -
    -
  -
-
-- ------- ----- ------ ------- ----- ------ ---------

The path passed to $nav can also be a function, which allows it to provide a different path based on the current value.

-- -------------------- ---- -------
----- ---------- - -----
  ----- ----- -- -
    -- --------- --- --------- -
      ------ --------- ------ --------
    -
    -- --------- --- ------- -
      ------ ---------
    -
    -- ------- ----------- ----- ----- -----------
  -
--

-------
  ------- ------------
  ------- ------- ----- ------- ------ --------- ------ ------- --------- ------ -------- ----- ---------
-
------- ------

You can also create recursive queries using path navigators.

-- -------------------- ---- -------
----- ----- - -----
  ------ ------ --
    ------------------- - ------- ------ - --
--

-------
  ------- --- -- --- - - --- ---
  --- -- -- --- -- -- --- -- ----
-
-- --- -- -- -- --

-------
  ------- --- -- --- - - --- -- ---------- -- --- - -----
  --- -- -- --- -- -- --- -- ----
-
-- --- -- --- --- --- -- ---- -- -----

Parameterized path navigators

To parameterize a path navigator, just create a function that returns a path navigator.

-- -------------------- ---- -------
----- ----- - ------- -- -----
  ----- -- -
    -- --------------------- -
      ----- --- ------------ ---- ----- -- ---------
    -
    ------ ---------- --------
  -
--

-------
  ---------- -------
  --- -- -- -- -- --
-
-- --- -- --

-------
  ---------- ------ ---------- -- --- - -----
  --- -- -- -- -- --
-
-- --- --- --- -- --

Lens navigators

Lens navigators are built by using $lens, which takes two functions. The first function applies a transformation for either a select or update. The second function is only invoked for an update and allows you to apply that transformation to the current object.

-- -------------------- ---- -------
-- ------ - --------- ---- ------- -- -------- --- ------ -- -- ------
----- ------- - ------
  -------- -- -
    -- ----------------------- -
      ------ --------------
    -
    ----- --- -------------- ---- ----- -- ---------
  --
  ----------- ------- -- -
    ----- --------- - --------------------
    -- ---------- - -------------- -
      ------ --------------- -----------
    -
    -- ---------- - -------------- -
      ------ - ----------------
      --- ---- - - -- - - --------- - -------------- ---- -
        -----------------------
      -
      ------ -------
    -
    ------ -------
  -
--

----------------- --- -- ---
-- ---

-------------- -- --- -- ---
-- --- -- --

-------------- -- --- -- ---
-- --- --

-------------- -- --- -- ---
-- --- -- -- ----------

Parameterized lens navigators

To parameterize a lens navigator, just create a function that returns a lens navigator.

-- -------------------- ---- -------
----- ------ - ------ -- ------
  -------- -- -------------------
  ------------- -- ----------------------
--

-------
  ------------- -- ----------- -- ---------------------
  ---------------------
-
-- -------------------

Core navigators

Core navigators are built by using $traverse, which takes an object with a select and update function.

-- -------------------- ---- -------
-- ------ - --------- ---- ------- -- -------- --- ------ -- -- ------
----- ------- - -----------
  ------- -------- ----- -- -
    -- ----------------------- -
      ------ --------------------
    -
    ----- --- -------------- ---- ----- -- ---------
  --
  ------- -------- ----- -- -
    -- ----------------------- -
      ----- --------- - --------------------
      -- ---------- - -------------- -
        ------ --------------- -----------
      -
      -- ---------- - -------------- -
        ------ - ----------------
        --- ---- - - -- - - --------- - -------------- ---- -
          -----------------------
        -
        ------ -------
      -
      ------ -------
    -
    ----- --- -------------- ---- ----- -- ---------
  -
---

----------------- --- -- ---
-- ---

-------------- -- --- -- ---
-- --- -- --

-------------- -- --- -- ---
-- --- --

-------------- -- --- -- ---
-- --- -- -- ----------

Parameterized core navigators

To parameterize a core navigator, just create a function that returns a core navigator.

-- -------------------- ---- -------
-- ------ - --------- ---- ------- -- ------- --- ----- - ----- -- -- ------
----- ----- - ------- -- -----------
  ------- -------- ----- -- -
    -- ----------------------- -
      ------ -------------------- --------
    -
    ----- --- ------------ ---- ----- -- ---------
  --
  ------- -------- ----- -- -
    -- ----------------------- -
      ----- ------ - -------------------- --------
      ----- -------- - ----------------
      ------------------ ------ -----------
      ------ ---------
    -
    ----- --- ------------ ---- ----- -- ---------
  -
---

------------------ ----- ---- -----
-- ------ -----

--------------- ------ ----- ---- -----
-- ----- ----

Performance

Qim aims to be performant enough.

For Lodash operations that are immutable (like get and set), Qim should have similar performance. Many Lodash functions mutate (like update), and in nearly all cases, Qim will be faster than Lodash/fp's immutable functions. Likewise, Qim will typically be faster than React's immutability helper (now an external package). Ramda seems to perform much better than Lodash/fp, and sometimes it will be faster than Qim, while sometimes Qim will be faster.

In some cases, a custom native helper function using Object.assign or slice along with a mutation may be faster for simple operations, but Qim aims to be as close as possible, while still allowing for a flexible querying API.

Comparing to Immutable.js is difficult in that it heavily depends on your particular use case. The sweet spot for Immutable.js is lots of transformations of large objects or arrays (thousands of items). And even then, you need to avoid marshalling data back and forth between Immutable and plain JS. If you marshal the data back and forth, you'll lose most of the benefit, and if you work with smaller objects and arrays, you're unlikely to see much benefit.

If you want flexible select and update of plain JS objects, Qim is likely to be a good fit. You can check out the current benchmarks to get an idea how Qim stacks up. As with all benchmarks, be careful reading into them too much. Also, Qim is new, and performance tradeoffs could change in favor of simplifying the code or API. Overall, remember that the goal of Qim is to be expressive and performant enough, not to win at every benchmark.

TODO

  • Work with iterables (like Map).
  • More tests.
  • Better error messages.
  • Expose ES6 modules.
  • Static typing? Qim might be diametrically opposed to static types, but worth seeing how good/bad they would fit.

Contributing

  • Do the usual fork and PR thing.
  • Make sure tests pass with npm test and preferaby add additional tests.
  • Make sure to run npm run benchmark to make sure the benchmarks pass. Currently, the benchmarks are running against Node 6.2.2 on a late 2013 MacBook Pro. The benchmarks are by no means perfect, and small regressions may be allowed in exchange for significant improvements, but for the most part, performance needs to remain consistent or improve.
  • Thanks!

Thanks

As mentioned above, the navigator concept borrows heavily from Specter, a Clojure library written by Nathan Marz.

HomePage

https://github.com/jdeal/qim#readme

Repository

git+https://github.com/jdeal/qim.git

来源:JavaScript中文网 ,转载请注明来源 https://www.javascriptcn.com/post/6005672281e8991b448e3921

纠错
反馈