使用next.js重构博客
发布日期:2022-05-17文章字数:3.4K
我的博客也建立快三年了,之前用的是hexo-theme-matery主题,最近感觉这个主题有点腻了。最近封闭在家,就准备进行重构一下,这一次就不准备用hexo了,虽然hexo有很多好看的主题,但是每个主题我都有觉得不满意的地方,想要修改一下,想要修改的话各种主题使用的都是ejs这种蛋疼的模板语法,改动起来也比较麻烦。
{% import 'menu-item.njk' as menu_item with context %}
{%- if theme.menu or theme.algolia_search.enable or theme.local_search.enable %}
<nav class="site-nav">
<ul class="main-menu menu">
{%- for node in theme.main_menu %}
{{- menu_item.render(node) | trim }}
{%- endfor %}
{%- if theme.algolia_search.enable or theme.local_search.enable %}
<li class="menu-item menu-item-search">
<a role="button" class="popup-trigger">
{%- if theme.menu_settings.icons %}<i class="fa fa-search fa-fw"></i>{% endif %}{{ __('menu.search') }}
</a>
</li>
{%- endif %}
</ul>
</nav>
{%- endif %}
why next.js
因为之前收在收藏夹里也存了一些优秀的博客,就挨个点击去寻找一些灵感,碰巧看到一个老哥最近也对博客进行了重构:使用 Next.js + Hexo 重构我的博客。 看完我惊呆了,还有这种操作。不过这位老哥并没有开源,只放出了一些零散的代码。
看完我觉得这种方案灰常不错,可定制性非常强(从零开始),还能实践一下新技术,因为之前从来没有用过react的技术栈,这次就准备也采用react+ts来开发。
去next.js官网看了一下文档,然后我选用了blog-starter-typescript这个项目作为脚手架。
开发过程
因为那个老哥并没有开源,我也不知道他的项目结构是怎样的,我试了几次才把他的代码成功运行:项目根目录新建一个hexo.ts文件,把他的代码复制进去,导出initHexo方法,然后在lib/api.ts
这个文件里引入initHexo方法,然后获取文章之类的方法就需要自己写了,比如下面的获取所有文章方法。
hexo.ts
import Hexo from 'hexo';
import fs from 'fs'
import {join} from 'path'
let __SECRET_HEXO_INSTANCE__: Hexo | null = null;
const initHexo = async () => {
if (__SECRET_HEXO_INSTANCE__) {
return __SECRET_HEXO_INSTANCE__;
}
const hexo = new Hexo(process.cwd(), {
silent: true,
// 在 next dev 时包含草稿
draft: process.env.NODE_ENV !== 'production'
});
const dbPath = join(hexo.base_dir, 'db.json');
if (process.env.NODE_ENV !== 'production') {
if (fs.existsSync(dbPath)) {
await fs.promises.unlink(dbPath);
}
}
await hexo.init();
await hexo.load();
if (hexo.env.init && hexo._dbLoaded) {
if (!fs.existsSync(dbPath)) {
if (process.env.NODE_ENV === 'production') {
await hexo.database.save();
}
}
}
__SECRET_HEXO_INSTANCE__ = hexo;
return hexo;
};
export default initHexo
lib/api.ts
import initHexo from "../hexo";
// 获取所有文章列表
export async function getAllPosts() {
const hexo = await initHexo();
let rawPostList = hexo.database.model('Post').find({}).sort('-date')
return rawPostList.map(post => {
return {
title: post.title,
date: post.date.format('MM-DD'),
year: post.date.format("YYYY"),
articlePath: post.articlePath,
tags: post.tags.find({}).map(item => item.name),
categories: post.categories.find({}).map(item => item.name),
}
})
}
因为之前没有经验,博客的文章链接是用的这种格式::year/:month/:day/:title/
,而且这个title竟然还是文件名,就会导致url里含有中文,实际就变成了这种蛋疼的格式:xxx/2020/02/27/%E5%89%8D%E7%AB%AF/%E4%BD%BF%E7%94%A/
。这一次我就调整了一下,在每个md文件里自己定义一个articlePath
字段,作为文章的永久链接,我是使用了日期加英文关键字定义,例本文链接:2022-05-10-use-nextjs-rebuild-blog
,但是文件名还是中文的:2022-05-重构博客.md
,这样以后需要修改的时候也好查找。
之前的主题首页会在首页为每个文章生成一个头图,可以自己定义或是在一堆图片里随机选择,影响加载速度不说还浪费CDN流量。我这次就准备做一个极简风格的主题,连分类标签页这些都不要了,把他们合并到了归档页面,点击对应标签和分类就可以筛选对应文章,因为之前hexo会为每个标签和分类都生成一个目录和html文件,标签比文章的数量还要多。
完成初版进行生成之后,我发现nextjs生成的最终目录会多一些json文件和js文件,而且在html里还会引入他们,文件还不小,总共有几百KB,研究一番之后发现是react的依赖文件及props文件,就是每个文章的数据源。而且我发现生成的html会多出一段script,也存着页面的props数据。因为是静态博客,生成了html就不需要react的这些依赖文件了,props也是多余的,因为生成的html已经包含了这些数据,比如文章的标题、内容等。
google了一下之后,果然也有人提出过相同的问题:Disable client-side React with Next JS,解决方案也是有的,在页面文件里上加上这一段配置即可:
import {PageConfig} from 'next';
export const config: PageConfig = {
unstable_runtimeJS: false
};
这样生成的就是一个纯粹的html,只引入了css文件,没有引入任何js,这样生成的一个html文件大小视字数而定,大小在十几KB到几十KB不等,CSS文件约30KB+。
动态嵌入script实现交互
当然只有干巴巴的html还是不行的,还有一些页面需要使用js实现功能。这里我写了几个scriptComponent,返回的是一段script文本,需要使用react的dangerouslySetInnerHTML属性来插入到html,在这样的文本中嵌入props是这样实现的,我把所有文章列表赋值给window,供下面的脚本使用。
export function ArchivesPropsScript(props) {
return (
<script id="ArchivesPropsScript" dangerouslySetInnerHTML={{
__html: `
window.totalArticleList = '${JSON.stringify(props.pageProps.articleList)}';
`
}}/>
)
}
归档页面的交互逻辑我使用了jquery实现,因为要重新生成文章列表,需要操作dom,操作dom的话还是jquery比较方便一些,相册页面使用了另外两个插件justifiedGallery
(相册布局插件,也依赖jquery)和fancybox
(相册弹层插件)。在开发相册页面逻辑的时候,因为是本地开发,jquery插件还没加载完成的时候总是报$ is not defined
这个错误,这里我写了一个同步加载script标签的方法来动态加载第三方插件,确保jquery加载完成后再加载justifiedGallery,两个都加载完成后再进行初始化。
function PromiseForEach(arr, cb) {
let realResult = []
let result = Promise.resolve()
arr.forEach((a, index) => {
result = result.then(() => {
return cb(a).then((res) => {
realResult.push(res)
})
})
})
return result.then(() => {
return realResult
})
}
function addScript(url) {
return new Promise((resolve, reject) => {
let script = document.createElement('script');
script.src = url;
document.documentElement.appendChild(script);
script.onload = ()=>{
return resolve('success')
}
script.onerror = ()=>{
return reject('error')
}
})
}
let urlList = [
'https://cdn.staticfile.org/jquery/2.2.0/jquery.min.js',
'https://cdn.staticfile.org/justifiedGallery/3.7.0/js/jquery.justifiedGallery.min.js',
]
PromiseForEach(urlList,addScript).then(res=>{
//加载完jquery和justifiedGallery,先初始化justifiedGallery插件
$("#gallery-box").justifiedGallery({margins: 5, rowHeight: 200});
//再加载fancybox,,因为这个文件有点耗时,后加载不影响页面显示,js加载完成后会自动初始化
addScript('/libs/fancybox/fancybox.umd.js');
AddCss('/libs/fancybox/fancybox.css');
})
后面引入站点统计的js也是使用这个方法,确保不会阻塞dom。
之后加入切换夜间模式的代码,使用原生js实现,无需引入jquery
let html = document.documentElement;
let colorScheme = localStorage.getItem('colorScheme')
if(colorScheme&&colorScheme=='dark'){
html.setAttribute('data-color-mode', 'dark')
} else {
html.setAttribute('data-color-mode', 'light')
}
let btn_toggle_theme = document.querySelector('#btn_toggle_theme');
btn_toggle_theme.addEventListener('click', function () {
let theme = html.dataset.colorMode;
if (theme == 'light') {
html.setAttribute('data-color-mode', 'dark')
localStorage.setItem('colorScheme','dark');
} else if (theme == 'dark') {
html.setAttribute('data-color-mode', 'light')
localStorage.setItem('colorScheme','light');
}
})
根据每个页面的名称加载不同的scriptComponent(_document.tsx):
export default class MyDocument extends Document {
render() {
return (
<Html lang="zh-cn"
data-color-mode="light"
>
<Head/>
<body>
<Main/>
<NextScript/>
</body>
<DarkModeScript/>
<DynamicLoadScript/>
{this.props.__NEXT_DATA__.page=='/article/[articlePath]' && <ArticleScript {...this.props.__NEXT_DATA__.props}/>}
{this.props.__NEXT_DATA__.page=='/archives' && <ArchivesPropsScript {...this.props.__NEXT_DATA__.props}/>}
{this.props.__NEXT_DATA__.page=='/archives' && <ArchivesScript />}
{this.props.__NEXT_DATA__.page=='/gallery/[galleryPath]' && <DynamicLoadScript/>}
{this.props.__NEXT_DATA__.page=='/gallery/[galleryPath]' && <GalleryScript/>}
<StatisticScript/>
</Html>
)
}
}
代码段高亮
使用hilight.js,我使用了github风格的代码高亮,暗色模式也有,去highlightjs官方仓库下载github.css 及github-dark-dimmed.css,复制其中的样式到主题配色文件(styles/theme.scss)里,再加载highlight.js。
addScript('https://unpkg.com/@highlightjs/cdn-assets@11.5.0/highlight.min.js').then(res => {
document.querySelectorAll('pre code').forEach((el) => {
hljs.highlightElement(el);
});
})
最后快开发完成的时候,我发现文章字数统计这个功能还没有,查看一些hexo主题的源码之后,发现是用了一个插件hexo-wordcount
,可以直接在模板文件中这样调用wordcount(page.content)
,查看了这个插件的源码之后,它是扩展了hexo的helper方法:hexo.extend.helper.register('wordcount')……
,之后我通过hexo.extend.helper.store.wordcount
找到了它,虽然也能凑合用,不过它返回的站点总字数单位是千字,而且后面还加了个字母k,我还是决定另写一下,这样还不用引入hexo-wordcount
的npm包,把这个插件源码里的counter方法复制出来放到api.ts头部,在访问文章详情的时候调用,统计站点总字数的时候返回万字。
/**
* 文章字数统计
* @param: post.content
* @return: [中文字数,英文字数]
*/
function wordCounter(content) {
content = stripHTML(content);
const cn = (content.match(/[\u4E00-\u9FA5]/g) || []).length;
const en = (content.replace(/[\u4E00-\u9FA5]/g, '').match(/[a-zA-Z0-9_\u0392-\u03c9\u0400-\u04FF]+|[\u4E00-\u9FFF\u3400-\u4dbf\uf900-\ufaff\u3040-\u309f\uac00-\ud7af\u0400-\u04FF]+|[\u00E4\u00C4\u00E5\u00C5\u00F6\u00D6]+|\w+/g) || []).length;
return [cn, en];
}
/**
* 获取站点信息,分类数、标签数、文章字数等
* @param: empty
* @return: siteInfo
*/
export async function getSiteInfo() {
const hexo = await initHexo();
const posts = hexo.database.model('Post').find({});
const tags = hexo.database.model('Tag').find({});
const categories = hexo.database.model('Category').find({});
let count = 0;
posts.forEach(function (post) {
const len = wordCounter(post.content);
count += len[0] + len[1];
});
if (count < 1000) {
return count;
}
return {
postCount: posts.length,
tagCount: tags.length,
categoryCount: categories.length,
wordCount: Math.round(count / 1000) / 10 // 总字数,单位万字
}
}
项目结构
废话就不多说了,下面是项目结构:
├── components─────────────────────────组件目录
│ ├── article-toc.tsx────────────────文章目录组件
│ ├── article.tsx────────────────────首页文章列表组件
│ ├── container.tsx──────────────────布局容器组件
│ ├── external-script.tsx────────────附加scrip标签组件
│ ├── footer.tsx─────────────────────footer组件
│ ├── header.tsx─────────────────────header组件
│ ├── layout.tsx─────────────────────布局组件
│ ├── meta.tsx───────────────────────meta标签组件
│ ├── paginator.tsx──────────────────分页组件
│ ├── post-body.tsx──────────────────文章主体组件
│ └── post-header.tsx────────────────文章头部组件
├── lib────────────────────────────────lib目录
│ ├── api.ts─────────────────────────api,数据源
│ └── constants.ts───────────────────常量文件
├── pages──────────────────────────────页面路由目录
│ ├── article────────────────────────文章路由目录
│ │ └── [articlePath].tsx──────────文章详情页面
│ ├── gallery────────────────────────相册路由目录
│ │ ├── [galleryPath].tsx──────────相册详情页面
│ │ └── index.tsx──────────────────相册主页
│ ├── page───────────────────────────分页路由目录
│ │ └── [index].tsx────────────────分页页面
│ ├── 404.tsx────────────────────────404页面
│ ├── about.tsx──────────────────────关于页面
│ ├── _app.tsx───────────────────────app入口
│ ├── archives.tsx───────────────────归档页面
│ ├── _document.tsx──────────────────自定义文档结构
│ └── index.tsx──────────────────────首页
├── public─────────────────────────────站点根目录文件
│ ├── libs───────────────────────────第三方库目录
│ │ └── fancybox──────────────────fancybox
│ └── medias─────────────────────────资源目录
│ ├── avatar.jpg─────────────────头像图片
│ └── favicon.png────────────────站点图标
├── source─────────────────────────────hexo目录
│ ├── _data──────────────────────────自定义数据目录
│ │ ├── friends.json───────────────友链数据
│ │ └── gallery.json───────────────相册数据
│ └── _posts─────────────────────────文章目录
│ ├── 笔记────────────────────────分类目录
│ ├── 技术────────────────────────分类目录
│ ├── 生活────────────────────────分类目录
│ └── 折腾────────────────────────分类目录
├── styles─────────────────────────────样式目录
│ ├── custom-markdown.scss────────────文章样式
│ ├── custom.sass─────────────────────自定义样式
│ ├── index.css───────────────────────
│ └── theme.scss──────────────────────主题颜色定义
├── _config.yml─────────────────────────hexo配置文件
├── hexo.ts─────────────────────────────hexo初始化文件,提供数据源
├── next.config.js──────────────────────next配置文件
├── next-env.d.ts───────────────────────??unknown
├── package.json────────────────────────
├── postcss.config.js───────────────────
├── README.md───────────────────────────
├── tailwind.config.js──────────────────tailwind配置文件
└── tsconfig.json───────────────────────ts配置文件
配置GitHub Actions
生成静态页面之后,再配置一下GitHub Actions,检测到blog-source分支提交后自动部署到master分支和腾讯COS,再用两段linux命令把生成的json和js文件也都一并删除了,这里的222333是我自定义的buildId,在next.config.js文件里,不然nextjs自动生成的是一段随机的字符串。
on:
push:
branches:
- blog-source
pull_request:
jobs:
deploy:
runs-on: ubuntu-20.04
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
steps:
- uses: actions/checkout@v3
- name: Install and Build
run: |
npm install
npm run build
npm run export
- name: clean useless file
run: |
find out/_next/ -name *.js -or -name *.json |xargs rm -rf
find out/_next/ -type d -name data -or -name chunks -or -name 222333 |xargs rm -rf
- name: Deploy
uses: JamesIves/github-pages-deploy-action@v4.3.3
with:
branch: master
folder: out
single-commit: true
- name: Deploy Cos
uses: TencentCloud/cos-action@v1
with:
secret_id: ${{ secrets.SECRETID }}
secret_key: ${{ secrets.SECRETKEY }}
cos_bucket: ${{ secrets.COS_BUCKET }}
cos_region: ${{ secrets.COS_REGION }}
local_path: out
remote_path: ''
clean: false
最后,开源是必须的,源码地址:点击前往