Eine Inhaltsübersicht ist eine Liste von Links, die es Ihnen ermöglicht, schnell zu bestimmten Inhaltsabschnitten auf derselben Seite zu springen. Sie ist vorteilhaft für lange Inhalte, da sie dem Benutzer einen praktischen Überblick über die vorhandenen Inhalte mit einer bequemen Möglichkeit bietet, dorthin zu gelangen.
Diese Anleitung zeigt Ihnen, wie Sie langen Markdown-Text in HTML parsen und dann eine Linkliste aus den Überschriften generieren. Danach verwenden wir die Intersection Observer API, um herauszufinden, welcher Abschnitt gerade aktiv ist, fügen eine Scroll-Animation hinzu, wenn auf einen Link geklickt wird, und lernen schließlich, wie Vue's <transition-group> uns ermöglicht, eine schöne animierte Liste zu erstellen, je nachdem, welcher Abschnitt gerade aktiv ist.
Markdown parsen
Im Web werden Textinhalte oft in Form von Markdown geliefert. Wenn Sie es noch nicht verwendet haben, gibt es viele Gründe, warum Markdown eine ausgezeichnete Wahl für Textinhalte ist. Wir werden einen Markdown-Parser namens marked verwenden, aber jeder andere Parser ist ebenfalls gut.
Wir werden unseren Inhalt aus einer Markdown-Datei auf GitHub abrufen. Nachdem wir unsere Markdown-Datei geladen haben, müssen wir nur noch die Funktion marked(<markdown>, <options>) aufrufen, um das Markdown in HTML zu parsen.
async function fetchAndParseMarkdown() {
const url = 'https://gist.githubusercontent.com/lisilinhart/e9dcf5298adff7c2c2a4da9ce2a3db3f/raw/2f1a0d47eba64756c22460b5d2919d45d8118d42/red_panda.md'
const response = await fetch(url)
const data = await response.text()
const htmlFromMarkdown = marked(data, { sanitize: true });
return htmlFromMarkdown
}
Nachdem wir unsere Daten abgerufen und geparst haben, übergeben wir das geparste HTML an unser DOM, indem wir den Inhalt mit innerHTML ersetzen.
async function init() {
const $main = document.querySelector('#app');
const htmlContent = await fetchAndParseMarkdown();
$main.innerHTML = htmlContent
}
init();
Generieren einer Liste von Überschriften-Links
Nachdem wir das HTML generiert haben, müssen wir unsere Überschriften in eine klickbare Linkliste umwandeln. Um die Überschriften zu finden, verwenden wir die DOM-Funktion querySelectorAll('h1, h2'), die alle <h1>- und <h2>-Elemente innerhalb unseres Markdown-Containers auswählt. Dann durchlaufen wir die Überschriften und extrahieren die benötigten Informationen: den Text innerhalb der Tags, die Tiefe (die 1 oder 2 ist) und die Element-ID, die wir verwenden können, um zu jeder jeweiligen Überschrift zu verlinken.
function generateLinkMarkup($contentElement) {
const headings = [...$contentElement.querySelectorAll('h1, h2')]
const parsedHeadings = headings.map(heading => {
return {
title: heading.innerText,
depth: heading.nodeName.replace(/\D/g,''),
id: heading.getAttribute('id')
}
})
console.log(parsedHeadings)
}
Dieser Ausschnitt ergibt ein Array von Elementen, das wie folgt aussieht
[
{title: "The Red Panda", depth: "1", id: "the-red-panda"},
{title: "About", depth: "2", id: "about"},
// ...
]
Nachdem wir die benötigten Informationen aus den Überschriftenelementen erhalten haben, können wir ES6-Template-Literale verwenden, um die benötigten HTML-Elemente für die Inhaltsübersicht zu generieren.
Zuerst durchlaufen wir alle Überschriften und erstellen <li>-Elemente. Wenn wir mit einem <h2> mit depth: 2 arbeiten, fügen wir eine zusätzliche Einrückungsklasse, .pl-4, hinzu, um diese einzurücken. So können wir <h2>-Elemente als eingerückte Unterüberschriften in der Linkliste anzeigen.
Schließlich fügen wir das Array der <li>-Snippets zusammen und umschließen es mit einem <ul>-Element.
function generateLinkMarkup($contentElement) {
// ...
const htmlMarkup = parsedHeadings.map(h => `
<li class="${h.depth > 1 ? 'pl-4' : ''}">
<a href="#${h.id}">${h.title}</a>
</li>
`)
const finalMarkup = `<ul>${htmlMarkup.join('')}</ul>`
return finalMarkup
}
Das ist alles, was wir brauchen, um unsere Linkliste zu generieren. Nun fügen wir das generierte HTML zum DOM hinzu.
async function init() {
const $main = document.querySelector('#content');
const $aside = document.querySelector('#aside');
const htmlContent = await fetchAndParseMarkdown();
$main.innerHTML = htmlContent
const linkHtml = generateLinkMarkup($main);
$aside.innerHTML = linkHtml
}
Hinzufügen eines Intersection Observers
Als Nächstes müssen wir herausfinden, welchen Teil des Inhalts wir gerade lesen. Intersection Observers sind dafür die perfekte Wahl. MDN definiert Intersection Observer wie folgt:
Die Intersection Observer API bietet eine Möglichkeit, asynchron Änderungen an der Schnittmenge eines Zielelements mit einem Elternelement oder dem Ansichtsfenster eines Top-Level-Dokuments zu beobachten.
Im Grunde ermöglichen sie uns, die Schnittmenge eines Elements mit dem Ansichtsfenster oder einem seiner Elternelemente zu beobachten. Um einen zu erstellen, können wir IntersectionObserver() aufrufen, was eine neue Beobachterinstanz erstellt. Wann immer wir einen neuen Beobachter erstellen, müssen wir ihm eine Callback-Funktion übergeben, die aufgerufen wird, wenn der Beobachter eine Schnittmenge eines Elements beobachtet hat. Travis Almand hat eine ausführliche Erklärung des Intersection Observers, die Sie lesen können, aber was wir im Moment benötigen, ist eine Callback-Funktion als ersten Parameter und ein Options-Objekt als zweiten Parameter.
function createObserver() {
const options = {
rootMargin: "0px 0px -200px 0px",
threshold: 1
}
const callback = () => { console.log("observed something") }
return new IntersectionObserver(callback, options)
}
Der Beobachter ist erstellt, aber im Moment wird nichts beobachtet. Wir müssen die Überschriftenelemente in unserem Markdown beobachten, also lassen Sie uns sie durchlaufen und sie mit der Funktion observe() zum Beobachter hinzufügen.
const observer = createObserver()
$headings.map(heading => observer.observe(heading))
Da wir unsere Linkliste aktualisieren wollen, übergeben wir sie als $links-Parameter an die observer-Funktion, da wir aus Performancegründen den DOM nicht bei jeder Aktualisierung neu lesen wollen. In der handleObserver-Funktion finden wir heraus, ob eine Überschrift mit dem Ansichtsfenster kollidiert, erhalten dann deren id und übergeben sie an eine Funktion namens updateLinks, die das Aktualisieren der Klasse der Links in unserer Inhaltsübersicht übernimmt.
function handleObserver(entries, observer, $links) {
entries.forEach((entry)=> {
const { target, isIntersecting, intersectionRatio } = entry
if (isIntersecting && intersectionRatio >= 1) {
const visibleId = `#${target.getAttribute('id')}`
updateLinks(visibleId, $links)
}
})
}
Lassen Sie uns die Funktion zum Aktualisieren der Linkliste schreiben. Wir müssen alle Links durchlaufen, die Klasse .is-active entfernen, falls sie existiert, und sie nur dem Element hinzufügen, das tatsächlich aktiv ist.
function updateLinks(visibleId, $links) {
$links.map(link => {
let href = link.getAttribute('href')
link.classList.remove('is-active')
if(href === visibleId) link.classList.add('is-active')
})
}
Das Ende unserer init()-Funktion erstellt einen Beobachter, beobachtet alle Überschriften und aktualisiert die Linkliste, sodass der aktive Link hervorgehoben wird, wenn der Beobachter eine Änderung bemerkt.
async function init() {
// Parsing Markdown
const $aside = document.querySelector('#aside');
// Generating a list of heading links
const $headings = [...$main.querySelectorAll('h1, h2')];
// Adding an Intersection Observer
const $links = [...$aside.querySelectorAll('a')]
const observer = createObserver($links)
$headings.map(heading => observer.observe(heading))
}
Scroll-zu-Abschnitt-Animation
Der nächste Teil ist die Erstellung einer Scroll-Animation, so dass beim Klicken auf einen Link in der Inhaltsübersicht der Benutzer zur Überschriftenposition gescrollt wird, anstatt abrupt dorthin zu springen. Dies wird oft als Smooth Scrolling bezeichnet.
Scroll-Animationen können schädlich sein, wenn ein Benutzer reduzierte Bewegung bevorzugt, daher sollten wir dieses Scrollverhalten nur animieren, wenn der Benutzer nicht anders angegeben hat. Mit window.matchMedia('(prefers-reduced-motion)') können wir die Benutzereinstellung lesen und unsere Animation entsprechend anpassen. Das bedeutet, wir benötigen einen Klick-Event-Listener für jeden Link. Da wir zu den Überschriften scrollen müssen, übergeben wir auch unsere Liste von $headings und die motionQuery.
const motionQuery = window.matchMedia('(prefers-reduced-motion)');
$links.map(link => {
link.addEventListener("click",
(evt) => handleLinkClick(evt, $headings, motionQuery)
)
})
Schreiben wir unsere Funktion handleLinkClick, die aufgerufen wird, wenn auf einen Link geklickt wird. Zuerst müssen wir das Standardverhalten von Links verhindern, das darin besteht, direkt zum Abschnitt zu springen. Dann lesen wir das href-Attribut des angeklickten Links und finden die Überschrift mit dem entsprechenden id-Attribut. Mit einem tabindex-Wert von -1 und focus() können wir unsere Überschrift fokussieren, um den Benutzer darauf aufmerksam zu machen, wohin er gesprungen ist. Schließlich fügen wir die Scroll-Animation hinzu, indem wir scroll() auf unserem Fenster aufrufen.
Hier kommt unsere motionQuery ins Spiel. Wenn der Benutzer reduzierte Bewegung bevorzugt, ist das Verhalten instant; andernfalls ist es smooth. Die Option top fügt einen kleinen Scroll-Rand am oberen Rand der Überschriften hinzu, um zu verhindern, dass sie ganz oben am Fenster kleben.
function handleLinkClick(evt, $headings, motionQuery) {
evt.preventDefault()
let id = evt.target.getAttribute("href").replace('#', '')
let section = $headings.find(heading => heading.getAttribute('id') === id)
section.setAttribute('tabindex', -1)
section.focus()
window.scroll({
behavior: motionQuery.matches ? 'instant' : 'smooth',
top: section.offsetTop - 20
})
}
Animieren der Linkliste
Für den letzten Teil werden wir Vue's <transition-group> verwenden, das sehr nützlich für Listenübergänge ist. Hier ist Sarah Drasner's ausgezeichnetes Intro zu Vue-Übergängen, falls Sie noch nie damit gearbeitet haben. Sie sind besonders großartig, da sie uns mit Animations-Lifecycle-Hooks mit einfachem Zugriff auf CSS-Animationen versorgen.

