diff --git a/README.md b/README.md index 1aec3a95f..01a04c763 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ HackerNews clone built with Vue 2.0 + vue-router + vuex, with server-side rendering.

- +
Live Demo @@ -12,7 +12,7 @@ HackerNews clone built with Vue 2.0 + vue-router + vuex, with server-side render ## 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. In real apps, you should always measure and optimize based on your actual app constraints. +> 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 @@ -32,6 +32,16 @@ HackerNews clone built with Vue 2.0 + vue-router + vuex, with server-side render - 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 diff --git a/build/setup-dev-server.js b/build/setup-dev-server.js index 53c9fca55..4c6ab517e 100644 --- a/build/setup-dev-server.js +++ b/build/setup-dev-server.js @@ -1,6 +1,8 @@ +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') @@ -10,15 +12,31 @@ const readFile = (fs, file) => { } catch (e) {} } -module.exports = function setupDevServer (app, cb) { - let bundle, clientManifest - let resolve - const readyPromise = new Promise(r => { resolve = r }) - const ready = (...args) => { - resolve() - cb(...args) +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' @@ -39,20 +57,15 @@ module.exports = function setupDevServer (app, cb) { 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' )) - if (bundle) { - ready(bundle, { - clientManifest - }) - } + 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) @@ -65,11 +78,7 @@ module.exports = function setupDevServer (app, cb) { // read bundle generated by vue-ssr-webpack-plugin bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json')) - if (clientManifest) { - ready(bundle, { - clientManifest - }) - } + update() }) return readyPromise diff --git a/build/vue-loader.config.js b/build/vue-loader.config.js deleted file mode 100644 index e4ae82189..000000000 --- a/build/vue-loader.config.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = { - extractCSS: process.env.NODE_ENV === 'production', - preserveWhitespace: false, - postcss: [ - require('autoprefixer')({ - browsers: ['last 3 versions'] - }) - ] -} diff --git a/build/webpack.base.config.js b/build/webpack.base.config.js index d72c0203a..a595f1dcb 100644 --- a/build/webpack.base.config.js +++ b/build/webpack.base.config.js @@ -1,8 +1,8 @@ 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' @@ -26,7 +26,11 @@ module.exports = { { test: /\.vue$/, loader: 'vue-loader', - options: vueConfig + options: { + compilerOptions: { + preserveWhitespace: false + } + } }, { test: /\.js$/, @@ -42,30 +46,38 @@ module.exports = { } }, { - test: /\.css$/, + test: /\.styl(us)?$/, use: isProd ? ExtractTextPlugin.extract({ - use: 'css-loader?minimize', + use: [ + { + loader: 'css-loader', + options: { minimize: true } + }, + 'stylus-loader' + ], fallback: 'vue-style-loader' }) - : ['vue-style-loader', 'css-loader'] - } + : ['vue-style-loader', 'css-loader', 'stylus-loader'] + }, ] }, performance: { - maxEntrypointSize: 300000, - hints: isProd ? 'warning' : false + 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 53ea05a36..9a7547571 100644 --- a/build/webpack.client.config.js +++ b/build/webpack.client.config.js @@ -1,4 +1,3 @@ -const glob = require('glob') const webpack = require('webpack') const merge = require('webpack-merge') const base = require('./webpack.base.config') @@ -48,6 +47,7 @@ if (process.env.NODE_ENV === 'production') { new SWPrecachePlugin({ cacheId: 'vue-hn', filename: 'service-worker.js', + minify: true, dontCacheBustUrlsMatching: /./, staticFileGlobsIgnorePatterns: [/\.map$/, /\.json$/], runtimeCaching: [ 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 84bca9f9f..b8c7a74c8 100644 --- a/package.json +++ b/package.json @@ -8,49 +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": "^4.0.0", - "es6-promise": "^4.1.0", - "express": "^4.15.2", - "extract-text-webpack-plugin": "^2.1.0", - "firebase": "^3.7.2", - "lru-cache": "^4.0.2", - "serve-favicon": "^2.4.1", - "vue": "^2.3.2", - "vue-router": "^2.5.0", - "vue-server-renderer": "^2.3.2", - "vuex": "^2.3.1", - "vuex-router-sync": "^4.1.2" + "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", - "babel-core": "^6.24.1", - "babel-loader": "^6.4.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.4.0", - "css-loader": "^0.28.0", - "file-loader": "^0.11.1", + "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", - "glob": "^7.1.1", - "rimraf": "^2.6.1", + "rimraf": "^2.6.2", "stylus": "^0.54.5", "stylus-loader": "^3.0.1", - "sw-precache-webpack-plugin": "^0.10.1", - "url-loader": "^0.5.8", - "vue-loader": "^12.0.2", - "vue-style-loader": "^3.0.0", - "vue-template-compiler": "^2.3.2", - "webpack": "^2.4.1", - "webpack-dev-middleware": "^1.10.1", - "webpack-hot-middleware": "^2.17.1", - "webpack-merge": "^4.0.0", - "webpack-node-externals": "^1.5.4" + "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" } -} \ No newline at end of file +} 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 40368dbcd..f7b7330b5 100644 --- a/server.js +++ b/server.js @@ -4,6 +4,7 @@ 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') @@ -15,12 +16,9 @@ const serverInfo = const app = express() -const template = fs.readFileSync(resolve('./src/index.template.html'), 'utf-8') - 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, { - template, // for component caching cache: LRU({ max: 1000, @@ -35,23 +33,30 @@ function createRenderer (bundle, options) { let renderer let readyPromise +const templatePath = resolve('./src/index.template.html') if (isProd) { - // In production: create server renderer using built server bundle. + // 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 - diff --git a/src/entry-client.js b/src/entry-client.js index 23aade21d..d5022d4df 100644 --- a/src/entry-client.js +++ b/src/entry-client.js @@ -44,18 +44,18 @@ router.onReady(() => { const activated = matched.filter((c, i) => { return diffed || (diffed = (prevMatched[i] !== c)) }) - if (!activated.length) { + const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _) + if (!asyncDataHooks.length) { return next() } + bar.start() - Promise.all(activated.map(c => { - if (c.asyncData) { - return c.asyncData({ store, route: to }) - } - })).then(() => { - bar.finish() - next() - }).catch(next) + Promise.all(asyncDataHooks.map(hook => hook({ store, route: to }))) + .then(() => { + bar.finish() + next() + }) + .catch(next) }) // actually mount to DOM @@ -63,6 +63,6 @@ router.onReady(() => { }) // 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 2adc7fc9f..c72a90594 100644 --- a/src/entry-server.js +++ b/src/entry-server.js @@ -12,26 +12,31 @@ export default context => { 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 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.asyncData && component.asyncData({ - store, - route: router.currentRoute - }) - })).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. diff --git a/src/index.template.html b/src/index.template.html index 9513c5992..56fdcec30 100644 --- a/src/index.template.html +++ b/src/index.template.html @@ -7,12 +7,17 @@ - + + - +

+ diff --git a/src/router/index.js b/src/router/index.js index 23eeddf88..d6546c520 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -11,6 +11,7 @@ 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') }, diff --git a/src/store/getters.js b/src/store/getters.js index fdc1cc32c..71019dfdd 100644 --- a/src/store/getters.js +++ b/src/store/getters.js @@ -3,14 +3,16 @@ export default { // 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 { + + 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. diff --git a/src/views/ItemList.vue b/src/views/ItemList.vue index 5b8c44c23..063113d08 100644 --- a/src/views/ItemList.vue +++ b/src/views/ItemList.vue @@ -36,14 +36,14 @@ export default { data () { return { transition: 'slide-right', - displayedPage: Number(this.$store.state.route.params.page) || 1, + displayedPage: Number(this.$route.params.page) || 1, displayedItems: this.$store.getters.activeItems } }, computed: { page () { - return Number(this.$store.state.route.params.page) || 1 + return Number(this.$route.params.page) || 1 }, maxPage () { const { itemsPerPage, lists } = this.$store.state diff --git a/src/views/ItemView.vue b/src/views/ItemView.vue index b8f7b920a..bc6a09794 100644 --- a/src/views/ItemView.vue +++ b/src/views/ItemView.vue @@ -16,7 +16,7 @@

- {{ item.kids ? item.descendants + ' comments' : 'No comments yet.'}} + {{ item.kids ? item.descendants + ' comments' : 'No comments yet.' }}