feat(common): Add loaderParams attribute to NgOptimizedImage (#48907)

Add a new loaderParams attribute, which can be used to send arbitrary data to a custom loader, allowing for greater control of image CDN features.

PR Close #48907
This commit is contained in:
Alex Castle 2023-02-01 15:38:19 -08:00 committed by Andrew Scott
parent 759db12e0b
commit 54b24eb40f
7 changed files with 212 additions and 24 deletions

View file

@ -276,9 +276,41 @@ providers: [
],
</code-example>
A loader function for the `NgOptimizedImage` directive takes an object with the `ImageLoaderConfig` type (from `@angular/common`) as its argument and returns the absolute URL of the image asset. The `ImageLoaderConfig` object contains the `src` and `width` properties.
A loader function for the `NgOptimizedImage` directive takes an object with the `ImageLoaderConfig` type (from `@angular/common`) as its argument and returns the absolute URL of the image asset. The `ImageLoaderConfig` object contains the `src` property, and optional `width` and `loaderParams` properties.
Note: a custom loader must support requesting images at various widths in order for `ngSrcset` to work properly.
Note: even though the `width` property may not always be present, a custom loader must use it to support requesting images at various widths in order for `ngSrcset` to work properly.
### The `loaderParams` Property
There is an additional attribute supported by the `NgOptimizedImage` directive, called `loaderParams`, which is specifically designed to support the use of custom loaders. The `loaderParams` attribute take an object with any properties as a value, and does not do anything on its own. The data in `loaderParams` is added to the `ImageLoaderConfig` object passed to your custom loader, and can be used to control the behavior of the loader.
A common use for `loaderParams` is controlling advanced image CDN features.
### Example custom loader
The following shows an example of a custom loader function. This example function concatenates `src` and `width`, and uses `loaderParams` to control a custom CDN feature for rounded corners:
<code-example format="typescript" language="typescript">
const myCustomLoader = (config: ImageLoaderConfig) => {
let url = `https://example.com/images/${config.src}?`;
let queryParams = [];
if (config.width) {
queryParams.push(`w=${config.width}`);
}
if (config.loaderParams?.roundedCorners) {
queryParams.push('mask=corners&corner-radius=5');
}
return url + queryParams.join('&');
};
</code-example>
Note that in the above example, we've invented the 'roundedCorners' property name to control a feature of our custom loader. We could then use this feature when creating an image, as follows:
<code-example format="html" language="html">
&lt;img ngSrc="profile.jpg" width="300" height="300" [loaderParams]="{roundedCorners: true}"&gt;
</code-example>
<!-- links -->

View file

@ -17,9 +17,9 @@ export const enum RuntimeErrorCode {
// (undocumented)
MISSING_BUILTIN_LOADER = 2962,
// (undocumented)
NG_FOR_MISSING_DIFFER = -2200,
MISSING_NECESSARY_LOADER = 2963,
// (undocumented)
NGSRCSET_WITHOUT_LOADER = 2963,
NG_FOR_MISSING_DIFFER = -2200,
// (undocumented)
OVERSIZED_IMAGE = 2960,
// (undocumented)

View file

@ -327,6 +327,9 @@ export type ImageLoader = (config: ImageLoaderConfig) => string;
// @public
export interface ImageLoaderConfig {
loaderParams?: {
[key: string]: any;
};
src: string;
width?: number;
}
@ -605,6 +608,9 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
set height(value: string | number | undefined);
// (undocumented)
get height(): number | undefined;
loaderParams?: {
[key: string]: any;
};
loading?: 'lazy' | 'eager' | 'auto';
// (undocumented)
ngOnChanges(changes: SimpleChanges): void;
@ -622,7 +628,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
// (undocumented)
get width(): number | undefined;
// (undocumented)
static ɵdir: i0.ɵɵDirectiveDeclaration<NgOptimizedImage, "img[ngSrc]", never, { "ngSrc": "ngSrc"; "ngSrcset": "ngSrcset"; "sizes": "sizes"; "width": "width"; "height": "height"; "loading": "loading"; "priority": "priority"; "disableOptimizedSrcset": "disableOptimizedSrcset"; "fill": "fill"; "src": "src"; "srcset": "srcset"; }, {}, never, never, true, never>;
static ɵdir: i0.ɵɵDirectiveDeclaration<NgOptimizedImage, "img[ngSrc]", never, { "ngSrc": "ngSrc"; "ngSrcset": "ngSrcset"; "sizes": "sizes"; "width": "width"; "height": "height"; "loading": "loading"; "priority": "priority"; "loaderParams": "loaderParams"; "disableOptimizedSrcset": "disableOptimizedSrcset"; "fill": "fill"; "src": "src"; "srcset": "srcset"; }, {}, never, never, true, never>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<NgOptimizedImage, never>;
}

View file

@ -27,6 +27,10 @@ export interface ImageLoaderConfig {
* Width of the requested image (to be used when generating srcset).
*/
width?: number;
/**
* Additional user-provided parameters for use by the ImageLoader.
*/
loaderParams?: {[key: string]: any;};
}
/**

View file

@ -13,7 +13,7 @@ import {isPlatformServer} from '../../platform_id';
import {imgDirectiveDetails} from './error_helper';
import {cloudinaryLoaderInfo} from './image_loaders/cloudinary_loader';
import {IMAGE_LOADER, ImageLoader, noopImageLoader} from './image_loaders/image_loader';
import {IMAGE_LOADER, ImageLoader, ImageLoaderConfig, noopImageLoader} from './image_loaders/image_loader';
import {imageKitLoaderInfo} from './image_loaders/imagekit_loader';
import {imgixLoaderInfo} from './image_loaders/imgix_loader';
import {LCPImageObserver} from './lcp_image_observer';
@ -317,6 +317,11 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
}
private _priority = false;
/**
* Data to pass through to custom loaders.
*/
@Input() loaderParams?: {[key: string]: any};
/**
* Disables automatic srcset generation for this image.
*/
@ -386,6 +391,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
}
assertNotMissingBuiltInLoader(this.ngSrc, this.imageLoader);
assertNoNgSrcsetWithoutLoader(this, this.imageLoader);
assertNoLoaderParamsWithoutLoader(this, this.imageLoader);
if (this.priority) {
const checker = this.injector.get(PreconnectLinkChecker);
checker.assertPreconnect(this.getRewrittenSrc(), this.ngSrc);
@ -462,11 +468,21 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
'fill',
'loading',
'sizes',
'loaderParams',
'disableOptimizedSrcset',
]);
}
}
private callImageLoader(configWithoutCustomParams: Omit<ImageLoaderConfig, 'loaderParams'>):
string {
let augmentedConfig: ImageLoaderConfig = configWithoutCustomParams;
if (this.loaderParams) {
augmentedConfig.loaderParams = this.loaderParams;
}
return this.imageLoader(augmentedConfig);
}
private getLoadingBehavior(): string {
if (!this.priority && this.loading !== undefined) {
return this.loading;
@ -485,7 +501,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
if (!this._renderedSrc) {
const imgConfig = {src: this.ngSrc};
// Cache calculated image src to reuse it later in the code.
this._renderedSrc = this.imageLoader(imgConfig);
this._renderedSrc = this.callImageLoader(imgConfig);
}
return this._renderedSrc;
}
@ -495,7 +511,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
const finalSrcs = this.ngSrcset.split(',').filter(src => src !== '').map(srcStr => {
srcStr = srcStr.trim();
const width = widthSrcSet ? parseFloat(srcStr) : parseFloat(srcStr) * this.width!;
return `${this.imageLoader({src: this.ngSrc, width})} ${srcStr}`;
return `${this.callImageLoader({src: this.ngSrc, width})} ${srcStr}`;
});
return finalSrcs.join(', ');
}
@ -518,15 +534,16 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
filteredBreakpoints = breakpoints!.filter(bp => bp >= VIEWPORT_BREAKPOINT_CUTOFF);
}
const finalSrcs =
filteredBreakpoints.map(bp => `${this.imageLoader({src: this.ngSrc, width: bp})} ${bp}w`);
const finalSrcs = filteredBreakpoints.map(
bp => `${this.callImageLoader({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`);
const finalSrcs = DENSITY_SRCSET_MULTIPLIERS.map(multiplier => `${this.callImageLoader({
src: this.ngSrc,
width: this.width! * multiplier
})} ${multiplier}x`);
return finalSrcs.join(', ');
}
@ -961,10 +978,25 @@ function assertNotMissingBuiltInLoader(ngSrc: string, imageLoader: ImageLoader)
function assertNoNgSrcsetWithoutLoader(dir: NgOptimizedImage, imageLoader: ImageLoader) {
if (dir.ngSrcset && imageLoader === noopImageLoader) {
console.warn(formatRuntimeError(
RuntimeErrorCode.NGSRCSET_WITHOUT_LOADER,
RuntimeErrorCode.MISSING_NECESSARY_LOADER,
`${imgDirectiveDetails(dir.ngSrc)} the \`ngSrcset\` attribute is present but ` +
`no image loader is configured (i.e. the default one is being used), ` +
`which would result in the same image being used for all configured sizes. ` +
`To fix this, provide a loader or remove the \`ngSrcset\` attribute from the image.`));
}
}
/**
* Warns if loaderParams is present and no loader is configured (i.e. the default one is being
* used).
*/
function assertNoLoaderParamsWithoutLoader(dir: NgOptimizedImage, imageLoader: ImageLoader) {
if (dir.loaderParams && imageLoader === noopImageLoader) {
console.warn(formatRuntimeError(
RuntimeErrorCode.MISSING_NECESSARY_LOADER,
`${imgDirectiveDetails(dir.ngSrc)} the \`loaderParams\` attribute is present but ` +
`no image loader is configured (i.e. the default one is being used), ` +
`which means that the loaderParams data will not be consumed and will not affect the URL. ` +
`To fix this, provide a custom loader or remove the \`loaderParams\` attribute from the image.`));
}
}

