LoginSignup
80
68

More than 5 years have passed since last update.

[Vue.js][ES2015][webpack]サンプルソースを利用した学習(HackerNews clone with Vue.js)

Last updated at Posted at 2016-08-31

TL;DR

これからVue.jsを利用してES2015の記法、Webpackなどを利用したモダンな開発を目指す方が対象です。Vue.jsは非常に学習コストが低く手軽にSPAが開発できるため初心者にはおすすめです。

0.サンプルソースのインストール

以下のURLからソースをローカルに設置してください。
https://github.com/vuejs/vue-hackernews

設置後は対象ディレクトリで以下のコマンドを実施しパッケージをインストールしてください。

npm install

開発時は以下のコマンドを実施することでhttp://localhost:8080/ で確認できます。

npm run dev

ビルドは以下のコマンドを実施することで各ビルドファイルが作成されます。※詳細は後述

npm run build

実際のデモページ

1.処理の流れ

基本的にindex.htmlの<div id="app"></div>をイベントによって書き換えていく形となります。初期表示ではsrc/main.jsが処理されNewsページを表示します。(正確にはルートアクセス時にNewsページにリダイレクトしている)

・ニュース表示
index.html > main.js > App.vue > NewsView.vue > Item.vue
・ユーザ表示
index.html > main.js > App.vue > UserView.vue
・コメント表示
index.html > main.js > App.vue > ItemView.vue > Item.vue > Comment.vue

一度ページを表示すれば"index.html > main.js > App.vue >"は再読込されません。
実際にはページ起動時にすべてのファイルが読み込まれます。上記はコンポーネントの作成順序となります。
※vue-routerのkeep-aliveオプションがあるため一度レンダリングしたページは再読込されません。(後述を参照)
※インスタンス化の確認はexport内にcreatedプロパティ(コンストラクタ)を用意することで可能です。

HackerNewsのデータはstore/index.js経由でfirebaseから取得しておりNewsView.vue、ItemView.vueコンポーネント内から利用しています。

以降で各ファイルの説明を記載します。

2.package.json

package.json
{
  "name": "vue-hackernews",
  "version": "1.0.0",
  "description": "HN clone with Vue.js using HN API",
  "scripts": {
    "dev": "webpack-dev-server --inline --hot --no-info",
    "build": "cross-env NODE_ENV=production webpack --progress --hide-modules"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/yyx990803/vue-hackernews.git"
  },
  "author": "Evan You",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/yyx990803/vue-hackernews/issues"
  },
  "homepage": "https://github.com/yyx990803/vue-hackernews",
  "devDependencies": {
    "babel-core": "^6.0.0",
    "babel-loader": "^6.0.0",
    "babel-plugin-transform-runtime": "^6.0.0",
    "babel-preset-es2015": "^6.0.0",
    "babel-runtime": "^6.0.0",
    "cross-env": "^1.0.5",
    "css-loader": "^0.23.1",
    "stylus": "^0.54.5",
    "stylus-loader": "^2.1.1",
    "vue-hot-reload-api": "^1.2.0",
    "vue-html-loader": "^1.0.0",
    "vue-loader": "^8.0.0",
    "vue-style-loader": "^1.0.0",
    "webpack": "^1.12.2",
    "webpack-dev-server": "^1.12.0"
  },
  "dependencies": {
    "es6-promise": "^3.0.2",
    "firebase": "^2.3.1",
    "vue": "^1.0.26",
    "vue-router": "^0.7.13"
  }
}

package.jsonはプロジェクトのタイトルや説明、利用するパッケージなどを記述します。
このファイルが存在するディレクトリでnpm installを行うことで各パッケージのインストールなどが行なわれプロジェクトが作成されます。(こちらを参考にしました)

特化した部分を以下に記載します。

scripts

devはwebpackを利用して開発コマンドを実施した場合、webpack-dev-serverを起動します。(本来はnpm scriptsコマンドなどで起動するが裏側でやってくれいている)
"--inline --hot --no-info"オプションはプログラムを変更した際にリアルタイムでページがリロードされ、その際に余計な情報を出力しない設定となります。オプションの詳細はこちら

