Async Imports
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.
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 <i>something</i> (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.
<b>components/inittinymce.js</b>
`
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: [
],
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;
`
<b>form.js entrypoint with async import of TinyMCE</b>
`
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"
}
`