Skip to content

import Image from 'next/image'

基于 Next MDX & Sandpack 的博客文章实现

Next MDX

对于博客来说,写 MarkDown 可以快速生成 HTML 也就是文章,而 MDX 是 MarkDown 的扩展,支持在 MarkDown 中写 JSX,使用 React 组件。参考上面的官方文档就可以做 MDX 功能

对于文章的目录划分,可以参考下面的官方例子

每次新增文章的时候,手动去 [slug] 里增加 generateStaticParams 配置,这样就可以在 build 的时候生成静态页面

image1.png

文章样式

因为是 Nextjs + tailwind 项目,可以使用 @tailwindcss/typography 来实现文章样式,其实就是加了一些 css,所以可以自己加,也没多少样式

image2.png

文章侧边栏目录实现

侧边栏目录需要自己写,Next MDX 没有提供侧边栏目录功能

1、文章渲染时生成唯一 id,用来定位目录和做锚点,这个 id 直接取标题

image3.png

2、监听内容变化,动态或者获取到 h1, h2, h3 的 id,然后生成目录,这里因为使用了ssg,所以能直接拿到文章目录的id

tsx
const [headings, setHeadings] = useState<Heading[]>([])

useEffect(() => {
  // 等待 DOM 完全加载
  const getHeadings = () => {
    const elements = Array.from(document.querySelectorAll('h1[id], h2[id], h3[id]'))
      .filter(
        (element): element is HTMLElement =>
          element instanceof HTMLElement && element.id.length > 0 && element.textContent !== null
      )
      .map((element) => {
        return {
          id: element.id,
          text: element.textContent || '',
          level: parseInt(element.tagName[1])
        }
      })

    if (elements.length > 0) {
      setHeadings(elements)
    }
  }

  // 初始获取
  getHeadings()
}, [])
const [headings, setHeadings] = useState<Heading[]>([])

useEffect(() => {
  // 等待 DOM 完全加载
  const getHeadings = () => {
    const elements = Array.from(document.querySelectorAll('h1[id], h2[id], h3[id]'))
      .filter(
        (element): element is HTMLElement =>
          element instanceof HTMLElement && element.id.length > 0 && element.textContent !== null
      )
      .map((element) => {
        return {
          id: element.id,
          text: element.textContent || '',
          level: parseInt(element.tagName[1])
        }
      })

    if (elements.length > 0) {
      setHeadings(elements)
    }
  }

  // 初始获取
  getHeadings()
}, [])

3、监听滚动位置,滚动到对应位置,对应的目录显示高亮,这里别用 intersectionObserver,很容易出问题,所以参考 react 官网文档的方案 getBoundingClientRect 来实现

text
视口顶部 ─────────────────
         ↑ TOP_OFFSET (100px)
激活线   ─────────────────  ← 这条线决定哪个标题被激活

         [标题1] ← 如果在激活线上方,不激活
         [标题2] ← 如果刚好越过激活线,激活这个
         [标题3] ← 如果还在激活线下方,不激活

视口底部 ─────────────────
视口顶部 ─────────────────
         ↑ TOP_OFFSET (100px)
激活线   ─────────────────  ← 这条线决定哪个标题被激活

         [标题1] ← 如果在激活线上方,不激活
         [标题2] ← 如果刚好越过激活线,激活这个
         [标题3] ← 如果还在激活线下方,不激活

