构建第一个vue-ssr应用-vue-srr学习笔记01

Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将静态标记”混合”为客户端上完全交互的应用程序。

服务器渲染的 Vue.js 应用程序也可以被认为是”同构”或”通用”,因为应用程序的大部分代码都可以在服务器和客户端上运行。

先弄一个客户端应用

使用vue-cli构建了一个最简单的应用,包含了4个页面,首页,新闻列表页,计数页,关于页。

其中首页和关于页为静态页,新闻列表页需要请求接口获取新闻列表,计数页使用vuex实现最简单的计数功能。

注意修改路由模式为history和vuex的使用。

GitHub地址:https://github.com/cdInit/vue-ssr/tree/master/step01

在客户端应用中改造

然后再客户端应用中逐步改造。

修改组件中获取数据方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//News.vue
<template>
<div>
<h1>新闻列表</h1>
<div v-for="(item,index) in news" :key="index">
<p class="newsItem">{{item.title}}</p>
</div>
</div>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
computed: {
...mapGetters({
news: 'getNews'
})
},
asyncData({ store }) {
return store.dispatch(`getNews`)
}
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.newsItem {
cursor: pointer;
}

.newsItem:hover {
color: #fff;
}
</style>

修改main.js

因为是服务端渲染,所以main.js里面的内容需要更改,为了和官方文档保持统一,我把main.js修改为了app.js。

修改后的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//app.js

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './vuex/store'
import { sync } from 'vuex-router-sync'
Vue.config.productionTip = false

export function createApp () {
// 主要是把 vue-router 的狀態放進 vuex 的 state 中,
// 這樣就可以透過改變 state 來進行路由的一些操作,
// 當然直接使用像是 $route.go 之類的也會影響到 state
sync(store, router)
// 创建应用程序实例,将 router 和 store 注入
const app = new Vue({
router,
store,
render: h => h(App)
})
return { app, router, store }
}

参考官方文档:

基本用法

编写通用代码

源码结构

路由和代码分割

添加 entry-client.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//entry-client.js
import { createApp } from './app'

const { app, router, store } = createApp()

if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}

router.onReady(() => {
// 添加路由钩子函数,用于处理 asyncData.
// 在初始路由 resolve 后执行,
// 以便我们不会二次预取(double-fetch)已有的数据。
// 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。
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()
}
// 这里如果有加载指示器(loading indicator),就触发
Promise.all(activated.map(c => {
if (c.asyncData) {
return c.asyncData({ store, route: to })
}
})).then(() => {
// 停止加载指示器(loading indicator)
next()
}).catch(next)
})
app.$mount('#app')
})


添加 webpack.base.config.js

在添加ssr需要的配置文件之前,将vue-cli生成的配置文件备份到其他地方。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
//webpack.base.config.js
const path = require('path')

module.exports = {
devtool: '#source-map',
entry: {
app: './src/entry-client.js',
vendor: ['vue', 'vue-router', 'vuex', 'vuex-router-sync', 'axios']
},
resolve: {
modules: [path.resolve(__dirname, 'src'), 'node_modules'],
extensions: ['.js', '.vue'],
alias: {
'src': path.resolve(__dirname, '../src'),
'assets': path.resolve(__dirname, '../src/assets'),
'components': path.resolve(__dirname, '../src/components')
}
},

output: {
path: path.resolve(__dirname, '../dist'),
publicPath: '/dist/',
filename: 'client-bundle.[chunkhash].js'
},

module: {
rules: [
{
enforce: 'pre',
test: /\.js$/,
loader: 'eslint-loader',
exclude: /node_modules/
},
{
enforce: 'pre',
test: /\.vue$/,
loader: 'eslint-loader',
exclude: /node_modules/
},
{
test: /\.vue$/,
loader: 'vue-loader',
options: {}
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: 'file-loader',
options: {
name: '[name].[ext]?[hash]'
}
}
]
}
}


添加 webpack.client.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const HTMLPlugin = require('html-webpack-plugin')
const SWPrecachePlugin = require('sw-precache-webpack-plugin')

