Правильные show/hide/toggle

Как правильно реализовать универсальные функции show и hide для DOM элемента? Есть несколько распространенных вариантов, с различными граблями, которые мы рассмотрим, и выберем оптимальный.

Самое интересное, что даже самые лучшие show/hide функции из хороших javascript-библиотек не универсальны.

Функция toggle: стандартный подход 1

Рассмотрим самую простую функцию toggle:

function toggle(el) {
  el.style.display = (el.style.display == 'none') ? 'block' : 'none'
}

Недостатки такого подхода легко видеть. Например, ссылки <a href="...">имеют по умолчанию display: inline. А этот код поставит display = "block".

Для проверки - кликните на любом месте этого желтого div, при этом вызовется toggle ссылки. И затем - кликните еще раз для повторного toggle.

click ссылка me

Как видно, прячет оно нормально, а вот показ - некорректный.

Аналогичная проблема будет с ячейками таблицы, у которых по стандарту display: table-cell.

Функция toggle: стандартный подход 2

Этой проблемы лишен следующий вариант:

function toggle(el) {
  el.style.display = (el.style.display == 'none') ? '' : 'none'
}

Вместо block свойство display сбрасывается - при этом элемент получает display из CSS по умолчанию, то есть то, которое было изначально:

click ссылка me

Как видите, все работает верно.

Проблема подхода

Но при этом возникает другая проблема. Свойство display у элемента может отличаться от унаследованного из CSS, например:

<a href="#" style="display:block">...</a>

Или особое значение display могло быть установлено из javascript.

При этом обнуление display сбросит это особое значение.

В DIV ссылка - <a href="#" onclick="return false" style="display:block">. При повторном показе функция toggle сбрасывает display в значение по умолчанию, поэтому элемент будет показан уже с display:inline.

click ссылка me

Функции show/hide. Попытка номер 3

Для лучшей применимости разобьем toggle на две части: show и hide и отладим их по отдельности.

Чтобы обойти описанную выше проблему с перезаписыванием display - при скрытии элемента будем записывать старое значение display в атрибут el.displayOld, а при показе - восстанавливать.

function hide(el) {
	if (!el.hasAttribute('displayOld')) {
		el.setAttribute("displayOld", el.style.display)
	}

	el.style.display = "none"
}

function show(el) {
	var old = el.getAttribute("displayOld");
	el.style.display = old || "";
}

Теперь show/hide для ссылки со своим display будет работать корректно.

Однако, и этот вариант - для нас не более чем промежуточный.

Функция hide работает отлично. О show такого не скажешь.

  1. Во-первых, вызов show до hide сбросит display. Это поведение - некорректное, его нужно поправить.
  2. Во-вторых, что более важно, если элемент спрятан CSS-классом, то show его не покажет.

    Например, show не покажет ссылку из этого примера:

    <style>
    .hidden {display:none}
    </style>
    <a href="#" class="hidden">Ссылка</a>
    

Чтобы решить эти проблему, функция show должна знать, показывается ли элемент реально или нет. Это нам поможет сделать доступ к Computed Style - вычисленному стилю элемента, доступ к которому делается по-разному, в зависимости от браузера.

Реальный display элемента

Следующая функция берет реальное значение display из Computed Style, то есть такое. которое получается в результате применения всех CSS-классов и свойств.

function getRealDisplay(elem) {
	if (elem.currentStyle) {
		return elem.currentStyle.display
	} else if (window.getComputedStyle) {
		var computedStyle = window.getComputedStyle(elem, null)

		return computedStyle.getPropertyValue('display')
	} 
}

Если элемент не показывается из-за CSS-класса, то getRealDisplay вернет для него 'none'.

Функция show с учетом CSS-класса

displayCache = {} 

