How to deal with side effects in tree shaking code?
Asked Answered
C

2

14

I've been trying to learn how to write code that is tree shaking friendly, but have run into a problem with unavoidable side effects that I'm not sure how to deal with.

In one of my modules, I access the global Audio constructor and use it to determine which audio files the browser can play (similar to how Modernizr does it). Whenever I try to tree shake my code, the Audio element and all references to it do not get eliminated, even if I don't import the module in my file.

let audio = new Audio(); // or document.createElement('audio')
let canPlay = {
  ogg: audio.canPlayType('audio/ogg; codecs="vorbis"').replace(/^no$/, '');
  mp3: audio.canPlayType('audio/mpeg; codecs="mp3"').replace(/^no$/, '');
  // ...
};

I understand that code that contains side effects cannot be eliminated, but what I can't find is how to deal with unavoidable side effects. I can't just not access a global object to create an audio element needed to detect feature support. So how do I handle accessing global browser functions/objects (which I do a lot in this library) in a way that is tree shaking friendly and still allows me to eliminate the code?

Contravallation answered 26/2, 2019 at 5:45 Comment(5)
Does it get eliminated if you instead export a let audio = () => new Audio() thunk?Birl
Sorry, I'm not sure I follow. Would the consumer have to call the audio function and set the canPlay themself?Contravallation
Yes, the consumer would call audio themselves to obtain the Audio value, and then they would plug it into canPlay, which would have to be parametrized to accept an Audio value.Birl
Can you provide an example of how you are exporting your module functions? I think wrapping what you've provided thus far in a single function should allow tree-shaking, but this depends on how you're exporting.Sinister
Since I'm still learning, I've been exporting a single default object/class for most of the code, following lodash es as an example template. In this particular case, my library isn't just a library of single functions though, but handles things like keyboard events, mouse events, and asset loading.Contravallation
B
6

You could take a page out of Haskell/PureScript's book, and simply restrict yourself from having any side effects occur when you import a module. Instead, you export a thunk that represents the side effect of e.g. getting access to the global Audio element in the user's browser, and parametrize the other functions/values wrt the value that this thunk produces.

Here's what it would look like for your code snippet:

// :: type IO a = () -!-> a

// :: IO Audio
let getAudio = () => new Audio();

// :: Audio -> { [MimeType]: Boolean }
let canPlay = audio => {
  ogg: audio.canPlayType('audio/ogg; codecs="vorbis"').replace(/^no$/, '');
  mp3: audio.canPlayType('audio/mpeg; codecs="mp3"').replace(/^no$/, '');
  // ...
};

Then in your main module you can use the appropriate thunks to instantiate the globals you actually need, and plug them into the parametrized functions/values that consume them.

It's fairly obvious how to plug all these new parameters manually, but it can get tedious. There's several techniques to mitigate this; an approach you can again steal from Haskell/PureScript is to use the reader monad, which facilitates a kind of dependency injection for programs consisting of simple functions.

A much more detailed explanation of the reader monad and how to use it to thread some context throughout your program is beyond the scope of this answer, but here are some links where you can read about these things:

(disclaimer: I haven't thoroughly read or vetted all of these links, I just googled keywords and copied some links where the introduction looked promising)

Birl answered 28/2, 2019 at 6:31 Comment(8)
I think I'm going to need a more thorough example. I don't understand Haskell and so almost every term in those articles and video is lost on me. I've been basing my es module architecture on lodash es as it shows how to export a main library and individual modules. If I export the audio and canUse functions and then try to initialize them so they are available when a user calls import myLib from 'myLib', then they won't be removed via tree shaking when someone does import { audioModule } from 'myLib'Contravallation
I'd really hate to force the user to call a whole slew of exported functions to initialize the different parts of the library, but is that what you're saying is the only way to avoid side effects? To push them to the consumer?Contravallation
I'm not sure I completely follow that first comment. Could you provide an example? If you export getAudio and canPlay from your module, and the user does import { getAudio, canPlay } from "yourmodule"; const audio = getAudio(); const { ogg } = canPlay(audio), everything else in your module can still be removed via tree shaking (including other functions that also accept an audio parameter).Birl
The other thing I don't understand is what "initializing different parts of the library" means. The library has no internal state, it's just a bag of functions and values that you're exporting. The user needs to import them and use them in their code.Birl
But that assumes the user needs the ogg property, when in reality it's an internal piece of data that the user doesn't need to know. In this particular example, it's used to determine which audio files from a list to load based on browser support. No input from the user needed. But it's only needed if the user imports the module.Contravallation
From what you seem to be saying, if jQuery were to be split into different modules, I would need to pass document and window to tons of functions just to use it. Something like $('div', document)? Or maybe I'd have to initialize jQuery and pass it window and document? That's what I'm confused on from your answer.Contravallation
As far as I'm aware document and window can be accessed without affecting tree shaking, the side effect that we're trying to work around is new Audio(). As far as Audio (and similar values that are side-effectfully produced) are concerned, yes, you would have to pass it around into tons of functions. Making this passing around convenient is what the reader monad is for.Birl
If you have boundaries where you have a large number of functions that you don't want independent tree-shaking for (i.e. usually they either all need to be present or all absent), you can share a single parameter for them, i.e.: audio => { const canPlay = ...; const setVolume = ...; ...; return { canPlay, setVolume, ... } }. This does mean that if you use canPlay you're going to get everything else as well, but if you use none of those members, both the thunk for producing the audio instance and the module of functions that depend on it will be eliminated by tree shaking.Birl
D
3

You can implement a module to give you a similar usage pattern that your question suggests, using audio() to access the audio object, and canPlay, without a function call. This can be done by running the Audio constructor in a function, as Asad suggested, and then calling that function every time you wish to access it. For the canPlay, we can use a Proxy, allowing the array indexing to be implemented under the hood as a function.

Let's assume we create a file audio.js:

let audio = () => new Audio();
let canPlay = new Proxy({}, {
    get: (target, name) => {
        switch(name) {
            case 'ogg':
                return audio().canPlayType('audio/ogg; codecs="vorbis"').replace(/^no$/, '');
            case 'mp3':
                return audio().canPlayType('audio/mpeg; codecs="mp3"').replace(/^no$/, '');
        }
    }
});

export {audio, canPlay}

These are the results of running on various index.js files, rollup index.js -f iife:

import {} from './audio';
(function () {
    'use strict';



}());
import {audio} from './audio';

console.log(audio());
(function () {
    'use strict';

    let audio = () => new Audio();

    console.log(audio());

}());
import {canPlay} from './audio';

console.log(canPlay['ogg']);
(function () {
    'use strict';

    let audio = () => new Audio();
    let canPlay = new Proxy({}, {
        get: (target, name) => {
            switch(name) {
                case 'ogg':
                    return audio().canPlayType('audio/ogg; codecs="vorbis"').replace(/^no$/, '');
                case 'mp3':
                    return audio().canPlayType('audio/mpeg; codecs="mp3"').replace(/^no$/, '');
            }
        }
    });

    console.log(canPlay['ogg']);

}());

Additionally, there is no way to implement audio as originally intended if you wish to preserve the properties outlined in the question. Other short possibilities to audio() are +audio or audio`` (as shown here: Invoking a function without parentheses), which can be considered to be more confusing.

Finally, other global variables that don't involve an array index or function call will have to be implemented in similar ways to let audio = () => new Audio();.

Dependence answered 6/3, 2019 at 14:15 Comment(2)
Nice, I didn't know about Proxies before. However, when I do the rollup on the file using import { audio } from './audio.js'; console.log(audio()); I get the canPlay variable still. Is there something special you're doing in a rollup config or something?Contravallation
I'm doing everything exactly as stated above, with rollup v1.4.1, no other configurations.Dependence

© 2022 - 2024 — McMap. All rights reserved.