Make WordPress Core


Ignore:
Timestamp:
06/26/2023 04:15:12 PM (22 months ago)
Author:
flixos90
Message:

Media: Automatically add fetchpriority="high" to hero image to improve load time performance.

This changeset adds support for the fetchpriority attribute, which is typically added to a single image in each HTML response with a value of "high". This enhances load time performance (also Largest Contentful Paint, or LCP) by telling the browser to prioritize this image for downloading even before the layout of the page has been computed. In lab tests, this has shown to improve LCP performance by ~10% on average.

Specifically, fetchpriority="high" is added to the first image that satisfies all of the following conditions:

  • The image is not lazy-loaded, i.e. does not have loading="lazy".
  • The image does not already have a (conflicting) fetchpriority attribute.
  • The size of of the image (i.e. width * height) is greater than 50,000 squarepixels.

While these heuristics are based on several field analyses, there will always be room for optimization. Sites can customize the squarepixel threshold using a new filter wp_min_priority_img_pixels which should return an integer for the value.

Since the logic for adding fetchpriority="high" is heavily intertwined with the logic for adding loading="lazy", yet the features should work decoupled from each other, the majority of code changes in this changeset is refactoring of the existing lazy-loading logic to be reusable. For this purpose, a new function wp_get_loading_optimization_attributes() has been introduced which returns an associative array of performance-relevant attributes for a given HTML element. This function replaces wp_get_loading_attr_default(), which has been deprecated. As another result of that change, a new function wp_img_tag_add_loading_optimization_attrs() replaces the more specific wp_img_tag_add_loading_attr(), which has been deprecated as well.

See https://make.wordpress.org/core/2023/05/02/proposal-for-enhancing-lcp-image-performance-with-fetchpriority/ for the original proposal and additional context.

