├── utils ├── isProd.js ├── path.js ├── miniCSSExtractPlugin.js └── generateStyleLoader.js ├── src ├── assets │ └── logo.png ├── views │ ├── notFound.vue │ ├── page1.vue │ ├── page2.vue │ └── home.vue ├── store │ └── index.js ├── app.js ├── router │ └── index.js ├── entry-server.js ├── App.vue ├── entry-client.js ├── app.styl └── markdown.styl ├── .babelrc ├── manifest.json ├── pm2.conf.json ├── middlewares ├── serveStatic.js ├── staticCache.js └── contentType.js ├── .gitignore ├── jest.config.js ├── test └── unit │ └── page1.spec.js ├── index.template.html ├── package.json ├── server.js └── README.md /utils/isProd.js: -------------------------------------------------------------------------------- 1 | module.exports = process.env.NODE_ENV === 'production' 2 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fantasticit/vue-ssr-boilerplate/HEAD/src/assets/logo.png -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["env", { "momdules": false }], "stage-2"], 3 | "plugins": ["syntax-dynamic-import"] 4 | } 5 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-ssr-boilerplate", 3 | "short_name": "vue-ssr", 4 | "start_url": "/", 5 | "display": "standalone" 6 | } 7 | -------------------------------------------------------------------------------- /utils/path.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | exports.resolve = function(filePath) { 4 | return path.resolve(__dirname, '../', filePath) 5 | } 6 | -------------------------------------------------------------------------------- /src/views/notFound.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /pm2.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-ssr-boilerplate", 3 | "script": "./server.js", 4 | "cwd": "./", 5 | "ignore_watch": ["node_modules", "log"], 6 | "error_file": "./log/error.log" 7 | } 8 | -------------------------------------------------------------------------------- /middlewares/serveStatic.js: -------------------------------------------------------------------------------- 1 | const mount = require('koa-mount') 2 | const static = require('koa-static') 3 | 4 | module.exports = function(url, filePath, opts = {}) { 5 | return mount(url, static(filePath, opts)) 6 | } 7 | -------------------------------------------------------------------------------- /middlewares/staticCache.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | module.exports = function(filePath, opts) { 4 | return async (ctx, next) => { 5 | // do something with opts 6 | 7 | const data = fs.createReadStream(filePath) 8 | return (ctx.body = data) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache-loader 2 | .DS_Store 3 | /.cache-loader/ 4 | node_modules/ 5 | /dist/ 6 | /release/ 7 | /coverage/ 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | # Editor directories and files 13 | .idea 14 | .vscode 15 | *.suo 16 | *.ntvs* 17 | *.njsproj 18 | *.sln 19 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | moduleFileExtensions: ['js', 'vue'], 4 | transform: { 5 | '^.+\\.js$': '/node_modules/babel-jest', 6 | '^.*\\.vue$': '/node_modules/jest-vue' 7 | }, 8 | moduleNameMapper: { 9 | '@/(.*)$': '/src/$1' 10 | }, 11 | testMatch: ['/test/**/?(*.)(spec|test).js'] 12 | } 13 | -------------------------------------------------------------------------------- /middlewares/contentType.js: -------------------------------------------------------------------------------- 1 | const mime = require('mime') 2 | 3 | module.exports = function() { 4 | return async (ctx, next) => { 5 | const url = ctx.request.url 6 | let ext = url.match(/\.\w+/g) 7 | ext = (ext && ext.reverse()[0]) || null 8 | const mimeType = mime.getType(ext) || 'text/html' 9 | 10 | ctx.set(`Content-Type`, `${mimeType}; charset=utf-8`) 11 | 12 | await next() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/unit/page1.spec.js: -------------------------------------------------------------------------------- 1 | import { shallow } from 'vue-test-utils' 2 | import Page1 from '@/views/page1.vue' 3 | import { createStore } from '@/store' 4 | 5 | const store = createStore() 6 | 7 | test('Page 1', () => { 8 | const wrapper = shallow(Page1, { store }) 9 | const { vm } = wrapper // vm: vue component,在其中可以 取到 data methods 等属性 10 | 11 | const div = wrapper.find('div h1') 12 | expect(div.text()).toBe('Page 1') 13 | }) 14 | -------------------------------------------------------------------------------- /utils/miniCSSExtractPlugin.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 2 | 3 | /** 4 | * MiniCssExtractPlugin 在服务端渲染生成 server bundle时,会出现 5 | * `document is not defined` 错误 6 | * 解决办法:跳过 `requireEnsure` 钩子,重写 `getCssChunkObject` 7 | * https://github.com/webpack-contrib/mini-css-extract-plugin/issues/90 8 | */ 9 | module.exports = class ServerMiniCssExtractPlugin extends MiniCssExtractPlugin { 10 | getCssChunkObject(mainChunk) { 11 | return {} 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /index.template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ title }} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/views/page1.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 27 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | 4 | Vue.use(Vuex) 5 | 6 | export function createStore() { 7 | return new Vuex.Store({ 8 | state: { 9 | count: 0 10 | }, 11 | actions: { 12 | incrementCount({ commit, state }) { 13 | commit('SET_COUNT', state.count + 1) 14 | }, 15 | 16 | decrementCount({ commit, state }) { 17 | commit('SET_COUNT', state.count - 1) 18 | } 19 | }, 20 | mutations: { 21 | SET_COUNT(state, num) { 22 | state.count = num 23 | } 24 | } 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | // 通用 entry 2 | import Vue from 'vue' 3 | import App from './App.vue' 4 | import { sync } from 'vuex-router-sync' 5 | import { createRouter } from './router' 6 | import { createStore } from './store' 7 | 8 | // 导出工厂函数 9 | // 用于创建新的 app、router和 store 实例 10 | export function createApp() { 11 | // 创建 router 和 store 实例 12 | const router = createRouter() 13 | const store = createStore() 14 | 15 | // 同步路由状态到 store 16 | sync(store, router) 17 | 18 | const app = new Vue({ 19 | router, // 注入 router 到根 Vue 实例 20 | store, 21 | render: h => h(App) 22 | }) 23 | 24 | return { app, router, store } 25 | } 26 | -------------------------------------------------------------------------------- /src/views/page2.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 32 | -------------------------------------------------------------------------------- /src/router/index.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 | component: () => 13 | import(/* webpackChunkName: 'home' */ '../views/home.vue') 14 | }, 15 | 16 | { 17 | path: '/page1', 18 | component: () => 19 | import(/* webpackChunkName: 'page1' */ '../views/page1.vue') 20 | }, 21 | 22 | { 23 | path: '/page2', 24 | component: () => 25 | import(/* webpackChunkName: 'page2' */ '../views/page2.vue') 26 | }, 27 | 28 | { 29 | path: '*', 30 | component: () => 31 | import(/* webpackChunkName: 'notFound' */ '../views/notFound.vue') 32 | } 33 | ] 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /utils/generateStyleLoader.js: -------------------------------------------------------------------------------- 1 | const ServerMiniCssExtractPlugin = require('./miniCSSExtractPlugin') 2 | 3 | function getLoader(type) { 4 | if (type === 'sass' || type === 'scss') { 5 | return 'sass-loader' 6 | } else if (type === 'stylus' || type === 'styl') { 7 | return 'stylus-loader' 8 | } else { 9 | // add your css loader here 10 | return 11 | } 12 | } 13 | 14 | module.exports = function to( 15 | type, 16 | opts = { 17 | isProd: false, 18 | publicPath: '/' 19 | } 20 | ) { 21 | return { 22 | test: new RegExp(`\\.${type}$`), 23 | use: (opts.isProd 24 | ? [ 25 | { 26 | loader: ServerMiniCssExtractPlugin.loader, 27 | options: { 28 | publicPath: opts.publicPath 29 | } 30 | }, 31 | 'css-loader', 32 | getLoader(type) 33 | ] 34 | : ['vue-style-loader', 'css-loader', getLoader(type)] 35 | ).filter(Boolean) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/entry-server.js: -------------------------------------------------------------------------------- 1 | // 仅运行于服务器 2 | import { createApp } from './app' 3 | 4 | export default context => { 5 | // 因为有可能是 异步路由钩子或组件,返回一个 promise 6 | // 便于 服务器能够等待所有的内容在渲染前,就已经准备就绪 7 | return new Promise((resolve, reject) => { 8 | const { app, router, store } = createApp() 9 | 10 | // 设置服务器端 router 的位置 11 | router.push(context.url) 12 | 13 | router.onReady(_ => { 14 | const matchedComponnets = router.getMatchedComponents() 15 | 16 | // 匹配不到路由,直接 404 17 | if (!matchedComponnets.length) { 18 | return reject({ code: 404 }) 19 | } 20 | 21 | // 服务端数据预取 22 | // 在路由组件上定义静态函数 asyncData,该函数在组件实例化之前调用,因而无法访问this 23 | // 如果匹配到的路由组建暴露了 asyncData,就调用该方法 24 | Promise.all( 25 | matchedComponnets.map(component => { 26 | if (component && component.asyncData) { 27 | return component.asyncData({ 28 | store, 29 | route: router.currentRoute 30 | }) 31 | } 32 | }) 33 | ) 34 | .then(_ => { 35 | // 数据预取完成 36 | context.state = store.state 37 | resolve(app) 38 | }) 39 | .catch(reject) 40 | }, reject) 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 37 | 38 | 42 | -------------------------------------------------------------------------------- /src/entry-client.js: -------------------------------------------------------------------------------- 1 | // 仅运行于浏览器 2 | import Vue from 'vue' 3 | import { createApp } from './app' 4 | 5 | // 全局混合,在客户端路由发生变化时,预取匹配组件数据 6 | Vue.mixin({ 7 | beforeRouteUpdate(to, from, next) { 8 | const { asyncData } = this.$options 9 | 10 | if (asyncData) { 11 | asyncData({ 12 | store: this.$store, 13 | route: to 14 | }) 15 | .then(next) 16 | .catch(next) 17 | } else { 18 | next() 19 | } 20 | } 21 | }) 22 | 23 | const { app, router, store } = createApp() 24 | 25 | if (window.__INITIAL_STATE__) { 26 | // 客户端,挂载 app 之前,store状态更换 27 | store.replaceState(window.__INITIAL_STATE__) 28 | } 29 | 30 | // App.vue 模板中跟元素具有 `id="app"` 31 | router.onReady(_ => { 32 | console.log('client entry') 33 | 34 | router.beforeResolve((to, from, next) => { 35 | const matchedComponents = router.getMatchedComponents(to) 36 | const prevMatchedComponents = router.getMatchedComponents(from) 37 | const activated = matchedComponents.filter( 38 | (component, i) => component !== prevMatchedComponents[i] 39 | ) 40 | 41 | const activatedAsyncHooks = activated 42 | .map(component => component && component.asyncData) 43 | .filter(Boolean) 44 | 45 | if (!activatedAsyncHooks.length) { 46 | return next() 47 | } 48 | 49 | // 开始预取 数据 50 | console.log('Prefetch data...') 51 | Promise.all(activatedAsyncHooks.map(hook => hook({ store, route: to }))) 52 | .then(_ => { 53 | console.log('ok') 54 | next() 55 | }) 56 | .catch(next) 57 | }) 58 | 59 | app.$mount('#app') // 客户端激活 60 | }) 61 | 62 | // 注册 service-worker 63 | if (window && location.protocol === 'https:' && navigator.serviceWorker) { 64 | navigator.serviceWorker.register('/service-worker.js') 65 | } 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-ssr-boilerplate", 3 | "version": "1.0.0", 4 | "description": "A boilerplate for developing Server Side Render Vue.js Application", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "node server.js", 8 | "start": "cross-env NODE_ENV=production node server.js", 9 | "test": "rimraf coverage && cross-env NODE_ENV=test jest", 10 | "pm2": "cross-env NODE_ENV=production pm2 start pm2.conf.json --watch", 11 | "build": "rimraf dist && npm run build:client && npm run build:server", 12 | "build:client": "cross-env NODE_ENV=production webpack --config ./build/webpack.client.conf.js --progress --hide-modules", 13 | "build:server": "cross-env NODE_ENV=production webpack --config ./build/webpack.server.conf.js --progress --hide-modules" 14 | }, 15 | "keywords": [ 16 | "vue", 17 | "ssr" 18 | ], 19 | "author": "justemit", 20 | "license": "MIT", 21 | "dependencies": { 22 | "koa": "^2.5.2", 23 | "koa-mount": "^3.0.0", 24 | "koa-router": "^7.4.0", 25 | "koa-static": "^5.0.0", 26 | "vue": "^2.5.17", 27 | "vue-router": "^3.0.1", 28 | "vue-server-renderer": "^2.5.17", 29 | "vuex": "^3.0.1", 30 | "vuex-router-sync": "^5.0.0" 31 | }, 32 | "devDependencies": { 33 | "babel-cli": "^6.26.0", 34 | "babel-core": "^6.26.3", 35 | "babel-jest": "^23.4.2", 36 | "babel-loader": "^7.1.5", 37 | "babel-plugin-syntax-dynamic-import": "^6.18.0", 38 | "babel-preset-env": "^1.7.0", 39 | "babel-preset-stage-2": "^6.24.1", 40 | "cross-env": "^5.2.0", 41 | "css-loader": "^1.0.0", 42 | "file-loader": "^1.1.11", 43 | "jest": "^23.5.0", 44 | "jest-vue": "^0.8.2", 45 | "koa-webpack-middleware": "^1.0.7", 46 | "memory-fs": "^0.4.1", 47 | "mime": "^2.3.1", 48 | "mini-css-extract-plugin": "^0.4.1", 49 | "optimize-css-assets-webpack-plugin": "^5.0.0", 50 | "rimraf": "^2.6.2", 51 | "stylus": "^0.54.5", 52 | "stylus-loader": "^3.0.2", 53 | "sw-precache-webpack-plugin": "^0.11.5", 54 | "uglifyjs-webpack-plugin": "^1.3.0", 55 | "url-loader": "^1.1.1", 56 | "vue-loader": "^15.4.0", 57 | "vue-template-compiler": "^2.5.17", 58 | "vue-test-utils": "^1.0.0-beta.11", 59 | "webpack": "^4.16.5", 60 | "webpack-cli": "^3.1.0", 61 | "webpack-hot-middleware": "^2.22.3", 62 | "webpack-merge": "^4.1.4", 63 | "webpack-node-externals": "^1.7.2" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const Koa = require('koa') 3 | const KoaRouter = require('koa-router') 4 | const { createBundleRenderer } = require('vue-server-renderer') 5 | 6 | const isProd = require('./utils/isProd') 7 | const { resolve } = require('./utils/path') 8 | const contentType = require('./middlewares/contentType') 9 | const serveStatic = require('./middlewares/serveStatic') 10 | const staticCache = require('./middlewares/staticCache') 11 | 12 | const templatePath = resolve('./index.template.html') 13 | 14 | const app = new Koa() 15 | const router = new KoaRouter() 16 | 17 | const createRenderer = (bundle, opts = {}) => { 18 | return createBundleRenderer( 19 | bundle, 20 | Object.assign(opts, { 21 | basedir: resolve('./dist'), 22 | template: fs.readFileSync(templatePath, 'utf-8'), 23 | runInNewContext: false 24 | }) 25 | ) 26 | } 27 | 28 | let renderer 29 | let readyPromise // 开发环境时,要确认 webpack 已经打包客户端代码和服务端代码 30 | 31 | if (isProd) { 32 | const prodBundle = require('./dist/vue-ssr-server-bundle.json') 33 | const prodClientManifest = require('./dist/vue-ssr-client-manifest.json') 34 | // 生成环境直接使用已经生成的 serverBundle 和 clientManifest 35 | renderer = createRenderer(prodBundle, { 36 | clientManifest: prodClientManifest 37 | }) 38 | } else { 39 | readyPromise = require('./build/dev-server')(app, (bundle, opts) => { 40 | renderer = createRenderer(bundle, opts) 41 | }) 42 | } 43 | 44 | const render = async ctx => { 45 | const url = ctx.request.url 46 | 47 | const context = { 48 | title: 'vue-ssr-boilerplate', 49 | url 50 | } 51 | 52 | if (isProd) { 53 | return (ctx.body = await renderer.renderToString(context)) 54 | } else { 55 | return readyPromise.then(async _ => { 56 | return (ctx.body = await renderer.renderToString(context)) 57 | }) 58 | } 59 | } 60 | 61 | router.get( 62 | '/service-worker.js', 63 | staticCache(resolve('./dist/service-worker.js')) 64 | ) 65 | router.get('/manifest.json', staticCache(resolve('./manifest.json'))) 66 | router.get('*', render) 67 | 68 | app.use(contentType()) 69 | app.use( 70 | serveStatic('/dist', 'dist', { 71 | maxAge: isProd ? 365 * 24 * 60 * 60 : 0 72 | }) 73 | ) 74 | app.use(router.routes()) 75 | 76 | const port = parseInt(process.argv[2]) || 8080 // 可以通过 npm run dev 9090 形式指定端口号 77 | app.listen(port, err => { 78 | if (err) { 79 | throw err 80 | } 81 | 82 | console.log(`Server is running at http://localhost:${port}`) 83 | }) 84 | -------------------------------------------------------------------------------- /src/app.styl: -------------------------------------------------------------------------------- 1 | *, *::before, *::after { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, body { 6 | margin: 0; 7 | padding: 0; 8 | height: 100%; 9 | } 10 | 11 | #app { 12 | // text-align: center; 13 | height: 100%; 14 | display: flex; 15 | flex-direction: column; 16 | 17 | button { 18 | padding: 6px 8px; 19 | background: #4fc08d; 20 | border: 1px solid #4fc08d; 21 | border-radius: 4px; 22 | font-size: 14px; 23 | color: #fff; 24 | line-height: 1.5; 25 | outline: none; 26 | cursor: pointer; 27 | } 28 | } 29 | 30 | @media (min-width: 768px) { 31 | .container { 32 | width: 750px; 33 | } 34 | } 35 | 36 | @media (min-width: 1200px) { 37 | .container { 38 | width: 1170px; 39 | } 40 | } 41 | 42 | @media (min-width: 992px) { 43 | .container { 44 | width: 970px; 45 | } 46 | } 47 | 48 | .container { 49 | padding-right: 15px; 50 | padding-left: 15px; 51 | margin-right: auto; 52 | margin-left: auto; 53 | } 54 | 55 | header { 56 | height: 80px; 57 | box-shadow: 0 4px 13px -3px rgba(0, 0, 0, 0.10196); 58 | border-bottom: 1px solid #d2d2d2; 59 | z-index: 2; 60 | display: flex; 61 | justify-content: center; 62 | 63 | > div { 64 | height: 100%; 65 | max-width: 980px; 66 | margin: 0 auto; 67 | display: inline-flex; 68 | justify-content: flex-start; 69 | align-items: center; 70 | } 71 | 72 | .logo { 73 | padding: 5px; 74 | height: 100%; 75 | margin-right: 2rem; 76 | 77 | img { 78 | max-height: 100%; 79 | } 80 | } 81 | 82 | nav { 83 | height: 100%; 84 | vertical-align: middle; 85 | 86 | ul { 87 | list-style: none; 88 | padding: 0; 89 | margin: 0; 90 | height: 100%; 91 | display: flex; 92 | justify-content: flex-start; 93 | align-items: stretch; 94 | } 95 | 96 | li { 97 | position: relative; 98 | display: block; 99 | padding: 0 15px; 100 | display: flex; 101 | justify-content: center; 102 | align-items: center; 103 | cursor: pointer; 104 | 105 | &::after { 106 | content: ''; 107 | position: absolute; 108 | bottom: 0; 109 | display: block; 110 | width: 0; 111 | height: 2px; 112 | background: #4fc08d; 113 | will-change: width; 114 | transition: width 300ms ease-in-out; 115 | } 116 | 117 | &:hover { 118 | color: #4fc08d; 119 | 120 | &::after { 121 | width: 100%; 122 | } 123 | } 124 | 125 | a { 126 | text-decoration: none; 127 | color: inherit; 128 | } 129 | } 130 | 131 | li.router-link-exact-active { 132 | color: #4fc08d; 133 | 134 | &::after { 135 | width: 100%; 136 | } 137 | } 138 | } 139 | } 140 | 141 | main { 142 | flex: 1; 143 | background: #fafafa; 144 | padding: 1rem; 145 | display: flex; 146 | justify-content: flex-start; 147 | 148 | > div { 149 | border-radius: 4px; 150 | padding: 15px; 151 | border: 1px solid #e1e4e8; 152 | overflow: auto; 153 | background: #fff; 154 | } 155 | } 156 | 157 | footer { 158 | padding: 15px; 159 | border-top: 1px solid rgba(0, 0, 0, 0.1); 160 | 161 | p { 162 | text-align: center; 163 | } 164 | 165 | a { 166 | color: #4fc08d; 167 | } 168 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | logo of vue-ssr-bolilerplate repository 4 |
5 |
6 |

7 | 8 | # vue-ssr-boilerplate 9 | 10 | > 仅用于学习,生产环境请使用官方更加完善的渲染框架 [nuxt.js](https://github.com/nuxt/nuxt.js) 。 11 | 12 | ### A boilerplate for developing Server Side Render Vue.js Application 13 | 14 | ## 1. 服务端渲染介绍 15 | 16 | 参考文章:[Vue SSR 渲染指南](https://ssr.vuejs.org) 17 | 18 | ## 2. 如何使用 19 | 20 | 使用的前提是安装依赖,即: `npm i`. 21 | 22 | ### 2.1 开发模式 23 | 24 | 执行 `npm run dev` 即可. 25 | 26 | ### 2.2 生产模式 27 | 28 | 生产模式,首先进行打包,然后再运行相关服务,即: 29 | 30 | ```shell 31 | npm run build && npm start # 如果线上使用 pm2,也可以用 npm run pm2 32 | ``` 33 | 34 | 生产模式如果要启用 `service-worker(pwa)`,首先确保部署时采用了 `https` 协议,然后修改 `build/webpack.client.conf.js` 中 SWPlugin 相关配置,例如: 35 | 36 | ```javascript 37 | new SWPrecachePlugin({ 38 | cacheId: 'vue-ssr-justemit', 39 | filename: 'service-worker.js', 40 | minify: true, 41 | // 设置为 false, sw生成的缓存是 filename?hash 形式,以便于浏览器更新缓存 42 | dontCacheBustUrlsMatching: false, 43 | // 忽略文件 44 | staticFileGlobsIgnorePatterns: [/\.map$/, /\.css$/], 45 | // For unknown URLs, fallback to the index page 46 | navigateFallback: 'https://example/', // 这里修改为 线上部署的地址 47 | // 运行时缓存 48 | runtimeCaching: [ // 这里修改为您实际开发时所需要的相关路由 49 | { 50 | urlPattern: '/', 51 | handler: 'networkFirst' 52 | }, 53 | { 54 | urlPattern: /\/(page1|page2|page3)/, 55 | handler: 'networkFirst' 56 | } 57 | ] 58 | } 59 | ``` 60 | 61 | 存在的问题: 62 | 当浏览器访问 `https://example.com/` 时,注册的 service-worker 的域是 `https://example.com/` ,是不能直接浏览器输入 `https://example.com/page1` 来访问 `/page1` 这个路由的的. 63 | 64 | 解决办法: 浏览器新标签直接访问 `https://example.com/page1` (直接访问走的是 服务端的路由匹配,即 `entry-server`), 将该域的 service-worker 注册,其他路由同理. 65 | 66 | 希望有懂这一块的小伙伴可以帮忙解决.(: 67 | 68 | ## 3 源码结构 69 | 70 | 服务端渲染应当为每个请求创建一个新的根 Vue 实例。如果在多个请求之间共享一个实例,显然是错误的。所以所有的根实例、根路由、根状态都应当是一个工厂函数,每次请求都应当得到一个新的根实例。 71 | 72 | ```shell 73 | src 74 | ├── components # 组件 75 | ├── router # vue-router 76 | ├── store # vuex store 77 | ├── App.vue 78 | ├── app.js # 通用 entry(universal entry) 79 | ├── entry-client.js # 仅运行于浏览器 80 | └── entry-server.js # 仅运行于服务器 81 | ``` 82 | 83 | - `app.js` 84 | 85 | `app.js` 是程序的“通用入口”。在客户端程序中,直接用此文件创建根 Vue 实例,并直接挂载到 DOM(激活)。在服务端渲染中,则将责任转到客户端入口文件。`app.js` 只是简单到处 `createApp` 函数。 86 | 87 | - `entry-client.js` 88 | 89 | 客户端入口只需要创建应用程序,并将其挂载到 DOM 中: 90 | 91 | ```JavaScript 92 | import { createApp } from './app' 93 | 94 | const { app } = createApp() 95 | app.$mount('#app') 96 | ``` 97 | 98 | - `entry-server.js` 99 | 100 | 服务器入口使用 `export default` 导出函数,并在每次渲染时重复调用此函数,用于创建和返回应用程序实例。也可以用于服务器端路由匹配和数据预取逻辑。 101 | 102 | 再次强调,如果使用了 `vue-router`、`vuex` 等,需要导出的均是一个工厂函数,而不是一个单例。 103 | 104 | ## 4 坑点提示 105 | 106 | ### 4.1 window 及其他浏览器环境下属性 107 | 108 | 由于采用了服务端渲染,所有关于浏览器上属性的使用,需要首先判断 `window` 对象是否存在。 109 | 110 | ### 4.2 单元测试 111 | 112 | 默认采用 `jest` 和 `vue-test-utils` 进行测试,如果测试的组件需要用到 `vuex`,需要在测试的代码中新创建一个 `store` 传入,例如: 113 | 114 | ```javascript 115 | import { createStore } from '@/store' 116 | 117 | const store = createStore() 118 | 119 | test('test', () => { 120 | const wrapper = shallow(Component, { store }) 121 | }) 122 | ``` 123 | 124 | ## 5 其他 125 | 126 | - 如果未来需要使用到“页面缓存或组件缓存”,可参考:[缓存](https://ssr.vuejs.org/zh/guide/caching.html#%E9%A1%B5%E9%9D%A2%E7%BA%A7%E5%88%AB%E7%BC%93%E5%AD%98-page-level-caching) 127 | 128 | ## LICENSE 129 | 130 | MIT 131 | -------------------------------------------------------------------------------- /src/markdown.styl: -------------------------------------------------------------------------------- 1 | .markdown-body blockquote, 2 | .markdown-body ul, 3 | .markdown-body ol, 4 | .markdown-body dl, 5 | .markdown-body table, 6 | .markdown-body pre{margin-top:0;margin-bottom:16px}.markdown-body hr{height:0.25em;padding:0;margin:24px 0;background-color:#e1e4e8;border:0}.markdown-body blockquote{padding:0 1em;color:#6a737d;border-left:0.25em solid #dfe2e5}.markdown-body blockquote > :first-child{margin-top:0}.markdown-body blockquote > :last-child{margin-bottom:0}.markdown-body kbd{display:inline-block;padding:3px 5px;font-size:11px;line-height:10px;color:#444d56;vertical-align:middle;background-color:#fafbfc;border:solid 1px #c6cbd1;border-bottom-color:#959da5;border-radius:3px;box-shadow:inset 0 -1px 0 #959da5}.markdown-body h1, 7 | .markdown-body h2, 8 | .markdown-body h3, 9 | .markdown-body h4, 10 | .markdown-body h5, 11 | .markdown-body h6{margin-top:24px;margin-bottom:16px;font-weight:600;line-height:1.25}.markdown-body h1 .octicon-link, 12 | .markdown-body h2 .octicon-link, 13 | .markdown-body h3 .octicon-link, 14 | .markdown-body h4 .octicon-link, 15 | .markdown-body h5 .octicon-link, 16 | .markdown-body h6 .octicon-link{color:#1b1f23;vertical-align:middle;visibility:hidden}.markdown-body h1:hover .anchor, 17 | .markdown-body h2:hover .anchor, 18 | .markdown-body h3:hover .anchor, 19 | .markdown-body h4:hover .anchor, 20 | .markdown-body h5:hover .anchor, 21 | .markdown-body h6:hover .anchor{text-decoration:none}.markdown-body h1:hover .anchor .octicon-link, 22 | .markdown-body h2:hover .anchor .octicon-link, 23 | .markdown-body h3:hover .anchor .octicon-link, 24 | .markdown-body h4:hover .anchor .octicon-link, 25 | .markdown-body h5:hover .anchor .octicon-link, 26 | .markdown-body h6:hover .anchor .octicon-link{visibility:visible}.markdown-body h1 tt, 27 | .markdown-body h1 code, 28 | .markdown-body h2 tt, 29 | .markdown-body h2 code, 30 | .markdown-body h3 tt, 31 | .markdown-body h3 code, 32 | .markdown-body h4 tt, 33 | .markdown-body h4 code, 34 | .markdown-body h5 tt, 35 | .markdown-body h5 code, 36 | .markdown-body h6 tt, 37 | .markdown-body h6 code{font-size:inherit}.markdown-body h1{padding-bottom:0.3em;font-size:2em;border-bottom:1px solid #eaecef}.markdown-body h2{padding-bottom:0.3em;font-size:1.5em;border-bottom:1px solid #eaecef}.markdown-body h3{font-size:1.25em}.markdown-body h4{font-size:1em}.markdown-body h5{font-size:0.875em}.markdown-body h6{font-size:0.85em;color:#6a737d}.markdown-body ul, 38 | .markdown-body ol{padding-left:2em}.markdown-body ul.no-list, 39 | .markdown-body ol.no-list{padding:0;list-style-type:none}.markdown-body ul ul, 40 | .markdown-body ul ol, 41 | .markdown-body ol ol, 42 | .markdown-body ol ul{margin-top:0;margin-bottom:0}.markdown-body li{word-wrap:break-all}.markdown-body li > p{margin-top:16px}.markdown-body li + li{margin-top:0.25em}.markdown-body dl{padding:0}.markdown-body dl dt{padding:0;margin-top:16px;font-size:1em;font-style:italic;font-weight:600}.markdown-body dl dd{padding:0 16px;margin-bottom:16px}.markdown-body table{display:block;width:100%;overflow:auto}.markdown-body table th{font-weight:600}.markdown-body table th, 43 | .markdown-body table td{padding:6px 13px;border:1px solid #dfe2e5}.markdown-body table tr{background-color:#fff;border-top:1px solid #c6cbd1}.markdown-body table tr:nth-child(2n){background-color:#f6f8fa}.markdown-body table img{background-color:transparent}.markdown-body img{max-width:100%;box-sizing:content-box;background-color:#fff}.markdown-body img[align=right]{padding-left:20px}.markdown-body img[align=left]{padding-right:20px}.markdown-body .emoji{max-width:none;vertical-align:text-top;background-color:transparent}.markdown-body span.frame{display:block;overflow:hidden}.markdown-body span.frame > span{display:block;float:left;width:auto;padding:7px;margin:13px 0 0;overflow:hidden;border:1px solid #dfe2e5}.markdown-body span.frame span img{display:block;float:left}.markdown-body span.frame span span{display:block;padding:5px 0 0;clear:both;color:#24292e}.markdown-body span.align-center{display:block;overflow:hidden;clear:both}.markdown-body span.align-center > span{display:block;margin:13px auto 0;overflow:hidden;text-align:center}.markdown-body span.align-center span img{margin:0 auto;text-align:center}.markdown-body span.align-right{display:block;overflow:hidden;clear:both}.markdown-body span.align-right > span{display:block;margin:13px 0 0;overflow:hidden;text-align:right}.markdown-body span.align-right span img{margin:0;text-align:right}.markdown-body span.float-left{display:block;float:left;margin-right:13px;overflow:hidden}.markdown-body span.float-left span{margin:13px 0 0}.markdown-body span.float-right{display:block;float:right;margin-left:13px;overflow:hidden}.markdown-body span.float-right > span{display:block;margin:13px auto 0;overflow:hidden;text-align:right}.markdown-body code, 44 | .markdown-body tt{padding:0.2em 0.4em;margin:0;font-size:85%;background-color:rgba(27,31,35,0.05);border-radius:3px}.markdown-body code br, 45 | .markdown-body tt br{display:none}.markdown-body del code{text-decoration:inherit}.markdown-body pre{word-wrap:normal}.markdown-body pre > code{padding:0;margin:0;font-size:100%;word-break:normal;white-space:pre;background:transparent;border:0}.markdown-body .highlight{margin-bottom:16px}.markdown-body .highlight pre{margin-bottom:0;word-break:normal}.markdown-body .highlight pre, 46 | .markdown-body pre{padding:16px;overflow:auto;font-size:85%;line-height:1.45;background-color:#f6f8fa;border-radius:3px} 47 | -------------------------------------------------------------------------------- /src/views/home.vue: -------------------------------------------------------------------------------- 1 | 92 | 93 | 96 | --------------------------------------------------------------------------------