const config = merge(base, {
resolve: {
alias: {
'create-api': './create-api-client.js'
}
},
plugins: [
// strip dev-only code in Vue source
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"client"'
}),
// extract vendor chunks for better caching
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor'
}),
// generate output HTML
new HTMLPlugin({
template: 'src/index.template.html' //在src目录先添加index.template.html文件
})
]
})

if (process.env.NODE_ENV === 'production') {
config.plugins.push(
// minify JS
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false
}
}),
// auto generate service worker
new SWPrecachePlugin({
cacheId: 'vue-hn',
filename: 'service-worker.js',
dontCacheBustUrlsMatching: /./,
staticFileGlobsIgnorePatterns: [/index\.html$/, /\.map$/]
})
)
}

module.exports = config

运行 webpack –config ./build/webpack.client.config.js –progress 我们就可以生成客户端的东西,那么接下来配置服务端的SSR

添加 entry-server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { createApp } from './app'

export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
router.push(context.url)
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// 对所有匹配的路由组件调用 `asyncData()`
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute
})
}
})).then(() => {
// 在所有预取钩子(preFetch hook) resolve 后,
// 我们的 store 现在已经填充入渲染应用程序所需的状态。
// 当我们将状态附加到上下文,
// 并且 `template` 选项用于 renderer 时,
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state
resolve(app)
}).catch(reject)
}, reject)
})
}

添加 webpack.server.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const VueSSRPlugin = require('vue-ssr-webpack-plugin')

module.exports = merge(base, {
target: 'node',
entry: './src/entry-server.js',
output: {
filename: 'server-bundle.js',
libraryTarget: 'commonjs2'
},
resolve: {
alias: {
'create-api': './create-api-server.js'
}
},
externals: Object.keys(require('../package.json').dependencies),
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"server"'
}),
new VueSSRPlugin()
]
})


运行 webpack –config ./build/webpack.server.config.js –progress

添加 server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
const fs = require('fs')
const path = require('path')
const express = require('express')
// const favicon = require('serve-favicon')
const resolve = file => path.resolve(__dirname, file)

const isProd = process.env.NODE_ENV === 'production'

const app = express()

const bundle = require('./dist/vue-ssr-bundle.json')

const template = fs.readFileSync(resolve('./dist/index.html'), 'utf-8')

let renderer = createRenderer(bundle, template)

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
})
})
}

const serve = (path, cache) => express.static(resolve(path), {
maxAge: cache && isProd ? 60 * 60 * 24 * 30 : 0
})


app.use('/dist', serve('./dist', true))
// app.use(favicon(path.resolve(__dirname, 'src/assets/logo.png')))
app.use('/service-worker.js', serve('./dist/service-worker.js'))

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

const s = Date.now()

res.setHeader("Content-Type", "text/html")

const errorHandler = err => {
if (err && err.code === 404) {
res.status(404).end('404 | Page Not Found')
} else {
// Render Error Page or Redirect
res.status(500).end('500 | Internal Server Error')
console.error(`error during render : ${req.url}`)
console.error(err)
}
}

renderer.renderToStream({ url: req.url })
.on('error', errorHandler)
.on('end', () => console.log(`whole request: ${Date.now() - s}ms`))
.pipe(res)
})

const port = process.env.PORT || 3000
app.listen(port, () => {
console.log(`server started at http://localhost:${port}`)
})


遇到缺少的包,装上就可以了。

运行:

node ./server.js

那么最后实现的效果是:

1.当我在首页刷新的时候,服务器会渲染html到客户端,但是点击其他的页面,并不会向服务器发送多余的请求。比如点击 新闻列表 ,依然是ajax请求新闻列表到客户端渲染。

2.当我在 新闻列表 刷新的时候就不一样了,他不会在客户端渲染,而是在服务端渲染好了然后把html发送给客户端。

这样就实现了简单的ssr,同构。

源码:https://github.com/cdInit/vue-ssr/tree/master/step02

后续:

  1. 我这里也只是弄了一个简单的例子出来,后面有时间再继续研究其他的东西,比如nuxt等。