function show(el) {
	if (getRealDisplay(el) != 'none') return // (1)

	el.style.display = el.getAttribute("displayOld") || "" // (2)

	if ( getRealDisplay(el) === "none" ) { // (3)
		var nodeName = el.nodeName, body = document.body, display

		if ( displayCache[nodeName] ) { // (3.1)
			display = displayCache[nodeName]
		} else { // (3.2)
			var testElem = document.createElement(nodeName)
			body.appendChild(testElem)
			display = getRealDisplay(testElem)

			if (display === "none" ) { // (3.2.1)
				display = "block"
			}

			body.removeChild(testElem)
			displayCache[nodeName] = display
		}

		el.setAttribute('displayOld', display)  // (3.3)
		el.style.display = display
	}
}
  1. Проверка, показывается ли элемент. Эта строчка нужна для безопасности. Если ее убрать, то (2) обнулит свойство display, а оно могло быть нестандартным. Этот глюк присутствует в ряде javascript-библиотек, например, в jQuery (проверено в 1.4 на момент написания).

    Вы можете захотеть убрать эту строчку в целях оптимизации, чтобы сделать show быстрее.

    Но при этом show станет некорректно работать при вызове без предшествующего hide на элементе с нестандартным display.

  2. Получить старое значение display, если оно сохранено hide и поставить его элементу. Если старого значения нет - на безрыбье и рак рыба, просто обнуляем display.
  3. Показывается ли элемент? Элемент может не показываться, например, из-за того, что в его CSS классе прописано display:none. Если так, то для показа элемента придется найти и применить подходящее значение display.

    Где взять значение display для показа изначально скрытого элемента? Это совсем не обязательно block, т.к. элемент мог быть ссылкой, ячейкой таблицы, да и вообще - "правильный" display для показа элемента зависит от места, времени и настроения программиста.

    В блоке (3.2) функция помещает элемент с таким же тэгом в конец <body> и получает его display, которое кеширует во вспомогательном объекте displayCache. Конечно, это всего лишь догадка, однако в простых случаях она работает.

    Этот display и используется для назначения элементу.

    1. Попробовать получить правильное значение display из кэша.
    2. В кэше нет - добавляем пустой тэг к <body>, затем берем его display.
      1. Если и этот тэг имеет реальный display:none - угадать не получилось. Возьмем block: что еще делать, элемент-то показать надо.
    3. Угаданное значение display применяем к элементу и сохраняем для дальнейшего использования.

Правильная функция toggle.

Итак, теперь на основе show и hide можно сделать функцию toggle, которая видимый элемент скрывает, а невидимый - наоборот, показывает. Иначе говоря, переключает состояние элемента.

Функция toggle сама по себе очень проста:

function toggle(el) {
	isHidden(el) ? show(el) : hide(el)
}

Для ее работы необходима вспомогательная функция isHidden, которая определяет, виден ли элемент. Само собой, имеется в виду реальная видимость, а не свойство display элемента.

Быстрая проверка видимости

Используем для этого трюк с offsetWidth/offsetHeight:

function isHidden(el) {
	var width = el.offsetWidth, height = el.offsetHeight,
		tr = el.nodeName.toLowerCase() === "tr"

	return width === 0 && height === 0 && !tr ?
		true : width > 0 && height > 0 && !tr ? false : getRealDisplay(el)
}

Эта реализация пытается получить ответ, по возможности используя проверку offsetWidth/offsetHeight, т.к. это быстрее, чем getRealDisplay.

Резюме

Итак, вот итоговый код toggle.js.

function getRealDisplay(elem) {
	if (elem.currentStyle) {
		return elem.currentStyle.display
	} else if (window.getComputedStyle) {
		var computedStyle = window.getComputedStyle(elem, null )

		return computedStyle.getPropertyValue('display')
	}
}

function hide(el) {
	if (!el.getAttribute('displayOld')) {
		el.setAttribute("displayOld", el.style.display)
	}

	el.style.display = "none"
}

displayCache = {}

function isHidden(el) {
	var width = el.offsetWidth, height = el.offsetHeight,
		tr = el.nodeName.toLowerCase() === "tr"

	return width === 0 && height === 0 && !tr ?
		true : width > 0 && height > 0 && !tr ? false :	getRealDisplay(el)
}

function toggle(el) {
	isHidden(el) ? show(el) : hide(el)
}


function show(el) {

	if (getRealDisplay(el) != 'none') return

	var old = el.getAttribute("displayOld");
	el.style.display = old || "";

	if ( getRealDisplay(el) === "none" ) {
		var nodeName = el.nodeName, body = document.body, display

		if ( displayCache[nodeName] ) {
			display = displayCache[nodeName]
		} else {
			var testElem = document.createElement(nodeName)
			body.appendChild(testElem)
			display = getRealDisplay(testElem)

			if (display === "none" ) {
				display = "block"
			}

			body.removeChild(testElem)
			displayCache[nodeName] = display
		}

		el.setAttribute('displayOld', display)
		el.style.display = display
	}
}

Пример работы вы можете увидеть на отдельной страничке.

Открыть пример в новом окне

Эта универсальная функция toggle широко используется в различных библиотеках, в частности, в jQuery.

Теперь вы знаете, что она делает ряд лишних операций, и если вдруг ваш toggle заглючит - представляете, в чем может быть дело.

Пусть ваш toggle всегда работает так, как задумано!