Commit 1b078be1 authored by Evan You's avatar Evan You

Update to use latest SSR features

- Use vue-server-renderer 2.2.0 + vue-ssr-webpack-plugin to handle Webpack code-split bundle - Use vue-router 2.2.0 to handle async components and async route hooks - Use vue-loader 10.2.0 + vue-style-loader 2.0 for inline critical CSS + better style split - Use vue-srr-html-stream to simplify streaming usage.
parent 6f0c0fef
...@@ -28,7 +28,7 @@ module.exports = function setupDevServer (app, opts) { ...@@ -28,7 +28,7 @@ module.exports = function setupDevServer (app, opts) {
const filePath = path.join(clientConfig.output.path, 'index.html') const filePath = path.join(clientConfig.output.path, 'index.html')
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
const index = fs.readFileSync(filePath, 'utf-8') const index = fs.readFileSync(filePath, 'utf-8')
opts.indexUpdated(index) opts.templateUpdated(index)
} }
}) })
...@@ -38,13 +38,15 @@ module.exports = function setupDevServer (app, opts) { ...@@ -38,13 +38,15 @@ module.exports = function setupDevServer (app, opts) {
// watch and update server renderer // watch and update server renderer
const serverCompiler = webpack(serverConfig) const serverCompiler = webpack(serverConfig)
const mfs = new MFS() const mfs = new MFS()
const outputPath = path.join(serverConfig.output.path, serverConfig.output.filename)
serverCompiler.outputFileSystem = mfs serverCompiler.outputFileSystem = mfs
serverCompiler.watch({}, (err, stats) => { serverCompiler.watch({}, (err, stats) => {
if (err) throw err if (err) throw err
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))
opts.bundleUpdated(mfs.readFileSync(outputPath, 'utf-8'))
// read bundle generated by vue-ssr-webpack-plugin
const bundlePath = path.join(serverConfig.output.path, 'vue-ssr-bundle.json')
opts.bundleUpdated(JSON.parse(mfs.readFileSync(bundlePath, 'utf-8')))
}) })
} }
...@@ -4,9 +4,9 @@ const vueConfig = require('./vue-loader.config') ...@@ -4,9 +4,9 @@ const vueConfig = require('./vue-loader.config')
module.exports = { module.exports = {
devtool: '#source-map', devtool: '#source-map',
entry: { entry: {
app: './src/client-entry.js', app: './src/entry-client.js',
vendor: [ vendor: [
'es6-promise', 'es6-promise/auto',
'firebase/app', 'firebase/app',
'firebase/database', 'firebase/database',
'vue', 'vue',
......
...@@ -2,7 +2,6 @@ const webpack = require('webpack') ...@@ -2,7 +2,6 @@ const webpack = require('webpack')
const base = require('./webpack.base.config') const base = require('./webpack.base.config')
const vueConfig = require('./vue-loader.config') const vueConfig = require('./vue-loader.config')
const HTMLPlugin = require('html-webpack-plugin') const HTMLPlugin = require('html-webpack-plugin')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const SWPrecachePlugin = require('sw-precache-webpack-plugin') const SWPrecachePlugin = require('sw-precache-webpack-plugin')
const config = Object.assign({}, base, { const config = Object.assign({}, base, {
...@@ -12,7 +11,7 @@ const config = Object.assign({}, base, { ...@@ -12,7 +11,7 @@ const config = Object.assign({}, base, {
}) })
}, },
plugins: (base.plugins || []).concat([ plugins: (base.plugins || []).concat([
// strip comments in Vue code // strip dev-only code in Vue source
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': '"client"' 'process.env.VUE_ENV': '"client"'
...@@ -29,30 +28,14 @@ const config = Object.assign({}, base, { ...@@ -29,30 +28,14 @@ const config = Object.assign({}, base, {
}) })
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
// Use ExtractTextPlugin to extract CSS into a single file
// so it's applied on initial render.
// vueConfig is already included in the config via LoaderOptionsPlugin
// here we overwrite the loader config for <style lang="stylus">
// so they are extracted.
vueConfig.loaders = {
stylus: ExtractTextPlugin.extract({
loader: 'css-loader!stylus-loader',
fallbackLoader: 'vue-style-loader' // <- this is a dep of vue-loader
})
}
config.plugins.push( config.plugins.push(
new ExtractTextPlugin('styles.[hash].css'),
// this is needed in webpack 2 for minifying CSS
new webpack.LoaderOptionsPlugin({
minimize: true
}),
// minify JS // minify JS
new webpack.optimize.UglifyJsPlugin({ new webpack.optimize.UglifyJsPlugin({
compress: { compress: {
warnings: false warnings: false
} }
}), }),
// auto generate service worker
new SWPrecachePlugin({ new SWPrecachePlugin({
cacheId: 'vue-hn', cacheId: 'vue-hn',
filename: 'service-worker.js', filename: 'service-worker.js',
......
const webpack = require('webpack') const webpack = require('webpack')
const base = require('./webpack.base.config') const base = require('./webpack.base.config')
const VueSSRPlugin = require('vue-ssr-webpack-plugin')
module.exports = Object.assign({}, base, { module.exports = Object.assign({}, base, {
target: 'node', target: 'node',
devtool: false, entry: './src/entry-server.js',
entry: './src/server-entry.js',
output: Object.assign({}, base.output, { output: Object.assign({}, base.output, {
filename: 'server-bundle.js', filename: 'server-bundle.js',
libraryTarget: 'commonjs2' libraryTarget: 'commonjs2'
...@@ -19,6 +19,7 @@ module.exports = Object.assign({}, base, { ...@@ -19,6 +19,7 @@ module.exports = Object.assign({}, base, {
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()
] ]
}) })
...@@ -26,6 +26,7 @@ ...@@ -26,6 +26,7 @@
"vue": "^2.1.10", "vue": "^2.1.10",
"vue-router": "^2.1.0", "vue-router": "^2.1.0",
"vue-server-renderer": "^2.1.10", "vue-server-renderer": "^2.1.10",
"vue-ssr-html-stream": "^1.0.0",
"vuex": "^2.1.0", "vuex": "^2.1.0",
"vuex-router-sync": "^4.0.2" "vuex-router-sync": "^4.0.2"
}, },
...@@ -34,7 +35,6 @@ ...@@ -34,7 +35,6 @@
"buble": "^0.15.1", "buble": "^0.15.1",
"buble-loader": "^0.4.0", "buble-loader": "^0.4.0",
"css-loader": "^0.26.0", "css-loader": "^0.26.0",
"extract-text-webpack-plugin": "^2.0.0-beta.3",
"file-loader": "^0.9.0", "file-loader": "^0.9.0",
"html-webpack-plugin": "^2.24.1", "html-webpack-plugin": "^2.24.1",
"rimraf": "^2.5.4", "rimraf": "^2.5.4",
...@@ -42,7 +42,8 @@ ...@@ -42,7 +42,8 @@
"stylus-loader": "^2.4.0", "stylus-loader": "^2.4.0",
"sw-precache-webpack-plugin": "^0.7.0", "sw-precache-webpack-plugin": "^0.7.0",
"url-loader": "^0.5.7", "url-loader": "^0.5.7",
"vue-loader": "^10.0.2", "vue-loader": "^10.2.0",
"vue-ssr-webpack-plugin": "^1.0.0",
"vue-template-compiler": "^2.1.8", "vue-template-compiler": "^2.1.8",
"webpack": "^2.2.0", "webpack": "^2.2.0",
"webpack-dev-middleware": "^1.8.4", "webpack-dev-middleware": "^1.8.4",
......
...@@ -3,7 +3,7 @@ const path = require('path') ...@@ -3,7 +3,7 @@ const path = require('path')
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 serialize = require('serialize-javascript') const HTMLStream = require('vue-ssr-html-stream')
const resolve = file => path.resolve(__dirname, file) const resolve = file => path.resolve(__dirname, file)
const isProd = process.env.NODE_ENV === 'production' const isProd = process.env.NODE_ENV === 'production'
...@@ -13,12 +13,12 @@ const serverInfo = ...@@ -13,12 +13,12 @@ const serverInfo =
const app = express() const app = express()
let indexHTML // generated by html-webpack-plugin let template // generated by html-webpack-plugin
let renderer // created from the webpack-generated server bundle let renderer // created from the webpack-generated server bundle
if (isProd) { if (isProd) {
// in production: create server renderer and index HTML from real fs // in production: create server renderer and index HTML from real fs
renderer = createRenderer(fs.readFileSync(resolve('./dist/server-bundle.js'), 'utf-8')) renderer = createRenderer(require('./dist/vue-ssr-bundle.json'), 'utf-8')
indexHTML = parseIndex(fs.readFileSync(resolve('./dist/index.html'), 'utf-8')) template = fs.readFileSync(resolve('./dist/index.html'), 'utf-8')
} 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 update renderer / index HTML on file change. // and update renderer / index HTML on file change.
...@@ -26,8 +26,8 @@ if (isProd) { ...@@ -26,8 +26,8 @@ if (isProd) {
bundleUpdated: bundle => { bundleUpdated: bundle => {
renderer = createRenderer(bundle) renderer = createRenderer(bundle)
}, },
indexUpdated: index => { templateUpdated: _template => {
indexHTML = parseIndex(index) template = _template
} }
}) })
} }
...@@ -42,69 +42,46 @@ function createRenderer (bundle) { ...@@ -42,69 +42,46 @@ function createRenderer (bundle) {
}) })
} }
function parseIndex (template) {
const contentMarker = '<!-- APP -->'
const i = template.indexOf(contentMarker)
return {
head: template.slice(0, i),
tail: template.slice(i + contentMarker.length)
}
}
const serve = (path, cache) => express.static(resolve(path), { const serve = (path, cache) => express.static(resolve(path), {
maxAge: cache && isProd ? 60 * 60 * 24 * 30 : 0 maxAge: cache && isProd ? 60 * 60 * 24 * 30 : 0
}) })
app.use(compression({ threshold: 0 })) app.use(compression({ threshold: 0 }))
app.use(favicon('./public/logo-48.png')) app.use(favicon('./public/logo-48.png'))
app.use('/service-worker.js', serve('./dist/service-worker.js'))
app.use('/manifest.json', serve('./manifest.json'))
app.use('/dist', serve('./dist')) app.use('/dist', serve('./dist'))
app.use('/public', serve('./public')) app.use('/public', serve('./public'))
app.use('/manifest.json', serve('./manifest.json'))
app.use('/service-worker.js', serve('./dist/service-worker.js'))
app.get('*', (req, res) => { app.get('*', (req, res) => {
if (!renderer) { if (!renderer) {
return res.end('waiting for compilation... refresh in a moment.') return res.end('waiting for compilation... refresh in a moment.')
} }
const s = Date.now()
res.setHeader("Content-Type", "text/html") res.setHeader("Content-Type", "text/html")
res.setHeader("Server", serverInfo) res.setHeader("Server", serverInfo)
var s = Date.now() const errorHandler = err => {
const context = { url: req.url } if (err && err.code === 404) {
const renderStream = renderer.renderToStream(context)
renderStream.once('data', () => {
res.write(indexHTML.head)
})
renderStream.on('data', chunk => {
res.write(chunk)
})
renderStream.on('end', () => {
// embed initial store state
if (context.initialState) {
res.write(
`<script>window.__INITIAL_STATE__=${
serialize(context.initialState, { isJSON: true })
}</script>`
)
}
res.end(indexHTML.tail)
console.log(`whole request: ${Date.now() - s}ms`)
})
renderStream.on('error', err => {
if (err && err.code === '404') {
res.status(404).end('404 | Page Not Found') res.status(404).end('404 | Page Not Found')
return } else {
}
// Render Error Page or Redirect // Render Error Page or Redirect
res.status(500).end('Internal Error 500') res.status(500).end('Internal Error 500')
console.error(`error during render : ${req.url}`) console.error(`error during render : ${req.url}`)
console.error(err) console.error(err)
}) }
}
const context = { url: req.url }
const htmlStream = new HTMLStream({ template, context })
renderer.renderToStream(context)
.on('error', errorHandler)
.pipe(htmlStream)
.on('end', () => console.log(`whole request: ${Date.now() - s}ms`))
.pipe(res)
}) })
const port = process.env.PORT || 8080 const port = process.env.PORT || 8080
......
import 'es6-promise/auto' import 'es6-promise/auto'
import { app, store } from './app' import { app, store, router } from './app'
// 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.
store.replaceState(window.__INITIAL_STATE__) store.replaceState(window.__INITIAL_STATE__)
// actually mount to DOM // wait until router has resolved all async before hooks
app.$mount('#app') // and async components...
router.onReady(() => {
// actually mount to DOM
app.$mount('#app')
})
// service worker // service worker
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
......
...@@ -10,23 +10,23 @@ const isDev = process.env.NODE_ENV !== 'production' ...@@ -10,23 +10,23 @@ const isDev = process.env.NODE_ENV !== 'production'
export default context => { export default context => {
const s = isDev && Date.now() const s = isDev && Date.now()
return new Promise((resolve, reject) => {
// set router's location // set router's location
router.push(context.url) router.push(context.url)
const matchedComponents = router.getMatchedComponents()
// wait until router has resolved possible async hooks
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// no matched routes // no matched routes
if (!matchedComponents.length) { if (!matchedComponents.length) {
return Promise.reject({ code: '404' }) reject({ code: 404 })
} }
// Call preFetch hooks on components matched by the route. // Call preFetch 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.
return Promise.all(matchedComponents.map(component => { Promise.all(matchedComponents.map(component => {
if (component.preFetch) { return component.preFetch && component.preFetch(store)
return component.preFetch(store)
}
})).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
...@@ -35,7 +35,9 @@ export default context => { ...@@ -35,7 +35,9 @@ export default context => {
// inline the state in the HTML response. This allows the client-side // inline the state in the HTML response. This allows the client-side
// store to pick-up the server-side state without having to duplicate // store to pick-up the server-side state without having to duplicate
// the initial data fetching on the client. // the initial data fetching on the client.
context.initialState = store.state context.state = store.state
return app resolve(app)
}).catch(reject)
})
}) })
} }
...@@ -3,9 +3,22 @@ import Router from 'vue-router' ...@@ -3,9 +3,22 @@ import Router from 'vue-router'
Vue.use(Router) Vue.use(Router)
import { createListView } from '../views/CreateListView' // We are using Webpack code splitting here so that each route's associated
import ItemView from '../views/ItemView.vue' // component code is loaded on-demand only when the route is visited.
import UserView from '../views/UserView.vue' // 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 UserView = () => System.import('../views/UserView.vue')
export default new Router({ export default new Router({
mode: 'history', mode: 'history',
......
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