vitejs / vite-plugin-vue2

Vite plugin for Vue 2.7
MIT License
551 stars 48 forks source link

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

Open thebanjomatic opened 1 year ago

thebanjomatic commented 1 year ago

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 commented 1 year ago

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
  }
}
tlongzou commented 1 year ago

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