本文由图雀社区认证作者 crimx[1] 写作而成,点击阅读原文查看作者的博客,感谢作者的优质输出,让我们的技术世界变得更加美好?
为什么选 Gatsby
我的博客最初是用 Github Pages 默认的 Jekyll[2] 框架,其使用的 Liquid[3] 模板引擎在使用上有诸多不便。
后来基于 Node.js 的 Hexo[4] 横空出世,我便重构了博客[5]对其深入整合,还为其写了一个 emoji 插件[6]。在编写过程中发现其 API 设计比较不成熟,调试体验也不是很好,阅读其它插件代码时发现很多都需要用到未公开接口。同时资源管理需要借助其它 Task runner,如当时比较流行的 Grunt 和 Gulp 。这样下来直接依赖了大量包,冲突不可避免的产生。
在一次换系统之后,项目终于构建不了了,包冲突处理起来非常头疼,也影响到了写博文的兴致。
拖延了一段时间后,终于开始考虑更换框架。这时 React Angular Vue 生态已比较成熟,所以就没必要考虑其它的模板引擎。
首先注意到的是新星 VuePress[7] 。然而考察过后发现其正在 v1 到 v2 的更替期,v1 功能比较简陋,v2 还在 alpha 期不稳定。且 VuePress 目前还是针对静态文档优化比较多,作为博客依然比较简陋。
这时 @unicar[8] 正好推荐了基于 React 的 Gatsby[9]。发现其生态很强大,再搭配 React 庞大的生态,确实非常吸引人。
而且在了解过程中还发现了 Netlify CMS[10] 这个内容管理平台,如此一来,文章数据完全可以存在 Github 中,同时可以便捷地编辑文章。
建议使用 Starter 修改着理解 Gatsby,我用的是 Gatsby + Netlify CMS Starter[11]。
完整的 Gatsby 项目结构可以看文档[12],这里针对搭建博客用到的功能说明一下。
/src/pages
目录下的组件会被生成同名页面。/src/templates
目录下放渲染数据的模板组件,如渲染 Markdown 文章,在其它博客系统中一般叫 layout
。/src/components
一般放其它共用的组件。/static
放其它静态资源,会跳过 Webpack 直接复制过去。接下来是两个比较常用的配置文件,需要修改时参考 Starter 改即可。
/gatsby-config.js
基本用来配置两个东西:siteMetadata
放一些全局信息,这些信息在每个页面都可以通过 GraphQL 获取到。plugins
配置插件,这个按用到时按该插件文档说明弄即可。/gatsby-node.js
可以调用 Gatsby node APIs[13] 干一些自动化的东西。一般有两个常用场景:/src/pages
以外的页面文件,如为每个 Markdown 文章生成页面文件。此外还有两个不那么常用的配置文件。
/gatsby-browser.js
可以调用 Gatsby 浏览器 APIs[14],一般插件才会用到,如滚动到特定位置。/gatsby-ssr.js
服务器渲染的配置,一般也是插件才用到。这就是搭建 Gatsby 博客的基本结构了,可以看到非常简单,且因为其丰富的生态,其它底层接口基本不需要用到。但接下来还是会有一些小坑,第一个便是 GraphQL,我们将马上来分析。
在上一节介绍了选择 Gatsby 的原因,其中提到了 Gatsby 使用 GraphQL 。大家可能会有疑惑,不是建静态博客么,怎么会有 GraphQL?难道还要部署服务器?
其实这里 GraphQL 并不是作为服务器端部署,而是作为 Gatsby 在本地管理资源的一种方式。
通过 GraphQL 统一管理实际上非常方便,因为作为一个数据库查询语言,它有非常完备的查询语句,与 JSON 相似的描述结构,再结合 Relay 的 Connections 方式处理集合,管理资源不再需要自行引入其它项目,大大减轻了维护难度。
这里也是 Gatsby 的第一个坑。在 Gatsby 中,根据 js 文件的位置不同,使用 GraphQL 有两种形式,且 Gatsby 对其做了魔法,在 src/pages
下的页面可以直接 export
GraphQL 查询,在其它页面需要用 StaticQuery 组件[15]或者 useStaticQuery[16] hook。
这里面查询语句虽然写的是字符串,但其实这些查询语句不会出现在最终的代码中,Gatsby 会先对其抽取[17]。
个人其实不太喜欢魔法,因为会增加初学者的理解难度。但不得不承认魔法确实很方便,就是用了魔法的项目应该在文档最显眼的地方说明一遍。
GraphQL 结构跟最终数据很相似,基本语法也非常简单,看看官方文档即可。一个快速上手的方式是访问项目开发时(默认 http://localhost:8000
)的 /___graphql
页面,通过 GraphiQL 编辑器右侧可以浏览所有能够查询的资源。
另一个需要理解的是 Relay 的 Connections 概念,你会发现 Gatsby 里所有的数据集合都是以这种方式查询。推荐阅读 Apollo 团队分享的文章[18]。
对 Connections 细致的理解往往是实现分页等底层需求时才需要,而这些均有插件完成。一般使用时只需要知道集合里每个项目的数据在 edges.node
中,同时通过 GraphiQL 浏览其它可以使用的数据。如对于 Markdown 文章,相应插件提供了字数统计以及阅读时长等数据,均可通过 GraphQL 直接获取。
Gatsby 魔法带来的另外一个坑是 GraphQL 报错信息不全,可能会默默被吞掉,也可能无法定位到最终文件。
我在修改 starter 时踩到一个坑是复制组件时忘了修改 static query 查询语句的名称,导致重名报错。
避免错误最好方式是在 GraphiQL 编辑器中写好运行无误再复制到组件中。
Gatsby 中处理 markdown 最常用也是默认的插件是 gatsby-transformer-remark
。这个插件对 markdown 文件解析后会生成 MarkdownRemark
GraphQL 节点,其中 front matters 数据也会被解析出来。同时 MarkdownRemark
的集合对应为 allMarkdownRemark
connections。
对于 connections 节点我们一般可以用 sort
和 filter
来筛选处理数据(可在 GraphiQL 编辑器中浏览),这里有一个坑便是如果要处理 front matters 数据,它们必须存在所有查询的 markdown 文件上并且具有相同的类型,插件才会生成相应的 fields,否则可能会抛出异常或者更糟糕的,默默失败了。
避免方式同上,先在 GraphiQL 编辑器中运行一遍,看看筛选的结果是否正确。
另外一种处理方式是在 /gatsby-node.js
中通过 onCreateNode
钩子,在生成 markdown 相关节点时手工处理,确保节点存在。
这在实现草稿和上下篇的时候会用到,具体例子我会在后续章节中提到。
搭建 Gatsby 博客其实不需要 CMS 都是可以的,编写 Markdown 然后 build 即可。但这么做还是略嫌不便,通过 CMS 一般可以在一个可视化的在线环境中编辑文章,然后一键即可发布。
Gatsby 主流的两个 CMS 是 Contentful 和 Netlify CMS。
对于 Contentful 来说,文章是放在 Contentful 的服务器上的,管理也是通过 Contentful 提供的工具。当然其质量还是不错的,喜欢的可以参照官方的教程[19]搭建。
Netlify CMS 是跟项目一起发布的,默认是在 /admin
页面下。文章也是存在源项目中,就是原来默认的 Markdown 文件。Netlify CMS 借助 Oauth 把写好的 Markdown 文件推送到项目源码的仓库上,再配合 Netlify 检测仓库变动自动构建发布。当然后者也不是必须的,可以换其它方式自动构建。
Netlify CMS 的优点是开源免费,文章跟项目源码在一起,界面可以高度自定义,甚至可以自行扩充 React 组件,基本满足简单的博客编写需求。
如果用官方的 starter[20] 配置将会非常简单。此 starter 默认使用 Github 作为仓库,Netlify 作为自动构建服务器。
默认的 /static/admin/config.yml
已经配置好了大部分,如果对文章 Markdown 添加了自定义的 front matters 则需要再做些细调。
Widgets 代表了在 CMS 中可输入的模块,官方[21]为常见的类型都提供了默认的 widgets ,没有满足的也可以自定义[22]。
如我的博客[23]中每篇文章都有一个 quote
域放些引用文字,那么在配置[24]中添加上
fields:
- label: "Quote"
name: "quote"
widget: "object"
fields:
- {label: "Content", name: "content", widget: "text", default: "", required: false}
- {label: "Author", name: "author", widget: "string", default: "", required: false}
- {label: "Source", name: "source", widget: "string", default: "", required: false}
如此即可在 CMS 中填写相关信息。
CMS 中提供了文章预览界面,如果需要自定义只需修改 /src/cms/
下相应的文件即可,就是简单的 React 组件。
以上便是 Netlify CMS 最常用的配置,只需简单的修改博客现在就能跑起来了。接下来我们会通过实现草稿模式和上下篇文章来深入理解 Gatsby 的机制。
迁移博客需要考虑的一个重要问题便是路径兼容。我们当然不希望迁移后原有的链接无法访问,这不仅影响到 SEO ,更带来了不好的用户访问体验。本文将聊聊怎么让 Gatsby 兼容 Jekyll 式路径。
一般来说,在 /src/pages/
目录下的组件会自动生成相应路径的页面,但如果是其它类型的文件就不会了。我们可以通过 Gatsby 的 Node APIs 来生成特定页面。
在 /gatsby-node.js
中配置 Gatsby Node APIs,如果项目是基于 starter 的话你很可能会发现里面已经有相应的配置。
我们通过声明 exports.createPages
钩子来配置页面生成,在回调中通过调用 actions.createPage
来生成某个指定页面。这个方法接受一个配置参数,其中的 path
域代表了生成页面的路径。
exports.createPages = ({ actions, graphql }) => {
actions.createPage({
path,
// ...
})
}
那么我们怎么知道该生成哪些页面呢?这里通过 exports.createPages
回调中的 graphql
来查询 Markdown 文件。
exports.createPages = ({ actions, graphql }) => {
const { createPage } = actions
return graphql(`
{
allMarkdownRemark(sort: { order: ASC, fields: [frontmatter___date] }) {
edges {
node {
id
fields {
slug
}
frontmatter {
path
title
layout
}
}
}
}
}
`).then(result => {
if (result.errors) {
result.errors.forEach(e => console.error(e.toString()))
return Promise.reject(result.errors)
}
// ...
})
}
Netlify CMS 会在 Markdown front matters 中的 path
域生成路径。根据默认的 /static/admin/config.yml
我们的路径应该是 /blog/{{year}}-{{month}}-{{day}}-{{slug}}/
,这个可能跟旧博客不一样,如 Jekyll 是 /{{year}}/{{month}}/{{day}}/{{slug}}/
。
在 Remark 插件生成的 Markdown 节点中,我们可以往 fields
域放一些自定义的变量。这里我们把自定义的路径存到 fields.slug
中。
通过 /gatsby-node.js
中的 exports.onCreateNode
钩子我们可以在生成节点的时候进行拦截处理。你可能发现文件里面已经有一些配置的代码了,我们这里只关注 Markdown 相关的。
exports.onCreateNode = ({ node, actions, getNode }) => {
const { createNodeField } = actions
if (node.internal.type === `MarkdownRemark`) {
// Jeykll style post path
const filepath = createFilePath({ node, getNode })
createNodeField({
node,
name: 'slug',
value: filepath.replace(
/^\/blog\/([\d]{4})-([\d]{2})-([\d]{2})-/,
'/$1/$2/$3/'
)
})
}
}
我们把原有的路径值换成了自定义值并存在了 fileds.slug
中。
回到我们前面的查询[25],得到需要的数据之后只需要对每个页面调用 actions.createPage
即可。
exports.createPages = ({ actions, graphql }) => {
const { createPage } = actions
return graphql(`
{
allMarkdownRemark(sort: { order: ASC, fields: [frontmatter___date] }) {
edges {
node {
id
fields {
slug
}
frontmatter {
path
title
layout
}
}
}
}
}
`).then(result => {
if (result.errors) {
result.errors.forEach(e => console.error(e.toString()))
return Promise.reject(result.errors)
}
const { edges } = result.data.allMarkdownRemark
const options = edges.map(edge => ({
path: edge.node.fields.slug,
title: edge.node.frontmatter.title,
component: path.resolve(
`src/templates/${edge.node.frontmatter.layout}.js`
),
// additional data can be passed via context
context: {
id: edge.node.id
}
}))
options.forEach(option => createPage(option))
})
}
也许你会问为什么不在这里直接计算自定义路径而是要存到 fields.slug
中。这是因为这个路径我们可能还会在其它地方用到,存起来就不必多处计算了。
上面代码中可以注意到还有个 context
域,这个域中的数据会被传到 component
的 props 中。这样我们在模板组件中通过 pageContext.id
便可判断当前渲染的文件。
通过实现自定义路径基本上可以了解 Gatsby 页面生成的方式了。下节中我会继续谈谈其它个性化的配置,如草稿模式和显示上下篇博文。
草稿模式即可以将文章保存为草稿而不被渲染出来。方式是在 front matters 中设置一个 draft
布尔域,以此域作为渲染参考。
这里有一个地方需要注意,前面文章提过,Markdown 插件需要所有文章中都有 draft
域且都是布尔类型才会生成相应的 GraphQL 查询。如果是新的博客这个问题不大,如果是迁移过来的,有两个解决方式,第一个是手动写个脚本给文章都补上域,另一个是利用 Gatsby 的 Node APIs 在 fields
上生成特定域,鲁棒性更好些。
观察 Remark 插件生成的 GraphQL 类型,我们可以发现,front matters 都被放在 frontmatter
域中,而与之同级的有一个前面文章提到过的 fields
域,用来放自定义生成的数据。
Gatsby 在生成 GraphQL 节点时提供了钩子 onCreateNode
,我们利用这个钩子往 fields
中放自定义的数据。
编辑 /gatsby-node.js
,如果是用了 starter 的话这里很可能已经有其它的代码,已有的不需要动,添加我们需要的即可。
exports.onCreateNode = ({ node, actions, getNode }) => {
const { createNodeField } = actions
if (node.internal.type === `MarkdownRemark`) {
createNodeField({
node,
name: 'draft',
value: Boolean(node.frontmatter.draft)
})
}
}
如此 fields
中就保证了会有 draft
这个域了。
有了标记之后,在生成页面的地方我们就需要过滤草稿。
首先是普通的文章页面生成,这个是在 createPages
钩子中,如果你的博客只有文章用到 Markdown 的话,可以在 GraphQL 查询中直接过滤,否则我们用前面文章的方法,先取所有 Markdown 文件再根据渲染的模板来分别处理各种类型的文章。
注意我把模板域的名字换成了自己更习惯的 layout
,原来的 starter 中应该叫 templateKey
。修改其实也很简单,搜索所有文件替换关键字即可。
options
.filter(
(_, i) =>
!(
edges[i].node.frontmatter.layout === 'blog-post' &&
edges[i].node.fields.draft
)
)
.forEach(option => createPage(option))
我在主页中也列举了最近的几篇文章,这里也需要过滤草稿,可以直接在 GraphQL 中过滤。
query IndexQuery {
latestPosts: allMarkdownRemark(
sort: { order: DESC, fields: [frontmatter___date] }
filter: {
fields: { draft: { ne: true } }
frontmatter: { layout: { eq: "blog-post" } }
}
limit: 5
) {
edges {
node {
excerpt(pruneLength: 200)
id
fields {
slug
}
frontmatter {
title
description
layout
date(formatString: "MMMM DD, YYYY")
}
}
}
}
}
其它地方同理。
在文章页面中我们通常会加入上下篇来引导继续浏览。这里我们同样在 createPages
钩子中处理,但这回我们添加到 context
域中,这个域里的数据会作为 props 传到模板组件中。
在 createPage
生成文章页面前添加处理代码[26]计算上下篇:
options
.filter(
(_, i) =>
edges[i].node.frontmatter.layout === 'blog-post' &&
!edges[i].node.fields.draft
)
.forEach((option, i, blogPostOptions) => {
option.context.prev =
i === 0
? null
: {
title: blogPostOptions[i - 1].title,
path: blogPostOptions[i - 1].path
}
option.context.next =
i === blogPostOptions.length - 1
? null
: {
title: blogPostOptions[i + 1].title,
path: blogPostOptions[i + 1].path
}
})
然后在文章的 /src/templates/blog-post.js
组件里,接收 pageContext
props,就可以使用上面传入的数据了。这是[27]我的例子。
通过实现这几个功能我们了解了 Gatsby 页面生成的方式以及其 Node APIs 的基本使用。Gatsby 的功能远不止这些,官方文档写得非常详细,需要实现其它功能建议先去看看有无现有的例子。本系列到这里暂告一段落,谢谢你的阅读,希望能对你搭建 Gatsby 博客有所帮助。
[1]
crimx: https://www.crimx.com/
[2]
Jekyll: https://jekyllrb.com
[3]
Liquid: https://shopify.github.io/liquid/
[4]
Hexo: https://hexo.io
[5]
博客: https://blog2018.crimx.com
[6]
emoji 插件: https://github.com/crimx/hexo-filter-github-emojis
[7]
VuePress: https://vuepress.vuejs.org/
[8]
@unicar: https://twitter.com/unicar9
[9]
Gatsby: https://www.gatsbyjs.org/
[10]
Netlify CMS: https://netlifycms.org
[11]
Gatsby + Netlify CMS Starter: https://github.com/netlify-templates/gatsby-starter-netlify-cms
[12]
文档: https://www.gatsbyjs.org/docs/gatsby-project-structure/
[13]
Gatsby node APIs: https://www.gatsbyjs.org/docs/node-apis/
[14]
Gatsby 浏览器 APIs: https://www.gatsbyjs.org/docs/browser-apis/
[15]
StaticQuery 组件: https://www.gatsbyjs.org/docs/static-query/
[16]
useStaticQuery: https://www.gatsbyjs.org/docs/use-static-query/
[17]
先对其抽取: https://www.gatsbyjs.org/docs/page-query#how-does-the-graphql-tag-work
[18]
Apollo 团队分享的文章: https://blog.apollographql.com/explaining-graphql-connections-c48b7c3d6976
[19]
教程: https://www.contentful.com/r/knowledgebase/gatsbyjs-and-contentful-in-five-minutes/
[20]
starter: https://github.com/netlify-templates/gatsby-starter-netlify-cms
[21]
官方: https://www.netlifycms.org/docs/widgets/
[22]
自定义: https://www.netlifycms.org/docs/custom-widgets/
[23]
博客: https://blog.crimx.com
[24]
配置: https://github.com/crimx/blog-2019/blob/3af6a9706e2c1e7f7c1a3c1dac0ad981d5603693/static/admin/config.yml#L14-L28
[25]
前面的查询: https://github.com/crimx/blog-2019/blob/d7c8c6bbbe73ef455f70bc629d153b836482f788/gatsby-node.js#L71-L79
[26]
添加处理代码: https://github.com/crimx/blog-2019/blob/d7c8c6bbbe73ef455f70bc629d153b836482f788/gatsby-node.js#L47-L68
[27]
这是: https://github.com/crimx/blog-2019/blob/1b2f63a60448a502c632d120c798009b2960b19f/src/templates/blog-post.js#L123-L160