Выделите пункт меню при прокрутке вниз до раздела

Я знаю, что этот вопрос задавался миллион раз на этом форуме, но ни одна из статей не помогла мне найти решение.

Я сделал небольшой кусок кода jquery, который выделяет хеш-ссылку при прокрутке вниз до раздела с тем же идентификатором, что и в хэш-ссылке.

$(window).scroll(function() {
    var position = $(this).scrollTop();

    $('.section').each(function() {
        var target = $(this).offset().top;
        var id = $(this).attr('id');

        if (position >= target) {
            $('#navigation > ul > li > a').attr('href', id).addClass('active');
        }
    });
});

Теперь проблема заключается в том, что она выделяет все хэш-ссылки, а не только те, с которыми имеет отношение раздел. Может ли кто-нибудь указать на ошибку, или это то, что я забыл?

Ответ 1

EDIT:

Я изменил свой ответ, чтобы немного рассказать о производительности и некоторых конкретных случаях.

Если вы просто ищете код, внизу есть откомментированный фрагмент.


Оригинальный ответ

Вместо того чтобы добавлять класс .active ко всем ссылкам, вы должны указать тот, атрибут href которого совпадает с идентификатором раздела.

Затем вы можете добавить класс .active к этой ссылке и удалить его из остальных.

        if (position >= target) {
            $('#navigation > ul > li > a').removeClass('active');
            $('#navigation > ul > li > a[href=#' + id + ']').addClass('active');
        }

С помощью вышеуказанной модификации ваш код будет правильно выделять соответствующую ссылку. Надеюсь, это поможет!


Улучшение производительности

Даже когда этот код выполнит свою работу, он далеко не оптимален. В любом случае, помните:

Мы должны забыть о малой эффективности, скажем, в 97% случаев: преждевременная оптимизация - корень всего зла. Все же мы не должны проходить до наших возможностей в эти критические 3%. (Дональд Кнут)

Поэтому, если при тестировании событий на медленном устройстве у вас нет проблем с производительностью, лучшее, что вы можете сделать, - это перестать читать и подумать о следующей потрясающей функции для вашего проекта!

Есть три основных шага для улучшения производительности:

Сделайте как можно больше предыдущих работ:

Чтобы избежать повторного поиска в DOM (каждый раз, когда событие инициируется), вы можете заранее кэшировать ваши объекты jQuery (например, на document.ready):

var $navigationLinks = $('#navigation > ul > li > a');
var $sections = $(".section"); 

Затем вы можете сопоставить каждый раздел с соответствующей навигационной ссылкой:

var sectionIdTonavigationLink = {};
$sections.each( function(){
    sectionIdTonavigationLink[ $(this).attr('id') ] = $('#navigation > ul > li > a[href=\\#' + $(this).attr('id') + ']');
});

Обратите внимание на две обратные косые черты в селекторе привязки: хэш '#' имеет особое значение в CSS, поэтому его необходимо экранировать (спасибо @Johnnie).

Кроме того, вы можете кэшировать позицию каждого раздела (Bootstrap Scrollspy делает это). Но, если вы это сделаете, вам нужно помнить, чтобы обновлять их каждый раз, когда они меняются (пользователь изменяет размеры окна, новый контент добавляется через ajax, подраздел расширяется и т.д.).

Оптимизировать обработчик событий:

Представьте, что пользователь прокручивает внутри одного раздела: активную навигационную ссылку менять не нужно. Но если вы посмотрите на код выше, вы увидите, что на самом деле он меняется несколько раз. Перед выделением правильной ссылки все предыдущие ссылки также сделают это (поскольку соответствующие разделы также проверяют условие position >= target).

Одно из решений состоит в том, чтобы выполнить итерации секций снизу вверх, причем первый, чей .offset().top равен или меньше, чем $(window).scrollTop, является правильным. И да, вы можете положиться на то, что jQuery будет возвращать объекты в порядке DOM (начиная с версии 1.3.2). Для итерации снизу вверх просто выберите их в обратном порядке:

var $sections = $( $(".section").get().reverse() );
$sections.each( ... );

Двойной $() необходим, потому что get() возвращает элементы DOM, а не объекты jQuery.

Как только вы нашли правильный раздел, вы должны return false выйти из цикла и не проверять другие разделы.

Наконец, вы не должны ничего делать, если правильная навигационная ссылка уже выделена, поэтому проверьте это:

