adobe / twist

State-management library with data binding and observables
Apache License 2.0
23 stars 10 forks source link

Document best practices for Twist library structure #15

Open lanther opened 6 years ago

lanther commented 6 years ago

The idea would be to have a standard approach for writing Twist libraries, so that they play well together. This should be build-system agnostic, e.g. not depend on webpack.

Basic structure

The basic structure for a library, is that it should provide a top-level index.js with named exports, e.g.:

export { default as ComponentA } from './src/components/ComponentA';
export { default as ComponentB } from './src/components/ComponentB';
... 

This has the following advantages:

  1. It means there's a single entry point for your library (people write import { ComponentA, ComponentB } from 'mylibrary'), so it's easy to discover what it exports.
  2. It's clear what the public API is (anything that's not exported is private).
  3. It abstracts away the actual location of the code, so you can restructure code without breaking anything.
  4. It works with tree-shaking, so static linking will only include what you use.
  5. It means you can build a single JS file (e.g. is publishing to a CDN), since

Twist libraries should also include a .twistrc configuration file, telling it what twist libraries it depends on (and if it defines any auto-importable decorators/components).

Dynamic Linking

The above structure will work fine for static linking with most build systems (webpack, rollup, browserify etc). But what about dynamic linking?

There are two main reasons you might want to do dynamic linking:

  1. Lazy loading - if your application is big, you might not want to load a module until it's needed, to save on the initial page load.
  2. Caching - if your application depends on a framework that doesn't change often (e.g. React and React-Twist), you might want to split this into a separate JS file, to take advantage of browser caching. The browser will only need to redownload any JS files that it doesn't already have cached.

Side note: It's good practice to include the version in the name of the library, when you store it on a CDN - e.g. react-twist-0.1.0. That way you can set the cache-control header so it never expires; since if you need an update, you'll request a different URL (since the version changed).

Note that most build systems have ways of doing lazy loading (e.g. see https://webpack.js.org/guides/lazy-loading) - but it's interesting to look at a build-system-agnostic approach, since we don't want to assume anything about somebody's setup.

So how can we do dynamic linking, without relying on a specific build system? We can do it in two pieces - there needs to be a wrapper over the library to load it at runtime (we'll call this the loader), and there needs to be a way for the dependent library to link to it (we'll call this the linker).

Linker

Let's start with the linker, since this is straightforward - rather than pulling in any code, it just looks at a global registry for the library.

// Check that the library is loaded, with a compatible version (throw an error if not).
let lib = window.DYNAMIC_MODULES.mylibrary;
checkCompatibility(lib.version, expectedVersionRange);
export var ComponentA = lib.exported.ComponentA;
export var ComponentB = lib.exported.ComponentB;

You would now need to statically link your code against this, instead of the library's index.js. There are a few options here:

  1. Put this in the index.js inside an if/else block with the actual imports - this would depend on using the webpack define plugin (or the equivalent on other build systems), along with UglifyJS to strip out dead code (if (false) { ... }).
  2. Put this in a different file alongside index.js, e.g. linker.js - this would depend on using the webpack provide plugin (or the equivalent on other build systems) to swap out references (i.e. changing import { ComponentA } from 'mylibrary' to import { ComponentA } from 'mylibrary/linker').

(2) is probably preferable, since it doesn't require any additional work in the (more common) static linking case - you only need to do some additional work if you're dynamically linking.

Note: A third option is to always import from mylibary/linker, but this needs you to know that you're always going to be dynamically linking against mylibrary, so it's not ideal if you want to support both static and dynamic linking.

At this point, we have a way of linking to some pre-loaded library, but this will crash at runtime, since the library isn't loaded yet (window.DYNAMIC_MODULES.mylibrary is undefined)- so let's turn to that piece.

Loader

We need a way of building a module such that it installs itself into window.DYNAMIC_MODULES, and makes sure that its dependencies are loaded. One approach is to build something like the following (loader.js), as a single JS file that you can store on a CDN:

import pkg from './package.json';
import * as lib from './index';

window.DYNAMIC_MODULES = window.DYNAMIC_MODULES || {};
window.DYNAMIC_MODULES[pkg.name] = {
    version: [pkg.version],
    exported: lib
};

export default function() {
    // Check to see if the dependencies are already loaded - if not, download and load them.
    // Note: storing this data in the package.json is just one approach; could be somewhere else.
    return Promise.all(pkg.dynamicDependencies.map(dep => loadModule(dep.name, dep.versionRange)));
}

The only tricky part here is loadModule(name, versionRange) : Promise - this needs to:

  1. Check if the module is already installed, and is a compatible version (if so, nothing to do).
  2. If a dependency is missing, it should download its JS file from a CDN, and then run the function that it exports. To do this, it needs to know where to fetch dependencies from (e.g. this could be handled by having a known registry that you query).

Note: The loadModule logic should be in a shared module, and this include some functions that encapsule reading/writing from window.DYNAMIC_MODULES

Lazy Loading

The approach so far handles dynamic linking, but it doesn't deal with lazy loading - when you download the main JS file and run the wrapped function(), it'll proceed to download all its dependencies, and after that, all the code the application needs is loaded.

To handle lazy loading, a module would need to know that it's asking for something lazily - for example, calling something like:

onClick() {
    this.loading = true; // Show a spinner
    loadModule('fancyview').then(() => {
        this.loading = false;
        this.view = getModule('fancyview');
    });
}

Where getModule looks up the module from window.DYNAMIC_MODULES. We're assuming it's a component (e.g. React or React-Twist) that you can then inject somewhere.