From 4f61ea5f9b5f8fc766ad0d3a21d02fa969f0e7d8 Mon Sep 17 00:00:00 2001 From: Adam Bradley Date: Wed, 14 Dec 2016 14:17:16 -0600 Subject: [PATCH] fix(img): use img tag due to cordova limitations --- src/components/img/img-loader.ts | 175 ---------------------------- src/components/img/img.ts | 113 ++++++------------ src/components/img/test/img.spec.ts | 68 +---------- src/module.ts | 3 - 4 files changed, 37 insertions(+), 322 deletions(-) delete mode 100644 src/components/img/img-loader.ts diff --git a/src/components/img/img-loader.ts b/src/components/img/img-loader.ts deleted file mode 100644 index 402ea14d66e..00000000000 --- a/src/components/img/img-loader.ts +++ /dev/null @@ -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 - 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; -} diff --git a/src/components/img/img.ts b/src/components/img/img.ts index 5ec748568eb..188b7e6bf67 100644 --- a/src/components/img/img.ts +++ b/src/components/img/img.ts @@ -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); } diff --git a/src/components/img/test/img.spec.ts b/src/components/img/test/img.spec.ts index 01eeca0d8e2..9d3e069e994 100644 --- a/src/components/img/test/img.spec.ts +++ b/src/components/img/test/img.spec.ts @@ -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); }); }); diff --git a/src/module.ts b/src/module.ts index 8f61845a382..f31c56c0d33 100644 --- a/src/module.ts +++ b/src/module.ts @@ -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,