最近花了几天时间用 Nuxt.js 重写我的博客。

Nuxt.js 简介

Nuxt.js 是 Vue.js 下的一个 Universal App Framework,它可以用于 SSR,不过我利用了它的 nuxt generate 功能进行页面静态化,基本实现了 static site generator 的功能。(之所以说「 基本」并不是表示 Nuxt.js 有限制,而是我的应用本身没能完全静态化。 )

为什么不再用 Hexo?

不可否认,Hexo 的生态是相当丰富的,各种各样的插件可以增强博客的功能;而且,Hexo 生成的站点是真正完全静态的,运行时开销很小。

但是,Hexo 下的各种配置相对复杂,而且目前 Hexo 的状态给我的感觉是效率不高。

之前我在用 Hexo 的时候,安装了一个插件压缩 HTML、CSS、JS 资源的插件。然而那个插件整个压缩过程相当慢(当然这个也许不能怪 Hexo)。受于 Hexo 的限制,你没办法在不同环境下启用或关闭此插件,也就是在开发环境上关闭此插件,在生成静态站点是启用此插件。这导致开发体验受到了下降。

当然还有一个不太重要的理由,就是主题的原因。原来我用的主题是 Archer,并作了一些修改。然而原主题也是在不断更新,合并自己不了解的代码是个麻烦的事。现在刚好想换个主题(以目前来看,严格地说不能叫「主题 」,因为现在我的博客根本就没有主题系统 ),又不想去学 Hexo 的主题开发。

为什么是 Nuxt.js 而不是别的?

Nuxt.js 是基于 Vue.js 的,而我多多少少学过 Vue.js 的东西,玩 Vue.js 的那一套并不会感到陌生。也就是说,即便是要学 Nuxt.js,成本也是比较低的。

VuePress

说实话,目前这个博客在不少方面的灵感来源于 VuePress。而且 VuePress 本身就把 CMS 的功能帮你实现好,你只需要关心内容写作。不过,目前 VuePress 的定位更偏向于软件文档(毕竟是给 Vue.js 及官方库服务),拿来当博客还不太适合。

多说一句:Blogging support 在 VuePress 的 Todo 列表里。

Gatsby

Gatsby 给我的感觉是太复杂了,而且我不太了解 React 生态下的那一套东西,CSS-in-JS 写起来也相当不舒服。

不过,如果你经常使用 React,或者对 React 的生态比较熟悉,那么 Gatsby 仍然是个不错的选择。

目前的页面 UI

总的来说一直在做减法:从当初 Hexo 上的 Material Design 主题,到后来的 Archer 的主题,再到现在,越来越简洁。(简洁不等于简单)

首页

首页完全是由我自己设计的。相比之前的主题,去掉了首页的头部背景图,也去掉了友链。

文章列表则由原来的同时展示文章标题、文章部分内容、标签、发布时间,变为现在的只有发布时间和文章标题。此外,每页最多显示 7 篇文章。

文章列表的翻页也是自己设计的。它不像之前 Hexo 那样每换一页就加载一个新的 HTML 页面,而是像 SPA 那样。这样做有优缺点。好处是翻页时给人的感觉是 seamless 的;坏处是不利于 SEO,因为是在首页加载完后才渲染列表内容。

「 关于」页面

这个页面不再用 Markdown 来完成,而是直接写在 Vue SFC 里。虽然从源代码上看不如 Markdown 那么好维护,不过这个页面不需要经常改变,所以是无所谓的。

「 关于」页面使用了像之前 Archer 主题那样的 topic background image。只是觉得这么做确实很美观,CSS 代码方面也参考了 Archer 主题。

这个页面有个没什么用的彩蛋,诸君可以自行挖掘。

友链页面

这个页面设计得有点赶,而且也没想到有什么好的设计思路。仅仅是对每个链接加了个 box-shadow。

文章页的头部

这个 header 做得比较简单。

首先中间是当前文章的标题。而两边分别是指向首页和指向「 关于」页面的链接。这两个链接都加个了 hover 时的 border-bottom。手机用户是看不到这两个链接的,这是为了使标题尽可能完整地显示出来。

还有一个就是这个 header 在你往下滚动整个页面的时候,它的背景色会改变。而且滚动越往下,颜色会越深。不过根据多次测试,这个效果在手机上表现得不好,可能是跟在手机上是以触摸的方式来滚动页面有关。

页面的底部

整个站点的底部都是统一的。

首先是四个 social 按钮。这些图标用的是 feather-icons[1]。不使用 Font Awesome 是因为它太大了。

接着是 CC BY-SA 的小尺寸图标,我认为大尺寸图标影响美观。

文章页

整个 Markdown 样式主要参考了 Vue.js 的文档网站。代码块则是使用 highlight.js 来渲染。

页面右下角有个可以点击的 </>,这其实是一个用于返回顶部的按钮。虽然说是按钮,但在 HTML 层面它仅仅是一个普通的 div 元素,里面也只有那三个字符,剩下的就是用 CSS 来控制各种效果。

在点击返回顶部的按钮后,可以看到有一个慢慢回到顶部的动效。这个动效来源于之前的 Archer 主题,并作了一点修改。

架构 & Workflow

目录结构

  • assets 目录用于存放公共的 JavaScript、CSS 等资源
  • components 目录用于存放各个 Vue 单文件组件(SFC)
  • layouts 目录用于存放页面布局,本质上也是 Vue SFC
  • modules 目录用于存放 Nuxt.js 的 module。整个博客的核心都在这里
  • pages 目录放的就是各个页面,其中有些是人工写好的(如「 关于」页、友链页 ),有些则是动态生成的(如文章页),动态生成的需要记入 .gitignore
  • scaffolds 目录放的是模板,用于生成保存的 pages 目录的页面
  • scripts 杂七杂八的脚本
  • source 放的是文章源 Markdown 文件

写作的 Workflow

只需将写好的 Markdown 文件保存在 source/posts 目录,生成静态站点的时候就会生成对应的页面。

工作原理

你可以直接阅读 modules 目录下的源码

时机

整个过程的关键是利用了 Nuxt.js 的 ready 钩子。这个钩子会在 Nuxt.js 执行初始化渲染器之前被调用(比渲染还要早)。在这个阶段,根据 Markdown 去生成 Vue SFC。

预渲染

我们姑且把 Markdown 转换成 HTML 的过程称为「预渲染 」吧。

在这里,我们使用的是 markdown-it[2] 来将 Markdown 转换成 HTML。markdown-it 拥有插件机制,可以通过加载插件来扩展 Markdown,而且生成出来的是标准的 HTML。

渲染过程可以在 modules/markdown.js 中找到。

生成单文件组件

Nuxt.js 只认 .vue 格式的单文件组件,所以我们要将上个步骤得到的 HTML 弄成 SFC。

一个好消息是,所有合法的 HTML 代码都是合法的 Vue SFC(template 部分)。所以我们可以事先准备好一个模板,然后将前面的 HTML 塞入模板中。

这里我使用 ejs[3]。它是一个 Node.js 下的模板引擎。我把这个模板放在了 scaffolds/post.vue

首先用 fs.readFileSync 读取模板的内容到字符串中,然后调用 ejs 进行渲染,此时得到的字符串已经是合法的 Vue SFC 代码,接下来只需将这些代码以 .vue 为后缀保存到 pages 目录即可。

监视文件

然而 Nuxt.js 的 ready 钩子在整个 Nuxt.js 的生命周期中只会被调用一次,所以像上面那样做虽然可行,但一旦更改 Markdown 内容就得重新运行 Nuxt.js。

在开发状态下,Nuxt.js 会对 pages 目录进行监视,对每次更改都会进行增量编译然后进行热模块替换。基于这种思路,我们可以做类似的事。

我使用 chokidar[4] 模块进行文件系统监视,监视 source/posts 目录。目录中有文件增加或文件有改动,就会调用 markdown-it 进行渲染并保存为 .vue 文件,从而让 Nuxt.js 进行接下来的增量编译。

生成 RSS 数据和文章列表

这个不太难,同样是在 Nuxt.js 的 ready 钩子中完成,并将文件保存在 static 目录中。Nuxt.js 不会对此目录中的文件进行 webpack 编译,而只会映射(开发时)或复制(生成静态站点时)到网站根目录。

标签功能

作为一个博客,标签功能或分类功能是必不可少的。我个人更倾向于使用标签而不是分类,因为通常而言,一篇文章只能属于一个分类,但可以拥有多个标签。

每篇文章所拥有的标签都记录在 front matter 里,渲染之前将它提取出来即可。

要解决的难题在于,如何在页面上为每个标签显示不同的文章列表。一个简单可行的办法是直接利用前面生成好的文章列表 JSON,在运行时读取它。但这里有一个问题,你得解决 URL 的问题。而且我希望能尽可能地减少运行时开销。

我使用的办法是利用 Nuxt.js 的异步数据功能,结合动态路由。关于异步数据我稍后会解释,这里先谈动态路由。

Nuxt.js 具有动态路由这一功能,动态路由本身怎么用可以直接看 Nuxt.js 的文档,这里不作解释。而我想谈的是在生成静态站点时如何使用动态路由。

在默认情况下,执行 nuxt generate(也就是生成静态站点)是不会对动态路由进行处理的。因为动态路由无法在编译时确定。不过,Nuxt.js 仍然给我们提供了一些办法,使得我们可以在生成静态站点时对动态路由进行处理。

根据 Nuxt.js 文档的叙述,我们可以在 Nuxt.js 的配置文件中进行生成静态站点时的动态路由配置。有两种方法:一是提供一个提前准备好的数组,数组中每个元素都是完整的 URL 路径,Nuxt.js 会自己进行路由参数匹配;二是提供一个可以异步返回一个数组的函数,数组内容跟第一种方法相同。

显然我们要用第二种方法。从所有 Markdown 文件中提取所有标签并去重,将这个数组返回给 Nuxt.js。Nuxt.js 将会为每条 URL 分别生成不同的页面。

异步数据

在这个博客中,有两处内容是动态生成的:一是首页的文章列表,二是各个标签页的文章列表。

通常的做法是在加载好页面后,通过 AJAX 来获取数据内容。这当然是可行的。但是,这将不可避免地产生一次 HTTP 请求。能不能提前将这些数据准备好呢?

Nuxt.js 为每个页面提供了这样的 API:asyncDatafetch。这两个方法都是在初始化组件前调用,所以不能在里面通过 this 来访问组件实例,但 Nuxt.js 会传递 context 参数,context 中包含路由参数等信息。

asyncDatafetch 的区别是:asyncData 可以让你同步或异步地访问外部并得到一些数据,然后通过 return 语句将这些数据返回给当前组件(这些数据在获得后可能需要整理),因为这些数据最终会被合并进组件实例的 $data 属性,所以在结构上必须保持与组件的 data 选项相同;而 fetch 可以让你同步或异步 地访问外部并得到一些数据,接着将这些数据 commit 给 Vuex。

我没有使用 Vuex(因为不需要),因此用 asyncData

那么问题来了,如何获取这些数据呢?(注意所有的数据都是由先前已经生成好的文章列表 JSON 文件中获取)它并不是一个网络上的 API,不能通过 HTTP 来获取。fs.readFile 也不行,生成静态站点的时候会报错(提示找不到 fs 模块)。

最后的解决办法是直接用 CommonJS 的 require 来读取它。为什么可以这么做呢?第一,它是个 JSON 文件,Node.js 下的 CommonJS 可以直接读取并自动解析 JSON;第二,require 对于 webpack 而言,仅仅是很普通的模块导入。

顺带一提,如果是在做 SSR 的时候,asyncDatafetch 会在每次的请求中执行一次;而在 nuxt generate 的时候,它将只执行一次并将当时所获取到的数据写入最终生成的 HTML 中而不会改变

一些感受

得益于 Nuxt.js 开箱即用的特性,下游开发者无需配置 .babelrcwebpack 等工具,也可享受 ES 转码、资源压缩等功能。Hexo 是没有办法做到这一点的(当然了,Hexo 的重心或许不在这里)。

此外,nuxt generate 的时间远远小于 hexo generate(在使用 Hexo 中的 minify 插件的前提下)。之前在 Travis CI 上需要 3~5 分钟才能将网站生成完毕;而如今在 Circle CI 上,最快只需半分钟,最慢也就一分半。

由于整个博客中的很多东西(包括功能、页面 UI 等)都由自己控制,可以做不少的优化。目前打开首页仅仅需要 100 KB(gzipped)。

另外得到的副产品是 Vue.js 及其生态了。你可以直接在 Markdown 中写 Vue template(这很骚,不过没什么用)。还有就是可以使用各种 Vue.js 库(但我不会那么干,因为我不需要,而且安装太多库会增大应用体积)。

最后,display: flex 太 tm 好用了!


  1. https://github.com/feathericons/feather↩︎

  2. https://github.com/markdown-it/markdown-it↩︎

  3. https://github.com/mde/ejs↩︎

  4. https://github.com/paulmillr/chokidar↩︎