mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-22 21:48:42 +08:00
fix(img): use img tag due to cordova limitations
This commit is contained in:
@ -1,175 +0,0 @@
|
||||
|
||||
export class ImgLoader {
|
||||
private imgs: ImgData[] = [];
|
||||
|
||||
load(src: string, useCache: boolean, callback: ImgLoadCallback) {
|
||||
// see if we already have image data for this src
|
||||
let img = this.imgs.find(i => i.src === src);
|
||||
|
||||
if (img && img.datauri && useCache) {
|
||||
// we found image data, and it's cool if we use the cache
|
||||
// so let's respond with the cached data
|
||||
callback(200, null, img.datauri);
|
||||
return;
|
||||
}
|
||||
|
||||
// so no cached image data, so we'll
|
||||
// need to do a new http request
|
||||
|
||||
if (img && img.xhr && img.xhr.readyState !== 4) {
|
||||
// looks like there's already an active http request going on
|
||||
// for this same source, so let's just add another listener
|
||||
img.xhr.addEventListener('load', (xhrEvent) => {
|
||||
const target: any = xhrEvent.target;
|
||||
const contentType = target.getResponseHeader('Content-Type');
|
||||
onXhrLoad(callback, target.status, contentType, target.response, useCache, img, this.imgs);
|
||||
});
|
||||
img.xhr.addEventListener('error', (xhrErrorEvent) => {
|
||||
onXhrError(callback, img, xhrErrorEvent);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!img) {
|
||||
// no image data yet, so let's create it
|
||||
img = { src: src, len: 0 };
|
||||
this.imgs.push(img);
|
||||
}
|
||||
|
||||
// ok, let's do a full request for the image
|
||||
img.xhr = new XMLHttpRequest();
|
||||
img.xhr.open('GET', src, true);
|
||||
img.xhr.responseType = 'arraybuffer';
|
||||
|
||||
// add the listeners if it loaded or errored
|
||||
img.xhr.addEventListener('load', (xhrEvent) => {
|
||||
const target: any = xhrEvent.target;
|
||||
const contentType = target.getResponseHeader('Content-Type');
|
||||
onXhrLoad(callback, target.status, contentType, target.response, useCache, img, this.imgs);
|
||||
});
|
||||
img.xhr.addEventListener('error', (xhrErrorEvent) => {
|
||||
onXhrError(callback, img, xhrErrorEvent);
|
||||
});
|
||||
|
||||
// awesome, let's kick off the request
|
||||
img.xhr.send();
|
||||
}
|
||||
|
||||
abort(src: string) {
|
||||
const img = this.imgs.find(i => i.src === src);
|
||||
if (img && img.xhr && img.xhr.readyState !== 4) {
|
||||
// we found the image data and there's an active
|
||||
// http request, so let's abort the request
|
||||
img.xhr.abort();
|
||||
img.xhr = null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
export function onXhrLoad(callback: ImgLoadCallback, status: number, contentType: string, responseData: ArrayBuffer, useCache: boolean, img: ImgData, imgs: ImgData[]) {
|
||||
if (!callback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// the http request has been loaded
|
||||
// create a rsp object to send back to the main thread
|
||||
let datauri: string = null;
|
||||
|
||||
if (status === 200) {
|
||||
// success!!
|
||||
// now let's convert the response arraybuffer data into a datauri
|
||||
datauri = getDataUri(contentType, responseData);
|
||||
|
||||
if (useCache) {
|
||||
// if the image was successfully downloaded
|
||||
// and this image is allowed to be cached
|
||||
// then let's add it to our image data for later use
|
||||
img.datauri = datauri;
|
||||
img.len = datauri.length;
|
||||
|
||||
cleanCache(imgs, CACHE_LIMIT);
|
||||
}
|
||||
}
|
||||
|
||||
// fire the callback with what we've learned today
|
||||
callback(status, null, datauri);
|
||||
}
|
||||
|
||||
|
||||
export function cleanCache(imgs: ImgData[], cacheLimit: number) {
|
||||
// let's loop through all our cached data and if we go
|
||||
// over our limit then let's clean it out a bit
|
||||
// oldest data should go first
|
||||
let cacheSize = 0;
|
||||
for (var i = imgs.length - 1; i >= 0; i--) {
|
||||
cacheSize += imgs[i].len;
|
||||
if (cacheSize > cacheLimit) {
|
||||
console.debug(`img-loader, clear cache`);
|
||||
imgs.splice(0, i + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function onXhrError(callback: ImgLoadCallback, imgData: ImgData, err: ErrorEvent) {
|
||||
// darn, we got an error!
|
||||
callback && callback(0, (err.message || ''), null);
|
||||
imgData.xhr = null;
|
||||
}
|
||||
|
||||
|
||||
function getDataUri(contentType, arrayBuffer): string {
|
||||
// take arraybuffer and content type and turn it into
|
||||
// a datauri string that can be used by <img>
|
||||
const rtn: string[] = ['data:' + contentType + ';base64,'];
|
||||
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
const byteLength = bytes.byteLength;
|
||||
const byteRemainder = byteLength % 3;
|
||||
const mainLength = byteLength - byteRemainder;
|
||||
let i, a, b, c, d, chunk;
|
||||
|
||||
for (i = 0; i < mainLength; i = i + 3) {
|
||||
chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
|
||||
a = (chunk & 16515072) >> 18;
|
||||
b = (chunk & 258048) >> 12;
|
||||
c = (chunk & 4032) >> 6;
|
||||
d = chunk & 63;
|
||||
rtn.push(ENCODINGS[a] + ENCODINGS[b] + ENCODINGS[c] + ENCODINGS[d]);
|
||||
}
|
||||
|
||||
if (byteRemainder === 1) {
|
||||
chunk = bytes[mainLength];
|
||||
a = (chunk & 252) >> 2;
|
||||
b = (chunk & 3) << 4;
|
||||
rtn.push(ENCODINGS[a] + ENCODINGS[b] + '==');
|
||||
|
||||
} else if (byteRemainder === 2) {
|
||||
chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1];
|
||||
a = (chunk & 64512) >> 10;
|
||||
b = (chunk & 1008) >> 4;
|
||||
c = (chunk & 15) << 2;
|
||||
rtn.push(ENCODINGS[a] + ENCODINGS[b] + ENCODINGS[c] + '=');
|
||||
}
|
||||
|
||||
return rtn.join('');
|
||||
}
|
||||
|
||||
// used by the setData function
|
||||
const ENCODINGS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
const CACHE_LIMIT = 1381855 * 20;
|
||||
|
||||
export interface ImgData {
|
||||
src: string;
|
||||
datauri?: string;
|
||||
len?: number;
|
||||
xhr?: XMLHttpRequest;
|
||||
}
|
||||
|
||||
export type ImgLoadCallback = {
|
||||
(status: number, msg: string, datauri: string): void;
|
||||
}
|
@ -2,8 +2,8 @@ import { ChangeDetectionStrategy, Component, ElementRef, Input, NgZone, OnDestro
|
||||
|
||||
import { Content } from '../content/content';
|
||||
import { DomController } from '../../util/dom-controller';
|
||||
import { ImgLoader, ImgLoadCallback } from './img-loader';
|
||||
import { isPresent, isTrueProperty } from '../../util/util';
|
||||
import { listenEvent, eventOptions } from '../../util/ui-event-manager';
|
||||
import { Platform } from '../../platform/platform';
|
||||
|
||||
|
||||
@ -80,40 +80,12 @@ import { Platform } from '../../platform/platform';
|
||||
* Its concrete object size is resolved as a cover constraint against the
|
||||
* element’s used width and height.
|
||||
*
|
||||
* ### Future Optimizations
|
||||
*
|
||||
* ### Web Worker and XHR Requests
|
||||
*
|
||||
* Another big cause of scroll jank is kicking off a new HTTP request,
|
||||
* which is exactly what images do. Normally, this isn't a problem for
|
||||
* something like a blog since all image HTTP requests are started immediately
|
||||
* as HTML parses. However, Ionic has the ability to include hundreds, or even
|
||||
* thousands of images within one page, but its not actually loading all of
|
||||
* the images at the same time.
|
||||
*
|
||||
* Imagine an app where users can scroll slowly, or very quickly, through
|
||||
* thousands of images. If they're scrolling extremely fast, ideally the app
|
||||
* wouldn't want to start all of those image requests, but if they're scrolling
|
||||
* slowly they would. Additionally, most browsers can only have six requests at
|
||||
* one time for the same domain, so it's extemely important that we're managing
|
||||
* exacctly which images we should downloading. Basically we want to ensure
|
||||
* that the app is requesting the most important images, and aborting
|
||||
* unnecessary requests, which is another benefit of using `ion-img`.
|
||||
*
|
||||
* Next, by running the image request within a web worker, we're able to pass
|
||||
* off the heavy lifting to another thread. Not only are able to take the load
|
||||
* of the main thread, but we're also able to accurately control exactly which
|
||||
* images should be downloading, along with the ability to abort unnecessary
|
||||
* requests. Aborting requets is just as important so that Ionic can free up
|
||||
* connections for the most important images which are visible.
|
||||
*
|
||||
* One restriction however, is that all image requests must work with
|
||||
* [cross-origin HTTP requests (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS).
|
||||
* Traditionally, the `img` element does not have this issue, but because
|
||||
* `ion-img` uses `XMLHttpRequest` within a web worker, then requests for
|
||||
* images must be served from the same domain, or the image server's response
|
||||
* must set the `Access-Control-Allow-Origin` HTTP header. Again, if your app
|
||||
* does not have the same problems which `ion-img` is solving, then it's
|
||||
* recommended to just use the standard `img` HTML element instead.
|
||||
* Future goals are to place image requests within web workers, and cache
|
||||
* images in-memory as datauris. This method has proven to be effective,
|
||||
* however there are some current limitations with Cordova which we are
|
||||
* currently working on.
|
||||
*
|
||||
*/
|
||||
@Component({
|
||||
@ -130,12 +102,10 @@ export class Img implements OnDestroy {
|
||||
/** @internal */
|
||||
_renderedSrc: string;
|
||||
/** @internal */
|
||||
_tmpDataUri: string;
|
||||
_hasLoaded: boolean;
|
||||
/** @internal */
|
||||
_cache: boolean = true;
|
||||
/** @internal */
|
||||
_cb: ImgLoadCallback;
|
||||
/** @internal */
|
||||
_bounds: any;
|
||||
/** @internal */
|
||||
_rect: any;
|
||||
@ -147,6 +117,10 @@ export class Img implements OnDestroy {
|
||||
_wQ: string = '';
|
||||
/** @internal */
|
||||
_hQ: string = '';
|
||||
/** @internal */
|
||||
_img: HTMLImageElement;
|
||||
/** @internal */
|
||||
_unreg: Function;
|
||||
|
||||
/** @private */
|
||||
canRequest: boolean;
|
||||
@ -155,7 +129,6 @@ export class Img implements OnDestroy {
|
||||
|
||||
|
||||
constructor(
|
||||
private _ldr: ImgLoader,
|
||||
private _elementRef: ElementRef,
|
||||
private _renderer: Renderer,
|
||||
private _platform: Platform,
|
||||
@ -191,11 +164,11 @@ export class Img implements OnDestroy {
|
||||
|
||||
if (newSrc.indexOf('data:') === 0) {
|
||||
// they're using an actual datauri already
|
||||
this._tmpDataUri = newSrc;
|
||||
this._hasLoaded = true;
|
||||
|
||||
} else {
|
||||
// reset any existing datauri we might be holding onto
|
||||
this._tmpDataUri = null;
|
||||
this._hasLoaded = false;
|
||||
}
|
||||
|
||||
// run update to kick off requests or render if everything is good
|
||||
@ -210,7 +183,7 @@ export class Img implements OnDestroy {
|
||||
if (this._requestingSrc) {
|
||||
// abort any active requests
|
||||
console.debug(`abortRequest ${this._requestingSrc} ${Date.now()}`);
|
||||
this._ldr.abort(this._requestingSrc);
|
||||
this._srcAttr('');
|
||||
this._requestingSrc = null;
|
||||
}
|
||||
if (this._renderedSrc) {
|
||||
@ -228,61 +201,34 @@ export class Img implements OnDestroy {
|
||||
// only attempt an update if there is an active src
|
||||
// and the content containing the image considers it updatable
|
||||
if (this._src && this._content.isImgsUpdatable()) {
|
||||
if (this.canRequest && (this._src !== this._renderedSrc && this._src !== this._requestingSrc) && !this._tmpDataUri) {
|
||||
if (this.canRequest && (this._src !== this._renderedSrc && this._src !== this._requestingSrc) && !this._hasLoaded) {
|
||||
// only begin the request if we "can" request
|
||||
// begin the image request if the src is different from the rendered src
|
||||
// and if we don't already has a tmpDataUri
|
||||
console.debug(`request ${this._src} ${Date.now()}`);
|
||||
this._requestingSrc = this._src;
|
||||
|
||||
this._cb = (status, msg, datauri) => {
|
||||
this._loadResponse(status, msg, datauri);
|
||||
this._cb = null;
|
||||
};
|
||||
|
||||
// post the message to the web worker
|
||||
this._ldr.load(this._src, this._cache, this._cb);
|
||||
this._isLoaded(false);
|
||||
this._srcAttr(this._src);
|
||||
|
||||
// set the dimensions of the image if we do have different data
|
||||
this._setDims();
|
||||
}
|
||||
|
||||
if (this.canRender && this._tmpDataUri && this._src !== this._renderedSrc) {
|
||||
if (this.canRender && this._hasLoaded && this._src !== this._renderedSrc) {
|
||||
// we can render and we have a datauri to render
|
||||
this._renderedSrc = this._src;
|
||||
this._setDims();
|
||||
this._dom.write(() => {
|
||||
if (this._tmpDataUri) {
|
||||
if (this._hasLoaded) {
|
||||
console.debug(`render ${this._src} ${Date.now()}`);
|
||||
this._isLoaded(true);
|
||||
this._srcAttr(this._tmpDataUri);
|
||||
this._tmpDataUri = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _loadResponse(status: number, msg: string, datauri: string) {
|
||||
this._requestingSrc = null;
|
||||
|
||||
if (status === 200) {
|
||||
// success :)
|
||||
this._tmpDataUri = datauri;
|
||||
this.update();
|
||||
|
||||
} else {
|
||||
// error :(
|
||||
if (status) {
|
||||
console.error(`img, status: ${status} ${msg}`);
|
||||
}
|
||||
this._renderedSrc = this._tmpDataUri = null;
|
||||
this._dom.write(() => {
|
||||
this._isLoaded(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
@ -297,11 +243,10 @@ export class Img implements OnDestroy {
|
||||
* @internal
|
||||
*/
|
||||
_srcAttr(srcAttr: string) {
|
||||
const imgEle = this._elementRef.nativeElement.firstChild;
|
||||
const renderer = this._renderer;
|
||||
|
||||
renderer.setElementAttribute(imgEle, 'src', srcAttr);
|
||||
renderer.setElementAttribute(imgEle, 'alt', this.alt);
|
||||
renderer.setElementAttribute(this._img, 'src', srcAttr);
|
||||
renderer.setElementAttribute(this._img, 'alt', this.alt);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -409,11 +354,25 @@ export class Img implements OnDestroy {
|
||||
*/
|
||||
@Input() alt: string = '';
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
ngAfterContentInit() {
|
||||
this._img = this._elementRef.nativeElement.firstChild;
|
||||
|
||||
this._unreg && this._unreg();
|
||||
const opts = eventOptions(false, true);
|
||||
this._unreg = listenEvent(this._img, 'load', false, opts, () => {
|
||||
this._hasLoaded = true;
|
||||
this.update();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
ngOnDestroy() {
|
||||
this._cb = null;
|
||||
this._unreg && this._unreg();
|
||||
this._content && this._content.removeImg(this);
|
||||
}
|
||||
|
||||
|
@ -1,55 +1,12 @@
|
||||
import { ElementRef, Renderer } from '@angular/core';
|
||||
import { Content } from '../../content/content';
|
||||
import { Img } from '../img';
|
||||
import { ImgLoader, ImgData, ImgLoadCallback, cleanCache, onXhrLoad } from '../img-loader';
|
||||
import { mockContent, MockDomController, mockElementRef, mockPlatform, mockRenderer, mockZone } from '../../../util/mock-providers';
|
||||
import { Platform } from '../../../platform/platform';
|
||||
|
||||
|
||||
describe('Img', () => {
|
||||
|
||||
describe('cleanCache', () => {
|
||||
|
||||
it('should clean out oldest img data when passing cache limit', () => {
|
||||
const imgs: ImgData[] = [
|
||||
{ src: 'img1.jpg', len: 100 },
|
||||
{ src: 'img2.jpg', len: 0 },
|
||||
{ src: 'img3.jpg', len: 100 },
|
||||
{ src: 'img4.jpg', len: 100 },
|
||||
];
|
||||
cleanCache(imgs, 100);
|
||||
expect(imgs.length).toEqual(1);
|
||||
expect(imgs[0].src).toEqual('img4.jpg');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('onXhrLoad', () => {
|
||||
|
||||
it('should cache img response', () => {
|
||||
const callback: ImgLoadCallback = () => {};
|
||||
const status = 200;
|
||||
const contentType = 'image/jpeg';
|
||||
const responseData = new ArrayBuffer(0);
|
||||
const useCache = true;
|
||||
const imgData: ImgData = {
|
||||
src: 'image.jpg'
|
||||
};
|
||||
const imgs: ImgData[] = [];
|
||||
|
||||
onXhrLoad(callback, status, contentType, responseData, useCache, imgData, imgs);
|
||||
|
||||
expect(imgData.datauri).toEqual('data:image/jpeg;base64,');
|
||||
expect(imgData.len).toEqual(imgData.datauri.length);
|
||||
});
|
||||
|
||||
it('should do nothing when theres no callback', () => {
|
||||
const r = onXhrLoad(null, 0, 'image/jpeg', new ArrayBuffer(0), true, null, null);
|
||||
expect(r).toEqual(null);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
|
||||
it('should clear rendering src', () => {
|
||||
@ -60,35 +17,14 @@ describe('Img', () => {
|
||||
expect(img._renderedSrc).toEqual(null);
|
||||
});
|
||||
|
||||
it('should abort requesting src', () => {
|
||||
spyOn(ldr, 'abort');
|
||||
img._requestingSrc = '_requestingSrc.jpg';
|
||||
img.reset();
|
||||
expect(ldr.abort).toHaveBeenCalledWith('_requestingSrc.jpg');
|
||||
expect(img._requestingSrc).toEqual(null);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('src setter', () => {
|
||||
|
||||
it('should abort request if already requesting', () => {
|
||||
spyOn(img, 'reset');
|
||||
img._requestingSrc = 'requesting.jpg';
|
||||
img._tmpDataUri = 'tmpDatauri.jpg';
|
||||
|
||||
img.src = 'image.jpg';
|
||||
|
||||
expect(img.reset).toHaveBeenCalled();
|
||||
expect(img.src).toEqual('image.jpg');
|
||||
expect(img._tmpDataUri).toEqual(null);
|
||||
});
|
||||
|
||||
it('should set datauri src', () => {
|
||||
spyOn(img, 'update');
|
||||
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAAAAACH5BAAAAAAALAAAAAABAAEAAAICTAEAOw==';
|
||||
expect(img.src).toEqual('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAAAAACH5BAAAAAAALAAAAAABAAEAAAICTAEAOw==');
|
||||
expect(img._tmpDataUri).toEqual(`data:image/gif;base64,R0lGODlhAQABAIAAAAAAAAAAACH5BAAAAAAALAAAAAABAAEAAAICTAEAOw==`);
|
||||
expect(img.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@ -112,7 +48,6 @@ describe('Img', () => {
|
||||
|
||||
|
||||
let img: Img;
|
||||
let ldr: ImgLoader;
|
||||
let elementRef: ElementRef;
|
||||
let renderer: Renderer;
|
||||
let platform: Platform;
|
||||
@ -121,12 +56,11 @@ describe('Img', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
content = mockContent();
|
||||
ldr = new ImgLoader();
|
||||
elementRef = mockElementRef();
|
||||
renderer = mockRenderer();
|
||||
platform = mockPlatform();
|
||||
dom = new MockDomController();
|
||||
img = new Img(ldr, elementRef, renderer, platform, mockZone(), content, dom);
|
||||
img = new Img(elementRef, renderer, platform, mockZone(), content, dom);
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -19,7 +19,6 @@ import { Events, setupProvideEvents } from './util/events';
|
||||
import { Form } from './util/form';
|
||||
import { GestureController } from './gestures/gesture-controller';
|
||||
import { Haptic } from './util/haptic';
|
||||
import { ImgLoader } from './components/img/img-loader';
|
||||
import { IonicGestureConfig } from './gestures/gesture-config';
|
||||
import { Keyboard } from './util/keyboard';
|
||||
import { LoadingController } from './components/loading/loading';
|
||||
@ -56,7 +55,6 @@ export { Config, setupConfig, ConfigToken } from './config/config';
|
||||
export { DomController, DomCallback } from './util/dom-controller';
|
||||
export { Platform, setupPlatform, UserAgentToken, DocumentDirToken, DocLangToken, NavigatorPlatformToken } from './platform/platform';
|
||||
export { Haptic } from './util/haptic';
|
||||
export { ImgLoader } from './components/img/img-loader';
|
||||
export { QueryParams, setupQueryParams, UrlToken } from './platform/query-params';
|
||||
export { DeepLinker } from './navigation/deep-linker';
|
||||
export { NavController } from './navigation/nav-controller';
|
||||
@ -176,7 +174,6 @@ export class IonicModule {
|
||||
Form,
|
||||
GestureController,
|
||||
Haptic,
|
||||
ImgLoader,
|
||||
Keyboard,
|
||||
LoadingController,
|
||||
Location,
|
||||
|
Reference in New Issue
Block a user