I'm on TC39. Would Three.js be interested in "tree-shakable methods" syntax?

Context

Three.js is not as tree-shakable as it could be. In my tests, a simple console.log(Vector3) weighs ~10KB after minification and DCE. When Vector3 is rewritten with tree-shakable methods, that drops to ~0.2KB.

I recently presented at TC39 on the large and growing cost of dead JS on the web. I believe we could encourage substantial performance benefits for end users by evolving JS to make tree-shakable coding patterns more attractive. A significant source of non-tree-shakable code is unused class methods. Since Three.js is popular and uses classes a lot, I wanted to reach out to your community to get your feedback on our current ideas!

Ideas

I think all of these ideas:

  • can support optional chaining and autocomplete.
  • would be a runtime performance improvement even beyond tree shaking thanks to static dispatch rather than dynamic/prototype dispatch.
  • can be transpiled without performance penalty or bloat
  • can support a backwards compatibility layer for users of the existing prototype-based API.

Note:

  • Assume we have to choose only one or none.
  • The exact spelling of these operators is not set in stone.

This-based chaining (..)

Aka “extension methods” aka “call-this operator”

/** @this {Vector3} */
function set(x, y, z) { this.x = x; this.y = y; this.z = z; }

new Vector3()..set(1, 2, 3);

obj..method() invokes method from lexical scope with this === obj.

  • Main pro: Immediately useful with all existing class methods for defensive coding, performance, pre-migration to tree-shakable methods, etc:

  • Main con: Leans into the this keyword, which many consider to be a confusing concept.

For more details, see https://github.com/tc39/proposal-call-this

Topic-based chaining (|> ##)

Aka “Hack pipelines”:

/** @param {Vector3} _this */

function set(_this, x, y, z) { _this.x = x; _this.y = y; _this.z = z; }

new Vector3() |> set(##, 1, 2, 3);

|> provides the left-side expression as ## (the “topic” token) to the right-side expression.

  • Main pro: General-purpose. Compatible with all existing standalone functions and other expressions.

  • Main con: More verbose (explicit topic token).

For more details, see https://github.com/tc39/proposal-pipeline-operator.

Arg-based chaining (::)

Aka “Elixir-style pipelines”:

/** @param {Vector3} _this */

function set(_this, x, y, z) { _this.x = x; _this.y = y; _this.z = z; }

new Vector3()::set(1, 2, 3);

obj::method(...args) invokes method from local scope with obj inserted into the first argument position.

  • Main pro: Compatible with many existing standalone functions since it doesn’t depend on this.

  • Main con: The argument list no longer contains all the arguments, which might be surprising/confusing.

The Elixir-style pipeline doesn’t have an existing proposal, but a few of us are open to it if you find it substantially more attractive than any other option.

Thoughts?

My main questions:

  • Which of these, if any, would you definitely adopt, if standardized?

  • Which of these, if any, would you refuse to adopt, even if standardized?

  • Any other ideas for how we might improve the language to make JS more tree-shakable?

Looking forward to hearing from you!

2 Likes

Interesting topic. I work with Three.js a lot and the tree-shaking issue is real, especially for math classes like Vector3, Matrix4, etc. A lot of the methods are unused in many apps but still end up in bundles because they’re attached to the prototype.

Personally, the |> pipeline approach feels the most natural to me if the goal is to move toward standalone functions that are easier to tree-shake. It keeps functions explicit and doesn’t rely on this, which a lot of modern JS codebases already try to avoid. Something like:

new Vector3() |> set(##, 1, 2, 3)

fits well with a functional style where math utilities live as separate imports. Bundlers could then drop unused operations much more reliably.

The .. version works nicely from a readability standpoint because it looks close to existing method chaining, but it doubles down on this, which is exactly what many libraries are trying to move away from. It also feels more like syntactic sugar for classes rather than encouraging tree-shakable architecture.

The :: idea is interesting but might be the most confusing for people reading the code later, since the object isn’t visible in the argument list anymore.

From a Three.js perspective, adoption would probably depend on how disruptive it is to the current API style. The ecosystem is huge and a full rewrite of math classes into standalone functions would be a big migration. But if the syntax made it easy to support both styles during a transition (class methods + tree-shakable helpers), that would make it much more realistic.

In general though, anything that makes it easier to write chainable code without binding methods to prototypes would help libraries like Three.js reduce bundle size. The pipeline direction seems the most future-proof to me.

1 Like

From my Three.js experience, the biggest wish is to have custom operators, this would make a lot of code simpler to write and read by humans. But this is unrelated to tree-shaking and was continuously and brutally rejected to be implemented in JS. So I have given up hoping to see v+u where both v and u are THREE.Vector3.

As for the |> pipelining: would it be possible to pipeline a set of several values, something like this:

(x, y) |> (encode(#1,0), decode(#2,null)) |> new Parser(#1, #2)

as equivalent of: new Parser(encode(x,0), decode(y,null)). I’m just curious, I do not advocate for such functionality.

For the new operators, is it clear:

  • what is the impact on performance (compilation and runtime), error reporting and debugging
  • how fast the new features will reach major browsers (is it possible to shim old browsers?)
  • what are the possible ways to misuse the operators accidentally or on purpose

My general concern about .., |> and :: is that they would add three more monsters to the zoo of over 50 JS operators. I’m afraid that at some point JS code may become as cryptic as APL. Quite often I spend minutes trying to decipher some JS expression that uses less common operators.

I’m not sure how these operators make tree-shaking better, as JS is too dynamic, so shakers tend to be more on the conservative side.

It might be helpful to have some online tool where users can paste JS code and it outputs the same code with the new operators applied. This would make it easy for a human to check whether the new code is shorter and easier to understand.

For those, who have never seen APL, here is some sample code (APL has a beautiful set of non-ASCII7 operators):

6 Likes

Could you elaborate on this? Three would have to refactor its Vector3 file to do something like this? Exporting the set method is part of it? This would then have to be imported wherever it is to be used?

1 Like

Good questions! Regarding how these syntaxes help tree shaking and the backwards compatibility story for each…

Vector3 is currently defined like so (simplified):

export class Vector3 {
  constructor(x,y,z) { this.x=x; ... }
  set(x,y,z) { this.x=x; ... }
}

This-based syntax

To be tree shakable and compatible with a this-based chaining syntax, a new Vector3 module could be introduced that exports a much simpler class with a bunch of “extension methods” like so:

export class Vector3 {
  constructor(x,y,z) { this.x=x; ... }
}

/** @this {Vector3} */
export function set(x,y,z) { this.x=x; ... }

Users of this module would experience more reliable tree shaking because they would import the extension methods directly, and the methods are no longer attached to the prototype.

For backwards compatibility, the original Vector3 module would be changed to attach the “extension methods” back onto the prototype:

import {Vector3, set} from '...';
Object.assign(Vector3.prototype, {set});
export {Vector3};

Anyone using this backward compatibility layer would not get the benefits of better tree shaking.

Arg- and Topic-based syntaxes

To be tree shakable and compatible with arg-based or topic-based chaining, a new Vector3 module could be introduced that exports a much simpler class with a bunch of helper functions like so:

export class Vector3 {
  constructor(x,y,z) { this.x=x; ... }
}

/** @param {Vector3} _this */
export function set(_this,x,y,z) { _this.x=x; ... }

Users of this module would experience better tree shaking because they would import the helper functions directly, and methods are no longer attached to the prototype.

For backwards compatibility, the original Vector3 module would be changed to add methods onto the prototype that delegate to the helpers, passing this as needed:

import {Vector3, set} from '...';
Object.assign(Vector3.prototype, {
  set(...args) { return set(this, ...args); },
});
export {Vector3};

Anyone using this backward compatibility module would not get the benefits of better tree shaking.

Technically the backwards compatibility layer here introduces one new function allocation per method, has an extra layer of function call at runtime, and is slightly more verbose compared to the this based strategy which can just re use the same function directly. I assume these costs are trivial in the context of a Threejs program, but correct me if I’m wrong there…

1 Like

what of three.module.js where many of the higher level classes reference Vector3? also managing set imports from other classes that use the method set such as Vector2 for example, would this require syntax such as import {set as setVector3}?

2 Likes

Yeah or Vector3Set. While i get the idea in spirit i think that this would be a really hard sell to the three.js team.

3 Likes

what is the impact on performance (compilation and runtime)

Almost certainly a net performance benefit in all cases, thanks to the tree shaking and the static function dispatch. all these syntaxes are straightforward to transpile syntactically (that is, file by file rather than with whole-program analysis).

what is the impact on … error reporting and debugging

Jumping to definition and tracing the code manually may be more reliable since more methods would be imported directly with no chance of polymorphic indirection. Stack trace paths may change slightly as code is moved around to get reliable tree shaking.

how fast the new features will reach major browsers

Unclear. That would really be up to those browsers.

(is it possible to shim old browsers?)

Yes, all these syntaxes are straightforward to transpile cheaply without whole-program analysis.

what are the possible ways to misuse the operators accidentally or on purpose

Hard to say. Everyone seems to have their own intuitions and guesses where bugs could creep in, but no one has any data yet. AFAICT any of them should be easy to validate with common strategies like TS.

what of three.module.js where many of the higher level classes reference Vector3?

Presumably the tree shakable versions of those classes would only reference the tree shakable Vector3 module. The backwards compatibility shims would continue to reference the original Vector3 module just as they do today.

would this require syntax such as import {set as setVector3}?

Yes, for users who decide they want the tree shaking benefit, they would need to pick a disambiguation strategy if names conflict. Renaming individual methods is an option. Using namespace imports is another.

2 Likes

It seems like this kind of conversion could be automated (either programmatically via annotations, or llm driven)? and I suspect it would have to be automated to be practical because I doubt the core library will adopt these concepts internally until browser coverage is ~100%, and that it remains ~100% backward compatible to existing source code/all the examples… but it might be straightforward to create a fork of an automated conversion?

2 Likes

It’s hard to not see the naming issue as a pretty big step back. Function names like “set” or “copy” and a lot of others are the same across dozens of classes across the project. Going all in on this approach would mean effectively requiring that there be no member function name collisions across the full project. I’m also assuming that the function inheritance model would have to change, as well?

function copyMesh( source ) {
  this..copyObject3D( source );
  // ...
}

mesh..copyMesh( source );

It seems to more or less destroy the usability of the inheritance and prototype chain, effectively bringing you back to functional programming, anyway, beyond some basic syntax differences.

I also find it interesting that the benefits here are completely relegated to code that is bundled. Is TC39 adding language features specifically to only benefit bundlers? I’d rather see some kind of strip-able syntactic sugar for users to specify (inline as more structured comments or otherwise) that bundlers can use as hints if this is really where problems lie. Without putting too much time into it, an import could be shaped like so to declare the required class members so bundlers are aware they can’t be inferred:

import {
  Vector3 /** @with .set .copy .clone **/,
  Matrix4 /** @with none **/,
} from 'three'

Or some other way to import sub fields that ensure the sub fields are usable as-expected on the class.

Likewise conventions like “PURE” annotations work well but they’re burdensome because they live in the wrong spot. Vector3 is always PURE but I have to declare every module-level Vector3 variable as PURE instead of just having the original class constructor marked as “PURE”.

I’m wondering how much a TC39-backed bundler-specific specification to enforce more consistency across the ecosystem would help with this before actual language features need to be considered.

6 Likes

Yes, this whole post seem like the ones we had for people using tierce party libs, trying to pull the sheets on their side of the bed. It doesn’t matter how famous your library/bundler is. JS is the common denominator. Imo there is no way to start special-tailoring code to every specific type of custom environment. Where does it stop?

2 Likes

Yes, this whole post seem like the ones we had for people using tierce party libs, trying to pull the sheets on their side of the bed. It doesn’t matter how famous your library/bundler is. JS is the common denominator. Imo there is no way to start special-tailoring code to every specific type of custom environment. Where does it stop?

I’m not exactly sure whether you’re suggesting that modifying Javascript as a language is the only to address the bundler issue or not. Or what specific post you’re referring to (eg mine or the whole thread or OP).

Of course I understand that Javascript is the common denominator and that it’s unreasonable for projects to chase down and add support every bundler-specific optimization. Things have improved significantly over the last decade, though. Certain notations for URL loading imports1, for example, have more or less become defacto standards and are now supported in every major bundler as far as I know. The PURE annotations, for better or for worse, and some package.json flags seem to be the same.

The issue as I see it is that these all seem to be ad-hoc additions. One bundler adds a feature, then another may if it becomes popular enough. There’s no real standard for these bundler features or even really a place to go to see what hints or you should be following if you want to try to be ecosystem-friendly. Modifying Javascript as a language to try to address some of these things feels very heavy handed I just wonder if it’s been considered to make a “JS Bundler Spec” from an established board so that at least everyone can point to it and say “here we can all see and agree on how to handle all of these hints in a bundler”.

1 such asnew URL( './path/to/file', import.meta.url )

2 Likes

My comment see beyond Bundling. That’s my point. This is only a fraction of where/how JS is used. People also generate it server-side using countless tools and different ways. And three.js can also be used with them. My personal view is you will never see the end of it. You could optimise all year round, and restart directly after because a new fancy method/tool just showed up to replace the old one.

3 Likes

There are lots of webGL libraries focused on being tiny. threejs is focused on being complete, fast, and only as complicated as needed to achieve that.
For the vast majority of threejs use cases, your first png/skybox/texture/model(s) size is going to dwarf the size of the bundled code anyway.. and on the second visit, the lib will be cached. Changing library semantics, or worse, deoptimizing classes for the sake of binary size is not really in the cards, imo.

There are/have been various attempts at large changes like this.. the only way they stick (see typescript bindings), is if someone takes on the maintenance burden or finds a way to automate their enhancement externally.

Anything else just creates more work for everybody, and increases the cognitive load of using the library.. (with the exception perhaps of annotating functions/methods/names in a way that is helpful to external tooling…)

3 Likes

Changing library semantics, or worse, deoptimizing classes

Can you clarify? What semantics are you concerned about changing? How are classes “deoptimized”?

1 Like

While I do like the idea of adding chaining to the language (I’d gladly adopt the feature in https://gltf-transform.dev/ !) I share @gkjohnson’s concerns about the proposed application here. Particularly the requirement of unique method names, and the loss of composition by inheritance. I’m not sure a greater probability of dead code removal (which presumably will continue to depend on which bundler you’re using…) would outweigh these.

The gl-matrix project offers a good example of a purely-functional, tree-shakeable math library today. As it has already settled on a functional API, I could imagine chaining being an unambiguous improvement for that project, just for the ergonomics. That might be a good case study.

I am curious if you’ve heard from the authors of bundlers — like Vite — what they would need to better support tree-shaking of unused methods from ES6 classes? I believe Closure Compiler supported this a decade ago… but that may have depended on its JSDoc-based type checking. Maybe proposal-type-annotations would help bundlers here? The other bundler-level discussion I was able to find was a rollup issue.

4 Likes
  • Hidden Class Deoptimization: Standard class methods benefit from V8’s “Hidden Classes.” Moving to externalized methods via .. or :: risks losing Inlining and Inline Caching, which are vital for hot loops in physics and 3D rendering.

  • Monomorphism vs. Polymorphism: If these “tree-shakable” methods become too generic (e.g., a single set() function for multiple object types), we hit the Polymorphism penalty, which can drop execution speed by 5x–10x on low-end hardware.

  • GC Pressure: Tree-shaking often encourages a “pure/functional” style (returning new objects). For 3D engines, this is a non-starter due to memory churn and Garbage Collection pauses. Mutation-based class methods are a deliberate performance optimization.

  • Low-End Hardware: On budget mobile devices, the overhead of polyfilling this syntax—and the loss of JIT optimizations—outweighs the 10KB download saving.

In short: Download speed is a one-time cost; execution speed is a per-frame cost. For 3D, we usually prioritize the latter.

(I wrote some of this in a wall-of-text and used Gemini to make it more succinct.
I don’t totally agree with the GC concerns, since those can be and already are mitigated using the library efficiently.)

1 Like

I think that JS is a horrible language and the committee in charge are less than competent.

Let me elaborate. The proposal on the table is basically “hey, functional languages are cool, let’s make JS into a functional language “, except… it already is, or “was” if you want to be pedantic.

Then, we got classes that were still kind of in vogue, and JS became a bastard child of two paradigms. Other languages were doing it too, so I guess that is what the committee told themselves to make them sleep at night.

You can probably tell I feel pretty strongly about it. Most of meep (my game engine) and Shade ( my graphics engine) are written in a C-like syntax. Where classes have barely any methods on them if any, and most of the “behavior” (or “code” for old timers) is written as standalone functions, how most of the classical C is written. It’s a bit weird, but it works and you get best of both worlds, you get convenience of structs and functional modularity. When I tell people they can import infinitely small parts of meep and only have to download exactly what they use - they don’t get it usually and it takes some explaining.

Now back to this proposal, you can probably tell, I’m very much against it. You take a serviceable functional programming language, you slap classes on top, and now you say “me no like classes, they are yucky!” . Well that’s your prerogative I suppose.

Personally I will keep writing functional style of the olden days and be moderately content while grumbling at the attempts to add more bloat to the language as you guys chase your own tails and then pat yourselves on the back for a job well done.

And if you think I’m pulling your leg on the whole “JS is not a class-based language”, just take a trip down the memory lane and dig into how the classes we have actually work under the hood. It’s functions and plain objects all the way down, baby.

Sorry for being a bit abrasive, had a lot of discussions on the topic of JS evolution. If there’s one good thing, that is out of 100 bad proposals I see over the years, usually barely a couple makes it into the language, so I still have hope.

—-

I’m being harsh and I’m coming off as a dinosaur. When Chrome came out, I was already writing JS, and it was a bad language. It lacked a lot of features that we take for granted today. Heck, you couldn’t even make an http request back then, forget promises, binary types, modules, classes and all the rest.

Around maybe 2013, a concept of “strict” mode was introduced. Most devs agreed that half the language was garbage and should not be used in polite company. I was in full agreement as well.

JS started to see some attention and love from W3C, we got some features that were a must, such as the ability to make network requests, modules and binary types, to name a few. But we got some garbage too. Trend chasing that made the language a bit nicer, but only a bit, and at the cost of performance and clarity. Now you can write a function 2 ways with a minor difference between the two. You can write var or let etc. I use those new features, and I enjoy them sometimes. But I have seen languages ground to mud like this, every new addition creates legacy and muddies the waters.

As someone who has worked on compilers a fair bit, I can see the complexity these features add on the compiler side too, making the language inherently slower to compile, slower to execute, more costly to maintain.

I’m not against change, I’m against thoughtless change that segments the user base. Features that are added just to restore what we already have is just painful to see.

1 Like

JS has to be horrible. Because it is tied to “how much power/flexibility should be allowed to browsers users”. It’s a political game among big actors of the web. In this climate you can’t guarantee any kind of natural ways to develop a language. It’s gonna be either nerfed or pushed towards goals the end-users never had any say in the first place. If companies like Apple could kill it like flash, and replace it by their “on-brand” baby…they would do it. They’re not interested by standards, they want influence, larger slices of the cake.

(sorry we went far off-topic here)

2 Likes

:waving_hand: Fellow TC39 Delegate here. Thank you everyone for responding, it’s interesting to get outside perspective!

Apologies for coming in late. I’m going to respond to a bunch of the topics that I see, I’m sorry if this gets too long.

It’s very likely this could be automated with an IDE, even without AI help.

The core library could do the conversion very early on (as I’ll explain just a bit later). Once the conversion is done, current users would not need to do anything. As Evan mentioned, the existing API for consumers could be completely maintained, so no breaking changes at all.

Instead, this puts power in the hands of your consumers. They are the ones that can adopt the new tree-shakeable API. They are the ones that control what syntax they want to use, whether one of our proposed pipelines (which are easily transpilable) or regular old set(vec, …) call syntax. And if they don’t want to adopt the new API, they’re free to continue using the classic class-based APIs without any changes.

I believe this will be easily automatable with VSCode or any IDE. I have a Vector3, I know I want to invoke a set function with it, IDE automatically adds import {set as setVector3} from … and inserts the disambiguated name locally. And note that you can also import * as vector3 and then do vec |> vector3.set(##).

I may be misunderstanding you here, but I think you’re wrong here. The copyMesh and copyObject3D you mention here would be lexically scoped, so it wouldn’t matter names you use for methods on your prototype. And this does not prevent you from using regular this.copyObject() with dynamic dispatch via prototype. This can be incrementally migrated, it’s not all or nothing.

The reason we’re proposing this is because it’s not really dependent on a specific bundler. Any bundler will be able to trivially implement the dead code elimination here. In fact, if you’re using any bundler that supports tree-shaking, or you use any minifier in your build process, then you already have everything necessary to take advantage of what we’re proposing.

They would have to integrate a sound type system directly into their codebase. And, developers would need to strongly type their code, and stop using the more dynamic features of JS. All of these are very difficult to get movement on. Evan’s recent TC39 talk gives a good overview of what’s necessary (a notes transcript should be released next week, but there’s no video).

With the pipeline proposals, there is no risk of Hidden Class Deopts because it doesn’t rely on the hidden class being optimized in the first place for efficient function access. The data properties that are stored on Vector3 and others will still have their Hidden Classes optimized without issue.

Additionally, this becomes guaranteed Monomorphic leading to additional gains. You pass a Vec3 to setVec3(…), and it’s always optimized for exactly Vec3’s hidden class. This removes the possibility of it being polymorphic, because your not confusing the engine with obj.set() where obj could be one of many classes.

As for Low End Hardware being affected by syntax, there is no runtime polyfill. It’s a pure syntax transformation from vec |> set(##, 1, 2, 3) to set(vec, 1, 2, 3) done during build time.

The reason I’m excited for pipelines is because I know it can lead to improvements to both code size and runtime execution. IMHO, this code is as optimizable as can be written in any style. It’s essentially leaning into static functions and regular data objects, like classic C code.

2 Likes