ramda / ramda

:ram: Practical functional Javascript

Home Page:https://ramdajs.com

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Profit from Tree Shaking in Ramda in Webpack

scabbiaza opened this issue · comments

Hello, guys!

During my experiments with Tree Shaking in Ramda I found that the maximum profit I can get from it is the reducing a bundle on about 7Kb.

I'm wondering, is this because of the dependencies in Ramda or because of Webpack, or something else?

Here is how I tested it.
I created a file with the next code

import * as R from "ramda"

R.identity()

And compiled it two times:
with ramda@0.24 the size of the bundle was 59.2 kB
and with ramda@0.25 - 51.2 kB.

In my real project, where I did the same experiment, four bundles that use Ramda decreased their size on about 7Kb.

On the other hand, when I imported identity function directly from the source file, like this:

import identity from "ramda/src/identity"

identity()

size of the bundle was only 916 bytes. That kinda result I was expecting from Tree Shaking.

Code and Webpack configurations I uploaded to this repo:
https://github.com/scabbiaza/ramda-webpack-tree-shaking-examples

I've recently spent a lot of time debugging a very similar problem. The way Webpack tree-shaking works is it will still output the unused exports but leave it to the minifier to eliminate them. In the case I was looking at it wasn't eliminating any of the unused exports because the exported modules were accessing or setting properties which the minifier realised could potentially (via getters and setters) cause side-effects.

Without looking into it properly I would suspect that the unused exports aren't being cleaned up because they are often being wrapped by another function (often some sort of curry function) and as far as the minifier is aware those function invocations could have side-effects so it just leaves them.

I think this is worth discussing in terms of better modularization of Ramda. I'm adding it to the list of items under discussion by the core team.

thanks for the report @scabbiaza that is good info.

Love the detailed report! I've created rollup example - scabbiaza/ramda-webpack-tree-shaking-examples#1

Resulting bundle - 513 bytes (that is umd wrapper included).

Ramda at the moment is suited for tree shaking as far as it can. Unfortunately webpack with its bug and its poor algorithm (not covering many cases) is not doing well here.

I've described some findings in comments under the refactoring PR, which I've discovered while working on it. Please read my comments there, starting from this one. However I see you probably already know most of the things Im describing there.

There is also a note about upcoming webpack4 which should help a lot in ramda's case, thanks to the "sideEffects": false entry in package.json.

And finally - after my findings I've created an issue on webpack's board, but to this very day I didn't get any kind of answer unfortunately.

PS. If you "manually cherry-pick" you should import from the es dir (when using ramda 0.25+ and module aware bundler), rather than src

@benji6, Webpack (actually not Webpack, but a minifier) does eliminate some functions.
For instance, in my example with using only identity, such functions as composeP or splitEvery were not present in the resulting bundle. However many more other were, like pipe, compose and so on.

@buzzdecafe, thank you! I'm happy to hear it.

@Andarist, thank you for the example with Rollup! The result is impressive.
Looks like the reason of the pure minification is in Webpack, not in Ramda.

I've created yet another PR to your repository with ModuleConcatenationPlugin in webpack's config. It also helps a lot for webpack's case, I don't quite think it's webpack's merit in this case - it's that the scope hoisted bundle is more easily dead code eliminated by UglifyJS.

Output bundle - 877 B

EDIT:// If we pass { compress: { passes: 3 } } to the uglifyJs plugin we can go further down to 736 B

@Andarist , thank you for PR!

Yes, a bundle does reduce dramatically and the base example works perfectly.

However, need to try this approach on a big project, because this note in ModuleConcatenationPlugin documentation makes me worry:
These wrapper functions made it slower for your JavaScript to execute in the browser.

@scabbiaza This comment is about webpack without ModuleConcatenationPlugin used.

I see, thank you! I was confused.
In this case, it should be the best solution on Webpack.

Keep in mind that ModuleConcatenationPlugin is considered experimental at this point (i think, maybe its already past that phase). Im not sure how well it plays with code-splitted project, but Im using it without any problems with a single bundle app.

Also - I really love the way you have created your examples repository. You could easily expand it to some medium writeup (not much longer than the existing README) and share with the community (I suppose posts reach broader audience than repositories). This is quite hot topic in the community, but I think most people do not realise what techniques can be used to leverage tree-shaking etc. More educational resources are needed and this would be a great one!

It's a good idea to write an article about it! Maybe I will :)

Note that at least with Rollup, the concatenation of all modules into a single scope is highly beneficial for the minification of the bundle. For example, in a React application using JSX, you typically have tons of calls to React.createElement, where React is an imported module. After Rollup concatenates all the modules, including React, to a single scope, React.createElement effectively becomes just createElement and the minifier can then automatically rename createElement to something short like h. This can reduce the bundle size significantly. The same applies to all module qualified references: SomeModule.someName can be shortened (easily by a minifier) after concatenation.

