【译】如何在Next.js应用程序中将Markdown从客户端中分离出来

333 阅读6分钟

Markdown确实是一种伟大的格式。它足够接近于纯文本,所以任何人都可以很快学会它,而且它的结构化程度很高,可以被解析并最终转换为你的名字。

这就是说:解析、处理、增强和转换Markdown需要代码。将所有这些代码放在客户端是有代价的。它本身并不庞大,但它仍然是几十KB的代码,只用来处理Markdown而不是其他。

在这篇文章中,我想解释一下如何在Next.js应用程序中使用统一/Remark生态系统(真心不知道该用哪个名字,这都是超级混乱的)将Markdown从客户端中分离出来。

总体思路

我们的想法是,只在Next.js的getStaticProps 函数中使用Markdown,所以这是在构建过程中完成的(如果使用Vercel的增量构建,则在Next的无服务器函数中完成),但绝不在客户端使用。我想getServerSideProps 也可以,但我认为getStaticProps 更有可能成为常见的使用情况。

这将返回解析和处理Markdown内容后得到的AST(抽象语法树,也就是描述我们内容的大嵌套对象),而客户端只负责将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 ,不能像这样工作。这是因为统一公司的.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没有提供一个方法来完成3个阶段中的这2个,但它为每个阶段提供了单独的方法,所以我们可以手动完成。

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!我们把我们的Markdown解析成一个语法树。然后我们在这个树上运行我们的插件(为了简单起见,这里是同步进行的,但你可以用.run(..) 来异步进行)。但我们并没有将我们的树转换为其他的语法,如HTML或JSX。我们可以在渲染时自己做。

渲染Markdown

现在我们已经准备好了我们很酷的树,我们可以用我们的方式来渲染它。让我们有一个MarkdownRenderer 组件,将树作为一个ast 道具来接收,然后用React组件来渲染它。

const getComponent = node => {
  switch (node.type) {
    case 'root':
      return React.Fragment

    case 'paragraph':
      return 'p'

    case 'emphasis':
      return 'em'

    case 'heading':
      return ({ children, depth = 2 }) => {
        const Heading = `h${depth}`
        return <Heading>{children}</Heading>
      }

    /* Handle all types here … */

    default:
      console.log('Unhandled node type', node)
      return React.Fragment
  }
}

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 key(这是我们的getComponent 方法,处理每一种类型的节点)找出要渲染的内容,然后渲染它。如果该节点有子节点,它就递归到子节点中;否则,它就把该组件渲染成最后的叶子。

清理树

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

错误。在"/"中序列化从getStaticProps 返回的.content[0].content.children[3].data.hChildren[0].data.hChildren[0].data.hChildren[0].data.hChildren[0].data.hName 时出错。原因:undefined 不能被序列化为JSON。请使用null 或省略此值。

发生这种情况是因为我们的AST包含的键的值是undefined ,这不是可以安全地序列化为JSON的东西。接下来给了我们解决方案:要么我们完全省略这个值,要么如果我们有点需要它,就用null 来代替它。

不过我们不可能手工修复每一个路径,所以我们需要递归地走一遍AST,把它清理掉。我发现这种情况是在使用remark-prism 的时候发生的,这是一个为代码块启用语法高亮的插件。该插件确实为节点添加了一个[data] 对象

我们可以做的是,在返回AST之前,先走一遍我们的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组件。

我希望这有帮助。:)


The postResponsible Markdown in Next.jsappeared first onCSS-Tricks.你可以通过成为MVP支持者来支持CSS-Tricks。