Edited at

Next.jsとFirebaseで更新の手間がかからないポートフォリオサイトを作ってみた


はじめに

ソフトウェアエンジニアとして就職して、2年半ほど経ちました。開発業務で得た知見を生かして、プライベートで簡単なツール開発、勉強会でのLT、Qiitaやブログへの記事執筆など様々な取り組みを行ってきたのですが、成果物が複数に分散する問題を抱えていました。

成果物を1箇所に集約することができれば、自分の得意なことや興味のあることが可視化できると考え、今回ポートフォリオサイトを作成したので、技術的な話をまとめたいと思います。


制作物

README.mdにビルドとデプロイ方法が記載しており、手順に従うことで同様のポートフォリオサイトが作れます。ご興味があれば、お試しください。

機能は以下の通りです。


  • 簡単なプロフィール表示

  • スキル表示

  • Qiita記事表示

  • GitHubリポジトリ表示

  • SpeakerDeckスライド表示


スキル表示

Screen Shot 2018-10-14 at 16.16.08.png


Qiita記事表示

Screen Shot 2018-10-14 at 16.16.51.png


SpeakerDeckスライド表示

Screen Shot 2018-10-14 at 16.17.41.png


コンセプト

ポートフォリオサイトを開発するにあたり、以下の2点の実現を目指しました。


  • 成果物を一覧性の高い形式で表示できる

  • 更新の手間がかからない


成果物を一覧性の高い形式で表示する

現状ではQiitaやはてなブログ、GitHub、SpeakerDeckなど複数サイトに成果物が分散しているので、ポートフォリオサイトから参照できることを目指しました。


更新の手間がかからない

新しい成果物を作成する度にポートフォリオを更新するのは面倒だと考えたので、APIを利用して、情報はなるべく外部から取得して、自動的に反映させることを意識しました。

また、ポートフォリオサイト側の更新が必要な項目もマークダウンで編集できるようにして、手間なく運用できるようにしました。


使用技術

最近気になっている技術を採用しました。プライベート開発の醍醐味ですね。


  • Next.js


    • Reactでサーバーサイドレンダリングするアプリケーションを構築するためのフレームワーク



  • Firebase Hosting


    • 静的コンテンツをホスティングするサービス



  • Cloud Functions for Firebase


    • イベントドリブンで関数を実行するサービス




アーキテクチャ

portfolio_architecture.png

ユーザーのポートフォリオサイトへのリクエストをトリガーに、Cloud Functions for Firebaseが起動し、外部APIへの情報取得を行い、Next.jsがサーバーサイドレンダリングを行い、サイトを表示します。

実装にあたっては以下のソースコード及び記事を参考にしました。


工夫した点


HTML5UP!のテンプレートをReactコンポーネント化

「デザインはできないけど、綺麗なサイトを作りたい」を実現するために既存のHTMLテンプレートを利用。HTML5 UP!ではレスポンシブデザインを施したHTML5テンプレートをCreative Commonsライセンスの下で提供しています。

作成したポートフォリオはStrataをベースにしており、ヘッダーやフッター、セクション等のパーツのReactコンポーネントを順に作成しました。

また、テンプレートに含まれるsassファイルでデザインを適用するためにNext.jsのSassプラグインを設定しました。

$ yarn add node-sass @zeit/next-sass


next.config.js

const withSass = require('@zeit/next-sass')

module.exports = withSass()



src/app/pages/index.js

import Layout from '../components/Layout'

// sassファイルの読み込み
import '../styles/main.scss'

export default () => (
<Layout/>
)



src/app/pages/_document.js

import Document, { Head, Main, NextScript } from 'next/document'

