Skip to content

Commit 4fde292

Browse files
atcastlethePunderWoman
authored andcommitted
feat(common): Add automatic srcset generation to ngOptimizedImage (#47547)
Add a feature to automatically generate the srcset attribute for images using the NgOptimizedImage directive. Uses the 'sizes' attribute to determine the appropriate srcset to generate. PR Close #47547
1 parent ed11a13 commit 4fde292

File tree

6 files changed

+381
-34
lines changed

6 files changed

+381
-34
lines changed

aio/content/guide/image-directive.md

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,24 +89,65 @@ You can typically fix this by adding `height: auto` or `width: auto` to your ima
8989

9090
### Handling `srcset` attributes
9191

92-
If your `<img>` tag defines a `srcset` attribute, replace it with `ngSrcset`.
92+
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.
93+
94+
#### Fixed-size images
95+
96+
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.
97+
98+
Example srcset generated: `<img ... srcset="image-400w.jpg 1x, image-800w.jpg 2x">`
99+
100+
#### Responsive images
101+
102+
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`.
103+
104+
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.
105+
106+
If you find that the above does not cover your desired image behavior, see the documentation on [advanced sizes values](#advanced-sizes-values).
107+
108+
By default, the responsive breakpoints are:
109+
110+
`[16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840]`
111+
112+
If you would like to customize these breakpoints, you can do so using the `IMAGE_CONFIG` provider:
113+
114+
<code-example format="typescript" language="typescript">
115+
providers: [
116+
{
117+
provide: IMAGE_CONFIG,
118+
useValue: {
119+
breakpoints: [16, 48, 96, 128, 384, 640, 750, 828, 1080, 1200, 1920]
120+
}
121+
},
122+
],
123+
</code-example>
124+
125+
If you would like to manually define a `srcset` attribute, you can provide your own directly, or use the `ngSrcset` attribute:
93126

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

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

98131
</code-example>
99132

100-
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.
101-
102-
You can also use `ngSrcset` with the standard image [`sizes` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes).
133+
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.
103134

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

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

108139
</code-example>
109140

141+
### Disabling automatic srcset generation
142+
143+
To disable srcset generation for a single image, you can add the `disableOptimizedSrcset` attribute on the image:
144+
145+
<code-example format="html" language="html">
146+
147+
&lt;img ngSrc="about.jpg" disableOptimizedSrcset&gt;
148+
149+
</code-example>
150+
110151
### Disabling image lazy loading
111152

112153
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).
@@ -117,6 +158,20 @@ By default, `NgOptimizedImage` sets `loading=lazy` for all images that are not m
117158

118159
</code-example>
119160

161+
### Advanced 'sizes' values
162+
163+
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:
164+
165+
<code-example format="html" language="html">
166+
167+
&lt;img ngSrc="cat.jpg" width="400" height="200" sizes="(max-width: 768px) 100vw, 50vw"&gt;
168+
169+
</code-example>
170+
171+
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.
172+
173+
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).
174+
120175
<!-- links -->
121176

122177
<!-- external links -->

goldens/public-api/common/index.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,9 @@ export abstract class NgLocalization {
546546

547547
// @public
548548
export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
549+
set disableOptimizedSrcset(value: string | boolean | undefined);
550+
// (undocumented)
551+
get disableOptimizedSrcset(): boolean;
549552
set height(value: string | number | undefined);
550553
// (undocumented)
551554
get height(): number | undefined;
@@ -563,11 +566,12 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
563566
get priority(): boolean;
564567
// @deprecated
565568
set rawSrc(value: string);
569+
sizes?: string;
566570
set width(value: string | number | undefined);
567571
// (undocumented)
568572
get width(): number | undefined;
569573
// (undocumented)
570-
static ɵdir: i0.ɵɵDirectiveDeclaration<NgOptimizedImage, "img[ngSrc],img[rawSrc]", never, { "rawSrc": "rawSrc"; "ngSrc": "ngSrc"; "ngSrcset": "ngSrcset"; "width": "width"; "height": "height"; "loading": "loading"; "priority": "priority"; "src": "src"; "srcset": "srcset"; }, {}, never, never, true, never>;
574+
static ɵdir: i0.ɵɵDirectiveDeclaration<NgOptimizedImage, "img[ngSrc],img[rawSrc]", never, { "rawSrc": "rawSrc"; "ngSrc": "ngSrc"; "ngSrcset": "ngSrcset"; "sizes": "sizes"; "width": "width"; "height": "height"; "loading": "loading"; "priority": "priority"; "disableOptimizedSrcset": "disableOptimizedSrcset"; "src": "src"; "srcset": "srcset"; }, {}, never, never, true, never>;
571575
// (undocumented)
572576
static ɵfac: i0.ɵɵFactoryDeclaration<NgOptimizedImage, never>;
573577
}

packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

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

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

@@ -49,6 +49,15 @@ export const ABSOLUTE_SRCSET_DENSITY_CAP = 3;
4949
*/
5050
export const RECOMMENDED_SRCSET_DENSITY_CAP = 2;
5151

52+
/**
53+
* Used in generating automatic density-based srcsets
54+
*/
55+
const DENSITY_SRCSET_MULTIPLIERS = [1, 2];
56+
57+
/**
58+
* Used to determine which breakpoints to use on full-width images
59+
*/
60+
const VIEWPORT_BREAKPOINT_CUTOFF = 640;
5261
/**
5362
* Used to determine whether two aspect ratios are similar in value.
5463
*/
@@ -61,6 +70,34 @@ const ASPECT_RATIO_TOLERANCE = .1;
6170
*/
6271
const OVERSIZED_IMAGE_TOLERANCE = 1000;
6372

73+
/**
74+
* A configuration object for the NgOptimizedImage directive. Contains:
75+
* - breakpoints: An array of integer breakpoints used to generate
76+
* srcsets for responsive images.
77+
*
78+
* Learn more about the responsive image configuration in [the NgOptimizedImage
79+
* guide](guide/image-directive).
80+
* @publicApi
81+
* @developerPreview
82+
*/
83+
export type ImageConfig = {
84+
breakpoints?: number[]
85+
};
86+
87+
const defaultConfig: ImageConfig = {
88+
breakpoints: [16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840],
89+
};
90+
91+
/**
92+
* Injection token that configures the image optimized image functionality.
93+
*
94+
* @see `NgOptimizedImage`
95+
* @publicApi
96+
* @developerPreview
97+
*/
98+
export const IMAGE_CONFIG = new InjectionToken<ImageConfig>(
99+
'ImageConfig', {providedIn: 'root', factory: () => defaultConfig});
100+
64101
/**
65102
* Directive that improves image loading performance by enforcing best practices.
66103
*
@@ -72,6 +109,7 @@ const OVERSIZED_IMAGE_TOLERANCE = 1000;
72109
*
73110
* In addition, the directive:
74111
* - Generates appropriate asset URLs if a corresponding `ImageLoader` function is provided
112+
* - Automatically generates a srcset
75113
* - Requires that `width` and `height` are set
76114
* - Warns if `width` or `height` have been set incorrectly
77115
* - Warns if the image will be visually distorted when rendered
@@ -165,6 +203,7 @@ const OVERSIZED_IMAGE_TOLERANCE = 1000;
165203
})
166204
export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
167205
private imageLoader = inject(IMAGE_LOADER);
206+
private config: ImageConfig = processConfig(inject(IMAGE_CONFIG));
168207
private renderer = inject(Renderer2);
169208
private imgElement: HTMLImageElement = inject(ElementRef).nativeElement;
170209
private injector = inject(Injector);
@@ -223,6 +262,12 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
223262
*/
224263
@Input() ngSrcset!: string;
225264

265+
/**
266+
* The base `sizes` attribute passed through to the `<img>` element.
267+
* Providing sizes causes the image to create an automatic responsive srcset.
268+
*/
269+
@Input() sizes?: string;
270+
226271
/**
227272
* The intrinsic width of the image in pixels.
228273
*/
@@ -269,6 +314,18 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
269314
}
270315
private _priority = false;
271316

317+
/**
318+
* Disables automatic srcset generation for this image.
319+
*/
320+
@Input()
321+
set disableOptimizedSrcset(value: string|boolean|undefined) {
322+
this._disableOptimizedSrcset = inputToBoolean(value);
323+
}
324+
get disableOptimizedSrcset(): boolean {
325+
return this._disableOptimizedSrcset;
326+
}
327+
private _disableOptimizedSrcset = false;
328+
272329
/**
273330
* Value of the `src` attribute if set on the host `<img>` element.
274331
* This input is exclusively read to assert that `src` is not set in conflict
@@ -290,12 +347,17 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
290347
assertNonEmptyInput(this, 'ngSrc', this.ngSrc);
291348
assertValidNgSrcset(this, this.ngSrcset);
292349
assertNoConflictingSrc(this);
293-
assertNoConflictingSrcset(this);
350+
if (this.ngSrcset) {
351+
assertNoConflictingSrcset(this);
352+
}
294353
assertNotBase64Image(this);
295354
assertNotBlobUrl(this);
296355
assertNonEmptyWidthAndHeight(this);
297356
assertValidLoadingInput(this);
298357
assertNoImageDistortion(this, this.imgElement, this.renderer);
358+
if (!this.ngSrcset) {
359+
assertNoComplexSizes(this);
360+
}
299361
if (this.priority) {
300362
const checker = this.injector.get(PreconnectLinkChecker);
301363
checker.assertPreconnect(this.getRewrittenSrc(), this.ngSrc);
@@ -325,8 +387,13 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
325387
// The `src` and `srcset` attributes should be set last since other attributes
326388
// could affect the image's loading behavior.
327389
this.setHostAttribute('src', this.getRewrittenSrc());
390+
if (this.sizes) {
391+
this.setHostAttribute('sizes', this.sizes);
392+
}
328393
if (this.ngSrcset) {
329394
this.setHostAttribute('srcset', this.getRewrittenSrcset());
395+
} else if (!this._disableOptimizedSrcset && !this.srcset) {
396+
this.setHostAttribute('srcset', this.getAutomaticSrcset());
330397
}
331398
}
332399

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

440+
private getAutomaticSrcset(): string {
441+
if (this.sizes) {
442+
return this.getResponsiveSrcset();
443+
} else {
444+
return this.getFixedSrcset();
445+
}
446+
}
447+
448+
private getResponsiveSrcset(): string {
449+
const {breakpoints} = this.config;
450+
451+
let filteredBreakpoints = breakpoints!;
452+
if (this.sizes?.trim() === '100vw') {
453+
// Since this is a full-screen-width image, our srcset only needs to include
454+
// breakpoints with full viewport widths.
455+
filteredBreakpoints = breakpoints!.filter(bp => bp >= VIEWPORT_BREAKPOINT_CUTOFF);
456+
}
457+
458+
const finalSrcs =
459+
filteredBreakpoints.map(bp => `${this.imageLoader({src: this.ngSrc, width: bp})} ${bp}w`);
460+
return finalSrcs.join(', ');
461+
}
462+
463+
private getFixedSrcset(): string {
464+
const finalSrcs = DENSITY_SRCSET_MULTIPLIERS.map(
465+
multiplier => `${this.imageLoader({src: this.ngSrc, width: this.width! * multiplier})} ${
466+
multiplier}x`);
467+
return finalSrcs.join(', ');
468+
}
469+
373470
ngOnDestroy() {
374471
if (ngDevMode) {
375472
if (!this.priority && this._renderedSrc !== null && this.lcpObserver !== null) {
@@ -399,6 +496,16 @@ function inputToBoolean(value: unknown): boolean {
399496
return value != null && `${value}` !== 'false';
400497
}
401498

499+
/**
500+
* Sorts provided config breakpoints and uses defaults.
501+
*/
502+
function processConfig(config: ImageConfig): ImageConfig {
503+
let sortedBreakpoints: {breakpoints?: number[]} = {};
504+
if (config.breakpoints) {
505+
sortedBreakpoints.breakpoints = config.breakpoints.sort((a, b) => a - b);
506+
}
507+
return Object.assign({}, defaultConfig, config, sortedBreakpoints);
508+
}
402509

403510
/***** Assert functions *****/
404511

@@ -448,6 +555,21 @@ function assertNotBase64Image(dir: NgOptimizedImage) {
448555
}
449556
}
450557

558+
/**
559+
* Verifies that the 'sizes' only includes responsive values.
560+
*/
561+
function assertNoComplexSizes(dir: NgOptimizedImage) {
562+
let sizes = dir.sizes;
563+
if (sizes?.match(/((\)|,)\s|^)\d+px/)) {
564+
throw new RuntimeError(
565+
RuntimeErrorCode.INVALID_INPUT,
566+
`${imgDirectiveDetails(dir.ngSrc, false)} \`sizes\` was set to a string including ` +
567+
`pixel values. For automatic \`srcset\` generation, \`sizes\` must only include responsive ` +
568+
`values, such as \`sizes="50vw"\` or \`sizes="(min-width: 768px) 50vw, 100vw"\`. ` +
569+
`To fix this, modify the \`sizes\` attribute, or provide your own \`ngSrcset\` value directly.`);
570+
}
571+
}
572+
451573
/**
452574
* Verifies that the `ngSrc` is not a Blob URL.
453575
*/

0 commit comments

Comments
 (0)