├── .gitignore
├── public
├── favicon.ico
└── index.template.html
├── src
├── assets
│ └── title.jpg
├── api.js
├── views
│ ├── Home
│ │ ├── Three.vue
│ │ ├── Two.vue
│ │ └── Home.vue
│ └── Table
│ │ ├── Three.vue
│ │ ├── Two.vue
│ │ └── Table.vue
├── App.vue
├── app.js
├── entry-client.js
├── store.js
├── entry-server.js
└── router.js
├── .babelrc
├── README.md
├── server
├── api.js
├── dev-server.js
└── pro-server.js
└── package.json
/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
3 | .log
4 | .vscode/
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/woai3c/vue-ssr-demo/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/src/assets/title.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/woai3c/vue-ssr-demo/HEAD/src/assets/title.jpg
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | "@babel/plugin-proposal-object-rest-spread",
4 | "@babel/plugin-transform-runtime"
5 | ],
6 | "presets": [
7 | "@babel/preset-env"
8 | ]
9 | }
--------------------------------------------------------------------------------
/src/api.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 |
3 | export function fetchData(key) {
4 | return axios.get('http://localhost:8080/fetchData?key=' + key)
5 | }
6 |
7 | export function changeData() {
8 | return axios.post('http://localhost:8080/changeData')
9 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # vue-ssr-demo
2 | ## 文档
3 | [手把手教你搭建 Vue 服务端渲染项目](https://github.com/woai3c/Front-end-articles/issues/13)
4 | ## 使用
5 | 克隆项目
6 | ```
7 | git clone https://github.com/woai3c/vue-ssr-demo.git
8 | ```
9 | 下载依赖
10 | ```
11 | npm i
12 | ```
13 | 启动开发服务器
14 | ```
15 | npm run dev
16 | ```
17 | 打包项目
18 | ```
19 | npm run build
20 | ```
21 | 开启服务器
22 | ```
23 | npm run server
24 | ```
25 |
--------------------------------------------------------------------------------
/public/index.template.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{title}}
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/views/Home/Three.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
三级路由
4 |
通过 axios 获取的{{ three }}
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/views/Table/Three.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
三级路由
4 |
通过 axios 获取的{{ three }}
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
虽然这只是一个 demo,但是引入第三方组件及样式问题的坑都踩完了,可以正常开发。
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/views/Home/Two.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
二级路由
4 |
通过 axios 获取的{{ two }}
5 |
跳到三级路由
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/views/Table/Two.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
二级路由
4 |
通过 axios 获取的{{ two }}
5 |
跳到三级路由
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/server/api.js:
--------------------------------------------------------------------------------
1 | module.exports = function setApi(app) {
2 | let apiData = {
3 | one: '一级路由数据(通过 ajax 请求获取)',
4 | two: '二级路由数据(通过 ajax 请求获取)',
5 | three: '三级路由数据(通过 ajax 请求获取)'
6 | }
7 |
8 | app.get('/fetchData', (req, res) => {
9 | res.send({
10 | code: 0,
11 | data: apiData[req.query.key]
12 | })
13 | })
14 |
15 | app.post('/changeData', (req, res) => {
16 | const data = {
17 | one: ' x 级路由数据(通过 ajax 请求获取)',
18 | two: ' xx 级路由数据(通过 ajax 请求获取)',
19 | three: ' xxx 级路由数据(通过 ajax 请求获取)'
20 | }
21 |
22 | res.send({
23 | code: 0,
24 | data,
25 | })
26 | })
27 | }
--------------------------------------------------------------------------------
/src/app.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import App from './App.vue'
3 | import { createRouter } from './router'
4 | import { createStore } from './store'
5 | import { sync } from 'vuex-router-sync'
6 | import { Button } from 'view-design'
7 | import 'view-design/dist/styles/iview.css'
8 |
9 | Vue.component('Button', Button)
10 |
11 | // 解决 [vue-router] failed to resolve async component default: referenceerror: window is not defined 问题
12 | if (typeof window === 'undefined') {
13 | global.window = {}
14 | }
15 |
16 | export function createApp(context) {
17 | const router = createRouter()
18 | const store = createStore()
19 |
20 | // 同步路由状态(route state)到 store
21 | sync(store, router)
22 |
23 | const app = new Vue({
24 | router,
25 | store,
26 | render: h => h(App)
27 | })
28 |
29 | return { app, router, store }
30 | }
31 |
--------------------------------------------------------------------------------
/src/views/Home/Home.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Home 一级路由切换到 Table
4 |
5 |
通过 ajax 获取的{{ one }}
6 |
跳到二级路由
7 |
8 |
9 |
10 |
27 |
28 |
--------------------------------------------------------------------------------
/src/views/Table/Table.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Table 一级路由切换到 Home
4 |
5 |
通过 ajax 获取的{{ one }}
6 |
跳到二级路由
7 |
8 |
9 |
10 |
27 |
28 |
--------------------------------------------------------------------------------
/src/entry-client.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import { createApp } from './app'
3 | const { app, router, store } = createApp()
4 |
5 | Vue.mixin({
6 | beforeMount() {
7 | const { asyncData } = this.$options
8 | if (asyncData) {
9 | // 将获取数据操作分配给 promise
10 | // 以便在组件中,我们可以在数据准备就绪后
11 | // 通过运行 `this.dataPromise.then(...)` 来执行其他任务
12 | this.dataPromise = asyncData({
13 | store: this.$store,
14 | route: this.$route
15 | })
16 | }
17 | },
18 |
19 | beforeRouteUpdate(to, from, next) {
20 | const { asyncData } = this.$options
21 | if (asyncData) {
22 | asyncData({
23 | store: this.$store,
24 | route: to
25 | }).then(next).catch(next)
26 | } else {
27 | next()
28 | }
29 | }
30 | })
31 |
32 | if (window.__INITIAL_STATE__) {
33 | store.replaceState(window.__INITIAL_STATE__)
34 | }
35 |
36 | router.onReady(() => {
37 | app.$mount('#app')
38 | })
39 |
--------------------------------------------------------------------------------
/src/store.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Vuex from 'vuex'
3 | import { fetchData, changeData } from './api.js'
4 |
5 | Vue.use(Vuex)
6 |
7 |
8 | export function createStore() {
9 | return new Vuex.Store({
10 | state: {
11 | one: '',
12 | two: '',
13 | three: ''
14 | },
15 | actions: {
16 | fetchData({ commit }, key) {
17 | return fetchData(key).then(res => {
18 | commit('setData', { key, data: res.data.data })
19 | })
20 | },
21 | changeData({ commit }) {
22 | return changeData().then(res => {
23 | commit('changeData', { data: res.data.data })
24 | })
25 | },
26 | },
27 | mutations: {
28 | setData(state, { key, data }) {
29 | state[key] = data
30 | },
31 | changeData(state, { data }) {
32 | state.one = data.one
33 | state.two = data.two
34 | state.three = data.three
35 | }
36 | }
37 | })
38 | }
39 |
--------------------------------------------------------------------------------
/src/entry-server.js:
--------------------------------------------------------------------------------
1 | import { createApp } from './app'
2 |
3 | export default context => {
4 | // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
5 | // 以便服务器能够等待所有的内容在渲染前,
6 | // 就已经准备就绪。
7 | return new Promise((resolve, reject) => {
8 | const { app, router, store } = createApp(context)
9 |
10 | // 设置服务器端 router 的位置
11 | router.push(context.url)
12 |
13 | // 等到 router 将可能的异步组件和钩子函数解析完
14 | router.onReady(() => {
15 | const matchedComponents = router.getMatchedComponents()
16 |
17 | // 匹配不到的路由,执行 reject 函数,并返回 404
18 | if (!matchedComponents.length) {
19 | return reject({ code: 404 })
20 | }
21 |
22 | Promise.all(
23 | matchedComponents.map(component => {
24 | if (component.asyncData) {
25 | return component.asyncData({
26 | store,
27 | route: router.currentRoute
28 | })
29 | }
30 | })
31 | ).then(() => {
32 | context.state = store.state
33 | // Promise 应该 resolve 应用程序实例,以便它可以渲染
34 | resolve(app)
35 | })
36 | })
37 | })
38 | }
39 |
--------------------------------------------------------------------------------
/src/router.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Router from 'vue-router'
3 |
4 | Vue.use(Router)
5 |
6 | export function createRouter() {
7 | return new Router({
8 | mode: 'history',
9 | routes: [
10 | {
11 | path: '/',
12 | redirect: '/Home'
13 | },
14 | {
15 | path: '/home',
16 | component: () => import('@/views/Home/Home'),
17 | children: [
18 | {
19 | path: 'two',
20 | component: () => import('@/views/Home/Two'),
21 | children: [
22 | {
23 | path: 'three',
24 | component: () => import('@/views/Home/Three'),
25 | }
26 | ]
27 | }
28 | ]
29 | },
30 | {
31 | path: '/table',
32 | component: () => import('@/views/Table/Table'),
33 | children: [
34 | {
35 | path: '/table/two',
36 | component: () => import('@/views/Table/Two'),
37 | children: [
38 | {
39 | path: '/table/two/three',
40 | component: () => import('@/views/Table/Three'),
41 | }
42 | ]
43 | }
44 | ]
45 | },
46 | ]
47 | })
48 | }
--------------------------------------------------------------------------------
/server/dev-server.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const express = require('express')
3 | const setApi = require('./api')
4 | const { createBundleRenderer } = require('vue-server-renderer')
5 | const devServer = require('../build/setup-dev-server')
6 | const favicon = require('serve-favicon')
7 | const resolve = (file) => path.resolve(__dirname, file)
8 |
9 | const app = express()
10 |
11 | const serve = (path) => {
12 | return express.static(resolve(path), {
13 | maxAge: 0
14 | })
15 | }
16 |
17 | app.use(favicon(resolve('../public/favicon.ico')))
18 | app.use('/dist', serve('../dist', true))
19 |
20 | function createRenderer(bundle, options) {
21 | return createBundleRenderer(
22 | bundle,
23 | Object.assign(options, {
24 | basedir: resolve('../dist'),
25 | runInNewContext: false
26 | })
27 | )
28 | }
29 |
30 | function render(req, res) {
31 | const startTime = Date.now()
32 | res.setHeader('Content-Type', 'text/html')
33 |
34 | const handleError = err => {
35 | if (err.url) {
36 | res.redirect(err.url)
37 | } else if (err.code === 404) {
38 | res.status(404).send('404 | Page Not Found')
39 | } else {
40 | res.status(500).send('500 | Internal Server Error~')
41 | console.log(err)
42 | }
43 | }
44 |
45 | const context = {
46 | title: 'SSR 测试', // default title
47 | url: req.url
48 | }
49 |
50 | renderer.renderToString(context, (err, html) => {
51 | if (err) {
52 | return handleError(err)
53 | }
54 |
55 | res.send(html)
56 | console.log(`whole request: ${ Date.now() - startTime }ms`)
57 | })
58 | }
59 |
60 | let renderer
61 | let readyPromise
62 | const templatePath = resolve('../public/index.template.html')
63 |
64 | readyPromise = devServer(
65 | app,
66 | templatePath,
67 | (bundle, options) => {
68 | renderer = createRenderer(bundle, options)
69 | }
70 | )
71 |
72 | const port = 8080
73 |
74 | app.listen(port, () => {
75 | console.log(`server started at localhost:${ port }`)
76 | })
77 |
78 | setApi(app)
79 |
80 | app.get('*', (req, res) => {
81 | readyPromise.then(() => render(req, res))
82 | })
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ssr-demo",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "cross-env NODE_ENV=development rimraf dist && node ./server/dev-server.js",
8 | "server": "cross-env NODE_ENV=production node ./server/pro-server.js --mode production",
9 | "build": "rimraf dist && npm run build:client && npm run build:server",
10 | "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js --progress --hide-modules --mode production",
11 | "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js --progress --hide-modules --mode production"
12 | },
13 | "keywords": [],
14 | "author": "",
15 | "license": "MIT",
16 | "dependencies": {
17 | "axios": "^0.21.1",
18 | "compression": "^1.7.4",
19 | "express": "^4.16.3",
20 | "serve-favicon": "^2.5.0",
21 | "view-design": "^4.4.0",
22 | "vue": "^2.6.12",
23 | "vue-router": "^3.0.1",
24 | "vue-server-renderer": "^2.6.12",
25 | "vuex": "^3.0.1",
26 | "vuex-router-sync": "^5.0.0"
27 | },
28 | "devDependencies": {
29 | "@babel/core": "^7.4.5",
30 | "@babel/plugin-proposal-object-rest-spread": "^7.4.4",
31 | "@babel/plugin-syntax-dynamic-import": "^7.2.0",
32 | "@babel/plugin-transform-runtime": "^7.10.1",
33 | "@babel/preset-env": "^7.10.2",
34 | "@babel/runtime": "^7.10.2",
35 | "babel-loader": "^8.0.6",
36 | "chokidar": "^3.4.3",
37 | "clean-webpack-plugin": "^2.0.2",
38 | "compression-webpack-plugin": "^10.0.0",
39 | "cross-env": "^7.0.2",
40 | "css-loader": "^2.1.1",
41 | "css-minimizer-webpack-plugin": "^1.1.5",
42 | "file-loader": "^3.0.1",
43 | "memory-fs": "^0.5.0",
44 | "mini-css-extract-plugin": "^1.2.1",
45 | "style-loader": "^0.23.1",
46 | "url-loader": "^1.1.2",
47 | "vue-loader": "^15.7.0",
48 | "vue-server-renderer": "^2.6.12",
49 | "vue-style-loader": "^4.1.2",
50 | "vue-template-compiler": "^2.6.12",
51 | "webpack": "^4.32.2",
52 | "webpack-cli": "^3.3.11",
53 | "webpack-dev-middleware": "^3.7.0",
54 | "webpack-hot-middleware": "^2.25.0",
55 | "webpack-merge": "^4.2.1",
56 | "webpack-node-externals": "^2.5.2",
57 | "webpackbar": "^4.0.0"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/server/pro-server.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const path = require('path')
3 | const express = require('express')
4 | const setApi = require('./api')
5 | const LRU = require('lru-cache') // 缓存
6 | const { createBundleRenderer } = require('vue-server-renderer')
7 | const favicon = require('serve-favicon')
8 | const resolve = file => path.resolve(__dirname, file)
9 |
10 | const app = express()
11 | // 开启 gzip 压缩 https://github.com/woai3c/node-blog/blob/master/doc/optimize.md
12 | const compression = require('compression')
13 | app.use(compression())
14 | // 设置 favicon
15 | app.use(favicon(resolve('../public/favicon.ico')))
16 |
17 | // 新版本 需要加 new,旧版本不用
18 | const microCache = new LRU({
19 | max: 100,
20 | maxAge: 60 * 60 * 24 * 1000 // 重要提示:缓存资源将在 1 天后过期。
21 | })
22 |
23 | const serve = (path) => {
24 | return express.static(resolve(path), {
25 | maxAge: 1000 * 60 * 60 * 24 * 30
26 | })
27 | }
28 |
29 | app.use('/dist', serve('../dist', true))
30 |
31 | function createRenderer(bundle, options) {
32 | return createBundleRenderer(
33 | bundle,
34 | Object.assign(options, {
35 | basedir: resolve('../dist'),
36 | runInNewContext: false
37 | })
38 | )
39 | }
40 |
41 | function render(req, res) {
42 | const hit = microCache.get(req.url)
43 | if (hit) {
44 | console.log('Response from cache')
45 | return res.end(hit)
46 | }
47 |
48 | res.setHeader('Content-Type', 'text/html')
49 |
50 | const handleError = err => {
51 | if (err.url) {
52 | res.redirect(err.url)
53 | } else if (err.code === 404) {
54 | res.status(404).send('404 | Page Not Found')
55 | } else {
56 | res.status(500).send('500 | Internal Server Error~')
57 | console.log(err)
58 | }
59 | }
60 |
61 | const context = {
62 | title: 'SSR 测试', // default title
63 | url: req.url
64 | }
65 |
66 | renderer.renderToString(context, (err, html) => {
67 | if (err) {
68 | return handleError(err)
69 | }
70 |
71 | microCache.set(req.url, html)
72 | res.send(html)
73 | })
74 | }
75 |
76 | const templatePath = resolve('../public/index.template.html')
77 | const template = fs.readFileSync(templatePath, 'utf-8')
78 | const bundle = require('../dist/vue-ssr-server-bundle.json')
79 | const clientManifest = require('../dist/vue-ssr-client-manifest.json') // 将js文件注入到页面中
80 | const renderer = createRenderer(bundle, {
81 | template,
82 | clientManifest
83 | })
84 |
85 | const port = 8080
86 |
87 | app.listen(port, () => {
88 | console.log(`server started at localhost:${ port }`)
89 | })
90 |
91 | setApi(app)
92 |
93 | app.get('*', render)
--------------------------------------------------------------------------------