Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Code Splitting

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Go to the login page. If you type a really long username, we yell at you! Yes, we value brevity!

Look at the code behind this: assets/js/login.js. If the length is longer than 20, we add the warning:

... lines 1 - 5
$(document).ready(function() {
... lines 7 - 12
$('.js-login-field-username').on('keydown', function(e) {
... lines 14 - 17
if ($usernameInput.val().length >= 20) {
const $warning = $('<div class="login-long-username-warning">This is a really long username - are you sure that is right?</div>');
$usernameInput.before($warning);
}
});
});

Code Splitting?

Guess what? Webpack comes with an absolutely killer feature... and I've been keeping it a secret! No more! For this to make sense, I want you to pretend that the code that adds the long username warning is really, really big. I literally mean, imagine the code inside the if statement is many lines... instead of just two.

In this situation, it's really wasteful to force the user to download all that extra JavaScript... just in case they ever type a long username!

In my perfect world, these two lines - or these many lines, in our imaginary situation - would only be downloaded by the user if and when they type a long username. Yes, I want to lazily load parts of our JavaScript!

With Webpack, this is possible! It's called "code splitting".

Refactoring to a Module

To use code splitting, you first need to move the conditional code to a new module. In the Components/ directory, create a new file called username_validation_error.js. Export a default function with a $usernameInput argument:

export default function($usernameInput) {
... lines 2 - 3
}

I'll move the two lines from login.js over to this new function. And I'll change $(this) to $usernameInput:

export default function($usernameInput) {
const $warning = $('<div class="login-long-username-warning">This is a really long username - are you sure that is right?</div>');
$usernameInput.before($warning);
}

To use the new module, back in login.js, add import username_validation_error from ./Components/username_validation_error:

... lines 1 - 4
import username_validation_error from './Components/username_validation_error';
... lines 6 - 25

And below, just, username_validation_error($(this)):

... lines 1 - 4
import username_validation_error from './Components/username_validation_error';
... line 6
$(document).ready(function() {
... lines 8 - 13
$('.js-login-field-username').on('keydown', function(e) {
... lines 15 - 18
if ($usernameInput.val().length >= 20) {
... line 20
username_validation_error($usernameInput);
}
});
});

Let's also log the imported module... which should be a function:

... lines 1 - 4
import username_validation_error from './Components/username_validation_error';
... line 6
$(document).ready(function() {
... lines 8 - 13
$('.js-login-field-username').on('keydown', function(e) {
... lines 15 - 18
if ($usernameInput.val().length >= 20) {
console.log(username_validation_error);
username_validation_error($usernameInput);
}
});
});

Oh, and make sure you have Components in your import!

... lines 1 - 4
import username_validation_error from './Components/username_validation_error';
... lines 6 - 25

Over in my Webpack tab, once I finished, Webpack was happy. Refresh the login page and... yep! It still works. Code refactoring complete!

Using the delayed import()

To add code splitting, Webpack has two syntaxes... and both work the same. The first is called require.ensure():

require.ensure(['./lazy_module'], function (require) {
    const lazyModule = require('./lazy_module');
});

The second - the one I want to show you - uses import:

import('./lazy_module').then(lazyModule => {
    // ...
});

Down in the if statement, this is the moment when I actually need to load my username_validation_error module. Add import() here... but use it like a function. I'll copy the ./Components/username_validation_error module path, delete that import line entirely:

... lines 1 - 4
import username_validation_error from './Components/username_validation_error';
... lines 6 - 25

And pass that as the first argument. When you use import like this, it returns a Promise. Hey, we know about those! It means that we can say .then() and pass a callback. The argument will be the imported module. So, username_validation_error:

... lines 1 - 5
$(document).ready(function() {
... lines 7 - 12
$('.js-login-field-username').on('keydown', function(e) {
... lines 14 - 17
if ($usernameInput.val().length >= 20) {
// use code splitting to lazily load this "chunk"
import('./Components/username_validation_error').then(username_validation_error => {
... lines 21 - 22
});
}
});
});

Move the code inside of the callback:

... lines 1 - 5
$(document).ready(function() {
... lines 7 - 12
$('.js-login-field-username').on('keydown', function(e) {
... lines 14 - 17
if ($usernameInput.val().length >= 20) {
// use code splitting to lazily load this "chunk"
import('./Components/username_validation_error').then(username_validation_error => {
console.log(username_validation_error);
username_validation_error($usernameInput);
});
}
});
});

Yea... I like this! It feels like... and well.. is an AJAX call! We're saying:

Good afternoon Webpack! Could you please download the username_validation_error module via AJAX and then execute my callback when it's ready. Thank you!

It should work... but go look at the Webpack terminal. Ah! It's SO angry!

Module build failed. Syntax error: import and export may only appear at the top level.

The dynamic import Proposal

Hmm. It's very unhappy about the import: it says that this is only allowed to live at the top of the file!

Here's the story: when you use import at the top of the file - like we've been doing until now - we're using a real, official ECMAScript feature - it's in ES6. But when you use import() like a function... well... that's not part of ECMAScript! Well, not yet. That functionality is just a proposal called "dynamic import".

The parse error comes from Babel: it tries to parse our code, but sees this code as invalid. And technically, it's right!

Making Babel like dynamic imports

So here's the plan: we need to teach Babel that this syntax is valid... but not to do anything with it. I mean, it should not have an error, and it should leave the import() function there so Webpack can parse it.

Doing this is easy: Babel is super configurable. In our open terminal, run:

yarn add babel-plugin-syntax-dynamic-import --dev

This is a plugin for Babel that makes it understand the dynamic import syntax.

Once it's installed, to activate it, open your .babelrc file. In addition to presets, the other common thing you'll add here is plugins. Pass it one: syntax-dynamic-import:

5 lines .babelrc
{
... line 2
"plugins": ["syntax-dynamic-import"]
}

Now, Babel will at least understand this as a valid syntax.

Go back to the terminal that's running Webpack and restart it... just to be safe:

yarn watch

Yes! It's happy! Back in the browser, bring up the network tab and refresh. Ok, I'll clear this out. Now, type a really long username.

Woh! Check it out! I don't see the error message... but it did make an AJAX request for a script tag! And look inside! Yea! This is our code-split module!

Using the .default Key

Great! Except... for the fact that it didn't work! Check out the console. Above the errors, remember, we logged the module. But... it's not a function! What!? It's an object... with a key called default... and that is the function!

This is a gotcha with code splitting. When you export things as default, the module will actually live on a default key! No big deal: to get things to work, say username_validation_error.default():

... lines 1 - 5
$(document).ready(function() {
... lines 7 - 12
$('.js-login-field-username').on('keydown', function(e) {
... lines 14 - 17
if ($usernameInput.val().length >= 20) {
// use code splitting to lazily load this "chunk"
import('./Components/username_validation_error').then(username_validation_error => {
username_validation_error.default($usernameInput);
});
}
});
});

Refresh again! Type a long username... and... woohoo! There's our warning! And it was loaded via an AJAX call. Hello code splitting. And though it looks really fast, in a real app, you may want to add a loading animation... like with any other AJAX call.

Code Splitting CSS

And, I have more good news! You can also code split CSS! Open login.css. At the bottom, yep, this last CSS rule only exists to style the warning box:

... lines 1 - 48
.login-long-username-warning {
color: #8a6d3b;
background-color: #fcf8e3;
padding: 15px;
margin-bottom: 10px;
border: 1px solid #faebcc;
border-radius:4px;
}

Just like with our JavaScript, it's wasteful to make the user download this... when they might not need it!

Remove that CSS and create a new file: css/login-username-error.css. Paste it here:

.login-long-username-warning {
color: #8a6d3b;
background-color: #fcf8e3;
padding: 15px;
margin-bottom: 10px;
border: 1px solid #faebcc;
border-radius:4px;
}

Now, inside username_validation_error.js, that CSS is really a dependency of this module. So, add import '../../css/login-username-error.css:

import '../../css/login-username-error.css';
export default function($usernameInput) {
... lines 4 - 5
}

When we refresh now, login.css does not contain that extra code. Yep, we've made that file slightly smaller. But when we try a really long username... it works! Look at the downloaded JavaScript file. Yes! It contains the CSS, down at the bottom.

When the JS file loads, the CSS is being injected onto the page via a traditional style tag. Well, it's a weird blob actually... but conceptually, this is a style tag with that CSS.

Oh, remember the extract-text-webpack-plugin?

180 lines webpack.config.js
... lines 1 - 3
const ExtractTextPlugin = require('extract-text-webpack-plugin');
... lines 5 - 40
const webpackConfig = {
... lines 42 - 51
module: {
rules: [
... lines 54 - 63
{
test: /\.css$/,
use: ExtractTextPlugin.extract({
use: [
cssLoader
],
// use this, if CSS isn't extracted
fallback: styleLoader
}),
},
... lines 74 - 106
]
},
... lines 109 - 155
};
... lines 157 - 180

Well, by default, it does not extract any CSS that has been code split. Nope, any code-split CSS is instead passed to the fallback loader: style-loader. In other words, code-split CSS is packaged into JavaScript and added to the page when that JavaScript file is downloaded.

So next time you have some conditional code... think about code splitting: you could drastically reduce the size of your assets!

Speaking of that, let's use a visualizer to make our assets even more efficient.

Leave a comment!

5
Login or Register to join the conversation
Default user avatar
Default user avatar Harvey Jones | posted 3 years ago

Thank you so much for this much detail on code splitting. I was learning it and this, along with another detailed article, helped me a lot.

Reply
Cluster Avatar
Cluster Avatar Cluster | posted 4 years ago | edited

Hey! I'm trying to follow along with Webpack v4. The ExtractTextPlugin doesn't work, so I'm using the MiniCssExtractPlugin (see snippets below). But I can't find a configuration for that plugin that would be equivalent to the fallback prop used in ExtractTextPlugin to avoid extracting CSS when code splitting is used and to use a different loader, i.e. the style-loader to generate the <style> node or blob for the ajax call. Any ideas on how to accomplish that? Cheers!


const plugins = [
    //...
    new MiniCssExtractPlugin({
        filename: useVersioning ? '[name].[contenthash:6].css' : '[name].css'
    })
];

const webpackConfig = {
    module: {
        rules: [
            //...
            {
                 test: /\.css$/,
                 use: [
                     MiniCssExtractPlugin.loader,
                     cssLoader
                 ]
             }
        ]
    },
};
Reply

Hi Cluster!

Another great question! I came up against this as well, when upgrading Webpack Encore to v4 of Webpack. I can't remember now everything I researched, but my conclusion is that this fallback option is no longer needed. The reason is that MiniCssExtraPlugin supports "async" chunks out-of-the-box. Basically, if you have an async chunk, it will be extracted to its own CSS file in all cases - e.g. 0.css. Then, when that needs to be loaded, it will be downloaded. It does not inject it via a "style" tag, which I think (not totally sure) was kind of a "hack" to load async chunks with ExtraTextWebpackPlugin. In the README for MiniCssExtraPlugin, they list "Async loading" as one of the advantages over ExtraTextWebpackPlugin.

You can see a bit of how it works in Encore here: https://github.com/symfony/...

Cheers!

Reply
GDIBass Avatar
GDIBass Avatar GDIBass | posted 5 years ago

... nvm I'm an idiot lol

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial explains the concepts of an old version of Webpack using an old version of Symfony. The most important concepts are still the same, but you should expect significant differences in new versions.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.2.0",
        "symfony/symfony": "3.3.*", // v3.3.16
        "twig/twig": "2.10.*", // v2.10.0
        "doctrine/orm": "^2.5", // v2.7.0
        "doctrine/doctrine-bundle": "^1.6", // 1.10.3
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.3.5
        "symfony/swiftmailer-bundle": "^2.3", // v2.6.3
        "symfony/monolog-bundle": "^2.8", // v2.12.1
        "symfony/polyfill-apcu": "^1.0", // v1.4.0
        "sensio/distribution-bundle": "^5.0", // v5.0.22
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.26
        "incenteev/composer-parameter-handler": "^2.0", // v2.1.2
        "friendsofsymfony/user-bundle": "^2.0", // v2.1.2
        "doctrine/doctrine-fixtures-bundle": "~2.3", // v2.4.1
        "doctrine/doctrine-migrations-bundle": "^1.2", // v1.3.2
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "friendsofsymfony/jsrouting-bundle": "^1.6" // 1.6.0
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.1.6
        "symfony/phpunit-bridge": "^3.0" // v3.3.5
    }
}

What JavaScript libraries does this tutorial use?

// package.json
{
    "dependencies": [],
    "devDependencies": {
        "babel-core": "^6.25.0", // 6.25.0
        "babel-loader": "^7.1.1", // 7.1.1
        "babel-plugin-syntax-dynamic-import": "^6.18.0", // 6.18.0
        "babel-preset-env": "^1.6.0", // 1.6.0
        "bootstrap-sass": "^3.3.7", // 3.3.7
        "clean-webpack-plugin": "^0.1.16", // 0.1.16
        "copy-webpack-plugin": "^4.0.1", // 4.0.1
        "core-js": "^2.4.1", // 2.4.1
        "css-loader": "^0.28.4", // 0.28.4
        "extract-text-webpack-plugin": "^3.0.0", // 3.0.0
        "file-loader": "^0.11.2", // 0.11.2
        "font-awesome": "^4.7.0", // 4.7.0
        "jquery": "^3.2.1", // 3.2.1
        "lodash": "^4.17.4", // 4.17.4
        "node-sass": "^4.5.3", // 4.5.3
        "resolve-url-loader": "^2.1.0", // 2.1.0
        "sass-loader": "^6.0.6", // 6.0.6
        "style-loader": "^0.18.2", // 0.18.2
        "sweetalert2": "^6.6.6", // 6.6.6
        "webpack": "^3.4.1", // 3.4.1
        "webpack-chunk-hash": "^0.4.0", // 0.4.0
        "webpack-dev-server": "^2.6.1", // 2.6.1
        "webpack-manifest-plugin": "^1.2.1" // 1.2.1
    }
}
userVoice