Edited at

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

More than 3 years have passed since last update.


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を付加する識別を行っています。


おわりに

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