ツクログネット

Gatsby+Contentfulブログの記事一覧をもっと見るボタンで進める

eye catch

ページを生成する

まずはページを生成します。Gatsbyでは、gatsby-node.jsファイルが読み込まれることで動的にページが生成されます。

では、gatsby-node.jsファイルに動的にページを生成するコードを書きます。

gatsby-node.js
const path = require('path');
const { JSDOM } = require('jsdom');

// 省略

// 動的にページを生成するコード
exports.createPages = async ({ graphql, actions }) => {
	const { createPage } = actions

	// ここから必要なページを生成するコードを書いていく
}

ページを生成するには、actionsから展開したcreatePage関数を使います。createPage関数はGatsbyのAPIであり、記事一覧や個別記事、404等のあらゆるページを動的に生成します。

今回は記事一覧ページの生成に焦点を当てます。

まず、GraphQLを使ってcontentfulのAPIにぶら下がってる全記事データを引っ張ってくる命令をし、その結果を待ちます。

gatsby-node.js
// 省略

exports.createPages = async ({ graphql, actions }) => {
	const { createPage } = actions

	const pagedResult = await graphql(`
				query {
					allContentfulBlogPost(
						sort: { fields: publishedAt, order: DESC }
						filter : ''
					) {
						edges {
							node {
								title
				                category {
				                	name
				                }
				                tags {
				                	name
				                }
				                slug
				                content {
				                  content
				                  childMarkdownRemark {
				                    html
				                  }
				                }
				                headerImage {
						          file {
						            url
						          }
						        }
				                publishedAt(formatString: "YYYY/MM/DD")
							}
						}
					}
				}
			`);
}

で、返ってきた結果からMarkdownの全記事データが入った配列を取得します。配列名はpagedArticlesとします。

gatsby-node.js
// 省略

exports.createPages = async ({ graphql, actions }) => {
	const { createPage } = actions

	// 省略

  const pagedArticles = pagedResult.data.allContentfulBlogPost.edges.map((edge) => edge.node);
}

で、pagedArticles配列のmapメソッドを呼び出し、pagedArticles配列から新たに、記事の概要に必要なデータを含めたarticleSummaries配列を作成します。

gatsby-node.js
// 省略

exports.createPages = async ({ graphql, actions }) => {
	const { createPage } = actions

	// 省略

  const articleSummaries = pagedArticles.map(({ tags, content, ...post }) => {
				const pagePath = `${new Date(post.publishedAt)}/ ${post.slug}`
				const html = content.childMarkdownRemark.html;
				const dom = JSDOM.fragment(html);
				const excerpt = dom.textContent.slice(0, 100) + '…';

				return {
					...post,
					tags: tags || [],
					pagePath,
					excerpt
				};
			});
}

最後に、createPage関数で記事一覧ページを生成します。

gatsby-node.js
// 省略

exports.createPages = async ({ graphql, actions }) => {
	const { createPage } = actions

	// 省略

  createPage({
				path: `${basePath}`,
				component: path.resolve('./src/templates/blog_page.js'),
				context: {
					articleSummaries,
					basePath
				}
			});
}

createPage関数を呼び出すときは、引数として次の4つのプロパティを持つオブジェクトを渡します。

  • path
  • component
  • context

これらのプロパティについては下記の通りです。

プロパティ名
path ページのパスを設定します。今回は、記事一覧ページをルートページにすることを想定するため、/に設定します。
component ページに適用するテンプレートのファイルパス(ファイルの保存場所)を設定します。テンプレートについては後述します。
context ページに渡したいデータをオブジェクト形式で設定します。今回は、オブジェクトのプロパティに全記事の概要データが入ったarticleSummaries配列とトップページのパスbasePathを持たせています。

これで、gatsby-node.jsファイルが読み込まれると記事一覧ページが生成されるようになりました。

ですが、今のままでは通常の記事一覧ページに加え、カテゴリーやタグ別の記事一覧ページを生成することになった場合、先ほど書いたコードと同じようなコードをずらずらぁ~と書くことになってしまいます。

そのため、これまでに書いたコードをcreateArticleListPageという名前の関数にします。

gatsby-node.js
// 省略

exports.createPages = async ({ graphql, actions }) => {
	const { createPage } = actions

	const createArticleListPage = async (basePath, filter) => {

			const pagedResult = await graphql(`
				query {
					allContentfulBlogPost(
						sort: { fields: publishedAt, order: DESC }
						${filter ? 'filter: ' + filter : ''}
					) {
						edges {
							node {
								title
				                category {
				                	name
				                }
				                tags {
				                	name
				                }
				                slug
				                content {
				                  content
				                  childMarkdownRemark {
				                    html
				                  }
				                }
				                headerImage {
						          file {
						            url
						          }
						        }
				                publishedAt(formatString: "YYYY/MM/DD")
							}
						}
					}
				}
			`);

			const pagedArticles = pagedResult.data.allContentfulBlogPost.edges.map((edge) => edge.node);

			const articleSummaries = pagedArticles.map(({ tags, content, ...post }) => {
				const pagePath = `${new Date(post.publishedAt)}/ ${post.slug}`
				const html = content.childMarkdownRemark.html;
				const dom = JSDOM.fragment(html);
				const excerpt = dom.textContent.slice(0, 100) + '…';

				return {
					...post,
					tags: tags || [],
					pagePath,
					excerpt
				};
			});

			createPage({
				path: `${basePath}`,
				component: path.resolve('./src/templates/blog_page.js'),
				context: {
					articleSummaries,
					basePath
				}
			});
	}

	await createArticleListPage('/');
}

この変更により、引数としてページのパスとgraphqlのfilterを与えて関数を呼び出すと、カテゴリーやタグ別の記事一覧ページの生成も行うことができます。

例えば、通常の記事一覧ページの他にカテゴリー別の記事一覧を生成するのであれば、下記のようにcreateArticleListPage関数を呼び出します。

gatsby-node.js
// 省略

exports.createPages = async ({ graphql, actions }) => {
  // 省略

  const categories = result.data.allContentfulBlogPost.allCategories;

  // 省略

  // 通常の記事一覧ページ生成
  await createArticleListPage('/');

  // カテゴリー別の記事一覧ページ生成
  for(const category of categories) {
		await createArticleListPage(`/category/${category}`, `{category: {name: {eq: "${category}"}}}`);
	}
}

生成した記事一覧ページの内容を表示する

現時点ではページを生成しただけであり、設定したパスにアクセスしても何も表示されません。

生成したページを表示するには、そのページに適用するtemplateファイルを作成します。

今回は、記事一覧ページに適用するtemplateファイルをblog_page.jsというファイル名で作成します。

blog_page.js
import React from 'react'
import Layout from "../components/layout"
import ArticleSummaries from '../components/ArticleSummaries'

const BlogPage = (props) => {
  return (
    <Layout>
      <ArticleSummaries
        className="article-summaries"
        articleSummaries={props.pageContext.articleSummaries}
      />
    </Layout>
  );
};

export default BlogPage

BlogPageコンポーネントは、先ほどのページ生成時に設定したcontextをpropsとして受け取ります。

この受け取ったpropsをそのままArticleSummariesコンポーネントに渡します。

ArticleSummariesコンポーネントは記事一覧を表示するコンポーネントです。作成してください。

ArticleSummaries.js
import React from 'react'
import Card from './Card'

const ArticleSummaries = ({ className, articleSummaries }) => {
	return (
		<>
			<ArticleSummaries className={className}>
				{	
		       		articleSummaries && articleSummaries.map((a, i) => (
		        		<Card
                  className="article-summary"
                  key={i}
                  image={a.headerImage.file.url}
                  title={a.title}
                  //text={}
                  link={`/${articleSummary.publishedAt}/${articleSummary.slug}`}
                />			   
		        	))
		        }
		    </ArticleSummaries>	    
	    </>
	)
}

export default ArticleSummaries

受け取ったarticleSummaries配列のmapメソッドを呼び出し、中身の記事概要のオブジェクトを展開し、その各プロパティをCardコンポーネントにpropsとして渡します。

Cardコンポーネントは記事一覧の1つ1つの概要を表示します。作成してください。

Card.js
import React from 'react'
import styled from 'styled-components'
import { Link } from 'gatsby'

const StyledCard = styled.div`
	background-color: var(--card-bg-color);
	border-radius: .25em;
	display: flex;
	flex-direction: column;
	height: 100%;

	& .card-body {
		display: flex;
		flex-direction: column;
		height: 100%;
		padding: 1em;
	}

	& .card-image {
		background-color: #333;
		border-radius: .25em;
		overflow: hidden;
		padding-top: 56.25%;
		position: relative;

		& img {
			position: absolute;
			top: 0;
		}
	}

	& .card-title {
		font-size: 1em;
		flex: 1;	
    line-height: 1.428;
		margin-bottom: 1em;

		&-link {
			text-decoration: none;
		}
	}
`

export const Card = ({ className, image, title, text, link, children }) => (
	<StyledCard className={className}>
		<div className="card-image">
			{image &&
		        <img src={image} alt="eye catch" />     
	        }
		</div>
	    <div className="card-body">
			<h3 className="card-title">
				<Link className="card-title-link" to={link}>
					{title}
				</Link>
			</h3>
			{text &&
				<p class="card-text">{text}</p>
			}
			{children}
		</div>
	</StyledCard>
)

