从构思到上线的全过程,开发中遇到一些未知问题,也都通过查阅资料和源码一一解决,小记一下望对正在使用或即将使用Nextjs
开发的你们有所帮助。
那些年我开发过的博客
就挺有意思,域名,技术栈和平台的折腾史
- 2018年使用
hexo
搭建了个静态博客,部署在github pages
- 2020年重新写了博客,
vue
,nodejs
,mongodb
三件套,使用nginx
部署在云服务器上 - 2023年云服务器过期了,再一次重写了博客,
nextjs
为基础框架,部署在vercel
上
背景
因为日常开发离不开终端,正好也有重写博客的想法,打算开发一个不只是看的博客网站,所以模仿终端风格开发了Yucihent。
技术栈
nextjs
更多技术栈
选用nextjs
是因为next13
更新且稳定了App Router
和一些其他新特性。
设计
简约为主,首页为类终端风格,prompt
样式参考了starship
,也参考过ohmyzsh themes
,选用starship
因为觉得更好看。
交互
通过手动输入或点击列出的命令进行交互,目前可交互的命令有:
help
查看更多list
和ls
列出可用命令clear
清空所有输出posts
列出所有文章about
关于我
后续会新增一些命令,增加交互的趣味。
暗黑模式
基于
tailwind
的dark mode
和next-themes
首先将tailwind
的dark mode
设置为class
,目的是将暗黑模式的切换设置为手动,而不是跟随系统。
// tailwind.config.jsmodule.exports = {darkMode: 'class'}// tailwind.config.js module.exports = { darkMode: 'class' }// tailwind.config.js module.exports = { darkMode: 'class' }
新建ThemeProvider
组件,用到next-themes
提供的ThemeProvider
,需要在文件顶部使用use client
,因为createContext
只在客户端组件使用。
'use client'import { ThemeProvider as NextThemeProvider } from 'next-themes'import type { ThemeProviderProps } from 'next-themes/dist/types'export default function ThemeProvider({children,...props}: ThemeProviderProps) {return <NextThemeProvider {...props}>{children}</NextThemeProvider>}'use client' import { ThemeProvider as NextThemeProvider } from 'next-themes' import type { ThemeProviderProps } from 'next-themes/dist/types' export default function ThemeProvider({ children, ...props }: ThemeProviderProps) { return <NextThemeProvider {...props}>{children}</NextThemeProvider> }'use client' import { ThemeProvider as NextThemeProvider } from 'next-themes' import type { ThemeProviderProps } from 'next-themes/dist/types' export default function ThemeProvider({ children, ...props }: ThemeProviderProps) { return <NextThemeProvider {...props}>{children}</NextThemeProvider> }
在app/layout.tsx
中使用ThemeProvider
,设置attribute
为class
,这是必要的。
<ThemeProvider attribute="class">{children}</ThemeProvider><ThemeProvider attribute="class">{children}</ThemeProvider><ThemeProvider attribute="class">{children}</ThemeProvider>
next-themes
提供了useTheme
,解构出theme
和setTheme
用于手动设置主题。
综上基本实现暗黑模式切换,但你会在控制台看到此报错信息:Warning: Extra attributes from the server: class,style
,虽然它并不影响功能,但终究是个报错。
作为第三方包,可能存在水合不匹配的问题,经查阅资料,禁用ThemeProvider
组件预渲染消除报错。
资料:
const NoSSRThemeProvider =dynamic(() => import('@/components/ThemeProvider'), {ssr: false})<NoSSRThemeProvider attribute="class">{children}</NoSSRThemeProvider>const NoSSRThemeProvider = dynamic(() => import('@/components/ThemeProvider'), { ssr: false }) <NoSSRThemeProvider attribute="class">{children}</NoSSRThemeProvider>const NoSSRThemeProvider = dynamic(() => import('@/components/ThemeProvider'), { ssr: false }) <NoSSRThemeProvider attribute="class">{children}</NoSSRThemeProvider>
类终端
由输入和输出组件组成,输入的结果添加到输出list中
命令输入的打字效果
定义打字间隔100ms,对键入的命令for处理,定时器中根据遍历的索引延迟赋值。
const autoTyping = (cmd: string) => {const interval = 100 // msfor (let i = 0; i < cmd.length; i++) {setTimeout(() => {setCmd((prev) => prev + cmd.charAt(i))},interval * (i + 1))}}const autoTyping = (cmd: string) => { const interval = 100 // ms for (let i = 0; i < cmd.length; i++) { setTimeout( () => { setCmd((prev) => prev + cmd.charAt(i)) }, interval * (i + 1) ) } }const autoTyping = (cmd: string) => { const interval = 100 // ms for (let i = 0; i < cmd.length; i++) { setTimeout( () => { setCmd((prev) => prev + cmd.charAt(i)) }, interval * (i + 1) ) } }
滚动到底部
定义外层容器ref
为containerRef
,键入命令后都自动滚动到页面底部,使用了scrollIntoView
api,作用是让调用这个api的容器始终在页面可见,block
参数设置为end
表示垂直方向末端对其即最底端。
const containerRef = useRef<HTMLDivElement>(null)useEffect(() => {containerRef.current?.scrollIntoView({behavior: 'smooth',block: 'end'})}, [typedCmds])const containerRef = useRef<HTMLDivElement>(null) useEffect(() => { containerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) }, [typedCmds])const containerRef = useRef<HTMLDivElement>(null) useEffect(() => { containerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) }, [typedCmds])
MDX
何为
mdx
?即给md
添加了jsx
支持,功能更强大的md,在nextjs中通过@next/mdx
解析.mdx
文件,它会将md
和react components
转成html
安装相关包,后两者作为@next/mdx
的peerDependencies
@next/mdx
@mdx-js/loader
@mdx-js/react
在next.config.js
新增createMDX
配置
// next.config.jsimport createMDX from '@next/mdx'const nextConfig = {}const withMDX = createMDX()export default withMDX(nextConfig)// next.config.js import createMDX from '@next/mdx' const nextConfig = {} const withMDX = createMDX() export default withMDX(nextConfig)// next.config.js import createMDX from '@next/mdx' const nextConfig = {} const withMDX = createMDX() export default withMDX(nextConfig)
接着在应用根目录下新建mdx-components.tsx
// mdx-components.tsximport type { MDXComponents } from 'mdx/types'export function useMDXComponents(components: MDXComponents): MDXComponents {return {...components}}// mdx-components.tsx import type { MDXComponents } from 'mdx/types' export function useMDXComponents(components: MDXComponents): MDXComponents { return { ...components } }// mdx-components.tsx import type { MDXComponents } from 'mdx/types' export function useMDXComponents(components: MDXComponents): MDXComponents { return { ...components } }
在app
目录下使用.mdx
文件,useMDXComponents
组件是必要的,
需要注意的是此文件命名上有一定规范只能命名为mdx-components
,不能为其他名称,也不可为MdxComponents
,从@next/mdx
源码中可以看出会去应用根目录查找mdx-components
。
// @next/mdx部分源码config.resolve.alias['next-mdx-import-source-file'] = ['private-next-root-dir/src/mdx-components','private-next-root-dir/mdx-components','@mdx-js/react']// @next/mdx部分源码 config.resolve.alias['next-mdx-import-source-file'] = [ 'private-next-root-dir/src/mdx-components', 'private-next-root-dir/mdx-components', '@mdx-js/react' ]// @next/mdx部分源码 config.resolve.alias['next-mdx-import-source-file'] = [ 'private-next-root-dir/src/mdx-components', 'private-next-root-dir/mdx-components', '@mdx-js/react' ]
至此就可以在app中使用mdx
。
排版
为mdx解析成的html添加样式
解析mdx为html,但并没有样式,所以我们借助@tailwindcss/typography
来为其添加样式,在tailwind.config.js
使用该插件。
// tailwind.config.jsmodule.exports = {plugins: [require('@tailwindcss/typography')]}// tailwind.config.js module.exports = { plugins: [require('@tailwindcss/typography')] }// tailwind.config.js module.exports = { plugins: [require('@tailwindcss/typography')] }
在外层标签上添加prose
的className,prose-invert
用于暗黑模式。
<article className="prose dark:prose-invert">{mdx}</article><article className="prose dark:prose-invert">{mdx}</article><article className="prose dark:prose-invert">{mdx}</article>
综上我们实现了对mdx的样式支持,然而有一点是@tailwindcss/typography
并不会对mdx代码块中代码进行高亮。
代码高亮
写文章或多或少都有代码,高亮是必不可少,那么
react-syntax-highlighter
该上场了
定义一个CodeHighligher
组件
// CodeHighligher.tsximport { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'import {oneDark,oneLight} from 'react-syntax-highlighter/dist/cjs/styles/prism'import { useTheme } from 'next-themes'export default function CodeHighligher({lang,code}: {lang: stringcode: string}) {const { theme } = useTheme()return (<SyntaxHighlighterlanguage={lang?.replace(/\language-/, '') || 'javascript'}style={theme === 'light' ? oneLight : oneDark}customStyle={{padding: 20,fontSize: 15,fontFamily: 'var(--font-family)'}}>{code}</SyntaxHighlighter>)}// CodeHighligher.tsx import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import { oneDark, oneLight } from 'react-syntax-highlighter/dist/cjs/styles/prism' import { useTheme } from 'next-themes' export default function CodeHighligher({ lang, code }: { lang: string code: string }) { const { theme } = useTheme() return ( <SyntaxHighlighter language={lang?.replace(/\language-/, '') || 'javascript'} style={theme === 'light' ? oneLight : oneDark} customStyle={{ padding: 20, fontSize: 15, fontFamily: 'var(--font-family)' }} > {code} </SyntaxHighlighter> ) }// CodeHighligher.tsx import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import { oneDark, oneLight } from 'react-syntax-highlighter/dist/cjs/styles/prism' import { useTheme } from 'next-themes' export default function CodeHighligher({ lang, code }: { lang: string code: string }) { const { theme } = useTheme() return ( <SyntaxHighlighter language={lang?.replace(/\language-/, '') || 'javascript'} style={theme === 'light' ? oneLight : oneDark} customStyle={{ padding: 20, fontSize: 15, fontFamily: 'var(--font-family)' }} > {code} </SyntaxHighlighter> ) }
react-syntax-highlighter
高亮代码可用hljs
和prism
,我在这使用的prism
,两者都有众多代码高亮主题可供选择,lang如果没标注则默认设置为javascript
也可以简写为js
,值得注意的是如果是使用hljs
,则必须写javascript
,不可简写为js
,否则代码高亮失败,这一点prism
更加友好。
同时可通过useTheme
实现亮色,暗色模式下使用不同代码高亮主题。
组件写好了,该如何使用?上面讲到过mdx的解析,在useMDXComponents
重新渲染pre
标签。
// mdx-components.tsximport type { MDXComponents } from 'mdx/types'import CodeHighligher from '@/components/CodeHighligher'export function useMDXComponents(components: MDXComponents): MDXComponents {return {pre: ({ children }) => {const { className, children: code } = propsreturn <CodeHighligher lang={className} code={code} />}}}// mdx-components.tsx import type { MDXComponents } from 'mdx/types' import CodeHighligher from '@/components/CodeHighligher' export function useMDXComponents(components: MDXComponents): MDXComponents { return { pre: ({ children }) => { const { className, children: code } = props return <CodeHighligher lang={className} code={code} /> } } }// mdx-components.tsx import type { MDXComponents } from 'mdx/types' import CodeHighligher from '@/components/CodeHighligher' export function useMDXComponents(components: MDXComponents): MDXComponents { return { pre: ({ children }) => { const { className, children: code } = props return <CodeHighligher lang={className} code={code} /> } } }
mdx文件中代码块会被解析成pre
标签,可以对pre
标签返回值作进一步处理,即返回高亮组件,这样可实现对代码高亮,当然高亮主题很多,选自己喜欢的。
文章
元数据
文章一些信息如标题,描述,日期,作者等都作为文章的元数据,使用
yaml
语法定义
---title: '文章标题'description: '文章描述'date: '2020-01-01'------ title: '文章标题' description: '文章描述' date: '2020-01-01' ------ title: '文章标题' description: '文章描述' date: '2020-01-01' ---
@next/mdx默认不会按照yaml
语法解析,这会被解析成h2
标签,然而我们并不希望元数据被解析成h2标签作为内容展示,更希望拿这类数据做其他处理,
为了正确解析yaml
,需要借助remark-frontmatter
来实现。
使用该插件,注意需要修改next配置文件名为next.config.mjs
,因为remark-frontmatter
只支持ESM
规范。
// next.config.mjsimport createMDX from '@next/mdx'import frontmatter from 'remark-frontmatter'const nextConfig = {}const withMDX = createMDX({options: {remarkPlugins: [frontmatter]}})export default withMDX(nextConfig)// next.config.mjs import createMDX from '@next/mdx' import frontmatter from 'remark-frontmatter' const nextConfig = {} const withMDX = createMDX({ options: { remarkPlugins: [frontmatter] } }) export default withMDX(nextConfig)// next.config.mjs import createMDX from '@next/mdx' import frontmatter from 'remark-frontmatter' const nextConfig = {} const withMDX = createMDX({ options: { remarkPlugins: [frontmatter] } }) export default withMDX(nextConfig)
yaml被正确解析了那么我们可以使用gray-matter
来获取文章元数据
列表
由于app目录是运行在nodejs runtime
下,基本思路是用nodejs的fs
模块去读取文章目录即mdxs/posts
,读取该目录下的所有文章放在一个list中。
使用fs.readdirSync
读取文章目录内容,但是这仅仅是拿到文章名称的集合。
const POST_PATH = path.join(process.cwd(), 'mdxs/posts')// 文章名称集合export function getPostList() {return fs.readdirSync(POST_PATH).map((name) => name.replace(/\.mdx/, ''))}const POST_PATH = path.join(process.cwd(), 'mdxs/posts') // 文章名称集合 export function getPostList() { return fs.readdirSync(POST_PATH).map((name) => name.replace(/\.mdx/, '')) }const POST_PATH = path.join(process.cwd(), 'mdxs/posts') // 文章名称集合 export function getPostList() { return fs.readdirSync(POST_PATH).map((name) => name.replace(/\.mdx/, '')) }
文章列表中展示的是标题而不是名称,标题作为文章的元数据,通过gray-matter
的read
api读取文件可获取(也可以使用fs.readFileSync
) read返回data
和content
的对象,
data
是元数据信息,content
则是文章内容。
export function getPostMetaList() {const posts = getPostList()return posts.map((post) => {const {data: { title, description, date }} = matter.read(path.join(POST_PATH, `${post}.mdx`))// 使用fs.readFileSync// const post = fs.readFileSync(path.join(POST_PATH, `${post}.mdx`), 'utf-8')// const {// data: { title, description, date }// } = matter(post)return {slug: post,title,description,date}})}export function getPostMetaList() { const posts = getPostList() return posts.map((post) => { const { data: { title, description, date } } = matter.read(path.join(POST_PATH, `${post}.mdx`)) // 使用fs.readFileSync // const post = fs.readFileSync(path.join(POST_PATH, `${post}.mdx`), 'utf-8') // const { // data: { title, description, date } // } = matter(post) return { slug: post, title, description, date } }) }export function getPostMetaList() { const posts = getPostList() return posts.map((post) => { const { data: { title, description, date } } = matter.read(path.join(POST_PATH, `${post}.mdx`)) // 使用fs.readFileSync // const post = fs.readFileSync(path.join(POST_PATH, `${post}.mdx`), 'utf-8') // const { // data: { title, description, date } // } = matter(post) return { slug: post, title, description, date } }) }
上述方法中我们拿到了所有文章标题,描述信息,日期的list,根据list渲染文章列表。
详情
文章列表中使用Link
跳转到详情,通过dynamic
动态加载文章对应的mdx
文件
export default function LoadMDX(props: Omit<PostMetaType, 'description'>) {const { slug, title, date } = propsconst DynamicMDX = dynamic(() => import(`@/mdxs/posts/${slug}.mdx`), {loading: () => <p>loading...</p>})return (<><div className="mb-12"><h1 className="mb-5 font-[600]">{title}</h1><time className="my-0">{date}</time></div><DynamicMDX /></>)}export default function LoadMDX(props: Omit<PostMetaType, 'description'>) { const { slug, title, date } = props const DynamicMDX = dynamic(() => import(`@/mdxs/posts/${slug}.mdx`), { loading: () => <p>loading...</p> }) return ( <> <div className="mb-12"> <h1 className="mb-5 font-[600]">{title}</h1> <time className="my-0">{date}</time> </div> <DynamicMDX /> </> ) }export default function LoadMDX(props: Omit<PostMetaType, 'description'>) { const { slug, title, date } = props const DynamicMDX = dynamic(() => import(`@/mdxs/posts/${slug}.mdx`), { loading: () => <p>loading...</p> }) return ( <> <div className="mb-12"> <h1 className="mb-5 font-[600]">{title}</h1> <time className="my-0">{date}</time> </div> <DynamicMDX /> </> ) }
generateStaticParams
优化文章列表跳转详情的速度
在文章详情组件导出generateStaticParams
方法,这个方法在构建时静态生成路由,而不是在请求时按需生成路由,一定程度上提高了访问详情页速度
export async function generateStaticParams() {const posts = await fetch('https://.../posts').then((res) => res.json())return posts.map((post) => ({slug: post.slug}))}export async function generateStaticParams() { const posts = await fetch('https://.../posts').then((res) => res.json()) return posts.map((post) => ({ slug: post.slug })) }export async function generateStaticParams() { const posts = await fetch('https://.../posts').then((res) => res.json()) return posts.map((post) => ({ slug: post.slug })) }
部署
项目是部署在vercel,使用github登录后我们新建一个项目,点进去后会看到Import Git Repository
,导入对应仓库即可,也可使用vercel提供的模版新建一个,后续我们每次提交代码都会自动化部署。
有自己域名的可以在Domains中添加,然后去到你买域名的地方添加对应DNS解析即可。
总结
开发中遇到了一些坑:
next-themes
报错Warning: Extra attributes from the server: class,style
,通过issues和看文档,最终找到了方案mdx-components
组件的命名,经多次测试发现只能命名为mdx-components
,阅读@next/mdx的源码也验证了- 语法高亮,开始使用的
hljs
,mdx中的代码块写的js
,部署到线上后发现代码并没有高亮,然后改用了prism
正常高亮,
又是阅读了react-syntax-highlighter
源码发现hljs的语言集合中并没有js
,所以无法正确解析,只能写成javascript
,而prism
两者写法都支持 - 首页的
posts
命令是运行在客户端组件中,fs无法使用,因此获取文章的方案使用fetch请求api - 使用
remark-frontmatter
解析yaml无法和mdxRs: true
同时使用,否则解析失败。添加此配置项表示使用基于rust
的解析器来解析mdx
,可能是还未支持的缘故
module.exports = withMDX({experimental: {mdxRs: true}})module.exports = withMDX({ experimental: { mdxRs: true } })module.exports = withMDX({ experimental: { mdxRs: true } })
后续更新:
- 会新增
Weekly
周刊模块,关注前端技术的更新 - 文章详情页添加上一篇和下一篇,更方便的阅读文章