Next.js 中负责任的 Markdown

Avatar of Kitty Giraudel
Kitty Giraudel

DigitalOcean 提供适用于您旅程各个阶段的云产品。 立即开始使用 价值 200 美元的免费积分!

Markdown 确实是一种很棒的格式。 它非常接近纯文本,所以任何人都可以快速学习它,而且它的结构足够好,可以被解析并最终转换为任何你想要的格式。

话虽如此:解析、处理、增强和转换 Markdown 需要代码。 在客户端交付所有这些代码会带来成本。 它本身并不大,但它仍然是几十 KB 的代码,仅用于处理 Markdown,而不是其他任何东西。

在本文中,我将解释如何在 Next.js 应用程序中使用 Unified/Remark 生态系统(我真的不知道该用哪个名字,这太令人困惑了)将 Markdown 保持在客户端之外。

总体思路

这个想法是只在 Next.js 的 getStaticProps 函数中使用 Markdown,这样在构建期间(或者如果使用 Vercel 的增量构建,则在 Next 无服务器函数中)就会完成,但永远不会在客户端完成。 我想 getServerSideProps 也可以,但我认为 getStaticProps 更可能是常见用例。

这将返回一个 AST(抽象语法树,也就是说一个描述我们内容的大型嵌套对象),该对象是解析和处理 Markdown 内容的结果,而客户端只负责将该 AST 渲染成 React 组件。

我想我们甚至可以在 getStaticProps 中直接将 Markdown 渲染为 HTML 并返回它以使用 dangerouslySetInnerHtml 渲染,但我们不是那种人。 安全问题。 而且,用我们自己的组件以我们想要的方式渲染 Markdown 的灵活性,而不是以纯 HTML 形式渲染。 认真的朋友们,不要那样做。 😅

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

我们将使用 Unified/Remark 生态系统。 我们需要安装 unifiedremark-parse,仅此而已。 解析 Markdown 本身比较简单

import { unified } from 'unified'
import markdown from 'remark-parse'

const parseMarkdown = content => unified().use(markdown).parse(content)

export default parseMarkdown

现在,我花了很长时间才明白为什么我的额外插件,比如 remark-prismremark-slug,不能像这样工作。 这是因为 Unified 的 .parse(..) 方法不会使用插件处理 AST。 正如它的名字所暗示的那样,它只是将 Markdown 内容字符串解析成一个树。

如果我们希望 Unified 应用我们的插件,我们需要 Unified 经历他们称之为“运行”阶段。 通常,这通过使用 .process(..) 方法而不是 .parse(..) 方法来完成。 不幸的是,.process(..) 不仅解析 Markdown 并应用插件,还会将 AST 字符串化成另一种格式(比如通过 remark-html 生成 HTML,或者通过 remark-react 生成 JSX)。 这不是我们想要的,因为我们希望保留 AST,但在它被插件处理之后。

| ........................ process ........................... |
| .......... parse ... | ... run ... | ... stringify ..........|

          +--------+                     +----------+
Input ->- | Parser | ->- Syntax Tree ->- | Compiler | ->- Output
          +--------+          |          +----------+
                              X
                              |
                       +--------------+
                       | Transformers |
                       +--------------+

因此,我们需要做的就是运行解析和运行阶段,但不是字符串化阶段。 Unified 没有提供一种方法来执行这三个阶段中的两个,但它为每个阶段提供了单独的方法,因此我们可以手动完成它

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)
}

瞧! 我们将 Markdown 解析成了一个语法树。 然后,我们在该树上运行了我们的插件(为了简单起见,这里同步完成,但您可以使用 .run(..) 来异步完成它)。 但是,我们没有将我们的树转换为 HTML 或 JSX 等其他语法。 我们可以自己在渲染中完成。

渲染 Markdown

现在我们已经准备好了我们酷炫的树,我们可以按照我们想要的方式渲染它。 让我们有一个 MarkdownRenderer 组件,它接收树作为 ast 属性,并使用 React 组件渲染它。

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)

我们渲染器的大部分逻辑都在 Node 组件中。 它根据 AST 节点的 type 键(这是我们的 getComponent 方法处理每种类型的节点)来确定要渲染的内容,然后渲染它。 如果该节点有子节点,它会递归进入子节点; 否则,它只是将组件作为最终叶子渲染。

清理树

根据我们使用的 Remark 插件,在尝试渲染页面时,我们可能会遇到以下问题

错误:错误序列化 .content[0].content.children[3].data.hChildren[0].data.hChildren[0].data.hChildren[0].data.hChildren[0].data.hName 从 “/” 中的 getStaticProps 返回。 原因:undefined 无法序列化为 JSON。 请使用 null 或省略此值。

这是因为我们的 AST 包含键,其值为 undefined,而 undefined 不是可以安全地序列化为 JSON 的东西。 Next 给出了解决方案:要么完全省略该值,要么如果我们需要它,则用 null 替换它。

但是,我们不会手工修复每条路径,因此我们需要递归遍历该 AST 并清理它。 我发现,当使用 remark-prism(一个用于启用代码块语法高亮的插件)时,会出现这种情况。 该插件确实 在节点中添加了 [data] 对象.

我们可以做的是在返回 AST 之前遍历它以清理这些节点

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
}

最后,我们可以做的一件事是删除 position 对象,它存在于每个节点上,并保存 Markdown 字符串中的原始位置。 它不是一个大对象(它只有两个键),但是当树变得很大时,它会迅速累加。

const cleanNode = node => {
  delete node.position

总结

就是这样了! 我们设法将 Markdown 处理限制在构建/服务器端代码中,因此我们没有将 Markdown 运行时发送到浏览器,这是不必要的成本。 我们将一个数据树传递给客户端,我们可以遍历它并将其转换为任何我们想要的 React 组件。

希望这对您有所帮助。 :)