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.
-> 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
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 @@
+