gristlabs_grist-core/documentation/grainjs.md
George Gevoian e208f827af (core) Add documentation to grist-core
Summary: Adds some documentation about Grist's components and infrastructure.

Test Plan: N/A

Reviewers: paulfitz

Reviewed By: paulfitz

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D3941
2023-07-16 22:27:12 -04:00

7.0 KiB
Raw Blame History

GrainJS & Grist Front-End Libraries

In the beginning of working on Grist, we chose to build DOM using pure Javascript, and used Knockout.js to tie DOM elements and properties to variables, called “observables”. This allowed us to describe the DOM structure in one place, using JS, and to keep the dynamic aspects of it separated into observables. These observables served as the model of the UI; other code could update these observables to cause UI to update, without knowing the details of the DOM construction.

Over time, we used the lessons we learned to make a new library implementing these same ideas, which we called GrainJS. It is open-source, written in TypeScript, and available at https://github.com/gristlabs/grainjs.

GrainJS documentation

GrainJS documentation is available at https://github.com/gristlabs/grainjs#documentation. Its the best place to start, since most Grist code is now based on GrainJS, and new code should be written using it too.

Older Grist Code

Before GrainJS, Grist code was based on a combination of Knockout and custom dom-building building functions.

Knockout Observables

You can find full documentation of knockout at https://knockoutjs.com/documentation/introduction.html, but you shouldnt need it. If youve read GrainJS documentation, here are the main differences.

Creating and using observables:

import * as ko from 'knockout';

const kObs = ko.observable(17);
kObs();      // Returns 17
kObs(8);
kObs();      // Returns 8
kObs.peek(); // Returns 8
import {Computed, Observable} from 'grainjs';

const gObs = Observable.create(null, 17)
gObs.get();     // Returns 17
gObs.set(8);

gObs.get();     // Returns 8

Creating and using computed observables

ko.computed(() => kObs() * 10);
Computed.create(null, use => use(gObs) * 10);

Note that in Knockout, the dependency on kObs() is created implicitly — because kObs() was called in the context of the computed's callback. In case of GrainJS, the dependency is created because the gObs observable was examined using the callback's use() function.

In Knockout, the .peek() method allows looking at an observables value quickly without any potential dependency-creation. So technically, kObs.peek() is whats equivalent to gObs.get().

Building DOM

Older Grist code builds DOM using the dom() function defined in app/client/lib/dom.js. It is entirely analogous to dom() in GrainJS.

The method dom.on('click', (ev) => { ... }) allows attaching an event listener during DOM construction. It is similar to the same-named method in GrainJS (dom.on), but is implemented actually using JQuery.

Methods dom.onDispose, and dom.autoDispose are analogous to GrainJS, but rely on Knockouts cleanup.

For DOM bindings, which allow tying DOM properties to observable values, there is a app/client/lib/koDom.js module. For example:

import * as dom from 'app/client/lib/dom';
import * as kd from 'app/client/lib/koDom';

dom(
  'div',
  kd.toggleClass('active', isActiveObs),
  kd.text(() => vm.nameObs().toUpperCase()),
)

Note that koDom methods work only with Knockout observables. Most dom-methods are very similar to GrainJS, but there are a few differences.

In place of GrainJSs dom.cls, older code uses kd.toggleClass to toggle a constant class name, and kd.cssClass to set a class named by an observable value.

What GrainJS calls dom.domComputed, is called kd.scope in older code; and dom.forEach is called kd.foreach (all lowercase).

Observable arrays, primarily needed for kd.foreach, are implemented in app/client/lib/koArray.js. There is an assortment of tools around them, not particularly well organized.

Old Disposables

We had to dispose resources before GrainJS, and the tools to simplify that live in app/client/lib/dispose.js. In particular, it provides a Disposable class, with a similar this.autoDispose() method to that of GrainJS.

What GrainJS calls this.onDispose(), is called this.autoDisposeCallback() in older code.

The older Disposable class also provides a static create() method, but that one does NOT take an owner callback as the first argument, as it pre-dates that idea. This makes it quite annoying to use side-by-side classes that extend older or newer Disposable.

Saving Observables

The module app/client/models/modelUtil.js provides some very Grist-specific tools that doesnt exist in GrainJS at all. In particular, it allows extending observables (regular or computed) with something it calls a “save interface”: addSaveInterface(observable, saveFunc) adds to an observable methods:

  • .saveOnly(value) — calls saveFunc(value).
  • .save() — calls saveFunc(obs.peek()).
  • .setAndSave(value) — calls obs(value); saveFunc(value).

These are used in practice for observables created that represent pieces of data in a Grist document, such as metadata values or cells in user tables, and in these cases saveFunc is arranged to send a UserAction to Grist to update the stored value in the document.

This should help you understand what you see, and you may use it in new code if it uses existing old-style “saveable” observables. But in new code, there is no reason to package up this functionality with an observable. For example, if some UI component allows changing a value, have it accept a callback to call with the new value. Depending on what you need, this callback could set an observable, or it could send an action to the server.

DocModel

The metadata of a Grist document, which drives the UI of the Grist application, is organized into a DocModel, which contains tables, each table with rows, and each row with a set of observables for each field:

  • DocModel — in app/client/models/DocModel
  • MetaTableModel — in app/client/models/MetaTableModel (for metadata tables, which Grist frontend understands and uses)
    • MetaRowModel — in app/client/models/MetaRowModel. These have particular typed fields, and are enhanced with helpful computeds, according to the table to which they belong to, using classes in app/client/models/entities.
  • DataTableModel — in app/client/models/DataTableModel (for user-data tables, which Grist can only treat generically)
    • DataRowModel — in app/client/models/DataRowModel.
  • BaseRowModel — base class for MetaRowModel and DataRowModel.

A RowModel contains an observable for each field. While there is old-style code that uses these observables, they all remain knockout observables.

Note that new code can use these knockout observables fairly seemlessly. For instance, a knockout observable can be used with GrainJS dom-methods, or as a dependency of a GrainJS computed.

Eventually, it would be nice to convert old-style code to use the newer libraries (and convert to TypeScript in the process), and to drop the need for old-style code entirely.