1 | <p>
|
2 | <a href="https://www.npmjs.com/package/patella">
|
3 | <img src="https://badgen.net/npm/v/patella?style=flat-square">
|
4 | </a>
|
5 | <a href="https://www.npmjs.com/package/patella?activeTab=dependencies">
|
6 | <img src="https://badgen.net/bundlephobia/dependency-count/patella?style=flat-square">
|
7 | </a>
|
8 | <a href="https://github.com/luavixen/Patella/blob/master/lib/patella.d.ts">
|
9 | <img src="https://badgen.net/npm/types/patella?style=flat-square">
|
10 | </a>
|
11 | <a href="https://bundlephobia.com/result?p=patella">
|
12 | <img src="https://badgen.net/bundlephobia/minzip/patella?style=flat-square">
|
13 | </a>
|
14 | <a href="https://github.com/luavixen/Patella/actions">
|
15 | <img src="https://badgen.net/github/status/luavixen/Patella/master?label=build&style=flat-square">
|
16 | </a>
|
17 | <a href="https://coveralls.io/github/luavixen/Patella">
|
18 | <img src="https://badgen.net/coveralls/c/github/luavixen/Patella?style=flat-square">
|
19 | </a>
|
20 | <a href="https://github.com/luavixen/Patella/blob/master/LICENSE">
|
21 | <img src="https://badgen.net/github/license/luavixen/Patella?style=flat-square">
|
22 | </a>
|
23 | </p>
|
24 |
|
25 | # Patella 🔁
|
26 | Patella, formerly known as Luar, is a library for <a href="https://wikipedia.org/wiki/Reactive_programming">reactive programming</a> in JavaScript, inspired by [Hyperactiv](https://github.com/elbywan/hyperactiv) and [Vue.js](https://vuejs.org/).
|
27 | Patella is compatible with Chrome 5, Firefox 4, and Internet Explorer 9.
|
28 |
|
29 | The [patellar tendon is responsible for the well known "knee-jerk reaction"](https://wikipedia.org/wiki/Patellar_reflex).
|
30 |
|
31 | Jump to one of:
|
32 | - [Installation](#installation)
|
33 | - [Usage](#usage)
|
34 | - [Examples and snippets](#examples-and-snippets)
|
35 | - [Pitfalls](#pitfalls)
|
36 | - [API](#api)
|
37 | - [Authors](#authors)
|
38 | - [License](#license)
|
39 |
|
40 | ## Installation
|
41 | Patella is available via [npm](https://www.npmjs.com/package/patella):
|
42 | ```sh
|
43 | $ npm install patella
|
44 | ```
|
45 | ```javascript
|
46 | // ECMAScript module environments
|
47 | import { observe, ignore, computed, dispose } from "patella";
|
48 | // CommonJS environments
|
49 | const { observe, ignore, computed, dispose } = require("patella");
|
50 | ```
|
51 |
|
52 | Or, for people working without a bundler, it can be included from [UNPKG](https://www.unpkg.com/browse/patella@latest/):
|
53 | ```html
|
54 | <script src="https://www.unpkg.com/patella"></script>
|
55 | <script>
|
56 | Patella.observe({});
|
57 | Patella.ignore({});
|
58 | Patella.computed(() => {});
|
59 | Patella.dispose(() => {});
|
60 | </script>
|
61 | ```
|
62 |
|
63 | Various other Patella builds are available in the [dist](./dist) folder, including sourcemaps and minified versions.
|
64 | Minification is performed using both [Terser](https://github.com/terser/terser) and [UglifyJS](https://github.com/mishoo/UglifyJS) using custom configurations designed for a balance of speed and size (Patella is a micro-library at 900~ bytes gzipped).
|
65 |
|
66 | ## Usage
|
67 | Patella provides functions for observing object mutations and acting on those mutations automatically.
|
68 | Possibly the best way to learn is by example, so let's take a page out of [Vue.js's guide](https://vuejs.org/v2/guide/events.html) and make a button that counts how many times it has been clicked using Patella's `observe(object)` and `computed(func)`:
|
69 | ```html
|
70 | <h1>Click Counter</h1>
|
71 | <button onclick="model.clicks++"></button>
|
72 | <script>
|
73 | const $button = document.getElementsByTagName("button")[0];
|
74 | const model = Patella.observe({
|
75 | clicks: 0
|
76 | });
|
77 | Patella.computed(() => {
|
78 | $button.innerText = model.clicks
|
79 | ? `I've been clicked ${model.clicks} times`
|
80 | : "Click me!";
|
81 | });
|
82 | </script>
|
83 | ```
|
84 | ![](./examples/counter-vid.gif)<br>
|
85 | View the [full source](./examples/counter.html) or [try it on JSFiddle](https://jsfiddle.net/luawtf/hL6g4emk/latest).
|
86 |
|
87 | Notice how in the above example, the `<button>` doesn't do any extra magic to change its text when clicked; it just increments the model's click counter, which is "connected" to the button's text in the computed function.
|
88 |
|
89 | Now let's try doing some math, here's a snippet that adds and multiplies two numbers:
|
90 | ```javascript
|
91 | const calculator = Patella.observe({
|
92 | left: 1,
|
93 | right: 1,
|
94 | sum: 0,
|
95 | product: 0
|
96 | });
|
97 |
|
98 | // Connect left, right -> sum
|
99 | Patella.computed(() => calculator.sum = calculator.left + calculator.right);
|
100 | // Connect left, right -> product
|
101 | Patella.computed(() => calculator.product = calculator.left * calculator.right);
|
102 |
|
103 | calculator.left = 2;
|
104 | calculator.right = 10;
|
105 | console.log(calculator.sum, calculator.product); // Output: 12 20
|
106 |
|
107 | calcuator.left = 3;
|
108 | console.log(calculator.sum, calculator.product); // Output: 13 30
|
109 | ```
|
110 | Pretty cool, right?
|
111 | Patella's main goal is to be as simple as possible; you only need two functions to build almost anything.
|
112 |
|
113 | ## Examples and snippets
|
114 | Jump to one of:
|
115 | - [Concatenator](#concatenator)
|
116 | - [Debounced search](#debounced-search)
|
117 | - [Pony browser](#pony-browser)
|
118 | - [Multiple objects snippet](#multiple-objects-snippet)
|
119 | - [Linked computed functions snippet](#linked-computed-functions-snippet)
|
120 |
|
121 | ### Concatenator
|
122 | ```html
|
123 | <h1>Concatenator</h1>
|
124 | <input type="text" oninput="model.first = value" placeholder="Enter some"/>
|
125 | <input type="text" oninput="model.second = value" placeholder="text!"/>
|
126 | <h3 id="output"></h3>
|
127 | <script>
|
128 | const $output = document.getElementById("output");
|
129 | const model = Patella.observe({
|
130 | first: "",
|
131 | second: "",
|
132 | full: ""
|
133 | });
|
134 | Patella.computed(() => {
|
135 | model.full = model.first + " " + model.second;
|
136 | });
|
137 | Patella.computed(() => {
|
138 | $output.innerText = model.full;
|
139 | });
|
140 | </script>
|
141 | ```
|
142 | ![](./examples/concatenator-vid.gif)<br>
|
143 | View the [full source](./examples/concatenator.html) or [try it on JSFiddle](https://jsfiddle.net/luawtf/zvnm4jp7/latest).
|
144 |
|
145 | ### Debounced search
|
146 | ```html
|
147 | <h1>Debounced Search</h1>
|
148 | <input type="text" oninput="model.input = value" placeholder="Enter your debounced search"/>
|
149 | <h3 id="search"></h3>
|
150 | <script>
|
151 | const $search = document.getElementById("search");
|
152 |
|
153 | const model = Patella.observe({
|
154 | input: "",
|
155 | search: ""
|
156 | });
|
157 |
|
158 | Patella.computed(() => {
|
159 | search.innerText = model.search;
|
160 | });
|
161 |
|
162 | let timeoutID;
|
163 | Patella.computed(() => {
|
164 | const input = model.input;
|
165 | if (timeoutID) clearTimeout(timeoutID);
|
166 | timeoutID = setTimeout(() => {
|
167 | model.search = input;
|
168 | }, 1000);
|
169 | });
|
170 | </script>
|
171 | ```
|
172 | ![](./examples/debounce-vid.gif)<br>
|
173 | View the [full source](./examples/debounce.html) or [try it on JSFiddle](https://jsfiddle.net/luawtf/abd3qxft/latest).
|
174 |
|
175 | ### Pony browser
|
176 | ```html
|
177 | <main id="app">
|
178 | <h1>Pony Browser</h1>
|
179 | <select></select>
|
180 | <ul></ul>
|
181 | <input type="text" placeholder="Add another pony"/>
|
182 | </main>
|
183 | <script>
|
184 | // Find elements
|
185 | const $app = document.getElementById("app");
|
186 | const [, $select, $list, $input] = $app.children;
|
187 |
|
188 | // Declare model
|
189 | const model = Patella.observe({
|
190 | /* Truncated, find full source in ./examples/pony.html */
|
191 | });
|
192 |
|
193 | // Populate <select>
|
194 | for (const [value, { name }] of Object.entries(model.characterSets)) {
|
195 | const $option = document.createElement("option");
|
196 | $option.value = value;
|
197 | $option.innerText = name;
|
198 | $select.appendChild($option);
|
199 | }
|
200 |
|
201 | // Connect model.selected.key -> model.selected.current
|
202 | Patella.computed(() => {
|
203 | model.selected.current = model.characterSets[model.selected.key];
|
204 | });
|
205 |
|
206 | // Connect model.selected.current.members -> <ul>
|
207 | Patella.computed(() => {
|
208 | $list.innerHTML = "";
|
209 | for (const member of model.selected.current.members) {
|
210 | const $entry = document.createElement("li");
|
211 | $entry.innerText = member;
|
212 | $list.appendChild($entry);
|
213 | }
|
214 | });
|
215 |
|
216 | // Connect <select> -> model.selected.key
|
217 | $select.addEventListener("change", () => {
|
218 | model.selected.key = $select.value;
|
219 | });
|
220 |
|
221 | // Connect <input> -> model.selected.current.members
|
222 | $input.addEventListener("keyup", ({ key }) => {
|
223 | if (key !== "Enter") return;
|
224 |
|
225 | const currentSet = model.selected.current;
|
226 | currentSet.members = [
|
227 | ...currentSet.members,
|
228 | $input.value
|
229 | ];
|
230 |
|
231 | $input.value = "";
|
232 | });
|
233 | </script>
|
234 | ```
|
235 | ![](./examples/pony-vid.gif)<br>
|
236 | View the [full source](./examples/pony.html) or [try it on JSFiddle](https://jsfiddle.net/luawtf/84wmaz0g/latest).
|
237 |
|
238 | ## Multiple objects snippet
|
239 | ```javascript
|
240 | // Setting up some reactive objects that contain some data about a US president...
|
241 | // Disclaimer: I am not an American :P
|
242 | const person = Patella.observe({
|
243 | name: { first: "George", last: "Washington" },
|
244 | age: 288
|
245 | });
|
246 | const account = Patella.observe({
|
247 | user: "big-george12",
|
248 | password: "IHateTheQueen!1"
|
249 | });
|
250 |
|
251 | // Declare that we will output a log message whenever person.name.first, account.user, or person.age are updated
|
252 | Patella.computed(() => console.log(
|
253 | `${person.name.first}'s username is ${account.user} (${person.age} years old)`
|
254 | )); // Output: George's username is big-george12 (288 years old)
|
255 |
|
256 | // Changing reactive properties will only run computed functions that depend on them
|
257 | account.password = "not-telling"; // Does not output (no computed function depends on this)
|
258 |
|
259 | // All operators work when updating properties
|
260 | account.user += "3"; // Output: George's username is big-george123 (288 years old)
|
261 | person.age++; // Output: George's username is big-george123 (289 years old)
|
262 |
|
263 | // You can even replace objects entirely
|
264 | // This will automatically observe this new object and will still trigger dependant computed functions
|
265 | // Note: You should ideally use ignore or dispose to prevent depending on objects that get replaced, see pitfalls
|
266 | person.name = {
|
267 | first: "Abraham",
|
268 | last: "Lincoln"
|
269 | }; // Output: Abraham's username is big-george123 (289 years old)
|
270 |
|
271 | person.name.first = "Thomas"; // Output: Thomas's username is big-george123 (289 years old)
|
272 | ```
|
273 |
|
274 | ### Linked computed functions snippet
|
275 | ```javascript
|
276 | // Create our nums object, with some default values for properties that will be computed
|
277 | const nums = Patella.observe({
|
278 | a: 33, b: 23, c: 84,
|
279 | x: 0,
|
280 | sumAB: 0, sumAX: 0, sumCX: 0,
|
281 | sumAllSums: 0
|
282 | });
|
283 |
|
284 | // Declare that (x) will be equal to (a + b + c)
|
285 | Patella.computed(() => nums.x = nums.a + nums.b + nums.c);
|
286 | // Declare that (sumAB) will be equal to (a + b)
|
287 | Patella.computed(() => nums.sumAB = nums.a + nums.b);
|
288 | // Declare that (sumAX) will be equal to (a + x)
|
289 | Patella.computed(() => nums.sumAX = nums.a + nums.x);
|
290 | // Declare that (sumCX) will be equal to (c + x)
|
291 | Patella.computed(() => nums.sumCX = nums.c + nums.x);
|
292 | // Declare that (sumAllSums) will be equal to (sumAB + sumAX + sumCX)
|
293 | Patella.computed(() => nums.sumAllSums = nums.sumAB + nums.sumAX + nums.sumCX);
|
294 |
|
295 | // Now lets check the (sumAllSums) value
|
296 | console.log(nums.sumAllSums); // Output: 453
|
297 |
|
298 | // Notice that when we update one value ...
|
299 | nums.c += 2;
|
300 | // ... all the other values update! (since we declared them as such)
|
301 | console.log(nums.sumAllSums); // Output: 459
|
302 | ```
|
303 |
|
304 | ## Pitfalls
|
305 | Patella uses JavaScript's [getters](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Functions/get)[ and ](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty)[setters](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Functions/set) to make all the reactivity magic possible, which comes with some tradeoffs that other libraries like [Hyperactiv](https://github.com/elbywan/hyperactiv) (which uses [Proxy](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Proxy)) don't have to deal with.
|
306 | This section details some of the stuff to look out for when using Patella in your applications.
|
307 |
|
308 | ### Computed functions can cause infinite loops
|
309 | ```javascript
|
310 | const object = Patella.observe({ x: 10, y: 20 });
|
311 |
|
312 | Patella.computed(function one() {
|
313 | if (object.x > 20) object.y++;
|
314 | });
|
315 |
|
316 | Patella.computed(function two() {
|
317 | if (object.y > 20) object.x++;
|
318 | });
|
319 |
|
320 | object.x = 25;
|
321 | // Uncaught Error: Computed queue overflow! Last 10 functions in the queue:
|
322 | // 1993: one
|
323 | // 1994: two
|
324 | // 1995: one
|
325 | // 1996: two
|
326 | // 1997: one
|
327 | // 1998: two
|
328 | // 1999: one
|
329 | // 2000: two
|
330 | // 2001: one
|
331 | // 2002: two
|
332 | // 2003: one
|
333 | ```
|
334 |
|
335 | ### Array mutations do not trigger dependencies
|
336 | ```javascript
|
337 | const object = Patella.observe({
|
338 | array: [1, 2, 3]
|
339 | });
|
340 |
|
341 | Patella.computed(() => console.log(object.array)); // Output: 1,2,3
|
342 |
|
343 | object.array[2] = 4; // No output, arrays are not reactive!
|
344 | object.array.push(5); // Still no output, as Patella does not replace array methods
|
345 |
|
346 | // If you want to use arrays, do it like this:
|
347 | // 1. Run your operations
|
348 | object.array[2] = 3;
|
349 | object.array[3] = 4;
|
350 | object.array.push(5);
|
351 | // 2. Then set the array to itself
|
352 | object.array = object.array; // Output: 1,2,3,4,5
|
353 | ```
|
354 |
|
355 | ### Properties added after observation are not reactive
|
356 | ```javascript
|
357 | const object = Patella.observe({ x: 10 });
|
358 | object.y = 20;
|
359 |
|
360 | Patella.computed(() => console.log(object.x)); // Output: 10
|
361 | Patella.computed(() => console.log(object.y)); // Output: 20
|
362 |
|
363 | object.x += 2; // Output: 12
|
364 |
|
365 | object.y += 2; // No output, as this property was added after observation
|
366 |
|
367 | Patella.observe(object);
|
368 |
|
369 | object.y += 2; // Still no output, as objects cannot be re-observed
|
370 | ```
|
371 |
|
372 | ### Prototypes will not be made reactive unless explicitly observed
|
373 | ```javascript
|
374 | const object = { a: 20 };
|
375 | const prototype = { b: 10 };
|
376 | Object.setPrototypeOf(object, prototype);
|
377 |
|
378 | Patella.observe(object);
|
379 |
|
380 | Patella.computed(() => console.log(object.a)); // Output: 10
|
381 | Patella.computed(() => console.log(object.b)); // Output: 20
|
382 |
|
383 | object.a = 15; // Output: 15
|
384 |
|
385 | object.b = 30; // No output, as this isn't an actual property on the object
|
386 | prototype.b = 36; // No output, as prototypes are not made reactive by observe
|
387 |
|
388 | Patella.observe(prototype);
|
389 |
|
390 | prototype.b = 32; // Output: 32
|
391 | ```
|
392 |
|
393 | ### Non-enumerable and non-configurable properties will not be made reactive
|
394 | ```javascript
|
395 | const object = { x: 1 };
|
396 | Object.defineProperty(object, "y", {
|
397 | configurable: true,
|
398 | enumerable: false,
|
399 | value: 2
|
400 | });
|
401 | Object.defineProperty(object, "z", {
|
402 | configurable: false,
|
403 | enumerable: true,
|
404 | value: 3
|
405 | });
|
406 |
|
407 | Patella.observe(object);
|
408 |
|
409 | Patella.computed(() => console.log(object.x)); // Output: 1
|
410 | Patella.computed(() => console.log(object.y)); // Output: 2
|
411 | Patella.computed(() => console.log(object.z)); // Output: 3
|
412 |
|
413 | object.x--; // Output: 0
|
414 |
|
415 | object.y--; // No output as this property is non-enumerable
|
416 | object.z--; // No output as this property is non-configurable
|
417 | ```
|
418 |
|
419 | ### Enumerable and configurable but non-writable properties will be made writable
|
420 | ```javascript
|
421 | const object = {};
|
422 | Object.defineProperty(object, "val", {
|
423 | configurable: true,
|
424 | enumerable: true,
|
425 | writable: false,
|
426 | value: 10
|
427 | });
|
428 |
|
429 | object.val = 20; // Does nothing
|
430 | console.log(object.val); // Output: 10
|
431 |
|
432 | Patella.observe(object);
|
433 |
|
434 | object.val = 20; // Works because the property descriptor has been overwritten
|
435 | console.log(object.val); // Output: 20
|
436 | ```
|
437 |
|
438 | ### Getter/setter properties will be accessed then lose their getter/setters
|
439 | ```javascript
|
440 | const object = {
|
441 | get val() {
|
442 | console.log("Gotten!");
|
443 | return 10;
|
444 | }
|
445 | };
|
446 |
|
447 | object.val; // Output: Gotten!
|
448 |
|
449 | Patella.observe(object); // Output: Gotten!
|
450 |
|
451 | object.val; // No output as the getter has been overwritten
|
452 | ```
|
453 |
|
454 | ### Properties named `__proto__` are ignored
|
455 | ```javascript
|
456 | const object = {};
|
457 | Object.defineProperty(object, "__proto__", {
|
458 | configurable: true,
|
459 | enumerable: true,
|
460 | writable: true,
|
461 | value: 10
|
462 | });
|
463 |
|
464 | Patella.observe(object);
|
465 |
|
466 | Patella.computed(() => console.log(object.__proto__)); // Output: 10
|
467 |
|
468 | object.__proto__++; // No output as properties named __proto__ are ignored
|
469 | ```
|
470 |
|
471 | ## API
|
472 | <h4 id="observe"><code>function observe(object)</code></h4>
|
473 | Description:
|
474 | <ul>
|
475 | <li>
|
476 | Makes an object and its properties reactive recursively.
|
477 | Subobjects (but not subfunctions!) will also be observed.
|
478 | Note that <code>observe</code> does not create a new object, it mutates the object passed into it: <code>observe(object) === object</code>.
|
479 | </li>
|
480 | </ul>
|
481 | Parameters:
|
482 | <ul>
|
483 | <li><code>object</code> — Object or function to make reactive</li>
|
484 | </ul>
|
485 | Returns:
|
486 | <ul>
|
487 | <li>Input <code>object</code>, now reactive</li>
|
488 | </ul>
|
489 |
|
490 | <h4 id="ignore"><code>function ignore(object)</code></h4>
|
491 | Description:
|
492 | <ul>
|
493 | <li>
|
494 | Prevents an object from being made reactive, <code>observe</code> will do nothing.
|
495 | Note that <code>ignore</code> is not recursive, so subobjects can still be made reactive by calling <code>observe</code> on them directly.
|
496 | </li>
|
497 | </ul>
|
498 | Parameters:
|
499 | <ul>
|
500 | <li><code>object</code> — Object or function to ignore</li>
|
501 | </ul>
|
502 | Returns:
|
503 | <ul>
|
504 | <li>Input <code>object</code>, now permanently ignored</li>
|
505 | </ul>
|
506 |
|
507 | <h4 id="computed"><code>function computed(func)</code></h4>
|
508 | Description:
|
509 | <ul>
|
510 | <li>
|
511 | Calls <code>func</code> with no arguments and records a list of all the reactive properties it accesses.
|
512 | <code>func</code> will then be called again whenever any of the accessed properties are mutated.
|
513 | Note that if <code>func</code> has been <code>dispose</code>d with <code>!!clean === false</code>, no operation will be performed.
|
514 | </li>
|
515 | </ul>
|
516 | Parameters:
|
517 | <ul>
|
518 | <li><code>func</code> — Function to execute</li>
|
519 | </ul>
|
520 | Returns:
|
521 | <ul>
|
522 | <li>Input <code>func</code></li>
|
523 | </ul>
|
524 |
|
525 | <h4 id="dispose"><code>function dispose(func, clean)</code></h4>
|
526 | Description:
|
527 | <ul>
|
528 | <li>
|
529 | "Disposes" a function that was run with <code>computed</code>, deregistering it so that it will no longer be called whenever any of its accessed reactive properties update.
|
530 | The <code>clean</code> parameter controls whether calling <code>computed</code> with <code>func</code> will work or no-op.
|
531 | </li>
|
532 | </ul>
|
533 | Parameters:
|
534 | <ul>
|
535 | <li><code>func</code> — Function to dispose, omit to dispose the currently executing computed function</li>
|
536 | <li><code>clean</code> — If truthy, only deregister the function from all dependencies, but allow it to be used with <code>computed</code> again in the future</li>
|
537 | </ul>
|
538 | Returns:
|
539 | <ul>
|
540 | <li>Input <code>func</code> if <code>func</code> is valid, otherwise <code>undefined</code></li>
|
541 | </ul>
|
542 |
|
543 | ## Authors
|
544 | Made with ❤ by Lua MacDougall ([foxgirl.dev](https://foxgirl.dev/))
|
545 |
|
546 | ## License
|
547 | This project is licensed under [MIT](LICENSE).
|
548 | More info in the [LICENSE](LICENSE) file.
|
549 |
|
550 | <i>"A short, permissive software license. Basically, you can do whatever you want as long as you include the original copyright and license notice in any copy of the software/source. There are many variations of this license in use."</i> - [tl;drLegal](https://tldrlegal.com/license/mit-license)
|