Vue fügt automatisch CSS-Klassen hinzu, wenn ein Element zu einer Liste hinzugefügt (v-enter) oder daraus entfernt (v-leave) wird, sowie Klassen für die aktive Animation (v-enter-active und v-leave-active). Das ist perfekt für unseren Fall, da wir die Animation variieren können, wenn Unterüberschriften zu unserer Liste hinzugefügt oder daraus entfernt werden. Um sie zu verwenden, müssen wir unsere <li>-Elemente in unserer Inhaltsübersicht mit einem <transition-group>-Element umschließen. Das Attribut name von <transition-group> bestimmt, wie die CSS-Animationen aufgerufen werden, das Attribut tag sollte unser übergeordnetes <ul>-Element sein.
<transition-group name="list" tag="ul">
<li v-for="(item, index) in activeHeadings" v-bind:key="item.id">
<a :href="item.id">
{{ item.text }}
</a>
</li>
</transition-group>
Jetzt müssen wir die eigentlichen CSS-Übergänge hinzufügen. Wenn ein Element ein- oder austritt, sollte es von nicht sichtbar (opacity: 0) und leicht nach unten verschoben (transform: translateY(10px)) animiert werden.
.list-enter, .list-leave-to {
opacity: 0;
transform: translateY(10px);
}
Dann definieren wir, welche CSS-Eigenschaft wir animieren möchten. Aus Performancegründen möchten wir nur die Eigenschaften transform und opacity animieren. CSS erlaubt uns, die Übergänge mit unterschiedlichen Timings zu verketten: die transform soll 0,8 Sekunden dauern und das Ausblenden nur 0,4s.
.list-leave-active, .list-move {
transition: transform 0.8s, opacity 0.4s;
}
Dann wollen wir eine kleine Verzögerung hinzufügen, wenn ein neues Element hinzugefügt wird, damit die Unterüberschriften nach dem Auf- oder Abwärtsbewegen der übergeordneten Überschrift einblenden. Wir können den Hook v-enter-active dafür nutzen.
.list-enter-active {
transition: transform 0.8s ease 0.4s, opacity 0.4s ease 0.4s;
}
Schließlich können wir den Elementen, die gerade ausgeblendet werden, eine absolute Positionierung hinzufügen, um plötzliche Sprünge zu vermeiden, während die anderen Elemente animiert werden.
.list-leave-active {
position: absolute;
}
Da die Scroll-Interaktion Elemente aus- und einblendet, ist es ratsam, die Scroll-Interaktion zu verzögern (debounce), falls jemand sehr schnell scrollt. Durch das Debouncing der Interaktion können wir überlappende unvollständige Animationen vermeiden. Sie können entweder Ihre eigene Debouncing-Funktion schreiben oder einfach die Lodash debounce-Funktion verwenden. Für unser Beispiel ist der einfachste Weg, überlappende Animationsaktualisierungen zu vermeiden, die Intersection Observer Callback-Funktion mit einer Debounce-Funktion zu umschließen und die debouncierte Funktion an den Observer zu übergeben.
const debouncedFunction = _.debounce(this.handleObserver)
this.observer = new IntersectionObserver(debouncedFunction,options)
Hier ist die finale Demo
Auch hier ist eine Inhaltsübersicht eine großartige Ergänzung für jeden langen Inhalt. Sie hilft zu verdeutlichen, welche Inhalte abgedeckt sind, und bietet schnellen Zugriff auf bestimmte Inhalte. Die Verwendung des Intersection Observers und der Listenanimationen von Vue kann dazu beitragen, eine Inhaltsübersicht noch interaktiver zu gestalten und sie sogar als Indikator für den Lesefortschritt zu nutzen. Aber auch wenn Sie nur eine Linkliste hinzufügen, wird dies bereits eine großartige Funktion für den Benutzer sein, der Ihre Inhalte liest.
Hallo. Tolle Technologie. Wie kann ich das in reinem JS implementieren, ohne Babel?
Wenn Babel das Problem ist, dann ist es bereits reines JavaScript: Wenn man auf "Compiled anzeigen" klickt, erhält man fast den gleichen Code (nur die Formatierung ändert sich). Ich denke, wer den Beitrag erstellt hat, hat "Babel" nur aus Gewohnheit verwendet. Andererseits benötigt es Vue, also nennen es vielleicht manche nicht "reines JavaScript".
Das ist großartig! Es ist eine einfache, aber schöne Implementierung, die gut funktioniert. Danke! Das hat mir ein paar Ideen gegeben.
Es ist erstaunlich, wie ähnlich Ihre Lösung dem ist, was ich für die Dokumentationswebsite von platformOS entwickelt habe, sogar die Namen sind fast gleich.
https://github.com/mdyd-dev/platformos-documentation/blob/master/src/js/toc.js
Ich habe diese Lösung noch nie gesehen, aber sie ergibt Sinn, da der größte Teil davon Vanilla JS ist und das Problem Sie in eine bestimmte Richtung lenkt. Ursprünglich habe ich den ersten Prototyp in Vue geschrieben und dann die verschiedenen Teile für diese Anleitung zu Vanilla JS umgeschrieben.
Wirklich interessant & detailliert! Ich liebe es ^_^