Tree-shaking improvements
See original GitHub issueThe issue came up on Twitter - https://twitter.com/brian_d_vaughn/status/936721354283294720 and it’s also discussed in the main README. I believe we can provide a better developer experience out of the box at slight costs.
What’s going on with a library such as react-virtualized
? Tree-shaking algorithms are extremely fragile (especially webpack’s - rollup’s is way superior at the moment) and they bail out easily from the optimizations.
Why? It is hard to statically analyze a language, even harder analyzing dynamic language and those algorithms don’t want to break anyone’s code unexpectedly, so they behave in overly safe manner (which overally is probably a good thing). In other words - they are afraid of possible side effects. In many cases they probably don’t even whitelist known globals as side effect-free.
What can even be tree-shaken? Mainly top level statements. Many statements consist of expressions though, which generally speaking are trickier to eliminate. Let’s consider simple:
export default class List extends React.Component {
render() {
return null
}
}
What can be seen here:
- single statement
- no top level expressions
Result - tree-shakeable
Now consider the same but when classes are transpiled down to es5 (using loose
mode for better readability):
var List = function (_React$Component) {
babelHelpers._inherits(List, _React$Component);
function List() {
babelHelpers._classCallCheck(this, List);
return babelHelpers._possibleConstructorReturn(this, _React$Component.apply(this, arguments));
}
List.prototype.render = function render() {
return null;
};
return List;
}(React.Component);
export default List;
What can be seen here:
- 2 statetements
- first with a
CallExpression
Result - not tree-shakeable
Maybe at this point we should go back a little and talk about what tree-shaking is doing in webpack. So as we know by default webpack wraps each module in a closure, it supplies module
, __webpack_exports__
, __webpack_require__
arguments so the module can register itself & require other modules from the global webpack’s module registry. When it detects that an export is a candidate for tree-shaking it simply removes the export statement and adds a comment like /* unused harmony default export */
(that is probably only for debugging etc), but it doesn’t actually remove the “tree-shaken” code. It shifts that responsibility on minifiers like UglifyJS which do dead code elimination later. In my opinion this is a huge mistake and part of the reason why rollup performs better when it comes to tree-shaking - it actually removes the “dead code”, or maybe it’s better said that it doesn’t include it, it only includes live code.
But going back to webpack - the export from second snippet gets marked as unused by webpack too, so why it doesn’t work? Because UglifyJS doesn’t know that it is safe to remove this call, it just has not sufficient information to determine statically that this IIFE does not produce any side-effects and that it is safe to remove it.
What can be done about it? Giving a small hint to UglifyJS about nature of this IIFE by adding #__PURE__
comment before the call. If we add it dead code elimination will be able to use it and remove it for us if the webpack has marked the export as unused before (thus it has skipped assigning it to __webpack_exports__
).
🎉 babel@7-beta already adds those #__PURE__
annotations, although we might need to tweak it in babel, because not every class declaration is pure (computed properties etc can make them impure).
So the answer seems to be obvious - just add #__PURE__
and be done with it, right?
Unfortunately it is not that simple. Consider classes with static properties (and we need them for React components, no matter if we use a proposal class properties transform or not):
export default class List extends React.Component {
static propTypes = {}
render() {
return null
}
}
which transforms to:
var List = function (_React$Component) {
babelHelpers._inherits(List, _React$Component);
function List() {
babelHelpers._classCallCheck(this, List);
return babelHelpers._possibleConstructorReturn(this, _React$Component.apply(this, arguments));
}
List.prototype.render = function render() {
return null;
};
return List;
}(React.Component);
List.propTypes = {};
export default List;
What can be seen here:
- 3 statetements
- first with a
CallExpression
again (could be marked as#__PURE__
now) - second is a property assignment
Result - not tree-shakeable even with #__PURE__
Why? Again - UglifyJS’ algorithms are not smart enough to know that a static assignment - List.propTypes = {};
- can be removed and while it is there whole List
’ IIFE cannot be removed because List
is used (by that static assignment).
Generally speaking it’s easier for minifiers to remove stuff (i.e. a class) if they are represented by a single statement, therefore this could be removed:
var List = /* #__PURE__ */ function (_React$Component) {
babelHelpers._inherits(List, _React$Component);
function List() {
babelHelpers._classCallCheck(this, List);
return babelHelpers._possibleConstructorReturn(this, _React$Component.apply(this, arguments));
}
List.prototype.render = function render() {
return null;
};
List.propTypes = {};
return List;
}(React.Component);
export default List;
🎉 but how to achieve such output? There are 3 options here:
- wait for babel to do that - that is pulling statics into the closure (which is not trivial because class transform and class properties transform are independent) and marking classes as
#__PURE__
. This won’t happen for babel@6 though, but personally I’m already using babel@7-beta for number of projects and it works fine. Statics issue is not yet resolved, but I’m gonna fix this when I find time. - add this stuff manually in the source code - you’d have to wrap your classes in simple IIFEs so the outputted statics would land inside it. It would result in each (most) classes being wrapped by 2 IIFEs - 1 custom for statics, the class one that is already there. This doesn’t seem like a problem to me though.
- implement custom transforms for both problems. For inserting
#__PURE__
annotations there is already babel-plugin-annotate-pure-calls (disclaimer - I’ve written it). For dealing with static properties problem I’d argue that it is easier to write one which would do what got mentioned above - wrapping a class into second IIFE.
🎉 those things mentioned would help…
…but unfortunately wouldn’t solve webpack’s problem entirely. As mentioned webpack is not even removing dead code and if you have a module like this:
/* 3 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
/* harmony import */ var _Grid__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(0);
var List = /* #__PURE__ */ function (_React$Component) {
babelHelpers._inherits(List, _React$Component);
function List() {
babelHelpers._classCallCheck(this, List);
return babelHelpers._possibleConstructorReturn(this, _React$Component.apply(this, arguments));
}
List.prototype.render = function render() {
return React.createElement(Object(_Grid__WEBPACK_IMPORTED_MODULE_0__["a" /* default */]), this.props);
};
List.propTypes = {};
return List;
}(React.Component);
/* unused harmony default export */ var _unused_webpack_default_export = (List);
})
List
will be removed correctly as unused, but List
’s dependencies (Grid
here) won’t. More can be read here (with standalone repo as example).
So what can be done about this?
- advising people to use
ModuleConcatenationPlugin
, but it’s only available for webpack@3+ and people need to activate it - adding
"sideEffects": false
to yourpackage.json
, but it will only start to benefit people when webpack@4 gets out. - switch to flat bundles - that way UglifyJS will be able to remove dependencies of unused exports, because they will all live in a single scope and won’t be added to a scope with
__webpack_require__
call (which is not tree-shakeable and even if it was it wouldn’t be able to remove the required module from the webpack’s registry)
NOTE
There are also some other issues in react-virtualized
which prevents proper tree-shaking, but those should be easier to fix.
- requiring commonjs modules, such as
classnames
,babel-runtime/helpers
- using a commonjs
require
(!) inserted bybabel-plugin-flow-react-proptypes
- prototype assignments such as this (again - things as single statement are easier to remove) and
Object.defineProperty
calls
Issue Analytics
- State:
- Created 6 years ago
- Reactions:10
- Comments:19 (12 by maintainers)
Top GitHub Comments
😂
It’s usually imported by user.
That’s my point. After this bug I realised how unsafe
sideEffects
is. And I don’t know the code of this project enough to add this field safely.