博客
Nextra Use and Code Analysis

探索Next.js静态站点框架Nextra的功能和实现原理

首发于掘金:探索Next.js静态站点框架Nextra的功能和实现原理 (opens in a new tab)

前言

NextraNext.js 开发成员之一 Shu Ding (opens in a new tab) 实现的一个静态网站生成库,让开发者可以使用 markdown 来进行编写页面。 markdown 现在流行的有两种写法,一种以 .md 结尾的模式写法,还有种以 .mdx 结尾的扩展写法,也就是可以直接在 markdown 文件中引入组件和编写 jsx 代码。

其实 Next.js 本身已经提供了 @next/mdx 插件来让 Next.js 工程可以支持使用上面描述的 markdown 的两种写法来进行编写页面内容,但不能直接依靠写 mdx 就可以生成一个完整的静态网站,于是就出现了 Nextra。在我之前写的一篇文章 《10分钟搭建一个 github page 个人网站》 (opens in a new tab) 中就使用了 Nextra 来搭建个人网站,个人觉得还挺好用的。

Nextra 功能梳理

其实 Nextra 本身只是 Next.js 的一个扩展插件而已, Next.js 本身的功能包括其社区的插件,只要不互相冲突,那么都是可以和 Nextra 一起使用的。

因此 Nextra 拥有 Next.js 本身的所有功能,不太清楚 Next.js 功能的可以去 《Next.js了解篇|一文带你梳理清楚Next.js的功能》 (opens in a new tab) 更详细的了解一下,这里针对 Nextra 扩展后的功能再梳理了一下。

Next.js 原有功能列表:

  1. 完善的工程化机制
  2. 良好的开发和构建性能
  3. 智能文件路由系统
  4. 多种渲染模式来保证页面性能体验
  5. 可扩展配置
  6. 提供其他多方面性能优化方案
  7. 提供性能数据,让开发者更好的分析性能。
  8. 提供的其他常用功能或者扩展。

使用 Nextra 后, 除了第 3 点和第 4 点有所区别以外,没有其他区别。

针对第 3 点功能,在 智能文件路由系统 功能的基础上,Nextra 进行了扩展,可智能识别 markdown 文件类型并进行加入到定义的主题菜单列表中。

针对第 4 点功能,Nextra 虽然 是为了搭建静态站点,你不需要写 JavaScript 代码就可以编写页面和导航,但如果中间某个页面不需要导航,完全想自定义,你也可以使用 JavaScript 来编写页面 ,也可以使用各种渲染模式,当然如果使用了 getServerSidePropsgetStaticPaths,说明需要动态的 SSR(服务端渲染) 和 ISR(静态增量再生),那么必须使用 Node 服务启动项目,只是影响你项目最终的部署方式。

现在再来整理一下 Nextra 新增的功能:

前三种的功能都比较明确,下面来看一下主题模板的使用和说明

Nextra 主题模板的使用和说明

先下载依赖:

pnpm i next react react-dom nextra nextra-theme-docs

接入到 Next.js 工程:

const withNextra = require('nextra')({
    // 主题名称
    theme: 'nextra-theme-docs',
    // 主题配置,指定主题配置文件
    themeConfig: './theme.config.jsx'
})
 
const nextConfig = {
    // next 本身配置
}
 
// withNextra 的作用其实就是添加 next 配置。
module.exports = withNextra(nextConfig);

themeConfig 是用于配置主题的全局内容或样式,用于指定主题配置文件,文件类型可以是 js|jsx|ts|tsx,支持热更新,不同的主题有不同的配置。

文档主题 nextra-theme-docs