Props thekt12, joemcgill, spacedmonkey, mukesh27, costdev, 10upsimon.
Fixes #58235.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/media.php

    r56031 r56037  
    10591059         */
    10601060        $context = apply_filters( 'wp_get_attachment_image_context', 'wp_get_attachment_image' );
    1061 
    1062         // Add `loading` attribute.
    1063         if ( wp_lazy_loading_enabled( 'img', $context ) ) {
    1064             $default_attr['loading'] = wp_get_loading_attr_default( $context );
    1065         }
    1066 
    1067         $attr = wp_parse_args( $attr, $default_attr );
     1061        $attr    = wp_parse_args( $attr, $default_attr );
     1062
     1063        $loading_attr              = $attr;
     1064        $loading_attr['width']     = $width;
     1065        $loading_attr['height']    = $height;
     1066        $loading_optimization_attr = wp_get_loading_optimization_attributes(
     1067            'img',
     1068            $loading_attr,
     1069            $context
     1070        );
     1071
     1072        // Add loading optimization attributes if not available.
     1073        $attr = array_merge( $attr, $loading_optimization_attr );
    10681074
    10691075        // Omit the `decoding` attribute if the value is invalid according to the spec.
     
    10741080        // If the default value of `lazy` for the `loading` attribute is overridden
    10751081        // to omit the attribute for this image, ensure it is not included.
    1076         if ( array_key_exists( 'loading', $attr ) && ! $attr['loading'] ) {
     1082        if ( isset( $attr['loading'] ) && ! $attr['loading'] ) {
    10771083            unset( $attr['loading'] );
     1084        }
     1085
     1086        // If the `fetchpriority` attribute is overridden and set to false or an empty string.
     1087        if ( isset( $attr['fetchpriority'] ) && ! $attr['fetchpriority'] ) {
     1088            unset( $attr['fetchpriority'] );
    10781089        }
    10791090
     
    17811792 * @see wp_img_tag_add_width_and_height_attr()
    17821793 * @see wp_img_tag_add_srcset_and_sizes_attr()
    1783  * @see wp_img_tag_add_loading_attr()
     1794 * @see wp_img_tag_add_loading_optimization_attrs()
    17841795 * @see wp_iframe_tag_add_loading_attr()
    17851796 *
     
    17941805    }
    17951806
    1796     $add_img_loading_attr    = wp_lazy_loading_enabled( 'img', $context );
    17971807    $add_iframe_loading_attr = wp_lazy_loading_enabled( 'iframe', $context );
    17981808
     
    18581868            }
    18591869
    1860             // Add 'loading' attribute if applicable.
    1861             if ( $add_img_loading_attr && ! str_contains( $filtered_image, ' loading=' ) ) {
    1862                 $filtered_image = wp_img_tag_add_loading_attr( $filtered_image, $context );
    1863             }
     1870            // Add loading optimization attributes if applicable.
     1871            $filtered_image = wp_img_tag_add_loading_optimization_attrs( $filtered_image, $context );
    18641872
    18651873            // Add 'decoding=async' attribute unless a 'decoding' attribute is already present.
     
    19151923
    19161924/**
    1917  * Adds `loading` attribute to an `img` HTML tag.
    1918  *
    1919  * @since 5.5.0
     1925 * Adds optimization attributes to an `img` HTML tag.
     1926 *
     1927 * @since 6.3.0
    19201928 *
    19211929 * @param string $image   The HTML `img` tag where the attribute should be added.
    19221930 * @param string $context Additional context to pass to the filters.
    1923  * @return string Converted `img` tag with `loading` attribute added.
    1924  */
    1925 function wp_img_tag_add_loading_attr( $image, $context ) {
    1926     // Get loading attribute value to use. This must occur before the conditional check below so that even images that
    1927     // are ineligible for being lazy-loaded are considered.
    1928     $value = wp_get_loading_attr_default( $context );
    1929 
    1930     // Images should have source and dimension attributes for the `loading` attribute to be added.
     1931 * @return string Converted `img` tag with optimization attributes added.
     1932 */
     1933function wp_img_tag_add_loading_optimization_attrs( $image, $context ) {
     1934    $width             = preg_match( '/ width=["\']([0-9]+)["\']/', $image, $match_width ) ? (int) $match_width[1] : null;
     1935    $height            = preg_match( '/ height=["\']([0-9]+)["\']/', $image, $match_height ) ? (int) $match_height[1] : null;
     1936    $loading_val       = preg_match( '/ loading=["\']([A-Za-z]+)["\']/', $image, $match_loading ) ? $match_loading[1] : null;
     1937    $fetchpriority_val = preg_match( '/ fetchpriority=["\']([A-Za-z]+)["\']/', $image, $match_fetchpriority ) ? $match_fetchpriority[1] : null;
     1938
     1939    /*
     1940     * Get loading optimization attributes to use.
     1941     * This must occur before the conditional check below so that even images
     1942     * that are ineligible for being lazy-loaded are considered.
     1943     */
     1944    $optimization_attrs = wp_get_loading_optimization_attributes(
     1945        'img',
     1946        array(
     1947            'width'         => $width,
     1948            'height'        => $height,
     1949            'loading'       => $loading_val,
     1950            'fetchpriority' => $fetchpriority_val,
     1951        ),
     1952        $context
     1953    );
     1954
     1955    // Images should have source and dimension attributes for the loading optimization attributes to be added.
    19311956    if ( ! str_contains( $image, ' src="' ) || ! str_contains( $image, ' width="' ) || ! str_contains( $image, ' height="' ) ) {
    19321957        return $image;
    19331958    }
    19341959
    1935     /**
    1936      * Filters the `loading` attribute value to add to an image. Default `lazy`.
    1937      *
    1938      * Returning `false` or an empty string will not add the attribute.
    1939      * Returning `true` will add the default value.
    1940      *
    1941      * @since 5.5.0
    1942      *
    1943      * @param string|bool $value   The `loading` attribute value. Returning a falsey value will result in
    1944      *                             the attribute being omitted for the image.
    1945      * @param string      $image   The HTML `img` tag to be filtered.
    1946      * @param string      $context Additional context about how the function was called or where the img tag is.
    1947      */
    1948     $value = apply_filters( 'wp_img_tag_add_loading_attr', $value, $image, $context );
    1949 
    1950     if ( $value ) {
    1951         if ( ! in_array( $value, array( 'lazy', 'eager' ), true ) ) {
    1952             $value = 'lazy';
    1953         }
    1954 
    1955         return str_replace( '<img', '<img loading="' . esc_attr( $value ) . '"', $image );
     1960    // Retained for backward compatibility.
     1961    $loading_attrs_enabled = wp_lazy_loading_enabled( 'img', $context );
     1962
     1963    if ( empty( $loading_val ) && $loading_attrs_enabled ) {
     1964        /**
     1965         * Filters the `loading` attribute value to add to an image. Default `lazy`.
     1966         * This filter is added in for backward compatibility.
     1967         *
     1968         * Returning `false` or an empty string will not add the attribute.
     1969         * Returning `true` will add the default value.
     1970         * `true` and `false` usage supported for backward compatibility.
     1971         *
     1972         * @since 5.5.0
     1973         *
     1974         * @param string|bool $loading Current value for `loading` attribute for the image.
     1975         * @param string      $image   The HTML `img` tag to be filtered.
     1976         * @param string      $context Additional context about how the function was called or where the img tag is.
     1977         */
     1978        $filtered_loading_attr = apply_filters(
     1979            'wp_img_tag_add_loading_attr',
     1980            isset( $optimization_attrs['loading'] ) ? $optimization_attrs['loading'] : false,
     1981            $image,
     1982            $context
     1983        );
     1984
     1985        // Validate the values after filtering.
     1986        if ( isset( $optimization_attrs['loading'] ) && ! $filtered_loading_attr ) {
     1987            // Unset `loading` attributes if `$filtered_loading_attr` is set to `false`.
     1988            unset( $optimization_attrs['loading'] );
     1989        } elseif ( in_array( $filtered_loading_attr, array( 'lazy', 'eager' ), true ) ) {
     1990            /*
     1991             * If the filter changed the loading attribute to "lazy" when a fetchpriority attribute
     1992             * with value "high" is already present, trigger a warning since those two attribute
     1993             * values should be mutually exclusive.
     1994             *
     1995             * The same warning is present in `wp_get_loading_optimization_attributes()`, and here it
     1996             * is only intended for the specific scenario where the above filtered caused the problem.
     1997             */
     1998            if ( isset( $optimization_attrs['fetchpriority'] ) && 'high' === $optimization_attrs['fetchpriority'] &&
     1999                ( isset( $optimization_attrs['loading'] ) ? $optimization_attrs['loading'] : false ) !== $filtered_loading_attr &&
     2000                'lazy' === $filtered_loading_attr
     2001            ) {
     2002                _doing_it_wrong(
     2003                    __FUNCTION__,
     2004                    __( 'An image should not be lazy-loaded and marked as high priority at the same time.' ),
     2005                    '6.3.0'
     2006                );
     2007            }
     2008
     2009            // The filtered value will still be respected.
     2010            $optimization_attrs['loading'] = $filtered_loading_attr;
     2011        }
     2012
     2013        if ( ! empty( $optimization_attrs['loading'] ) ) {
     2014            $image = str_replace( '<img', '<img loading="' . esc_attr( $optimization_attrs['loading'] ) . '"', $image );
     2015        }
     2016    }
     2017
     2018    if ( empty( $fetchpriority_val ) && ! empty( $optimization_attrs['fetchpriority'] ) ) {
     2019        $image = str_replace( '<img', '<img fetchpriority="' . esc_attr( $optimization_attrs['fetchpriority'] ) . '"', $image );
    19562020    }
    19572021
     
    21042168    // Get loading attribute value to use. This must occur before the conditional check below so that even iframes that
    21052169    // are ineligible for being lazy-loaded are considered.
    2106     $value = wp_get_loading_attr_default( $context );
     2170    $optimization_attrs = wp_get_loading_optimization_attributes(
     2171        'iframe',
     2172        array(
     2173            /*
     2174             * The concrete values for width and height are not important here for now
     2175             * since fetchpriority is not yet supported for iframes.
     2176             * TODO: Use WP_HTML_Tag_Processor to extract actual values once support is
     2177             * added.
     2178             */
     2179            'width'   => str_contains( $iframe, ' width="' ) ? 100 : null,
     2180            'height'  => str_contains( $iframe, ' height="' ) ? 100 : null,
     2181            // This function is never called when a 'loading' attribute is already present.
     2182            'loading' => null,
     2183        ),
     2184        $context
     2185    );
    21072186
    21082187    // Iframes should have source and dimension attributes for the `loading` attribute to be added.
     
    21102189        return $iframe;
    21112190    }
     2191
     2192    $value = isset( $optimization_attrs['loading'] ) ? $optimization_attrs['loading'] : false;
    21122193
    21132194    /**
     
    54705551
    54715552/**
    5472  * Gets the default value to use for a `loading` attribute on an element.
    5473  *
    5474  * This function should only be called for a tag and context if lazy-loading is generally enabled.
    5475  *
    5476  * The function usually returns 'lazy', but uses certain heuristics to guess whether the current element is likely to
    5477  * appear above the fold, in which case it returns a boolean `false`, which will lead to the `loading` attribute being
    5478  * omitted on the element. The purpose of this refinement is to avoid lazy-loading elements that are within the initial
    5479  * viewport, which can have a negative performance impact.
    5480  *
    5481  * Under the hood, the function uses {@see wp_increase_content_media_count()} every time it is called for an element
    5482  * within the main content. If the element is the very first content element, the `loading` attribute will be omitted.
    5483  * This default threshold of 3 content elements to omit the `loading` attribute for can be customized using the
    5484  * {@see 'wp_omit_loading_attr_threshold'} filter.
    5485  *
    5486  * @since 5.9.0
     5553 * Gets loading optimization attributes.
     5554 *
     5555 * This function returns an array of attributes that should be merged into the given attributes array to optimize
     5556 * loading performance. Potential attributes returned by this function are:
     5557 * - `loading` attribute with a value of "lazy"
     5558 * - `fetchpriority` attribute with a value of "high"
     5559 *
     5560 * If any of these attributes are already present in the given attributes, they will not be modified. Note that no
     5561 * element should have both `loading="lazy"` and `fetchpriority="high"`, so the function will trigger a warning in case
     5562 * both attributes are present with those values.
     5563 *
     5564 * @since 6.3.0
    54875565 *
    54885566 * @global WP_Query $wp_query WordPress Query object.
    54895567 *
    5490  * @param string $context Context for the element for which the `loading` attribute value is requested.
    5491  * @return string|bool The default `loading` attribute value. Either 'lazy', 'eager', or a boolean `false`, to indicate
    5492  *                     that the `loading` attribute should be skipped.
    5493  */
    5494 function wp_get_loading_attr_default( $context ) {
     5568 * @param string $tag_name The tag name.
     5569 * @param array  $attr     Array of the attributes for the tag.
     5570 * @param string $context  Context for the element for which the loading optimization attribute is requested.
     5571 * @return array Loading optimization attributes.
     5572 */
     5573function wp_get_loading_optimization_attributes( $tag_name, $attr, $context ) {
    54955574    global $wp_query;
    54965575
    5497     // Skip lazy-loading for the overall block template, as it is handled more granularly.
     5576    /*
     5577     * Closure for postprocessing logic.
     5578     * It is here to avoid duplicate logic in many places below, without having
     5579     * to introduce a very specific private global function.
     5580     */
     5581    $postprocess = static function( $loading_attributes, $with_fetchpriority = false ) use ( $tag_name, $attr, $context ) {
     5582        // Potentially add `fetchpriority="high"`.
     5583        if ( $with_fetchpriority ) {
     5584            $loading_attributes = wp_maybe_add_fetchpriority_high_attr( $loading_attributes, $tag_name, $attr );
     5585        }
     5586        // Potentially strip `loading="lazy"` if the feature is disabled.
     5587        if ( isset( $loading_attributes['loading'] ) && ! wp_lazy_loading_enabled( $tag_name, $context ) ) {
     5588            unset( $loading_attributes['loading'] );
     5589        }
     5590        return $loading_attributes;
     5591    };
     5592
     5593    $loading_attrs = array();
     5594
     5595    /*
     5596     * Skip lazy-loading for the overall block template, as it is handled more granularly.
     5597     * The skip is also applicable for `fetchpriority`.
     5598     */
    54985599    if ( 'template' === $context ) {
    5499         return false;
    5500     }
    5501 
    5502     // Do not lazy-load images in the header block template part, as they are likely above the fold.
    5503     // For classic themes, this is handled in the condition below using the 'get_header' action.
     5600        return $loading_attrs;
     5601    }
     5602
     5603    // For now this function only supports images and iframes.
     5604    if ( 'img' !== $tag_name && 'iframe' !== $tag_name ) {
     5605        return $loading_attrs;
     5606    }
     5607
     5608    // For any resources, width and height must be provided, to avoid layout shifts.
     5609    if ( ! isset( $attr['width'], $attr['height'] ) ) {
     5610        return $loading_attrs;
     5611    }
     5612
     5613    if ( isset( $attr['loading'] ) ) {
     5614        /*
     5615         * While any `loading` value could be set in `$loading_attrs`, for
     5616         * consistency we only do it for `loading="lazy"` since that is the
     5617         * only possible value that WordPress core would apply on its own.
     5618         */
     5619        if ( 'lazy' === $attr['loading'] ) {
     5620            $loading_attrs['loading'] = 'lazy';
     5621            if ( isset( $attr['fetchpriority'] ) && 'high' === $attr['fetchpriority'] ) {
     5622                _doing_it_wrong(
     5623                    __FUNCTION__,
     5624                    __( 'An image should not be lazy-loaded and marked as high priority at the same time.' ),
     5625                    '6.3.0'
     5626                );
     5627            }
     5628        }
     5629
     5630        return $postprocess( $loading_attrs, true );
     5631    }
     5632
     5633    // An image with `fetchpriority="high"` cannot be assigned `loading="lazy"` at the same time.
     5634    if ( isset( $attr['fetchpriority'] ) && 'high' === $attr['fetchpriority'] ) {
     5635        return $postprocess( $loading_attrs, true );
     5636    }
     5637
     5638    /*
     5639     * Do not lazy-load images in the header block template part, as they are likely above the fold.
     5640     * For classic themes, this is handled in the condition below using the 'get_header' action.
     5641     */
    55045642    $header_area = WP_TEMPLATE_PART_AREA_HEADER;
    55055643    if ( "template_part_{$header_area}" === $context ) {
    5506         return false;
     5644        return $postprocess( $loading_attrs, true );
    55075645    }
    55085646
    55095647    // Special handling for programmatically created image tags.
    5510     if ( ( 'the_post_thumbnail' === $context || 'wp_get_attachment_image' === $context ) ) {
     5648    if ( 'the_post_thumbnail' === $context || 'wp_get_attachment_image' === $context ) {
    55115649        /*
    55125650         * Skip programmatically created images within post content as they need to be handled together with the other
     
    55165654         */
    55175655        if ( doing_filter( 'the_content' ) ) {
    5518             return false;
     5656            return $postprocess( $loading_attrs, true );
    55195657        }
    55205658
     
    55305668            && did_action( 'get_header' ) && ! did_action( 'get_footer' )
    55315669        ) {
    5532             return false;
     5670            return $postprocess( $loading_attrs, true );
    55335671        }
    55345672    }
     
    55415679        // Only elements within the main query loop have special handling.
    55425680        if ( is_admin() || ! in_the_loop() || ! is_main_query() ) {
    5543             return 'lazy';
     5681            $loading_attrs['loading'] = 'lazy';
     5682            return $postprocess( $loading_attrs, false );
    55445683        }
    55455684
     
    55475686        $content_media_count = wp_increase_content_media_count();
    55485687
    5549         // If the count so far is below the threshold, return `false` so that the `loading` attribute is omitted.
     5688        // If the count so far is below the threshold, `loading` attribute is omitted.
    55505689        if ( $content_media_count <= wp_omit_loading_attr_threshold() ) {
    5551             return false;
    5552         }
    5553 
    5554         // For elements after the threshold, lazy-load them as usual.
    5555         return 'lazy';
     5690            // The first largest image will still get `fetchpriority='high'`.
     5691            return $postprocess( $loading_attrs, true );
     5692        }
    55565693    }
    55575694
    55585695    // Lazy-load by default for any unknown context.
    5559     return 'lazy';
     5696    $loading_attrs['loading'] = 'lazy';
     5697    return $postprocess( $loading_attrs, false );
    55605698}
    55615699
     
    56105748    return $content_media_count;
    56115749}
     5750
     5751/**
     5752 * Determines whether to add `fetchpriority='high'` to loading attributes.
     5753 *
     5754 * @since 6.3.0
     5755 * @access private
     5756 *
     5757 * @param array  $loading_attrs Array of the loading optimization attributes for the element.
     5758 * @param string $tag_name      The tag name.
     5759 * @param array  $attr          Array of the attributes for the element.
     5760 * @return array Updated loading optimization attributes for the element.
     5761 */
     5762function wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr ) {
     5763    // For now, adding `fetchpriority="high"` is only supported for images.
     5764    if ( 'img' !== $tag_name ) {
     5765        return $loading_attrs;
     5766    }
     5767
     5768    if ( isset( $attr['fetchpriority'] ) ) {
     5769        /*
     5770         * While any `fetchpriority` value could be set in `$loading_attrs`,
     5771         * for consistency we only do it for `fetchpriority="high"` since that
     5772         * is the only possible value that WordPress core would apply on its
     5773         * own.
     5774         */
     5775        if ( 'high' === $attr['fetchpriority'] ) {
     5776            $loading_attrs['fetchpriority'] = 'high';
     5777            wp_high_priority_element_flag( false );
     5778        }
     5779        return $loading_attrs;
     5780    }
     5781
     5782    // Lazy-loading and `fetchpriority="high"` are mutually exclusive.
     5783    if ( isset( $loading_attrs['loading'] ) && 'lazy' === $loading_attrs['loading'] ) {
     5784        return $loading_attrs;
     5785    }
     5786
     5787    if ( ! wp_high_priority_element_flag() ) {
     5788        return $loading_attrs;
     5789    }
     5790
     5791    /**
     5792     * Filters the minimum square-pixels threshold for an image to be eligible as the high-priority image.
     5793     *
     5794     * @since 6.3.0
     5795     *
     5796     * @param int $threshold Minimum square-pixels threshold. Default 50000.
     5797     */
     5798    $wp_min_priority_img_pixels = apply_filters( 'wp_min_priority_img_pixels', 50000 );
     5799    if ( $wp_min_priority_img_pixels <= $attr['width'] * $attr['height'] ) {
     5800        $loading_attrs['fetchpriority'] = 'high';
     5801        wp_high_priority_element_flag( false );
     5802    }
     5803    return $loading_attrs;
     5804}
     5805
     5806/**
     5807 * Accesses a flag that indicates if an element is a possible candidate for `fetchpriority='high'`.
     5808 *
     5809 * @since 6.3.0
     5810 * @access private
     5811 *
     5812 * @param bool $value Optional. Used to change the static variable. Default null.
     5813 * @return bool Returns true if high-priority element was marked already, otherwise false.
     5814 */
     5815function wp_high_priority_element_flag( $value = null ) {
     5816    static $high_priority_element = true;
     5817
     5818    if ( is_bool( $value ) ) {
     5819        $high_priority_element = $value;
     5820    }
     5821    return $high_priority_element;
     5822}
Note: See TracChangeset for help on using the changeset viewer.