/* eslint-disable no-var,no-console */ /** * Проверка на масштабирование изображений в браузере. * Срабатывает, если натуральный размер изображения намного больше отображаемого на странице, * то есть браузер грузит большую картинку и масштабирует её до маленькой. */ (function() { if (!window.Promise || !String.prototype.startsWith || window.MSInputMethodContext) { // Не запускаем проверку в IE11 и браузерах, не поддерживающих нужные API return; } /** * Минимальный дополнительный трафик с картинки, при превышении которого срабатывает проверка * @type {number} */ var EXTRA_TRAFFIC_KB_LIMIT = 4; class Img { constructor(imgElement, src) { // console.log('Проверяем', imgElement, src); this.img = imgElement; this.src = src || this.img.src; this.className = this.img.className; } /** * Массив URL, которые не будут проверяться */ ignoredImageSources = [ // 'https://example.com/img/placeholder.png' ]; /** * Изображения, которые надо игнорировать: * - или проверено и ничего плохого не найдено * - или уже заведена задача на починку * * Игнорируются, если все перечисленные свойства совпадут. * Если какое-то свойство здесь не указано - оно не будет проверяться. * * Свойства: * className - точный className DOM-элемента * w, h - ширина и высота DOM-элемента * nw, nh - натуральные размеры изображения * xScale, yScale - коэффициент масштабирования по осям */ ignoredImages = [ ]; calculateDimensions() { this.w = this.img.offsetWidth; this.h = this.img.offsetHeight; this.nw = this.img.naturalWidth; this.nh = this.img.naturalHeight; this.calculateScale(); return Promise.resolve(); } calculateScale() { this.xScale = this.nw / this.w; this.yScale = this.nh / this.h; } checkDimensions() { if (this.shouldIgnoreImageBefore()) { return; } this.calculateDimensions().then(function() { if (this.shouldIgnoreImageAfter()) { return; } var w = this.w, h = this.h, nw = this.nw, nh = this.nh; if (w === 0 || h === 0) { // Скрытое изображение - не репортим // this.report('Скрытое изображение, можно грузить лениво'); return; } if (nw <= w && nh <= h) { // Увеличенное изображение - не репортим return; } if (this.xScale === 2 && this.yScale === 2 && this.src.endsWith('_2x')) { // Изображение retina 2x return; } if (this.xScale < 3 && this.xScale > 1 / 3 && this.yScale < 3 && this.yScale > 1 / 3) { // Увеличение или уменьшение менее, чем в 3 раза - OK return; } // 10000 - эмпирическая константа, дающая примерно похожие числа в проверенных случаях // Для более точных результатов надо усложнять алгоритм, что сейчас нецелесообразно, // т.к. самые значительные различия находятся и таким алгоритмом var extraTrafficKb = Math.round((nw * nh - w * h) / 10000); if (extraTrafficKb < EXTRA_TRAFFIC_KB_LIMIT) { return; } this.report( 'Масштабированное изображение: потеря трафика около ' + extraTrafficKb + 'кБ' + ' видимый размер: ' + w + 'x' + h ); }.bind(this)); } shouldIgnoreImageBefore() { return this.ignoredImageSources.indexOf(this.src) !== -1; } matches(props) { for (var prop in props) { if (props.hasOwnProperty(prop) && props[prop] !== this[prop]) { return false; } } return true; } shouldIgnoreImageAfter() { return this.ignoredImages.some(function(props) { return this.matches(props); }, this); } report(message) { message += ' натуральный размер: ' + this.nw + 'x' + this.nh; message += ' class: "' + this.className + '"'; if (!this.src.startsWith('data:image')) { message += ' src: ' + this.src; } console.log(message, this.img); this.img.style.outline = '3px dotted red'; } } class BgImg extends Img { calculateDimensions() { return Promise.all([ this.calculateImgDimensions(), this.calculateBgDimensions() ]).then(function() { this.calculateScale(); }.bind(this)); } calculateImgDimensions() { return new Promise(function(resolve) { var img = new Image(); img.onload = function() { img.onload = img.onerror = null; this.nw = img.naturalWidth; this.nh = img.naturalHeight; resolve(); }.bind(this); img.onerror = function() { // Игнорируем ошибку загрузки изображения img.onload = img.onerror = null; this.nw = this.nh = 0; resolve(); }.bind(this); img.src = this.src; }.bind(this)); } calculateBgDimensions() { var backgroundSize = this.img.style.backgroundSize; if (backgroundSize) { var match = backgroundSize.match(/(\d+)px (\d+)px/); if (match) { this.w = parseInt(match[1]); this.h = parseInt(match[2]); return; } } this.w = this.img.offsetWidth || 0; this.h = this.img.offsetHeight || 0; } shouldIgnoreImageBefore() { var src = this.src; if ( src === 'none' || src.startsWith('https://favicon.yandex.net/favicon/v2/') || this.ignoredImageSources.indexOf(src) !== -1 ) { return true; } if (src.startsWith('data:image/')) { // Короткие data-url не проверяем return src.length < 1000; } return !/^(https?:\/\/|\/\/)/.test(src); } } var i; var images = document.querySelectorAll('img[src]'); console.log('Проверяю', images.length, 'изображений'); for (i = 0; i < images.length; i++) { new Img(images[i]).checkDimensions(); } /* background-image только в inline-стилях можно найти так: document.querySelectorAll('[style*="background"][style*="url("]') Но нас интересуют computed-стили, поэтому проверяем все элементы DOM */ var allElements = document.querySelectorAll('*'); var bgImagesCount = 0; for (i = 0; i < allElements.length; i++) { var container = allElements[i]; var backgroundImage = getComputedStyle(container).backgroundImage; if (!backgroundImage.startsWith('url(')) { continue; } backgroundImage = backgroundImage.replace(/^url\("?|"?\)$/g, ''); if (backgroundImage.indexOf('url(') === -1) { new BgImg(container, backgroundImage).checkDimensions(); bgImagesCount++; continue; } var bgImages = backgroundImage.split(/"?\),\s*url\("?/); bgImagesCount += bgImages.length; for (var j = 0; j < bgImages.length; j++) { new BgImg(container, bgImages[j]).checkDimensions(); } } console.log('Проверяю', bgImagesCount, 'фоновых изображений'); })();