diff --git a/.babelrc b/.babelrc
index 61d50f277..4f95cb077 100644
--- a/.babelrc
+++ b/.babelrc
@@ -1,6 +1,8 @@
{
"presets": [
- ["es2015", { "modules": false }],
- "stage-2"
+ ["env", { "modules": false }]
+ ],
+ "plugins": [
+ "syntax-dynamic-import"
]
}
diff --git a/.gitignore b/.gitignore
index 060b35bff..2d25aef65 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,6 @@
node_modules/
dist/
npm-debug.log
+yarn-error.log
+.idea
+*.iml
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 000000000..b65dd9e62
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2013-present, Yuxi (Evan) You
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/README.md b/README.md
index 75a96823a..01a04c763 100644
--- a/README.md
+++ b/README.md
@@ -3,37 +3,58 @@
HackerNews clone built with Vue 2.0 + vue-router + vuex, with server-side rendering.
-
+
Live Demo
-> Note: the demo may need some spin up time if nobody has accessed it for a certain period.
-
## Features
+> Note: in practice, it is unnecessary to code-split for an app of this size (where each async chunk is only a few kilobytes), nor is it optimal to extract an extra CSS file (which is only 1kb) -- they are used simply because this is a demo app showcasing all the supported features.
+
- Server Side Rendering
- Vue + vue-router + vuex working together
- Server-side data pre-fetching
- Client-side state & DOM hydration
+ - Automatically inlines CSS used by rendered components only
+ - Preload / prefetch resource hints
+ - Route-level code splitting
+- Progressive Web App
+ - App manifest
+ - Service worker
+ - 100/100 Lighthouse score
- Single-file Vue Components
- Hot-reload in development
- CSS extraction for production
-- Real-time List Updates with FLIP Animation
+- Animation
+ - Effects when switching route views
+ - Real-time list updates with FLIP Animation
+
+## A Note on Performance
+
+This is a demo primarily aimed at explaining how to build a server-side rendered Vue app, as a companion to our SSR documentation. There are a few things we probably won't do in production if we were optimizing for performance, for example:
+
+- This demo uses the Firebase-based HN API to showcase real-time updates, but the Firebase API also comes with a larger bundle, more JavaScript to parse on the client, and doesn't offer an efficient way to batch-fetch pages of items, so it impacts performance quite a bit on a cold start or cache miss.
+
+- In practice, it is unnecessary to code-split for an app of this size (where each async chunk is only a few kilobytes so the extra request isn't really worth it), nor is it optimal to extract an extra CSS file (which is only 1kb).
+
+It is therefore not recommended to use this app as a reference for Vue SSR performance - instead, do your own benchmarking, and make sure to measure and optimize based on your actual app constraints.
## Architecture Overview
+**A detailed Vue SSR guide can be found [here](https://ssr.vuejs.org).**
+
## Build Setup
-**Requires Node.js 6+**
+**Requires Node.js 7+**
``` bash
# install dependencies
-npm install
+npm install # or yarn
# serve in dev mode, with hot reload at localhost:8080
npm run dev
@@ -44,3 +65,7 @@ npm run build
# serve in production mode
npm start
```
+
+## License
+
+MIT
diff --git a/build/setup-dev-server.js b/build/setup-dev-server.js
index 47b6e0438..4c6ab517e 100644
--- a/build/setup-dev-server.js
+++ b/build/setup-dev-server.js
@@ -1,37 +1,85 @@
+const fs = require('fs')
const path = require('path')
-const webpack = require('webpack')
const MFS = require('memory-fs')
+const webpack = require('webpack')
+const chokidar = require('chokidar')
const clientConfig = require('./webpack.client.config')
const serverConfig = require('./webpack.server.config')
-module.exports = function setupDevServer (app, onUpdate) {
- // setup on the fly compilation + hot-reload
+const readFile = (fs, file) => {
+ try {
+ return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
+ } catch (e) {}
+}
+
+module.exports = function setupDevServer (app, templatePath, cb) {
+ let bundle
+ let template
+ let clientManifest
+
+ let ready
+ const readyPromise = new Promise(r => { ready = r })
+ const update = () => {
+ if (bundle && clientManifest) {
+ ready()
+ cb(bundle, {
+ template,
+ clientManifest
+ })
+ }
+ }
+
+ // read template from disk and watch
+ template = fs.readFileSync(templatePath, 'utf-8')
+ chokidar.watch(templatePath).on('change', () => {
+ template = fs.readFileSync(templatePath, 'utf-8')
+ console.log('index.html template updated.')
+ update()
+ })
+
+ // modify client config to work with hot middleware
clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
+ clientConfig.output.filename = '[name].js'
clientConfig.plugins.push(
new webpack.HotModuleReplacementPlugin(),
- new webpack.NoErrorsPlugin()
+ new webpack.NoEmitOnErrorsPlugin()
)
+ // dev middleware
const clientCompiler = webpack(clientConfig)
- app.use(require('webpack-dev-middleware')(clientCompiler, {
+ const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
publicPath: clientConfig.output.publicPath,
- stats: {
- colors: true,
- chunks: false
- }
- }))
- app.use(require('webpack-hot-middleware')(clientCompiler))
+ noInfo: true
+ })
+ app.use(devMiddleware)
+ clientCompiler.plugin('done', stats => {
+ stats = stats.toJson()
+ stats.errors.forEach(err => console.error(err))
+ stats.warnings.forEach(err => console.warn(err))
+ if (stats.errors.length) return
+ clientManifest = JSON.parse(readFile(
+ devMiddleware.fileSystem,
+ 'vue-ssr-client-manifest.json'
+ ))
+ update()
+ })
+
+ // hot middleware
+ app.use(require('webpack-hot-middleware')(clientCompiler, { heartbeat: 5000 }))
// watch and update server renderer
const serverCompiler = webpack(serverConfig)
const mfs = new MFS()
- const outputPath = path.join(serverConfig.output.path, serverConfig.output.filename)
serverCompiler.outputFileSystem = mfs
serverCompiler.watch({}, (err, stats) => {
if (err) throw err
stats = stats.toJson()
- stats.errors.forEach(err => console.error(err))
- stats.warnings.forEach(err => console.warn(err))
- onUpdate(mfs.readFileSync(outputPath, 'utf-8'))
+ if (stats.errors.length) return
+
+ // read bundle generated by vue-ssr-webpack-plugin
+ bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
+ update()
})
+
+ return readyPromise
}
diff --git a/build/vue-loader.config.js b/build/vue-loader.config.js
deleted file mode 100644
index 78f2d50fd..000000000
--- a/build/vue-loader.config.js
+++ /dev/null
@@ -1,7 +0,0 @@
-module.exports = {
- postcss: [
- require('autoprefixer')({
- browsers: ['last 3 versions']
- })
- ]
-}
diff --git a/build/webpack.base.config.js b/build/webpack.base.config.js
index 3bfba23f0..a595f1dcb 100644
--- a/build/webpack.base.config.js
+++ b/build/webpack.base.config.js
@@ -1,42 +1,83 @@
const path = require('path')
const webpack = require('webpack')
-const vueConfig = require('./vue-loader.config')
+const ExtractTextPlugin = require('extract-text-webpack-plugin')
+const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
+const { VueLoaderPlugin } = require('vue-loader')
+
+const isProd = process.env.NODE_ENV === 'production'
module.exports = {
- devtool: '#source-map',
- entry: {
- app: './src/client-entry.js',
- vendor: ['vue', 'vue-router', 'vuex', 'firebase', 'lru-cache', 'es6-promise']
- },
+ devtool: isProd
+ ? false
+ : '#cheap-module-source-map',
output: {
path: path.resolve(__dirname, '../dist'),
publicPath: '/dist/',
- filename: 'client-bundle.js'
+ filename: '[name].[chunkhash].js'
+ },
+ resolve: {
+ alias: {
+ 'public': path.resolve(__dirname, '../public')
+ }
},
module: {
- loaders: [
+ noParse: /es6-promise\.js$/, // avoid webpack shimming process
+ rules: [
{
test: /\.vue$/,
- loader: 'vue'
+ loader: 'vue-loader',
+ options: {
+ compilerOptions: {
+ preserveWhitespace: false
+ }
+ }
},
{
test: /\.js$/,
- loader: 'babel',
+ loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.(png|jpg|gif|svg)$/,
- loader: 'url',
- query: {
+ loader: 'url-loader',
+ options: {
limit: 10000,
name: '[name].[ext]?[hash]'
}
- }
+ },
+ {
+ test: /\.styl(us)?$/,
+ use: isProd
+ ? ExtractTextPlugin.extract({
+ use: [
+ {
+ loader: 'css-loader',
+ options: { minimize: true }
+ },
+ 'stylus-loader'
+ ],
+ fallback: 'vue-style-loader'
+ })
+ : ['vue-style-loader', 'css-loader', 'stylus-loader']
+ },
]
},
- plugins: [
- new webpack.LoaderOptionsPlugin({
- vue: vueConfig
- })
- ]
+ performance: {
+ hints: false
+ },
+ plugins: isProd
+ ? [
+ new VueLoaderPlugin(),
+ new webpack.optimize.UglifyJsPlugin({
+ compress: { warnings: false }
+ }),
+ new webpack.optimize.ModuleConcatenationPlugin(),
+ new ExtractTextPlugin({
+ filename: 'common.[chunkhash].css'
+ })
+ ]
+ : [
+ new VueLoaderPlugin(),
+ new FriendlyErrorsPlugin()
+ ]
}
diff --git a/build/webpack.client.config.js b/build/webpack.client.config.js
index 365de414d..9a7547571 100644
--- a/build/webpack.client.config.js
+++ b/build/webpack.client.config.js
@@ -1,47 +1,73 @@
const webpack = require('webpack')
+const merge = require('webpack-merge')
const base = require('./webpack.base.config')
-const vueConfig = require('./vue-loader.config')
+const SWPrecachePlugin = require('sw-precache-webpack-plugin')
+const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
-const config = Object.assign({}, base, {
- plugins: base.plugins.concat([
- // strip comments in Vue code
+const config = merge(base, {
+ entry: {
+ app: './src/entry-client.js'
+ },
+ resolve: {
+ alias: {
+ 'create-api': './create-api-client.js'
+ }
+ },
+ plugins: [
+ // strip dev-only code in Vue source
new webpack.DefinePlugin({
- 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development')
+ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
+ 'process.env.VUE_ENV': '"client"'
}),
// extract vendor chunks for better caching
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
- filename: 'client-vendor-bundle.js'
- })
- ])
+ minChunks: function (module) {
+ // a module is extracted into the vendor chunk if...
+ return (
+ // it's inside node_modules
+ /node_modules/.test(module.context) &&
+ // and not a CSS file (due to extract-text-webpack-plugin limitation)
+ !/\.css$/.test(module.request)
+ )
+ }
+ }),
+ // extract webpack runtime & manifest to avoid vendor chunk hash changing
+ // on every build.
+ new webpack.optimize.CommonsChunkPlugin({
+ name: 'manifest'
+ }),
+ new VueSSRClientPlugin()
+ ]
})
if (process.env.NODE_ENV === 'production') {
- // Use ExtractTextPlugin to extract CSS into a single file
- // so it's applied on initial render
- const ExtractTextPlugin = require('extract-text-webpack-plugin')
-
- // vueConfig is already included in the config via LoaderOptionsPlugin
- // here we overwrite the loader config for
diff --git a/src/components/Item.vue b/src/components/Item.vue
index 5c201758a..156a68390 100644
--- a/src/components/Item.vue
+++ b/src/components/Item.vue
@@ -3,8 +3,8 @@
{{ item.score }}
- {{ item.title }}
- ({{ item.url | host }})
+ {{ item.title }}
+ ({{ item.url | host }})
{{ item.title }}
@@ -27,12 +27,14 @@
@@ -56,9 +58,9 @@ export default {
margin-top -10px
.meta, .host
font-size .85em
- color #999
+ color #828282
a
- color #999
+ color #828282
text-decoration underline
&:hover
color #ff6600
diff --git a/src/components/ProgressBar.vue b/src/components/ProgressBar.vue
new file mode 100644
index 000000000..795280972
--- /dev/null
+++ b/src/components/ProgressBar.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Spinner.vue b/src/components/Spinner.vue
index 569eb5b02..3bd25dcc0 100644
--- a/src/components/Spinner.vue
+++ b/src/components/Spinner.vue
@@ -1,12 +1,16 @@
-
-
-
+
+
+
+
+
@@ -15,18 +19,15 @@ $offset = 126
$duration = 1.4s
.spinner
- position fixed
- z-index 999
- right 15px
- bottom 15px
- opacity 0
transition opacity .15s ease
animation rotator $duration linear infinite
animation-play-state paused
-
-.spinner.show
- opacity 1
- animation-play-state running
+ &.show
+ animation-play-state running
+ &.v-enter, &.v-leave-active
+ opacity 0
+ &.v-enter-active, &.v-leave
+ opacity 1
@keyframes rotator
0%
@@ -39,7 +40,7 @@ $duration = 1.4s
stroke-dasharray $offset
stroke-dashoffset 0
transform-origin center
- animation dash 1.4s ease-in-out infinite
+ animation dash $duration ease-in-out infinite
@keyframes dash
0%
diff --git a/src/entry-client.js b/src/entry-client.js
new file mode 100644
index 000000000..d5022d4df
--- /dev/null
+++ b/src/entry-client.js
@@ -0,0 +1,68 @@
+import Vue from 'vue'
+import 'es6-promise/auto'
+import { createApp } from './app'
+import ProgressBar from './components/ProgressBar.vue'
+
+// global progress bar
+const bar = Vue.prototype.$bar = new Vue(ProgressBar).$mount()
+document.body.appendChild(bar.$el)
+
+// a global mixin that calls `asyncData` when a route component's params change
+Vue.mixin({
+ beforeRouteUpdate (to, from, next) {
+ const { asyncData } = this.$options
+ if (asyncData) {
+ asyncData({
+ store: this.$store,
+ route: to
+ }).then(next).catch(next)
+ } else {
+ next()
+ }
+ }
+})
+
+const { app, router, store } = createApp()
+
+// prime the store with server-initialized state.
+// the state is determined during SSR and inlined in the page markup.
+if (window.__INITIAL_STATE__) {
+ store.replaceState(window.__INITIAL_STATE__)
+}
+
+// wait until router has resolved all async before hooks
+// and async components...
+router.onReady(() => {
+ // Add router hook for handling asyncData.
+ // Doing it after initial route is resolved so that we don't double-fetch
+ // the data that we already have. Using router.beforeResolve() so that all
+ // async components are resolved.
+ router.beforeResolve((to, from, next) => {
+ const matched = router.getMatchedComponents(to)
+ const prevMatched = router.getMatchedComponents(from)
+ let diffed = false
+ const activated = matched.filter((c, i) => {
+ return diffed || (diffed = (prevMatched[i] !== c))
+ })
+ const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
+ if (!asyncDataHooks.length) {
+ return next()
+ }
+
+ bar.start()
+ Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
+ .then(() => {
+ bar.finish()
+ next()
+ })
+ .catch(next)
+ })
+
+ // actually mount to DOM
+ app.$mount('#app')
+})
+
+// service worker
+if ('https:' === location.protocol && navigator.serviceWorker) {
+ navigator.serviceWorker.register('/service-worker.js')
+}
diff --git a/src/entry-server.js b/src/entry-server.js
new file mode 100644
index 000000000..c72a90594
--- /dev/null
+++ b/src/entry-server.js
@@ -0,0 +1,52 @@
+import { createApp } from './app'
+
+const isDev = process.env.NODE_ENV !== 'production'
+
+// This exported function will be called by `bundleRenderer`.
+// This is where we perform data-prefetching to determine the
+// state of our application before actually rendering it.
+// Since data fetching is async, this function is expected to
+// return a Promise that resolves to the app instance.
+export default context => {
+ return new Promise((resolve, reject) => {
+ const s = isDev && Date.now()
+ const { app, router, store } = createApp()
+
+ const { url } = context
+ const { fullPath } = router.resolve(url).route
+
+ if (fullPath !== url) {
+ return reject({ url: fullPath })
+ }
+
+ // set router's location
+ router.push(url)
+
+ // wait until router has resolved possible async hooks
+ router.onReady(() => {
+ const matchedComponents = router.getMatchedComponents()
+ // no matched routes
+ if (!matchedComponents.length) {
+ return reject({ code: 404 })
+ }
+ // Call fetchData hooks on components matched by the route.
+ // A preFetch hook dispatches a store action and returns a Promise,
+ // which is resolved when the action is complete and store state has been
+ // updated.
+ Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
+ store,
+ route: router.currentRoute
+ }))).then(() => {
+ isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
+ // After all preFetch hooks are resolved, our store is now
+ // filled with the state needed to render the app.
+ // Expose the state on the render context, and let the request handler
+ // inline the state in the HTML response. This allows the client-side
+ // store to pick-up the server-side state without having to duplicate
+ // the initial data fetching on the client.
+ context.state = store.state
+ resolve(app)
+ }).catch(reject)
+ }, reject)
+ })
+}
diff --git a/src/index.template.html b/src/index.template.html
new file mode 100644
index 000000000..56fdcec30
--- /dev/null
+++ b/src/index.template.html
@@ -0,0 +1,23 @@
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/router/index.js b/src/router/index.js
index 61bac2bbb..d6546c520 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -3,21 +3,25 @@ import Router from 'vue-router'
Vue.use(Router)
-import { createListView } from '../views/CreateListView'
-import ItemView from '../views/ItemView.vue'
-import UserView from '../views/UserView.vue'
+// route-level code splitting
+const createListView = id => () => import('../views/CreateListView').then(m => m.default(id))
+const ItemView = () => import('../views/ItemView.vue')
+const UserView = () => import('../views/UserView.vue')
-export default new Router({
- mode: 'history',
- scrollBehavior: () => ({ y: 0 }),
- routes: [
- { path: '/top/:page(\\d+)?', component: createListView('top') },
- { path: '/new/:page(\\d+)?', component: createListView('new') },
- { path: '/show/:page(\\d+)?', component: createListView('show') },
- { path: '/ask/:page(\\d+)?', component: createListView('ask') },
- { path: '/job/:page(\\d+)?', component: createListView('job') },
- { path: '/item/:id(\\d+)', component: ItemView },
- { path: '/user/:id', component: UserView },
- { path: '*', redirect: '/top' }
- ]
-})
+export function createRouter () {
+ return new Router({
+ mode: 'history',
+ fallback: false,
+ scrollBehavior: () => ({ y: 0 }),
+ routes: [
+ { path: '/top/:page(\\d+)?', component: createListView('top') },
+ { path: '/new/:page(\\d+)?', component: createListView('new') },
+ { path: '/show/:page(\\d+)?', component: createListView('show') },
+ { path: '/ask/:page(\\d+)?', component: createListView('ask') },
+ { path: '/job/:page(\\d+)?', component: createListView('job') },
+ { path: '/item/:id(\\d+)', component: ItemView },
+ { path: '/user/:id', component: UserView },
+ { path: '/', redirect: '/top' }
+ ]
+ })
+}
diff --git a/src/server-entry.js b/src/server-entry.js
deleted file mode 100644
index b56f4f0be..000000000
--- a/src/server-entry.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { app, router, store } from './app'
-
-const isDev = process.env.NODE_ENV !== 'production'
-
-// This exported function will be called by `bundleRenderer`.
-// This is where we perform data-prefetching to determine the
-// state of our application before actually rendering it.
-// Since data fetching is async, this function is expected to
-// return a Promise that resolves to the app instance.
-export default context => {
- // set router's location
- router.push(context.url)
-
- const s = isDev && Date.now()
-
- // Call preFetch hooks on components matched by the route.
- // A preFetch hook dispatches a store action and returns a Promise,
- // which is resolved when the action is complete and store state has been
- // updated.
- return Promise.all(router.getMatchedComponents().map(component => {
- if (component.preFetch) {
- return component.preFetch(store)
- }
- })).then(() => {
- isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
- // After all preFetch hooks are resolved, our store is now
- // filled with the state needed to render the app.
- // Expose the state on the render context, and let the request handler
- // inline the state in the HTML response. This allows the client-side
- // store to pick-up the server-side state without having to duplicate
- // the initial data fetching on the client.
- context.initialState = store.state
- return app
- })
-}
diff --git a/src/store/actions.js b/src/store/actions.js
new file mode 100644
index 000000000..258dcfbda
--- /dev/null
+++ b/src/store/actions.js
@@ -0,0 +1,49 @@
+import {
+ fetchUser,
+ fetchItems,
+ fetchIdsByType
+} from '../api'
+
+export default {
+ // ensure data for rendering given list type
+ FETCH_LIST_DATA: ({ commit, dispatch, state }, { type }) => {
+ commit('SET_ACTIVE_TYPE', { type })
+ return fetchIdsByType(type)
+ .then(ids => commit('SET_LIST', { type, ids }))
+ .then(() => dispatch('ENSURE_ACTIVE_ITEMS'))
+ },
+
+ // ensure all active items are fetched
+ ENSURE_ACTIVE_ITEMS: ({ dispatch, getters }) => {
+ return dispatch('FETCH_ITEMS', {
+ ids: getters.activeIds
+ })
+ },
+
+ FETCH_ITEMS: ({ commit, state }, { ids }) => {
+ // on the client, the store itself serves as a cache.
+ // only fetch items that we do not already have, or has expired (3 minutes)
+ const now = Date.now()
+ ids = ids.filter(id => {
+ const item = state.items[id]
+ if (!item) {
+ return true
+ }
+ if (now - item.__lastUpdated > 1000 * 60 * 3) {
+ return true
+ }
+ return false
+ })
+ if (ids.length) {
+ return fetchItems(ids).then(items => commit('SET_ITEMS', { items }))
+ } else {
+ return Promise.resolve()
+ }
+ },
+
+ FETCH_USER: ({ commit, state }, { id }) => {
+ return state.users[id]
+ ? Promise.resolve(state.users[id])
+ : fetchUser(id).then(user => commit('SET_USER', { id, user }))
+ }
+}
diff --git a/src/store/api.js b/src/store/api.js
deleted file mode 100644
index 117627523..000000000
--- a/src/store/api.js
+++ /dev/null
@@ -1,94 +0,0 @@
-import Firebase from 'firebase'
-import LRU from 'lru-cache'
-
-const inBrowser = typeof window !== 'undefined'
-
-// When using bundleRenderer, the server-side application code runs in a new
-// context for each request. To allow caching across multiple requests, we need
-// to attach the cache to the process which is shared across all requests.
-const cache = inBrowser
- ? null
- : (process.__API_CACHE__ || (process.__API_CACHE__ = createCache()))
-
-function createCache () {
- return LRU({
- max: 1000,
- maxAge: 1000 * 60 * 15 // 15 min cache
- })
-}
-
-// create a single api instance for all server-side requests
-const api = inBrowser
- ? new Firebase('https://hacker-news.firebaseio.com/v0')
- : (process.__API__ || (process.__API__ = createServerSideAPI()))
-
-function createServerSideAPI () {
- const api = new Firebase('https://hacker-news.firebaseio.com/v0')
-
- // cache the latest story ids
- api.__ids__ = {}
- ;['top', 'new', 'show', 'ask', 'job'].forEach(type => {
- api.child(`${type}stories`).on('value', snapshot => {
- api.__ids__[type] = snapshot.val()
- })
- })
-
- // warm the front page cache every 15 min
- warmCache()
- function warmCache () {
- fetchItems((api.__ids__.top || []).slice(0, 30))
- setTimeout(warmCache, 1000 * 60 * 15)
- }
-
- return api
-}
-
-function fetch (child) {
- if (cache && cache.has(child)) {
- return Promise.resolve(cache.get(child))
- } else {
- return new Promise((resolve, reject) => {
- api.child(child).once('value', snapshot => {
- const val = snapshot.val()
- // mark the timestamp when this item is cached
- val.__lastUpdated = Date.now()
- cache && cache.set(child, val)
- resolve(val)
- }, reject)
- })
- }
-}
-
-export function fetchIdsByType (type) {
- return api.__ids__ && api.__ids__[type]
- ? Promise.resolve(api.__ids__[type])
- : fetch(`${type}stories`)
-}
-
-export function fetchItem (id) {
- return fetch(`item/${id}`)
-}
-
-export function fetchItems (ids) {
- return Promise.all(ids.map(id => fetchItem(id)))
-}
-
-export function fetchUser (id) {
- return fetch(`user/${id}`)
-}
-
-export function watchList (type, cb) {
- let first = true
- const ref = api.child(`${type}stories`)
- const handler = snapshot => {
- if (first) {
- first = false
- } else {
- cb(snapshot.val())
- }
- }
- ref.on('value', handler)
- return () => {
- ref.off('value', handler)
- }
-}
diff --git a/src/store/getters.js b/src/store/getters.js
new file mode 100644
index 000000000..71019dfdd
--- /dev/null
+++ b/src/store/getters.js
@@ -0,0 +1,23 @@
+export default {
+ // ids of the items that should be currently displayed based on
+ // current list type and current pagination
+ activeIds (state) {
+ const { activeType, itemsPerPage, lists } = state
+
+ if (!activeType) {
+ return []
+ }
+
+ const page = Number(state.route.params.page) || 1
+ const start = (page - 1) * itemsPerPage
+ const end = page * itemsPerPage
+
+ return lists[activeType].slice(start, end)
+ },
+
+ // items that should be currently displayed.
+ // this Array may not be fully fetched.
+ activeItems (state, getters) {
+ return getters.activeIds.map(id => state.items[id]).filter(_ => _)
+ }
+}
diff --git a/src/store/index.js b/src/store/index.js
index 3e34700be..32d8fbc54 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -1,100 +1,28 @@
import Vue from 'vue'
import Vuex from 'vuex'
-import { fetchItem, fetchItems, fetchIdsByType, fetchUser } from './api'
+import actions from './actions'
+import mutations from './mutations'
+import getters from './getters'
Vue.use(Vuex)
-const store = new Vuex.Store({
- state: {
- activeType: null,
- itemsPerPage: 20,
- items: {/* [id: number]: Item */},
- users: {/* [id: string]: User */},
- lists: {
- top: [/* number */],
- new: [],
- show: [],
- ask: [],
- job: []
- }
- },
-
- actions: {
- // ensure data for rendering given list type
- FETCH_LIST_DATA: ({ commit, dispatch, state }, { type }) => {
- commit('SET_ACTIVE_TYPE', { type })
- return fetchIdsByType(type)
- .then(ids => commit('SET_LIST', { type, ids }))
- .then(() => dispatch('ENSURE_ACTIVE_ITEMS'))
- },
-
- // ensure all active items are fetched
- ENSURE_ACTIVE_ITEMS: ({ dispatch, getters }) => {
- return dispatch('FETCH_ITEMS', {
- ids: getters.activeIds
- })
- },
-
- FETCH_ITEMS: ({ commit, state }, { ids }) => {
- // only fetch items that we don't already have.
- ids = ids.filter(id => !state.items[id])
- if (ids.length) {
- return fetchItems(ids).then(items => commit('SET_ITEMS', { items }))
- } else {
- return Promise.resolve()
- }
- },
-
- FETCH_USER: ({ commit, state }, { id }) => {
- return state.users[id]
- ? Promise.resolve(state.users[id])
- : fetchUser(id).then(user => commit('SET_USER', { user }))
- }
- },
-
- mutations: {
- SET_ACTIVE_TYPE: (state, { type }) => {
- state.activeType = type
- },
-
- SET_LIST: (state, { type, ids }) => {
- state.lists[type] = ids
- },
-
- SET_ITEMS: (state, { items }) => {
- items.forEach(item => {
- if (item) {
- Vue.set(state.items, item.id, item)
- }
- })
- },
-
- SET_USER: (state, { user }) => {
- Vue.set(state.users, user.id, user)
- }
- },
-
- getters: {
- // ids of the items that should be currently displayed based on
- // current list type and current pagination
- activeIds (state) {
- const { activeType, itemsPerPage, lists } = state
- const page = Number(state.route.params.page) || 1
- if (activeType) {
- const start = (page - 1) * itemsPerPage
- const end = page * itemsPerPage
- return lists[activeType].slice(start, end)
- } else {
- return []
+export function createStore () {
+ return new Vuex.Store({
+ state: {
+ activeType: null,
+ itemsPerPage: 20,
+ items: {/* [id: number]: Item */},
+ users: {/* [id: string]: User */},
+ lists: {
+ top: [/* number */],
+ new: [],
+ show: [],
+ ask: [],
+ job: []
}
},
-
- // items that should be currently displayed.
- // this Array may not be fully fetched.
- activeItems (state, getters) {
- return getters.activeIds.map(id => state.items[id]).filter(_ => _)
- }
- }
-})
-
-export default store
+ actions,
+ mutations,
+ getters
+ })
+}
diff --git a/src/store/mutations.js b/src/store/mutations.js
new file mode 100644
index 000000000..591fa0b5f
--- /dev/null
+++ b/src/store/mutations.js
@@ -0,0 +1,23 @@
+import Vue from 'vue'
+
+export default {
+ SET_ACTIVE_TYPE: (state, { type }) => {
+ state.activeType = type
+ },
+
+ SET_LIST: (state, { type, ids }) => {
+ state.lists[type] = ids
+ },
+
+ SET_ITEMS: (state, { items }) => {
+ items.forEach(item => {
+ if (item) {
+ Vue.set(state.items, item.id, item)
+ }
+ })
+ },
+
+ SET_USER: (state, { id, user }) => {
+ Vue.set(state.users, id, user || false) /* false means user not found */
+ }
+}
diff --git a/src/filters/index.js b/src/util/filters.js
similarity index 100%
rename from src/filters/index.js
rename to src/util/filters.js
diff --git a/src/util/title.js b/src/util/title.js
new file mode 100644
index 000000000..664e0594c
--- /dev/null
+++ b/src/util/title.js
@@ -0,0 +1,30 @@
+function getTitle (vm) {
+ const { title } = vm.$options
+ if (title) {
+ return typeof title === 'function'
+ ? title.call(vm)
+ : title
+ }
+}
+
+const serverTitleMixin = {
+ created () {
+ const title = getTitle(this)
+ if (title) {
+ this.$ssrContext.title = `Vue HN 2.0 | ${title}`
+ }
+ }
+}
+
+const clientTitleMixin = {
+ mounted () {
+ const title = getTitle(this)
+ if (title) {
+ document.title = `Vue HN 2.0 | ${title}`
+ }
+ }
+}
+
+export default process.env.VUE_ENV === 'server'
+ ? serverTitleMixin
+ : clientTitleMixin
diff --git a/src/views/CreateListView.js b/src/views/CreateListView.js
index fcb110b28..5c94cc149 100644
--- a/src/views/CreateListView.js
+++ b/src/views/CreateListView.js
@@ -1,15 +1,20 @@
-import ItemList from '../components/ItemList.vue'
+import ItemList from './ItemList.vue'
+
+const camelize = str => str.charAt(0).toUpperCase() + str.slice(1)
// This is a factory function for dynamically creating root-level list views,
// since they share most of the logic except for the type of items to display.
// They are essentially higher order components wrapping ItemList.vue.
-export function createListView (type) {
+export default function createListView (type) {
return {
name: `${type}-stories-view`,
- // this will be called during SSR to pre-fetch data into the store!
- preFetch (store) {
+
+ asyncData ({ store }) {
return store.dispatch('FETCH_LIST_DATA', { type })
},
+
+ title: camelize(type),
+
render (h) {
return h(ItemList, { props: { type }})
}
diff --git a/src/components/ItemList.vue b/src/views/ItemList.vue
similarity index 75%
rename from src/components/ItemList.vue
rename to src/views/ItemList.vue
index d4d0b2382..063113d08 100644
--- a/src/components/ItemList.vue
+++ b/src/views/ItemList.vue
@@ -1,6 +1,5 @@
-
< prev
< prev
@@ -20,15 +19,13 @@
-