-
Notifications
You must be signed in to change notification settings - Fork 143
Defer enqueued scripts with an experimental toggle #102
Description
Background
wp_enqueue_script and friends are the recommended way of loading JavaScript in WordPress. Besides the loading aspect itself, these methods manage dependencies so that scripts are loaded in the correct sequence, with dependencies executing before their dependants, provided they're correctly declared.
The mechanism through which this is accomplished on the browser is the use of blocking scripts:
<script type='text/javascript' src='dependency.js?ver=1.0.0' id='dependency-js'></script>
<script type='text/javascript' src='dependant.js?ver=1.0.0' id='dependant-js'></script>Blocking scripts are problematic for performance, because they block document parsing and rendering when they're reached. This means that if a blocking script is in <head>, for example, the document will remain completely unrendered while the script is fetched, parsed, compiled, and executed, likely resulting in worse scores in metrics like First Paint, First Contentful Paint, and Largest Contentful Paint.
One way of avoiding some of these issues is to move the scripts to the footer, which can be done with the $in_footer parameter in wp_enqueue_script. However, this is not an ideal solution from a performance point of view, since from an API perspective it can lead to problems if dependencies and dependants have different values and expectations for $in_footer.
Using the defer attribute
While the enqueue mechanism uses the default behaviour blocking scripts, two other timing options exist on the web: deferred scripts and async scripts. Async scripts execute as soon as they are fetched, thus not maintaining ordering, which means they're unfit for the purpose of managed dependencies (and exhibit generally unpredictable performance). Deferred scripts, however, wait until the DOMContentLoaded event to execute, and maintain ordering amongst themselves, thus offering good default performance characteristics (by giving the document a chance to render) while being suitable for a dependency management mechanism.
Deferred scripts are usually created with the defer attribute:
<script defer type='text/javascript' src='dependency.js?ver=1.0.0' id='dependency-js'></script>
<script defer type='text/javascript' src='dependant.js?ver=1.0.0' id='dependant-js'></script>Since modifying the script tags inserted by wp_enqueue_script is somewhat difficult, developers don't generally do this themselves, with plugins such as perfmatters attempting to do this automatically for the user.
Challenges with deferring scripts
The biggest issue with deferring scripts is that the defer attribute only works with external scripts. Inline scripts ignore the attribute and thus behave as blocking scripts, executing earlier. The following code would thus fail, with the inline code executing before the dependency:
<script defer type='text/javascript' src='dependency.js?ver=1.0.0' id='dependency-js'></script>
<script defer>
// Use dependency.js global
window.dependencyJS.doSomething(); // Fails, because it's not ready yet.
</script>Until somewhat recently, the only practical ways around this would have been to either keep everything as external scripts (which would have been problematic for internationalisation or any other scripts that have conditional content on the server side); or instead to register event listeners that would have waited for the dependency to load, and then wrap the inline code in a function that would have waited to be called (which would have been challenging, because it means modifying the inline scripts).
However, with the advent of ES modules and type="module", we can make use of a side-effect of these new features.
Module scripts
Realising that blocking scripts are a bad performance default, module scripts were standardised to use deferred semantics instead, both when loaded externally and when inlined. This means that even though module scripts were not created with the purpose of allowing for deferred inline scripts, we can take advantage of that standardisation choice in order to enable that use case.
This means that we could now have a full, working mechanism to enable deferring both external and inline scripts, while preserving ordering:
<script defer type='text/javascript' src='dependency.js?ver=1.0.0' id='dependency-js'></script>
<script type="module">
// Use dependency.js global
window.dependencyJS.doSomething(); // Works!
</script>However, module scripts come with a few additional restrictions, beyond the use of deferred semantics:
- They don't work cross-origin without the use of CORS headers. This is not an issue for inline scripts, but it does prevent their usage as a general replacement for external scripts. Thankfully, we have
deferfor that. - They need a correctly set mime type. This shouldn't generally be a problem, as far as I know, unless you have a misbehaving plugin that's breaking JS mime types.
- Top-level
thisevaluates toundefinedinstead ofwindow. This is likely not a problem for most scripts, although it should be noted. - They always use strict mode. While classic scripts default to "sloppy mode" and need an explicit
'use strict';to be executed in strict mode, module scripts are always in strict mode. Some scripts will break when forced to use strict mode. - They don't work in older browsers. They do work in all of the supported browsers for WordPress, with the possible exception of a few browsers hovering around 1% usage, such as Opera Mini.
- Top-level variable declarations are specific to a module, rather than being placed in
window. This means that if a script wants to create a new global, it needs to explicitly dowindow.foo = 'bar'rather than simplyvar foo = 'bar'at the top level. - Modules are only executed once, so if you're expecting to load the same script multiple times and have it run multiple times, that won't work. This doesn't really apply to inline scripts, since they're defined in-place and don't reference a unique external location.
A possible general solution
Taking all of the above into account, a possible solution that would work for most use-cases would be to:
- Add the
deferattribute to every script enqueued viawp_enqueue_script - Add
type="module"to every script inlined bywp_localize_script,wp_add_inline_script, or any other core method that adds an inline script to the document.
In modern browsers, this would generally only break in situations where an inlined script doesn't work correctly in strict mode, when it's expecting top-level this to evaluate to window, or when it's expecting to set globals with a top-level variable declaration. I expect this to be a reasonable opt-in compromise.
This could be implemented as a simple toggle in the performance plugin, which the user could optionally enable, modifying the core enqueuing behaviour as a whole. If the user ran into one of the compatibility issues above, or if they intend to keep supporting old browsers on a best-effort basis, they could simply keep the switch toggled off.
Edit: to clarify, this toggle is not meant as a general, long-term solution, nor something that would eventually be promoted to Core. It would simply exist as an experimental option in the performance plugin, that may well improve performance for a site, but may also break it as a result.
Safeguard considerations
In order to avoid accidentally breaking admin pages in case a compatibility issue exists, we could ensure that admin pages were always excluded from this optimisation, and thus kept their existing blocking behaviour, particularly since performance optimisation is not as important there.
Another option would be to have a global safety URL parameter that would disable the optimisation (e.g. disable_js_defer), although this would be much less discoverable and hard to stumble upon as a solution when your site is broken. It may be a good complement to the above, however, and particularly useful for debugging compatibility problems.
Reference and thanks
- JavaScript Modules, MDN Web Docs
<script defer>MDN Web Docs<script type=module>support, Can I Use
Thank you to Khoi Pro for suggesting looking into this as part of our JS issue prioritisation!
This issue covers the card in https://github.com/WordPress/performance/projects/3#card-75561256.