import Image from 'next/image'
基于 Next MDX & Sandpack 的博客文章实现
Next MDX
对于博客来说,写 MarkDown 可以快速生成 HTML 也就是文章,而 MDX 是 MarkDown 的扩展,支持在 MarkDown 中写 JSX,使用 React 组件。参考上面的官方文档就可以做 MDX 功能
对于文章的目录划分,可以参考下面的官方例子
每次新增文章的时候,手动去 [slug] 里增加 generateStaticParams 配置,这样就可以在 build 的时候生成静态页面

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

文章侧边栏目录实现
侧边栏目录需要自己写,Next MDX 没有提供侧边栏目录功能
1、文章渲染时生成唯一 id,用来定位目录和做锚点,这个 id 直接取标题

2、监听内容变化,动态或者获取到 h1, h2, h3 的 id,然后生成目录,这里因为使用了ssg,所以能直接拿到文章目录的id
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 来实现
视口顶部 ─────────────────
↑ TOP_OFFSET (100px)
激活线 ───────────────── ← 这条线决定哪个标题被激活
[标题1] ← 如果在激活线上方,不激活
[标题2] ← 如果刚好越过激活线,激活这个
[标题3] ← 如果还在激活线下方,不激活
视口底部 ─────────────────视口顶部 ─────────────────
↑ TOP_OFFSET (100px)
激活线 ───────────────── ← 这条线决定哪个标题被激活
[标题1] ← 如果在激活线上方,不激活
[标题2] ← 如果刚好越过激活线,激活这个
[标题3] ← 如果还在激活线下方,不激活
视口底部 ─────────────────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、点击页面元素,滚动到对应位置(锚点)
<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,一个在线编辑器,支持在浏览器在线编译和运行代码,做完达到的效果如下:


MDX 里实现 Sandpack 组件
1、注册 Sandpack 组件
这一步是用 nextjs mdx 的能力,注册一个组件,这个组件使用开源的 sandpack 组件

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

这个props的数据可以看出
- 比如```js这个会被处理成language-js,同理 css 会被处理成 language-css
- 数据在 pre 同级的 props 的 children.props 里
- 设置的 hidden active 参数属于 meta 信息
这里有点坑,我默认用的是react,但有时候需要 nextjs,默认的 nextjs 模版很老,但是又暂时无法从 props 取到 meta 信息,根据 meta 信息来新建对应的文件。而且覆盖 nextjs 模版或者完全自定义模版都不行,所以暂时 nextjs 就用 page router
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 组件,就可以展示在页面上
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组件
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 // 代码占的百分比
}}
/>
)
}