Idea: update all modules to not import the entire three.js library

For example, the very first import in OrbitControls.js is this:

import {
	EventDispatcher,
	MOUSE,
	Quaternion,
	Spherical,
	TOUCH,
	Vector2,
	Vector3
} from 'three';

This means that if someone uses ES modules (f.e. in a browser) to import OrbitControls, they will inadvertently import the entire Three.js library, even if they need only a small fraction of it.

This is bad for application load performance.

Instead, the best practice would be this:

import {Vector2} from 'three/src/math/Vector2.js'
// ...etc...

to import only the needed classes.

This will keep applications as small as possible using native ES Modules, as well as other build systems that do not perform tree shaking, let alone dead-code-elimination (which is difficult without a type system anyway), by default.

just how often do you have an application that needs OrbitControls but not three.js :face_with_raised_eyebrow:

Keep in mind that three.js is compiled for releases — a three import resolves to build/three.module.js and not src/Three.js, and the compiled version has (among other things) constants compiled out to reduce size.

Honestly there are a lot of ways to package a library, and none of them will make everyone happy. I think this is a good compromise and consistent with what modern bundlers support. Given that maintaining more variations is not free, it is a fair expectation that users who require tree-shaking must either (a) use a modern build tool, or (b) be willing to compile three.js themselves.

2 Likes

that is not a valid import. like don said, you are bypassing module resolve. this can double three imports because the namespaces conflict, the bundle will be twice as big under some conditions, among many other bugs. threejs did that previously and it was a major problem.

i think it’s not good in general not bend libraries around an incomplete module spec. every single library published on npm respects module resolution, you hack three, but the next thing you import throws you back. the core issue is the spec.

How often do you have an app that needs OrbitControls as well as every single class that exists inside of Three.js?

Some people will import from three all the time and get the entire jungle.

In my apps, I only import from three/src/whatever, and I don’t get the entire jungle, unless I need a class like OrbitControls.

The solution right now is to fork or copy it and modify it, or use something like patch-package to commit a node_modules patch to the project locally.

Importing the jungle when it is all not needed is not the best practice.

The Three.js team can in fact recommend (and design, see below) a better practice, if it chooses to.

@drcmda That import is completely valid, and I use it in production. It works great at not importing absolutely every class that Three.js exports.

The problem is, people aren’t being told to follow the best practice. For example, right here:

https://threejs.org/docs/index.html#manual/en/introduction/Installation

No I’m not, see line 15 in package.json here:

The src/ folder is explicitly exposed.

Yeah, I’m aware that importing from three and three/src can lead to duplicate code. That is why I have to fork certain modules (or not use them) to avoid the problem.

Having everyone import from a massive three.module.js file is honestly not good practice.

2 Likes

Maybe not: the spec only says how ES Modules work, not how people should organize code.

For my projects I’ve decided to publish only classes exported from their specific files, without index files.

Usage will look like this:

import {Something} from 'some-package/path/to/Something'
import {Other} from 'some-package/path/to/Other'

There will be no chance for anyone to import everything unnecessarily, because I will not give them that option. They can build that themselves if they really want to.

It’s a design choice I’ve decided to follow from now on.

(The IDEs nowadays auto-complete these too)

three.module.js would equally be wrong, “three” and “three.module” and “three/src” are separate namespaces. “three” + import maps, if you’re using browser esm, would be valid.

No I’m not, see line 15 in package.json here:

I’m aware that importing from three and three/src can lead to duplicate code.

this happens bc you are bypassing resolution. it’s not just “duplicate code”, which is also true, but three breaks as it mutates its own namespace (ShaderLib etc), which leads to malfunction. would be pretty weird to advocate this imo.

Can you elaborate more on this? I also thought the above posted was a valid way to import things.

For my projects I’ve decided to publish only classes exported from their specific files, without index files. … It’s a design choice I’ve decided to follow from now on…

I wouldn’t advise this. Then every bit of the structure of your codebase is also part of its public API, and any changes to file paths are breaking changes. You could design a library with multiple carefully-chosen exports like foo/core and foo/ui — or use a monorepo with multiple npm packages like @foo/core and @foo/ui — but don’t export every file. This is reinventing the wheel where best practices already exist. If you’re publishing a library (as opposed to a standalone web application) then I would look at the configuration used in microbundle, for example – it’s made for this use case and tested and designed for the most common build systems.

1 Like

I don’t get it… If you use a modern bundler, doesn’t module resolving and tree shaking happen recursively nowadays? F.e. If a module inside another module exports something you don’t use, doesn’t it get tree shaken out of the final build?

1 Like

Yes, tree-shaking within a bundled file works in any modern bundler. Shipping individual source files is not necessary.

3 Likes

Thanks for confirming :smile:

I never imported modules from the examples directly, nor do I think you should. In my opinion they are examples by design and are meant for you to copy over or include in your project before its being bundled.

As long as you import from the esm directory, tree-shaking should be fine. I don’t even think its considered good practice to expose the entirety of node_modules/ to the browser anyways. That’s a lot of un-curated javascript files which some might even use as an attack vector for XSS (f.e. a peer dependency that could contain malicious code).

1 Like