View file

@ -33,5 +33,5 @@ export const enum RuntimeErrorCode {
OVERSIZED_IMAGE = 2960,
TOO_MANY_PRELOADED_IMAGES = 2961,
MISSING_BUILTIN_LOADER = 2962,
NGSRCSET_WITHOUT_LOADER = 2963,
MISSING_NECESSARY_LOADER = 2963,
}

View file

@ -642,14 +642,9 @@ describe('Image directive', () => {
});
const inputs = [
['ngSrc', 'new-img.png'],
['width', 10],
['height', 20],
['priority', true],
['fill', true],
['loading', true],
['sizes', '90vw'],
['disableOptimizedSrcset', true],
['ngSrc', 'new-img.png'], ['width', 10], ['height', 20], ['priority', true], ['fill', true],
['loading', true], ['sizes', '90vw'], ['disableOptimizedSrcset', true],
['loaderParams', '{foo: "test1"}']
];
inputs.forEach(([inputName, value]) => {
it(`should throw if the \`${inputName}\` input changed after directive initialized the input`,
@ -665,6 +660,7 @@ describe('Image directive', () => {
[loading]="loading"
[sizes]="sizes"
[disableOptimizedSrcset]="disableOptimizedSrcset"
[loaderParams]="loaderParams"
>`
})
class TestComponent {
@ -676,6 +672,7 @@ describe('Image directive', () => {
loading = false;
sizes = '100vw';
disableOptimizedSrcset = false;
loaderParams = {bar: 'test2'};
}
setupTestingModule({component: TestComponent});
@ -1121,6 +1118,26 @@ describe('Image directive', () => {
});
describe('loaders', () => {
const imageLoaderWithData = (config: ImageLoaderConfig) => {
let paramsString = '';
if (config.loaderParams) {
paramsString =
Object.entries(config.loaderParams).map(entry => `${entry[0]}=${entry[1]}`).join('&');
}
let queryString = `${config.width ? 'w=' + config.width + '&' : ''}${paramsString}`;
return `${config.src}?${queryString}`;
};
// Test complex loaderParams schema with nesting:
// loaderParams = {
// transforms1: {example1: "foo"},
// transforms2: {example2: "bar"}
// }
const nestedImageLoader = (config: ImageLoaderConfig) => {
return `${config.src}/${config.loaderParams?.transforms1.example1}/${
config.loaderParams?.transforms2.example2}`;
};
it('should set `src` to match `ngSrc` if image loader is not provided', () => {
setupTestingModule();
@ -1194,13 +1211,36 @@ describe('Image directive', () => {
expect(consoleWarnSpy.calls.count()).toBe(1);
expect(consoleWarnSpy.calls.argsFor(0)[0])
.toBe(
'NG02963: The NgOptimizedImage directive (activated on an <img> element ' +
`NG0${
RuntimeErrorCode
.MISSING_NECESSARY_LOADER}: The NgOptimizedImage directive (activated on an <img> element ` +
'with the `ngSrc="img.png"`) has detected that the `ngSrcset` attribute is ' +
'present but no image loader is configured (i.e. the default one is being used), ' +
`which would result in the same image being used for all configured sizes. ` +
'To fix this, provide a loader or remove the `ngSrcset` attribute from the image.');
});
it('should warn if there is no image loader but `loaderParams` is present', () => {
setUpModuleNoLoader();
const template =
`<img ngSrc="img.png" width="150" height="50" [loaderParams]="{foo: 'test'}">`;
const fixture = createTestComponent(template);
const consoleWarnSpy = spyOn(console, 'warn');
fixture.detectChanges();
expect(consoleWarnSpy.calls.count()).toBe(1);
expect(consoleWarnSpy.calls.argsFor(0)[0])
.toBe(
`NG0${
RuntimeErrorCode
.MISSING_NECESSARY_LOADER}: The NgOptimizedImage directive (activated on an <img> element ` +
'with the `ngSrc="img.png"`) has detected that the `loaderParams` attribute is ' +
'present but no image loader is configured (i.e. the default one is being used), ' +
`which means that the loaderParams data will not be consumed and will not affect the URL. ` +
'To fix this, provide a custom loader or remove the `loaderParams` attribute from the image.');
});
it('should set `src` using the image loader provided via the `IMAGE_LOADER` token to compose src URL',
() => {
const imageLoader = (config: ImageLoaderConfig) => `${IMG_BASE_URL}/${config.src}`;
@ -1235,6 +1275,80 @@ describe('Image directive', () => {
expect(imgs[0].src.trim()).toBe(`${IMG_BASE_URL}/img.png?rewritten=true`);
});
it('should pass data payload from loaderParams to custom image loaders', () => {
setupTestingModule({imageLoader: imageLoaderWithData});
const template = `
<img ngSrc="${IMG_BASE_URL}/img.png" width="150" height="50"
[loaderParams]="{testProp1: 'testValue1', testProp2: 'testValue2'}" />
`;
const fixture = createTestComponent(template);
fixture.detectChanges();
const nativeElement = fixture.nativeElement as HTMLElement;
const imgs = nativeElement.querySelectorAll('img')!;
expect(imgs[0].src).toBe(`${IMG_BASE_URL}/img.png?testProp1=testValue1&testProp2=testValue2`);
});
it('should pass nested data payloads from loaderParams to custom image loaders', () => {
@Component({
selector: 'test-cmp',
template: `<img
[ngSrc]="ngSrc"
[width]="width"
[height]="height"
[loaderParams]="params"
>`
})
class TestComponent {
ngSrc = `${IMG_BASE_URL}/img.png`;
width = 300;
height = 300;
params = {transforms1: {example1: 'foo'}, transforms2: {example2: 'bar'}};
}
setupTestingModule({imageLoader: nestedImageLoader, component: TestComponent});
const fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
const nativeElement = fixture.nativeElement as HTMLElement;
const imgs = nativeElement.querySelectorAll('img')!;
expect(imgs[0].src).toBe(`${IMG_BASE_URL}/img.png/foo/bar`);
});
it('should pass data payload from loaderParams to loader when generating srcsets', () => {
setupTestingModule({imageLoader: imageLoaderWithData});
const template = `
<img ngSrc="${IMG_BASE_URL}/img.png" width="150" height="50"
[loaderParams]="{testProp1: 'testValue1', testProp2: 'testValue2'}" />
`;
const fixture = createTestComponent(template);
fixture.detectChanges();
const nativeElement = fixture.nativeElement as HTMLElement;
const imgs = nativeElement.querySelectorAll('img')!;
expect(imgs[0].srcset)
.toBe(`${IMG_BASE_URL}/img.png?w=150&testProp1=testValue1&testProp2=testValue2 1x, ${
IMG_BASE_URL}/img.png?w=300&testProp1=testValue1&testProp2=testValue2 2x`);
});
it('should pass data payload from loaderParams to loader when generating responsive srcsets',
() => {
setupTestingModule({imageLoader: imageLoaderWithData});
const template = `
<img ngSrc="${IMG_BASE_URL}/img.png" width="150" height="50" sizes="100vw"
[loaderParams]="{testProp1: 'testValue1', testProp2: 'testValue2'}" />
`;
const fixture = createTestComponent(template);
fixture.detectChanges();
const nativeElement = fixture.nativeElement as HTMLElement;
const imgs = nativeElement.querySelectorAll('img')!;
expect(imgs[0].srcset)
.toBe(`${IMG_BASE_URL}/img.png?w=640&testProp1=testValue1&testProp2=testValue2 640w, ${
IMG_BASE_URL}/img.png?w=750&testProp1=testValue1&testProp2=testValue2 750w, ${
IMG_BASE_URL}/img.png?w=828&testProp1=testValue1&testProp2=testValue2 828w, ${
IMG_BASE_URL}/img.png?w=1080&testProp1=testValue1&testProp2=testValue2 1080w, ${
IMG_BASE_URL}/img.png?w=1200&testProp1=testValue1&testProp2=testValue2 1200w, ${
IMG_BASE_URL}/img.png?w=1920&testProp1=testValue1&testProp2=testValue2 1920w, ${
IMG_BASE_URL}/img.png?w=2048&testProp1=testValue1&testProp2=testValue2 2048w, ${
IMG_BASE_URL}/img.png?w=3840&testProp1=testValue1&testProp2=testValue2 3840w`);
});
it('should set `src` to an image URL that does not include a default width parameter', () => {
const imageLoader = (config: ImageLoaderConfig) => {
const widthStr = config.width ? `?w=${config.width}` : ``;