Skip to content

Commit 1b078be

Browse files
committed
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.
1 parent 6f0c0fe commit 1b078be

File tree

11 files changed

+229
-252
lines changed

11 files changed

+229
-252
lines changed

build/setup-dev-server.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ module.exports = function setupDevServer (app, opts) {
2828
const filePath = path.join(clientConfig.output.path, 'index.html')
2929
if (fs.existsSync(filePath)) {
3030
const index = fs.readFileSync(filePath, 'utf-8')
31-
opts.indexUpdated(index)
31+
opts.templateUpdated(index)
3232
}
3333
})
3434

@@ -38,13 +38,15 @@ module.exports = function setupDevServer (app, opts) {
3838
// watch and update server renderer
3939
const serverCompiler = webpack(serverConfig)
4040
const mfs = new MFS()
41-
const outputPath = path.join(serverConfig.output.path, serverConfig.output.filename)
4241
serverCompiler.outputFileSystem = mfs
4342
serverCompiler.watch({}, (err, stats) => {
4443
if (err) throw err
4544
stats = stats.toJson()
4645
stats.errors.forEach(err => console.error(err))
4746
stats.warnings.forEach(err => console.warn(err))
48-
opts.bundleUpdated(mfs.readFileSync(outputPath, 'utf-8'))
47+
48+
// read bundle generated by vue-ssr-webpack-plugin
49+
const bundlePath = path.join(serverConfig.output.path, 'vue-ssr-bundle.json')
50+
opts.bundleUpdated(JSON.parse(mfs.readFileSync(bundlePath, 'utf-8')))
4951
})
5052
}

