Skip to content

Tree-shaking: importing a single symbol costs 184 kB gzip #1616

Description

@obiot

import { Vector2d } from "melonjs" pulls 184 kB gzip. The class itself is
about 1 kB.

Measured at 20.2.0 against a real installed package — node_modules/melonjs
containing the published build/ and the real package.json (so sideEffects
is honoured), bundled with esbuild --bundle --minify --format=esm:

imported gzip
{ Vector2d } 183.9 kB
{ Application } 230.5 kB
* as me (everything) 246.1 kB

So tree-shaking recovers about 25% at best. For a real game it recovers almost
nothing: the platformer example's actual 31 imports come to 234 kB against
246 kB for the whole engine, a saving of ~5%.

It is structural, not intrinsic

Bundling the same import from the source module graph instead of the published
artifact:

entry gzip
{ Vector2d } from src/math/vector2d.ts 1.1 kB
{ Vector2d } from src/index.ts (barrel) 133 kB
{ Vector2d } from the published build/index.js 184 kB

Same class, three orders of magnitude apart depending on the path in. An esbuild
metafile for the barrel case shows 258 of 258 modules retained — howler
(35 kB), the glTF parser, the Canvas renderer, even loader/melonjs_logo.png.
Nothing is dropped.

Two contributing factors, as far as I got:

  1. The package ships as a single pre-bundled file. files is
    ["build", "package.json", "README.md", "CHANGELOG"], and everything under
    build/ other than index.js is .d.ts. So a consumer's bundler is handed
    one 2.1 MB module and has to shake within it, rather than across a module
    graph where whole files can be dropped.
  2. The barrel re-exports everything. Touching any one export brings
    index.ts in, and from there the entire graph.

Worth noting what is not the cause, to save anyone re-checking: sideEffects
is declared in the permissive array form, there are no import cycles back
through the barrel (0 modules import it), a bare import "melonjs" with nothing
used shakes to 0 kB, and the polyfill side-effect import is only 8 kB.

Separately: the sideEffects globs point at paths that are not published

"files":       ["build", "package.json", "README.md", "CHANGELOG"],
"sideEffects": ["./src/polyfill/**"]

src/ is not in the tarball (confirmed with npm pack --dry-run), so the glob
matches nothing for consumers. The array form means everything else is treated
as side-effect-free, so this is permissive rather than harmful — but it does not
express what it looks like it expresses, and if the intent was to protect the
polyfill's side effects then that protection is not there.

Possible directions

Not prescriptive, and each has a cost worth weighing:

  • Emit a preserved module graph (per-module output) in addition to or instead of
    the single bundle, so bundlers can drop whole files.
  • Add subpath exports (melonjs/math, melonjs/physics, …) so consumers who
    want a slice can ask for it directly. src/math/vector2d.ts is already 1.1 kB
    standalone, so the granularity exists.
  • Point the sideEffects globs at what actually ships.

Why it matters now

The README used to claim the engine was tree-shakeable "so you only pay for what
you use". That claim was removed in #1614 because these numbers do not support
it. If this is fixed, it can go back.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions