最近花了几天时间用 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:asyncData
和 fetch
。这两个方法都是在初始化组件前调用,所以不能在里面通过 this
来访问组件实例,但 Nuxt.js 会传递 context
参数,context
中包含路由参数等信息。
asyncData
和 fetch
的区别是: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 的时候,asyncData
和 fetch
会在每次的请求中执行一次;而在 nuxt generate
的时候,它将只执行一次并将当时所获取到的数据写入最终生成的 HTML 中而不会改变。
¶一些感受
得益于 Nuxt.js 开箱即用的特性,下游开发者无需配置 .babelrc
和 webpack
等工具,也可享受 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 好用了!