Nuxt.jsとContentfulとCanvasでぬるぬるポートフォリオ作った

* Webデザイナー/フロントエンドエンジニアを志望する学生としてポートフォリオの一つも作れないのはマズいかも
* 今Qiitaではポートフォリオを作るのが流行ってるっぽい

という理由で、ポートフォリオサイトを作ってみることにしました。

# つくったサイト

[https://work.wararyo.com/](https://work.wararyo.com/)

![wararyo'swork.gif](https://qiita-image-store.s3.amazonaws.com/0/74419/4f1966c9-fc15-2fa2-35f7-91c89f2801a3.gif)

(本当はもっと画質のいいGIF上げたいんですがQiitaに怒られたので諦めました…)

### リポジトリ
https://github.com/wararyo/wararyo-works

# TL;DR

* Nuxt.jsとContentfulを使用したSPA
* シームレスなページ遷移
* URL付きモーダルウィンドウ
* Canvasでインタラクティブな飾り
* PHPで無理やりOGP対応

# コンセプト

### コンテンツ

* 過去に作った作品を置くだけのサイト (プロフィール等なし)
* 曲、動画、写真などあらゆる種類のコンテンツを置く

僕がDTMも電子工作もゲーム制作もWeb制作もする人間なので、いろんなことに手を出す人間だというのを一番に押し出したいと考えました。
wararyoってどんな人間なんだろう、と思って訪れた人がwararyoの全容をなんとなく把握できる場所です。
プロフィールやお問い合わせなどはブログにあるため、今回は純粋に作品を表示するだけのサイトにしました。

### デザイン
* wararyoカラーのアクセントをどこかに設けたい
* Canvasを除くすべての要素に対してRetina対応
* 全画面ヘッダーを採用 なんかカッコいい文言を書く
* 作品はモーダルウィンドウで表示したい
* BootstrapやBuefyなど既存のCSSライブラリは用いない

デザインの詳細は[wararyo's work内のwararyo's workページ](https://work.wararyo.com/pick-up/wararyos-work)に書いてます

### 性能・機能

* とりあえず通常時60fpsは達成したい(モバイル・PC)
* ファーストビューを表示するのに必要な通信量500KB以内
* 作品に一意のURLを与えて気軽にシェアできるようにする
* OGP対応は絶対する

# CMS選び

初めは無難にWordpressを考えていましたが、新しいことに挑戦してみたかったので今流行りのヘッドレスCMS"**Contentful**"を使ってみることにしました。
Jekyllなども考えたのですが、新しい作品を追加するたびにサイトを生成しなければいけないのは、ポートフォリオとしては煩雑かなと思い見送りました。

結果としてContentfulは今回の要件に**ぴったり**でした。

例えばカテゴリーに固有の色を設定したい場合、WordpressではわざわざPHPを書くかAdvanced Custom Fieldなどのプラグインが必要でした。しかしContentfulでは全てを1から定義できるため、CategoryにColorフィールドを追加することも数秒でできます。
また、「作品一覧では画像をアイキャッチにしつつ、個別作品ページではYoutube動画をアイキャッチにしたい」といった複雑な要求にも対応できる柔軟さを持っています。

作品の詳細がMarkdownで記述できるのも個人的には推しポイントです。

### 互換性の面でContentfulに一抹の不安

多くのWordpress製サイトと違い、データベースは自分のサーバーではなくContentfulのサーバーが持っています。また、自分でコンテンツの枠組みから設定する形なので、ほかのCMSとの互換性も保証できません。Contentfulがサービスを終了した場合にはどうやってほかのサービスに移行すればいいのか、少々不安です。

# NuxtでモーダルウィンドウにURLを付与する

今回作ったポートフォリオのURL書式は`https://work.wararyo.com/{カテゴリのSlug}/{作品のSlug}`となっています。

Nuxtは`pages`ディレクトリ内のファイルに応じてRouterの設定を自動生成してしまうので、URLをこの書式にするために少々強引な方法をとりました。

``` :ファイル構成
...
┣pages
┃┣index.vue
┃┗index
┃ ┣_category.vue
┃ ┗_category
┃  ┗work.vue
...
```

``` Vue:_category.vue
<!-- 空 -->
```

``` Vue:_work.vue
<!-- 空 -->
```

vueファイルと同名のフォルダを作ることで、そのページの中に入れる**子ページ**を定義することができます。
子ページは`<nuxt-child>`タグで挿入することができるのですが、**今回は使用していません**。
そう、**子ページを定義しつつ、その子ページを使わない**という方法をとりました。
例えば`https://work.wararyo.com/game/eclair`にアクセスした場合、
`$route.params.category`に`game`が、
`$route.params.work`に`eclair`が代入されます。

あとは`$route`をwatchして`$route.params.work`が`undefined`でない時は該当するworkオブジェクトを取得したのちモーダルウィンドウに渡すだけです。

``` Vue:index.vue
<template>
  <section class="container">
    <work-modal :work="workObject"/>
    <!-- 省略 -->
</template>
<script>
export default {
  computed: {
    work () {
      if(this.$route.params.work === void 0) return "";
      return this.$route.params.work;
    },
    workObject () {
      if(this.work == "") return void 0;
      let work = this.work;
      let w = this.works.filter(function (item) {
        return item.fields.slug == work;
      });
      return w[0];
    }
    //...
</script>
```

**カードをクリックする->該当するURLへ移動する->**`$route.params.work`**に値が代入される->モーダルウィンドウが表示される**、というスタイルです。
レスポンスの遅さが心配されるところですが、さすがはVue、全く遅延は感じません。

``` Vue:WorkCard.vue
<!-- 作品一覧で使われる作品カード -->
<template>
	<nuxt-link v-if="post !== void 0"
		:to="makeLink($route.params.category,post.fields.slug)"
		class="work-card">
		<!-- 省略 -->
	</nuxt-link>
</template>
<!-- ... -->
```
そんなわけで作品カードの実体はただの`nuxt-link`(レンダリング時にaタグに変換される)です。`@click`属性は使用していません。

# Canvas製「魔法のじゅうたん」
wararyoカラーの飾りを作りました。一人で勝手に「魔法のじゅうたん」と呼んでいます。
マウスを置くと頂点が集まってきます。

![じゅうたん.gif](https://qiita-image-store.s3.amazonaws.com/0/74419/34df1a26-598b-c851-1b7c-ae9ddf785b3c.gif)

既存のライブラリを使い楽をしたかったのですがいいものが見つからなかったので自前で書きました。
偶然見つけたakm2さんの"Abstriangulation"という作品がイイカンジだったのでForkして作りました。MIT Licenseなのできっと大丈夫
[http://jsdo.it/akm2/wTcC](http://jsdo.it/akm2/wTcC)

### 擬似3D

3Dっぽい見た目をしていますが、実際は完全2D処理です。

* 上の方の透明度を下げてフォグを表現
* 上から下にいくにつれて頂点の縦方向の間隔を大きくし、遠近感を表現
* 下の頂点の方がマウスに追従しやすい

これらの工夫により3Dっぽい見た目を再現しています。
これらを全て無効にしたらかなり安っぽいです。

![全て無効な2Dじゅうたん](https://qiita-image-store.s3.amazonaws.com/0/74419/9e02bc56-7620-e176-6314-64c29b697498.png)

### iOS対応

iOS Safariはスクロールするとアドレスバーが伸縮しますが、その度に`resize`イベントが発生します。
`resize`が発生するたびに頂点を再配置する仕様にしていたら、スクロールするたびに魔法のじゅうたんが激しく荒ぶるようになってしまったので、幅が同じ時は頂点の再配置を行わないようにしました。
また、iOS Safariでは`mousemove`イベントは発生せず、代わりに`touchmove`イベントが発生します。
UAで判別して、iOSの場合は`touchmove`イベントを使用するようにしました。

``` Javascript:navigation-canvas.js
var userAgent = window.navigator.userAgent.toLowerCase();
var isIOS = userAgent.indexOf('iphone') > -1 ||userAgent.indexOf('ipad') > -1 ||userAgent.indexOf('ipod')  > -1;
var moveEventName  = isIOS ? "touchmove" : "mousemove";

//...
function init() {
    window.addEventListener('resize', resize, false);
    resize(null);
    document.addEventListener(moveEventName,mouseMoved);
    //...
}

function resize(e) {
    //Set Context
    context = canvas.getContext('2d');
    context.lineWidth = 0.6;
    context.strokeStyle = LINE_COLOR;
    if(canvas.width === window.innerWidth) return;//幅が一緒なら以降の処理はしない
    canvas.width = window.innerWidth;
    canvas.height = CANVAS_HEIGHT;
    //以降、頂点の再配置処理
```

# PHPで無理やりOGP
皆さんご存知の通り、JavascriptでOGPタグを設定しても反映されません。静的ページにしたり、SSRするなどの対策が一般的です。
しかし、今回は`nuxt build --spa`で生成した`index.html`を`index.php`にして、PHPでOGPタグを埋め込むことにしました。

毎回置き換えるのは面倒なので自動化スプリプトを組みました。頭悪い置換っぷりですが所詮は自分のために自分で作っているサイトです。手を抜けるところはどんどん抜いていきましょう。

``` JS:nuxt.config.js
module.exports = {
  head: {
    title: 'wararyo\'s work',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'og:site_name', property: 'og:site_name', content: 'wararyo\'s work' },
      { hid: 'og:type', property: 'og:type', content: 'website' },
      { name: 'twitter:card', content: 'summary_large_image' },
      { name: 'twitter:site', content: '@wararyo' },
      { content: 'replace' } //これをOGPに置き換える
    ],
    //...
```

``` json:package.json
  "scripts": {
    "dev": "nuxt --spa",
    "build": "nuxt build --spa && node ogp-replace.js dist/index.html '<meta data-n-head=\"true\" content=\"replace\">' ogp.php dist/index.php",
  },
```

``` JS:ogp-replace.js
if(process.argv.length < 6) {
	console.log("Usage: node "+process.argv[1]+" [file_in] [replace_from] [replace_file] [file_out]");
	return 0;
}

var fs = require('fs');
var fileInPath = process.argv[2];//置換を行いたいファイル
var replaceFrom = process.argv[3];//置換を行いたいキーワード
var replaceTo = fs.readFileSync(process.argv[4], 'utf-8');//置換先
var fileOutPath = process.argv[5];//保存先
fs.readFile(fileInPath, 'utf8', function (err,data) {
  if (err) {
    return console.log(err);
  }
  var result = data.replace(replaceFrom, replaceTo);

  fs.writeFile(fileOutPath, result, 'utf8', function (err) {
     if (err) return console.log(err);
  });
  if(fileInPath != fileOutPath) fs.unlinkSync(fileInPath);//置換前のファイル消す
});
```

``` PHP:ogp.php
<?php 
$DESCRIPTION_MAX_LENGTH = 96;
//正しいURLなら$matches[1]にカテゴリーSlugが、$matches[2]に作品Slugが入る
$matched = preg_match('/\/(.+)\/(.+)\/?$/',$_SERVER["REQUEST_URI"],$matches);
if($matched){
	$work_slug = $matches[2];
	$url = "https://cdn.contentful.com/spaces/_YOUR_SPACE_ID_/environments/master/entries/?access_token=_YOUR_ACCESS_TOKEN_&content_type=work&fields.slug=$work_slug";
	$json = file_get_contents($url);
	if($json) {
		$obj = json_decode($json);
		$ogp = new stdClass();
		$ogp->title =  "wararyo's work - ".$obj->items[0]->fields->title;
		$ogp->image = 'https:'.$obj->includes->Asset[0]->fields->file->url;
		$ogp->description = 
			str_replace(PHP_EOL, '', 
				htmlspecialchars(
				mb_substr($obj->items[0]->fields->content,
				0,DESCRIPTION_MAX_LENGTH)));
	}
}

 ?>
<meta name="description" content="<?=isset($ogp->description)?$ogp->description:'wararyoのポートフォリオサイトです。'?>">
<meta property="og:title" content="<?=isset($ogp->title)?$ogp->title:'wararyo\'s work'?>">
<meta property="og:image" content="<?=isset($ogp->image)?$ogp->image:'https://work.wararyo.com/ogp.jpg'?>">
<meta property="og:description" content="<?=isset($ogp->description)?$ogp->description:'wararyoのポートフォリオサイトです。'?>">
```
# その他気づいたこと

### Contentfulでリサイズした画像はキャッシュされるっぽい

Contentfulは画像の後ろに`?w=640&fm=jpg`などと書くことでサイズやフォーマットの変換を行ってくれるのですが、「**もしかしたら画像をリサイズする時間より、リサイズされてない画像をダウンロードする方が早いのでは…？**」という疑問がありました。

しかしそれは杞憂でした。
`?w=640&fm=jpg`を追記した後に初回アクセス->ブラウザのキャッシュを消去して再読み込み
という実験をしてみたところ、両方ともブラウザ側のキャッシュが無いにも関わらず、後者の方が明らかに高速でした。
Contentfulサーバー内部でキャッシュされていると考えられます。

### Canvasは存在するだけで重い

魔法のじゅうたんを描画しながらYoutubeを再生すると重すぎてYoutubeがカクカクになったりしました。
モーダル表示時は描画処理を停止しても相変わらず重く、やむなく`visibility:hidden;`で非表示にすることにしました。謎です。

# 結果

ContentfulをNuxt.jsを使って、自分が求めていた通りのポートフォリオサイトを作ることができました！

改善できるポイントとしては、

* PICK UP!!ページにて、作品のカテゴリが分からない(PICK UP!!だけは専用ページにすることも検討)
* 背景もなんかカッコよく動いて欲しい(マシン負荷と相談しながら)
* カテゴリー切り替え時にフェードインだけでなくフェードアウトもしてほしい(もうちょっとでできそう)
* ロリポップにはもうちょっとTTFBを早くしていただきたい(スタンダードプランに変更検討中)
* ファビコンが見辛い

などがあります。気ままに改善していきましょう。

### ページ速度

Auditsの結果は以下の通りです。

<img width="633" alt="Audits" src="https://qiita-image-store.s3.amazonaws.com/0/74419/9ac85597-7083-aa11-ca9f-0acdf42d14ee.png">

まずまずですね。PWA対応してみるのも面白そうです。

ファーストビューを表示するのに必要なデータ量は**453KB**でした。ギリギリ目標達成です。