1 内容载体选择
基础内容载体的选择,一般而言可以有以下几种选择:
- 1 直接使用
csdn
、掘金
等平台托管博客内容。 - 2 使用功能全面的cms平台,例如
wordpress
。 - 3 纯静态的渲染方式,例如
github pages
。 - 4 在
github pages
之前对文档加工,例如hexo
,gatsby
等。 - 5 使用服务端渲染,
nextjs
托管vercel
等。
以下对比时基础的设置下的对比:
代表 | 编写格式 | 内容存储 | 个人开销 | 自由度 | 评论系统 |
---|---|---|---|---|---|
csdn | markdown/富文本 | csdn平台 | - | - | 完善 |
wordpress | markdown/富文本 | 自己服务器 | 服务器 | 低,第三方扩展 | 完善 |
github pages | markdown | github | - | - | - |
hexo | markdown | github | - | 高,可自行修改构建程序 | - |
nextjs | mdx | github | - | 高,可引入react到md | - |
我之前用过好多种方式,18年左右选择使用,自行集成静态文件,然后托管到github pages
,也就是类似hugo
的方式,只不过是自己处理的md
转html
的过程。
可以看我19年录制的视频,为什么用这种方式的原因介绍的比较清楚。视频地址。 简单讲,就是我需要一个目录页能快速查找之前记录的笔记。
现在6年过去了,尤其在看国外一些大佬的blog
的时候,发现有很多不错的功能,比如说直接把codesandbox
这种在线代码工具嵌入进来;直接把自己写一个前端组件嵌入进来;代码高亮直接指定行号高亮等等。这些功能都对于表达清楚文章的内容非常有帮助。
而要实现这么高的自由度,就只能通过服务端渲染的方式来实现了,借助next
和mdx
,将md
与react
整合,打开新世界大门。
当然ssr也有很多其他选择,但是next
显然是最成熟的方案,所以最后我选择了nextjs
+ vercel
来部署我的博客。
2 基础配置
我的目的就是为了支持mdx
渲染,哦对,简单说一下mdx
,就是在md
中能插入jsx
,而jsx
是html
标签的超集,这样换取了超高的自由度。算是在完全自己写一个网页
和 不写网页纯写md
之间做了很好的权衡,即保留前者的自由度,又不会太复杂,导致写文档成为麻烦的事。
先配置nextjs
项目,这里不展开讲基础的创建了,我有个仓库详细展示了如何一步步搭建基于nextjs
的项目,可以参考:nextjs-blog。这个文档一共分为5步,对应了5次代码提交。
- 1 创建项目
- 2 添加mdx支持
- 3 添加样式
- 4 如何写自定义markdown插件
- 5
slug
动态路由 + 注入自定义组件
3 Remote MDX
nextjs
官方文档也有这一节remote-mdx,是因为步骤2中配置的mdx
支持后,最后一步slug
路由的时候,发现动态加载只能动态修改文件名,不能动态修改目录名。
这可能与import
这个函数比较特殊有关,他应该会在编译的时候就确定目录,不能动态修改目录的范围,估计是为了安全性?这个没仔细研究,但是问题就是如下:
let MDX = dynamic(()=> import(`@/posts/${slug}.md`)); // √ 这是OK的slug动态改变的文件名
let MDX = dynamic(()=> import(`@/${month}/${slug}.md`)); // × 这会报错,变量动态修改了目录就会报错
我当前的结构比较简单就是一个月份目录下面,每个md
文件是一篇文章。我当前的目录结构想要前移过来就要大改,比如把24.06/test.md
就需要改成24.06_test.md
,使其目录不变,仅文件名变化。
另一个思路就是写很多switch
判断分支,类似下面,虽然用脚本提前生成这段代码也不是不可以,但是总觉得很别扭。
swich(month) {
case "18.1":
MDX = dynamic(()=>import(`@/18.1/${slug}.md`))
break
case "18.2":
MDX = dynamic(()=>import(`@/18.1/${slug}.md`))
break
....
// 这里要把我的所有目录结构都列出来
}
为了避免这样的改造,就需要remote mdx
远程mdx的工具,而不是使用原生的mdx支持,所以第二步中配置的mdx相关的支持可以都删除了,直接用第三方的mdx
编译工具。
这种工具很多,上面官网介绍了一个第三方的lib: next-mdx-remote
,但因为版本兼容性问题,一开始按照官方的demo并没有成功启动,所以我选择了mdx-bundler
mdx-bundler
除了能将mdx
源码编译为jsx
代码,还支持import
引入外部文。
$ npm install --save mdx-bundler esbuild
安装依赖后,接下来要做的是利用nextjs
的slug功能,将文章的md
文件名作为slug
,然后在参数中获取slug
即文件名,根据文件名到对应的目录下读取文件的内容,然后用mdx-bundler
解析即可。
创建app/blog/[month]/[slug]/page.js
文件
import fs from 'fs';
import path from 'path';
import { bundleMDX } from 'mdx-bundler'
import { getMDXComponent } from 'mdx-bundler/client'
export default async function Post({ params }) {
// 从param中能拿到路径中的month和slug
let { month, slug } = params;
// slug对应我们的文件名,因为有中文所以需要反转
slug = querystring.unescape(slug);
// 按照md或者mdx后缀去寻找对应的文件系统中的文件,读取文本内容
var mdxPath = path.join(process.cwd(), month, `${slug}.mdx`);
var mdPath = path.join(process.cwd(), month, `${slug}.md`);
const mdxSource = fs.existsSync(mdxPath) ?
fs.readFileSync(mdxPath, 'utf8') :
fs.readFileSync(mdPath, 'utf8');
// 用mdx-bundler解析mdx,解析成jsx代码
const result = await bundleMDX({
source: mdxSource,
});
// code是解析出的jsx代码,frontmatter是解析的前言部分
const { code, frontmatter } = result
// 通过getMDXComponent转换解析的code为react组件
const Component = getMDXComponent(code)
return (
<>
<main>
<Component />
</main>
</>
)
}
上面代码就可以实现远程mdx
的功能,当我们访问http://localhost:3000/blog/24.06/我的博客是如何搭建的
这样的网址的时候,路径中的24.06
和我的博客是如何搭建的
会作为params
中的month
和slug
参数传递进来,然后找到24.06
下我的博客是如何搭建的.md
文件;将其内容读取出来后,用mdx-bundler
把md
文件内容转换为react组件了。
因为在步骤2基础配置中,我们已经引入了github-markdown.css
(如果没有引入的话,回看下之前步骤的教程来引入这个css即可),所以页面可以直接渲染成比较好看的样式。
4 插件
正如步骤2中我们引入了很多remark
和rehype
一样,对于remote形式我们也可以引入这些插件,只需要修改bundleMDX
这行代码,通过mdxOptions
来增加各种插件,同时增加了cwd
和esbuildOptions
的部分配置,是为了解决一些react组件引入时候的问题。
+ import remarkGfm from 'remark-gfm';
+ import rehypeSlug from 'rehype-slug';
+ import rehypePrismPlus from 'rehype-prism-plus';
+ import toc from "@jsdevtools/rehype-toc";
+ import remarkCodeTitles from "remark-flexible-code-titles";
+ import rehypeAutolinkHeadings from 'rehype-autolink-headings';
+ //注意@/rehypePlugins目录下是自定义的一部分组件后续会说他们的作用
+ import rehypeCodeCopyButton from '@/rehypePlugins/rehype-code-copy-button.mjs'
+ import rehypeImageSrcModifier from '@/rehypePlugins/rehype-image-src-modifier.mjs'
+ import rehypeTocExt from '@/rehypePlugins/rehype-toc-ext.mjs'
- const result = await bundleMDX({
- source: mdxSource,
- });
+ const result = await bundleMDX({
+ source: mdxSource,
+ cwd: path.join(process.cwd(), 'app', 'components'),
+ mdxOptions: (options, frontmatter) => {
+ options.remarkPlugins = [
+ ...(options.remarkPlugins ?? []),
+ ...[remarkGfm, remarkCodeTitles]
+ ]
+ options.rehypePlugins = [
+ ...(options.rehypePlugins ?? []),
+ ...[
+ rehypeSlug,
+ [rehypeAutolinkHeadings, { behavior: 'wrap' }],
+ [rehypePrismPlus, { ignoreMissing: true, showLineNumbers: true }],
+ rehypeCodeCopyButton,
+ rehypeImageSrcModifier,
+ toc,
+ rehypeTocExt,
+ ]
+ ]
+ return options;
+ },
+ esbuildOptions: options => {
+ options.outdir = path.join(process.cwd(), 'public')
+ options.write = true
+ return options
+ }
+ })
上面代码中引入的@/rehypePlugins
目录下是我自定义的几个组件,以rehype-toc-ext.mjs
为例,我们来看下如何写一个插件,插件本身就是一个函数,函数返回值是(tree)=>{}
的函数,其中tree
就是当前的mdx
的ast树,我们可以对这个ast树进行各种操作,一般会用到visit
函数,如下代码:
代码实现了把原来的内容放到新的div中,同时删除原来的nav标签,并添加一个新的nav的div标签,一起放到一个总的div中按照左右排列,实现toc放到右边的效果(原来是上面)
import { visit } from 'unist-util-visit';
export default function rehypeTocExt() {
return (tree) => {
let tocNav = null;
// 遍历ast树所有dom节点找到 nav标签,并且class=toc将其删除
// nav.toc是另一个toc插件生成的dom
visit(tree, 'element', (node, index, parent) => {
if (node.tagName === 'nav' && node.properties && node.properties.className && node.properties.className.includes('toc')) {
tocNav = node;
parent.children.splice(index, 1);
}
});
// 创建3个div,一个是container,一个是content,一个是toc
// 第1个 包含 后面2个
const { children } = tree;
const container = {
type: 'element',
tagName: 'div',
properties: { className: ['container-wrapper', 'container'] },
children: [],
};
const contentDiv = {
type: 'element',
tagName: 'div',
properties: { className: ['content-wrapper'] },
children: [],
};
// 把tocNav塞到toc中
const tocDiv = {
type: 'element',
tagName: 'div',
properties: { className: ['toc-wrapper'] },
children: [tocNav],
};
// 把原来tree下面所有节点,塞到content中
while (children.length > 0) {
contentDiv.children.push(children.shift());
}
container.children.push(contentDiv);
container.children.push(tocDiv);
children.push(container);
};
}
除了插件之外我还增加了一些小的功能,比如代码阅读时间和利用frontmatter
展示一些信息,代码如下:
import readingTime from'reading-time'
......
......
// 这个库根据字数,推算大概需要的阅读时间
const { text: readingTimeText } = readingTime(mdxSource);
......
const { code, frontmatter } = result
......
return
<>
<header>
{/* 从frontmatter中获取title展示到最上面作为标题 */}
<h1>{frontmatter.title || slug}</h1>
{/* 从frontmatter中获取description展示到第2行 */}
{frontmatter.description && <p>{frontmatter.description}</p>}
<div className='flex gap-4'>
{/* 阅读时间和tags展示 */}
<span>{readingTimeText + (frontmatter.created ? (" created at" + frontmatter.created): "")}</span>
<div>
{frontmatter.tags && frontmatter.tags
.split(',').filter(item => item.trim().length)
.map((tag, i) =>
(<span key={i}
className='bg-green-600 text-white px-2 py-1 rounded-md mr-1 text-sm'>
{tag.trim()}
</span>))}
</div>
</div>
<hr></hr>
</header>
<main>
<Component />
</main>
</>
效果是这样的:
5 配置提前生成的静态内容
还是在blog/[month]/[slug]/page.js
文件最后追加如下,这个函数会在next build
的时候被调用,返回值是一个数组,数组中的每一项就是一个路由,会提前把这些路径的静态内容生成到public
目录下,这样就可以在next export
的时候直接使用这些静态文件,而不需要再次生成,提高了生成速度。
......
export async function generateStaticParams() {
const path = require('path');
const POSTS_PATH = process.cwd();
var filenames = fs.readdirSync(POSTS_PATH);
// 搜索当前路径下所有的24.06这种格式的路径,这是我存放blog文件的地方
const monthes = filenames.filter(item => item.match(/\d\d\.\d\d?/));
// 然后在这个月份路径下面,搜索所有的md和mdx格式的文件
return monthes.flatMap((month) => {
const filenames = fs.readdirSync(path.join(POSTS_PATH, month));
const posts = filenames.filter(item => item.match(/\.mdx?/))
.map(item => {
const slug = item.replace(/\.mdx?/, '');
return { slug, month };
});
return posts;
})
}
6 添加评论区
利用github discussion
作为评论区,方式很简单,我这里直接用了https://giscus.app/zh-CN进行配置。
因为我们是react风格的,直接引入script标签有点怪,所以也可以用giscus
提供的react组件,使用也很简单,如下:
$ npm i @giscus/react
'use client'
import Giscus from '@giscus/react';
export default function () {
return <Giscus
id="comments"
repo="sunwu51/notebook"
repoId="MDEwOlJlcG9zaXRvcnkxMTkxNjk2MzE="
category="General"
categoryId="DIC_kwDOBxpiX84CfzJL"
mapping="url"
term=""
reactionsEnabled="1"
emitMetadata="0"
inputPosition="top"
theme="light"
lang="zh-CN"
loading="lazy"
/>
}
然后在page.js
引入并放到页面最后即可
import Discussion from '@/app/components/Discussion';
......
<main>
<Component />
</main>
<div className='container mx-auto py-12 px-0'>
<Discussion />
</div>
评论区的内容是存储到了github discussions
中,title就是我们页面的url(因为有中文所以有些转义)
7 添加全文搜索功能
这里借助了algolia docsearch
提供的免费功能,具体接入流程可以参考如何引入搜索功能到博客
8 添加自定义组件
在blog/[month]/[slug]/page.js
文件中,我们可以直接使用mdx
的自定义组件,例如在写文章中经常会用到的文件结构目录,如果用markdown直接写的话,样式不够美观。就可以从antd
中引入。在上面第三步中已经能看到文件目录的结构。
代码就是直接在md
文件中写,但是要想这个标签生效,需要引入antd
,并将这个组件注册进来。
<DirectoryTree
multiple
defaultExpandAll
treeData={[
{title: '文章1的题目', children: [{ title: 'page.mdx', isLeaf: true}]},
{title: '文章2的题目', children: [{ title: 'page.mdx', isLeaf: true}]},
{title: '文章3的题目', children: [{ title: 'page.mdx', isLeaf: true}]},
]}
/>
安装
$ npm i antd
注意这里不能再page.js
直接从antd
中引入,因为page.js
是服务端渲染的,如果直接引入antd组件,组件中如果使用了React.useState
等客户端渲染才有的功能,就会报错。所以先创建了一个Antd.jsx
文件:
'use client'
import { Tree } from 'antd'; // 这里还可以引入其他你需要的组件
const { DirectoryTree } = Tree;
export { DirectoryTree }
还是修改page.js
如下,这样就可以在md文件中直接使用DirectoryTree
标签了,就像上面的代码。
import { DirectoryTree } from '@/app/components/Antd';
......
<main>
<Component components={{
DirectoryTree,
// 如果其他第三方组件要注册,写在这里即可
}} />
</main>