Script Loader: Add support for HTML 5 "async" and "defer" attributes.

This allows developers to register scripts with an intended loading strategy by changing the `$in_footer` parameter of `wp_register_script` and `wp_enqueue_script` to an array that accepts both an `in_footer` and `strategy` argument. If present, the loading strategy attribute will be added to the script tag when that script is printed to the page as long as it is not a dependency of any blocking scripts, including any inline scripts attached to the script or any of its dependents.

Props 10upsimon, thekt12, westonruter, costdev, flixos90, spacedmonkey, adamsilverstein, azaozz, mukeshpanchal27, mor10, scep, wpnook, vanaf1979, Otto42.
Fixes #12009.

Built from https://develop.svn.wordpress.org/trunk@56033


git-svn-id: http://core.svn.wordpress.org/trunk@55545 1a063a9b-81f0-0310-95a4-ce76da25c4cd
This commit is contained in:
Joe McGill 2023-06-26 13:42:23 +00:00
parent 28e2911898
commit 53e7cc3ddf
3 changed files with 354 additions and 41 deletions

View File

@ -133,6 +133,24 @@ class WP_Scripts extends WP_Dependencies {
*/
private $type_attr = '';
/**
* Holds a mapping of dependents (as handles) for a given script handle.
* Used to optimize recursive dependency tree checks.
*
* @since 6.3.0
* @var array
*/
private $dependents_map = array();
/**
* Holds a reference to the delayed (non-blocking) script loading strategies.
* Used by methods that validate loading strategies.
*
* @since 6.3.0
* @var string[]
*/
private $delayed_strategies = array( 'defer', 'async' );
/**
* Constructor.
*
@ -285,28 +303,26 @@ class WP_Scripts extends WP_Dependencies {
}
$src = $obj->src;
$strategy = $this->get_eligible_loading_strategy( $handle );
$intended_strategy = (string) $this->get_data( $handle, 'strategy' );
$cond_before = '';
$cond_after = '';
$conditional = isset( $obj->extra['conditional'] ) ? $obj->extra['conditional'] : '';
if ( ! $this->is_delayed_strategy( $intended_strategy ) ) {
$intended_strategy = '';
}
if ( $conditional ) {
$cond_before = "<!--[if {$conditional}]>\n";
$cond_after = "<![endif]-->\n";
}
$before_handle = $this->print_inline_script( $handle, 'before', false );
$after_handle = $this->print_inline_script( $handle, 'after', false );
$before_script = $this->get_inline_script_tag( $handle, 'before' );
$after_script = $this->get_inline_script_tag( $handle, 'after' );
if ( $before_handle ) {
$before_handle = sprintf( "<script%s id='%s-js-before'>\n%s\n</script>\n", $this->type_attr, esc_attr( $handle ), $before_handle );
}
if ( $after_handle ) {
$after_handle = sprintf( "<script%s id='%s-js-after'>\n%s\n</script>\n", $this->type_attr, esc_attr( $handle ), $after_handle );
}
if ( $before_handle || $after_handle ) {
$inline_script_tag = $cond_before . $before_handle . $after_handle . $cond_after;
if ( $before_script || $after_script ) {
$inline_script_tag = $cond_before . $before_script . $after_script . $cond_after;
} else {
$inline_script_tag = '';
}
@ -333,7 +349,10 @@ class WP_Scripts extends WP_Dependencies {
*/
$srce = apply_filters( 'script_loader_src', $src, $handle );
if ( $this->in_default_dir( $srce ) && ( $before_handle || $after_handle || $translations_stop_concat ) ) {
if (
$this->in_default_dir( $srce )
&& ( $before_script || $after_script || $translations_stop_concat || $this->is_delayed_strategy( $strategy ) )
) {
$this->do_concat = false;
// Have to print the so-far concatenated scripts right away to maintain the right order.
@ -390,9 +409,16 @@ class WP_Scripts extends WP_Dependencies {
return true;
}
$tag = $translations . $cond_before . $before_handle;
$tag .= sprintf( "<script%s src='%s' id='%s-js'></script>\n", $this->type_attr, $src, esc_attr( $handle ) );
$tag .= $after_handle . $cond_after;
$tag = $translations . $cond_before . $before_script;
$tag .= sprintf(
"<script%s src='%s' id='%s-js'%s%s></script>\n",
$this->type_attr,
$src, // Value is escaped above.
esc_attr( $handle ),
$strategy ? " {$strategy}" : '',
$intended_strategy ? " data-wp-strategy='{$intended_strategy}'" : ''
);
$tag .= $after_script . $cond_after;
/**
* Filters the HTML script tag of an enqueued script.
@ -445,29 +471,97 @@ class WP_Scripts extends WP_Dependencies {
* Prints inline scripts registered for a specific handle.
*
* @since 4.5.0
* @deprecated 6.3.0 Use methods get_inline_script_tag() or get_inline_script_data() instead.
*
* @param string $handle Name of the script to add the inline script to.
* @param string $handle Name of the script to print inline scripts for.
* Must be lowercase.
* @param string $position Optional. Whether to add the inline script
* before the handle or after. Default 'after'.
* @param bool $display Optional. Whether to print the script
* instead of just returning it. Default true.
* @return string|false Script on success, false otherwise.
* @param bool $display Optional. Whether to print the script tag
* instead of just returning the script data. Default true.
* @return string|false Script data on success, false otherwise.
*/
public function print_inline_script( $handle, $position = 'after', $display = true ) {
$output = $this->get_data( $handle, $position );
_deprecated_function( __METHOD__, '6.3.0', 'WP_Scripts::get_inline_script_data() or WP_Scripts::get_inline_script_tag()' );
$output = $this->get_inline_script_data( $handle, $position );
if ( empty( $output ) ) {
return false;
}
$output = trim( implode( "\n", $output ), "\n" );
if ( $display ) {
printf( "<script%s id='%s-js-%s'>\n%s\n</script>\n", $this->type_attr, esc_attr( $handle ), esc_attr( $position ), $output );
echo $this->get_inline_script_tag( $handle, $position );
}
return $output;
}
return $output;
/**
* Gets data for inline scripts registered for a specific handle.
*
* @since 6.3.0
*
* @param string $handle Name of the script to get data for.
* Must be lowercase.
* @param string $position Optional. Whether to add the inline script
* before the handle or after. Default 'after'.
* @return string Inline script, which may be empty string.
*/
public function get_inline_script_data( $handle, $position = 'after' ) {
$data = $this->get_data( $handle, $position );
if ( empty( $data ) || ! is_array( $data ) ) {
return '';
}
return trim( implode( "\n", $data ), "\n" );
}
/**
* Gets unaliased dependencies.
*
* An alias is a dependency whose src is false. It is used as a way to bundle multiple dependencies in a single
* handle. This in effect flattens an alias dependency tree.
*
* @since 6.3.0
*
* @param string[] $deps Dependency handles.
* @return string[] Unaliased handles.
*/
private function get_unaliased_deps( array $deps ) {
$flattened = array();
foreach ( $deps as $dep ) {
if ( ! isset( $this->registered[ $dep ] ) ) {
continue;
}
if ( $this->registered[ $dep ]->src ) {
$flattened[] = $dep;
} elseif ( $this->registered[ $dep ]->deps ) {
array_push( $flattened, ...$this->get_unaliased_deps( $this->registered[ $dep ]->deps ) );
}
}
return $flattened;
}
/**
* Gets tags for inline scripts registered for a specific handle.
*
* @since 6.3.0
*
* @param string $handle Name of the script to get associated inline script tag for.
* Must be lowercase.
* @param string $position Optional. Whether to get tag for inline
* scripts in the before or after position. Default 'after'.
* @return string Inline script, which may be empty string.
*/
public function get_inline_script_tag( $handle, $position = 'after' ) {
$js = $this->get_inline_script_data( $handle, $position );
if ( empty( $js ) ) {
return '';
}
$id = "{$handle}-js-{$position}";
return wp_get_inline_script_tag( $js, compact( 'id' ) );
}
/**
@ -714,6 +808,199 @@ JS;
return false;
}
/**
* This overrides the add_data method from WP_Dependencies, to support normalizing of $args.
*
* @since 6.3.0
*
* @param string $handle Name of the item. Should be unique.
* @param string $key The data key.
* @param mixed $value The data value.
* @return bool True on success, false on failure.
*/
public function add_data( $handle, $key, $value ) {
if ( ! isset( $this->registered[ $handle ] ) ) {
return false;
}
if ( 'strategy' === $key ) {
if ( ! empty( $value ) && ! $this->is_delayed_strategy( $value ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: 1: $strategy, 2: $handle */
__( 'Invalid strategy `%1$s` defined for `%2$s` during script registration.' ),
$value,
$handle
),
'6.3.0'
);
return false;
} elseif ( ! $this->registered[ $handle ]->src && $this->is_delayed_strategy( $value ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: 1: $strategy, 2: $handle */
__( 'Cannot supply a strategy `%1$s` for script `%2$s` because it is an alias (it lacks a `src` value).' ),
$value,
$handle
),
'6.3.0'
);
return false;
}
}
return parent::add_data( $handle, $key, $value );
}
/**
* Gets all dependents of a script.
*
* @since 6.3.0
*
* @param string $handle The script handle.
* @return string[] Script handles.
*/
private function get_dependents( $handle ) {
// Check if dependents map for the handle in question is present. If so, use it.
if ( isset( $this->dependents_map[ $handle ] ) ) {
return $this->dependents_map[ $handle ];
}
$dependents = array();
// Iterate over all registered scripts, finding dependents of the script passed to this method.
foreach ( $this->registered as $registered_handle => $args ) {
if ( in_array( $handle, $args->deps, true ) ) {
$dependents[] = $registered_handle;
}
}
// Add the handles dependents to the map to ease future lookups.
$this->dependents_map[ $handle ] = $dependents;
return $dependents;
}
/**
* Checks if the strategy passed is a valid delayed (non-blocking) strategy.
*
* @since 6.3.0
*
* @param string $strategy The strategy to check.
* @return bool True if $strategy is one of the delayed strategies, otherwise false.
*/
private function is_delayed_strategy( $strategy ) {
return in_array(
$strategy,
$this->delayed_strategies,
true
);
}
/**
* Gets the best eligible loading strategy for a script.
*
* @since 6.3.0
*
* @param string $handle The script handle.
* @return string The best eligible loading strategy.
*/
private function get_eligible_loading_strategy( $handle ) {
$eligible = $this->filter_eligible_strategies( $handle );
// Bail early once we know the eligible strategy is blocking.
if ( empty( $eligible ) ) {
return '';
}
return in_array( 'async', $eligible, true ) ? 'async' : 'defer';
}
/**
* Filter the list of eligible loading strategies for a script.
*
* @since 6.3.0
*
* @param string $handle The script handle.
* @param string[]|null $eligible Optional. The list of strategies to filter. Default null.
* @param array<string, true> $checked Optional. An array of already checked script handles, used to avoid recursive loops.
* @return string[] A list of eligible loading strategies that could be used.
*/
private function filter_eligible_strategies( $handle, $eligible = null, $checked = array() ) {
// If no strategies are being passed, all strategies are eligible.
if ( null === $eligible ) {
$eligible = $this->delayed_strategies;
}
// If this handle was already checked, return early.
if ( isset( $checked[ $handle ] ) ) {
return $eligible;
}
// Mark this handle as checked.
$checked[ $handle ] = true;
// If this handle isn't registered, don't filter anything and return.
if ( ! isset( $this->registered[ $handle ] ) ) {
return $eligible;
}
// If the handle is not enqueued, don't filter anything and return.
if ( ! $this->query( $handle, 'enqueued' ) ) {
return $eligible;
}
$is_alias = (bool) ! $this->registered[ $handle ]->src;
$intended_strategy = $this->get_data( $handle, 'strategy' );
// For non-alias handles, an empty intended strategy filters all strategies.
if ( ! $is_alias && empty( $intended_strategy ) ) {
return array();
}
// Handles with inline scripts attached in the 'after' position cannot be delayed.
if ( $this->has_inline_script( $handle, 'after' ) ) {
return array();
}
// If the intended strategy is 'defer', filter out 'async'.
if ( 'defer' === $intended_strategy ) {
$eligible = array( 'defer' );
}
$dependents = $this->get_dependents( $handle );
// Recursively filter eligible strategies for dependents.
foreach ( $dependents as $dependent ) {
// Bail early once we know the eligible strategy is blocking.
if ( empty( $eligible ) ) {
return array();
}
$eligible = $this->filter_eligible_strategies( $dependent, $eligible, $checked );
}
return $eligible;
}
/**
* Gets data for inline scripts registered for a specific handle.
*
* @since 6.3.0
*
* @param string $handle Name of the script to get data for. Must be lowercase.
* @param string $position The position of the inline script.
* @return bool Whether the handle has an inline script (either before or after).
*/
private function has_inline_script( $handle, $position = null ) {
if ( $position && in_array( $position, array( 'before', 'after' ), true ) ) {
return (bool) $this->get_data( $handle, $position );
}
return (bool) ( $this->get_data( $handle, 'before' ) || $this->get_data( $handle, 'after' ) );
}
/**
* Resets class properties.
*

View File

@ -157,6 +157,7 @@ function wp_add_inline_script( $handle, $data, $position = 'after' ) {
*
* @since 2.1.0
* @since 4.3.0 A return value was added.
* @since 6.3.0 The $in_footer parameter of type boolean was overloaded to be an $args parameter of type array.
*
* @param string $handle Name of the script. Should be unique.
* @param string|false $src Full URL of the script, or path of the script relative to the WordPress root directory.
@ -166,20 +167,32 @@ function wp_add_inline_script( $handle, $data, $position = 'after' ) {
* as a query string for cache busting purposes. If version is set to false, a version
* number is automatically added equal to current installed WordPress version.
* If set to null, no version is added.
* @param bool $in_footer Optional. Whether to enqueue the script before `</body>` instead of in the `<head>`.
* Default 'false'.
* @param array|bool $args {
* Optional. An array of additional script loading strategies. Default empty array.
* Otherwise, it may be a boolean in which case it determines whether the script is printed in the footer. Default false.
*
* @type string $strategy Optional. If provided, may be either 'defer' or 'async'.
* @type bool $in_footer Optional. Whether to print the script in the footer. Default 'false'.
* }
* @return bool Whether the script has been registered. True on success, false on failure.
*/
function wp_register_script( $handle, $src, $deps = array(), $ver = false, $in_footer = false ) {
function wp_register_script( $handle, $src, $deps = array(), $ver = false, $args = array() ) {
if ( ! is_array( $args ) ) {
$args = array(
'in_footer' => (bool) $args,
);
}
_wp_scripts_maybe_doing_it_wrong( __FUNCTION__, $handle );
$wp_scripts = wp_scripts();
$registered = $wp_scripts->add( $handle, $src, $deps, $ver );
if ( $in_footer ) {
if ( ! empty( $args['in_footer'] ) ) {
$wp_scripts->add_data( $handle, 'group', 1 );
}
if ( ! empty( $args['strategy'] ) ) {
$wp_scripts->add_data( $handle, 'strategy', $args['strategy'] );
}
return $registered;
}
@ -331,6 +344,7 @@ function wp_deregister_script( $handle ) {
* @see WP_Dependencies::enqueue()
*
* @since 2.1.0
* @since 6.3.0 The $in_footer parameter of type boolean was overloaded to be an $args parameter of type array.
*
* @param string $handle Name of the script. Should be unique.
* @param string $src Full URL of the script, or path of the script relative to the WordPress root directory.
@ -340,24 +354,36 @@ function wp_deregister_script( $handle ) {
* as a query string for cache busting purposes. If version is set to false, a version
* number is automatically added equal to current installed WordPress version.
* If set to null, no version is added.
* @param bool $in_footer Optional. Whether to enqueue the script before `</body>` instead of in the `<head>`.
* Default 'false'.
* @param array|bool $args {
* Optional. An array of additional script loading strategies. Default empty array.
* Otherwise, it may be a boolean in which case it determines whether the script is printed in the footer. Default false.
*
* @type string $strategy Optional. If provided, may be either 'defer' or 'async'.
* @type bool $in_footer Optional. Whether to print the script in the footer. Default 'false'.
* }
*/
function wp_enqueue_script( $handle, $src = '', $deps = array(), $ver = false, $in_footer = false ) {
function wp_enqueue_script( $handle, $src = '', $deps = array(), $ver = false, $args = array() ) {
_wp_scripts_maybe_doing_it_wrong( __FUNCTION__, $handle );
$wp_scripts = wp_scripts();
if ( $src || $in_footer ) {
if ( $src || ! empty( $args ) ) {
$_handle = explode( '?', $handle );
if ( ! is_array( $args ) ) {
$args = array(
'in_footer' => (bool) $args,
);
}
if ( $src ) {
$wp_scripts->add( $_handle[0], $src, $deps, $ver );
}
if ( $in_footer ) {
if ( ! empty( $args['in_footer'] ) ) {
$wp_scripts->add_data( $_handle[0], 'group', 1 );
}
if ( ! empty( $args['strategy'] ) ) {
$wp_scripts->add_data( $_handle[0], 'strategy', $args['strategy'] );
}
}
$wp_scripts->enqueue( $handle );

View File

@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
$wp_version = '6.3-alpha-56032';
$wp_version = '6.3-alpha-56033';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.