mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(common): Add image lazy loading and fetchpriority (#47082)
PR Close #47082
This commit is contained in:
committed by
Pawel Kozlowski
parent
b58454d129
commit
0566205a02
@@ -6,7 +6,7 @@
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {Directive, Inject, InjectionToken, Input, NgModule, OnInit, ɵRuntimeError as RuntimeError} from '@angular/core';
|
||||
import {Directive, ElementRef, Inject, InjectionToken, Input, NgModule, OnInit, Renderer2, ɵRuntimeError as RuntimeError} from '@angular/core';
|
||||
|
||||
import {RuntimeErrorCode} from '../errors';
|
||||
|
||||
@@ -50,18 +50,16 @@ export const IMAGE_LOADER = new InjectionToken<ImageLoader>('ImageLoader', {
|
||||
* @usageNotes
|
||||
* TODO: add Image directive usage notes.
|
||||
*/
|
||||
@Directive({
|
||||
selector: 'img[rawSrc]',
|
||||
host: {
|
||||
'[src]': 'getRewrittenSrc()',
|
||||
},
|
||||
})
|
||||
@Directive({selector: 'img[rawSrc]'})
|
||||
export class NgOptimizedImage implements OnInit {
|
||||
constructor(@Inject(IMAGE_LOADER) private imageLoader: ImageLoader) {}
|
||||
constructor(
|
||||
@Inject(IMAGE_LOADER) private imageLoader: ImageLoader, private renderer: Renderer2,
|
||||
private imgElement: ElementRef) {}
|
||||
|
||||
// Private fields to keep normalized input values.
|
||||
private _width?: number;
|
||||
private _height?: number;
|
||||
private _priority?: boolean;
|
||||
|
||||
/**
|
||||
* Name of the source image.
|
||||
@@ -94,6 +92,14 @@ export class NgOptimizedImage implements OnInit {
|
||||
return this._height;
|
||||
}
|
||||
|
||||
@Input()
|
||||
set priority(value: string|boolean|undefined) {
|
||||
this._priority = inputToBoolean(value);
|
||||
}
|
||||
get priority(): boolean|undefined {
|
||||
return this._priority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value of the `src` if it's set on a host <img> element.
|
||||
* This input is needed to verify that there are no `src` and `rawSrc` provided
|
||||
@@ -105,6 +111,19 @@ export class NgOptimizedImage implements OnInit {
|
||||
if (ngDevMode) {
|
||||
assertExistingSrc(this);
|
||||
}
|
||||
this.setHostAttribute('loading', this.getLoadingBehavior());
|
||||
this.setHostAttribute('fetchpriority', this.getFetchPriority());
|
||||
// The `src` attribute should be set last since other attributes
|
||||
// could affect the image's loading behavior.
|
||||
this.setHostAttribute('src', this.getRewrittenSrc());
|
||||
}
|
||||
|
||||
getLoadingBehavior(): string {
|
||||
return this.priority ? 'eager' : 'lazy';
|
||||
}
|
||||
|
||||
getFetchPriority(): string {
|
||||
return this.priority ? 'high' : 'auto';
|
||||
}
|
||||
|
||||
getRewrittenSrc(): string {
|
||||
@@ -117,6 +136,10 @@ export class NgOptimizedImage implements OnInit {
|
||||
};
|
||||
return this.imageLoader(imgConfig);
|
||||
}
|
||||
|
||||
private setHostAttribute(name: string, value: string): void {
|
||||
this.renderer.setAttribute(this.imgElement.nativeElement, name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,6 +160,10 @@ function inputToInteger(value: string|number|undefined): number|undefined {
|
||||
return typeof value === 'string' ? parseInt(value, 10) : value;
|
||||
}
|
||||
|
||||
function inputToBoolean(value: unknown): boolean {
|
||||
return value != null && `${value}` !== 'false';
|
||||
}
|
||||
|
||||
function imgDirectiveDetails(dir: NgOptimizedImage) {
|
||||
return `The NgOptimizedImage directive (activated on an <img> element ` +
|
||||
`with the \`rawSrc="${dir.rawSrc}"\`)`;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {CommonModule, DOCUMENT} from '@angular/common';
|
||||
import {IMAGE_LOADER, ImageLoader, ImageLoaderConfig, NgOptimizedImage} from '@angular/common/src/directives/ng_optimized_image';
|
||||
import {Component} from '@angular/core';
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
@@ -25,6 +25,57 @@ describe('Image directive', () => {
|
||||
expect(img.src.endsWith('/path/img.png')).toBeTrue();
|
||||
});
|
||||
|
||||
it('should set `loading` and `fetchpriority` attributes before `src`', () => {
|
||||
// Only run this test in a browser since the Node-based DOM mocks don't
|
||||
// allow to override `HTMLImageElement.prototype.setAttribute` easily.
|
||||
if (!isBrowser) return;
|
||||
|
||||
setupTestingModule();
|
||||
|
||||
const template = '<img rawSrc="path/img.png" width="150" height="50" priority>';
|
||||
TestBed.overrideComponent(TestComponent, {set: {template: template}});
|
||||
|
||||
const _document = TestBed.inject(DOCUMENT);
|
||||
const _window = _document.defaultView!;
|
||||
const setAttributeSpy =
|
||||
spyOn(_window.HTMLImageElement.prototype, 'setAttribute').and.callThrough();
|
||||
|
||||
const fixture = TestBed.createComponent(TestComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
|
||||
const img = nativeElement.querySelector('img')!;
|
||||
expect(img.getAttribute('loading')).toBe('eager');
|
||||
|
||||
let _imgInstance = null;
|
||||
let _loadingAttrId = -1;
|
||||
let _fetchpriorityAttrId = -1;
|
||||
let _srcAttrId = -1;
|
||||
const count = setAttributeSpy.calls.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (!_imgInstance) {
|
||||
_imgInstance = setAttributeSpy.calls.thisFor(i);
|
||||
} else if (_imgInstance !== setAttributeSpy.calls.thisFor(i)) {
|
||||
// Verify that the <img> instance is the same during the test.
|
||||
fail('Unexpected instance of a second <img> instance present in a test.');
|
||||
}
|
||||
|
||||
// Note: spy.calls.argsFor(i) returns args as an array: ['src', 'eager']
|
||||
const attrName = setAttributeSpy.calls.argsFor(i)[0];
|
||||
if (attrName == 'loading') _loadingAttrId = i;
|
||||
if (attrName == 'fetchpriority') _fetchpriorityAttrId = i;
|
||||
if (attrName == 'src') _srcAttrId = i;
|
||||
}
|
||||
// Verify that both `loading` and `fetchpriority` are set *before* `src`:
|
||||
expect(_loadingAttrId).toBeGreaterThan(-1); // was actually set
|
||||
expect(_loadingAttrId).toBeLessThan(_srcAttrId); // was set after `src`
|
||||
|
||||
expect(_fetchpriorityAttrId).toBeGreaterThan(-1); // was actually set
|
||||
expect(_fetchpriorityAttrId).toBeLessThan(_srcAttrId); // was set after `src`
|
||||
});
|
||||
|
||||
|
||||
it('should use an image loader provided via `IMAGE_LOADER` token', () => {
|
||||
const imageLoader = (config: ImageLoaderConfig) => `${config.src}?w=${config.width}`;
|
||||
setupTestingModule({imageLoader});
|
||||
@@ -54,6 +105,56 @@ describe('Image directive', () => {
|
||||
'the `rawSrc` to compute the final image URL and set the `src` itself.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lazy loading', () => {
|
||||
it('should eagerly load priority images', () => {
|
||||
setupTestingModule();
|
||||
|
||||
const template = '<img rawSrc="path/img.png" width="150" height="50" priority>';
|
||||
const fixture = createTestComponent(template);
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
const img = nativeElement.querySelector('img')!;
|
||||
expect(img.getAttribute('loading')).toBe('eager');
|
||||
});
|
||||
it('should lazily load non-priority images', () => {
|
||||
setupTestingModule();
|
||||
|
||||
const template = '<img rawSrc="path/img.png" width="150" height="50">';
|
||||
const fixture = createTestComponent(template);
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
const img = nativeElement.querySelector('img')!;
|
||||
expect(img.getAttribute('loading')).toBe('lazy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch priority', () => {
|
||||
it('should be "high" for priority images', () => {
|
||||
setupTestingModule();
|
||||
|
||||
const template = '<img rawSrc="path/img.png" width="150" height="50" priority>';
|
||||
const fixture = createTestComponent(template);
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
const img = nativeElement.querySelector('img')!;
|
||||
expect(img.getAttribute('fetchpriority')).toBe('high');
|
||||
});
|
||||
it('should be "auto" for non-priority images', () => {
|
||||
setupTestingModule();
|
||||
|
||||
const template = '<img rawSrc="path/img.png" width="150" height="50">';
|
||||
const fixture = createTestComponent(template);
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
const img = nativeElement.querySelector('img')!;
|
||||
expect(img.getAttribute('fetchpriority')).toBe('auto');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helpers
|
||||
|
||||
Reference in New Issue
Block a user