buildは環境変数NODE_ENVにproductionをセットしてwebpackを実行します。"--progress --hide-modules"オプションはビルド中の進捗を表示して、作成したモジュール名を表示しない設定となります。
オプションの詳細はこちら

devDependencies

パッケージ名 概要
babel-core
babel-loader
babel-plugin-transform-runtime
babel-preset-es2015
babel-runtime
babelはES6(ES2015)をES5にトランスパイルするために利用
cross-env 環境変数を異なる環境でも同じように設定するためのパッケージ
css-loader 各コンポーネント単位でCSSを準備できるようにする(CSSモジュールの考え方はこちら
stylus
stylus-loader
stylusはSASSと同じようなCSSメタ言語
vue-hot-reload-api 開発時にVue.jsのcomponentsのプログラムを変更した際にリアルタイムに更新内容をページに反映する
vue-html-loader Vue.jsのhtmlをjavascript上で扱えるようにするために利用(html-loaderのfork)
vue-loader Vue.jsのcomponentsの形式を扱えるようにするために利用
vue-style-loader Vue.jsのcssをjavascript上で扱えるようにするために利用(style-loaderのfork)
webpack こちらを参照
webpack-dev-server sriptsの説明を参照

dependencies

モジュール名 概要
es6-promise ES6記法でpromiseを利用するために利用(promiseの詳細はこちら
firebase Google製のmBaaS(Hackernewsの情報はここから取得する)
vue
vue-router
割愛

3.webpack(webpack.config.js)

babelや各種loaderはwebpackを利用して実行します。
webpackはコマンドラインからの利用も可能ですが、webpack.config.jsに各種設定を記述しnpm runで実行します。

webpack.config.js

webpack.config.js
var webpack = require('webpack')

module.exports = {
  entry: './src/main.js',
  output: {
    path: './static',
    publicPath: '/static/',
    filename: 'build.js'
  },
  module: {
    // avoid webpack trying to shim process
    noParse: /es6-promise\.js$/,
    loaders: [
      {
        test: /\.vue$/,
        loader: 'vue'
      },
      {
        test: /\.js$/,
        // excluding some local linked packages.
        // for normal use cases only node_modules is needed.
        exclude: /node_modules|vue\/dist|vue-router\/|vue-loader\/|vue-hot-reload-api\//,
        loader: 'babel'
      }
    ]
  },
  babel: {
    presets: ['es2015'],
    plugins: ['transform-runtime']
  }
}

if (process.env.NODE_ENV === 'production') {
  module.exports.plugins = [
    new webpack.DefinePlugin({
      'process.env': {
        NODE_ENV: '"production"'
      }
    }),
    new webpack.optimize.UglifyJsPlugin({
      compress: {
        warnings: false
      }
    }),
    new webpack.optimize.OccurenceOrderPlugin()
  ]
} else {
  module.exports.devtool = '#source-map'
}

階層1 階層2 階層3 概要
entry ビルドの起点となるファイルのパス
output path ビルドしたファイルの出力先パス
publicPath URL参照となるパス
filename ビルドしたファイルの出力ファイル名
module noParse パースを無効にするファイルを指定
loaders test 変換するファイルを指定(正規表現も可能)
exclude 除外するファイルを指定(含める場合はinclude)
loader 使用するloaderの名前を指定
babel presets babel関連の設定でプリセットを指定(公式)
plugins babel関連の設定で使用するプラグインを指定

説明はこちらを参考にさせていただきました。

以下はビルド時のみ適用

プラグイン名 概要
webpack.DefinePlugin 環境変数の設定、この値によりロジックを切り分けている
webpack.optimize.UglifyJsPlugin ビルドしたJSをminifyする
webpack.optimize.OccurenceOrderPlugin 利用順にプラグインを読み込む

開発時のみmodule.exports.devtool = '#source-map'となり、source-mapを作成します。

4.main.js

src/main.js
import Vue from 'vue'
import Router from 'vue-router'
import { domain, fromNow } from './filters'
import App from './components/App.vue'
import NewsView from './components/NewsView.vue'
import ItemView from './components/ItemView.vue'
import UserView from './components/UserView.vue'

// install router
Vue.use(Router)

// register filters globally
Vue.filter('fromNow', fromNow)
Vue.filter('domain', domain)

// routing
var router = new Router()

router.map({
  '/news/:page': {
    component: NewsView
  },
  '/user/:id': {
    component: UserView
  },
  '/item/:id': {
    component: ItemView
  }
})

router.beforeEach(function () {
  window.scrollTo(0, 0)
})

router.redirect({
  '*': '/news/1'
})

router.start(App, '#app')

importでは利用するモジュールを読み込みます。

import 文 は、外部モジュールや他のスクリプトなどからエクスポートされた関数、オブジェクト、プリミティブをインポートするために使用します。

import - JavaScript | MDN

Vue.use() グローバルメソッドでvue-routerプラグインを使用します。
Vue.filter()でカスタムフィルタを登録します。(後述の12.Filterで説明)
以降はルーティングの設定で詳しくはこちらを参照してください。(router.beforeEachはこちら

5.App.vue

src/components/App.vue
<template>
  <div id="wrapper">
    <!-- header -->
    <div id="header">
      <a id="yc" href="http://www.ycombinator.com">
        <img src="https://news.ycombinator.com/y18.gif">
      </a>
      <h1><a href="#/">Hacker News</a></h1>
      <span class="source">
        Built with <a href="http://vuejs.org" target="_blank">Vue.js</a> |
        <a href="https://github.com/vuejs/vue-hackernews" target="_blank">Source</a>
      </span>
    </div>
    <!-- main view -->
    <router-view
      class="view"
      keep-alive
      transition
      transition-mode="out-in">
    </router-view>
  </div>
</template>

<style lang="stylus">
@import "../variables.styl"

html, body
  font-family Verdana
  font-size 13px
  height 100%

ul
  list-style-type none
  padding 0
  margin 0

a
  color #000
  cursor pointer
  text-decoration none

#wrapper
  background-color $bg
  position relative
  width 85%
  min-height 80px
  margin 0 auto

#header
  background-color #f60
  height 24px
  position relative
  h1
    font-weight bold
    font-size 13px
    display inline-block
    vertical-align middle
    margin 0
  .source
    color #fff
    font-size 11px
    position absolute
    top 4px
    right 4px
    a
      color #fff
      &:hover
        text-decoration underline

#yc
  border 1px solid #fff
  margin 2px
  display inline-block
  vertical-align middle
  img
    vertical-align middle

.view
  position absolute
  background-color $bg
  width 100%
  transition opacity .2s ease
  box-sizing border-box
  padding 8px 20px
  &.v-enter, &.v-leave
    opacity 0

@media screen and (max-width: 700px)
  html, body
    margin 0
  #wrapper
    width 100%
</style>

Vue.jsのコンポーネントとは

コンポーネントは Vue.js の最も強力な機能の1つです。基本的な >HTML 要素を拡張して再利用可能なコードのカプセル化を助けます。
高度なレベルでは、コンポーネントは Vue.js のコンパイラが指定された振舞いをアタッチするカスタム要素です。
場合によっては、特別な is 属性で拡張されたネイティブな HTML 要素の姿をとることもあります。

コンポーネントとは何か?

App.vueはサンプルプロジェクトの根幹となるコンポーネントで、最初に記載しましたが、index.htmlの<div id="app"></div>にApp.vueの<template>内のHTMLが追加されます。
アクセスするURLまたは操作イベントにより<router-view>タグ内が該当するコンポーネントに置き換えられます。
class="view"は置き換わるラッパータグに付加されます。<router-view>ではVue.jsコンポーネントのオプションが一部利用可能となっています。

・keep-alive

状態を保持したり再レンダリングを避けたりするために、もし切り替えで取り除かれたコンポーネントを生きた状態で保持したい場合は、ディレクティブのパラメータ keep-alive を追加することができます

・transition

transition-mode パラメータ属性は、2つの動的コンポーネント間でのトランジションがどう実行されるかを指定できます。

デフォルトでは、入ってくるコンポーネントと出て行くコンポーネントのトランジションが同時に起こります。この属性によって、もう2つのモードを設定することができます:

・in-out: 新しいコンポーネントのトランジションが初めに起こり、そのトランジションが完了した後に現在のコンポーネントの出て行くトランジションが開始します。

・out-in: 現在のコンポーネントが出て行くトランジションが初めに起こり、そのトランジションが完了した後に新しいコンポーネントのトランジションが開始します。

<router-view>とオプションの説明はこちらこちら

template、script、styleタグにlang属性を設定することでそれぞれメタ言語を利用することができます。
(jade,coffeeなどなど)
CSS部分ではlang="stylus"とすることでstylus形式で記述することを宣言しています。

6.NewsView.vue

src/components/NewsView.vue

<template>
  <div class="news-view" :class="{ loading: !items.length }">
    <!-- item list -->
    <item
      v-for="item in items"
      :item="item"
      :index="$index | formatItemIndex"
      track-by="id">
    </item>
    <!-- navigation -->
    <div class="nav" v-show="items.length > 0">
      <a v-if="page > 1" :href="'#/news/' + (page - 1)">&lt; prev</a>
      <a v-if="page < 4" :href="'#/news/' + (page + 1)">more...</a>
    </div>
  </div>
</template>

<script>
import store from '../store'
import Item from './Item.vue'

export default {

  name: 'NewsView',

  components: {
    Item
  },

  data () {
    return {
      page: 1,
      items: []
    }
  },

  route: {
    data ({ to }) {
      // This is the route data hook. It gets called every time the route
      // changes while this component is active.
      // 
      // What we are doing:
      // 
      // 1. Get the `to` route using ES2015 argument destructuring;
      // 2. Get the `page` param and cast it to a Number;
      // 3. Fetch the items from the store, which returns a Promise containing
      //    the fetched items;
      // 4. Chain the Promise and return the final data for the component.
      //    Note we are waiting until the items are resolved before resolving
      //    the entire object, because we don't want to update the page before
      //    the items are fetched.
      const page = +to.params.page
      document.title = 'Vue.js HN Clone'
      return store.fetchItemsByPage(page).then(items => ({
        page,
        items
      }))
    }
  },

  created () {
    store.on('topstories-updated', this.update)
  },

  destroyed () {
    store.removeListener('topstories-updated', this.update)
  },

  methods: {
    update () {
      store.fetchItemsByPage(this.page).then(items => {
        this.items = items
      })
    }
  },

  filters: {
    formatItemIndex (index) {
      return (this.page - 1) * store.storiesPerPage + index + 1
    }
  }
}
</script>

<style lang="stylus">
.news-view
  padding-left 5px
  padding-right 15px
  &.loading:before
    content "Loading..."
    position absolute
    top 16px
    left 20px
  .nav
    padding 10px 10px 10px 40px
    margin-top 10px
    border-top 2px solid #f60
    a
      margin-right 10px
      &:hover
        text-decoration underline
</style>

初期表示でも利用しているニュース一覧コンポーネントです。
storeのニュースデータ件数分のItemコンポーネントを作成し<Item>内に表示します。
:class="{ loading: !items.length }"でstoreからdataのitemにデータがセットされるまでローディングを表示します。(ローディングはCSSで表現)
Vue.jsでは親コンポーネントのデータを子コンポーネントが利用する場合、Propsという機能を利用します。ここではItemコンポーネントにitem,indexを渡しています。
#Props-によるデータ伝達

v-forディレクティブはこちらを参照してください。track-byディレクティブは利用することでDOM要素を再利用することができます。そのためデータ内の一意となるIDをセットします。
#track-by

<script>タグ内はES2015で記述しています。データ取得用のstoreモジュールとItemコンポーネントを読み込みます。export default内はImportコマンドにて読みこまれた際にレスポンスとなる値で、Vue.jsのコンポーネント形式で記述します。 利用している各属性の説明は以下となります。

属性 概要
name コンポーネントの名称です。テンプレートで自分自身を再帰的に呼びだす際は必須です。
components 子コンポーネントを指定します。ここに指定することで当コンポーネントのみ利用可能なコンポーネントが作成可能です。
data コンポーネントが利用するデータオブジェクトを指定します。dataの詳細はこちらで、関数形式で指定する理由はこちら を確認してください。
route vue-routerで利用します。dataはURL(route)の変更やコンポーネントが再利用で呼び出されコンポーネントのdataを更新します。ここではstore.js経由でfirebaseからpageに応じたデータを取得しdataオブジェクトを更新しています。 詳細はこちら
created コンポーネント作成時にコールされます。ここではstore.jsのイベントを監視します。詳細はstore.jsに記述。createdの詳細はこちら
destroyed コンポーネントが破棄された後にコールされます。ここではcreated時に登録したイベント監視を削除します。destroyedの詳細はこちら
methods Vue インスタンスに組み込まれるメソッドで、メソッド内から直接アクセス可能となり、ディレクティブの式で使用することもできます。methodsの詳細はこちら
filter ここではURL(route)で受け取ったpageを元にItemsコンポーネントに渡す値を算出しています。カスタムフィルタの詳細はこちら

各記法についてはNewsView.vueを変換したbuild.js(以下)と比較すると分かりやすいかと思います。

build.js(抜粋)
    'use strict';

    Object.defineProperty(exports, "__esModule", {
      value: true
    });

    var _store = __webpack_require__(92);

    var _store2 = _interopRequireDefault(_store);

    var _Item = __webpack_require__(132);

    var _Item2 = _interopRequireDefault(_Item);

    function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

    // <template>
    //   <div class="news-view" :class="{ loading: !items.length }">
    //     <!-- item list -->
    //     <item
    //       v-for="item in items"
    //       :item="item"
    //       :index="$index | formatItemIndex"
    //       track-by="id">
    //     </item>
    //     <!-- navigation -->
    //     <div class="nav" v-show="items.length > 0">
    //       <a v-if="page > 1" :href="'#/news/' + (page - 1)">&lt; prev</a>
    //       <a v-if="page < 4" :href="'#/news/' + (page + 1)">more...</a>
    //     </div>
    //   </div>
    // </template>
    //
    // <script>
    exports.default = {

      name: 'NewsView',

      components: {
        Item: _Item2.default
      },

      data: function data() {
        return {
          page: 1,
          items: []
        };
      },


      route: {
        data: function data(_ref) {
          var to = _ref.to;

          // This is the route data hook. It gets called every time the route
          // changes while this component is active.
          // 
          // What we are doing:
          // 
          // 1. Get the `to` route using ES2015 argument destructuring;
          // 2. Get the `page` param and cast it to a Number;
          // 3. Fetch the items from the store, which returns a Promise containing
          //    the fetched items;
          // 4. Chain the Promise and return the final data for the component.
          //    Note we are waiting until the items are resolved before resolving
          //    the entire object, because we don't want to update the page before
          //    the items are fetched.
          var page = +to.params.page;
          document.title = 'Vue.js HN Clone';
          return _store2.default.fetchItemsByPage(page).then(function (items) {
            return {
              page: page,
              items: items
            };
          });
        }
      },

      created: function created() {
        _store2.default.on('topstories-updated', this.update);
      },
      destroyed: function destroyed() {
        _store2.default.removeListener('topstories-updated', this.update);
      },


      methods: {
        update: function update() {
          var _this = this;

          _store2.default.fetchItemsByPage(this.page).then(function (items) {
            _this.items = items;
          });
        }
      },

      filters: {
        formatItemIndex: function formatItemIndex(index) {
          return (this.page - 1) * _store2.default.storiesPerPage + index + 1;
        }
      }
    };

7.Store

src/store/index.js
import Firebase from 'firebase'
import { EventEmitter } from 'events'
import { Promise } from 'es6-promise'

const api = new Firebase('https://hacker-news.firebaseio.com/v0')
const itemsCache = Object.create(null)
const store = new EventEmitter()
const storiesPerPage = store.storiesPerPage = 30

let topStoryIds = []

export default store

/**
 * Subscribe to real time updates of the top 100 stories,
 * and cache the IDs locally.
 */

api.child('topstories').on('value', snapshot => {
  topStoryIds = snapshot.val()
  store.emit('topstories-updated')
})

/**
 * Fetch an item data with given id.
 *
 * @param {Number} id
 * @return {Promise}
 */

store.fetchItem = id => {
  return new Promise((resolve, reject) => {
    if (itemsCache[id]) {
      resolve(itemsCache[id])
    } else {
      api.child('item/' + id).once('value', snapshot => {
        const story = itemsCache[id] = snapshot.val()
        resolve(story)
      }, reject)
    }
  })
}

/**
 * Fetch the given list of items.
 *
 * @param {Array<Number>} ids
 * @return {Promise}
 */

store.fetchItems = ids => {
  if (!ids || !ids.length) {
    return Promise.resolve([])
  } else {
    return Promise.all(ids.map(id => store.fetchItem(id)))
  }
}

/**
 * Fetch items for the given page.
 *
 * @param {Number} page
 * @return {Promise}
 */

store.fetchItemsByPage = page => {
  const start = (page - 1) * storiesPerPage
  const end = page * storiesPerPage
  const ids = topStoryIds.slice(start, end)
  return store.fetchItems(ids)
}

/**
 * Fetch a user data with given id.
 *
 * @param {Number} id
 * @return {Promise}
 */

store.fetchUser = id => {
  return new Promise((resolve, reject) => {
    api.child('user/' + id).once('value', snapshot => {
      resolve(snapshot.val())
    }, reject)
  })
}

前述のとおりHackerNewsのデータはfirebaseから取得しています。
Hacker News API
firebaseの基本的な利用方法はこちらを参照してください。
旧ドキュメント
Importにより利用するモジュールの概要は以下となります。

モジュール 概要
firebase firebaseがオフィシャルに用意しているパッケージ
events Node.js上でイベントを扱うのに便利なパッケージ
es6-promise ES6でpromiseオブジェクト(非同期処理を簡単にするもの)を扱うためのパッケージ
promiseについてはこちらが分かりやすいかと

次のconst、letは各値の宣言、初期化を行っています。Firebaseクラスは利用するオブジェクトのURL(RDBでいうところのデータベース)を指定します。Object.createではitemsCacheをオブジェクト型として初期化しています。storiesPerPageは1ページに表示するニュース件数を定義しています。

export default storeはstoreモジュールをImportした際に利用可能となるオブジェクトを指定しています。export defaultではクラス、オブジェクト、文字列など任意の値が指定可能です。ここではEventEmitter型のオブジェクトを戻しています。

api.child('topstories').on('value',〜はtopstoriesオブジェクトから1回だけデータを取得する操作となります。firebaseはソケット通信を利用してデータの追加・変更時などのアクション登録も可能ですが、本プロジェクトでは利用毎に基本的に1回だけ取得する形となります。第2引数にはcallback関数を指定しており、レスポンスとなるニュースのID配列(コメントには100件とあるが500件入ってる)を保持して、'topstories-updated'イベントを発行します。少し前後しますがNewsView.vueではこのイベントを監視しており、NewsView内のupdateメソッド経由でstoreのデータ取得を呼び出します。

store.fetchItem = id => {〜は引数のidから対象のNewsデータをPromiseオブジェクトの形式で戻します。既に取得済みの場合はキャッシュから、データがない場合はfirebaseから取得します。Promiseオブジェクトに渡すcallbackの引数はresolve, rejectとなり成功時はresolve、失敗時はrejectにレスポンスしたい値を渡します。

store.fetchItems = ids => {〜は上述のラッパーとなり、複数のidを受け取り紐づくデータをレスポンスします。

store.fetchItemsByPage = page => {〜はページに紐づくidを最初に保持したID配列から抽出し紐づくデータをレスポンスします。

store.fetchUser = id => {〜はユーザに関する情報をfirebaseのuserデータから取得しレスポンスします。

8.Item.vue

src/components/Item.vue
<template>
  <div class="item">
    <span class="index">{{index}}.</span>
    <p>
      <a class="title" :href="href" target="_blank">{{{item.title}}}</a>
      <span class="domain" v-show="showDomain">
        ({{item.url | domain}})
      </span>
    </p>
    <p class="subtext">
      <span v-show="showInfo">
        {{item.score}} points by
        <a :href="'#/user/' + item.by">{{item.by}}</a>
      </span>
      {{item.time | fromNow}} ago
      <span class="comments-link" v-show="showInfo">
        | <a :href="'#/item/' + item.id">{{item.descendants}} {{item.descendants | pluralize 'comment'}}</a>
      </span>
    </p>
  </div>
</template>

<script>
export default {

  name: 'Item',

  props: {
    item: Object,
    index: Number
  },

  computed: {
    href () {
      return this.item.url || ('#/item/' + this.item.id)
    },
    showInfo () {
      return this.item.type === 'story' || this.item.type === 'poll'
    },
    showDomain () {
      return this.item.type === 'story'
    }
  }
}
</script>

<style lang="stylus">
@import "../variables.styl"

.item
  padding 2px 0 2px 40px
  position relative
  transition background-color .2s ease
  p
    margin 2px 0
  .title:visited
      color $gray
  .index
    color $gray
    position absolute
    width 30px
    text-align right
    left 0
    top 4px
  .domain, .subtext
    font-size 11px
    color $gray
    a
      color $gray
  .subtext a:hover
    text-decoration underline
</style>

ニュース一覧の明細行となるコンポーネントです。firebaseから取得するitemsはこちらを参照してください。
親コンポーネントからPropsでデータを受け取る場合は伝達を想定する props を明示的に宣言する必要があります。
computedではテンプレート内で利用する算出プロパティを記述することができます。
基本的にはitemsのデータが外部サイトかHackerNews内のコンテンツか識別して出力内容を制御しています。

9.UserView.vue

src/components/UserView.vue
<template>
  <div class="user-view" v-show="user">
    <ul>
      <li><span class="label">user:</span> {{user.id}}</li>
      <li><span class="label">created:</span> {{user.created | fromNow}} ago</li>
      <li><span class="label">karma:</span> {{user.karma}}</li>
      <li>
        <span class="label">about:</span>
        <div class="about">
          {{{user.about}}}
        </div>
      </li>
    </ul>
    <p class="links">
      <a :href="'https://news.ycombinator.com/submitted?id=' + user.id">submissions</a><br>
      <a :href="'https://news.ycombinator.com/threads?id=' + user.id">comments</a>
    </p>
  </div>
</template>

<script>
import store from '../store'

export default {

  name: 'UserView',

  data () {
    return {
      user: {}
    }
  },

  route: {
    data ({ to }) {
      // Promise sugar syntax: return an object that contains Promise fields.
      // http://router.vuejs.org/en/pipeline/data.html#promise-sugar
      document.title = 'Profile: ' + to.params.id + ' | Vue.js HN Clone'
      return {
        user: store.fetchUser(to.params.id)
      }
    }
  }
}
</script>

<style lang="stylus">
@import "../variables.styl"

.user-view
  color $gray
  li
    margin 5px 0
  .label
    display inline-block
    min-width 60px
  .about
    margin-top 1em
  .links a
    text-decoration underline
</style>

ニュース一覧のユーザ名クリックまたは直接URLをアクセスした際にユーザ情報を表示します。
userオブジェクトについてはこちらを参照してください。
submissionsおよびcommentsのリンク先はcloneプロジェクトに用意はなく本家サイトとなっています。存在しないユーザ情報の場合はv-show="user"の制御でユーザ情報部分が非表示となります。

10.ItemView.vue

src/components/ItemView.vue
<template>
  <div class="item-view" v-show="item">
    <item :item="item"></item>
    <p class="itemtext" v-if="hasText" v-html="item.text"></p>
    <ul class="poll-options" v-if="pollOptions">
      <li v-for="option in pollOptions">
        <p>{{option.text}}</p>
        <p class="subtext">{{option.score}} points</p>
      </li>
    </ul>
    <ul class="comments" v-if="comments">
      <comment
        v-for="comment in comments"
        :comment="comment">
      </comment>
    </ul>
    <p v-show="!comments.length && !isJob">No comments yet.</p>
  </div>
</template>

<script>
import store from '../store'
import Item from './Item.vue'
import Comment from './Comment.vue'

export default {

  name: 'ItemView',

  components: {
    Item,
    Comment
  },

  data () {
    return {
      item: {},
      comments: [],
      pollOptions: null
    }
  },

  route: {
    data ({ to }) {
      return store.fetchItem(to.params.id).then(item => {
        document.title = item.title + ' | Vue.js HN Clone'
        return {
          item,
          // the final resolved data can further contain Promises
          comments: store.fetchItems(item.kids),
          pollOptions: item.type === 'poll'
            ? store.fetchItems(item.parts)
            : null
        }
      })
    }
  },

  computed: {
    isJob () {
      return this.item.type === 'job'
    },

    hasText () {
      return this.item.hasOwnProperty('text')
    }
  }
}
</script>

<style lang="stylus">
@import "../variables.styl"

.item-view
  .item
    padding-left 0
    margin-bottom 30px
    .index
      display none
  .poll-options
    margin-left 30px
    margin-bottom 40px
    li
      margin 12px 0
    p
      margin 8px 0
    .subtext
      color $gray
      font-size 11px
  .itemtext
    color $gray
    margin-top 0
    margin-bottom 30px
  .itemtext p
    margin 10px 0
</style>

HackerNews内のコンテンツ、ニュース一覧のコメントクリックまたは直接URLをアクセスした際にニュース情報を表示します。typeのjobはhiring用のページでpollはアンケートページとなります。
URLパラメータのItemのidからItemの情報を取得して、コメント、アンケートの項目など紐づく情報を取得しdataにセットします。

11.Comment.vue

src/components/Comment.vue
<template>
  <li v-show="comment.text">
    <div class="comhead">
      <a class="toggle" @click="open = !open">{{open ? '[-]' : '[+]'}}</a>
      <a :href="'#/user/' + comment.by">{{comment.by}}</a>
      {{comment.time | fromNow}} ago
    </div>
    <p class="comment-content" v-show="open">
      {{{comment.text}}}
    </p>
    <ul class="child-comments" v-if="comment.kids" v-show="open">
      <comment v-for="comment in childComments" :comment="comment"></comment>
    </ul>
  </li>
</template>

<script>
import store from '../store'

export default {

  name: 'Comment',

  props: {
    comment: Object
  },

  data () {
    return {
      childComments: [],
      open: true
    }
  },

  created () {
    if (this.comment.kids) {
      store.fetchItems(this.comment.kids).then(comments => {
        this.childComments = comments
      })
    }
  }
}
</script>

<style lang="stylus">
@import "../variables.styl"

.comhead
  color $gray
  font-size 11px
  margin-bottom 8px
  a
    color $gray
    &:hover
      text-decoration underline
  .toggle
    margin-right 4px

.comment-content
  margin 0 0 16px 24px
  word-wrap break-word
  code
    white-space pre-wrap

.child-comments
  margin 8px 0 8px 22px
</style>

ニュースに紐づくコメントを表示します。コメントに対するコメントを再帰処理にて取得・表示を行っています。

12.filter

src/filters/index.js
const urlParser = document.createElement('a')

export function domain (url) {
  urlParser.href = url
  return urlParser.hostname
}

export function fromNow (time) {
  const between = Date.now() / 1000 - Number(time)
  if (between < 3600) {
    return pluralize(~~(between / 60), ' minute')
  } else if (between < 86400) {
    return pluralize(~~(between / 3600), ' hour')
  } else {
    return pluralize(~~(between / 86400), ' day')
  }
}

function pluralize(time, label) {
    if (time === 1) {
        return time + label
    }

    return time + label + 's';
}

カスタムフィルタはテンプレート内でdataの値を整形して表示をする時などに利用します。(詳細はこちら
このパッケージはmain.jsでImportされfilterに登録されており、表示用にURLからドメインを抽出して戻すdomainと、unixタイムを表示用の文字列に変換する2つを用意しています。
ドメイン抽出ではaタグのhostnameを利用しており、時間表示では単位にsを付加する識別を行っています。

おわりに

雑に記載してしまったので不適切な表記、同じ意味で異なるワードの統一などは順次更新していきます。間違い等があれば、ご指摘ください。

80
68
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
80
68