The three package currently gives people more than one option, without module resolution needing to be bypassed:

  • import three.module.js (by using import ... from "three" if we want to be technical, but this makes no difference for this topic)
  • or from src/ (by using import ... from "three/src/...)

Both are valid, as per the package.json exports field in the three package, to be technical:

I’m aware of how module resolution works, and that’s really an aside to the topic. The fact is, both are importable, and that’s the main topic.

Importing from three/src/ will not break anything, unless something else (in the app, or in three itself) is not also importing from src (preferably with relative paths). examples/ is an example that violates this principle.

And what I’m saying is, don’t do that.

Based on that logic, if the three package explicitly allows importing from three/src/, yet doing that will break ShaderLib as you described, then the three package is doing something broken; and that is a bug in three.

A link to such example would be helpful @drcmda.

This I know. Everything has trade-offs. I can place things that aren’t meant to be in a stable location in a _internal/ folder or similar (there is in fact a way to enforce that external consumers do not import from there at runtime, too, unless they fork and modify the code).

If I want to move something that is intended for public use, I can bump the version to mark the breaking change. Eventually it will stabilize.

It is no different than a path within an object changing as would be the case with old-style namespaces or any nested objects of any API; it just happens to be in the file system instead.

But then when I want to change those, that’s a breaking change too. It doesn’t matter if it is in the file system or not really.

I’ve decided to do that too, but each package may still have separate related APIs, and splitting absolutely everything into separate packages would be too much work; f.e. one function per package can be overkill.

Do you have a link where it describes that publishing both an ESM bundle and plain src/ (like three) is a best practice?

We’d hope so, but it doesn’t always work if some things can’t be statically analyzed well enough.

Also, it’s been proven, and it makes sense, that splitting things into separate files and importing only what is needed, gives us the smallest app when using native ES modules without a bundler.

Running import * as THREE from 'three' does not do any sort of tree shaking in Node, for example.

Literally the only best way to do this, today, in every environment, is by importing only things we need directly, and not by importing index files that include everything. All other cases are subsets, and plus if we all followed this approach all the time (good luck with us getting all people to do so), then bundlers would optimize further, but at the very least there would be a baseline default amount of optimization that requires no tools.

In some people’s opinions, copy/pasting code is not so ideal when they want to have upstream updates that those classes do receive. Things in the examples/ folder (which, keep in mind, are also published on NPM, besides three.module.js and src/), often get improved over time, and having to copy/paste all the time is not ideal. People can choose their trade-offs, but if copy/pasting is the intent for examples/ (by the Three.js team) then maybe they shouldn’t be published to npm in the first place. I don’t think it will be easy to convince Three.js core to unpublish those from npm. And, the docs should then also explicitly mention that examples should be copy/pasted as a best practice. etc.

Only if I have a tool that supports it in my current project. It’s not that easy to just switch tools in every project. Also not every project is a web (like actually served over the internet) app.

module resolution means that your package.json has a name, for threejs that is “three”

{
  "name": "three",

it then lists a couple of possible exports which have to do with the target system

  "main": "./build/three.js",
  "module": "./build/three.module.js",

when you import * as THREE from 'three', your environment will “resolve” it to the export that suits your system. a 3rd party library should also import from three because it will be resolved towards the same export, they will share the same package.

if you import from /build/three or src/whatever and your 3rd party uses three or the other way around you end up importing two separate threejs because you skip module resolution. it’s fairly simple, never rely on, or import from internals, always import from the package name, that is why package.json exists in the first place.

1 Like

i think there is a general misconception, this has still nothing to do with “tree shaking”, you’re just hoping to import a little less. although if three would recommend your pattern you would accidentally import double and tripple, worse if libraries started to do it. shaking off parts that are unused is a process in bundling environments slightly similar to dead code elimination or minification. if you want to use pure, optimized esm in production you absolutely need a tool, at least esbuild (vite, snowpack, etc). “native ES modules without a bundler” does not exist, unless you want to put all the impact onto the users shoulders paying with load times.

I’m not skipping module resolution. three imports from node_modules/three/build/three.module.js, and three/src/whatever imports from node_modules/three/src/whatever.

That is how Node module resolution works, and it is allowed, as described by the package.json exports field.

I’m not skipping module resolution!

Quite a lot less in various cases. I’d rather not have to tweak build tools if library import statements import less by default.

No, if three recommended importing from three/src, and then the package.json exports field was updated accordingly to disallow anything but three/src imports (a breaking change), then people would be able to import only specific files…

… unless they skip module loading by doing import * as THREE from '../node_modules/three/build/three.module.js'. Now that is skipping module resolution.

What I am doing is not skipping module resolution! I am using Node module resolution to import ... from 'three/src/whatever' exactly as Node module resolution allows; exactly as specified in the Node ESM spec.

Look at this!
This Webpack loader decomposes import statements to import more specific paths.

It converts something like

import {Matrix4, Material} from 'three'

to

import {Matrix4} from 'three/src/math/Matrix4.js'
import {Material} from 'three/src/materials/Material.js'

Awesome!

Need something similar for outputting individual files, like a Babel plugin.

Please note that not all libraries are tree-shakable. So the above lib will work nicely for bundles.