Markdown ist wirklich ein großartiges Format. Es ist nah genug an einfachem Text, sodass jeder es schnell lernen kann, und es ist strukturiert genug, um geparst und schließlich in alles Mögliche umgewandelt zu werden.
Das heißt: Markdown zu parsen, zu verarbeiten, zu verbessern und zu konvertieren erfordert Code. Das Ausliefern all dieses Codes auf dem Client hat seinen Preis. Es ist an sich nicht riesig, aber es sind immer noch ein paar Dutzend Kilobytes Code, die nur zur Verarbeitung von Markdown und nichts anderem verwendet werden.
In diesem Artikel möchte ich erklären, wie man Markdown in einer Next.js-Anwendung mithilfe des Unified/Remark-Ökosystems (bin mir wirklich nicht sicher, welchen Namen ich verwenden soll, das ist alles super verwirrend) aus dem Client heraushält.
Allgemeine Idee
Die Idee ist, Markdown nur in den `getStaticProps`-Funktionen von Next.js zu verwenden, so dass dies während eines Builds (oder in einer Next-Serverless-Funktion bei Verwendung von Vercels inkrementellen Builds) geschieht, aber niemals im Client. Ich denke, `getServerSideProps` wäre auch in Ordnung, aber ich glaube, `getStaticProps` ist der wahrscheinlichere Anwendungsfall.
Dies würde ein AST (Abstract Syntax Tree, also ein großes verschachteltes Objekt, das unseren Inhalt beschreibt) zurückgeben, das sich aus dem Parsen und Verarbeiten des Markdown-Inhalts ergibt, und der Client wäre nur für das Rendern dieses AST in React-Komponenten verantwortlich.
Ich schätze, wir könnten Markdown sogar direkt in `getStaticProps` als HTML rendern und es mit `dangerouslySetInnerHtml` zurückgeben, aber wir sind nicht diese Art von Leuten. Sicherheit ist wichtig. Und auch die Flexibilität, Markdown so zu rendern, wie wir es mit unseren Komponenten wollen, anstatt dass es als einfacher HTML-Code gerendert wird. Ernsthaft Leute, tut das nicht. 😅
export const getStaticProps = async () => {
// Get the Markdown content from somewhere, like a CMS or whatnot. It doesn’t
// matter for the sake of this article, really. It could also be read from a
// file.
const markdown = await getMarkdownContentFromSomewhere()
const ast = parseMarkdown(markdown)
return { props: { ast } }
}
const Page = props => {
// This would usually have your layout and whatnot as well, but omitted here
// for sake of simplicity of course.
return <MarkdownRenderer ast={props.ast} />
}
export default Page
Markdown parsen
Wir werden das Unified/Remark-Ökosystem verwenden. Wir müssen `unified` und `remark-parse` installieren, und das war's schon. Das Parsen des Markdowns selbst ist relativ einfach.
import { unified } from 'unified'
import markdown from 'remark-parse'
const parseMarkdown = content => unified().use(markdown).parse(content)
export default parseMarkdown
Was ich eine ganze Weile gebraucht habe, um zu verstehen, ist, warum meine zusätzlichen Plugins wie `remark-prism` oder `remark-slug` nicht so funktionierten. Das liegt daran, dass die `.parse(..)`-Methode von Unified das AST nicht mit Plugins verarbeitet. Wie der Name schon sagt, parst sie nur den Markdown-Inhalt als String in einen Baum.
Wenn wir Unified möchten, dass es unsere Plugins anwendet, müssen wir Unified die sogenannte "run"-Phase durchlaufen lassen. Normalerweise geschieht dies durch die Verwendung der `.process(..)`-Methode anstelle der `.parse(..)`-Methode. Leider parst `.process(..)` nicht nur Markdown und wendet Plugins an, sondern stringifiziert das AST auch in ein anderes Format (wie HTML über `remark-html` oder JSX mit `remark-react`). Und das ist nicht das, was wir wollen, da wir das AST erhalten möchten, aber nachdem es von Plugins verarbeitet wurde.
| ........................ process ........................... |
| .......... parse ... | ... run ... | ... stringify ..........|
+--------+ +----------+
Input ->- | Parser | ->- Syntax Tree ->- | Compiler | ->- Output
+--------+ | +----------+
X
|
+--------------+
| Transformers |
+--------------+
Wir müssen also sowohl die Parsing- als auch die Laufphasen durchführen, aber nicht die Stringifizierungsphase. Unified stellt keine Methode bereit, um diese 2 von 3 Phasen durchzuführen, aber es stellt für jede Phase individuelle Methoden bereit, sodass wir es manuell tun können.
import { unified } from 'unified'
import markdown from 'remark-parse'
import prism from 'remark-prism'
const parseMarkdown = content => {
const engine = unified().use(markdown).use(prism)
const ast = engine.parse(content)
// Unified‘s *process* contains 3 distinct phases: parsing, running and
// stringifying. We do not want to go through the stringifying phase, since we
// want to preserve an AST, so we cannot call `.process(..)`. Calling
// `.parse(..)` is not enough though as plugins (so Prism) are executed during
// the running phase. So we need to manually call the run phase (synchronously
// for simplicity).
// See: https://github.com/unifiedjs/unified#description
return engine.runSync(ast)
}
Tada! Wir haben unser Markdown in einen Syntaxbaum geparst. Und dann haben wir unsere Plugins auf diesem Baum ausgeführt (hier synchron zur Einfachheit halber, aber Sie könnten `.run(..)` verwenden, um es asynchron zu tun). Aber wir haben unseren Baum nicht in eine andere Syntax wie HTML oder JSX umgewandelt. Das können wir selbst machen, beim Rendern.
Markdown rendern
Jetzt, da wir unseren coolen Baum bereit haben, können wir ihn so rendern, wie wir es beabsichtigen. Nehmen wir eine `MarkdownRenderer`-Komponente, die den Baum als `ast`-Prop erhält und alles mit React-Komponenten rendert.
const getComponent = node => {
switch (node.type) {
case 'root':
return ({ children }) => <>{children}</>
case 'paragraph':
return ({ children }) => <p>{children}</p>
case 'emphasis':
return ({ children }) => <em>{children}</em>
case 'heading':
return ({ children, depth = 2 }) => {
const Heading = `h${depth}`
return <Heading>{children}</Heading>
}
case 'text':
return ({ value }) => <>{value}</>
/* Handle all types here … */
default:
console.log('Unhandled node type', node)
return ({ children }) => <>{children}</>
}
}
const Node = node => {
const Component = getComponent(node)
const { children } = node
return children ? (
<Component {...node}>
{children.map((child, index) => (
<Node key={index} {...child} />
))}
</Component>
) : (
<Component {...node} />
)
}
const MarkdownRenderer = props => <Node {...props.ast} />
export default React.memo(MarkdownRenderer)
Der Großteil der Logik unseres Renderers lebt in der `Node`-Komponente. Sie findet heraus, was gerendert werden soll, basierend auf dem `type`-Schlüssel des AST-Knotens (dies ist unsere `getComponent`-Methode, die jeden Knotentyp behandelt) und rendert ihn dann. Wenn der Knoten Kinder hat, geht er rekursiv in die Kinder; andernfalls rendert er die Komponente einfach als abschließendes Blatt.
Aufräumen des Baums
Abhängig davon, welche Remark-Plugins wir verwenden, können wir beim Rendern unserer Seite auf folgendes Problem stoßen:
Error: Error serializing
.content[0].content.children[3].data.hChildren[0].data.hChildren[0].data.hChildren[0].data.hChildren[0].data.hNamereturned fromgetStaticPropsin “/”. Reason:undefinedcannot be serialized as JSON. Please usenullor omit this value.
Das passiert, weil unser AST Schlüssel enthält, deren Werte `undefined` sind, was nicht sicher als JSON serialisiert werden kann. Next gibt uns die Lösung: Entweder wir lassen den Wert weg oder ersetzen ihn durch `null`, wenn wir ihn irgendwie benötigen.
Wir werden jedoch nicht jeden Pfad von Hand korrigieren, also müssen wir diesen AST rekursiv durchlaufen und aufräumen. Ich habe herausgefunden, dass dies beim Verwenden von remark-prism auftritt, einem Plugin zur Aktivierung der Syntaxhervorhebung für Codeblöcke. Das Plugin fügt tatsächlich ein `[data]`-Objekt zu Knoten hinzu.
Was wir tun können, ist, unseren AST vor der Rückgabe zu durchlaufen, um diese Knoten aufzuräumen.
const cleanNode = node => {
if (node.value === undefined) delete node.value
if (node.tagName === undefined) delete node.tagName
if (node.data) {
delete node.data.hName
delete node.data.hChildren
delete node.data.hProperties
}
if (node.children) node.children.forEach(cleanNode)
return node
}
const parseMarkdown = content => {
const engine = unified().use(markdown).use(prism)
const ast = engine.parse(content)
const processedAst = engine.runSync(parsed)
cleanNode(processedAst)
return processedAst
}
Eine letzte Sache, die wir tun können, um weniger Daten an den Client zu senden, ist das Entfernen des `position`-Objekts, das auf jedem einzelnen Knoten vorhanden ist und die ursprüngliche Position im Markdown-String enthält. Es ist kein großes Objekt (es hat nur zwei Schlüssel), aber wenn der Baum groß wird, summiert es sich schnell auf.
const cleanNode = node => {
delete node.position
Zusammenfassung
Das war's Leute! Wir haben es geschafft, die Markdown-Verarbeitung auf den Build-/Server-Code zu beschränken, damit wir keine Markdown-Laufzeit an den Browser liefern, was unnötig kostspielig ist. Wir übergeben einen Datenbaum an den Client, den wir durchlaufen und in beliebige React-Komponenten umwandeln können.
Ich hoffe, das hilft. :)
Hallo, ich bin der Maintainer von remark/unified! Ich habe die Frage gesehen, welche Begriffe man wofür verwenden soll, und dachte, ich versuche, es zu erklären. Es ist total verständlich, dass es verwirrend ist, aber für alle, die interessiert sind, hier ist es:
unified ist der Name für das, was alles darunter liegt: die `parse`, `run`, `stringify`-Schnittstelle. Es ist auch der Name, den Benutzer für *alles* verwenden (typischerweise als *unified collective*).
remark ist das Markdown-Ökosystem: Wenn Sie also Plugins haben, die auf einem Markdown-AST arbeiten, ist das remark.
In vielen Fällen arbeiten Sie auch mit HTML, das als rehype bezeichnet wird.
Es gibt auch andere AST-Ökosysteme, wie natürliche Sprache, JavaScript, XML, mit anderen Namen.
Wenn Sie also mit Markdown beginnen, können Sie `remark-parse` und andere remark-Plugins verwenden.
Wenn Sie mit HTML beginnen, können Sie `rehype-parse` und rehype-Plugins verwenden.
Sie können dort aufhören und `remark-stringify`/`rehype-stringify` verwenden.
Oder Sie können von einem zum anderen wechseln, mit `remark-rehype` oder `rehype-remark`. Und die Plugins der anderen Ökosysteme verwenden!
Beispiel: https://github.com/remarkjs/remark-rehype#use
Also… leider war die Befolgung dieser Anleitung nicht einfach. Es gibt eine Reihe von Dingen, die in der neuesten Version nicht ganz funktionierten.
Erstens, in der `parseMarkdown`-Funktion, wenn ich `runSync` verwende, funktioniert es nicht. Wenn ich es in eine asynchrone Funktion umwandle und `run(ast)` verwende, beschwert sich TypeScript sehr laut, aber das Ergebnis funktioniert zumindest.
Ich werde nicht einmal darüber sprechen, wie keine der anderen Typen angegeben wurden (nicht jeder verwendet TypeScript, also ist das keine Schuld der Anleitung wirklich).
Für diejenigen, die Typen wollen, hier ist, was ich mir ausgedacht habe:
Was mich daran erinnert, `getComponent` muss den `Node` aufräumen, bevor es ein `Fragment` zurückgibt, oder Dinge, die den Strict-Mode von React verwenden, schreien. Ersetzen Sie im Grunde die Rückgabe am Ende durch dies (und ersetzen Sie dann jeden Switch-Fall, der ein `Fragment` zurückgibt, durch `break` stattdessen).
Nachdem wir das alles aus dem Weg geräumt haben, sprechen wir darüber, **was die erwartete Rückgabe von getComponent wirklich ist**: eine `ReactElement`-Funktion/Klassenkonstruktor.
Warum ist das wichtig?
Das Zurückgeben eines Strings (wie 'a') **tut tatsächlich nicht, was es sollte. Es rendert stattdessen ein leeres a-Tag ohne Eigenschaften.** Wir brauchen also ordnungsgemäße Konstruktoren für unterstützte Tags.
Ich musste den Switch-Fall damit ersetzen:
Und diese Komponenten selbst?
Hallo Damon, und danke, dass Sie sich die Zeit genommen haben, einen Kommentar zu hinterlassen. Es gibt einiges zu entpacken, erlauben Sie mir also, die Dinge nacheinander durchzugehen.
Ich persönlich benutze kein TypeScript und habe es nie getan, also kann ich auf diesem Gebiet nur begrenzt etwas tun. Wie Sie sagten, nicht jeder benutzt TypeScript. Es tut mir leid, dass Sie damit Probleme haben.
Was Fragmente angeht, haben Sie absolut Recht. Ich habe den Code aktualisiert, um stattdessen `({ children }) => <>{children}</>` zu verwenden, sodass keine Props an das Fragment übergeben werden. Auf diese Weise muss die Bereinigung nicht vor dem Rendern erfolgen, wie Sie vorgeschlagen haben, und es wird auch unempfindlich gegen die Hinzufügung von AST-Feldern.
Das Zurückgeben von Strings wie `p` oder `em` als Teil von `getComponent` funktioniert einwandfrei (gerade getestet). Es reicht jedoch nicht für Links, da diese einen `url`-Schlüssel vom AST erhalten und stattdessen das `href`-Attribut rendern müssen. Ich denke, die Verwendung einer ordnungsgemäßen Komponentendefinition ist etwas sicherer, daher habe ich den Artikel entsprechend aktualisiert. Und wie erwähnt, müssen alle Typen implementiert werden, da der Code-Snippet nur einige wenige zeigt. Ich habe auch die Handhabung des `text`-Typs zur Verdeutlichung hinzugefügt.
Nochmals vielen Dank für Ihr Feedback! Ich hoffe, der Artikel ist jetzt etwas klarer. :)
Ich komme zurück, um etwas hinzuzufügen, das ich kürzlich bemerkt habe: Es scheint, dass alles auch mit unified v10 gut funktioniert, solange der Import aktualisiert wird, um eine benannte Importierung anstelle der Standardimportierung zu verwenden (`import { unified } from 'unified'`).
TypeScript-Tipp: Die Knotentypen befinden sich in mdast (Abhängigkeit von remark).
Ich aliasse alle Typen, damit sie nicht mit der UI-Bibliothek kollidieren, die ich verwende.
Ich mag den Artikel und ich denke tatsächlich, dass der Ansatz interessant ist, in Markdown zu schreiben, aber das AST an den Client zu senden.
Ich habe das kürzlich selbst ausprobiert. Zuerst habe ich versucht, zu HTML zu rendern, um zu sehen, wie groß die Größe war. Ich rendere auch Mathematik, aber bei zwei Tests stieg die Markdown-Datei um das 18-fache und bei der anderen um das 35-fache. Ich kann nur vermuten, dass das AST ähnlich ist, da diese Bäume sehr groß sein können. Ich denke, es ist ein Kompromiss zwischen Größe über das Netzwerk und Kosten für die Laufzeitverarbeitung. Im Moment habe ich die Laufzeitverarbeitung gewählt, da ich denke, dass sie die Notwendigkeit, die Pipeline zu unterbrechen, reduziert.
Das war ein wunderbar detaillierter Beitrag!
Er hat mir geholfen, etwas Ähnliches zu implementieren, wo ich HTML anstelle von Markdown erhalte, aber dennoch einige Elemente durch benutzerdefinierte Komponenten wie Nextjs' Image und Link ersetzen möchte.