├── .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 | 7 | -------------------------------------------------------------------------------- /src/views/Table/Three.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | -------------------------------------------------------------------------------- /src/views/Home/Two.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /src/views/Table/Two.vue: -------------------------------------------------------------------------------- 1 | 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 | 10 | 27 | 28 | -------------------------------------------------------------------------------- /src/views/Table/Table.vue: -------------------------------------------------------------------------------- 1 | 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) --------------------------------------------------------------------------------