if ( !$navigationLink.hasClass( 'active' ) ) {
    $navigationLinks.removeClass('active');
    $navigationLink.addClass('active');
}

Инициируйте событие как можно меньше:

Самый точный способ предотвратить медленный или не отвечающий на события с высоким рейтингом (прокрутка, изменение размера...) - это контролировать частоту вызова обработчика событий: убедитесь, что вам не нужно проверять, какая ссылка должна быть выделена. 100 раз в секунду! Если, помимо подсветки ссылок, вы добавите какой-нибудь причудливый эффект параллакса, вы сможете быстро справиться с неприятностями.

На данный момент, конечно, вы хотите прочитать о throttle, debounce и requestAnimationFrame. Эта статья является хорошей лекцией и дает вам очень хорошее представление о трех из них. В нашем случае регулирование соответствует нашим потребностям.

По сути, регулирование обеспечивает минимальный интервал времени между двумя выполнениями функций.

Я реализовал функцию дросселя в сниппете. Оттуда вы можете получить более изощренную или, что еще лучше, библиотеку, такую как underscore.js или lodash (если вам не нужна вся библиотека, вы всегда можете извлечь из нее функция дроссельной заслонки).

Примечание: если вы посмотрите вокруг, вы найдете более простые функции газа. Остерегайтесь их, потому что они могут пропустить последний триггер события (и это самый важный!).

Частные случаи:

Я не буду включать эти случаи во фрагмент, чтобы не усложнять его.

В приведенном ниже фрагменте ссылки будут выделены, когда раздел достигнет самой верхней части страницы. Если вы хотите, чтобы они были выделены ранее, вы можете добавить небольшое смещение следующим образом:

if (position + offset >= target) {

Это особенно полезно, когда у вас есть верхняя панель навигации.

И если ваш последний раздел слишком мал, чтобы достичь верхней части страницы, вы можете выделить соответствующую ему ссылку, когда полоса прокрутки находится в самом нижнем положении:

if ( $(window).scrollTop() >= $(document).height() - $(window).height() ) {
    // highlight the last link

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

Фрагмент и тест

Наконец, у вас есть откомментированный фрагмент. Обратите внимание, что я изменил имя некоторых переменных, чтобы сделать их более наглядными.

// cache the navigation links 
var $navigationLinks = $('#navigation > ul > li > a');
// cache (in reversed order) the sections
var $sections = $($(".section").get().reverse());

// map each section id to their corresponding navigation link
var sectionIdTonavigationLink = {};
$sections.each(function() {
    var id = $(this).attr('id');
    sectionIdTonavigationLink[id] = $('#navigation > ul > li > a[href=\\#' + id + ']');
});

// throttle function, enforces a minimum time interval
function throttle(fn, interval) {
    var lastCall, timeoutId;
    return function () {
        var now = new Date().getTime();
        if (lastCall && now < (lastCall + interval) ) {
            // if we are inside the interval we wait
            clearTimeout(timeoutId);
            timeoutId = setTimeout(function () {
                lastCall = now;
                fn.call();
            }, interval - (now - lastCall) );
        } else {
            // otherwise, we directly call the function 
            lastCall = now;
            fn.call();
        }
    };
}

function highlightNavigation() {
    // get the current vertical position of the scroll bar
    var scrollPosition = $(window).scrollTop();

    // iterate the sections
    $sections.each(function() {
        var currentSection = $(this);
        // get the position of the section
        var sectionTop = currentSection.offset().top;

        // if the user has scrolled over the top of the section  
        if (scrollPosition >= sectionTop) {
            // get the section id
            var id = currentSection.attr('id');
            // get the corresponding navigation link
            var $navigationLink = sectionIdTonavigationLink[id];
            // if the link is not active
            if (!$navigationLink.hasClass('active')) {
                // remove .active class from all the links
                $navigationLinks.removeClass('active');
                // add .active class to the current link
                $navigationLink.addClass('active');
            }
            // we have found our section, so we return false to exit the each loop
            return false;
        }
    });
}

$(window).scroll( throttle(highlightNavigation,100) );

// if you don't want to throttle the function use this instead:
// $(window).scroll( highlightNavigation );
#navigation {
    position: fixed;
}
#sections {
    position: absolute;
    left: 150px;
}
.section {
    height: 200px;
    margin: 10px;
    padding: 10px;
    border: 1px dashed black;
}
#section5 {
    height: 1000px;
}
.active {
    background: red;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<div id="navigation">
    <ul>
        <li><a href="#section1">Section 1</a></li>
        <li><a href="#section2">Section 2</a></li>
        <li><a href="#section3">Section 3</a></li>
        <li><a href="#section4">Section 4</a></li>
        <li><a href="#section5">Section 5</a></li>
    </ul>
</div>
<div id="sections">
    <div id="section1" class="section">
        I'm section 1
    </div>
    <div id="section2" class="section">
        I'm section 2
    </div>
    <div id="section3" class="section">
        I'm section 3
    </div>
    <div id="section4" class="section">
        I'm section 4
    </div>
    <div id="section5" class="section">
        I'm section 5
    </div>
</div>

Ответ 2

Для тех, кто пытается использовать это решение совсем недавно, я попал в ловушку, пытаясь заставить его работать. Вам может понадобиться избежать href следующим образом:

$('#navigation > ul > li > a[href=\\#' + id + ']');

И теперь мой браузер не вызывает ошибку на этой части.

Ответ 3

В этой строке:

 $('#navigation > ul > li > a').attr('href', id).addClass('active');

Фактически вы устанавливаете атрибут href для каждого элемента $('# navigation > ul > li > a'), а затем добавляете активный класс ко всем из них. Может быть, вам нужно что-то вроде:

$('#navigation > ul > li > a[href=#' + id + ']')

И выберите только a, который соответствует href идентификатору. Есть смысл?

Ответ 4

Я взял отличный код Дэвида и удалил из него все зависимости jQuery на тот случай, если кому-то будет интересно:

// cache the navigation links 
var $navigationLinks = document.querySelectorAll('nav > ul > li > a');
// cache (in reversed order) the sections
var $sections = document.getElementsByTagName('section');

// map each section id to their corresponding navigation link
var sectionIdTonavigationLink = {};
for (var i = $sections.length-1; i >= 0; i--) {
	var id = $sections[i].id;
	sectionIdTonavigationLink[id] = document.querySelectorAll('nav > ul > li > a[href=\\#' + id + ']') || null;
}

// throttle function, enforces a minimum time interval
function throttle(fn, interval) {
	var lastCall, timeoutId;
	return function () {
		var now = new Date().getTime();
		if (lastCall && now < (lastCall + interval) ) {
			// if we are inside the interval we wait
			clearTimeout(timeoutId);
			timeoutId = setTimeout(function () {
				lastCall = now;
				fn.call();
			}, interval - (now - lastCall) );
		} else {
			// otherwise, we directly call the function 
			lastCall = now;
			fn.call();
		}
	};
}

function getOffset( el ) {
	var _x = 0;
	var _y = 0;
	while( el && !isNaN( el.offsetLeft ) && !isNaN( el.offsetTop ) ) {
		_x += el.offsetLeft - el.scrollLeft;
		_y += el.offsetTop - el.scrollTop;
		el = el.offsetParent;
	}
	return { top: _y, left: _x };
}

function highlightNavigation() {
	// get the current vertical position of the scroll bar
	var scrollPosition = window.pageYOffset || document.documentElement.scrollTop;

	// iterate the sections
	for (var i = $sections.length-1; i >= 0; i--) {
		var currentSection = $sections[i];
		// get the position of the section
		var sectionTop = getOffset(currentSection).top;

	   // if the user has scrolled over the top of the section  
		if (scrollPosition >= sectionTop - 250) {
			// get the section id
			var id = currentSection.id;
			// get the corresponding navigation link
			var $navigationLink = sectionIdTonavigationLink[id];
			// if the link is not active
			if (typeof $navigationLink[0] !== 'undefined') {
				if (!$navigationLink[0].classList.contains('active')) {
					// remove .active class from all the links
					for (i = 0; i < $navigationLinks.length; i++) {
						$navigationLinks[i].className = $navigationLinks[i].className.replace(/ active/, '');
					}
					// add .active class to the current link
					$navigationLink[0].className += (' active');
				}
			} else {
					// remove .active class from all the links
					for (i = 0; i < $navigationLinks.length; i++) {
						$navigationLinks[i].className = $navigationLinks[i].className.replace(/ active/, '');
					}
			}	
			// we have found our section, so we return false to exit the each loop
			return false;
		}
	}
}

window.addEventListener('scroll',throttle(highlightNavigation,150));