I've tested Using these 4 different syntax with Ramda 0.25 and webpack (3.8.1) treeshaking in a production build with module concatenation. These were my results.

import * as R from 'ramda';
// No tree shaking, 318 modules ~52kb
 
import { identity } from 'ramda'
// No tree shaking, 318 modules ~52kb

import { identity } from 'ramda/es';
// No tree shaking, 318 modules ~52kb

import identity from 'ramda/es/identity';
// This was the only one that worked. 4 modules - 302B

From what I can tell the only way currently to benefit from tree shaking is to import modules individually.

As explained here first three options you have presented are basically the same - they do exactly the same thing.

Please follow related discussions (they should all be linked in this thread) - its just how webpack is currently working (meaning poorly in those regards). Other tools like rollup gives better results, but keep also in mind that ramda is a special case because of the heavy usage of higher order functions which are not easily tree-shakeable anyway.

For better webpack's results you can use its ModuleConcatenationPlugin, although it still won't give perfect output, it will be by far better.

I have made some investigations on this, related to char0n/ramda-adjunct#456.

Even if I use babel (no webpack), "module" field (package.json) resolution does not works.

ramda@0.25.0
babel@6
{
  "presets": [
    ["es2015"],
    ["stage-0"]
  ]
}

If I remove node_modules/ramda/src folder, and let node_modules/ramda/es, this doesn't works.

import R from 'ramda' // always use "main" field (commonjs)
import * as R from 'ramda' // always use "main" field (commonjs)

Anyway, it works fine with a "jsnext:main" field in package.json, but "module" seems to be ignored by babel.

{
  "jsnext:main": "./es/index.js"
}

Any ideas ?

Both "jsxnext:main" and "module" have nothing to do with babel. Babel is just a transpiler and it doesn't resolve your modules, it operates on single files only.

Those fields are targeting bundlers such as webpack and rollup and their resolution algorithms.

You'd have to share a repository illustrating the problem, so I could look at it and point out the problem more quickly.

I don't know why I thought babel-preset-es2015 have a resolver for import, but effectively it's just transform import syntax into require.

import R from 'ramda';

will be replaced by something like:

var R = require('ramda')

So commonJS bundle is used here, everything is fine.
I think my incomprehension come with eslint-plugin-import errors I'd have.

So the case is closed, right? Please remember about closing the issue on ramda-adjunct board 😃 Cheers!

I'm not sure for ramda-adjunct issue, I'd already have this case :

import R from 'ramda' // OK
import RA from 'ramda-adjunct' // undefined
import * as RA from 'ramda-adjunct' // OK

I have to take a little time to see what is going on here.

Anyway, Ramda imports are good to me, sorry for the inconvenience.

Hi,

I'm working on optimizing the ramda(heavy) usage on en existing codebase, starting by enabling tree-shaking.

What is its current state? It seems that the only importing method allowing tree shaking is:
import identity from 'ramda/es/identity';

But I'm not seing any build output difference compared to the other 3 importing methods.

ModuleConcatenationPlugin is now (Webpack 5) enabled by default, so it should work "out of the box" 🤔

I would love some 2022 feedbacks.

When i've optimized this stuff the lib became almost fully tree-shakeable when importing stuff from the root entrypoint. This was quite some time ago - cant say if there were any regressions in this area since then.

If ur company care about it - i could make a paid audit of your code, your conclusions and Ramda.

When i've optimized this stuff the lib became almost fully tree-shakeable when importing stuff from the root entrypoint. This was quite some time ago - cant say if there were any regressions in this area since then.

What do you mean by "importing stuff from the root entrypoint"? import { module } from 'ramda' ?

Yes, when I was testing this out this was sufficient to get almost perfect tree-shaking - there was no need to use deep imports like ramda/es/add etc

I just did some com tests with Next.js v12 :

Import method Side (Next.js) Modules Weight Tree shaking
import * as R from 'ramda'; client 222 1,21 kb 🔴
import { identity } from 'ramda' client 222 1,21 kb 🔴
babel-plugin-ramda client 222 1,21 kb 🔴
import identity from 'ramda/src/identity' client 4 336 b
import identity from 'ramda/es/identity' server 4 1.52 kb 🤔

It seems that's the only way to get a fully optimized build is via import identity from 'ramda/src/identity'.

I was thinking /es/ would be more efficient, and I don't fully understand why this method has so much weight while only using 4 modules (may be related to the fact that it is included in the server output, for whatever reason) 🤔

Well, hard to say what's going on if you don't share full repro case that could be inspected and analyzed.

@Andarist Here is the basic Next project used for my tests (cf. my previous message results).

If you were using some bundle analyzer here - then it's broken. IIRC some of those were just hooked into webpack before certain optimizations were executed so they were always skewing results.

If we actually inspect output files then we can roughly see everything that was included from Ramda:

code from Ramda
    6155: function (t, e, n) {
      "use strict";
      n.d(e, {
        yRu: function () {
          return y;
        },
      });
      function r(t) {
        return (
          null != t &&
          "object" === typeof t &&
          !0 === t["@@functional/placeholder"]
        );
      }
      function i(t) {
        return function e(n) {
          return 0 === arguments.length || r(n) ? e : t.apply(this, arguments);
        };
      }
      Array.isArray;
      "undefined" !== typeof Symbol && Symbol.iterator;
      function o(t, e) {
        return Object.prototype.hasOwnProperty.call(e, t);
      }
      var a = Object.prototype.toString,
        c = (function () {
          return "[object Arguments]" === a.call(arguments)
            ? function (t) {
                return "[object Arguments]" === a.call(t);
              }
            : function (t) {
                return o("callee", t);
              };
        })(),
        u = c,
        l = !{ toString: null }.propertyIsEnumerable("toString"),
        s = [
          "constructor",
          "valueOf",
          "isPrototypeOf",
          "toString",
          "propertyIsEnumerable",
          "hasOwnProperty",
          "toLocaleString",
        ],
        f = (function () {
          return arguments.propertyIsEnumerable("length");
        })(),
        d = function (t, e) {
          for (var n = 0; n < t.length; ) {
            if (t[n] === e) return !0;
            n += 1;
          }
          return !1;
        };
      Object.keys, Number.isInteger;
      "function" === typeof Object.is && Object.is;
      var g = function (t) {
        return (t < 10 ? "0" : "") + t;
      };
      Date.prototype.toISOString;
      function p(t) {
        return t;
      }
      var y = i(p);
      "function" === typeof Object.assign && Object.assign;
      var m =
        "\t\n\v\f\r \xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\ufeff";
      String.prototype.trim;
    },

There are, in fact, some things that wouldn't have to be here... but common, this code weighs something around 720bytes (gzipped).

Thanks as always @Andarist for bringing your expertise to bear here. Some day, I'll dedicate some effort to learning all this stuff!

@CrossEye it really isnt worth it 😂

@Andarist Thanks for taking a look.

I'm using webpack-bundle-analyzer, but I will take a look a the code output of the build as you did 👍

So import { module } from 'ramda' is the way to go for ramda tree shaking in 2022 ✅

so @Andarist we should ignore @monsieurnebo recommendation for this syntax? import identity from 'ramda/src/identity'?

According to my latest test - that wasn't a that long time ago - yes 😉 It's just that this stuff is notoriously hard to test in an automatic way. That's why various tools might report somewhat inaccurate data.

treeshake with webpack and imports as import { func } from 'ramda' doesn't work in 2022
You can see it with statoscope

import { func } from 'ramda'
image

import func from 'ramda/es/func'
image

Have you confirmed this manually? Those tools often are inaccurate

@Andarist I can confirm it as well. You can verify it by this simple entry point with webpack@5:

import { any } from 'ramda';

console.dir(any);

You would have to prepare a runnable repro case for me to investigate this.

We experience the same problem on a particular instance but does not seem reproducible in an isolated build: https://github.com/anacierdem/ramda-test Note that the output script is committed in dist/main.js.
I don't know why it does not behave as expected in some situations, might or might not be related to ramda itself.

Hi @Andarist,

Managed to find a consistent time block to do some deeper research. The conclusion of the research is: ramda can properly tree-shake.


Explanation

The key to properly tree-shake ramda imports is to:

{
  mode: 'production', // drops "dead code" from the bundle by setting proper defaults to `optimization` config
  optimization: {
    sideEffects: true, // tells webpack to recognise the sideEffects flag in package.json, ramda is side effects free
    minimize: true, // needs to be set to `true` for proper tree-shaking
    providedExports: true, // if set to `true` it gives far better results
    usedExports: true, // needs to be set to `true` for proper tree-shaking
    concatenateModules: true, // needs to be set to `true` for proper tree-shaking
  }
}

The repo where I did the research is available at https://github.com/char0n/ramda-tree-shaking-webpack.

nice work @char0n , did you happen to find out whether all of these optimization fields are necessary given that webpack docs say that when mode is set to production it has some default optimizations? top of https://webpack.js.org/configuration/optimization/

@damiangreen as mentioned in my research notes, you either use webpack mode=production which sets proper defaults to webpack optimization config OR if you want to adjust webpack optimization config make sure following options are always enabled:

{
  mode: 'production', // drops "dead code" from the bundle by setting proper defaults to `optimization` config
  optimization: {
    sideEffects: true, // tells webpack to recognise the sideEffects flag in package.json, ramda is side effects free
    minimize: true, // needs to be set to `true` for proper tree-shaking
    providedExports: true, // if set to `true` it gives far better results
    usedExports: true, // needs to be set to `true` for proper tree-shaking
    concatenateModules: true, // needs to be set to `true` for proper tree-shaking
  }
}

So in ramda terminology: the bullet list two last items form disjunction and not conjunction ;]

NOTE: mode=production implicitly sets the optimization config as we see it in above code fragments