首发于:个人博客:吃饭不洗碗
前言
国庆七天,就这样没了,以不想看人海的借口(9 s q),在家里白吃白喝待了七天。但作为前端练习生的我,又有时间倒腾了,这不,夜深人静时,用 React + Github Issues + Github Graphql
API + Github Action + Github Pages 重写了自己的博客。
- 我的新博客站点
- 如果你对下面的实现过程没兴趣,可直接查看最后一节 快速搭建属于你的博客
目的意图
以前是用 Hexo 搭的, 那为什么要换,嗯 …….. 图个乐。其实还有就是:阿里云又让续费服务器了;用 segmentfault 做自己的文件存储计划失效了(图片链接有时效性,动态的);hexo 上条条框框太多了,博客资源仓库是静态的,都是离线编写。那为什么又要用上面的那一堆东西呢?嗯,是这样的:
- React : 三大框架,也就这个入了门,写起来快;
- Github Issues:博客数据库加图片文件存储,一箭双雕, 有时间再整个评论,直接帽子戏法;
- Github Graphql API:博客数据库(Issues)读取接口,为什么不用 V3,接口返回东西太多,浪费带宽;
- Github Action:Github 去年出的新功能 - 持续集成服务,可用于打包构建部署静态资源;
- Github Pages:服务器就不续费了,钱省下吃火锅。Pages 用起挺好,还白送个域名,而且还免费配置了 gzip,缓存策略
权限申请
Github Graphql Api 访问 token
Github API V3 版提供的 Restful 接口,可以直接访问,不需要鉴权,但访问次数有限制。而对于 Github API V4,即 Graphql 接口,需要鉴权,所以我们需要申请一个 token,具体步骤是:登录你的 github -> Settings -> Developer settings -> Personal access tokens -> Generate New。如下图所示,我们需要设置这个 token 的操作范围,出于安全的考虑,这里不做任何勾选。这样做对应的权限是 no_scope,对应的权限是只读,可通过下面的图片和链接获取详细的权限范围。
权限分配说明
Github Action 部署权限
关于 Github Action 是什么,还未了解的,可以阅读官方文档,也可以自己 Goggle 一下。在后续 Github Pages 的部署,会用到一对秘钥,可以通过下面的命令在 cmd 中生成:
ssh-keygen -t rsa -b 4096 -C "$(git config user.email)" -f gh-pages -N ""
然后在你的当前目录会得到如下两个文件(gh-pages 与 gh-pages.pub):
然后参照 Github 上一个现成的手把手部署文档,在你的项目中设置这对秘钥。在后面的项目部署再细讲。
博客代码实现
其实一个简单的博客,就一个列表页和一个详情页,列表页就是展示你写了哪些文章,而详情页就是展示文章具体内容;下面的代码不会涉及到页面 UI 的设计及项目架构设计,重点讲与 github 的接口交互实现,想直接看源码实现的,可以直接 clone 下载下来看:
git clone -b blog https://github.com/closertb/closertb.github.io.git
写前须知
- React: 只涉及到 React-Router 这个生态,页面全部用函数式组件编写,所以 Hooks 有必要了解一下;
- 关于 Graphql: 可以查看官网介绍, 也可以查看我之前写过的文章,1、同学,GraphQL 了解一下:基础篇, 2、同学,GraphQL 了解一下:实践篇, 3、GraphQL 进阶篇: 挥手 Redux 不是梦,看前两篇就够用了;
- 关于 apollo graphql: 一个 Graphql 的第三方框架,可查看官网介绍,在上面的两篇文章也有提及, 重点看下 query 就行了;
- Github Graphql API: 官网链接,但相信我,在官网学 Github API 是相当低效的,Graphql 的优势就是接口实现了,运用 Graphiql 界面技术,相应的接口文档就出来了,官网也提供了这样的界面:Graphql Explorer, 你可以复制下面的代码到 Graphql Explorer,并改成你自己的项目进行尝试(需删掉注释)
query {repository(owner: "closertb", name: "closertb.github.io") {
issues(last: 10, states:OPEN, orderBy: {
field: CREATED_AT
direction: DESC
}) {
totalCount
edges {
cursor // 节点标志位,后面分页会用到
node {
title
url
createdAt
updatedAt
reactions(first: 100) { // issue 动态,什么赞,踩,喝彩这些
totalCount
nodes {content}
}
}
}
}
}
}
建立 Graphq 客户端
建立 Graphql 客户端, 类似于我们写常规请求时,要初始化一个 Http 类或 Axios 类,来配置请求的目标域名与验证令牌,这个也是相似的;这里就会用到了前面申请的 Github Graphql API 访问 token,都是一些 Api 调用,直接上代码:
// Initialize
const cache = new InMemoryCache();
const authLink = setContext((_, { headers}) => ({
headers: {
...headers,
Authorization: `bearer ${token}`, // 申请的 token
}
}));
const httpLink = new HttpLink({
uri: 'https://api.github.com/graphql', // 所有请求的 Url
batchInterval: 10,
opts: {credentials: 'cross-origin',},
});
const client = new ApolloClient({clientState: { resolvers, defaults, cache, typeDefs},
cache, // 本地数据存储, 暂时用不上
link: authLink.concat(httpLink)
});
function App() {
return (<ApolloProvider client={client}>
<LayoutRouter />
</ApolloProvider>
);
}
这里关注一下 uri 与 header 的设置就行了。
列表页的实现
列表的展示,链接可点
列表的展示,主要就涉及到文章标题、文章发布日期、关联 Issue、相关动态(赞,评论数量什么的),简略版的如我写的这样;
最难的,就是写 Graphql 查询语句了,但幸好有了 Graphql Explorer 的存在,我们可以无限次的尝试自己写的 sql 语句,最后版的 sql 与上面的相似,只不过涉及到动态传参与 graphql-tag 转化:
import gql from 'graphql-tag';
import {OWNER, PROJECT} from '../../configs/constants';
/**
* @param pageBefore:向前查的标志节点
* @param pageAfter:向后查的标志节点
* @param pageFirst: 向前查的条数
* @param pageLast: 向后查的条数
* 说明:查询时,pageFirst 与 pageAfter 设置值,即为向后翻页;pageLast 与 pageBefore 设置值,即为向后翻页
*/
export const sql = gql`
query Blog($pageFirst: Int, $pageLast: Int, $pageBefore: String, $pageAfter: String){repository(owner: ${OWNER}, name: ${PROJECT}) {
sshUrl
issues(first: $pageFirst, last: $pageLast, states:OPEN, before: $pageBefore, after: $pageAfter, orderBy: {
field: CREATED_AT
direction: DESC
}, filterBy: {createdBy: "closertb"}) {
totalCount
edges {
cursor
node {
title
url
createdAt
updatedAt
reactions(first: 100) {
totalCount
nodes {content}
}
}
}
}
}
}`;
上面这段语句的语义化还是相当明显了,就不再过多说明了。然后直接采用 apollo 提供的查询 hooks:useQuery,通过传入 sql 语句与查询变量,hooks 将返回三个值(loading, error, data),我们优先处理错误,然后处理请求中的状态,当没有错误并请求完成的时候,展示列表,原理很简单,直接看代码:
export default function Blog() {const query = { pageFirst: 10};
const {loading, error, data} = useQuery(sql, {variables: query});
if (error) {return (<p>{error.message || '未知错误'}</p>);
}
if (loading) {return (<p>loading</p>);
}
const {repository: { issues: { totalCount = 0, edges = [] } } } = data;
return (
<div className="list-wrapper">
<ul>
{edges.map(({ node: { title, url, updatedAt} }) => (<li className="block" key={url}>
<h4 className="title">
<Link to={`/blog/${url.replace(/.*issues\//, '')}`}>{title}</Link>
</h4>
<div className="info">
<div className="reactions">
<a href={url} target="_blank" rel="noopener noreferrer">Issue 链接 </a>
</div>
<span className="create-time">
{updatedAt}
</span>
</div>
</li>
))}
</ul>
</div>);
上下翻页的实现
列表的主要难点,主要体现在翻页的实现上。常用的 restful 接口,我们在查询时,只需传入 page 和 pageSize,即可获取要查询页的数据。但 Github 的 Graphql 接口并没有这样做,而是以另一种思维来做了这件事,把所有的 Issue 整理在一张时间轴上(如下图所示),每一个 Issue 都作为一个节点(用 cursor 标识),举例说,当我们查最近(first)的 10 条,如果我们不传标识,接口会默认以第一条 issue 作为标识,取最近的 10 条:而如果传了一个有效的标识,接口会以这个标识往后取最近的 10 条,很简单有没有。现在就来实现翻页的代码。
这里我们采用了 useState, useRef, useCallback 三个 hooks 来实现
export default function Blog() {const [param, setParam] = useState({pageFirst: PAGE_SIZE, current: 1});
const pageRef = useRef();
const setCursorBack = useCallback((data) => {const { repository: { issues: { edges} } } = data;
pageRef.current = {pageBefore: edges[0].cursor,
pageAfter: edges[edges.length - 1].cursor
};
});
const setNextPage = useCallback(dir => () => {const { pageAfter, pageBefore} = pageRef.current;
// dir: true 为向下,false 为向上
setParam(dir ?
{pageFirst: PAGE_SIZE, pageAfter, current: param.current + 1} :
{pageLast: PAGE_SIZE, pageBefore, current: param.current - 1});
});
const {current, ...query} = param;
// ... 接上面的代码
// 回调存储表要的临时变量
if (!loading && !error && callback) {setCursorBack(data);
}
return (
<div className="list-wrapper">
<ul>
{/* ... 接上面的代码 */}
</ul>
<div className="page-jump">
{current !== 1 && <a className="last" onClick={setNextPage(false)}> 上一页 </a>}
{current < (issues.totalCount / PAGE_SIZE) && <a className="next" onClick={setNextPage(true)}> 下一页 </a>}
</div>
</div>);
}
上面代码仅是思路,具体实现请参看源码。大概说一下,用了 current 来存储当前的查询参数和当前 页码,通过 setParam 触发翻页查询,使用一个 ref 来存储当前列表的头部和尾部标识位,用于下一次翻页查询。
详情页的实现
详情页的实现比列表页更简单,在 Github V3 Restful 接口中,返回的 issue 详情,返回的 body 是 md 格式字符串,你需要自己转码才能在页面上展示。而在 Graphql 接口的返回里,你可以选择 md 格式,也可以选择 html 格式的返回值,当然聪明点的,都会直接选择 html,然后展示样式直接复用 github 的文章样式。
但除了文章详情,更友好的交互,还会在页末展示上一篇,下一篇这种。在 api 中这个也是没有现成接口的,但我们可以用类似上面翻页的思路来实现,我们以当前文章为标识位,查找上一篇和下一篇文章列表。在 Restful 接口中,这种我们一般都是先查出详情,然后再发送请求查上一篇和下一篇。这时候 graphql 接口的 优势 又体现的淋漓尽致,看下面代码实现, 查询代码较长,作用分别是查询当前文章详情(issue),查询上一篇文章(last),查询下一篇文章(next),一次查询,做三件事:
/**
* @param: number 文章索引编号
* @param: cursor 当前文章标识,用于查上一篇,下一篇
*/
export const sql = gql`query BlogDetail($number: Int!, $cursor: String) {repository(owner: ${OWNER}, name: ${PROJECT}) {issue(number: $number) {
title
url
bodyHTML
updatedAt
comments(first:100) {
totalCount
nodes {
createdAt
bodyHTML
author {
login
avatarUrl
}
}
}
reactions(first: 100) {
totalCount
nodes {content}
}
}
last: issues(last: 1, before: $cursor, orderBy: {
field: CREATED_AT
direction: DESC
}, filterBy: {createdBy: "closertb"}) {
edges {
cursor
node {
title
url
}
}
}
next: issues(first: 1, after: $cursor, orderBy: {
field: CREATED_AT
direction: DESC
}, filterBy: {createdBy: "closertb"}) {
edges {
cursor
node {
title
url
}
}
}
}
}`;
因为使用的是 react,我们直接拿到了详情的 html,所以使用了 dangerouslySetInnerHTML 这个属性。具体实现代码如下:
function RelateLink({data, className}) {const { edges = [] } = data;
if (edges.length === 0) {return null;}
const {cursor, node: { title, url} } = edges[0];
return (<Link className={className} to={`/blog/${url.replace(/.*issues\//, '')}?cursor=${cursor}`}>{title}</Link>);
}
export default function BlogDetail({location: { pathname, search = ''} }) {const number = pathname.replace('/blog/', '');
if (typeof number !== 'string' && typeof +number !== 'number') {return <div className="content-waring"> 路径无效 </div>;}
// 提取 cursor
const cursor = search ?
search.slice(1).split('&').find(item => item.includes('cursor=')).replace('cursor=', '') :
undefined;
const param = {number: +number, cursor};
const {loading, error, data = {} } = useQuery(sql, param);
// ...loading , error 处理什么的
const {repository: { issue: { title, url, bodyHTML, updatedAt}, last = {}, next = {} } } = data;
return (<div className={style.Detail}>
<div className="header">
<h3 className="title">{title}</h3>
<div className="info">
<a href={url} target="_blank" rel="noopener noreferrer">issue 链接 </a>
<span> 更新于:{DateFormat(updatedAt)}</span>
</div>
</div>
<div className="markdown-body" dangerouslySetInnerHTML={{__html: bodyHTML}} />
<div className="page-jump">
<RelateLink data={last} className="last" />
<RelateLink data={next} className="next" />
</div>
</div>
);
}
至此,一个简单的博客功能就完成了。
其他
- 统一错误的处理
- 统一 loading 动画的处理
- 博客更新很慢,请求接口结果缓存也是很重要的
- 移动端的适配
- 评论什么的展示
这些都是一些很平常的方案,有兴趣的可查看源码;
Github Action 构建打包
Github 去年出的新功能 - 持续集成服务,作为前端的我们,可用于打包构建部署静态资源。如果你还不了解,可自行 Goggle(baidu)一下或查看官方文档。而关于 github page 项目自动打包部署,可查看这篇文章了解, 只提醒这两点,就是 user pages 站点(xxx.github.io),这个 pages 的静态资源没法选资源和资源文件夹,静态资源默认部署到 master 分支根目录的,所以资源推送是配置要注意。
快速搭建属于你的博客
项目 Copy
可采用下面两种方式:
- create-doddle 脚手架:
npx create-doddle github yourblogName
- git 直接 clone 项目 blog 分支
git clone -b blog https://github.com/closertb/closertb.github.io.git
然后:
npm i
github 项目创建
如果你和我一样,有现成的 pages(特指:xxx.github.io), 那你可以忽略这一步。这个项目将用于 issues 的创建(数据仓库),博客源码的存放(我新建了 blog 分支),博客静态资源的部署(master 分支)。愿你以前就存在一个用 issue 写博客的好习惯。搬运博客文章,我至少搬了一天。如果没有 github pages,那你还需要去创建一个 pages 项目,步骤很简单,看这里,一个普通项目也行,但需要开通 pages 功能。
修改配置
- 修改 configs/constants 文件中的配置 :
/* Github 个人信息相关 */
export const SITE_NAME = '网站标题'; // 网站标题
export const SITE_ADDRESS = '你网站网址'; // 网站地址,可为空
export const SITE_MOTTO = '一个落魄前端工程师'; // 一句段子,可为空
export const OWNER = 'xxx'; // 你 github 名
export const PROJECT = '"xxx.github.io"'; // 你 issue 项目名, 就是上一步创建的项目名,注意用 pages 做项目名时,需要用双引号括起来
// 由于 github 安全保护规则,token 不能暴露到仓库中,但又因为我们申请的 token 只是一个只读 token,所以这里使用了简单函数进行了对称加解密,以绕开规则;export const TOKEN = '你申请的 token,参见申请权限章节';
export const GITHUB_URL = 'https://github.com/xxx'; // 你的 github 主页地址
然后 npm start; 项目在本地已经能跑起来了,看看数据是否和你 issue 的吻合。
- 修改.github/workflow/nodejs.yml 构建配置,具体可参见上一节的构建打包:
主要修改点就是你监听的分支、token 变量名与构建结果要推送的地址;
on:
push:
branches:
- blog // 你源代码分支
- name: deploy
uses: peaceiris/actions-gh-pages@v2.5.0
env:
ACTIONS_DEPLOY_KEY: ${{secrets.Deploy_Access_Token}}
PUBLISH_BRANCH: master // pages 只能推 master 分支
PUBLISH_DIR: './dist'
- git init, 然后设置到你自己的项目上去,然后 push, 等待构建结果,完成,你的 pages 仓库应该就有你构建后的代码了,访问 xxx.github.io, 就能看到你的线上项目了;
- 至此,完成。如果你还需要设置 CNAME 解析,可在 public 文件夹添加 CNAME 文件,打包构建时会自动给你部署到根目录;
写在最后
如果你还有任何疑问,可在这篇文章下留下你的评论