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

Merge branch 'perf' into client-manifest

parents d75accb8 f0b94b1b
......@@ -16,7 +16,7 @@
},
"dependencies": {
"compression": "^1.6.2",
"cross-env": "^3.2.4",
"cross-env": "^4.0.0",
"es6-promise": "^4.1.0",
"express": "^4.15.2",
"firebase": "^3.7.2",
......
......@@ -8,6 +8,7 @@ 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}`
......@@ -26,7 +27,8 @@ function createRenderer (bundle, options) {
maxAge: 1000 * 60 * 15
}),
// 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))
app.use('/manifest.json', serve('./manifest.json', true))
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/
const pageCache = LRU({
const microCache = LRU({
max: 100,
maxAge: 1000
})
......@@ -73,7 +75,7 @@ const pageCache = LRU({
// 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 => true
const isCacheable = req => useMicroCache
app.get('*', (req, res) => {
if (!renderer) {
......@@ -98,7 +100,7 @@ app.get('*', (req, res) => {
const cacheable = isCacheable(req)
if (cacheable) {
const hit = pageCache.get(req.url)
const hit = microCache.get(req.url)
if (hit) {
if (!isProd) {
console.log(`cache hit!`)
......@@ -113,7 +115,7 @@ app.get('*', (req, res) => {
}
res.end(html)
if (cacheable) {
pageCache.set(req.url, html)
microCache.set(req.url, html)
}
if (!isProd) {
console.log(`whole request: ${Date.now() - s}ms`)
......
import Firebase from 'firebase/app'
import 'firebase/database'
const config = {
databaseURL: 'https://hacker-news.firebaseio.com'
export function createAPI ({ config, version }) {
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 LRU from 'lru-cache'
import { fetchItems } from './api'
let api
const config = {
databaseURL: 'https://hacker-news.firebaseio.com'
}
const version = '/v0'
if (process.__API__) {
export function createAPI ({ config, version }) {
let api
// this piece of code may run multiple times in development mode,
// so we attach the instantiated API to `process` to avoid duplications
if (process.__API__) {
api = process.__API__
} else {
} else {
Firebase.initializeApp(config)
api = process.__API__ = Firebase.database().ref(version)
api.onServer = true
// fetched item cache
......@@ -28,6 +26,6 @@ if (process.__API__) {
api.cachedIds[type] = snapshot.val()
})
})
}
return api
}
export default api
// 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
// make sure to do this only once across all requests
if (api.onServer && !api.warmCacheStarted) {
api.warmCacheStarted = true
if (api.onServer) {
warmCache()
}
......@@ -14,8 +22,10 @@ function warmCache () {
}
function fetch (child) {
logRequests && console.log(`fetching ${child}...`)
const cache = api.cachedItems
if (cache && cache.has(child)) {
logRequests && console.log(`cache hit for ${child}.`)
return Promise.resolve(cache.get(child))
} else {
return new Promise((resolve, reject) => {
......@@ -24,6 +34,7 @@ function fetch (child) {
// mark the timestamp when this item is cached
if (val) val.__lastUpdated = Date.now()
cache && cache.set(child, val)
logRequests && console.log(`fetched ${child}.`)
resolve(val)
}, reject)
})
......
import Vue from 'vue'
import App from './App.vue'
import store from './store'
import router from './router'
import { createStore } from './store'
import { createRouter } from './router'
import { sync } from 'vuex-router-sync'
import * as filters from './filters'
// sync the router with the vuex store.
// this registers `store.state.route`
sync(store, router)
// register global utility filters.
Object.keys(filters).forEach(key => {
Vue.filter(key, filters[key])
})
// 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({
// Expose a factory function that creates a fresh set of store, router,
// app instances on each call (which is called for each SSR request)
export function createApp () {
// 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,
store,
render: h => h(App)
})
})
// expose the app, the router and the store.
// 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.
export { app, router, store }
// expose the app, the router and the store.
// 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.
return { app, router, store }
}
......@@ -22,9 +22,7 @@
<script>
import Spinner from './Spinner.vue'
import Item from './Item.vue'
import { watchList } from '../store/api'
let isInitialRender = true
import { watchList } from '../api'
export default {
name: 'item-list',
......@@ -39,18 +37,13 @@ export default {
},
data () {
const data = {
const isInitialRender = !this.$root._isMounted
return {
loading: false,
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,
displayedItems: isInitialRender ? this.$store.getters.activeItems : []
}
isInitialRender = false
return data
},
computed: {
......
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.
// 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'
......@@ -10,6 +10,8 @@ const isDev = process.env.NODE_ENV !== 'production'
export default context => {
const s = isDev && Date.now()
const { app, router, store } = createApp()
return new Promise((resolve, reject) => {
// set router's location
router.push(context.url)
......
......@@ -3,24 +3,13 @@ import Router from 'vue-router'
Vue.use(Router)
// We are using Webpack code splitting here so that each route's associated
// component code is loaded on-demand only when the route is visited.
// 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))
// route-level code splitting
const createListView = id => () => System.import('../views/CreateListView').then(m => m.default(id))
const ItemView = () => System.import('../views/ItemView.vue')
const UserView = () => System.import('../views/UserView.vue')
export default new Router({
export function createRouter () {
return new Router({
mode: 'history',
scrollBehavior: () => ({ y: 0 }),
routes: [
......@@ -33,4 +22,5 @@ export default new Router({
{ 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', { 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 Vuex from 'vuex'
import { fetchItems, fetchIdsByType, fetchUser } from './api'
import actions from './actions'
import mutations from './mutations'
import getters from './getters'
Vue.use(Vuex)
const store = new Vuex.Store({
export function createStore () {
return new Vuex.Store({
state: {
activeType: null,
itemsPerPage: 20,
......@@ -18,94 +21,8 @@ const store = new Vuex.Store({
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
actions,
mutations,
getters
})
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'
// This is a factory function for dynamically creating root-level list views,
// since they share most of the logic except for the type of items to display.
// They are essentially higher order components wrapping ItemList.vue.
export function createListView (type) {
export default function createListView (type) {
return {
name: `${type}-stories-view`,
// 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