export default class MyDocument extends Document {
render() {
return (
<html>
<Head>
{ /*
Sassをビルドして出力されたcssファイルを読み込み
/*
}
<link rel="stylesheet" href="/_next/static/style.css" />
<link rel="stylesheet" href="/static/css/font-awesome.min.css" />
<script src="/static/js/jquery.min.js"/>
<script src="/static/js/jquery.poptrox.min.js"/>
<script src="/static/js/browser.min.js"/>
<script src="/static/js/breakpoints.min.js"/>
<script src="/static/js/util.js"/>
<script src="/static/js/main.js"/>
</Head>
<body className="is-preload">
<Main/>
<NextScript/>
</body>
</html>
)
}
}


サンプルコード:next.js/examples/with-next-sass at canary · zeit/next.js


マークダウンを利用した編集

サイトの一部をマークダウンで編集可能にしています。GitHubではWeb UI上でマークダウンの編集ができるので、ちょっとした更新はブラウザだけで完結します。

Screen_Shot_2018-10-14_at_15_02_36.png

実際に読み込んでいるマークダウンファイル


src/app/content/site_description.md

### Kentaro Matsushita

I'm working as a software engineer in Kanazawa, Japan.

マークダウンの利用は@zeit/next-mdxで実現しています。

$ yarn add @zeit/next-mdx

next.config.jsにプラグインの設定を記述します。


src/app/next/config.js

const withMDX = require('@zeit/next-mdx')({

extension: /\.md?$/
})

module.exports = withMDX()


他のjsファイルと同様にimportで読み込むと表示可能です。


src/app/components/Header.js

import React from 'react'

// マークダウンファイルを読み込み
import SiteDescription from '../content/site_description.md'

const Header = props => (
<SiteDescription/>
)

export default Header


サンプルコード:next.js/examples/with-markdown at canary · zeit/next.js


外部APIから成果物の情報を取得


外部APIのコール

getInitialPropsと呼ばれるメソッドで外部APIをコールします。このメソッドは子コンポーネントでは動作しないので、親コンポーネントでデータを取得して、propsで子コンポーネントにデータを渡します。


src/app/pages/index.js

import React, { Component } from 'react'

import config from '../lib/config'
import Layout from '../components/Layout'
import { getRepos, getSlides, getQiitaItems, getArticles } from '../lib/utils'
import '../styles/main.scss'

export default class Index extends Component {

// データを取得
static async getInitialProps() {
const repos = await getRepos(config.user.github, config.github.topic)
const slides = await getSlides(
config.user.speaker_deck,
config.speaker_deck.slides_count
)
const qiitaItems = await getQiitaItems(config.qiita.item_count)
const articles = await getArticles(
config.blog.feed_url,
config.blog.article_count
)
return {
repos: repos,
slides: slides,
qiitaItems: qiitaItems,
articles: articles
}
}

render() {
const { repos, slides, qiitaItems, articles } = this.props

// 子コンポーネントに取得したデータを渡す
return (
<Layout
repos={repos}
slides={slides}
qiitaItems={qiitaItems}
articles={articles}
/>
)
}
}


サンプルコード:next.js/examples/data-fetch at canary · zeit/next.js


GitHubリポジトリの取得

Search | GitHub Developer Guide

GitHubリポジトリの取得はSearch Repositories APIを利用しています。GitHubのリポジトリにトピックと呼ばれるタグ付け機能が提供されており、ポートフォリオサイトに表示したいリポジトリには「my-portfolio」というトピックを付与しています。

Search Repositories APIをコールするときは、自分のユーザー名とmy-portfolioトピックでフィルタリングをかけて、ポートフォリオに表示するリポジトリを制御しています。

Screen Shot 2018-10-14 at 1.41.48.png


Qiita記事の取得

Qiitaの記事取得はQiita APIを利用しています。認証中のユーザーの投稿をした記事を取得するAPIで件数を指定して、取得しています。

Qiita API v2ドキュメント - Qiita:Developer


SpeakerDeckスライドの取得 / はてなブログの記事取得

SpeakerDeckのスライドの取得はFeedとYQLというサービスを組み合わせて実現しています。SpeakerDeckはAPIが提供されていないので、代替方法を調査した結果、この方法に辿り着きました。

YQLはYahoo!が提供しているWebサービスでXMLをJSONに変換する機能があります。今回はSpeakerDeckのFeedをパースして、JSONで情報取得し、ポートフォリオサイトで表示しています。

YQLの利用は専用構文をパラメータに与えて、エンドポイントにリクエストするだけで非常に簡単です。動作確認はYQL - Yahoo Developer Networkのサイトから可能です。

Screen Shot 2018-10-14 at 2.02.53.png

スライドを2件取得するYQLのクエリ

select * from feed where url = 'https://speakerdeck.com/kentarom.atom' limit 2

結果は以下のような、JSONで返却されます。

{

"query": {
"count": 2,
"created": "2018-10-13T17:08:22Z",
"lang": "ja",
"results": {
"entry": [
{
"id": "tag:speakerdeck.com,2005:Talk/467924",
"published": "2018-10-04T02:00:05-04:00",
"updated": "2018-10-04T10:36:20-04:00",
"link": {
"href": "https://speakerdeck.com/kentarom/make-a-portfolio-site-with-firebase",
"rel": "alternate",
"type": "text/html"
},
"title": "Firebaseで
ポートフォリオサイトを作ってみた / Make a portfolio site with Firebase",
"content": {
"type": "html",
"content": "<img src=\"https://speakerd.s3.amazonaws.com/presentations/cd5930861b2a43e0aa93951a6be09068/preview_slide_0.jpg?467668\" alt=\"Preview slide 0\" /><div><p>社内LT</p></div>"
},
"author": {
"name": "Kentaro Matsushita"
}
},
{
"id": "tag:speakerdeck.com,2005:Talk/459561",
"published": "2018-08-17T13:26:53-04:00",
"updated": "2018-08-18T00:14:27-04:00",
"link": {
"href": "https://speakerdeck.com/kentarom/lets-challenge-automating-your-github-workflow-with-probot",
"rel": "alternate",
"type": "text/html"
},
"title": "ProbotでGitHubワークフローの
自動化に挑戦しよう / Let&#x27;s challenge automating your GitHub workflow with Probot",
"content": {
"type": "html",
"content": "<img src=\"https://speakerd.s3.amazonaws.com/presentations/c2fef73a15c34b239e11ad74d52d067e/preview_slide_0.jpg?459312\" alt=\"Preview slide 0\" /><div><p>2018/08/18 kanazawa.rb meetup #72 Lightning Talk</p></div>"
},
"author": {
"name": "Kentaro Matsushita"
}
}
]
}
}
}

はてなブログの記事取得も同様の方法で行っています。


APIアクセストークンの管理

GitHubやQiitaからデータ取得するためのAPIのアクセストークンの管理はローカル開発環境とCI環境のデプロイ実行環境によって、別の方法を使用してます。アクセストークンを安全に管理するために使い分けています。


dotenvの利用

ローカル開発環境ではアプリケーション起動やデプロイでdotenvを使用しています。Next.jsプラグインであるwith-dotenvでは、.envファイルにアクセストークンを記載することで、ソースコード上で環境変数としてアクセス可能です。


.env

GITHUB_API_TOKEN=

QIITA_API_TOKEN=

const githubToken = process.env.GITHUB_API_TOKEN

const qiitaToken = process.env.QIITA_API_TOKEN

next.js/examples/with-dotenv at canary · zeit/next.js


Cloud Functions for Firebaseの環境変数設定の利用

CI環境ではCloud Functions for Firebaseの環境変数設定を利用しています。.envファイルをGitHubのリモートリポジトリにコミットするのは望ましくないので、代替方法です。

Circle CI上であらかじめ環境変数を設定し、Cloud Functionsをデプロイするときに環境変数を読み込んでセットしています。

環境変数名は大文字が利用できないので、名前の差異をソースコードで吸収して、どちらでも動作するようにしています。

# デプロイ時のCloud Functionsへの環境変数設定

firebase functions:config:set token.github="$GITHUB_API_TOKEN" token.qiita="$QIITA_API_TOKEN"


サイト設定を集約

サイト設定は設定ファイルを用意し、そちらで一元管理しています。各種SNS情報を変更しても、1箇所変えれば、変更が全適用されます。


src/app/lib/config.js


module.exports = {
site: {
url: prod ? 'https://kentarom.com' : 'http://localhost:3000',
title: 'Kentaro Matsushita - @kentaro-m',
description: '',
image: 'ogp_image.png'
},
user: {
name: 'Kentaro Matsushita',
github: 'kentaro-m',
qiita: 'kentaro-m',
speaker_deck: 'kentarom',
twitter: '_kentaro_m',
facebook: 'kentaro.m9',
linkedin: 'kentarom'
},
skills: [
{ type: 'JavaScript (ES2015)', level: 80 },
{ type: 'Node.js', level: 70 },
{ type: 'AWS', level: 70 },
{ type: 'React.js', level: 60 },
{ type: 'HTML', level: 60 },
{ type: 'CSS', level: 60 },
{ type: 'Java', level: 50 },
{ type: 'Go', level: 40 },
{ type: 'TypeScript', level: 40 }
],
github: {
topic: 'my-portfolio'
},
qiita: {
item_count: '5'
},
blog: {
url: 'https://kentarom.hatenablog.com/',
feed_url: 'https://kentarom.hatenablog.com/feed',
article_count: '5'
},
speaker_deck: {
slides_count: '6'
}
}


CDパイプラインを整備

GitHubとCircle CIとFirebaseを連携させて、リモートのmasterブランチにコミットすると、デプロイされる環境を整備しました。


.circleci/config.yml

version: 2

jobs:
build:
docker:
- image: circleci/node:8.12.0
working_directory: ~/workspace

steps:
- checkout
- restore_cache:
key: yarn-{{ .Branch }}-{{ checksum "yarn.lock" }}
- run:
name: install dependencies
command: yarn install
- run:
# NOTE: Added a command to avoid build failures
# Node Sass could not find a binding for your current environment: Linux 64-bit with Node.js 8.x
name: rebuild node-sass
command: yarn add node-sass --force
# 以前ビルドした結果を削除 (デプロイごとに綺麗にする)
- run:
name: clean dist dir
command: yarn clean
# ビルド実行
- run:
name: build
command: yarn predeploy
- save_cache:
key: yarn-{{ .Branch }}-{{ checksum "yarn.lock" }}
paths:
- ~/workspace/node_modules
# ビルドした結果をdeployジョブでも利用するために一時保存
- persist_to_workspace:
root: .
paths:
- .

deploy:
docker:
- image: circleci/node:8.12.0
working_directory: ~/workspace

steps:
# buildジョブで一時保存されたビルド結果を読み込む
- attach_workspace:
at: .
- run:
# 環境変数の設定
name: set environment variables
command: yarn set-env
- run:
# Firebaseへデプロイ
name: deploy to firebase
command: yarn deploy:ci

workflows:
version: 2
build_and_deploy:
jobs:
- build
- deploy:
requires:
- build
# masterブランチのコミットのみデプロイ
filters:
branches:
only: master


Firebase CLIをCI環境で利用するためにはfirebase login:ciコマンドで認証トークンを取得し、CIの環境変数として設定し、デプロイ実行時にtokenオプションで指定する必要があります。

$ firebase deploy --token="$FIREBASE_TOKEN"

package.jsonのnpm-scriptsにビルド・デプロイに関するスクリプトを定義し、Circle CIの設定ファイルでは定義したものを呼び出すだけにしています。

{

"name": "portfolio",
...
"scripts": {
"dev": "next src/app",
"build": "next build",
"start": "next start",
"lint": "eslint src/app/**/*.js",
"preserve": "yarn build-public && yarn build-funcs && yarn build-app && yarn copy-deps && yarn install-deps",
"serve": "NODE_ENV=production firebase serve",
"predeploy": "yarn build-public && yarn build-funcs && yarn build-app && yarn copy-deps",
"deploy": "firebase deploy",
"deploy:ci": "firebase deploy --token=\"$FIREBASE_TOKEN\"",
"set-env": "firebase functions:config:set token.github=\"$GITHUB_API_TOKEN\" token.qiita=\"$QIITA_API_TOKEN\" token.sentry=\"$SENTRY_PUBLIC_DSN\"",
"copy-deps": "cpx \"*{package.json,yarn.lock}\" \"dist/functions\"",
"install-deps": "cd \"dist/functions\" && yarn",
"build-public": "cpx \"src/public/**/*.*\" \"dist/public\" -C && cpx \"src/app/static/**/*.*\" \"dist/public/static\"",
"build-funcs": "cpx \"src/functions/**/*.*\" \"dist/functions\"",
"build-app": "NODE_ENV=production next build \"src/app\"",
"clean": "rimraf \"dist/functions/**\" && rimraf \"dist/public\"",
"firebase-login": "firebase login"
},
...
}


改善箇所


ページ表示速度が遅い

現状ではページの表示速度が遅いので改善が必要です。

Screen Shot 2018-10-14 at 16.30.28.png

遅い理由は以下が考えられます。


  • Cloud Functions for Firebaseのデプロイリージョンがus-central1 (アイオワ)



    • asia-northeast1 (東京) に変更する



  • リクエストの度に外部APIをコールしている


    • 結果は頻繁に変わらないので、API実行結果をキャッシュする



  • Firebase Hostingのキャッシュ管理を利用できていない




機能追加


職務経歴を追加したい

経験したプロジェクトをタイムライン形式で表示できたら良いと考えています。


問い合わせフォームを追加したい

他の方のポートフォリオサイトを見ると、問い合わせフォームがあるところが多いので追加したいです。Netlify Formsのように簡単にフォームを導入できるサービスを探しています。

Forms | Netlify


さいごに

FirebaseとNext.js共に触れるのは初めてだったのですが、ドキュメントとサンプルコードが多いおかげでサクッと作れました。

今回、ポートフォリオサイトを実際に作ってみて、自分の成果物を1ページにまとめることができて、非常に満足しています。

ツイートやブログ等の発信も大切ですが、フロー型なので時間の経過とともに忘れ去られてしまいます。1ソフトウェアエンジニアとしての得意分野や興味のある分野をアピールするためには成果物を積み上げていくストック型のポートフォリオサイトの作成は有効だと感じました。