ckeditor / ckeditor5

Powerful rich text editor framework with a modular architecture, modern integrations, and features like collaborative editing.
https://ckeditor.com/ckeditor-5
Other
8.28k stars 3.59k forks source link

Improve tree-shaking in bundles for new installation methods #16292

Open filipsobol opened 2 weeks ago

filipsobol commented 2 weeks ago

When working on the new installation methods (NIM), we noticed that the bundle size in the consuming projects was larger than we expected. Investigation showed that there was a lot of code in various core and commercial packages that was not properly tree-shaken.

Although we noticed this while working on NIM, this is not a new problem. Because in the old installation methods the plugins are imported directly from their respective packages, it was much less likely that the code that wasn't properly tree-shaken would end up unused. However, in NIM, all core and commercial packages are imported from the ckeditor5 and ckeditor5-premium-feature packages. This means that pulling code from one plugin will also pull side-effects from all other plugins in these packages.

How significant is this issue? When using the 0.0.0-nightly-20240426.0 build, the following imports will result in a bundle size of 1390.65 kB in Rollup and esbuild (minified, not gzipped):

import 'ckeditor5';
import 'ckeditor5-premium-features';

In a perfect world, the bundle size should be 0.0 kB because we didn't import anything specific. However, about 550 kB of that 1390.65 kB are essential packages (engine, ui, core, utils, watchdog) that are used by every plugin. While we could try to make this part tree-shakable, that's not the main focus of this task. The main focus now is to make the other 840 kB tree-shakable.

Our initial tests show that we can reduce the bundle size to 738 kB (or 182 kB minzipped) without major code changes, with the potential to reduce the size by another 150 kB. This would be a 58% reduction from the current state.

Problems

Below are the main problems that we identified.

Static methods and fields

In our codebase, we make heavy use of static methods and fields. While support for static public methods was introduced in ES2015, support for static private methods and static fields (public and private) was added in ES2022.

Because support for all four combinations has long been available in TypeScript, it and other bundlers had to transpile them to support the older browser. Unfortunately, these workarounds make the output code less tree-shakable. For a simple example, consider the following code:

export class Test {
  public static field = 123;

  public static method() {
    return 123;
  }
}

Since public static methods are well supported, the public static method() is transpiled to static method(). However, the public static field is transpiled to code that cannot be tree-shaken, and the output varies between transpilers. See the following links for TypeScript, esbuild, and SWC outputs:

Change the output target to ES2022 to see how the workarounds disappears to something that can be easily analyzed and tree-shaken.

We can't change the target in the entire codebase because that would break support for webpack 4 (that's why we only target ES2019 for now), but we can change the target only in our new CLI tool to only affect new installation methods.

Before we can do that, however, we need to fix the problem described in #14703, because part of our code is not valid and only works because of the transpilation.

Mixins

We use mixins in our codebase to share code between classes. However, the current implementation of mixins in TypeScript is not tree-shakable. Consider the following code:

class FileLoader extends ObservableMixin() {}

When statically analyzing the code, it's impossible for bundlers to know what ObservableMixin() will return and if it contains side effects. This makes (some) classes that extend mixins not tree-shakable.

This can be easily fixed by adding the #__PURE__ comment to all mixin calls, like this:

class FileLoader extends /* #__PURE__ */ ObservableMixin() {}

Alternatively (or even preferably), we could try annotating the mixin functions with #__NO_SIDE_EFFECTS__, but we need to make sure that this comment works when using webpack, Vite, and esbuild.

Other problems

How to debug

(One time) Setup repository

  1. Clone this repository.
  2. Create the external folder in the root of the repository.
  3. Clone the ckeditor5-dev repository into the external folder.
  4. Open the external/ckeditor5-dev/packages/ckeditor5-dev-build-tools/src/config.ts file and add the experimentalLogSideEffects: true property to the object returned from the getRollupConfig function.
  5. Open the external/ckeditor5-dev/packages/ckeditor5-dev-build-tools/src/build.ts file and add preserveModules: true to the object passed to the build.write in the build function (not the generateUmdBuild function).
  6. Run yarn reinstall in the root of the ckeditor5 repository. This should install all dependencies and build the changes we made in the ckeditor5-dev repository.

(One time) Setup Verdaccio

  1. Install Verdaccio.
  2. Create .npmrc file and add registry=http://localhost:4873/ to it.
  3. Run npm adduser --registry http://localhost:4873/. Make sure to name the user ckeditor (this is required by one of our internal scripts).

(One time) Setup test repository

  1. Clone the new-installation-methods repository.
  2. In the vite directory create the .npmrc file and add registry=http://localhost:4873/ to it.
  3. Run npm install in the vite directory.
  4. Change the contents of vite/src/main.ts to:
    import 'ckeditor5';
    import 'ckeditor5-premium-features';

Test changes

In the ckeditor5 repository:

  1. Make the changes in the code.
  2. Stage the changes (git add .).
  3. Run npm run release:prepare-packages -- --nightly in the root of the ckeditor5 repository (you can comment out some of the steps in the scripts/release/preparepackages.js file to speed up the process).
  4. Run npm run release:publish-packages -- --nightly to publish the changes to Verdaccio.
  5. Revert all unstaged changes (git restore .).

In the new-installation-methods repository:

  1. Reinstall the dependencies in the vite directory.
  2. Run npm run build to see the changes in the bundle size.
  3. Run the following command and check the bundle size:
    npx esbuild ./src/main.ts \
      --bundle \
      --minify \
      --format=esm \
      --outfile=custom/bundle.js \
      --metafile=meta.json \
      --legal-comments=none \
      --target=es2022
  4. Upload the meta.json file to the esbuild analyzer for a more detailed analysis.
filipsobol commented 5 days ago

[!IMPORTANT]
This comment shows progress (from top to bottom) of how each change improved the bundle size. The bundles are minified, but not gzipped.

Side-effects

Side-effect code ```ts import 'ckeditor5'; import 'ckeditor5-premium-features'; ```
Name Version Vite esbuild webpack
Baseline 0.0.0-nightly-20240426.0 1377 kb 1412 kb 1411 kb
#__PURE__ before mixins 0.0.0-nightly-20240509.0 1374 kb 1396 kb 1411 kb
Bumping target to ES2022 0.0.0-nightly-20240510.0 679 kb 1295 kb 1296 kb
#__PURE__ in commercial 0.0.0-nightly-20240515.0 685 kb 1081 kb 1159 kb

Build

Build code ```ts import { ClassicEditor, Essentials, CKFinderUploadAdapter, Autoformat, Bold, Italic, BlockQuote, CKBox, CKFinder, EasyImage, Heading, Image, ImageCaption, ImageStyle, ImageToolbar, ImageUpload, PictureEditing, Indent, Link, List, MediaEmbed, Paragraph, PasteFromOffice, Table, TableToolbar, TextTransformation, CloudServices, Mention } from 'ckeditor5'; import { CaseChange, SlashCommand } from 'ckeditor5-premium-features'; import 'ckeditor5/index.css'; import 'ckeditor5-premium-features/index.css'; ClassicEditor.create( document.querySelector( '#editor' ) as HTMLElement, { plugins: [ Essentials, CKFinderUploadAdapter, Autoformat, Bold, Italic, BlockQuote, CKBox, CKFinder, CloudServices, EasyImage, Heading, Image, ImageCaption, ImageStyle, ImageToolbar, ImageUpload, Indent, Link, List, MediaEmbed, Paragraph, PasteFromOffice, PictureEditing, Table, TableToolbar, TextTransformation, Mention, CaseChange, SlashCommand ], licenseKey: '', // Replace this with your license key. toolbar: { items: [ 'undo', 'redo', '|', 'heading', '|', 'bold', 'italic', '|', 'link', 'uploadImage', 'insertTable', 'blockQuote', 'mediaEmbed', '|', 'bulletedList', 'numberedList', 'outdent', 'indent', 'caseChange' ] }, image: { toolbar: [ 'imageStyle:inline', 'imageStyle:block', 'imageStyle:side', '|', 'toggleImageCaption', 'imageTextAlternative' ] }, table: { contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ] }, language: 'en' } ); ```
Name Version Vite esbuild webpack
Baseline 0.0.0-nightly-20240426.0 1819 kb 1779 kb 1793 kb
#__PURE__ before mixins 0.0.0-nightly-20240509.0 1820 kb 1773 kb 1794 kb
Bumping target to ES2022 0.0.0-nightly-20240510.0 1537 kb 1715 kb 1734 kb
#__PURE__ in commercial 0.0.0-nightly-20240515.0 1556 kb 1608 kb 1693 kb