build/webpack.base.config.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ const vueConfig = require('./vue-loader.config')
44
module.exports = {
55
devtool: '#source-map',
66
entry: {
7-
app: './src/client-entry.js',
7+
app: './src/entry-client.js',
88
vendor: [
9-
'es6-promise',
9+
'es6-promise/auto',
1010
'firebase/app',
1111
'firebase/database',
1212
'vue',

build/webpack.client.config.js

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ const webpack = require('webpack')
22
const base = require('./webpack.base.config')
33
const vueConfig = require('./vue-loader.config')
44
const HTMLPlugin = require('html-webpack-plugin')
5-
const ExtractTextPlugin = require('extract-text-webpack-plugin')
65
const SWPrecachePlugin = require('sw-precache-webpack-plugin')
76

87
const config = Object.assign({}, base, {
@@ -12,7 +11,7 @@ const config = Object.assign({}, base, {
1211
})
1312
},
1413
plugins: (base.plugins || []).concat([
15-
// strip comments in Vue code
14+
// strip dev-only code in Vue source
1615
new webpack.DefinePlugin({
1716
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
1817
'process.env.VUE_ENV': '"client"'
@@ -29,30 +28,14 @@ const config = Object.assign({}, base, {
2928
})
3029

3130
if (process.env.NODE_ENV === 'production') {
32-
// Use ExtractTextPlugin to extract CSS into a single file
33-
// so it's applied on initial render.
34-
// vueConfig is already included in the config via LoaderOptionsPlugin
35-
// here we overwrite the loader config for <style lang="stylus">
36-
// so they are extracted.
37-
vueConfig.loaders = {
38-
stylus: ExtractTextPlugin.extract({
39-
loader: 'css-loader!stylus-loader',
40-
fallbackLoader: 'vue-style-loader' // <- this is a dep of vue-loader
41-
})
42-
}
43-
4431
config.plugins.push(
45-
new ExtractTextPlugin('styles.[hash].css'),
46-
// this is needed in webpack 2 for minifying CSS
47-
new webpack.LoaderOptionsPlugin({
48-
minimize: true
49-
}),
5032
// minify JS
5133
new webpack.optimize.UglifyJsPlugin({
5234
compress: {
5335
warnings: false
5436
}
5537
}),
38+
// auto generate service worker
5639
new SWPrecachePlugin({
5740
cacheId: 'vue-hn',
5841
filename: 'service-worker.js',

build/webpack.server.config.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
const webpack = require('webpack')
22
const base = require('./webpack.base.config')
3+
const VueSSRPlugin = require('vue-ssr-webpack-plugin')
34

45
module.exports = Object.assign({}, base, {
56
target: 'node',
6-
devtool: false,
7-
entry: './src/server-entry.js',
7+
entry: './src/entry-server.js',
88
output: Object.assign({}, base.output, {
99
filename: 'server-bundle.js',
1010
libraryTarget: 'commonjs2'
@@ -19,6 +19,7 @@ module.exports = Object.assign({}, base, {
1919
new webpack.DefinePlugin({
2020
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
2121
'process.env.VUE_ENV': '"server"'
22-
})
22+
}),
23+
new VueSSRPlugin()
2324
]
2425
})

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"vue": "^2.1.10",
2727
"vue-router": "^2.1.0",
2828
"vue-server-renderer": "^2.1.10",
29+
"vue-ssr-html-stream": "^1.0.0",
2930
"vuex": "^2.1.0",
3031
"vuex-router-sync": "^4.0.2"
3132
},
@@ -34,15 +35,15 @@
3435
"buble": "^0.15.1",
3536
"buble-loader": "^0.4.0",
3637
"css-loader": "^0.26.0",
37-
"extract-text-webpack-plugin": "^2.0.0-beta.3",
3838
"file-loader": "^0.9.0",
3939
"html-webpack-plugin": "^2.24.1",
4040
"rimraf": "^2.5.4",
4141
"stylus": "^0.54.5",
4242
"stylus-loader": "^2.4.0",
4343
"sw-precache-webpack-plugin": "^0.7.0",
4444
"url-loader": "^0.5.7",
45-
"vue-loader": "^10.0.2",
45+
"vue-loader": "^10.2.0",
46+
"vue-ssr-webpack-plugin": "^1.0.0",
4647
"vue-template-compiler": "^2.1.8",
4748
"webpack": "^2.2.0",
4849
"webpack-dev-middleware": "^1.8.4",

server.js

Lines changed: 27 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ const path = require('path')
33
const express = require('express')
44
const favicon = require('serve-favicon')
55
const compression = require('compression')
6-
const serialize = require('serialize-javascript')
6+
const HTMLStream = require('vue-ssr-html-stream')
77
const resolve = file => path.resolve(__dirname, file)
88

99
const isProd = process.env.NODE_ENV === 'production'
@@ -13,21 +13,21 @@ const serverInfo =
1313

1414
const app = express()
1515

16-
let indexHTML // generated by html-webpack-plugin
16+
let template // generated by html-webpack-plugin
1717
let renderer // created from the webpack-generated server bundle
1818
if (isProd) {
1919
// in production: create server renderer and index HTML from real fs
20-
renderer = createRenderer(fs.readFileSync(resolve('./dist/server-bundle.js'), 'utf-8'))
21-
indexHTML = parseIndex(fs.readFileSync(resolve('./dist/index.html'), 'utf-8'))
20+
renderer = createRenderer(require('./dist/vue-ssr-bundle.json'), 'utf-8')
21+
template = fs.readFileSync(resolve('./dist/index.html'), 'utf-8')
2222
} else {
2323
// in development: setup the dev server with watch and hot-reload,
2424
// and update renderer / index HTML on file change.
2525
require('./build/setup-dev-server')(app, {
2626
bundleUpdated: bundle => {
2727
renderer = createRenderer(bundle)
2828
},
29-
indexUpdated: index => {
30-
indexHTML = parseIndex(index)
29+
templateUpdated: _template => {
30+
template = _template
3131
}
3232
})
3333
}
@@ -42,69 +42,46 @@ function createRenderer (bundle) {
4242
})
4343
}
4444

45-
function parseIndex (template) {
46-
const contentMarker = '<!-- APP -->'
47-
const i = template.indexOf(contentMarker)
48-
return {
49-
head: template.slice(0, i),
50-
tail: template.slice(i + contentMarker.length)
51-
}
52-
}
53-
5445
const serve = (path, cache) => express.static(resolve(path), {
5546
maxAge: cache && isProd ? 60 * 60 * 24 * 30 : 0
5647
})
5748

5849
app.use(compression({ threshold: 0 }))
5950
app.use(favicon('./public/logo-48.png'))
60-
app.use('/service-worker.js', serve('./dist/service-worker.js'))
61-
app.use('/manifest.json', serve('./manifest.json'))
6251
app.use('/dist', serve('./dist'))
6352
app.use('/public', serve('./public'))
53+
app.use('/manifest.json', serve('./manifest.json'))
54+
app.use('/service-worker.js', serve('./dist/service-worker.js'))
6455

6556
app.get('*', (req, res) => {
6657
if (!renderer) {
6758
return res.end('waiting for compilation... refresh in a moment.')
6859
}
6960

61+
const s = Date.now()
62+
7063
res.setHeader("Content-Type", "text/html")
7164
res.setHeader("Server", serverInfo)
7265

73-
var s = Date.now()
74-
const context = { url: req.url }
75-
const renderStream = renderer.renderToStream(context)
76-
77-
renderStream.once('data', () => {
78-
res.write(indexHTML.head)
79-
})
80-
81-
renderStream.on('data', chunk => {
82-
res.write(chunk)
83-
})
84-
85-
renderStream.on('end', () => {
86-
// embed initial store state
87-
if (context.initialState) {
88-
res.write(
89-
`<script>window.__INITIAL_STATE__=${
90-
serialize(context.initialState, { isJSON: true })
91-
}</script>`
92-
)
93-
}
94-
res.end(indexHTML.tail)
95-
console.log(`whole request: ${Date.now() - s}ms`)
96-
})
97-
98-
renderStream.on('error', err => {
99-
if (err && err.code === '404') {
66+
const errorHandler = err => {
67+
if (err && err.code === 404) {
10068
res.status(404).end('404 | Page Not Found')
101-
return
69+
} else {
70+
// Render Error Page or Redirect
71+
res.status(500).end('Internal Error 500')
72+
console.error(`error during render : ${req.url}`)
73+
console.error(err)
10274
}
103-
// Render Error Page or Redirect
104-
res.status(500).end('Internal Error 500')
105-
console.error(`error during render : ${req.url}`)
106-
console.error(err)
107-
})
75+
}
76+
77+
const context = { url: req.url }
78+
const htmlStream = new HTMLStream({ template, context })
79+
80+
renderer.renderToStream(context)
81+
.on('error', errorHandler)
82+
.pipe(htmlStream)
83+
.on('end', () => console.log(`whole request: ${Date.now() - s}ms`))
84+
.pipe(res)
10885
})
10986

11087
const port = process.env.PORT || 8080

src/client-entry.js renamed to src/entry-client.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import 'es6-promise/auto'
2-
import { app, store } from './app'
2+
import { app, store, router } from './app'
33

44
// prime the store with server-initialized state.
55
// the state is determined during SSR and inlined in the page markup.
66
store.replaceState(window.__INITIAL_STATE__)
77

8-
// actually mount to DOM
9-
app.$mount('#app')
8+
// wait until router has resolved all async before hooks
9+
// and async components...
10+
router.onReady(() => {
11+
// actually mount to DOM
12+
app.$mount('#app')
13+
})
1014

1115
// service worker
1216
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {

src/entry-server.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { app, router, store } from './app'
2+
3+
const isDev = process.env.NODE_ENV !== 'production'
4+
5+
// This exported function will be called by `bundleRenderer`.
6+
// This is where we perform data-prefetching to determine the
7+
// state of our application before actually rendering it.
8+
// Since data fetching is async, this function is expected to
9+
// return a Promise that resolves to the app instance.
10+
export default context => {
11+
const s = isDev && Date.now()
12+
13+
return new Promise((resolve, reject) => {
14+
// set router's location
15+
router.push(context.url)
16+
17+
// wait until router has resolved possible async hooks
18+
router.onReady(() => {
19+
const matchedComponents = router.getMatchedComponents()
20+
// no matched routes
21+
if (!matchedComponents.length) {
22+
reject({ code: 404 })
23+
}
24+
// Call preFetch hooks on components matched by the route.
25+
// A preFetch hook dispatches a store action and returns a Promise,
26+
// which is resolved when the action is complete and store state has been
27+
// updated.
28+
Promise.all(matchedComponents.map(component => {
29+
return component.preFetch && component.preFetch(store)
30+
})).then(() => {
31+
isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
32+
// After all preFetch hooks are resolved, our store is now
33+
// filled with the state needed to render the app.
34+
// Expose the state on the render context, and let the request handler
35+
// inline the state in the HTML response. This allows the client-side
36+
// store to pick-up the server-side state without having to duplicate
37+
// the initial data fetching on the client.
38+
context.state = store.state
39+
resolve(app)
40+
}).catch(reject)
41+
})
42+
})
43+
}

src/router/index.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,22 @@ import Router from 'vue-router'
33

44
Vue.use(Router)
55

6-
import { createListView } from '../views/CreateListView'
7-
import ItemView from '../views/ItemView.vue'
8-
import UserView from '../views/UserView.vue'
6+
// We are using Webpack code splitting here so that each route's associated
7+
// component code is loaded on-demand only when the route is visited.
8+
// It's actually not really necessary for a small project of this size but
9+
// the goal is to demonstrate how to do it.
10+
//
11+
// Note that the dynamic import syntax should actually be just `import()`
12+
// but buble/acorn doesn't support parsing that syntax until it's stage 4
13+
// so we use the old System.import here instead.
14+
//
15+
// If using Babel, `import()` can be supported via
16+
// babel-plugin-syntax-dynamic-import.
17+
18+
const createListView = name => () =>
19+
System.import('../views/CreateListView').then(m => m.createListView(name))
20+
const ItemView = () => System.import('../views/ItemView.vue')
21+
const UserView = () => System.import('../views/UserView.vue')
922

1023
export default new Router({
1124
mode: 'history',

src/server-entry.js

Lines changed: 0 additions & 41 deletions
This file was deleted.

0 commit comments

Comments
 (0)