使用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.cssgithub-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

最后,开源是必须的,源码地址:点击前往