Cardコンポーネントは、propsとしてimage、title、text、link、childrenを受け取ります。

今回は、articleSummaries配列内のarticleSummaryオブジェクトの各プロパティを受け取ることになります。

これでページに記事一覧が表示されるようになりました。

一度に表示する数を制限する

今のままでは、1つのページに全記事の概要が一覧表示されてしまっており、これでは一度に表示する数としてはあまりにも多いです。

なので、一度に表示する数を制限します。

ArticleSummariesコンポーネントのarticleSummariesプロパティに渡すprops.pageContext.articleSummaries配列のsliceメソッドを下記のように呼び出します。

blog_page.js
// 省略

const BlogPage = props => {
  // 省略

  return (
    <div>
      <ArticleSummaries 
		    className="article-summaries"
				articleSummaries={props.pageContext.articleSummaries.slice(0, 4)}
			/>
    </div>
  )
}

// 省略

props.pageContext.articleSummaries配列のsliceメソッドをslice(0, 4)のように呼び出すことで、props.pageContext.articleSummaries配列から0~3番目の要素を抽出した配列がArticleSummariesコンポーネントに渡るため、先頭から4つの記事概要が一覧表示されます。

もっと見るボタンを実装する

一度に表示する数を制限することはできましたが、今のままでは後続の記事概要を表示する手段がなく、4件の記事しか見ることができません。

そこで、記事一覧の下にもっと見るボタンを配置して、それを押すたびに後続の記事概要が指定した件数ずつ追加表示されるようにします。

ではまず、もっと見るボタンを表示します。もっと見るボタンはAutoPagerコンポーネントで表示します。

AutoPager.js
import React from 'react'
import styled from 'styled-components'
import { Button } from './Button'

export const AutoPager = ({ className }) => {
	return (
		<div css={`text-align: center`}>
			<Button className="more-see-button">もっと見る</Button>
	  </div>
	)
}

次に、AutoPagerコンポーネントをBlogPageコンポーネントに配置して、もっと見るボタンを表示します。

blog_page.js
// 省略
import { AutoPager } from '../components/AutoPager'

const BlogPage = props => {
  // 省略

  return (
    <div>
      <>
        <ArticleSummaries 
		      className="article-summaries"
				  articleSummaries={props.pageContext.articleSummaries.slice(0, 4)}
			  />
        <AutoPager className="more-link" />
      </>
    </div>
  )
}

// 省略

ボタンは表示されましたが、現時点では押しても何も起こりません。なので、ボタンを押すと次の4件が表示されるようにします。

まず、BlogPageコンポーネント内に新たなステートdisplayCountとそれを更新するseeMore関数を定義します。

blog_page.js
// 省略

const BlogPage = props => {
  // 省略

  const [displayedCount, setDisplayedCount] = useState(4)
  const seeMore = useCallback(e => setDisplayedCount(p => p + 4), [])

  return (
    <!-- 省略 -->
  )
}

// 省略

seeMore関数は実行される度にdisplayedCountを4つ増やします。

そして新たに、AutoPagerコンポーネントにhandleClickプロパティを追加し、そこに作成したseeMore関数を渡します。

AutoPager.js
// 省略

export const AutoPager = ({ className, handleClick }) => {
	return (
		<div css={`text-align: center`}>
			<Button className="more-see-button" onClick={handleClick}>もっと見る</Button>
	  </div>
	)
}
blog_page.js
// 省略

const BlogPage = props => {
  // 省略

  return (
    <div>
      <>
        <!-- 省略 -->

        <AutoPager
				  className="more-link"
				  handleClick={seeMore}
			  />
      </>
    </div>
  )
}

// 省略

この状態でボタンを押しても依然として件数は4のまま変わりません。なぜなら、props.pageContext.articleSummariesのsliceメソッドの第二引数を4に固定しているため、先頭から4つの要素を取り出し続けるからです。

そこで、この4の部分をdisplayedCountに変更します。displayedCountはボタンが押される度にseeMore関数によって4ずつ増えることから、0~4→0~8→0~12...のようにprops.pageContext.articleSummaries配列から要素を取り出すことができます。

この変更により、記事概要が4件ずつ追加表示されるようになるのです。

では、BlogPageコンポーネントを下記のように書き換えます。

blog_page.js
// 省略
import React, { useState, useCallback, useLayoutEffect } from 'react'

const BlogPage = props => {
  // 省略

  const [currentArticleSummaries, setCurrentArticleSummaries] = useState(props.pageContext.articleSummaries)

	useLayoutEffect(() => {
		setCurrentArticleSummaries(props.pageContext.articleSummaries.slice(0, displayedCount))
	}, [displayedCount, props.pageContext.articleSummaries])

  return (
    <div>
      <>
        <ArticleSummaries 
		      className="article-summaries"
				  articleSummaries={currentArticleSummaries}
			  />

        <!-- 省略 -->
      </>
    </div>
  )
}

// 省略

変更点はまず、props.pageContext.articleSummaries配列は、ボタンが押される度に状態が変わるため、currentArticleSummariesという名前のステートとして管理するようにします。

で、displayedCountが更新された、つまりボタンが押されたタイミングでuseLayoutEffectフックを呼び出し、currentArticleSummaries配列を元のprops.pageContext.articleSummaries配列からsliceで取り出した新たな配列に更新します。

更新したcurrentArticleSummaries配列はArticleSummariesコンポーネントのarticleSummariesプロパティに設定します。

これで終わり、、ではありません。あと1つすべきことがあります。

今のままでは、記事一覧が増えていく過程で、これ以上表示する記事がないにも関わらずもっと見るボタンが表示され続けてしまいます。

この問題を解決するために、まずBlogPageコンポーネント内に新たに残りの記事数を示すステートlimitを定義します。

blog_page.js
// 省略

const BlogPage = props => {
  // 省略

  const [limit, setLimit] = useState(null)

  return (
    // 省略
  )
}

// 省略

limitステートは、ボタンのクリックでdisplayedCountが増える度に減らしていきます。全記事数articleSummaries.lengthからdisplayedCountを引いた値で更新するのです。

blog_page.js
// 省略

const BlogPage = props => {
  // 省略

	useLayoutEffect(() => {
		// 省略

    setLimit(articleSummaries.length - displayedCount)
	}, [displayedCount, props.pageContext.articleSummaries])

  return (
    // 省略
  )
}

// 省略

そして、このlimitが0より小さくなったときにボタンを非表示にします。

AutoPager.js
// 省略

export const AutoPager = ({ className, limit, handleClick }) => (
  <div
    css={`
      text-align: center;
    `}
  >
    {limit > 0 && (
      <Button onClick={handleClick}>
        {`もっと見る(あと${limit}記事)`}
      </Button>
    )}
  </div>
);

AutoPagerコンポーネントに新たにlimitプロパティを追加後、Buttonコンポーネントを条件付きレンダーします。

これで、表示する記事があるときのみボタンが表示されるようになりました。

コンポーネントからロジックを分離する

BlogPageコンポーネント内がもっと見るボタンの実装によって散らかってしまいました。

そこで、BlogPageコンポーネントの見通しを良くするために、もっと見るボタンの実装に関するロジックを抽出し、それをカスタムフックに閉じ込めます。

まず、useAutoPagerフックを作成します。

useAutoPager.js
import { useCallback, useState, useLayoutEffect } from 'react'

const useAutoPager = (articleSummaries, postsPerPage) => {
	const [currentArticleSummaries, setCurrentArticleSummaries] = useState(articleSummaries)
	const [displayedCount, setDisplayedCount] = useState(postsPerPage)
	const [limit, setLimit] = useState(null)
	const seeMore = useCallback(e => setDisplayedCount(p => p + postsPerPage), [])

	useLayoutEffect(() => {
		if(displayedCount > articleSummaries.length) {
			return
		}

		setLimit(articleSummaries.length - displayedCount)

		setCurrentArticleSummaries(articleSummaries.slice(0, displayedCount))

	}, [displayedCount, articleSummaries])

	return { 
    articleSummaries: currentArticleSummaries,
    limit,
    seeMore
  }
}

export default useAutoPager

引数としてarticleSummaries配列と1ページに表示する最大投稿数postsPerPageを受け取ります。

中身はArticleSummariesコンポーネントのロジックそのままですが、currentArticleSummaries配列、limitステート、seeMore関数を持つオブジェクトを返します。

作成したuseAutoPagerフックはBlogPageコンポーネント内で呼び出します。

blog_page.js
// 省略

import useAutoPager from '../hooks/useAutoPager'

const BlogPage = props => {
  // 省略

  const autoPager = useAutoPager(props.pageContext.articleSummaries, 4)

  return (
    <div>
      <>
        <ArticleSummaries 
		      className="article-summaries"
				  articleSummaries={autoPager.articleSummaries}
			  />
        <AutoPager className="more-link" handleClick={autoPager.seeMore} limit={autoPager.limit}/>
      </>
    </div>
  )
}

export default BlogPage

これでBlogPageコンポーネントからロジックを切り離すことができました。

完成したコード

完成したコードはGitHubからご覧いただけます(近日公開)。