视口底部 ─────────────────
tsx
useEffect(() => {
  const TOP_OFFSET = 100 // 考虑固定头部的高度

  const updateActiveLink = () => {
    const pageHeight = document.body.scrollHeight
    const scrollPosition = window.scrollY + window.innerHeight

    if (scrollPosition >= 0 && pageHeight - scrollPosition <= 0) {
      // 滚动到页面底部
      const lastHeading = headings[headings.length - 1]
      if (lastHeading) {
        setActiveId(lastHeading.id)
      }
      return
    }

    let index = -1
    while (index < headings.length - 1) {
      const heading = headings[index + 1]
      const element = document.getElementById(heading.id)

      if (!element) {
        index += 1
        continue
      }

      const { top } = element.getBoundingClientRect()

      if (top >= TOP_OFFSET) {
        break
      }
      index += 1
    }

    const activeIndex = Math.max(index, 0)
    if (headings[activeIndex]) {
      setActiveId(headings[activeIndex].id)
    }
  }

  // 初始化时执行一次
  updateActiveLink()

  // 监听滚动事件
  const handleScroll = () => {
    updateActiveLink()
  }

  window.addEventListener('scroll', handleScroll, { passive: true })

  return () => {
    window.removeEventListener('scroll', handleScroll)
  }
}, [headings])
useEffect(() => {
  const TOP_OFFSET = 100 // 考虑固定头部的高度

  const updateActiveLink = () => {
    const pageHeight = document.body.scrollHeight
    const scrollPosition = window.scrollY + window.innerHeight

    if (scrollPosition >= 0 && pageHeight - scrollPosition <= 0) {
      // 滚动到页面底部
      const lastHeading = headings[headings.length - 1]
      if (lastHeading) {
        setActiveId(lastHeading.id)
      }
      return
    }

    let index = -1
    while (index < headings.length - 1) {
      const heading = headings[index + 1]
      const element = document.getElementById(heading.id)

      if (!element) {
        index += 1
        continue
      }

      const { top } = element.getBoundingClientRect()

      if (top >= TOP_OFFSET) {
        break
      }
      index += 1
    }

    const activeIndex = Math.max(index, 0)
    if (headings[activeIndex]) {
      setActiveId(headings[activeIndex].id)
    }
  }

  // 初始化时执行一次
  updateActiveLink()

  // 监听滚动事件
  const handleScroll = () => {
    updateActiveLink()
  }

  window.addEventListener('scroll', handleScroll, { passive: true })

  return () => {
    window.removeEventListener('scroll', handleScroll)
  }
}, [headings])

4、点击页面元素,滚动到对应位置(锚点)

tsx
<ul className="mt-4 space-y-1 text-sm">
  {headings.map((heading, index) => (
    <li
      key={`heading-${heading.id}-${index}`}
      className={cn('rounded-l-xl px-2', selectedIndex === index ? 'bg-blue-50 dark:bg-blue-900/20' : null, {
        'pl-4': heading.level === 3,
        hidden: heading.level > 3
      })}
    >
      <a
        href={`#${heading.id}`}
        className={cn(
          selectedIndex === index
            ? 'font-semibold text-blue-600 dark:text-blue-400'
            : 'text-zinc-500 dark:text-zinc-400',
          'block py-2 leading-normal transition-colors hover:text-blue-600 dark:hover:text-blue-400'
        )}
        onClick={() => {
          // 手动设置选中状态,提供即时反馈
          setSelectedIndex(index)
        }}
      >
        {heading.text}
      </a>
    </li>
  ))}
</ul>
<ul className="mt-4 space-y-1 text-sm">
  {headings.map((heading, index) => (
    <li
      key={`heading-${heading.id}-${index}`}
      className={cn('rounded-l-xl px-2', selectedIndex === index ? 'bg-blue-50 dark:bg-blue-900/20' : null, {
        'pl-4': heading.level === 3,
        hidden: heading.level > 3
      })}
    >
      <a
        href={`#${heading.id}`}
        className={cn(
          selectedIndex === index
            ? 'font-semibold text-blue-600 dark:text-blue-400'
            : 'text-zinc-500 dark:text-zinc-400',
          'block py-2 leading-normal transition-colors hover:text-blue-600 dark:hover:text-blue-400'
        )}
        onClick={() => {
          // 手动设置选中状态,提供即时反馈
          setSelectedIndex(index)
        }}
      >
        {heading.text}
      </a>
    </li>
  ))}
</ul>

Sandpack

sandpack,一个在线编辑器,支持在浏览器在线编译和运行代码,做完达到的效果如下:

sandpack

下面图片 Demo 入口

sandpack

MDX 里实现 Sandpack 组件

1、注册 Sandpack 组件

这一步是用 nextjs mdx 的能力,注册一个组件,这个组件使用开源的 sandpack 组件

sandpack

2、props 解析(重点),在上一步做完后,<Sandpack> 组件里的代码会被解析字符串通过 props 拿到,然后自己写解析,解析成开源 sandpack 组件需要的格式,也就是图里上面的 react element 格式转成需要的对象配置格式

