乐趣区

泡一杯茶用React-Github生态自定义你的博客

首发于:个人博客:吃饭不洗碗

前言

国庆七天,就这样没了,以不想看人海的借口(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

可采用下面两种方式:

  1. create-doddle 脚手架:
 npx create-doddle github yourblogName
  1. 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 功能。

修改配置

  1. 修改 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 的吻合。

  1. 修改.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'
  1. git init, 然后设置到你自己的项目上去,然后 push, 等待构建结果,完成,你的 pages 仓库应该就有你构建后的代码了,访问 xxx.github.io, 就能看到你的线上项目了;
  2. 至此,完成。如果你还需要设置 CNAME 解析,可在 public 文件夹添加 CNAME 文件,打包构建时会自动给你部署到根目录;

写在最后

如果你还有任何疑问,可在这篇文章下留下你的评论

退出移动版