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

Merge branch 'perf' into client-manifest

parents d75accb8 f0b94b1b
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
}, },
"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",
"firebase": "^3.7.2", "firebase": "^3.7.2",
......
...@@ -8,6 +8,7 @@ const resolve = file => path.resolve(__dirname, file) ...@@ -8,6 +8,7 @@ const resolve = file => path.resolve(__dirname, file)
const { createBundleRenderer } = require('vue-server-renderer') 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}`
...@@ -26,7 +27,8 @@ function createRenderer (bundle, options) { ...@@ -26,7 +27,8 @@ function createRenderer (bundle, options) {
maxAge: 1000 * 60 * 15 maxAge: 1000 * 60 * 15
}), }),
// this is only needed when vue-server-renderer is npm-linked // this is only needed when vue-server-renderer is npm-linked
basedir: resolve('./dist') basedir: resolve('./dist'),
directMode: true
})) }))
} }
...@@ -62,9 +64,9 @@ app.use('/public', serve('./public', true)) ...@@ -62,9 +64,9 @@ 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'))
// 1-second micro-cache. // 1-second microcache.
// https://www.nginx.com/blog/benefits-of-microcaching-nginx/ // https://www.nginx.com/blog/benefits-of-microcaching-nginx/
const pageCache = LRU({ const microCache = LRU({
max: 100, max: 100,
maxAge: 1000 maxAge: 1000
}) })
...@@ -73,7 +75,7 @@ const pageCache = LRU({ ...@@ -73,7 +75,7 @@ const pageCache = LRU({
// if your app involves user-specific content, you need to implement custom // 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 // logic to determine whether a request is cacheable based on its url and
// headers. // headers.
const isCacheable = req => true const isCacheable = req => useMicroCache
app.get('*', (req, res) => { app.get('*', (req, res) => {
if (!renderer) { if (!renderer) {
...@@ -98,7 +100,7 @@ app.get('*', (req, res) => { ...@@ -98,7 +100,7 @@ app.get('*', (req, res) => {
const cacheable = isCacheable(req) const cacheable = isCacheable(req)
if (cacheable) { if (cacheable) {
const hit = pageCache.get(req.url) const hit = microCache.get(req.url)
if (hit) { if (hit) {
if (!isProd) { if (!isProd) {
console.log(`cache hit!`) console.log(`cache hit!`)
...@@ -113,7 +115,7 @@ app.get('*', (req, res) => { ...@@ -113,7 +115,7 @@ app.get('*', (req, res) => {
} }
res.end(html) res.end(html)
if (cacheable) { if (cacheable) {
pageCache.set(req.url, html) microCache.set(req.url, html)
} }
if (!isProd) { if (!isProd) {
console.log(`whole request: ${Date.now() - s}ms`) console.log(`whole request: ${Date.now() - s}ms`)
......
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__) {
if (process.__API__) {
api = process.__API__ api = process.__API__
} else { } else {
Firebase.initializeApp(config) Firebase.initializeApp(config)
api = process.__API__ = Firebase.database().ref(version) api = process.__API__ = Firebase.database().ref(version)
api.onServer = true api.onServer = true
// fetched item cache // fetched item cache
...@@ -28,6 +26,6 @@ if (process.__API__) { ...@@ -28,6 +26,6 @@ if (process.__API__) {
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 * as filters from './filters'
// sync the router with the vuex store.
// this registers `store.state.route`
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 () {
const app = new Vue({ // create store and router instances
const store = createStore()
const router = createRouter()
// sync the router with the vuex store.
// this registers `store.state.route`
sync(store, router)
// create the app instance.
// here we inject the router and store to all child components,
// making them available everywhere as `this.$router` and `this.$store`.
const app = new Vue({
router, router,
store, store,
render: h => h(App) 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 }
}
...@@ -22,9 +22,7 @@ ...@@ -22,9 +22,7 @@
<script> <script>
import Spinner from './Spinner.vue' import Spinner from './Spinner.vue'
import Item from './Item.vue' import Item from './Item.vue'
import { watchList } from '../store/api' import { watchList } from '../api'
let isInitialRender = true
export default { export default {
name: 'item-list', name: 'item-list',
...@@ -39,18 +37,13 @@ export default { ...@@ -39,18 +37,13 @@ export default {
}, },
data () { data () {
const data = { const isInitialRender = !this.$root._isMounted
return {
loading: false, loading: false,
transition: 'slide-up', transition: 'slide-up',
// if this is the initial render, directly render with the store state
// 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, displayedPage: isInitialRender ? Number(this.$store.state.route.params.page) || 1 : -1,
displayedItems: isInitialRender ? this.$store.getters.activeItems : [] displayedItems: isInitialRender ? this.$store.getters.activeItems : []
} }
isInitialRender = false
return data
}, },
computed: { computed: {
......
import 'es6-promise/auto' import 'es6-promise/auto'
import { app, store, router } from './app' import { createApp } from './app'
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.
......
import { app, router, store } from './app' import { createApp } from './app'
const isDev = process.env.NODE_ENV !== 'production' const isDev = process.env.NODE_ENV !== 'production'
...@@ -10,6 +10,8 @@ const isDev = process.env.NODE_ENV !== 'production' ...@@ -10,6 +10,8 @@ const isDev = process.env.NODE_ENV !== 'production'
export default context => { export default context => {
const s = isDev && Date.now() const s = isDev && Date.now()
const { app, router, store } = createApp()
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// set router's location // set router's location
router.push(context.url) router.push(context.url)
......
...@@ -3,24 +3,13 @@ import Router from 'vue-router' ...@@ -3,24 +3,13 @@ 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 () {
return new Router({
mode: 'history', mode: 'history',
scrollBehavior: () => ({ y: 0 }), scrollBehavior: () => ({ y: 0 }),
routes: [ routes: [
...@@ -33,4 +22,5 @@ export default new Router({ ...@@ -33,4 +22,5 @@ export default new Router({
{ path: '/user/:id', component: UserView }, { path: '/user/:id', component: UserView },
{ path: '/', redirect: '/top' } { 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', { 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 () {
return new Vuex.Store({
state: { state: {
activeType: null, activeType: null,
itemsPerPage: 20, itemsPerPage: 20,
...@@ -18,94 +21,8 @@ const store = new Vuex.Store({ ...@@ -18,94 +21,8 @@ const store = new Vuex.Store({
job: [] job: []
} }
}, },
actions,
actions: { mutations,
// ensure data for rendering given list type getters
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 []
}
},
// items that should be currently displayed.
// this Array may not be fully fetched.
activeItems (state, getters) {
return getters.activeIds.map(id => state.items[id]).filter(_ => _)
}
}
})
export default store
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, { user }) => {
Vue.set(state.users, user.id, user)
}
}
...@@ -3,7 +3,7 @@ import ItemList from '../components/ItemList.vue' ...@@ -3,7 +3,7 @@ import ItemList from '../components/ItemList.vue'
// 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! // this will be called during SSR to pre-fetch data into the store!
......
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