Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Async Imports

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

Head back to /admin/article. We have a... sort of... "performance" issue here. When you create a new article, we have an author field that uses a bunch of autocomplete JavaScript and CSS. The thing is, if you go back and edit an article, this is purposely not used here.

So, what's the problem? Open admin_article_form.js. We import algolia-autocomplete:

... lines 1 - 4
import autocomplete from './components/algolia-autocomplete';
... lines 6 - 163

And it imports a third-party library and some CSS:

import $ from 'jquery';
import 'autocomplete.js/dist/autocomplete.jquery';
import '../../css/algolia-autocomplete.scss';
... lines 4 - 27

So, it's not a tiny amount of code to get this working. The admin_article_form.js entry file is included on both the new and edit pages. But really, a big chunk of that file is totally unused on the edit page. What a waste!

Conditionally Dependencies?

The problem is that you can't conditionally import things: you can't put an if statement around the import, because Webpack needs to know, at build time, whether or not it should include the content of that import into the final built admin_article_form.js file.

But, this is a real-world problem! For example, suppose that when a user clicks a specific link on your site, a dialog screen pops up that requires a lot of JavaScript and CSS. Cool. But what if most users don't ever click that link? Making all your users download the dialog box JavaScript and CSS when only a few of them will ever need it is a waste! You're slowing down everyone's experience.

We need to be able to lazily load dependencies. And here's how.

Hello Async/Dynamic import()

Copy the file path then delete the import:

... lines 1 - 4
import autocomplete from './components/algolia-autocomplete';
... lines 6 - 163

All imports are normally at the top of the file. But now... down inside the if statement, this is when we know that we need to use that library. Use import() like a function and pass it the path that we want to import.

This works almost exactly like an AJAX call. It's not instant, so it returns a Promise. Add .then() and, for the callback, Webpack will pass us the module that we're importing: autocomplete:

... lines 1 - 7
$(document).ready(function() {
const $autoComplete = $('.js-user-autocomplete');
if (!$autoComplete.is(':disabled')) {
import('./components/algolia-autocomplete').then((autocomplete) => {
... line 12
});
}
... lines 15 - 45
});
... lines 47 - 164

Finish the arrow function, then move the old code inside:

... lines 1 - 7
$(document).ready(function() {
const $autoComplete = $('.js-user-autocomplete');
if (!$autoComplete.is(':disabled')) {
import('./components/algolia-autocomplete').then((autocomplete) => {
autocomplete($autoComplete, 'users', 'email');
});
}
... lines 15 - 45
});
... lines 47 - 164

So, it will hit our import code, download the JavaScript - just like an AJAX call - and when it finishes, call our function. And, because the "traditional" import call is gone from the top of the file, the autocomplete stuff won't be included in admin_article_form.js. That entry file just got smaller. That's freakin' awesome!

By the way, if we were running the code, like, after a user clicked something, there would be a small delay while the JavaScript was being downloaded. To make the experience fluid, you could add a loading animation before the import() call and stop it inside the callback.

Ok, let's try this! Go back to /admin/article/new. And... oh!

autocomplete is not a function

Using module_name.default

in article_form.js. So... this is a little bit of a gotcha. If your module uses the newer, trendier, export default syntax:

... lines 1 - 4
export default function($elements, dataKey, displayKey) {
... lines 6 - 25
};

When you use "async" or "dynamic" imports, you need to say autocomplete.default() in the callback:

... lines 1 - 7
$(document).ready(function() {
const $autoComplete = $('.js-user-autocomplete');
if (!$autoComplete.is(':disabled')) {
import('./components/algolia-autocomplete').then((autocomplete) => {
autocomplete.default($autoComplete, 'users', 'email');
});
}
... lines 15 - 45
});
... lines 47 - 164

Move back over and refresh again. No errors! And it works! But also, look at the Network tab - filter for "scripts". It downloaded 1.js and 0.js. The 1.js file contains the autocomplete vendor library and 0.js contains our JavaScript. It loaded this lazily and it's even "code splitting" our lazy JavaScript into two files... which is kinda crazy. The 0.js also contains the CSS... well, it says it does... but it's not really there. Because, in the CSS tab, it's loaded via its own 0.css file.

If you look at the DOM, you can even see how Webpack hacked the script and link tags into the head of our page: these were not there on page-load.

So... dynamic imports... just work! And you can imagine how powerful this could be in a single page application where you can asynchronously load the components for a page when the user goes to that page... instead of having one gigantic JavaScript file for your whole site.

By the way, the dynamic import syntax can be even simpler if you use the await keyword and some fancy destructuring. You'll also need to install a library called regenerator-runtime. Check out the code on this page for an example.

// and run: yarn add regenerator-runtime --dev

async function initializeAutocomplete($autoComplete) {
    const { default: autocomplete } = await import('./components/algolia-autocomplete');

    autocomplete($autoComplete, 'users', 'email');
}

$(document).ready(function() {
    const $autoComplete = $('.js-user-autocomplete');
    if (!$autoComplete.is(':disabled')) {
        initializeAutocomplete($autoComplete);
    }

    // ...
}

Next: there's just one more thing to talk about: how to build our assets for production, and some tips on deployment.

Leave a comment!

4
Login or Register to join the conversation
halifaxious Avatar
halifaxious Avatar halifaxious | posted 3 years ago

I've been importing TinyMCE for all my forms and it works fine. But it's large so I thought I'd import it asynchronously instead. My async call is downloading the code and it's running something (because my textboxes disappear). But TinyMCE is no longer working. I think I'm calling it incorrectly. No matter what variation of tinyMCE + default that I try I get 'xxx is not a function'. Here is my code. I appreciate any guidance you can offer.

components/inittinymce.js

import $ from 'jquery';


import tinymce from 'tinymce';


import 'tinymce/themes/silver';
import 'tinymce-i18n/langs5/en_CA';
import 'tinymce-i18n/langs5/fr_FR';
import 'tinymce/plugins/lists';
//lots of plugins...
import 'tinymce/plugins/paste';


const mcelang = $('html').attr('lang') == 'en' ? 'en_CA': 'fr_FR';


//init tinymce object
tinymce.init({
selector: 'textarea.tinymce',
menubar: false,
plugins: [
'advlist autolink lists link image charmap ',
'searchreplace visualblocks',
'insertdatetime table paste'
],
toolbar: 'undo redo searchreplace paste | styleselect | bold italic charmap| alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image insertdatetime table ',
contextmenu: "link image imagetools table spellchecker",
language: mcelang


});


export default tinymce;

form.js entrypoint with async import of TinyMCE

import $ from 'jquery';
import '../../public/bundles/acrdutility/js/collection';
// import './components/inittinymce'; // removed in favour of async call


if($('textarea.tinymce').length>0){
import('./components/inittinymce').then((tinymce) => { tinymce.default.tinymce();}); //all variations of function name throw "no such function error"
}

Reply

Hey halifaxious,

As a workaround, I think you can add an additional check in PHP or in your template and selectively include or not the TimeMCE. But what about your problem, not sure exactly. I'd recommend you to look into issues in their official github repo, probably someone already asked about it and know the solution. So, it works when you always load it? I mean, when your code is:


import $ from 'jquery';
import '../../public/bundles/acrdutility/js/collection';
import tinymce from './components/inittinymce'; // removed in favour of async call

tinymce();

If so, then inside your if statement the code should be like this:


if($('textarea.tinymce').length>0) {
import('./components/inittinymce').then((tinymce) => {
tinymce.default();
}); //all variations of function name throw "no such function error"
}

i.e. instead of "tinymce.default.tinymce();" you need just "tinymce.default();". Or do you have a different working code when you TinyMCE in all cases? Then, what if you dump the "tinymce" variable in that then() callback? Something like this


if($('textarea.tinymce').length>0) {
import('./components/inittinymce').then((tinymce) => {
console.log(tinymce);

tinymce.default.tinymce();
}); //all variations of function name throw "no such function error"
}

What do you see in Console tab?

Also, I'd recommend you to wait until the page is loaded, i.e. do it inside of "$(document).ready()":


import $ from 'jquery';
import '../../public/bundles/acrdutility/js/collection';
// import './components/inittinymce'; // removed in favour of async call

$(document).ready(function() {
if($('textarea.tinymce').length>0){
import('./components/inittinymce').then((tinymce) => { tinymce.default.tinymce();}); //all variations of function name throw "no such function error"
}
});

I hope this helps!

Cheers!

Reply
halifaxious Avatar
halifaxious Avatar halifaxious | victor | posted 3 years ago

Thanks Victor. I've fixed my code so that I call the correct function. But TinyMCE is still not initializing properly-- I get an invisible widget. I've submitted a bug report here, https://github.com/tinymce/.... It includes all my current code so hopefully the tinyMCE people will know what's going on. There didn't appear to be any other similar issues posted.

Reply

Hey halifaxious ,

Good idea, thanks for the link to your issue, it might be helpful for other users.

Cheers!

Reply
Cat in space

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

This tutorial works great with Symfony5!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "aws/aws-sdk-php": "^3.87", // 3.91.4
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.1
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.9.0
        "league/flysystem-aws-s3-v3": "^1.0", // 1.0.22
        "league/flysystem-cached-adapter": "^1.0", // 1.0.9
        "liip/imagine-bundle": "^2.1", // 2.1.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.1.0
        "oneup/flysystem-bundle": "^3.0", // 3.0.3
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.3.1
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.2.5
        "symfony/console": "^4.0", // v4.2.5
        "symfony/flex": "^1.9", // v1.17.6
        "symfony/form": "^4.0", // v4.2.5
        "symfony/framework-bundle": "^4.0", // v4.2.5
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.2.5
        "symfony/serializer-pack": "^1.0", // v1.0.2
        "symfony/twig-bundle": "^4.0", // v4.2.5
        "symfony/validator": "^4.0", // v4.2.5
        "symfony/web-server-bundle": "^4.0", // v4.2.5
        "symfony/webpack-encore-bundle": "^1.4", // v1.5.0
        "symfony/yaml": "^4.0", // v4.2.5
        "twig/extensions": "^1.5" // v1.5.4
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.1.0
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.2.5
        "symfony/dotenv": "^4.0", // v4.2.5
        "symfony/maker-bundle": "^1.0", // v1.11.5
        "symfony/monolog-bundle": "^3.0", // v3.3.1
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.2.5
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/var-dumper": "^3.3|^4.0" // v4.2.5
    }
}

What JavaScript libraries does this tutorial use?

// package.json
{
    "devDependencies": {
        "@symfony/webpack-encore": "^0.27.0", // 0.27.0
        "autocomplete.js": "^0.36.0",
        "autoprefixer": "^9.5.1", // 9.5.1
        "bootstrap": "^4.3.1", // 4.3.1
        "core-js": "^3.0.0", // 3.0.1
        "dropzone": "^5.5.1", // 5.5.1
        "font-awesome": "^4.7.0", // 4.7.0
        "jquery": "^3.4.0", // 3.4.0
        "popper.js": "^1.15.0",
        "postcss-loader": "^3.0.0", // 3.0.0
        "sass": "^1.29.0", // 1.29.0
        "sass-loader": "^7.0.1", // 7.3.1
        "sortablejs": "^1.8.4", // 1.8.4
        "webpack-notifier": "^1.6.0" // 1.7.0
    }
}