Repository: vuejs/vue-hackernews-2.0 Branch: master Commit: 98399b55c6f1 Files: 34 Total size: 48.3 KB Directory structure: gitextract_ha5ywnnr/ ├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── build/ │ ├── setup-dev-server.js │ ├── webpack.base.config.js │ ├── webpack.client.config.js │ └── webpack.server.config.js ├── manifest.json ├── package.json ├── server.js └── src/ ├── App.vue ├── api/ │ ├── create-api-client.js │ ├── create-api-server.js │ └── index.js ├── app.js ├── components/ │ ├── Comment.vue │ ├── Item.vue │ ├── ProgressBar.vue │ └── Spinner.vue ├── entry-client.js ├── entry-server.js ├── index.template.html ├── router/ │ └── index.js ├── store/ │ ├── actions.js │ ├── getters.js │ ├── index.js │ └── mutations.js ├── util/ │ ├── filters.js │ └── title.js └── views/ ├── CreateListView.js ├── ItemList.vue ├── ItemView.vue └── UserView.vue ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "presets": [ ["env", { "modules": false }] ], "plugins": [ "syntax-dynamic-import" ] } ================================================ FILE: .gitignore ================================================ .DS_Store node_modules/ dist/ npm-debug.log yarn-error.log .idea *.iml ================================================ FILE: LICENSE ================================================ 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. ================================================ FILE: README.md ================================================ # vue-hackernews-2.0 HackerNews clone built with Vue 2.0 + vue-router + vuex, with server-side rendering.


Live Demo

## 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 - 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 7+** ``` bash # install dependencies npm install # or yarn # serve in dev mode, with hot reload at localhost:8080 npm run dev # build for production npm run build # serve in production mode npm start ``` ## License MIT ================================================ FILE: build/setup-dev-server.js ================================================ const fs = require('fs') const path = require('path') 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') 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.NoEmitOnErrorsPlugin() ) // dev middleware const clientCompiler = webpack(clientConfig) const devMiddleware = require('webpack-dev-middleware')(clientCompiler, { publicPath: clientConfig.output.publicPath, 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() serverCompiler.outputFileSystem = mfs serverCompiler.watch({}, (err, stats) => { if (err) throw err stats = stats.toJson() 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 } ================================================ FILE: build/webpack.base.config.js ================================================ const path = require('path') 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-source-map', output: { path: path.resolve(__dirname, '../dist'), publicPath: '/dist/', filename: '[name].[chunkhash].js' }, resolve: { alias: { 'public': path.resolve(__dirname, '../public') } }, module: { noParse: /es6-promise\.js$/, // avoid webpack shimming process rules: [ { test: /\.vue$/, loader: 'vue-loader', options: { compilerOptions: { preserveWhitespace: false } } }, { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ }, { test: /\.(png|jpg|gif|svg)$/, 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'] }, ] }, 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() ] } ================================================ FILE: build/webpack.client.config.js ================================================ const webpack = require('webpack') const merge = require('webpack-merge') const base = require('./webpack.base.config') 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' } }, plugins: [ // strip dev-only code in Vue source new webpack.DefinePlugin({ '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', 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') { config.plugins.push( // auto generate service worker new SWPrecachePlugin({ cacheId: 'vue-hn', filename: 'service-worker.js', minify: true, dontCacheBustUrlsMatching: /./, 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' } ] }) ) } module.exports = config ================================================ FILE: build/webpack.server.config.js ================================================ const webpack = require('webpack') const merge = require('webpack-merge') const base = require('./webpack.base.config') const nodeExternals = require('webpack-node-externals') const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') module.exports = merge(base, { target: 'node', devtool: '#source-map', entry: './src/entry-server.js', output: { filename: 'server-bundle.js', libraryTarget: 'commonjs2' }, resolve: { alias: { 'create-api': './create-api-server.js' } }, // 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 VueSSRServerPlugin() ] }) ================================================ FILE: manifest.json ================================================ { "name": "Vue Hackernews 2.0", "short_name": "Vue HN", "icons": [{ "src": "/public/logo-120.png", "sizes": "120x120", "type": "image/png" }, { "src": "/public/logo-144.png", "sizes": "144x144", "type": "image/png" }, { "src": "/public/logo-152.png", "sizes": "152x152", "type": "image/png" }, { "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", "display": "standalone", "theme_color": "#f60" } ================================================ FILE: package.json ================================================ { "name": "vue-hackernews-2.0", "description": "A Vue.js project", "author": "Evan You ", "private": true, "scripts": { "dev": "node server", "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", "postinstall": "npm run build" }, "engines": { "node": ">=7.0", "npm": ">=4.0" }, "dependencies": { "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": "^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", "rimraf": "^2.6.2", "stylus": "^0.54.5", "stylus-loader": "^3.0.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" } } ================================================ FILE: server.js ================================================ 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 template and built server bundle. // The server bundle is generated by vue-ssr-webpack-plugin. 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 ================================================ FILE: src/components/Item.vue ================================================ ================================================ FILE: src/components/ProgressBar.vue ================================================ ================================================ FILE: src/components/Spinner.vue ================================================ ================================================ FILE: src/entry-client.js ================================================ 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') } ================================================ FILE: src/entry-server.js ================================================ 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) }) } ================================================ FILE: src/index.template.html ================================================ {{ title }} ================================================ FILE: src/router/index.js ================================================ import Vue from 'vue' import Router from 'vue-router' Vue.use(Router) // 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 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' } ] }) } ================================================ FILE: src/store/actions.js ================================================ 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 })) } } ================================================ FILE: src/store/getters.js ================================================ 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(_ => _) } } ================================================ FILE: src/store/index.js ================================================ import Vue from 'vue' import Vuex from 'vuex' import actions from './actions' import mutations from './mutations' import getters from './getters' Vue.use(Vuex) 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: [] } }, actions, mutations, getters }) } ================================================ FILE: src/store/mutations.js ================================================ 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 */ } } ================================================ FILE: src/util/filters.js ================================================ export function host (url) { const host = url.replace(/^https?:\/\//, '').replace(/\/.*$/, '') const parts = host.split('.').slice(-3) if (parts[0] === 'www') parts.shift() return parts.join('.') } export function timeAgo (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' } ================================================ FILE: src/util/title.js ================================================ 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 ================================================ FILE: src/views/CreateListView.js ================================================ 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 default function createListView (type) { return { name: `${type}-stories-view`, asyncData ({ store }) { return store.dispatch('FETCH_LIST_DATA', { type }) }, title: camelize(type), render (h) { return h(ItemList, { props: { type }}) } } } ================================================ FILE: src/views/ItemList.vue ================================================ ================================================ FILE: src/views/ItemView.vue ================================================ ================================================ FILE: src/views/UserView.vue ================================================