Make WordPress Core

Changeset 52065


Ignore:
Timestamp:
11/09/2021 12:34:17 AM (5 years ago)
Author:
flixos90
Message:

Media: Refine the heuristics to exclude certain images and iframes from being lazy-loaded to improve performance.

This changeset implements the refined lazy-loading behavior outlined in https://make.wordpress.org/core/2021/07/15/refining-wordpress-cores-lazy-loading-implementation/ in order to improve the Largest Contentful Paint metric, which can see a regression from images or iframes above the fold being lazy-loaded. Adjusting this so far has been possible for developers via filters and still is, however this enhancement brings a more accurate behavior out of the box for the majority of themes.

Specifically, this changeset skips the very first "content image or iframe" on the page from being lazy-loaded. "Content image or iframe" denotes any image or iframe that is found within content of any post in the current main query loop as well as any featured image of such a post. This applies both to "singular" as well as "archive" content: On a "singular" page the first image/iframe of the post is not lazy-loaded, while on an "archive" page the first image/iframe of the _first_ post in the query is not lazy-loaded.

This approach refines the lazy-loading behavior correctly for the majority of themes, which use a single-column layout for post content. For themes with multi-column layouts, a new wp_omit_loading_attr_threshold filter can be used to change how many of the first images/iframes are being skipped from lazy-loaded (default is 1). For example, a theme using a three-column grid of latest posts for archives could use the filter to override the threshold to 3 on archive pages, so that the first three content images/iframes would not be lazy-loaded.

Props adamsilverstein, azaozz, flixos90, hellofromtonya, jonoaldersonwp, mte90, rviscomi, tweetythierry, westonruter.
Fixes #53675. See #50425.

Location:
trunk
Files:
4 edited