提供了默认的文档模板,并且都是可以在 themeConfig 中进行配置,包含了:

  • 文档工程全局配置:文档工程地址、SEO、HEAD标签、主题黑暗模式控制和扩展 主题模式 (opens in a new tab),主题主色调颜色控制
  • 顶部导航栏:Logo、工程链接、聊天地址、搜索功能、Banner广告
  • 侧边导航栏:只能设置侧边导航整体功能,比如默认折叠层级、标题组件、侧边栏切换按钮
  • 内容:自定义MDX组件、网站书写方向、主要内容的扩展部分(比如用于扩展评论组件)
  • 页面内容的目录导航栏配置
  • 页尾配置:控制当前浏览器页面的前后页面的导航、页面创建或修改时间
  • 页脚配置:文档主题的页面底部内容。
  • 主题切换配置:主题切换选项控制
  • 错误页面配置:404、500错误页面自定义。

这里更多的一个梳理和翻译,具体配置可去参考 官方文档 (opens in a new tab)

顶部导航栏内容(不在这里配置)、

其中侧边导航的默认样式可通过 themeConfig 进行配置,扩展了导航的具体列表的 配置方式 (opens in a new tab)

可以读取 mdx 或者 md 文件名称作为菜单名称,如果文件名不是你想要显示的名称,或者导航列表的默认顺序不是你想要的排序,那么也可以在页面目录下使用 pages/**/*._meta.json 来配置导航栏上每个页面显示的名称和调整导航列表顺序,当然也可以使用 Front Matter 中的 title 修改页面的导航名称,使用 date 调整页面顺序(倒序)。

文档主题已经很稳定了,可以放心使用。

博客主题 nextra-theme-blog

还在建设中,不是很完善,但也可以使用 Front Matter ,简单使用可以去参考一下官方提供的一个 例子 (opens in a new tab) ,比较久没有升级了,如果你有兴趣也可以参考一下自己去写一个博客主题也不错。

Nextra 实现原理探索

Nextra 官方仓库包含了 3 个包:

./packages
├── nextra 核心库
├── nextra-theme-blog 博客主题
└── nextra-theme-docs 文档主题

其中主题库主要是提供了一套 UI 组件,以及提供对样式模板进行配置的方式也就是 themeConfig 和给开发者使用的一些组件或 hook。

具体来看一下 nextra-theme-docs 的入口文件的 export 内容:

// packages/nextra-theme-docs/src/index.tsx
 
// 提供给 Nextra 核心库使用的布局组件
export default function Layout({
  children,
  ...context
}: NextraThemeLayoutProps): ReactElement {
  return (
    <ConfigProvider value={context}>
      <InnerLayout {...context.pageOpts}>{children}</InnerLayout>
    </ConfigProvider>
  )
}
 
// 提供给开发者使用的hook,用于获取主题的配置信息和页面的FrontMatter信息
export { useConfig, PartialDocsThemeConfig as DocsThemeConfig }
// 提供给开发者使用的hook,就是 @mdx-js/react 中的组件,Nextra 核心库会使用,但并不依赖主题库中的导出。
export { useMDXComponents } from 'nextra/mdx'
// 提供给开发者使用的hook,内部主题色系就是使用的 next-themes
export { useTheme } from 'next-themes'
// 提供给开发者使用的组件,部分组件在布局组件中都有用到,不是提供给 Nextra 核心库
export {
  Bleed,
  Callout,
  Collapse,
  NotFoundPage,
  ServerSideErrorPage,
  Steps,
  Tabs,
  Tab,
  Cards,
  Card,
  FileTree,
  Navbar,
  SkipNavContent,
  SkipNavLink,
  ThemeSwitch
} from './components'

可以看到提供给 Nextra 核心库的只有一个默认导出的 Layout 组件,在 Nextra 的核心实现中将会用到主题库导出的 Layout 组件。

下面我们来看一下 Nextra 的入口文件(只留下核心代码):

// packages/nextra/src/index.js
// 默认页面文件后缀
const DEFAULT_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx']
 
const nextra = (themeOrNextraConfig, themeConfig) =>
  function withNextra(nextConfig = {}) {
    // 主题文件可通过两个参数传入,或者一个参数传入
    const nextraConfig = {
      ...DEFAULT_CONFIG,
      ...(typeof themeOrNextraConfig === 'string'
        ? { theme: themeOrNextraConfig, themeConfig }
        : themeOrNextraConfig)
    }
    
    // 下面两行是处理多语言的逻辑
    const locales = nextConfig.i18n?.locales || DEFAULT_LOCALES
    const nextraPlugin = new NextraPlugin({ ...nextraConfig, locales })
    
    // 添加 rewrites 来护理 _meta 文件的解析
    const rewrites = async () => {
      const rules = [
        {
          source: '/:path*/_meta',
          destination: '/404'
        }
      ]
      // 兼容外部的 rewrites
      if (nextConfig.rewrites) {
        const originalRewrites = await nextConfig.rewrites()
        if (Array.isArray(originalRewrites)) {
          return [...originalRewrites, ...rules]
        }
        return {
          ...originalRewrites,
          beforeFiles: [...(originalRewrites.beforeFiles || []), ...rules]
        }
      }
 
      return rules
    }
    
    // 这里即将验证前面说的 nextra 本身就是 Next.js 的一个插件,用于处理 markdown 文件,加载新的文件格式就需要注入新的 loader,这里就是 loader 的配置参数
    const nextraLoaderOptions = {
      ...nextraConfig,
      // ...其他配置省略,不影响主流程
    }
    
    // 返回处理后的 next config 配置
    return {
      ...nextConfig,
      rewrites,
      // 扩展页面文件后缀
      pageExtensions: [
        ...(nextConfig.pageExtensions || DEFAULT_EXTENSIONS),
        ...MARKDOWN_EXTENSIONS // ['md', 'mdx']
      ],
      webpack(config, options) {
        // 此处处理 plugin,现在有多语言plugin和搜索plugin
        if (options.nextRuntime !== 'edge' && options.isServer) {
          config.plugins ||= []
          config.plugins.push(nextraPlugin)
          // 搜索功能实现就于此处有关
          if (nextraConfig.flexsearch) {
            const nextraSearchPlugin = new NextraSearchPlugin({})
            config.plugins.push(nextraSearchPlugin)
          }
        }
        
        config.module.rules.push(
          {
            // 匹配非页面的 Markdown,也就是可以把 Markdown 当成组件使用,但我试了一下会报错
            test: MARKDOWN_EXTENSION_REGEX, // /\.mdx?$/
            issuer: request => !!request || request === null,
            use: [
              options.defaultLoaders.babel,
              {
                loader: 'nextra/loader',
                options: nextraLoaderOptions
              }
            ]
          },
          {
            // 匹配 Markdown 编写的页面
            test: MARKDOWN_EXTENSION_REGEX, // /\.mdx?$/
            issuer: request => request === '',
            use: [
              options.defaultLoaders.babel,
              {
                loader: 'nextra/loader',
                options: {
                  ...nextraLoaderOptions,
                  isPageImport: true // 有这个标志的才会显示 Layout
                }
              }
            ]
          },
          {
            // 匹配 _meta 文件,也就是不仅支持 json ,还支持使用js的方式进行使用,因为 json 不需要 loader,因此这里不匹配 json
            test: /_meta(\.[a-z]{2}-[A-Z]{2})?\.js$/,
            issuer: request => !request,
            use: [
              options.defaultLoaders.babel,
              {
                loader: 'nextra/loader',
                options: {
                  isMetaImport: true
                }
              }
            ]
          }
        )
 
        return nextConfig.webpack?.(config, options) || config
      }
    }
  }
 
module.exports = nextra

从代码中可以看出,入口文件其实也就做了几件事:

  • 添加 webpack plugin 处理多语言和搜索功能
  • 扩展页面文件后缀 mdx/md
  • 添加 webpack loader 解析 mdx/md

其中最核心就是 loader 了,下面我们到 loader 中去看看到底做了什么(代码太多,这里主要使用注释列出代码逻辑的顺序)。

// packages/nextra/src/loader.ts
 
// 全局部分代码有一些编译前的文件检查
 
// 核心
async function loader(
  context: LoaderContext<LoaderOptions>,
  source: string
): Promise<string> {
  const {
    isMetaImport = false,
    isPageImport = false,
    theme,
    themeConfig,
    locales,
    flexsearch,
    // ...
  } = context.getOptions()
 
  // 不解析 _meta.js,_meta.js 后面动态解析
  if (isMetaImport) {
    return 'export default () => null'
  }
 
  // ...省略掉一些文件过滤和文件依赖处理事项
  
  // 这里是最核心的代码编译部分,匹配到的文件都将使用 compileMdx 去进行编译处理,并返回组装页面需要的内容
  const {
    result,
    headings,
    title,
    frontMatter,
    structurizedData,
    searchIndexKey,
    hasJsxInH1,
    readingTime
  } = await compileMdx(
    source,
    {
      mdxOptions: {
        ...mdxOptions,
        jsx: true,
        outputFormat: 'program',
        format: 'detect'
      },
      readingTime: _readingTime,
      defaultShowCopyCode,
      staticImage,
      flexsearch,
      latex,
      codeHighlight,
      route: pageNextRoute,
      locale
    },
    {
      filePath: mdxPath,
      useCachedCompiler: false, // TODO: produce hydration errors or error - Create a new processor first, by calling it: use `processor()` instead of `processor`.
      isPageImport
    }
  )
 
  // 不是页面级组件,则直接返回, 不需要添加到 layout。
  if (!isPageImport) {
    return result
  }
  
  // 后面的的部分就是使用编译得到的内容,进行拼接成一个编译后的页面级别的 js 代码
  // 其中涉及到了多语言/frontMatter/layout/路由/页面标题等等
}

代码太多了,就不全部贴出来讲解,loader 做的事情,整体处理流程大致如下:

  1. 编译前置事项:文件过滤和文件依赖处理事项
    1. 过滤掉 _meta.js
    2. 过滤掉 /pages/api/ 下的文件
    3. 页面文件收集
    4. 页面文件路由处理
    5. 本地主题依赖处理
    6. 把前面收集的文件和主题配置文件添加到 webpack context 依赖中
  2. 使用 compileMdx 编译 markdown 代码:
    1. 使用 grayMatter 解析 Front Matter
    2. 使用 github-slugger 支持结构化内容搜索
    3. 使用 @mdx-js/mdx 解析 .mdx 或者 .md 文件
  3. 编译后事项:组装页面内容
    1. 不是页面级组件,则直接返回, 不需要显示 Layout
    2. 页面级组件需要处理搜索、路由、文件修改时间戳、布局组件组装等等事项

原理部分就梳理到这里了,我们可以看出 Nextra 并没有使用 @next/mdx ,而是自己写了一个 loader 来支持 markdown,因为其中涉及到了其他功能的支持,而 @next/mdx 是不支持 Front Matter 和搜索功能的,@next/mdx 源码很简单,感兴趣的可以去看看。

总结

之前使用过 hexo 以及 vuepress 来搭建过博客或者个人网站,它们都是搭建静态站点的一个不错选择,也很多人选择,不过个人还是更喜欢 Nextra,可以完全使用 Next.js 的所有功能。

这类型的静态站点实现的功能都很类似,主要的功能就是:

  • 基础:完整的工程化能力
  • 功能:支持 markdown 编写页面,甚至扩展成 mdx ,让在页面可以动态插入代码运行,更容易演示代码
  • 功能:扩展 Front Matter
  • 功能:文档内容搜索功能
  • 主题:至少实现默认的一套UI样式模板

基础实现起来是最难的,Nextra 要不是 Next.js 提供了大部分的功能,实现起来也不容易,功能基本上也都是封装好的现成品,可以直接下载对应的包进行扩展,主题就需要自己开发一整套组件了。

其实本篇文章过程中,不去看源码,很多功能就不清楚是在 Nextra 中实现的还是在主题中实现的,梳理清楚主要源码结果后,可以清晰的知道其中的职责划分,对定位问题也更好的定位,也能学习到很多知识。