sandpack

这个props的数据可以看出

  • 比如```js这个会被处理成language-js,同理 css 会被处理成 language-css
  • 数据在 pre 同级的 props 的 children.props 里
  • 设置的 hidden active 参数属于 meta 信息

这里有点坑,我默认用的是react,但有时候需要 nextjs,默认的 nextjs 模版很老,但是又暂时无法从 props 取到 meta 信息,根据 meta 信息来新建对应的文件。而且覆盖 nextjs 模版或者完全自定义模版都不行,所以暂时 nextjs 就用 page router

tsx
export const AppJSPath = `/App.js`
export const StylesCSSPath = `/styles.css`

const createFileMap = (children: any): Record<string, SandpackFile> => {
  const result: Record<string, SandpackFile> = {}

  const preElements = Array.isArray(children) ? children : [children]

  preElements.forEach((codeSnippet: { props: { children: any } }) => {
    const codeElement = codeSnippet.props.children // 实际的 <code> 元素
    if (!codeElement || !codeElement.props) return

    const { props } = codeElement
    let filePath: string // 文件路径
    const fileHidden = false // 是否隐藏文件
    const fileActive = false // 当有多个文件时,决定哪个文件默认被选中和显示

    if (props.className === 'language-js' || props.className === 'language-javascript') {
      filePath = AppJSPath
    } else if (props.className === 'language-css') {
      filePath = StylesCSSPath
    } else {
      // 默认作为 JS 文件处理
      filePath = AppJSPath
    }

    if (result[filePath]) {
      throw new Error(`文件 ${filePath} 被定义了多次。每个文件片段应该有唯一的路径名`)
    }

    result[filePath] = {
      code: (props.children || '') as string,
      hidden: fileHidden,
      active: fileActive
    }
  })

  return result
}
export const AppJSPath = `/App.js`
export const StylesCSSPath = `/styles.css`

const createFileMap = (children: any): Record<string, SandpackFile> => {
  const result: Record<string, SandpackFile> = {}

  const preElements = Array.isArray(children) ? children : [children]

  preElements.forEach((codeSnippet: { props: { children: any } }) => {
    const codeElement = codeSnippet.props.children // 实际的 <code> 元素
    if (!codeElement || !codeElement.props) return

    const { props } = codeElement
    let filePath: string // 文件路径
    const fileHidden = false // 是否隐藏文件
    const fileActive = false // 当有多个文件时,决定哪个文件默认被选中和显示

    if (props.className === 'language-js' || props.className === 'language-javascript') {
      filePath = AppJSPath
    } else if (props.className === 'language-css') {
      filePath = StylesCSSPath
    } else {
      // 默认作为 JS 文件处理
      filePath = AppJSPath
    }

    if (result[filePath]) {
      throw new Error(`文件 ${filePath} 被定义了多次。每个文件片段应该有唯一的路径名`)
    }

    result[filePath] = {
      code: (props.children || '') as string,
      hidden: fileHidden,
      active: fileActive
    }
  })

  return result
}

然后把解析的代码传入 开源的 sandpack 组件,就可以展示在页面上

tsx
const files = createFileMap(props.children)

return <CodeSandbox template={props.template} customSetup={props.customSetup} files={files} />
const files = createFileMap(props.children)

return <CodeSandbox template={props.template} customSetup={props.customSetup} files={files} />

开源的sandpack组件

tsx
import { Sandpack } from '@codesandbox/sandpack-react'

export function CodeSandbox({ files, template = 'react', customSetup }: CodeSandboxProps) {
  return (
    <Sandpack
      template={template}
      theme={'dark'}
      files={files}
      customSetup={customSetup}
      options={{
        editorWidthPercentage: 60 // 代码占的百分比
      }}
    />
  )
}
import { Sandpack } from '@codesandbox/sandpack-react'

export function CodeSandbox({ files, template = 'react', customSetup }: CodeSandboxProps) {
  return (
    <Sandpack
      template={template}
      theme={'dark'}
      files={files}
      customSetup={customSetup}
      options={{
        editorWidthPercentage: 60 // 代码占的百分比
      }}
    />
  )
}

仓库地址

github 地址