JavaScript
vue.js
Firebase
nuxt.js
Nuxt.jsDay 5

NuxtのOGP対応について / firebaseとNuxtでwebサービスつくったのでそのNuxtまわり

ogimg_1.png
趣味制作で、これ最高なんすよ〜といったランキングを作れるサービス、COUCH(カウチ)をリリースしました。
URLのランキングしか作れない、知り合いに向けたNEVERまとめ的なものをイメージしてもらえたら大体あってます。

人は忘れるし変わるので年月で区切ってBEST保存できればなと。
毎月2000reblog、100likeしてる身として、去年のおすすめreblogすぐ出せない&忘れてる状態となるのが悔しくて作ったというのが本音
使ってもらえたら嬉しいです。基本1人で作ってるので、足りない至らない部分あります。暖かく見守ってもらえれたら助かります。

COUCH(カウチ)は、Nuxtとfirebase(Authentication, Firestore, Functions, Storage, Hosting)で作ったので、当記事はそのNuxt周りのお話、
具体的にはnpmとOGPについて書いていきます。

firebaseについてはFirebase #2 Advent Calendar 2018 12/14(金)にて書く予定です。

お世話になったnpm

  • element-ui
    • 人気なだけあって探しやすい、だいたい賄えます。でもデザイン改修つらい。重い。
  • vue-awesome
    • fontawesome探す中で採用。本家がちょっと使いずらそうだったので必要十分かなと。楽。しかし、iconのlistが見つからないので雰囲気でicon name書くことに。
  • vue2-medium-editor
    • 現在、editorの最終到達点はbearだと認識しているのですがいかんせんapp。web環境ではmediumnoteもいいかんじ。vue2-medium-editorはboldやリンクまわりがいい感じにサクッと実装できるのでおすすめです。urlのコメント機能で使用。別軸で入力一文字目が重複してしまうバグが発生しているので直したい。
  • sanitize-html
    • railsにあるsanitize。超便利。
  • vue-tweet-embed
  • vue-youtube-embed

OGP設定について

Nuxtを選ぶ理由にOGP対応があります。
表示後のheder要素書き換えにクローラーが対応してくれないことが原因みたいなので、最終的にはvue単体で大丈夫なようにgoogleさん待ち案件なのでは?感はあります。
が、その時はまだ来てないので、みんなが結局やるであろうopgのutil周り晒します。

nuxt.config.js
const SITE_NAME = 'COUCH'
const TITLE = `${SITE_NAME} [カウチ] - tell me BEST, in YOUR words.`
const DESC = 'カウチ(COUCH)はおすすめBESTランキング作成サービスです'
const KEYWORDS = 'COUCH,カウチ,BEST,ベスト,まとめ,キュレーション,キュレーター'
const DEV_BASE_URL = 'http://localhost:3333'
const BASE_URL = 'https://couch.id'
const OGIMG_URL = `${BASE_URL}/ogimg_1.png` // 1200x630
const TWITTER_ID = '@couch_id' // @[ Twitter ID]
 // const FACEBOOK_APP_ID = '' // App-ID(15文字の半角数字)
 // const FACEBOOK_PAGE_URL = ''  // FacebookページのURL

module.exports = {
  ...
  manifest: {
    name: SITE_NAME,
    lang: 'ja',
    short_name: SITE_NAME,
    title: TITLE,
    'og:title': TITLE,
    description: DESC,
    'og:description': DESC,
    theme_color: '#f2f2f2',
    background_color: '#f2f2f2',
    display: 'standalone'
  },
  env: {
    dev: (process.env.NODE_ENV !== 'production'),
    baseUrl: BASE_URL,
    devBaseUrl: DEV_BASE_URL,
    siteName: process.env.SITE_NAME || SITE_NAME,
    title: process.env.TITLE || TITLE,
    description: process.env.DESC || DESC,
    ogimgURL: process.env.OGIMG_URL || OGIMG_URL
  },
  head: {
    title: TITLE,
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no' },
      { hid: 'description', name: 'description', content: DESC },
      { hid: 'keywords', name: 'keywords', content: KEYWORDS, 'xml:lang': 'ja', lang: 'ja' },
      { hid: 'og:site_name', name: 'og:site_name', content: SITE_NAME },
      // { hid: 'fb:app_id', name: 'fb:app_id', content: FACEBOOK_APP_ID },
      // { hid: 'article:publisher', name: 'article:publisher', content: FACEBOOK_PAGE_URL },
      { hid: 'twitter:site', name: 'twitter:site', content: TWITTER_ID },
      { hid: 'og:type', name: 'og:type', content: 'website' },
      { hid: 'og:title', name: 'og:title', content: TITLE },
      { hid: 'og:description', name: 'og:description', content: DESC },
      { hid: 'og:image', name: 'og:image', content: OGIMG_URL },
      { hid: 'og:url', name: 'og:url', content: BASE_URL },
      { hid: 'msapplication-TileColor', name: 'msapplication-TileColor', content: '#f2f2f2' },
      { hid: 'theme-color', name: 'theme-color', content: '#ffffff' },
      { hid: 'apple-mobile-web-app-title', name: 'apple-mobile-web-app-title', content: SITE_NAME },
      { hid: 'application-name', name: 'application-name', content: SITE_NAME },
      { hid: 'msapplication-config', name: 'msapplication-config', content: `${BASE_URL}/browserconfig.xml` }
    ],
    link: [
      { rel: 'icon', type: 'image/x-icon', href: `${BASE_URL}/favicon.ico` },
      { rel: 'apple-touch-icon', sizes: '180x180', href: `${BASE_URL}/apple-touch-icon.png` },
      { rel: 'icon', type: 'image/png', sizes: '32x32', href: `${BASE_URL}/favicon-32x32.png` },
      { rel: 'icon', type: 'image/png', sizes: '16x16', href: `${BASE_URL}/favicon-16x16.png` },
      { rel: 'mask-icon', href: `${BASE_URL}/safari-pinned-tab.svg`, color: '#000000' },
      { rel: 'shortcut icon', href: `${BASE_URL}/favicon.ico` }
    ]
  },
head.js
 // 各pageのheadで呼ばれるhead  utils
const SITE_NAME = process.env.siteName
const TITLE = process.env.title
const DESC = process.env.description
const BASE_URL = process.env.baseUrl
const OGIMG_URL = process.env.ogimgURL
const OG_TYPE = {
  article: 'article',
  website: 'website'
}
const TWITTER_CARD = {
  summary_large_image: 'summary_large_image',
  player: 'player'
}

const getSnsOgp = function (title, pageURL, imgURL, desc, videoURL = '', videoType = '') {
  let result = [
    { hid: 'twitter:title', name: 'twitter:title', content: title },
    { hid: 'twitter:description', name: 'twitter:description', content: desc },
    { hid: 'twitter:image', name: 'twitter:image', content: imgURL },
    { hid: 'twitter:image:alt', name: 'twitter:image:alt', content: desc }
  ]
  let optional = []
  if (videoURL) {
    optional = [
      { hid: 'twitter:card', name: 'twitter:card', content: TWITTER_CARD.player },
      { hid: 'twitter:player', name: 'twitter:player', content: pageURL },
      { hid: 'twitter:player:width', name: 'twitter:player:width', content: '480' },
      { hid: 'twitter:player:height', name: 'twitter:player:height', content: '480' },
      { hid: 'twitter:player:stream', name: 'twitter:player:stream', content: videoURL },
      { hid: 'twitter:player:stream:content_type', name: 'twitter:player:stream:content_type', content: videoType }
    ]
  } else {
    optional = [
      { hid: 'twitter:card', name: 'twitter:card', content: TWITTER_CARD.summary_large_image }
    ]
  }
  [].push.apply(result, optional)
  return result
}

export default {
  getHeadTop: function () {
    // topページ用
    const sns = getSnsOgp(TITLE, BASE_URL, OGIMG_URL, DESC)
    return {
      meta: [ ...sns ]
    }
  },
  getHead: function (contentTitle = '', pageURL = BASE_URL, imgURL = '', desc = '', videoURL = '', videoType = 'video/mp4;') {
    // topページ以外用
    let pageTitle = TITLE
    if (contentTitle) pageTitle = `${contentTitle} - ${SITE_NAME}`
    const imageURL = imgURL || OGIMG_URL
    const description = desc || DESC
    const sns = getSnsOgp(pageTitle, pageURL, imageURL, description, videoURL, videoType)
    return {
      title: pageTitle,
      meta: [
        { hid: 'og:type', name: 'og:type', content: OG_TYPE.article },
        { hid: 'description', name: 'description', content: description },
        { hid: 'og:title', name: 'og:title', content: pageTitle },
        { hid: 'og:description', name: 'og:description', content: description },
        { hid: 'og:image', name: 'og:image', content: imageURL },
        { hid: 'og:url', name: 'og:url', content: pageURL },
        ...sns
      ]
    }
  }
}

head要素のhidについてはメタタグが重複したときは?をご参照ください。

使い方
pages/ _userId/xxxs/ _xxxId.vue

export default {
  name: 'xxxPage',
  async asyncData ({ store, params, error }) {
    try {
      const xxxId = params.xxxId
      let pageXxx = await fireRead.getXxx(xxxId)
      ....
      return {
        xxxId: xxxId,
        pageXxx: pageXxx,
        pageAuthor: pageAuthor
      }
    } catch (e) {
      ....
    }
  },
  data () {
    return {
      xxxId: this.$route.params.xxxId,
      pageXxx: null,
      pageAuthor: null
    }
  },
  head () {
    // !!!ここから!!!
    if (!this.pageXxx) return headMeta.getHead('', this.$route.fullPath)
    if (!this.pageXxx.isPublic) return headMeta.getHead('', this.$route.fullPath)
    const title = `${this.pageXxx.title}'s best by ${this.pageAuthor.displayName}`
    const imageMainURL = (this.pageXxx.hasOwnProperty('imageMainURL')) ? this.pageXxx.imageMainURL : ''
    return headMeta.getHead(title, this.$route.fullPath, imageMainURL, this.pageXxx.desc)
  },

このような感じでhead,ogp要素が正しく認識されるはずです。

また、facebook上でurlのogp確認テストしていた際、テスト情報のキャッシュが残り、更新してくれない時があります。その場合は
https://developers.facebook.com/tools/debug/og/object/
こちらのページで再読み込みができます。ご参考までに。

雑感

  • vueファイル単体でpug,script,sassまとめて管理できるのでcomponentとして蓋できる。強い。
    • でもjsのutil関数群や子componentがDRYにさせてくれない時も。設計力が問われる。そもそもかっちりやるならreactでやるべき?触ったことない。
  • 敷居の広さだけでもjs新時代感ある。というかそこが一番重要か。楽しさまで早い。
  • 時間があるならvue、Nuxtの順で別プロジェクトとして勉強したほうがいい。どの部分の問題かわからなくなりそう。
  • NuxtはasyncDataの扱い難しかった。mountedでも同じようなデータ取ることになる場面もあったので、asyncDataはhead用にだけと割り切って使うようにした。
  • Nuxtのpagesディレクトリの存在、router記述しなくていいのはありがたすぎる。
  • typescriptは今回見送った。そろそろ再挑戦してもいい時期だと思うがそれならreactと同時にやりたい感もある。
  • 作ったものの、非表示にした機能何個かあるので、出すタイミングできたら出したい。
  • 成り立ちが3年程前、各自、tumblrで今年のBEST reblogを最低3つ用意してパセラで発表の忘年会(年一で面白い)なので、今のtumblrお葬式ムードほんとうに悲しい。