transloadit / uppy

The next open source file uploader for web browsers :dog:
https://uppy.io
MIT License
29.24k stars 2.01k forks source link

Dynamically imported Uppy plugins #4252

Open mifi opened 1 year ago

mifi commented 1 year ago

Problem

From discussion here: https://github.com/transloadit/uppy/pull/4225#issuecomment-1321834598

Bundle size is large if developers want to support all upload methods or plugins, even those that are not actually used by the user - simply importing our plugins gives a large bundle size.

Solution

It would be nice to start out with a minimal uploader (small bundle size) that can be inlined (like the Mini proposal). Then once users select other upload sources in the UI, we could dynamically load chunks for those plugins using JS dynamic imports.

For example from #4225 when the user click "Dropbox" in the menu, then chunks for the dashboard and Dropbox plugin would be loaded and shown. This would allow for a minimal core to be loaded much faster with the site.

I did some testing where I started a Create-react-app project (uses webpack) and then added two fake dependencies inside node_modules:

Then another uppy folder inside node_modules that will dynamically import these two dependencies.

Test setup

First run:

npx create-react-app dynamic-import-test
cd dynamic-import-test
yarn add -D webpack-bundle-analyzer

Create these files:

// node_modules/uppy/index.js
function Uppy() {
  const plugins = [];

  async function addPlugin(id) {
    if (id === 'audio') plugins.push(await import ('uppy-plugin-audio'));
    else if (id === 'webcam') plugins.push(await import ('uppy-plugin-webcam'));
  }

  function load() {
    plugins.forEach((plugin) => plugin.load());
  }

  return {
    addPlugin,
    load,
  }
} 

export default Uppy
// node_modules/uppy-plugin-audio/index.js
const lotsOfCode = [
  '1234567890', '1234567890', '1234567890', '1234567890',
  // ... copy-paste line 2000 times ...
];

export function load() {
  console.log('audio', lotsOfCode.length);
};
// node_modules/uppy-plugin-webcam/index.js
const lotsOfCode = [
  '1234567890', '1234567890', '1234567890', '1234567890',
  // ... copy-paste line 2000 times ...
];

export function load() {
  console.log('webcam', lotsOfCode.length);
};
// src/index.js
import Uppy from 'uppy';

const uppy = Uppy();

function App() {
  async function loadPlugin(id) {
    await uppy.addPlugin(id);
    uppy.load();
  }

  return (
    <div>
      <button onClick={() => loadPlugin('audio')}>Audio</button>
      <button onClick={() => loadPlugin('webcam')}>Webcam</button>
    </div>
  );
}

const container = document.getElementById('root');
const root = createRoot(container);

root.render(<App />);

Now build the bundles and start a static server:

npx react-scripts build --stats
(cd build && http-server)
open http://127.0.0.1:8080/

Results

Check network tab in DevTools

  1. It has loaded /static/js/main.adcf708b.js - 151kB

    Screenshot 2022-12-16 at 20 57 21
  2. Now click "Audio" button - it will load the audio chunk /static/js/78.c46b4426.chunk.js - 220kb

    Screenshot 2022-12-16 at 20 57 39
  3. Now click "Webcam" button - it will load the webcam chunk /static/js/38.f157b14b.chunk.js - 220kb

    Screenshot 2022-12-16 at 20 57 55

Analyze webpack bundle:

npx webpack-bundle-analyzer build/bundle-stats.json
Screenshot 2022-12-16 at 21 08 57

Conclusion

From my testing, dynamic imports from inside npm modules works with a popular framework like create-react-app (webpack). Because dynamic imports are now part of the web, it leads me to believe that most bundlers support this now. This POC shows that we should be able to modify Uppy so that instead of the developer using Uppy having to manually handle importing Uppy dependencies/plugins and passing them to uppy.use, Uppy can instead internally dynamically import plugins once the functionality that uses them is being opened.

Downsides:

Alternatives

Alternative 1

The developer using Uppy can use dynamic import when importing Uppy. Then their bundle will be small and we don't have to change anything, but once Uppy needs to be imported, they will still have to load all the plugins. (big bundle)

Alternative 2

We could pass the responsibility of dynamically importing plugins onto the developer using Uppy. Then we would modify Uppy so that instead of importing plugins when the user calls uppy.use(plugin), we could use an option that Uppy can call when Uppy needs a particular plugin (kind of like dependency injection, but async). something like:

const uppy =  new Uppy({
  onPluginNeeded: {
    webcam: async () => import('@uppy/webcam'),
    dashboard: async () => import('@uppy/dashboard'),
    // ...etc
  }
})
CanRau commented 1 year ago

This could also help in cases like this https://github.com/transloadit/uppy/issues/4218#issuecomment-1399409372 even though those plugins might be combined, I think the option to dynamically load, enable/disable would be a fantastic addition