Commit 0f33f3be authored by Evan You's avatar Evan You

Merge branch 'client-manifest'

parents 43477487 99b84e2b
...@@ -5,8 +5,14 @@ const clientConfig = require('./webpack.client.config') ...@@ -5,8 +5,14 @@ const clientConfig = require('./webpack.client.config')
const serverConfig = require('./webpack.server.config') const serverConfig = require('./webpack.server.config')
module.exports = function setupDevServer (app, cb) { module.exports = function setupDevServer (app, cb) {
let bundle let bundle, clientManifest
let template let resolve
let resolved = false
const readyPromise = new Promise(r => { resolve = r })
const ready = (...args) => {
if (!resolved) resolve()
cb(...args)
}
// modify client config to work with hot middleware // modify client config to work with hot middleware
clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app] clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
...@@ -25,12 +31,12 @@ module.exports = function setupDevServer (app, cb) { ...@@ -25,12 +31,12 @@ module.exports = function setupDevServer (app, cb) {
app.use(devMiddleware) app.use(devMiddleware)
clientCompiler.plugin('done', () => { clientCompiler.plugin('done', () => {
const fs = devMiddleware.fileSystem const fs = devMiddleware.fileSystem
const filePath = path.join(clientConfig.output.path, 'index.html') const readFile = file => fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
if (fs.existsSync(filePath)) { clientManifest = JSON.parse(readFile('vue-ssr-client-manifest.json'))
template = fs.readFileSync(filePath, 'utf-8') if (bundle) {
if (bundle) { ready(bundle, {
cb(bundle, template) clientManifest
} })
} }
}) })
...@@ -46,12 +52,16 @@ module.exports = function setupDevServer (app, cb) { ...@@ -46,12 +52,16 @@ module.exports = function setupDevServer (app, cb) {
stats = stats.toJson() stats = stats.toJson()
stats.errors.forEach(err => console.error(err)) stats.errors.forEach(err => console.error(err))
stats.warnings.forEach(err => console.warn(err)) stats.warnings.forEach(err => console.warn(err))
const readFile = file => mfs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
// read bundle generated by vue-ssr-webpack-plugin // read bundle generated by vue-ssr-webpack-plugin
const bundlePath = path.join(serverConfig.output.path, 'vue-ssr-bundle.json') bundle = JSON.parse(readFile('vue-ssr-server-bundle.json'))
bundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8')) if (clientManifest) {
if (template) { ready(bundle, {
cb(bundle, template) clientManifest
})
} }
}) })
return readyPromise
} }
module.exports = { module.exports = {
extractCSS: process.env.NODE_ENV === 'production',
preserveWhitespace: false, preserveWhitespace: false,
postcss: [ postcss: [
require('autoprefixer')({ require('autoprefixer')({
......
const path = require('path') const path = require('path')
const webpack = require('webpack')
const vueConfig = require('./vue-loader.config') const vueConfig = require('./vue-loader.config')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin') const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
const isProd = process.env.NODE_ENV === 'production' const isProd = process.env.NODE_ENV === 'production'
...@@ -7,19 +9,7 @@ const isProd = process.env.NODE_ENV === 'production' ...@@ -7,19 +9,7 @@ const isProd = process.env.NODE_ENV === 'production'
module.exports = { module.exports = {
devtool: isProd devtool: isProd
? false ? false
: '#cheap-module-eval-source-map', : '#cheap-module-source-map',
entry: {
app: './src/entry-client.js',
vendor: [
'es6-promise/auto',
'firebase/app',
'firebase/database',
'vue',
'vue-router',
'vuex',
'vuex-router-sync'
]
},
output: { output: {
path: path.resolve(__dirname, '../dist'), path: path.resolve(__dirname, '../dist'),
publicPath: '/dist/', publicPath: '/dist/',
...@@ -53,6 +43,15 @@ module.exports = { ...@@ -53,6 +43,15 @@ module.exports = {
limit: 10000, limit: 10000,
name: '[name].[ext]?[hash]' name: '[name].[ext]?[hash]'
} }
},
{
test: /\.css$/,
use: isProd
? ExtractTextPlugin.extract({
use: 'css-loader?minimize',
fallback: 'vue-style-loader'
})
: ['vue-style-loader', 'css-loader']
} }
] ]
}, },
...@@ -60,7 +59,16 @@ module.exports = { ...@@ -60,7 +59,16 @@ module.exports = {
maxEntrypointSize: 300000, maxEntrypointSize: 300000,
hints: isProd ? 'warning' : false hints: isProd ? 'warning' : false
}, },
plugins: isProd ? [] : [ plugins: isProd
new FriendlyErrorsPlugin() ? [
] new webpack.optimize.UglifyJsPlugin({
compress: { warnings: false }
}),
new ExtractTextPlugin({
filename: 'common.[chunkhash].css'
})
]
: [
new FriendlyErrorsPlugin()
]
} }
const webpack = require('webpack') const webpack = require('webpack')
const merge = require('webpack-merge') const merge = require('webpack-merge')
const base = require('./webpack.base.config') const base = require('./webpack.base.config')
const HTMLPlugin = require('html-webpack-plugin')
const SWPrecachePlugin = require('sw-precache-webpack-plugin') const SWPrecachePlugin = require('sw-precache-webpack-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const config = merge(base, { const config = merge(base, {
entry: {
app: './src/entry-client.js'
},
resolve: { resolve: {
alias: { alias: {
'create-api': './create-api-client.js' 'create-api': './create-api-client.js'
...@@ -18,23 +21,28 @@ const config = merge(base, { ...@@ -18,23 +21,28 @@ const config = merge(base, {
}), }),
// extract vendor chunks for better caching // extract vendor chunks for better caching
new webpack.optimize.CommonsChunkPlugin({ 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 // extract webpack runtime & manifest to avoid vendor chunk hash changing
new HTMLPlugin({ // on every build.
template: 'src/index.template.html' new webpack.optimize.CommonsChunkPlugin({
}) name: 'manifest'
}),
new VueSSRClientPlugin()
] ]
}) })
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
config.plugins.push( config.plugins.push(
// minify JS
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false
}
}),
// auto generate service worker // auto generate service worker
new SWPrecachePlugin({ new SWPrecachePlugin({
cacheId: 'vue-hn', cacheId: 'vue-hn',
......
const webpack = require('webpack') const webpack = require('webpack')
const merge = require('webpack-merge') const merge = require('webpack-merge')
const base = require('./webpack.base.config') 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, { module.exports = merge(base, {
target: 'node', target: 'node',
...@@ -16,12 +17,17 @@ module.exports = merge(base, { ...@@ -16,12 +17,17 @@ module.exports = merge(base, {
'create-api': './create-api-server.js' '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: [ plugins: [
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"server"' 'process.env.VUE_ENV': '"server"'
}), }),
new VueSSRPlugin() new VueSSRServerPlugin()
] ]
}) })
...@@ -16,38 +16,38 @@ ...@@ -16,38 +16,38 @@
}, },
"dependencies": { "dependencies": {
"compression": "^1.6.2", "compression": "^1.6.2",
"cross-env": "^3.2.4", "cross-env": "^4.0.0",
"es6-promise": "^4.1.0", "es6-promise": "^4.1.0",
"express": "^4.15.2", "express": "^4.15.2",
"extract-text-webpack-plugin": "^2.1.0",
"firebase": "^3.7.2", "firebase": "^3.7.2",
"lru-cache": "^4.0.2", "lru-cache": "^4.0.2",
"serve-favicon": "^2.4.1", "serve-favicon": "^2.4.1",
"vue": "^2.2.4", "vue": "^2.3.0-beta.1",
"vue-router": "^2.3.0", "vue-router": "^2.5.0",
"vue-server-renderer": "^2.2.4", "vue-server-renderer": "^2.3.0-beta.1",
"vue-style-loader": "^2.0.4", "vuex": "^2.3.1",
"vuex": "^2.2.1", "vuex-router-sync": "^4.1.2"
"vuex-router-sync": "^4.1.2",
"webpack-merge": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"autoprefixer": "^6.7.7", "autoprefixer": "^6.7.7",
"buble": "^0.15.2", "buble": "^0.15.2",
"buble-loader": "^0.4.1", "buble-loader": "^0.4.1",
"css-loader": "^0.27.3", "css-loader": "^0.28.0",
"file-loader": "^0.10.1", "file-loader": "^0.11.1",
"friendly-errors-webpack-plugin": "^1.6.1", "friendly-errors-webpack-plugin": "^1.6.1",
"html-webpack-plugin": "^2.28.0",
"rimraf": "^2.6.1", "rimraf": "^2.6.1",
"stylus": "^0.54.5", "stylus": "^0.54.5",
"stylus-loader": "^3.0.1", "stylus-loader": "^3.0.1",
"sw-precache-webpack-plugin": "^0.9.1", "sw-precache-webpack-plugin": "^0.9.1",
"url-loader": "^0.5.8", "url-loader": "^0.5.8",
"vue-loader": "^11.1.4", "vue-loader": "^12.0.0",
"vue-ssr-webpack-plugin": "^1.0.2", "vue-style-loader": "^3.0.0",
"vue-template-compiler": "^2.2.4", "vue-template-compiler": "^2.3.0-beta.1",
"webpack": "^2.2.1", "webpack": "^2.2.1",
"webpack-merge": "^4.0.0",
"webpack-dev-middleware": "^1.10.1", "webpack-dev-middleware": "^1.10.1",
"webpack-hot-middleware": "^2.17.1" "webpack-hot-middleware": "^2.17.1",
"webpack-node-externals": "^1.5.4"
} }
} }
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const LRU = require('lru-cache')
const express = require('express') const express = require('express')
const favicon = require('serve-favicon') const favicon = require('serve-favicon')
const compression = require('compression') const compression = require('compression')
const resolve = file => path.resolve(__dirname, file) const resolve = file => path.resolve(__dirname, file)
const { createBundleRenderer } = require('vue-server-renderer')
const isProd = process.env.NODE_ENV === 'production' const isProd = process.env.NODE_ENV === 'production'
const useMicroCache = process.env.MICRO_CACHE !== 'false'
const serverInfo = const serverInfo =
`express/${require('express/package.json').version} ` + `express/${require('express/package.json').version} ` +
`vue-server-renderer/${require('vue-server-renderer/package.json').version}` `vue-server-renderer/${require('vue-server-renderer/package.json').version}`
const app = express() 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,
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 renderer
let readyPromise
if (isProd) { if (isProd) {
// In production: create server renderer using server bundle and index HTML // In production: create server renderer using built server bundle.
// template from real fs.
// The server bundle is generated by vue-ssr-webpack-plugin. // The server bundle is generated by vue-ssr-webpack-plugin.
const bundle = require('./dist/vue-ssr-bundle.json') const bundle = require('./dist/vue-ssr-server-bundle.json')
// src/index.template.html is processed by html-webpack-plugin to inject // The client manifests are optional, but it allows the renderer
// build assets and output as dist/index.html. // to automatically infer preload/prefetch links and directly add <script>
const template = fs.readFileSync(resolve('./dist/index.html'), 'utf-8') // tags for any async chunks used during render, avoiding waterfall requests.
renderer = createRenderer(bundle, template) const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createRenderer(bundle, {
clientManifest
})
} else { } else {
// In development: setup the dev server with watch and hot-reload, // In development: setup the dev server with watch and hot-reload,
// and create a new renderer on bundle / index template update. // and create a new renderer on bundle / index template update.
require('./build/setup-dev-server')(app, (bundle, template) => { readyPromise = require('./build/setup-dev-server')(app, (bundle, options) => {
renderer = createRenderer(bundle, template) renderer = createRenderer(bundle, options)
})
}
function createRenderer (bundle, template) {
// https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/README.md#why-use-bundlerenderer
return require('vue-server-renderer').createBundleRenderer(bundle, {
template,
cache: require('lru-cache')({
max: 1000,
maxAge: 1000 * 60 * 15
})
}) })
} }
...@@ -52,31 +65,67 @@ app.use('/public', serve('./public', true)) ...@@ -52,31 +65,67 @@ app.use('/public', serve('./public', true))
app.use('/manifest.json', serve('./manifest.json', true)) app.use('/manifest.json', serve('./manifest.json', true))
app.use('/service-worker.js', serve('./dist/service-worker.js')) app.use('/service-worker.js', serve('./dist/service-worker.js'))
app.get('*', (req, res) => { // 1-second microcache.
if (!renderer) { // https://www.nginx.com/blog/benefits-of-microcaching-nginx/
return res.end('waiting for compilation... refresh in a moment.') const microCache = LRU({
} max: 100,
maxAge: 1000
})
// since this app has no user-specific content, every page is micro-cacheable.
// if your app involves user-specific content, you need to implement custom
// logic to determine whether a request is cacheable based on its url and
// headers.
const isCacheable = req => useMicroCache
function render (req, res) {
const s = Date.now() const s = Date.now()
res.setHeader("Content-Type", "text/html") res.setHeader("Content-Type", "text/html")
res.setHeader("Server", serverInfo) res.setHeader("Server", serverInfo)
const errorHandler = err => { const handleError = err => {
if (err && err.code === 404) { if (err && err.code === 404) {
res.status(404).end('404 | Page Not Found') res.status(404).end('404 | Page Not Found')
} else { } else {
// Render Error Page or Redirect // Render Error Page or Redirect
res.status(500).end('500 | Internal Server Error') res.status(500).end('500 | Internal Server Error')
console.error(`error during render : ${req.url}`) console.error(`error during render : ${req.url}`)
console.error(err) console.error(err.stack)
}
}
const cacheable = isCacheable(req)
if (cacheable) {
const hit = microCache.get(req.url)
if (hit) {
if (!isProd) {
console.log(`cache hit!`)
}
return res.end(hit)
} }
} }
renderer.renderToStream({ url: req.url }) const context = {
.on('error', errorHandler) title: 'Vue HN 2.0', // default title
.on('end', () => console.log(`whole request: ${Date.now() - s}ms`)) url: req.url
.pipe(res) }
renderer.renderToString(context, (err, html) => {
if (err) {
return handleError(err)
}
res.end(html)
if (cacheable) {
microCache.set(req.url, html)
}
if (!isProd) {
console.log(`whole request: ${Date.now() - s}ms`)
}
})
}
app.get('*', isProd ? render : (req, res) => {
readyPromise.then(() => render(req, res))
}) })
const port = process.env.PORT || 8080 const port = process.env.PORT || 8080
......
...@@ -92,8 +92,6 @@ a ...@@ -92,8 +92,6 @@ a
padding 15px 30px padding 15px 30px
@media (max-width 600px) @media (max-width 600px)
body
font-size 14px
.header .header
.inner .inner
padding 15px padding 15px
......
import Firebase from 'firebase/app' import Firebase from 'firebase/app'
import 'firebase/database' import 'firebase/database'
const config = { export function createAPI ({ config, version }) {
databaseURL: 'https://hacker-news.firebaseio.com' Firebase.initializeApp(config)
return Firebase.database().ref(version)
} }
const version = '/v0'
Firebase.initializeApp(config)
const api = Firebase.database().ref(version)
export default api
\ No newline at end of file
import Firebase from 'firebase' import Firebase from 'firebase'
import LRU from 'lru-cache' import LRU from 'lru-cache'
import { fetchItems } from './api'
let api export function createAPI ({ config, version }) {
const config = { let api
databaseURL: 'https://hacker-news.firebaseio.com' // this piece of code may run multiple times in development mode,
} // so we attach the instantiated API to `process` to avoid duplications
const version = '/v0' if (process.__API__) {
api = process.__API__
} else {
Firebase.initializeApp(config)
api = process.__API__ = Firebase.database().ref(version)
if (process.__API__) { api.onServer = true
api = process.__API__
} else {
Firebase.initializeApp(config)
api = process.__API__ = Firebase.database().ref(version)
api.onServer = true
// fetched item cache // fetched item cache
api.cachedItems = LRU({ api.cachedItems = LRU({
max: 1000, max: 1000,
maxAge: 1000 * 60 * 15 // 15 min cache maxAge: 1000 * 60 * 15 // 15 min cache
}) })
// cache the latest story ids // cache the latest story ids
api.cachedIds = {} api.cachedIds = {}
;['top', 'new', 'show', 'ask', 'job'].forEach(type => { ;['top', 'new', 'show', 'ask', 'job'].forEach(type => {
api.child(`${type}stories`).on('value', snapshot => { api.child(`${type}stories`).on('value', snapshot => {
api.cachedIds[type] = snapshot.val() api.cachedIds[type] = snapshot.val()
})
}) })
}) }
return api
} }
export default api
// this is aliased in webpack config based on server/client build // this is aliased in webpack config based on server/client build
import api from 'create-api' import { createAPI } from 'create-api'
const logRequests = !!process.env.DEBUG_API
const api = createAPI({
version: '/v0',
config: {
databaseURL: 'https://hacker-news.firebaseio.com'
}
})
// warm the front page cache every 15 min // warm the front page cache every 15 min
// make sure to do this only once across all requests // make sure to do this only once across all requests
if (api.onServer && !api.warmCacheStarted) { if (api.onServer) {
api.warmCacheStarted = true
warmCache() warmCache()
} }
...@@ -14,8 +22,10 @@ function warmCache () { ...@@ -14,8 +22,10 @@ function warmCache () {
} }
function fetch (child) { function fetch (child) {
logRequests && console.log(`fetching ${child}...`)
const cache = api.cachedItems const cache = api.cachedItems
if (cache && cache.has(child)) { if (cache && cache.has(child)) {
logRequests && console.log(`cache hit for ${child}.`)
return Promise.resolve(cache.get(child)) return Promise.resolve(cache.get(child))
} else { } else {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
...@@ -24,6 +34,7 @@ function fetch (child) { ...@@ -24,6 +34,7 @@ function fetch (child) {
// mark the timestamp when this item is cached // mark the timestamp when this item is cached
if (val) val.__lastUpdated = Date.now() if (val) val.__lastUpdated = Date.now()
cache && cache.set(child, val) cache && cache.set(child, val)
logRequests && console.log(`fetched ${child}.`)
resolve(val) resolve(val)
}, reject) }, reject)
}) })
......
import Vue from 'vue' import Vue from 'vue'
import App from './App.vue' import App from './App.vue'
import store from './store' import { createStore } from './store'
import router from './router' import { createRouter } from './router'
import { sync } from 'vuex-router-sync' import { sync } from 'vuex-router-sync'
import * as filters from './filters' import titleMixin from './util/title'
import * as filters from './util/filters'
// sync the router with the vuex store. // mixin for handling title
// this registers `store.state.route` Vue.mixin(titleMixin)
sync(store, router)
// register global utility filters. // register global utility filters.
Object.keys(filters).forEach(key => { Object.keys(filters).forEach(key => {
Vue.filter(key, filters[key]) Vue.filter(key, filters[key])
}) })
// create the app instance. // Expose a factory function that creates a fresh set of store, router,
// here we inject the router and store to all child components, // app instances on each call (which is called for each SSR request)
// making them available everywhere as `this.$router` and `this.$store`. export function createApp (ssrContext) {
const app = new Vue({ // create store and router instances
router, const store = createStore()
store, const router = createRouter()
render: h => h(App)
}) // sync the router with the vuex store.
// this registers `store.state.route`
sync(store, router)
// create the app instance.
// here we inject the router, store and ssr context to all child components,
// making them available everywhere as `this.$router` and `this.$store`.
const app = new Vue({
router,
store,
ssrContext,
render: h => h(App)
})
// expose the app, the router and the store. // expose the app, the router and the store.
// note we are not mounting the app here, since bootstrapping will be // note we are not mounting the app here, since bootstrapping will be
// different depending on whether we are in a browser or on the server. // different depending on whether we are in a browser or on the server.
export { app, router, store } return { app, router, store }
}
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
<span class="score">{{ item.score }}</span> <span class="score">{{ item.score }}</span>
<span class="title"> <span class="title">
<template v-if="item.url"> <template v-if="item.url">
<a :href="item.url" target="_blank">{{ item.title }}</a> <a :href="item.url" target="_blank" rel="noopener">{{ item.title }}</a>
<span class="host"> ({{ item.url | host }})</span> <span class="host"> ({{ item.url | host }})</span>
</template> </template>
<template v-else> <template v-else>
...@@ -27,7 +27,7 @@ ...@@ -27,7 +27,7 @@
</template> </template>
<script> <script>
import { timeAgo } from '../filters' import { timeAgo } from '../util/filters'
export default { export default {
name: 'news-item', name: 'news-item',
......
<!-- borrowed from Nuxt! -->
<template>
<div class="progress" :style="{
'width': percent+'%',
'height': height,
'background-color': canSuccess? color : failedColor,
'opacity': show ? 1 : 0
}"></div>
</template>
<script>
export default {
data () {
return {
percent: 0,
show: false,
canSuccess: true,
duration: 3000,
height: '2px',
color: '#ffca2b',
failedColor: '#ff0000',
}
},
methods: {
start () {
this.show = true
this.canSuccess = true
if (this._timer) {
clearInterval(this._timer)
this.percent = 0
}
this._cut = 10000 / Math.floor(this.duration)
this._timer = setInterval(() => {
this.increase(this._cut * Math.random())
if (this.percent > 95) {
this.finish()
}
}, 100)
return this
},
set (num) {
this.show = true
this.canSuccess = true
this.percent = Math.floor(num)
return this
},
get () {
return Math.floor(this.percent)
},
increase (num) {
this.percent = this.percent + Math.floor(num)
return this
},
decrease (num) {
this.percent = this.percent - Math.floor(num)
return this
},
finish () {
this.percent = 100
this.hide()
return this
},
pause () {
clearInterval(this._timer)
return this
},
hide () {
clearInterval(this._timer)
this._timer = null
setTimeout(() => {
this.show = false
this.$nextTick(() => {
setTimeout(() => {
this.percent = 0
}, 200)
})
}, 500)
return this
},
fail () {
this.canSuccess = false
return this
}
}
}
</script>
<style scoped>
.progress {
position: fixed;
top: 0px;
left: 0px;
right: 0px;
height: 2px;
width: 0%;
transition: width 0.2s, opacity 0.4s;
opacity: 1;
background-color: #efc14e;
z-index: 999999;
}
</style>
...@@ -19,10 +19,6 @@ $offset = 126 ...@@ -19,10 +19,6 @@ $offset = 126
$duration = 1.4s $duration = 1.4s
.spinner .spinner
position fixed
z-index 999
right 15px
bottom 15px
transition opacity .15s ease transition opacity .15s ease
animation rotator $duration linear infinite animation rotator $duration linear infinite
animation-play-state paused animation-play-state paused
......
import Vue from 'vue'
import 'es6-promise/auto' 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. // prime the store with server-initialized state.
// the state is determined during SSR and inlined in the page markup. // the state is determined during SSR and inlined in the page markup.
...@@ -10,6 +33,31 @@ if (window.__INITIAL_STATE__) { ...@@ -10,6 +33,31 @@ if (window.__INITIAL_STATE__) {
// wait until router has resolved all async before hooks // wait until router has resolved all async before hooks
// and async components... // and async components...
router.onReady(() => { 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))
})
if (!activated.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)
})
// actually mount to DOM // actually mount to DOM
app.$mount('#app') app.$mount('#app')
}) })
......
import { app, router, store } from './app' import { createApp } from './app'
const isDev = process.env.NODE_ENV !== 'production' const isDev = process.env.NODE_ENV !== 'production'
...@@ -8,9 +8,10 @@ const isDev = process.env.NODE_ENV !== 'production' ...@@ -8,9 +8,10 @@ const isDev = process.env.NODE_ENV !== 'production'
// Since data fetching is async, this function is expected to // Since data fetching is async, this function is expected to
// return a Promise that resolves to the app instance. // return a Promise that resolves to the app instance.
export default context => { export default context => {
const s = isDev && Date.now()
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const s = isDev && Date.now()
const { app, router, store } = createApp(context)
// set router's location // set router's location
router.push(context.url) router.push(context.url)
...@@ -21,12 +22,15 @@ export default context => { ...@@ -21,12 +22,15 @@ export default context => {
if (!matchedComponents.length) { if (!matchedComponents.length) {
reject({ code: 404 }) 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, // A preFetch hook dispatches a store action and returns a Promise,
// which is resolved when the action is complete and store state has been // which is resolved when the action is complete and store state has been
// updated. // updated.
Promise.all(matchedComponents.map(component => { Promise.all(matchedComponents.map(component => {
return component.preFetch && component.preFetch(store) return component.asyncData && component.asyncData({
store,
route: router.currentRoute
})
})).then(() => { })).then(() => {
isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`) isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
// After all preFetch hooks are resolved, our store is now // After all preFetch hooks are resolved, our store is now
...@@ -38,6 +42,6 @@ export default context => { ...@@ -38,6 +42,6 @@ export default context => {
context.state = store.state context.state = store.state
resolve(app) resolve(app)
}).catch(reject) }).catch(reject)
}) }, reject)
}) })
} }
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>{{ title || 'Vue HN 2.0' }}</title>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Vue HN 2.0</title>
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
<link rel="shortcut icon" sizes="48x48" href="/public/logo-48.png"> <link rel="shortcut icon" sizes="48x48" href="/public/logo-48.png">
<meta name="theme-color" content="#f60"> <meta name="theme-color" content="#f60">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<% for (var chunk of webpack.chunks) {
for (var file of chunk.files) {
if (file.match(/\.(js|css)$/)) { %>
<link rel="<%= chunk.initial?'preload':'prefetch' %>" href="<%= htmlWebpackPlugin.files.publicPath + file %>" as="<%= file.match(/\.css$/)?'style':'script' %>"><% }}} %>
</head> </head>
<body> <body>
<!--vue-ssr-outlet--> <!--vue-ssr-outlet-->
......
...@@ -3,34 +3,24 @@ import Router from 'vue-router' ...@@ -3,34 +3,24 @@ import Router from 'vue-router'
Vue.use(Router) Vue.use(Router)
// We are using Webpack code splitting here so that each route's associated // route-level code splitting
// component code is loaded on-demand only when the route is visited. const createListView = id => () => System.import('../views/CreateListView').then(m => m.default(id))
// 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.
const createListView = name => () =>
System.import('../views/CreateListView').then(m => m.createListView(name))
const ItemView = () => System.import('../views/ItemView.vue') const ItemView = () => System.import('../views/ItemView.vue')
const UserView = () => System.import('../views/UserView.vue') const UserView = () => System.import('../views/UserView.vue')
export default new Router({ export function createRouter () {
mode: 'history', return new Router({
scrollBehavior: () => ({ y: 0 }), mode: 'history',
routes: [ scrollBehavior: () => ({ y: 0 }),
{ path: '/top/:page(\\d+)?', component: createListView('top') }, routes: [
{ path: '/new/:page(\\d+)?', component: createListView('new') }, { path: '/top/:page(\\d+)?', component: createListView('top') },
{ path: '/show/:page(\\d+)?', component: createListView('show') }, { path: '/new/:page(\\d+)?', component: createListView('new') },
{ path: '/ask/:page(\\d+)?', component: createListView('ask') }, { path: '/show/:page(\\d+)?', component: createListView('show') },
{ path: '/job/:page(\\d+)?', component: createListView('job') }, { path: '/ask/:page(\\d+)?', component: createListView('ask') },
{ path: '/item/:id(\\d+)', component: ItemView }, { path: '/job/:page(\\d+)?', component: createListView('job') },
{ path: '/user/:id', component: UserView }, { path: '/item/:id(\\d+)', component: ItemView },
{ path: '/', redirect: '/top' } { path: '/user/:id', component: UserView },
] { path: '/', redirect: '/top' }
}) ]
})
}
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 }))
}
}
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
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 []
}
},
// 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(_ => _)
}
}
import Vue from 'vue' import Vue from 'vue'
import Vuex from 'vuex' 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) Vue.use(Vuex)
const store = new Vuex.Store({ export function createStore () {
state: { return new Vuex.Store({
activeType: null, state: {
itemsPerPage: 20, activeType: null,
items: {/* [id: number]: Item */}, itemsPerPage: 20,
users: {/* [id: string]: User */}, items: {/* [id: number]: Item */},
lists: { users: {/* [id: string]: User */},
top: [/* number */], lists: {
new: [], top: [/* number */],
show: [], new: [],
ask: [], show: [],
job: [] 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 []
} }
}, },
actions,
// items that should be currently displayed. mutations,
// this Array may not be fully fetched. getters
activeItems (state, getters) { })
return getters.activeIds.map(id => state.items[id]).filter(_ => _) }
}
}
})
export default store
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 */
}
}
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.$root.$options.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
import ItemList from '../components/ItemList.vue' import ItemList from './ItemList.vue'
import { setTitle } from '../util/title'
const camelize = str => str.charAt(0).toUpperCase() + str.slice(1)
// This is a factory function for dynamically creating root-level list views, // 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. // since they share most of the logic except for the type of items to display.
// They are essentially higher order components wrapping ItemList.vue. // They are essentially higher order components wrapping ItemList.vue.
export function createListView (type) { export default function createListView (type) {
return { return {
name: `${type}-stories-view`, 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 }) return store.dispatch('FETCH_LIST_DATA', { type })
}, },
title: camelize(type),
render (h) { render (h) {
return h(ItemList, { props: { type }}) return h(ItemList, { props: { type }})
} }
......
<template> <template>
<div class="news-view"> <div class="news-view">
<spinner :show="loading"></spinner>
<div class="news-list-nav"> <div class="news-list-nav">
<router-link v-if="page > 1" :to="'/' + type + '/' + (page - 1)">&lt; prev</router-link> <router-link v-if="page > 1" :to="'/' + type + '/' + (page - 1)">&lt; prev</router-link>
<a v-else class="disabled">&lt; prev</a> <a v-else class="disabled">&lt; prev</a>
...@@ -20,17 +19,13 @@ ...@@ -20,17 +19,13 @@
</template> </template>
<script> <script>
import Spinner from './Spinner.vue' import { watchList } from '../api'
import Item from './Item.vue' import Item from '../components/Item.vue'
import { watchList } from '../store/api'
let isInitialRender = true
export default { export default {
name: 'item-list', name: 'item-list',
components: { components: {
Spinner,
Item Item
}, },
...@@ -39,18 +34,11 @@ export default { ...@@ -39,18 +34,11 @@ export default {
}, },
data () { data () {
const data = { return {
loading: false, transition: 'slide-right',
transition: 'slide-up', displayedPage: Number(this.$store.state.route.params.page) || 1,
// if this is the initial render, directly render with the store state displayedItems: this.$store.getters.activeItems
// otherwise this is a page switch, start with blank and wait for data load.
// we need these local state so that we can precisely control the timing
// of the transitions.
displayedPage: isInitialRender ? Number(this.$store.state.route.params.page) || 1 : -1,
displayedItems: isInitialRender ? this.$store.getters.activeItems : []
} }
isInitialRender = false
return data
}, },
computed: { computed: {
...@@ -91,7 +79,7 @@ export default { ...@@ -91,7 +79,7 @@ export default {
methods: { methods: {
loadItems (to = this.page, from = -1) { loadItems (to = this.page, from = -1) {
this.loading = true this.$bar.start()
this.$store.dispatch('FETCH_LIST_DATA', { this.$store.dispatch('FETCH_LIST_DATA', {
type: this.type type: this.type
}).then(() => { }).then(() => {
...@@ -104,7 +92,7 @@ export default { ...@@ -104,7 +92,7 @@ export default {
: to > from ? 'slide-left' : 'slide-right' : to > from ? 'slide-left' : 'slide-right'
this.displayedPage = to this.displayedPage = to
this.displayedItems = this.$store.getters.activeItems this.displayedItems = this.$store.getters.activeItems
this.loading = false this.$bar.finish()
}) })
} }
} }
...@@ -143,11 +131,11 @@ export default { ...@@ -143,11 +131,11 @@ export default {
padding 0 padding 0
margin 0 margin 0
.slide-left-enter, .slide-right-leave-active .slide-left-enter, .slide-right-leave-to
opacity 0 opacity 0
transform translate(30px, 0) transform translate(30px, 0)
.slide-left-leave-active, .slide-right-enter .slide-left-leave-to, .slide-right-enter
opacity 0 opacity 0
transform translate(-30px, 0) transform translate(-30px, 0)
......
...@@ -28,13 +28,53 @@ ...@@ -28,13 +28,53 @@
</template> </template>
<script> <script>
import { setTitle } from '../util/title'
import Spinner from '../components/Spinner.vue' import Spinner from '../components/Spinner.vue'
import Comment from '../components/Comment.vue' import Comment from '../components/Comment.vue'
function fetchItem (store) { export default {
return store.dispatch('FETCH_ITEMS', { name: 'item-view',
ids: [store.state.route.params.id] components: { Spinner, Comment },
})
data: () => ({
loading: true
}),
computed: {
item () {
return this.$store.state.items[this.$route.params.id]
}
},
// We only fetch the item itself before entering the view, because
// it might take a long time to load threads with hundreds of comments
// due to how the HN Firebase API works.
asyncData ({ store, route: { params: { id }}}) {
return store.dispatch('FETCH_ITEMS', { ids: [id] })
},
title () {
return this.item.title
},
// Fetch comments when mounted on the client
beforeMount () {
this.fetchComments()
},
// refetch comments if item changed
watch: {
item: 'fetchComments'
},
methods: {
fetchComments () {
this.loading = true
fetchComments(this.$store, this.item).then(() => {
this.loading = false
})
}
}
} }
// recursively fetch all descendent comments // recursively fetch all descendent comments
...@@ -47,36 +87,6 @@ function fetchComments (store, item) { ...@@ -47,36 +87,6 @@ function fetchComments (store, item) {
}))) })))
} }
} }
function fetchItemAndComments (store) {
return fetchItem(store).then(() => {
const { items, route } = store.state
return fetchComments(store, items[route.params.id])
})
}
export default {
name: 'item-view',
components: { Spinner, Comment },
data () {
return {
loading: true
}
},
computed: {
item () {
return this.$store.state.items[this.$route.params.id]
}
},
// on the server, only fetch the item itself
preFetch: fetchItem,
// on the client, fetch everything
beforeMount () {
fetchItemAndComments(this.$store).then(() => {
this.loading = false
})
}
}
</script> </script>
<style lang="stylus"> <style lang="stylus">
...@@ -105,10 +115,8 @@ export default { ...@@ -105,10 +115,8 @@ export default {
padding 1em 0 padding 1em 0
position relative position relative
.spinner .spinner
position absolute display inline-block
top 0 margin -15px 0
right 0
bottom auto
.comment-children .comment-children
list-style-type none list-style-type none
......
<template> <template>
<div class="user-view"> <div class="user-view">
<spinner :show="!user"></spinner>
<template v-if="user"> <template v-if="user">
<h1>User : {{ user.id }}</h1> <h1>User : {{ user.id }}</h1>
<ul class="meta"> <ul class="meta">
...@@ -13,29 +12,32 @@ ...@@ -13,29 +12,32 @@
<a :href="'https://news.ycombinator.com/threads?id=' + user.id">comments</a> <a :href="'https://news.ycombinator.com/threads?id=' + user.id">comments</a>
</p> </p>
</template> </template>
<template v-else-if="user === false">
<h1>User not found.</h1>
</template>
</div> </div>
</template> </template>
<script> <script>
import Spinner from '../components/Spinner.vue' import { setTitle } from '../util/title'
function fetchUser (store) {
return store.dispatch('FETCH_USER', {
id: store.state.route.params.id
})
}
export default { export default {
name: 'user-view', name: 'user-view',
components: { Spinner },
computed: { computed: {
user () { user () {
return this.$store.state.users[this.$route.params.id] return this.$store.state.users[this.$route.params.id]
} }
}, },
preFetch: fetchUser,
beforeMount () { asyncData ({ store, route: { params: { id }}}) {
fetchUser(this.$store) return store.dispatch('FETCH_USER', { id })
},
title () {
return this.user
? this.user.id
: 'User not found'
} }
} }
</script> </script>
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment