本記事は Vue.js #1 Advent Calendar 2017 の23日目の記事です。
TL;DR
- soussune(そうっすね)という技術系ポッドキャストのサイトをJekyllで構築してます
- よくVueの話をしてるのに肝心のサイトでは使ってない。のでVue使いたい
- Nuxt.jsにNuxtentモジュール入れて、
nuxt generate
で静的サイトを生成してデプロイしました
ポッドキャストをやっている @trkw と @miyaoka の二人でこの一週間くらいスクラッチでサイトを作ってました。そこで実際にNuxt使ってみた話についていろいろ書いていこうと思います。
使用前・使用後
開発は8,9割程度終わった感じですが、現状まだ新サイトに移行してない状況です。
→移行しました。
旧サイト
新サイト
Jekyllとは
-
Rebuild や yatteiki.fm などのポッドキャストサイトが利用している、ruby製の静的サイトジェネレーター
- Rebuild: 16: Designing new Rebuild.fm (Naoya Ito, nagayama)
- Yattecast - Podcastサイトをつくるためのテンプレート
- ローカルで開発できるのでコーディングの効率がいい
- ポッドキャスト作りはいろいろめんどくさくてハードル高い。Yattecastが無かったらsoussuneも始まってなかった
- ただ、せっかくだから今どきのフロント技術を活かしてもっとやりこみたい
- Jekyll上にVueぶっこむこともできるがつらくなるのは目に見えてる。フロント技術のみでいきましょう
サイト構成
全体の構成はこんな感じのサイトです。
今回は、とくに打ち合わせもせずに二人で適当にやりたいところを実装してdiscordで話しながら進めていきました。なので、それぞれの話を書いていきます。
@trkwサイドの話: Nuxt化への道のり
Nuxtentで静的サイトを作る
今回soussuneでは Nuxtent を利用してサイトを構築しています。Jekyll、Hexoやその他の静的サイトジェネレーターを使うくらい簡単にコンテンツを扱うことができるツールです。始めるには、vue-cliでnuxtent-starterのテンプレートを使用します。
vue init nuxt-community/nuxtent-starter my-site
$ cd my-site
# install dependencies
$ npm install # Or yarn install
既存の環境に構築したい場合は、以下のように、nuxt.config.js内のmodulesに'nuxtent'を追加してもOKです。
module.exports = {
modules: [
'nuxtent'
]
}
ただし、@nuxtjs/axiosとは依存関係にあるので、こちらのインストールもする必要があります。
- https://github.com/nuxt-community/nuxtent-module
- https://github.com/soussune/soussune.com/blob/master/nuxtent.config.js
soussuneでは、nuxtent.config.jsで以下のようにepisodeとactorsのルーティングを定義しています。
const externalLinks = require('markdown-it-link-attributes')
module.exports = {
content: [
[
'episode',
{
page: '_id',
permalink: '/episode/:slug',
generate: [
// for static build
'get',
'getAll'
],
isPost: false
}
],
[
'actors',
{
page: '_id',
permalink: '/actors/:slug',
generate: [
// for static build
'get',
'getAll'
],
isPost: false
}
]
],
parsers: {
md: {
use: [
[
externalLinks, {
attrs: {
target: '_blank',
rel: 'noopener'
}
}
]
]
}
}
}
(※エピソードのルーティング、本当は複数形の /episodes/:slug
にしたかったのですが現行サイトで /episode
になっちゃってるのでそれを引きずってます)
ポッドキャストに必要なRSS機能が無いので、実装する対応
feedテンプレート
以下のようにNuxtのIssueをあげている人もいました。このIssueをあげられていた方は、@nuxtjs/sitemapや独自の実装を加えながら、atom.ejsにfeedのテンプレートを作っていてそこにデータをいれているパターン。
僕らもYattecastを利用していたので、Jekyllではこのatom.ejsのようにテンプレートを修正して対応していました
- https://github.com/soussune/soussune.github.io/blob/master/feed.xml
- このパターンはわかりやすくいいなぁと思っていたけど、Nuxtならconfigで管理してしまったほうがよいのではと考えました。
modules
modulesの作り方を勉強したので、テンプレートを意識せずnuxt.config.jsでObjectのみで管理しておきたいと思いました。nuxt.config.jsで管理すれば、Build時にFeed更新いけるんじゃないかとか考えながらsoussuneでは以下のように実装しました。
nuxt.config.jsには以下のようなObjectを作ってます。これは、npmのrssのサンプルに近い形で書いてます。
rss: {
title: 'soussune - エンジニアわいわいポッドキャスト「そうっすね」',
description: 'テクノロジーと世の中についてエンジニア達が雑談するポッドキャストです。',
feed_url: 'https://soussune.com/feed.xml',
site_url: 'https://soussune.com/',
image_url: 'https://soussune.com/images/itunes-artwork.jpg',
webMaster: 'soussune.user@email.com (soussune)',
managingEditor: 'soussune.user@email.com (soussune)',
copyright: '2017 soussune',
language: 'ja',
pubDate: 'Thu, 01 Jun 2017 04:00:00 GMT',
ttl: '60',
custom_namespaces: {
'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd',
'itunesu': 'http://www.itunesu.com/feed'
},
custom_elements: [
{ 'itunes:subtitle': 'エンジニアわいわいポッドキャスト「そうっすね」' },
{ 'itunes:author': 'そうっすね制作委員会' },
{ 'itunes:summary': 'テクノロジーと世の中についてエンジニア達が雑談するポッドキャストです。' },
{ 'itunes:keywords': 'soussune, tech, technology, keyboard, web, development, developer' },
{ 'itunes:owner': [
{ 'itunes:name': 'そうっすね制作委員会' },
{ 'itunes:email': 'soussune.user@gmail.com' }
]},
{ 'itunes:image': {
_attr: {
href: 'https://soussune.com/images/itunes-artwork.jpg'
}
}},
{ 'itunes:category': [
{_attr: {
text: 'Technology'
}},
{ 'itunes:category': {
_attr: {
text: 'Tech News'
}
}},
{ 'itunes:category': {
_attr: {
text: 'Software How-To'
}
}}
]},
{ 'itunes:explicit': 'no' }
]
},
rssItems: episodes.episode,
各rssItemsついて
- 以下のように各記事のmarkdownからfeedに必要な情報を配列にして返しています。ここは少しいい感じにリファクタしたいので、今後頑張っていきたい。
- https://github.com/soussune/soussune.com/blob/master/server/sitemap.js
成果物
最終的にこのような形で出力されます。
@miyaokaサイドの話: やりたい機能を作る
実装したUIその1: (独善的な)音声プレイヤー 🔈
今回の目玉機能ですね。ポッドキャストサイトなので主機能として音声プレイヤーがあります。普通は各ページごとにプレイヤーのインスタンスを配置しますが、これをサイト全体でシングルトン化しました。
要は SoundCloud 的なものと言えば分かりやすいでしょうか。あのサイトでは音源を再生するとボトム固定のプレイヤーで再生されます。それにより再生されたままの状態で他のページに遷移することができるので、サイト全体の音源を練り歩く体験性が実現されています。
海外のポッドキャストサイトを探してみたところ、このサイトなども上部に固定のプレイヤーとなっており同様の感じです。しかし、これが良いかというとユーザーの慣れや分かりやすさの問題が大きいため、独りよがりなデザインにならないようにという点には気をつける必要はあると思います。ただ今回は機能性を追求するとこうせざるを得ないなという思いがありました。
Webのプレイヤーというものは再生中にリンクをクリックしたりページ遷移してしまうと、いともたやすく再生がオワってしまいます。現行サイトでもIssueにしてました。このへんRebuildなんかだとイベントハンドリングしてちゃんと確認ダイアログ出してますね。そうなんです。確認出せばいいんです。でも結局このときはリンクを全部新規タブで開くように設定したので、とりあえず問題にならなくなりました。
で、改めて今回作ろうとなったとき、果たして確認を出せば良いのかという意識がありました。そうする必要が無いならそうする必要が無いように作ろうと。あと、とりあえずNuxtでサイト立ち上げてみたら、当たり前ですが現行サイトより遷移が速いので、__ガンガン遷移しまくりたい気持ち__が芽生えました。そうしたらもう、止められないですよね。。。
なのでプレイヤーはシングルトンで固定。ユーザーの遷移したい気持ちを妨げてはいけないでしょう。なんかいい話っぽいですが、ユーザーというか自分ですね。自分の気持ちが大事です。
プレイヤーとコントローラー
SoundCloudを見ればわかりますが、固定のプレイヤーがあり、さらに各ページ内にもプレイヤーがあります。そしてこの両者は同じ音源を操作、状態表示してます。つまり一つのプレイヤーと複数のコントローラーということになります。最初は単にプレイヤー兼コントローラーを作っていたのですが、分ける必要が出てきたのでそんな作りにしました。
そのへん繋ぎこむのは、そうですね。そうです。その通り。Vuexですね。よしなにやりましょう。
(開発途中のもの。2つあると分かりづらいので記事内では再生停止のみのボタンにした)
プレイヤー自作
これまでサイトに音声プレイヤー配置したいって言ったら、とりあえずググって良さげなライブラリ使うってのが普通だと思うんですが、HTML5のaudio要素にsrc指定するだけで再生できるわけだし、その制御周りを作るのはまさにVueの得意なところだと思うんですよね。
controls属性をつければブラウザベンダの標準コントローラーを表示して操作もできます。しかし、これも以前からの課題に思っていることがありました。
- 再生ボタンが小さいので大きくしたい
- シークバーはスマホでも操作しやすいように画面幅一杯というUIにしたい
というあたりですね。
特にポッドキャストの場合だと音源が2,3時間とか長い場合もあるので、シークバーが短いと狙ったところを再生するのが難しいのです。なのでまあ、シークバー→でっかく、再生ボタン→でっかく、という意識が強かったので作っていくことにしました。
アプリ志向
とはいえ、なんていうか、そもそもなんですが、自分は__webではあんま再生してない__んですよね…。今どきは基本スマホアプリで聴くものですし、アプリのほうが楽なんですよね。速度コントロールとか、15秒巻き戻したりとか。そうした操作に特化して設計されているのがアプリの強さです。
(iPhone標準のポッドキャストアプリでも十分ですが、サードパーティのOvercastとかだとチャプター機能にも対応していてもっと使いやすいです。うちのサイトもチャプター情報まで読み取れるか分からないですが、できたらチャプター表示と遷移の対応をしたいです)
でもWebってアプリじゃないですか。いや ドキュメント ですけど。Nuxtで作るってことはもうアプリじゃないですか。そこはもはやアプリという戦場に居るわけで、いつまでもドキュメント気分で居たら死んじゃうと思うんですよね。
でも、昔FlashやっててFlashサイト作ってたときはほんとアレでしたね。みんなただ独善的なアプリを作りたかっただけなんですよね。ドキュメントの気持ちなんて考えずに…。ドキュメントの心はすっかりもてあそばれて傷つきました。ユーザーさんも、僕らFlasherも傷つきました。あの時代を経て、HTML5、標準化。いい時代になったと思います。今ならドキュメントのこともしっかり考えられるくらいみんな大人になったと思います。我々が求めているのはドキュメントであり、そしてアプリなのです。
なのでまあwebで再生したくなるようなものを作っていけたらいいじゃないですか。作りましょうよ。
実装したUIその2: 検索機能 🔎
何故探すのか
サイトに検索機能ほしいなと思いました。
でもほんとに欲しいですか? 僕の場合いくつか理由があります。
- 単純にログが増えてきたので探したい
- 検索があればナビゲーションを作らなくて済む
- クライアントサイドで全部やれるんだからやりたい
検索対象としては、「タイトル、本文、トピック、出演者」といったデータをStoreに持っているので、そことマッチさせるだけです。
検索欄
話題になった dev.to のレイアウトを見ると、ヘッダ中央に検索欄ありますよね。なんかもうこれだけで、意味、感じちゃいますよね。感じられてますか、意味。つまりそこがナビゲーションの起点だというアレを感じる感じですね。ブラウザが検索欄を起点としてるのと一緒だと思います。
これまで検索っていうと、とりあえずなんか右上とかに添え物的に置かれててどうにも本気で使う気力が感じられなかったイメージがあるんですけど、やっぱそこは中央にドンと置いて主張するのがモダンなのかなあという気はします。
で、ナビゲーションとするなら徹底しましょう。
今回は、どのページに居ても検索内容が入力されたらシームレスに記事一覧画面に遷移して、検索結果を表示するようにしました。このへんの遷移について作る前はあんまりイメージしきれていなかったんですが、とりあえず作っていったらああこれでいいじゃないとなったので、やはり気軽に作ってしまえる環境は楽だなあという感じはします。
そもそもの意識としては検索結果というよりただのフィルタリングですね。なので特別に検索結果画面というものを作るのではなく、記事一覧画面に対して動的なフィルタをかけるというものになります。そしてその状態をシェアできるように内容変更時にrouterのクエリも書き換えて、サイト初期化時にクエリから再現できるようにしてます。そうすることでこのように 「 vueについて話した回 」 といった感じにURLでサクッと状態共有できるようになります。
いやー前からこういうのがやりたかったっていうか、ドキュメントでありアプリって感じじゃないですか。
検索への重い想い
さっき言った、サイトの検索は添え物的って感覚は、検索は重いっていうところがあるのかなと思います。何が重いかって言うと、
- ただでさえユーザーがマウスから手を離してキーボードを叩く労力を支払うし、
- 検索待ちに要する時間、
- そして期待ほどマッチしない結果…。
それだったら要らないですよね。
でもフィルタリングは便利です。即座に応答して、マッチしてなければマッチするように変えていける。コマンドラインのフィルタリングツールのpecoなんかは恩恵を受けている人も多いのではないでしょうか。dev.toを始めとして速さの追求が体験性として大きく取り沙汰されている昨今、クライアントサイドでやれるところはやっていきたいですね。
そんなわけでプレイヤーはボトム、検索はトップと、上下それぞれに機能を実装してアプリ的なサイトになったかなあという感じがします。
実装上のつまづきどころ、知見を振り返る
ルーティング
同じpagename間では遷移時のpage transitionが発動しない
- 前後記事に遷移する場合など。しばらくハマってた。
- ユニークなkeyをつけて読み直しが発生するようにする
- Transition effects when route slug changes · Issue #474 · vuejs/vue-router
- つけないほうがコンポーネント読み直ししなくてパフォーマンスがいいと思うのでそのへんは考えて使う
-
実装したもの
- keyにfullpathを使っていたがこれだと検索時のクエリ変更でも遷移が発動してしまうのでパスのみにした
ページ内リンクするための#hashの文字
-
/
が入ってるとエラーになった。エスケープの必要あり? - ちなみに数字始まりもダメらしい
- If the head of the hash value is numeric, an error will occur by hiro0218 · Pull Request #1952 · vuejs/vue-router
ページ間でページの前後を判断して左右スライドするトランジション設定
- transitionに関数をセットする
- ページ遷移時のトランジション - Nuxt.js
- API: transition プロパティ - Nuxt.js
transition (to, from) {
return (!from || from.name !== to.name)
? 'page'
: Number(to.params.id) < Number(from.params.id) ? 'slide-right' : 'slide-left'
}
遷移時にデフォルトではスクロール位置がそのまま(children routesがある場合)
- 各コンポーネントにscrollToTopを設定する
- またはscrollBehavior自体を書く
- API:router プロパティ - scrollBehavior
- デフォルトでtopに行くようにしたかったのでscrollBehaviorを変更
再生プレイヤー
<input type="range">
でシークバーを作る際のタッチ対応
- input rangeは便利だけど、モバイルではつまみ部分しかタッチ対応しておらず、トラック部分のタッチで動かないのが不便。ポンコツ
- input range touchでググるとだいたいjQuery製のライブラリ。そんなに機能は求めていないんだ…。
- やるべきことは
touchstart
とtouchmove
のイベントハンドリング。それでタッチ座標とrange内容から値を算出できるので、スライダーのvalueを更新する -
実装したもの
- これだと横位置固定なので、広く対応するならもうちょい厳密に書く必要はある
<input type="range">
のスタイリング、クロスブラウザ対応
- darlanrod/input-range-scss: Styling Cross-Browser Compatible Range Inputs with Sass
- ブラウザベンダー対応がややこしいのでこのへんmixinしてプロパティ設定しよう
- 実装したもの
HTMLAudioElementのイベントハンドリング
- Media events - Web developer guides | MDN
- いろいろあるのでよしなに使って好きなプレイヤーを作ろう
- 再生速度コントロールなどもできる
SPA
ページ遷移してきた場合、embedされたツイッターウィジェットは再度読み込む必要がある
- Initializing embedded content after a page has loaded — Twitter Developers
- mountedだけじゃなくupdatedのタイミングでも必要
- 実装したもの
mounted () {
this.loadTwitterWidget()
},
updated () {
this.loadTwitterWidget()
},
methods: {
loadTwitterWidget (): void {
if (window['twttr']) window['twttr'].widgets.load( 対象のDOM )
}
}
検索
nuxtentで本文内容が入って無くて検索できない
- comp.mdだと本文の代わりにrelativePathになるのでcompつけないようにして対応
インクリメンタルサーチ時のIME対応
-
inputイベントはIME変換前の入力中でも発火するのでそれで検索かけると不都合なところがある
- サーバーにリクエスト投げる場合は無駄なコストが発生する
- 確定前文字列で検索してしまうので結果がマッチせずゼロ件表示になってしまう
-
IME対応なし
-
IME対応あり
-
- 変換中というステータスは無いので、
compositionstart/update/end
などのイベントを組み合わせて判定する。ここではディレクティブで実装 - JavaScript とクロスブラウザでの IME event handling (2017年) - たにしきんぐダム
- 変換中というステータスは無いので、
-
でも結局、入力欄とは別に現在の検索中内容を表示するようにしたら一時的にゼロ件表示になっても違和感無いように思えたのでinputイベントのみにした
CSS
- grid layout子要素のmax-widthがきかない
- max-widthではなく、gridのほうでminmax()を指定する
その他これから
- デバイス別表示
- Nuxt.jsの本格導入で遠回りしないためのTips v1.1 - Qiita
- スタイルではなく、要素自体を切り替えるのはなんか上手いこと使えそう