mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
759db12e0b
commit
54b24eb40f
7 changed files with 212 additions and 24 deletions
|
|
@ -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">
|
||||
|
||||
<img ngSrc="profile.jpg" width="300" height="300" [loaderParams]="{roundedCorners: true}">
|
||||
|
||||
</code-example>
|
||||
|
||||
<!-- links -->
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.`));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}` : ``;
|
||||
|
|
|
|||
Loading…
Reference in a new issue