Legend:

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

    r51903 r52065  
    10471047                // Add `loading` attribute.
    10481048                if ( wp_lazy_loading_enabled( 'img', 'wp_get_attachment_image' ) ) {
    1049                         $default_attr['loading'] = 'lazy';
     1049                        $default_attr['loading'] = wp_get_loading_attr_default( 'wp_get_attachment_image' );
    10501050                }
    10511051
     
    18211821        }
    18221822
    1823         foreach ( $images as $image => $attachment_id ) {
    1824                 $filtered_image = $image;
    1825 
    1826                 // Add 'width' and 'height' attributes if applicable.
    1827                 if ( $attachment_id > 0 && false === strpos( $filtered_image, ' width=' ) && false === strpos( $filtered_image, ' height=' ) ) {
    1828                         $filtered_image = wp_img_tag_add_width_and_height_attr( $filtered_image, $context, $attachment_id );
    1829                 }
    1830 
    1831                 // Add 'srcset' and 'sizes' attributes if applicable.
    1832                 if ( $attachment_id > 0 && false === strpos( $filtered_image, ' srcset=' ) ) {
    1833                         $filtered_image = wp_img_tag_add_srcset_and_sizes_attr( $filtered_image, $context, $attachment_id );
    1834                 }
    1835 
    1836                 // Add 'loading' attribute if applicable.
    1837                 if ( $add_img_loading_attr && false === strpos( $filtered_image, ' loading=' ) ) {
    1838                         $filtered_image = wp_img_tag_add_loading_attr( $filtered_image, $context );
    1839                 }
    1840 
    1841                 if ( $filtered_image !== $image ) {
    1842                         $content = str_replace( $image, $filtered_image, $content );
    1843                 }
    1844         }
    1845 
    1846         foreach ( $iframes as $iframe => $attachment_id ) {
    1847                 $filtered_iframe = $iframe;
    1848 
    1849                 // Add 'loading' attribute if applicable.
    1850                 if ( $add_iframe_loading_attr && false === strpos( $filtered_iframe, ' loading=' ) ) {
    1851                         $filtered_iframe = wp_iframe_tag_add_loading_attr( $filtered_iframe, $context );
    1852                 }
    1853 
    1854                 if ( $filtered_iframe !== $iframe ) {
    1855                         $content = str_replace( $iframe, $filtered_iframe, $content );
     1823        // Iterate through the matches in order of occurrence as it is relevant for whether or not to lazy-load.
     1824        foreach ( $matches as $match ) {
     1825                // Filter an image match.
     1826                if ( isset( $images[ $match[0] ] ) ) {
     1827                        $filtered_image = $match[0];
     1828                        $attachment_id  = $images[ $match[0] ];
     1829
     1830                        // Add 'width' and 'height' attributes if applicable.
     1831                        if ( $attachment_id > 0 && false === strpos( $filtered_image, ' width=' ) && false === strpos( $filtered_image, ' height=' ) ) {
     1832                                $filtered_image = wp_img_tag_add_width_and_height_attr( $filtered_image, $context, $attachment_id );
     1833                        }
     1834
     1835                        // Add 'srcset' and 'sizes' attributes if applicable.
     1836                        if ( $attachment_id > 0 && false === strpos( $filtered_image, ' srcset=' ) ) {
     1837                                $filtered_image = wp_img_tag_add_srcset_and_sizes_attr( $filtered_image, $context, $attachment_id );
     1838                        }
     1839
     1840                        // Add 'loading' attribute if applicable.
     1841                        if ( $add_img_loading_attr && false === strpos( $filtered_image, ' loading=' ) ) {
     1842                                $filtered_image = wp_img_tag_add_loading_attr( $filtered_image, $context );
     1843                        }
     1844
     1845                        if ( $filtered_image !== $match[0] ) {
     1846                                $content = str_replace( $match[0], $filtered_image, $content );
     1847                        }
     1848                }
     1849
     1850                // Filter an iframe match.
     1851                if ( isset( $iframes[ $match[0] ] ) ) {
     1852                        $filtered_iframe = $match[0];
     1853
     1854                        // Add 'loading' attribute if applicable.
     1855                        if ( $add_iframe_loading_attr && false === strpos( $filtered_iframe, ' loading=' ) ) {
     1856                                $filtered_iframe = wp_iframe_tag_add_loading_attr( $filtered_iframe, $context );
     1857                        }
     1858
     1859                        if ( $filtered_iframe !== $match[0] ) {
     1860                                $content = str_replace( $match[0], $filtered_iframe, $content );
     1861                        }
    18561862                }
    18571863        }
     
    18701876 */
    18711877function wp_img_tag_add_loading_attr( $image, $context ) {
     1878        // Get loading attribute value to use. This must occur before the conditional check below so that even images that
     1879        // are ineligible for being lazy-loaded are considered.
     1880        $value = wp_get_loading_attr_default( $context );
     1881
    18721882        // Images should have source and dimension attributes for the `loading` attribute to be added.
    18731883        if ( false === strpos( $image, ' src="' ) || false === strpos( $image, ' width="' ) || false === strpos( $image, ' height="' ) ) {
     
    18841894         *
    18851895         * @param string|bool $value   The `loading` attribute value. Returning a falsey value will result in
    1886          *                             the attribute being omitted for the image. Default 'lazy'.
     1896         *                             the attribute being omitted for the image.
    18871897         * @param string      $image   The HTML `img` tag to be filtered.
    18881898         * @param string      $context Additional context about how the function was called or where the img tag is.
    18891899         */
    1890         $value = apply_filters( 'wp_img_tag_add_loading_attr', 'lazy', $image, $context );
     1900        $value = apply_filters( 'wp_img_tag_add_loading_attr', $value, $image, $context );
    18911901
    18921902        if ( $value ) {
     
    19962006        }
    19972007
     2008        // Get loading attribute value to use. This must occur before the conditional check below so that even iframes that
     2009        // are ineligible for being lazy-loaded are considered.
     2010        $value = wp_get_loading_attr_default( $context );
     2011
    19982012        // Iframes should have source and dimension attributes for the `loading` attribute to be added.
    19992013        if ( false === strpos( $iframe, ' src="' ) || false === strpos( $iframe, ' width="' ) || false === strpos( $iframe, ' height="' ) ) {
     
    20102024         *
    20112025         * @param string|bool $value   The `loading` attribute value. Returning a falsey value will result in
    2012          *                             the attribute being omitted for the iframe. Default 'lazy'.
     2026         *                             the attribute being omitted for the iframe.
    20132027         * @param string      $iframe  The HTML `iframe` tag to be filtered.
    20142028         * @param string      $context Additional context about how the function was called or where the iframe tag is.
    20152029         */
    2016         $value = apply_filters( 'wp_iframe_tag_add_loading_attr', 'lazy', $iframe, $context );
     2030        $value = apply_filters( 'wp_iframe_tag_add_loading_attr', $value, $iframe, $context );
    20172031
    20182032        if ( $value ) {
     
    51785192        return compact( 'width', 'height', 'type' );
    51795193}
     5194
     5195/**
     5196 * Gets the default value to use for a `loading` attribute on an element.
     5197 *
     5198 * This function should only be called for a tag and context if lazy-loading is generally enabled.
     5199 *
     5200 * The function usually returns 'lazy', but uses certain heuristics to guess whether the current element is likely to
     5201 * appear above the fold, in which case it returns a boolean `false`, which will lead to the `loading` attribute being
     5202 * omitted on the element. The purpose of this refinement is to avoid lazy-loading elements that are within the initial
     5203 * viewport, which can have a negative performance impact.
     5204 *
     5205 * Under the hood, the function uses {@see wp_increase_content_media_count()} every time it is called for an element
     5206 * within the main content. If the element is the very first content element, the `loading` attribute will be omitted.
     5207 * This default threshold of 1 content element to omit the `loading` attribute for can be customized using the
     5208 * {@see 'wp_omit_loading_attr_threshold'} filter.
     5209 *
     5210 * @since 5.9.0
     5211 *
     5212 * @param string $context Context for the element for which the `loading` attribute value is requested.
     5213 * @return string|bool The default `loading` attribute value. Either 'lazy', 'eager', or a boolean `false`, to indicate
     5214 *                     that the `loading` attribute should be skipped.
     5215 */
     5216function wp_get_loading_attr_default( $context ) {
     5217        // Only elements with 'the_content' or 'the_post_thumbnail' context have special handling.
     5218        if ( 'the_content' !== $context && 'the_post_thumbnail' !== $context ) {
     5219                return 'lazy';
     5220        }
     5221
     5222        // Only elements within the main query loop have special handling.
     5223        if ( is_admin() || ! in_the_loop() || ! is_main_query() ) {
     5224                return 'lazy';
     5225        }
     5226
     5227        // Increase the counter since this is a main query content element.
     5228        $content_media_count = wp_increase_content_media_count();
     5229
     5230        // If the count so far is below the threshold, return `false` so that the `loading` attribute is omitted.
     5231        if ( $content_media_count <= wp_omit_loading_attr_threshold() ) {
     5232                return false;
     5233        }
     5234
     5235        // For elements after the threshold, lazy-load them as usual.
     5236        return 'lazy';
     5237}
     5238
     5239/**
     5240 * Gets the threshold for how many of the first content media elements to not lazy-load.
     5241 *
     5242 * This function runs the {@see 'wp_omit_loading_attr_threshold'} filter, which uses a default threshold value of 1.
     5243 * The filter is only run once per page load, unless the `$force` parameter is used.
     5244 *
     5245 * @since 5.9.0
     5246 *
     5247 * @param bool $force Optional. If set to true, the filter will be (re-)applied even if it already has been before.
     5248 *                    Default false.
     5249 * @return int The number of content media elements to not lazy-load.
     5250 */
     5251function wp_omit_loading_attr_threshold( $force = false ) {
     5252        static $omit_threshold;
     5253
     5254        // This function may be called multiple times. Run the filter only once per page load.
     5255        if ( ! isset( $omit_threshold ) || $force ) {
     5256                /**
     5257                 * Filters the threshold for how many of the first content media elements to not lazy-load.
     5258                 *
     5259                 * For these first content media elements, the `loading` attribute will be omitted. By default, this is the case
     5260                 * for only the very first content media element.
     5261                 *
     5262                 * @since 5.9.0
     5263                 *
     5264                 * @param int $omit_threshold The number of media elements where the `loading` attribute will not be added. Default 1.
     5265                 */
     5266                $omit_threshold = apply_filters( 'wp_omit_loading_attr_threshold', 1 );
     5267        }
     5268
     5269        return $omit_threshold;
     5270}
     5271
     5272/**
     5273 * Increases an internal content media count variable.
     5274 *
     5275 * @since 5.9.0
     5276 * @access private
     5277 *
     5278 * @param int $amount Optional. Amount to increase by. Default 1.
     5279 * @return int The latest content media count, after the increase.
     5280 */
     5281function wp_increase_content_media_count( $amount = 1 ) {
     5282        static $content_media_count = 0;
     5283
     5284        $content_media_count += $amount;
     5285
     5286        return $content_media_count;
     5287}
  • trunk/src/wp-includes/pluggable.php

    r51301 r52065  
    26792679
    26802680                if ( wp_lazy_loading_enabled( 'img', 'get_avatar' ) ) {
    2681                         $defaults['loading'] = 'lazy';
     2681                        $defaults['loading'] = wp_get_loading_attr_default( 'get_avatar' );
    26822682                }
    26832683
  • trunk/src/wp-includes/post-thumbnail-template.php

    r52028 r52065  
    187187                }
    188188
     189                // Get the 'loading' attribute value to use as default, taking precedence over the default from
     190                // `wp_get_attachment_image()`.
     191                $loading = wp_get_loading_attr_default( 'the_post_thumbnail' );
     192
     193                // Add the default to the given attributes unless they already include a 'loading' directive.
     194                if ( empty( $attr ) ) {
     195                        $attr = array( 'loading' => $loading );
     196                } elseif ( is_array( $attr ) && ! array_key_exists( 'loading', $attr ) ) {
     197                        $attr['loading'] = $loading;
     198                } elseif ( is_string( $attr ) && ! preg_match( '/(^|&)loading=', $attr ) ) {
     199                        $attr .= '&loading=' . $loading;
     200                }
     201
    189202                $html = wp_get_attachment_image( $post_thumbnail_id, $size, false, $attr );
    190203
  • trunk/tests/phpunit/tests/media.php

    r52010 r52065  
    30253025         * @ticket 50425
    30263026         * @ticket 53463
     3027         * @ticket 53675
    30273028         * @dataProvider data_wp_lazy_loading_enabled_context_defaults
    30283029         *
     
    30473048                        'get_avatar => true'              => array( 'get_avatar', true ),
    30483049                        'arbitrary context => true'       => array( 'something_completely_arbitrary', true ),
     3050                        'the_post_thumbnail => true'      => array( 'the_post_thumbnail', true ),
    30493051                );
    30503052        }
     
    31873189                );
    31883190        }
     3191
     3192        /**
     3193         * @ticket 53675
     3194         * @dataProvider data_wp_get_loading_attr_default
     3195         *
     3196         * @param string $context
     3197         */
     3198        function test_wp_get_loading_attr_default( $context ) {
     3199                global $wp_query, $wp_the_query;
     3200
     3201                // Return 'lazy' by default.
     3202                $this->assertSame( 'lazy', wp_get_loading_attr_default( 'test' ) );
     3203                $this->assertSame( 'lazy', wp_get_loading_attr_default( 'wp_get_attachment_image' ) );
     3204
     3205                // Return 'lazy' if not in the loop or the main query.
     3206                $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
     3207
     3208                $wp_query = new WP_Query( array( 'post__in' => array( self::$post_ids['publish'] ) ) );
     3209                $this->reset_content_media_count();
     3210                $this->reset_omit_loading_attr_filter();
     3211
     3212                while ( have_posts() ) {
     3213                        the_post();
     3214
     3215                        // Return 'lazy' if in the loop but not in the main query.
     3216                        $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
     3217
     3218                        // Set as main query.
     3219                        $wp_the_query = $wp_query;
     3220
     3221                        // For contexts other than for the main content, still return 'lazy' even in the loop
     3222                        // and in the main query, and do not increase the content media count.
     3223                        $this->assertSame( 'lazy', wp_get_loading_attr_default( 'wp_get_attachment_image' ) );
     3224
     3225                        // Return `false` if in the loop and in the main query and it is the first element.
     3226                        $this->assertFalse( wp_get_loading_attr_default( $context ) );
     3227
     3228                        // Return 'lazy' if in the loop and in the main query for any subsequent elements.
     3229                        $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
     3230
     3231                        // Yes, for all subsequent elements.
     3232                        $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
     3233                }
     3234        }
     3235
     3236        function data_wp_get_loading_attr_default() {
     3237                return array(
     3238                        array( 'the_content' ),
     3239                        array( 'the_post_thumbnail' ),
     3240                );
     3241        }
     3242
     3243        /**
     3244         * @ticket 53675
     3245         */
     3246        function test_wp_omit_loading_attr_threshold_filter() {
     3247                global $wp_query, $wp_the_query;
     3248
     3249                $wp_query     = new WP_Query( array( 'post__in' => array( self::$post_ids['publish'] ) ) );
     3250                $wp_the_query = $wp_query;
     3251                $this->reset_content_media_count();
     3252                $this->reset_omit_loading_attr_filter();
     3253
     3254                // Use the filter to alter the threshold for not lazy-loading to the first three elements.
     3255                add_filter(
     3256                        'wp_omit_loading_attr_threshold',
     3257                        function() {
     3258                                return 3;
     3259                        }
     3260                );
     3261
     3262                while ( have_posts() ) {
     3263                        the_post();
     3264
     3265                        // Due to the filter, now the first three elements should not be lazy-loaded, i.e. return `false`.
     3266                        for ( $i = 0; $i < 3; $i++ ) {
     3267                                $this->assertFalse( wp_get_loading_attr_default( 'the_content' ) );
     3268                        }
     3269
     3270                        // For following elements, lazy-load them again.
     3271                        $this->assertSame( 'lazy', wp_get_loading_attr_default( 'the_content' ) );
     3272                }
     3273        }
     3274
     3275        /**
     3276         * @ticket 53675
     3277         */
     3278        function test_wp_filter_content_tags_with_wp_get_loading_attr_default() {
     3279                global $wp_query, $wp_the_query;
     3280
     3281                $img1         = get_image_tag( self::$large_id, '', '', '', 'large' );
     3282                $iframe1      = '<iframe src="https://www.example.com" width="640" height="360"></iframe>';
     3283                $img2         = get_image_tag( self::$large_id, '', '', '', 'medium' );
     3284                $img3         = get_image_tag( self::$large_id, '', '', '', 'thumbnail' );
     3285                $iframe2      = '<iframe src="https://wordpress.org" width="640" height="360"></iframe>';
     3286                $lazy_img2    = wp_img_tag_add_loading_attr( $img2, 'the_content' );
     3287                $lazy_img3    = wp_img_tag_add_loading_attr( $img3, 'the_content' );
     3288                $lazy_iframe2 = wp_iframe_tag_add_loading_attr( $iframe2, 'the_content' );
     3289
     3290                // Use a threshold of 2.
     3291                add_filter(
     3292                        'wp_omit_loading_attr_threshold',
     3293                        function() {
     3294                                return 2;
     3295                        }
     3296                );
     3297
     3298                // Following the threshold of 2, the first two content media elements should not be lazy-loaded.
     3299                $content_unfiltered = $img1 . $iframe1 . $img2 . $img3 . $iframe2;
     3300                $content_expected   = $img1 . $iframe1 . $lazy_img2 . $lazy_img3 . $lazy_iframe2;
     3301
     3302                $wp_query     = new WP_Query( array( 'post__in' => array( self::$post_ids['publish'] ) ) );
     3303                $wp_the_query = $wp_query;
     3304                $this->reset_content_media_count();
     3305                $this->reset_omit_loading_attr_filter();
     3306
     3307                while ( have_posts() ) {
     3308                        the_post();
     3309
     3310                        add_filter( 'wp_img_tag_add_srcset_and_sizes_attr', '__return_false' );
     3311                        $content_filtered = wp_filter_content_tags( $content_unfiltered, 'the_content' );
     3312                        remove_filter( 'wp_img_tag_add_srcset_and_sizes_attr', '__return_false' );
     3313                }
     3314
     3315                // After filtering, the first image should not be lazy-loaded while the other ones should be.
     3316                $this->assertSame( $content_expected, $content_filtered );
     3317        }
     3318
     3319        /**
     3320         * @ticket 53675
     3321         */
     3322        public function test_wp_omit_loading_attr_threshold() {
     3323                $this->reset_omit_loading_attr_filter();
     3324
     3325                // Apply filter, ensure default value of 1.
     3326                $omit_threshold = wp_omit_loading_attr_threshold();
     3327                $this->assertSame( 1, $omit_threshold );
     3328
     3329                // Add a filter that changes the value to 3. However, the filter is not applied a subsequent time in a single
     3330                // page load by default, so the value is still 1.
     3331                add_filter(
     3332                        'wp_omit_loading_attr_threshold',
     3333                        function() {
     3334                                return 3;
     3335                        }
     3336                );
     3337                $omit_threshold = wp_omit_loading_attr_threshold();
     3338                $this->assertSame( 1, $omit_threshold );
     3339
     3340                // Only by enforcing a fresh check, the filter gets re-applied.
     3341                $omit_threshold = wp_omit_loading_attr_threshold( true );
     3342                $this->assertSame( 3, $omit_threshold );
     3343        }
     3344
     3345        private function reset_content_media_count() {
     3346                // Get current value without increasing.
     3347                $content_media_count = wp_increase_content_media_count( 0 );
     3348
     3349                // Decrease it by its current value to "reset" it back to 0.
     3350                wp_increase_content_media_count( - $content_media_count );
     3351        }
     3352
     3353        private function reset_omit_loading_attr_filter() {
     3354                // Add filter to "reset" omit threshold back to null (unset).
     3355                add_filter( 'wp_omit_loading_attr_threshold', '__return_null', 100 );
     3356
     3357                // Force filter application to re-run.
     3358                wp_omit_loading_attr_threshold( true );
     3359
     3360                // Clean up the above filter.
     3361                remove_filter( 'wp_omit_loading_attr_threshold', '__return_null', 100 );
     3362        }
    31893363}
    31903364
Note: See TracChangeset for help on using the changeset viewer.