Make WordPress Core

Changeset 52065


Ignore:
Timestamp:
11/09/2021 12:34:17 AM (2 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  
    1047 1047         // Add `loading` attribute.
    1048 1048         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' );
    1050 1050         }
    1051 1051
     
    1821 1821     }
    1822 1822
    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             }
    1856 1862         }
    1857 1863     }
     
    1870 1876  */
    1871 1877 function 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
    1872 1882     // Images should have source and dimension attributes for the `loading` attribute to be added.
    1873 1883     if ( false === strpos( $image, ' src="' ) || false === strpos( $image, ' width="' ) || false === strpos( $image, ' height="' ) ) {
     
    1884 1894      *
    1885 1895      * @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.
    1887 1897      * @param string      $image   The HTML `img` tag to be filtered.
    1888 1898      * @param string      $context Additional context about how the function was called or where the img tag is.
    1889 1899      */
    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 );
    1891 1901
    1892 1902     if ( $value ) {
     
    1996 2006     }
    1997 2007
      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
    1998 2012     // Iframes should have source and dimension attributes for the `loading` attribute to be added.
    1999 2013     if ( false === strpos( $iframe, ' src="' ) || false === strpos( $iframe, ' width="' ) || false === strpos( $iframe, ' height="' ) ) {
     
    2010 2024      *
    2011 2025      * @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.
    2013 2027      * @param string      $iframe  The HTML `iframe` tag to be filtered.
    2014 2028      * @param string      $context Additional context about how the function was called or where the iframe tag is.
    2015 2029      */
    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 );
    2017 2031
    2018 2032     if ( $value ) {
     
    5178 5192     return compact( 'width', 'height', 'type' );
    5179 5193 }
      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  */
      5216 function 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  */
      5251 function 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  */
      5281 function 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  
    2679 2679
    2680 2680         if ( wp_lazy_loading_enabled( 'img', 'get_avatar' ) ) {
    2681               $defaults['loading'] = 'lazy';
      2681             $defaults['loading'] = wp_get_loading_attr_default( 'get_avatar' );
    2682 2682         }
    2683 2683
  • trunk/src/wp-includes/post-thumbnail-template.php

    r52028 r52065  
    187 187         }
    188 188
      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
    189 202         $html = wp_get_attachment_image( $post_thumbnail_id, $size, false, $attr );
    190 203
  • trunk/tests/phpunit/tests/media.php

    r52010 r52065  
    3025 3025      * @ticket 50425
    3026 3026      * @ticket 53463
      3027      * @ticket 53675
    3027 3028      * @dataProvider data_wp_lazy_loading_enabled_context_defaults
    3028 3029      *
     
    3047 3048             'get_avatar => true'              => array( 'get_avatar', true ),
    3048 3049             'arbitrary context => true'       => array( 'something_completely_arbitrary', true ),
      3050             'the_post_thumbnail => true'      => array( 'the_post_thumbnail', true ),
    3049 3051         );
    3050 3052     }
     
    3187 3189         );
    3188 3190     }
      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     }
    3189 3363 }
    3190 3364
Note: See TracChangeset for help on using the changeset viewer.