Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(common): Add automatic srcset generation to ngOptimizedImage (second PR) #47695

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
63 changes: 59 additions & 4 deletions aio/content/guide/image-directive.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,24 +89,65 @@ You can typically fix this by adding `height: auto` or `width: auto` to your ima

### Handling `srcset` attributes

If your `<img>` tag defines a `srcset` attribute, replace it with `ngSrcset`.
Defining a [`srcset` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/srcset) ensures that the browser requests an image at the right size for your user's viewport, so it doesn't waste time downloading an image that's too large. 'NgOptimizedImage' generates an appropriate `srcset` for the image, based on the presence and value of the [`sizes` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes) on the image tag.

#### Fixed-size images

If your image should be "fixed" in size (i.e. the same size across devices, except for [pixel density](https://web.dev/codelab-density-descriptors/)), there is no need to set a `sizes` attribute. A `srcset` can be generated automatically from the image's width and height attributes with no further input required.

Example srcset generated: `<img ... srcset="image-400w.jpg 1x, image-800w.jpg 2x">`

#### Responsive images

If your image should be responsive (i.e. grow and shrink according to viewport size), then you will need to define a [`sizes` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes) to generate the `srcset`.

If you haven't used `sizes` before, a good place to start is to set it based on viewport width. For example, if your CSS causes the image to fill 100% of viewport width, set `sizes` to `100vw` and the browser will select the image in the `srcset` that is closest to the viewport width (after accounting for pixel density). If your image is only likely to take up half the screen (ex: in a sidebar), set `sizes` to `50vw` to ensure the browser selects a smaller image. And so on.

If you find that the above does not cover your desired image behavior, see the documentation on [advanced sizes values](#advanced-sizes-values).

By default, the responsive breakpoints are:

`[16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840]`

If you would like to customize these breakpoints, you can do so using the `IMAGE_CONFIG` provider:

<code-example format="typescript" language="typescript">
providers: [
{
provide: IMAGE_CONFIG,
useValue: {
breakpoints: [16, 48, 96, 128, 384, 640, 750, 828, 1080, 1200, 1920]
}
},
],
</code-example>

If you would like to manually define a `srcset` attribute, you can provide your own directly, or use the `ngSrcset` attribute:

<code-example format="html" language="html">

&lt;img ngSrc="hero.jpg" ngSrcset="100w, 200w, 300w"&gt;

</code-example>

If the `ngSrcset` attribute is present, `NgOptimizedImage` generates and sets the [`srcset` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/srcset) using the configured image loader. Do not include image file names in `ngSrcset` - the directive infers this information from `ngSrc`. The directive supports both width descriptors (e.g. `100w`) and density descriptors (e.g. `1x`) are supported.

You can also use `ngSrcset` with the standard image [`sizes` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes).
If the `ngSrcset` attribute is present, `NgOptimizedImage` generates and sets the `srcset` using the configured image loader. Do not include image file names in `ngSrcset` - the directive infers this information from `ngSrc`. The directive supports both width descriptors (e.g. `100w`) and density descriptors (e.g. `1x`) are supported.

<code-example format="html" language="html">

&lt;img ngSrc="hero.jpg" ngSrcset="100w, 200w, 300w" sizes="50vw"&gt;

</code-example>

### Disabling automatic srcset generation

To disable srcset generation for a single image, you can add the `unoptimized` attribute on the image:

<code-example format="html" language="html">

&lt;img ngSrc="about.jpg" unoptimized&gt;

</code-example>

### Disabling image lazy loading

By default, `NgOptimizedImage` sets `loading=lazy` for all images that are not marked `priority`. You can disable this behavior for non-priority images by setting the `loading` attribute. This attribute accepts values: `eager`, `auto`, and `lazy`. [See the documentation for the standard image `loading` attribute for details](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/loading#value).
Expand All @@ -117,6 +158,20 @@ By default, `NgOptimizedImage` sets `loading=lazy` for all images that are not m

</code-example>

### Advanced 'sizes' values

You may want to have images displayed at varying widths on differently-sized screens. A common example of this pattern is a grid- or column-based layout that renders a single column on mobile devices, and two columns on larger devices. You can capture this behavior in the `sizes` attribute, using a "media query" syntax, such as the following:

<code-example format="html" language="html">

&lt;img ngSrc="cat.jpg" width="400" height="200" sizes="(max-width: 768px) 100vw, 50vw"&gt;

</code-example>

The `sizes` attribute in the above example says "I expect this image to be 100 percent of the screen width on devices under 768px wide. Otherwise, I expect it to be 50 percent of the screen width.

For additional information about the `sizes` attribute, see [web.dev](https://web.dev/learn/design/responsive-images/#sizes) or [mdn](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes).

<!-- links -->

<!-- external links -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Directive, ElementRef, inject, Injector, Input, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
import {Directive, ElementRef, inject, InjectionToken, Injector, Input, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';

import {RuntimeErrorCode} from '../../errors';

Expand Down Expand Up @@ -49,6 +49,15 @@ export const ABSOLUTE_SRCSET_DENSITY_CAP = 3;
*/
export const RECOMMENDED_SRCSET_DENSITY_CAP = 2;

/**
* Used in generating automatic density-based srcsets
*/
const DENSITY_SRCSET_MULTIPLIERS = [1, 2];

/**
* Used to determine which breakpoints to use on full-width images
*/
const VIEWPORT_BREAKPOINT_CUTOFF = 640;
/**
* Used to determine whether two aspect ratios are similar in value.
*/
Expand All @@ -61,6 +70,34 @@ const ASPECT_RATIO_TOLERANCE = .1;
*/
const OVERSIZED_IMAGE_TOLERANCE = 1000;

/**
* A configuration object for the NgOptimizedImage directive. Contains:
* - viewportBreakpoints: An array of integer breakpoints used to generate
* srcsets for responsive images.
* - subViewportBreakpoints: An array of smaller breakpoints. Added to the
* srcset when `sizes` indicates a sub-full-width responsive image.
*
* Learn more about the responsive image configuration in [the NgOptimizedImage
* guide](guide/image-directive).
* @developerPreview
*/
export type ImageConfig = {
breakpoints?: number[]
};

const defaultConfig: ImageConfig = {
breakpoints: [16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840],
};

/**
* Injection token that configures the image optimized image functionality.
*
* @see `NgOptimizedImage`
* @developerPreview
*/
export const IMAGE_CONFIG = new InjectionToken<ImageConfig>(
'ImageConfig', {providedIn: 'root', factory: () => defaultConfig});

/**
* Directive that improves image loading performance by enforcing best practices.
*
Expand All @@ -72,6 +109,7 @@ const OVERSIZED_IMAGE_TOLERANCE = 1000;
*
* In addition, the directive:
* - Generates appropriate asset URLs if a corresponding `ImageLoader` function is provided
* - Automatically generates a srcset
* - Requires that `width` and `height` are set
* - Warns if `width` or `height` have been set incorrectly
* - Warns if the image will be visually distorted when rendered
Expand Down Expand Up @@ -165,6 +203,7 @@ const OVERSIZED_IMAGE_TOLERANCE = 1000;
})
export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
private imageLoader = inject(IMAGE_LOADER);
private config: ImageConfig = processConfig(inject(IMAGE_CONFIG));
private renderer = inject(Renderer2);
private imgElement: HTMLImageElement = inject(ElementRef).nativeElement;
private injector = inject(Injector);
Expand Down Expand Up @@ -223,6 +262,12 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
*/
@Input() ngSrcset!: string;

/**
* The base `sizes` attribute passed through to the `<img>` element.
* Providing sizes causes the image to create an automatic responsive srcset.
*/
@Input() sizes?: string;

/**
* The intrinsic width of the image in pixels.
*/
Expand Down Expand Up @@ -269,6 +314,18 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
}
private _priority = false;

/**
* Disables automatic srcset generation for this image.
*/
@Input()
set unoptimized(value: string|boolean|undefined) {
this._unoptimized = inputToBoolean(value);
}
get unoptimized(): boolean {
return this._unoptimized;
}
private _unoptimized = false;

/**
* Value of the `src` attribute if set on the host `<img>` element.
* This input is exclusively read to assert that `src` is not set in conflict
Expand All @@ -290,12 +347,17 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
assertNonEmptyInput(this, 'ngSrc', this.ngSrc);
assertValidNgSrcset(this, this.ngSrcset);
assertNoConflictingSrc(this);
assertNoConflictingSrcset(this);
if (this.ngSrcset) {
assertNoConflictingSrcset(this);
}
assertNotBase64Image(this);
assertNotBlobUrl(this);
assertNonEmptyWidthAndHeight(this);
assertValidLoadingInput(this);
assertNoImageDistortion(this, this.imgElement, this.renderer);
if (!this.srcset) {
assertNoComplexSizes(this);
}
if (this.priority) {
const checker = this.injector.get(PreconnectLinkChecker);
checker.assertPreconnect(this.getRewrittenSrc(), this.ngSrc);
Expand Down Expand Up @@ -325,8 +387,13 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
// The `src` and `srcset` attributes should be set last since other attributes
// could affect the image's loading behavior.
this.setHostAttribute('src', this.getRewrittenSrc());
if (this.sizes) {
this.setHostAttribute('sizes', this.sizes);
}
if (this.ngSrcset) {
this.setHostAttribute('srcset', this.getRewrittenSrcset());
} else if (!this._unoptimized && !this.srcset) {
this.setHostAttribute('srcset', this.getAutomaticSrcset());
}
}

Expand Down Expand Up @@ -370,6 +437,36 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
return finalSrcs.join(', ');
}

private getAutomaticSrcset(): string {
if (this.sizes) {
return this.getResponsiveSrcset();
} else {
return this.getFixedSrcset();
}
}

private getResponsiveSrcset(): string {
const {breakpoints} = this.config;

let filteredBreakpoints = breakpoints!;
if (this.sizes?.trim() === '100vw') {
// Since this is a full-screen-width image, our srcset only needs to include
// breakpoints with full viewport widths.
filteredBreakpoints = breakpoints!.filter(bp => bp >= VIEWPORT_BREAKPOINT_CUTOFF);
}

const finalSrcs =
filteredBreakpoints.map(bp => `${this.imageLoader({src: this.ngSrc, width: bp})} ${bp}w`);
return finalSrcs.join(', ');
}

private getFixedSrcset(): string {
const finalSrcs = DENSITY_SRCSET_MULTIPLIERS.map(
multiplier => `${this.imageLoader({src: this.ngSrc, width: this.width! * multiplier})} ${
multiplier}x`);
return finalSrcs.join(', ');
}

ngOnDestroy() {
if (ngDevMode) {
if (!this.priority && this._renderedSrc !== null && this.lcpObserver !== null) {
Expand Down Expand Up @@ -399,6 +496,16 @@ function inputToBoolean(value: unknown): boolean {
return value != null && `${value}` !== 'false';
}

/**
* Sorts provided config breakpoints and uses defaults.
*/
function processConfig(config: ImageConfig): ImageConfig {
let sortedBreakpoints: {breakpoints?: number[]} = {};
if (config.breakpoints) {
sortedBreakpoints.breakpoints = config.breakpoints.sort((a, b) => a - b);
}
return Object.assign({}, defaultConfig, config, sortedBreakpoints);
}

/***** Assert functions *****/

Expand Down Expand Up @@ -448,6 +555,23 @@ function assertNotBase64Image(dir: NgOptimizedImage) {
}
}

/**
* Verifies that the 'sizes' only includes responsive values.
*/
function assertNoComplexSizes(dir: NgOptimizedImage) {
let sizes = dir.sizes;
if (sizes) {
if (sizes.match(/((\)|,)\s|^)\d+px/)) {
throw new RuntimeError(
RuntimeErrorCode.INVALID_INPUT,
`${imgDirectiveDetails(dir.ngSrc, false)} \`sizes\` was set to a string including ` +
`pixel values. For automatic \`srcset\` generation, \`sizes\` must only include responsive ` +
`values, such as \`sizes="50vw"\` or \`sizes="(min-width: 768px) 50vw, 100vw"\`. ` +
`To fix this, modify the \`sizes\` attribute, or provide your own \`srcset\` value directly.`);
}
}
}

/**
* Verifies that the `ngSrc` is not a Blob URL.
*/
Expand Down