diff --git a/.babelrc b/.babelrc new file mode 100644 index 000000000..4f95cb077 --- /dev/null +++ b/.babelrc @@ -0,0 +1,8 @@ +{ + "presets": [ + ["env", { "modules": false }] + ], + "plugins": [ + "syntax-dynamic-import" + ] +} diff --git a/.gitignore b/.gitignore index 0ef619628..2d25aef65 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ node_modules/ dist/ npm-debug.log +yarn-error.log .idea *.iml \ No newline at end of file diff --git a/README.md b/README.md index d21acd894..01a04c763 100644 --- a/README.md +++ b/README.md @@ -3,34 +3,54 @@ 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 screen shot 2016-08-11 at 6 06 57 pm +**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 diff --git a/build/setup-dev-server.js b/build/setup-dev-server.js index 16ac8e75b..4c6ab517e 100644 --- a/build/setup-dev-server.js +++ b/build/setup-dev-server.js @@ -1,12 +1,41 @@ +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, cb) { +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] @@ -23,19 +52,20 @@ module.exports = function setupDevServer (app, cb) { noInfo: true }) app.use(devMiddleware) - clientCompiler.plugin('done', () => { - const fs = devMiddleware.fileSystem - const filePath = path.join(clientConfig.output.path, 'index.html') - if (fs.existsSync(filePath)) { - template = fs.readFileSync(filePath, 'utf-8') - if (bundle) { - cb(bundle, template) - } - } + 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)) + app.use(require('webpack-hot-middleware')(clientCompiler, { heartbeat: 5000 })) // watch and update server renderer const serverCompiler = webpack(serverConfig) @@ -44,14 +74,12 @@ module.exports = function setupDevServer (app, cb) { 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)) + if (stats.errors.length) return // read bundle generated by vue-ssr-webpack-plugin - const bundlePath = path.join(serverConfig.output.path, 'vue-ssr-bundle.json') - bundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8')) - if (template) { - cb(bundle, template) - } + 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 74434e82b..000000000 --- a/build/vue-loader.config.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - preserveWhitespace: false, - postcss: [ - require('autoprefixer')({ - browsers: ['last 3 versions'] - }) - ] -} diff --git a/build/webpack.base.config.js b/build/webpack.base.config.js index 1eb04d4f2..a595f1dcb 100644 --- a/build/webpack.base.config.js +++ b/build/webpack.base.config.js @@ -1,25 +1,15 @@ const path = require('path') -const vueConfig = require('./vue-loader.config') +const webpack = require('webpack') +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: isProd ? false - : '#cheap-module-eval-source-map', - entry: { - app: './src/entry-client.js', - vendor: [ - 'es6-promise/auto', - 'firebase/app', - 'firebase/database', - 'vue', - 'vue-router', - 'vuex', - 'vuex-router-sync' - ] - }, + : '#cheap-module-source-map', output: { path: path.resolve(__dirname, '../dist'), publicPath: '/dist/', @@ -36,15 +26,16 @@ module.exports = { { test: /\.vue$/, loader: 'vue-loader', - options: vueConfig + options: { + compilerOptions: { + preserveWhitespace: false + } + } }, { test: /\.js$/, - loader: 'buble-loader', - exclude: /node_modules/, - options: { - objectAssign: 'Object.assign' - } + loader: 'babel-loader', + exclude: /node_modules/ }, { test: /\.(png|jpg|gif|svg)$/, @@ -53,14 +44,40 @@ module.exports = { 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'] + }, ] }, performance: { - maxEntrypointSize: 300000, - hints: isProd ? 'warning' : false + hints: false }, - plugins: isProd ? [] : [ - new FriendlyErrorsPlugin() - ] + 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 2fcf0280e..9a7547571 100644 --- a/build/webpack.client.config.js +++ b/build/webpack.client.config.js @@ -1,10 +1,13 @@ const webpack = require('webpack') const merge = require('webpack-merge') const base = require('./webpack.base.config') -const HTMLPlugin = require('html-webpack-plugin') const SWPrecachePlugin = require('sw-precache-webpack-plugin') +const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') const config = merge(base, { + entry: { + app: './src/entry-client.js' + }, resolve: { alias: { 'create-api': './create-api-client.js' @@ -18,29 +21,53 @@ const config = merge(base, { }), // extract vendor chunks for better caching new webpack.optimize.CommonsChunkPlugin({ - name: ['vendor', 'manifest'] + name: 'vendor', + 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) + ) + } }), - // generate output HTML - new HTMLPlugin({ - template: 'src/index.template.html' - }) + // 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') { config.plugins.push( - // minify JS - new webpack.optimize.UglifyJsPlugin({ - compress: { - warnings: false - } - }), // auto generate service worker new SWPrecachePlugin({ cacheId: 'vue-hn', filename: 'service-worker.js', + minify: true, dontCacheBustUrlsMatching: /./, - staticFileGlobsIgnorePatterns: [/index\.html$/, /\.map$/] + staticFileGlobsIgnorePatterns: [/\.map$/, /\.json$/], + runtimeCaching: [ + { + urlPattern: '/', + handler: 'networkFirst' + }, + { + urlPattern: /\/(top|new|show|ask|jobs)/, + handler: 'networkFirst' + }, + { + urlPattern: '/item/:id', + handler: 'networkFirst' + }, + { + urlPattern: '/user/:id', + handler: 'networkFirst' + } + ] }) ) } diff --git a/build/webpack.server.config.js b/build/webpack.server.config.js index 42013cce7..615e6a3f5 100644 --- a/build/webpack.server.config.js +++ b/build/webpack.server.config.js @@ -1,7 +1,8 @@ const webpack = require('webpack') const merge = require('webpack-merge') const base = require('./webpack.base.config') -const VueSSRPlugin = require('vue-ssr-webpack-plugin') +const nodeExternals = require('webpack-node-externals') +const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') module.exports = merge(base, { target: 'node', @@ -16,12 +17,17 @@ module.exports = merge(base, { 'create-api': './create-api-server.js' } }, - externals: Object.keys(require('../package.json').dependencies), + // https://webpack.js.org/configuration/externals/#externals + // https://github.com/liady/webpack-node-externals + externals: nodeExternals({ + // do not externalize CSS files in case we need to import it from a dep + whitelist: /\.css$/ + }), plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 'process.env.VUE_ENV': '"server"' }), - new VueSSRPlugin() + new VueSSRServerPlugin() ] }) diff --git a/manifest.json b/manifest.json index 10741bdec..5e30b54cb 100644 --- a/manifest.json +++ b/manifest.json @@ -17,10 +17,18 @@ "src": "/public/logo-192.png", "sizes": "192x192", "type": "image/png" - },{ + }, { + "src": "/public/logo-256.png", + "sizes": "256x256", + "type": "image/png" + }, { "src": "/public/logo-384.png", "sizes": "384x384", "type": "image/png" + }, { + "src": "/public/logo-512.png", + "sizes": "512x512", + "type": "image/png" }], "start_url": "/", "background_color": "#f2f3f5", diff --git a/package.json b/package.json index 38eddd546..b8c7a74c8 100644 --- a/package.json +++ b/package.json @@ -8,46 +8,50 @@ "start": "cross-env NODE_ENV=production node server", "build": "rimraf dist && npm run build:client && npm run build:server", "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js --progress --hide-modules", - "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js --progress --hide-modules" + "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js --progress --hide-modules", + "postinstall": "npm run build" }, "engines": { "node": ">=7.0", "npm": ">=4.0" }, "dependencies": { - "compression": "^1.6.2", - "cross-env": "^3.2.4", - "es6-promise": "^4.1.0", - "express": "^4.15.2", - "firebase": "^3.7.2", - "lru-cache": "^4.0.2", - "serve-favicon": "^2.4.1", - "vue": "^2.2.4", - "vue-router": "^2.3.0", - "vue-server-renderer": "^2.2.4", - "vue-style-loader": "^2.0.4", - "vuex": "^2.2.1", - "vuex-router-sync": "^4.1.2", - "webpack-merge": "^4.0.0" + "compression": "^1.7.1", + "cross-env": "^5.1.1", + "es6-promise": "^4.1.1", + "express": "^4.16.2", + "extract-text-webpack-plugin": "^3.0.2", + "firebase": "4.6.2", + "lru-cache": "^4.1.1", + "route-cache": "0.4.3", + "serve-favicon": "^2.4.5", + "vue": "^2.5.22", + "vue-router": "^3.0.1", + "vue-server-renderer": "^2.5.22", + "vuex": "^3.0.1", + "vuex-router-sync": "^5.0.0" }, "devDependencies": { - "autoprefixer": "^6.7.7", - "buble": "^0.15.2", - "buble-loader": "^0.4.1", - "css-loader": "^0.27.3", - "file-loader": "^0.10.1", + "autoprefixer": "^7.1.6", + "babel-core": "^6.26.0", + "babel-loader": "^7.1.2", + "babel-plugin-syntax-dynamic-import": "^6.18.0", + "babel-preset-env": "^1.6.1", + "chokidar": "^1.7.0", + "css-loader": "^0.28.7", + "file-loader": "^1.1.5", "friendly-errors-webpack-plugin": "^1.6.1", - "html-webpack-plugin": "^2.28.0", - "rimraf": "^2.6.1", + "rimraf": "^2.6.2", "stylus": "^0.54.5", "stylus-loader": "^3.0.1", - "sw-precache-webpack-plugin": "^0.9.1", - "url-loader": "^0.5.8", - "vue-loader": "^11.1.4", - "vue-ssr-webpack-plugin": "^1.0.2", - "vue-template-compiler": "^2.2.4", - "webpack": "^2.2.1", - "webpack-dev-middleware": "^1.10.1", - "webpack-hot-middleware": "^2.17.1" + "sw-precache-webpack-plugin": "^0.11.4", + "url-loader": "^0.6.2", + "vue-loader": "^15.3.0", + "vue-template-compiler": "^2.5.22", + "webpack": "^3.8.1", + "webpack-dev-middleware": "^1.12.0", + "webpack-hot-middleware": "^2.20.0", + "webpack-merge": "^4.2.1", + "webpack-node-externals": "^1.7.2" } } diff --git a/public/logo-256.png b/public/logo-256.png new file mode 100644 index 000000000..10e8600f3 Binary files /dev/null and b/public/logo-256.png differ diff --git a/public/logo-512.png b/public/logo-512.png new file mode 100644 index 000000000..ac8f68624 Binary files /dev/null and b/public/logo-512.png differ diff --git a/server.js b/server.js index fd9662b74..f7b7330b5 100644 --- a/server.js +++ b/server.js @@ -1,48 +1,66 @@ const fs = require('fs') const path = require('path') +const LRU = require('lru-cache') const express = require('express') const favicon = require('serve-favicon') const compression = require('compression') +const microcache = require('route-cache') const resolve = file => path.resolve(__dirname, file) +const { createBundleRenderer } = require('vue-server-renderer') const isProd = process.env.NODE_ENV === 'production' +const useMicroCache = process.env.MICRO_CACHE !== 'false' const serverInfo = `express/${require('express/package.json').version} ` + `vue-server-renderer/${require('vue-server-renderer/package.json').version}` const app = express() +function createRenderer (bundle, options) { + // https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/README.md#why-use-bundlerenderer + return createBundleRenderer(bundle, Object.assign(options, { + // for component caching + cache: LRU({ + max: 1000, + maxAge: 1000 * 60 * 15 + }), + // this is only needed when vue-server-renderer is npm-linked + basedir: resolve('./dist'), + // recommended for performance + runInNewContext: false + })) +} + let renderer +let readyPromise +const templatePath = resolve('./src/index.template.html') if (isProd) { - // In production: create server renderer using server bundle and index HTML - // template from real fs. + // In production: create server renderer using template and built server bundle. // The server bundle is generated by vue-ssr-webpack-plugin. - const bundle = require('./dist/vue-ssr-bundle.json') - // src/index.template.html is processed by html-webpack-plugin to inject - // build assets and output as dist/index.html. - const template = fs.readFileSync(resolve('./dist/index.html'), 'utf-8') - renderer = createRenderer(bundle, template) + const template = fs.readFileSync(templatePath, 'utf-8') + const bundle = require('./dist/vue-ssr-server-bundle.json') + // The client manifests are optional, but it allows the renderer + // to automatically infer preload/prefetch links and directly add + + diff --git a/src/components/Spinner.vue b/src/components/Spinner.vue index 08ec343db..3bd25dcc0 100644 --- a/src/components/Spinner.vue +++ b/src/components/Spinner.vue @@ -19,10 +19,6 @@ $offset = 126 $duration = 1.4s .spinner - position fixed - z-index 999 - right 15px - bottom 15px transition opacity .15s ease animation rotator $duration linear infinite animation-play-state paused diff --git a/src/entry-client.js b/src/entry-client.js index 3353087ba..d5022d4df 100644 --- a/src/entry-client.js +++ b/src/entry-client.js @@ -1,5 +1,28 @@ +import Vue from 'vue' import 'es6-promise/auto' -import { app, store, router } from './app' +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. @@ -10,11 +33,36 @@ if (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 (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { +if ('https:' === location.protocol && navigator.serviceWorker) { navigator.serviceWorker.register('/service-worker.js') } diff --git a/src/entry-server.js b/src/entry-server.js index de56aa8bf..c72a90594 100644 --- a/src/entry-server.js +++ b/src/entry-server.js @@ -1,4 +1,4 @@ -import { app, router, store } from './app' +import { createApp } from './app' const isDev = process.env.NODE_ENV !== 'production' @@ -8,26 +8,35 @@ const isDev = process.env.NODE_ENV !== 'production' // Since data fetching is async, this function is expected to // return a Promise that resolves to the app instance. export default context => { - const s = isDev && Date.now() - 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(context.url) + router.push(url) // wait until router has resolved possible async hooks router.onReady(() => { const matchedComponents = router.getMatchedComponents() // no matched routes if (!matchedComponents.length) { - reject({ code: 404 }) + return reject({ code: 404 }) } - // Call preFetch hooks on components matched by the route. + // 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(component => { - return component.preFetch && component.preFetch(store) - })).then(() => { + 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. @@ -38,6 +47,6 @@ export default context => { context.state = store.state resolve(app) }).catch(reject) - }) + }, reject) }) } diff --git a/src/index.template.html b/src/index.template.html index 150732847..56fdcec30 100644 --- a/src/index.template.html +++ b/src/index.template.html @@ -1,19 +1,23 @@ + {{ title }} - Vue HN 2.0 - + + + + - <% for (var chunk of webpack.chunks) { - for (var file of chunk.files) { - if (file.match(/\.(js|css)$/)) { %> - <% }}} %> + - + + diff --git a/src/router/index.js b/src/router/index.js index 7532b3e0b..d6546c520 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -3,34 +3,25 @@ import Router from 'vue-router' Vue.use(Router) -// We are using Webpack code splitting here so that each route's associated -// component code is loaded on-demand only when the route is visited. -// It's actually not really necessary for a small project of this size but -// the goal is to demonstrate how to do it. -// -// Note that the dynamic import syntax should actually be just `import()` -// but buble/acorn doesn't support parsing that syntax until it's stage 4 -// so we use the old System.import here instead. -// -// If using Babel, `import()` can be supported via -// babel-plugin-syntax-dynamic-import. +// 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') -const createListView = name => () => - System.import('../views/CreateListView').then(m => m.createListView(name)) -const ItemView = () => System.import('../views/ItemView.vue') -const UserView = () => System.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/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/create-api-client.js b/src/store/create-api-client.js deleted file mode 100644 index bbff23279..000000000 --- a/src/store/create-api-client.js +++ /dev/null @@ -1,11 +0,0 @@ -import Firebase from 'firebase/app' -import 'firebase/database' - -const config = { - databaseURL: 'https://hacker-news.firebaseio.com/' -} -const version = '/v0' - -Firebase.initializeApp(config) -const api = Firebase.database().ref(version) -export default api \ No newline at end of file diff --git a/src/store/create-api-server.js b/src/store/create-api-server.js deleted file mode 100644 index ff04a1cd1..000000000 --- a/src/store/create-api-server.js +++ /dev/null @@ -1,33 +0,0 @@ -import Firebase from 'firebase' -import LRU from 'lru-cache' -import { fetchItems } from './api' - -let api -const config = { - databaseURL: 'https://hacker-news.firebaseio.com/' -} -const version = '/v0' - -if (process.__API__) { - api = process.__API__ -} else { - Firebase.initializeApp(config) - api = process.__API__ = Firebase.database().ref(version) - api.onServer = true - - // fetched item cache - api.cachedItems = LRU({ - max: 1000, - maxAge: 1000 * 60 * 15 // 15 min cache - }) - - // cache the latest story ids - api.cachedIds = {} - ;['top', 'new', 'show', 'ask', 'job'].forEach(type => { - api.child(`${type}stories`).on('value', snapshot => { - api.cachedIds[type] = snapshot.val() - }) - }) -} - -export default api 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 e353bbfec..32d8fbc54 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1,111 +1,28 @@ import Vue from 'vue' import Vuex from 'vuex' -import { 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 }) => { - // 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', { 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 76% rename from src/components/ItemList.vue rename to src/views/ItemList.vue index 9c0001da1..063113d08 100644 --- a/src/components/ItemList.vue +++ b/src/views/ItemList.vue @@ -1,6 +1,5 @@