Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(treeshaking): allowing tree-shaking with terser #74

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

thebanjomatic
Copy link

@thebanjomatic thebanjomatic commented Jan 12, 2023

The code currently generated by this plugin looks something like the following when building a library:

src/index.js

export {default as Foo} from './component.vue';
var __component__ = /*#__PURE__*/ normalizeComponent(...)
var Foo = __component__.exports

In the event you aren't actually using the Foo export, using a minifier like Terser, this code is unable to be dropped. This is because Terser assumes that the property property access (__component__.exports) is not side-effect free, and as a result it decides it can not drop it, and by extension it can not drop the rest of the component code. For more context, see the pure_getters documentation here: https://terser.org/docs/api-reference#compress-options

To work around this, I have wrapped the __component__.exports statement in a function so that we can mark the function invokation as #__PURE__ and thus allow for unused components bundled into a single library to be dropped fully.

The resulting code looks like:

function getExports(component) {
  return component.exports
}
...
var __component__ = /*#__PURE__*/ normalizeComponent(...)
var Foo = /*#__PURE__*/ getExports(__component__)

Additional Context about the problem

I am using vite & vite-plugin-vue2 to build a component library. In this library there are a couple hundred icons which are each vue components. The library is shipped as a single .js file with named exports for each icon component.

When consuming this library from a vite application with the default minification settings (esbuild), tree-shaking works correctly and only the icons that are used are present in the bundled application code. I believe rollup may be just handling things better here since terser and esbuild both seem to behave identically in this regard. (see comment below for reproducers)

However, when consuming this library from webpack and using terser as the minification engine, the bundled application has the code for every component is being included even though only a couple are being used. When I make the change in this PR, I am no longer seeing the unused components in the resulting bundle.

@thebanjomatic
Copy link
Author

thebanjomatic commented Jan 12, 2023

I've been trying to find a good way to demonstrate the issue, and the best I've come up with is to past the following into https://try.terser.org/ and then swap the comments on the last two lines.

The same behavior reproduces when using esbuild as the minifier: link

function getExports(component) { 
  return component.exports
}

function normalizeComponent (
    scriptExports,
    render,
    staticRenderFns,
    functionalTemplate,
    injectStyles,
    scopeId,
    moduleIdentifier, /* server only */
    shadowMode /* vue-cli only */
) {
  // Vue.extend constructor export interop
  var options = typeof scriptExports === 'function'
      ? scriptExports.options
      : scriptExports

  // render functions
  if (render) {
    options.render = render
    options.staticRenderFns = staticRenderFns
    options._compiled = true
  }

  // functional template
  if (functionalTemplate) {
    options.functional = true
  }

  // scopedId
  if (scopeId) {
    options._scopeId = 'data-v-' + scopeId
  }

  var hook
  if (moduleIdentifier) { // server build
    hook = function (context) {
      // 2.3 injection
      context =
          context || // cached call
          (this.$vnode && this.$vnode.ssrContext) || // stateful
          (this.parent && this.parent.$vnode && this.parent.$vnode.ssrContext) // functional
      // 2.2 with runInNewContext: true
      if (!context && typeof __VUE_SSR_CONTEXT__ !== 'undefined') {
        context = __VUE_SSR_CONTEXT__
      }
      // inject component styles
      if (injectStyles) {
        injectStyles.call(this, context)
      }
      // register component module identifier for async chunk inference
      if (context && context._registeredComponents) {
        context._registeredComponents.add(moduleIdentifier)
      }
    }
    // used by ssr in case component is cached and beforeCreate
    // never gets called
    options._ssrRegister = hook
  } else if (injectStyles) {
    hook = shadowMode
        ? function () {
          injectStyles.call(
              this,
              (options.functional ? this.parent : this).$root.$options.shadowRoot
          )
        }
        : injectStyles
  }

  if (hook) {
    if (options.functional) {
      // for template-only hot-reload because in that case the render fn doesn't
      // go through the normalizer
      options._injectStyles = hook
      // register for functional component in vue file
      var originalRender = options.render
      options.render = function renderWithStyleInjection (h, context) {
        hook.call(context)
        return originalRender(h, context)
      }
    } else {
      // inject component registration as beforeCreate hook
      var existing = options.beforeCreate
      options.beforeCreate = existing
          ? [].concat(existing, hook)
          : [hook]
    }
  }

  return {
    exports: scriptExports,
    options: options
  }
}
const icon = "_icon_5y8ds_1";
const style0 = {
  icon,
};

const _sfc_main = /* @__PURE__ */ Vue.extend({
  computed: {
    value() {
      return Date.now();
    },
  },
});

var _sfc_render = function render() {
  var _vm = this, _c = _vm._self._c;
  _vm._self._setupProxy;
  return _c("div", { class: _vm.iconClasses }, [_vm._t("default")], 2);
};
var _sfc_staticRenderFns = [];
const __cssModules = {
  "$style": style0
};
function _sfc_injectStyles(ctx) {
  for (var key in __cssModules) {
    this[key] = __cssModules[key];
  }
}

var __component__ = /*#__PURE__*/ normalizeComponent(
  _sfc_main,
  _sfc_render,
  _sfc_staticRenderFns,
  false,
  _sfc_injectStyles,
  null,
  null,
  null
)

// swap between the commented lines below:
var Foo = __component__.exports
// var Foo = /*#__PURE__*/ getExports(__component__)

The version of this code represented by this fix (var Foo = /*#__PURE__*/ getExports(__component__)) results in zero remaining code, and the existing behavior results in 1020 byte of code left behind.

You can also reproduce the fully tree-shaken result with the existing code by using the following terser options to assert that all getters are pure, but it isn't generally correct or safe to do so:

{
  "compress": {
    "pure_getters": true
  }
}

The code currently generated by this plugin looks something like the following when building a library:

src/index.js
```js
export {default as Foo} from './component.vue';
```

```js
var __component__ = /*#__PURE__*/ normalizeComponent(...)
var Foo = __component__.exports
```

In the event you aren't actually using the Foo export, this code is unable to be dropped because Terser assumes that property access (__component__.exports) is not side-effect free and as a result it decides it can not drop it, and thus it can't drop the rest of the component code.

To work around this, I have wrapped the `__component__.exports` statement in a function so that we can mark the function invokation as `#__PURE__` and thus allow for unused components to be dropped fully.

The resulting code looks like:
```js
var __component__ = /*#__PURE__*/ normalizeComponent(...)
var Foo = /*#__PURE__*/  getExports(__component__)
```
Copy link
Author

@thebanjomatic thebanjomatic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sodatea let me know if you have any questions about this PR.

@@ -137,7 +137,7 @@ var __component__ = /*#__PURE__*/__normalizer(

let resolvedMap: RawSourceMap | undefined = scriptMap

output.push(`export default __component__.exports`)
output.push(`export default /*#__PURE__*/ __getExports(__component__)`)
Copy link
Author

@thebanjomatic thebanjomatic Jan 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also have not exported the getExports function from the normalizer module and instead just used an IIFE:

output.push(`export default /*#__PURE__*/ (function() { return __component__.exports })()`)

But I thought the exported function was more clear. That said, the IIFE approach wouldn't require touching the NORMALIZER_ID module at all, so it might be preferable for that reason.

return component.exports
}

export function normalizeComponent (
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I switched this to be a named export instead out of habit since I've run into issues in the past with mixing named and default exports for commonjs modules.

I can switch this back to the default export if you'd prefer.

@tlongzou
Copy link

tlongzou commented May 22, 2023

You are absolutely right. I also encountered the same issue, and it seems that this merge request will have no negative impact. please look about this merge request @sodatea

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants