├── .gitignore ├── README.md ├── config ├── app.js ├── koa │ ├── dev.js │ ├── hot.js │ └── static.js ├── lib │ ├── env.js │ ├── index.js │ ├── openBrowser.js │ └── openChrome.applescript ├── server.js ├── setup-dev-server.js ├── ssr.js ├── webpack.api.config.js ├── webpack.base.config.js ├── webpack.server.config.js └── webpack.web.config.js ├── package.json ├── public ├── favicon.ico ├── index.html └── robots.txt ├── src ├── api │ └── app.js └── web │ ├── App.vue │ ├── app.js │ ├── components │ └── ProgressBar.vue │ ├── entry-client.js │ ├── entry-server.js │ ├── libs │ └── util.js │ ├── page │ └── home.vue │ ├── router │ ├── admin.js │ └── index.js │ ├── store │ ├── index.js │ ├── modules │ │ ├── api.js │ │ └── index.js │ └── util.js │ └── util │ ├── filters.js │ └── title.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | # 编译后的文件以下两个目录 5 | /dist/web 6 | /dist/api 7 | 8 | # Log files 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | # Editor directories and files 14 | .idea 15 | .vscode 16 | *.suo 17 | *.ntvs* 18 | *.njsproj 19 | *.sln 20 | *.sw* 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vue SSR Koa2 Scaffold 2 | 3 | 基于 Vue 2,Webpack 4,Koa 2 的 SSR 脚手架。 4 | 5 | 具体配置指南请参阅博文:[Vue SSR( Vue2 + Koa2 + Webpack4)配置指南 https://www.wyr.me/post/593](https://www.wyr.me/post/593) 6 | 7 | **2019 年 06 月 02 日 23:16:47 更新** 8 | 1、升级各个依赖项,特意升级了`axios`模块。 9 | 10 | **2019 年 04 月 07 日 21:54:24 更新** 11 | 1、升级各个依赖项。 12 | 2、完善对请求错误的处理。 13 | -------------------------------------------------------------------------------- /config/app.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | app: { 3 | port: 3000, // 监听的端口 4 | devHost: '127.0.0.1', // 开发环境下打开的地址,监听了0.0.0.0,但是不是所有设备都支持访问这个地址,用127.0.0.1或localhost代替 5 | open: true // 是否打开浏览器 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /config/koa/dev.js: -------------------------------------------------------------------------------- 1 | const devMiddleware = require('webpack-dev-middleware') 2 | 3 | module.exports = (compiler, opts) => { 4 | const expressMiddleware = devMiddleware(compiler, opts) 5 | 6 | async function middleware (ctx, next) { 7 | await expressMiddleware(ctx.req, { 8 | end: (content) => { 9 | ctx.body = content 10 | }, 11 | setHeader: (name, value) => { 12 | ctx.set(name, value) 13 | } 14 | }, next) 15 | } 16 | 17 | middleware.getFilenameFromUrl = expressMiddleware.getFilenameFromUrl 18 | middleware.waitUntilValid = expressMiddleware.waitUntilValid 19 | middleware.invalidate = expressMiddleware.invalidate 20 | middleware.close = expressMiddleware.close 21 | middleware.fileSystem = expressMiddleware.fileSystem 22 | 23 | return middleware 24 | } 25 | -------------------------------------------------------------------------------- /config/koa/hot.js: -------------------------------------------------------------------------------- 1 | // 参考自 https://github.com/ccqgithub/koa-webpack-hot/blob/master/index.js 2 | const hotMiddleware = require('webpack-hot-middleware') 3 | const PassThrough = require('stream').PassThrough 4 | 5 | module.exports = (compiler, opts = {}) => { 6 | opts.path = opts.path || '/__webpack_hmr' 7 | 8 | const middleware = hotMiddleware(compiler, opts) 9 | 10 | return async (ctx, next) => { 11 | if (ctx.request.path !== opts.path) { 12 | return next() 13 | } 14 | 15 | const stream = new PassThrough() 16 | ctx.body = stream 17 | 18 | middleware(ctx.req, { 19 | write: stream.write.bind(stream), 20 | writeHead: (status, headers) => { 21 | ctx.status = status 22 | Object.keys(headers).forEach(key => { 23 | ctx.set(key, headers[key]) 24 | }) 25 | }, 26 | end: () => { 27 | stream.end() 28 | } 29 | }, next) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /config/koa/static.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * From koa-static 5 | */ 6 | 7 | const { resolve } = require('path') 8 | const assert = require('assert') 9 | const send = require('koa-send') 10 | 11 | /** 12 | * Expose `serve()`. 13 | */ 14 | 15 | module.exports = serve 16 | 17 | /** 18 | * Serve static files from `root`. 19 | * 20 | * @param {String} root 21 | * @param {Object} [opts] 22 | * @return {Function} 23 | * @api public 24 | */ 25 | 26 | function serve (root, opts) { 27 | opts = Object.assign({}, opts) 28 | 29 | assert(root, 'root directory is required to serve files') 30 | 31 | // options 32 | opts.root = resolve(root) 33 | if (opts.index !== false) opts.index = opts.index || 'index.html' 34 | 35 | if (!opts.defer) { 36 | return async function serve (ctx, next) { 37 | let done = false 38 | 39 | if (ctx.method === 'HEAD' || ctx.method === 'GET') { 40 | if (ctx.path === '/' || ctx.path === '/index.html') { // exclude index.html file 41 | await next() 42 | return 43 | } 44 | try { 45 | done = await send(ctx, ctx.path, opts) 46 | } catch (err) { 47 | if (err.status !== 404) { 48 | throw err 49 | } 50 | } 51 | } 52 | 53 | if (!done) { 54 | await next() 55 | } 56 | } 57 | } 58 | 59 | return async function serve (ctx, next) { 60 | await next() 61 | 62 | if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return 63 | // response is already handled 64 | if (ctx.body != null || ctx.status !== 404) return // eslint-disable-line 65 | 66 | try { 67 | await send(ctx, ctx.path, opts) 68 | } catch (err) { 69 | if (err.status !== 404) { 70 | throw err 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /config/lib/env.js: -------------------------------------------------------------------------------- 1 | // From: https://github.com/vuejs/vue-cli/blob/dev/packages/%40vue/cli-shared-utils/lib/env.js 2 | const { execSync } = require('child_process') 3 | const fs = require('fs') 4 | const path = require('path') 5 | const LRU = require('lru-cache') 6 | 7 | let _hasYarn 8 | const _yarnProjects = new LRU({ 9 | max: 10, 10 | maxAge: 1000 11 | }) 12 | let _hasGit 13 | const _gitProjects = new LRU({ 14 | max: 10, 15 | maxAge: 1000 16 | }) 17 | 18 | // env detection 19 | exports.hasYarn = () => { 20 | if (process.env.VUE_CLI_TEST) { 21 | return true 22 | } 23 | if (_hasYarn != null) { 24 | return _hasYarn 25 | } 26 | try { 27 | execSync('yarnpkg --version', { stdio: 'ignore' }) 28 | return (_hasYarn = true) 29 | } catch (e) { 30 | return (_hasYarn = false) 31 | } 32 | } 33 | 34 | exports.hasProjectYarn = (cwd) => { 35 | if (_yarnProjects.has(cwd)) { 36 | return checkYarn(_yarnProjects.get(cwd)) 37 | } 38 | 39 | const lockFile = path.join(cwd, 'yarn.lock') 40 | const result = fs.existsSync(lockFile) 41 | _yarnProjects.set(cwd, result) 42 | return checkYarn(result) 43 | } 44 | 45 | function checkYarn (result) { 46 | if (result && !exports.hasYarn()) throw new Error(`The project seems to require yarn but it's not installed.`) 47 | return result 48 | } 49 | 50 | exports.hasGit = () => { 51 | if (process.env.VUE_CLI_TEST) { 52 | return true 53 | } 54 | if (_hasGit != null) { 55 | return _hasGit 56 | } 57 | try { 58 | execSync('git --version', { stdio: 'ignore' }) 59 | return (_hasGit = true) 60 | } catch (e) { 61 | return (_hasGit = false) 62 | } 63 | } 64 | 65 | exports.hasProjectGit = (cwd) => { 66 | if (_gitProjects.has(cwd)) { 67 | return _gitProjects.get(cwd) 68 | } 69 | 70 | let result 71 | try { 72 | execSync('git status', { stdio: 'ignore', cwd }) 73 | result = true 74 | } catch (e) { 75 | result = false 76 | } 77 | _gitProjects.set(cwd, result) 78 | return result 79 | } 80 | -------------------------------------------------------------------------------- /config/lib/index.js: -------------------------------------------------------------------------------- 1 | [ 2 | 'env', 3 | 'openBrowser' 4 | ].forEach(m => { 5 | Object.assign(exports, require(`./${m}`)) 6 | }) 7 | -------------------------------------------------------------------------------- /config/lib/openBrowser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * From: https://github.com/vuejs/vue-cli/blob/dev/packages/%40vue/cli-shared-utils/lib/openBrowser.js 3 | * 4 | * Copyright (c) 2015-present, Facebook, Inc. 5 | * 6 | * This source code is licensed under the MIT license found in the 7 | * LICENSE file at 8 | * https://github.com/facebookincubator/create-react-app/blob/master/LICENSE 9 | */ 10 | 11 | const opn = require('opn') 12 | const execa = require('execa') 13 | const chalk = require('chalk') 14 | const execSync = require('child_process').execSync 15 | 16 | // https://github.com/sindresorhus/opn#app 17 | const OSX_CHROME = 'google chrome' 18 | 19 | const Actions = Object.freeze({ 20 | NONE: 0, 21 | BROWSER: 1, 22 | SCRIPT: 2 23 | }) 24 | 25 | function getBrowserEnv () { 26 | // Attempt to honor this environment variable. 27 | // It is specific to the operating system. 28 | // See https://github.com/sindresorhus/opn#app for documentation. 29 | const value = process.env.BROWSER 30 | let action 31 | if (!value) { 32 | // Default. 33 | action = Actions.BROWSER 34 | } else if (value.toLowerCase().endsWith('.js')) { 35 | action = Actions.SCRIPT 36 | } else if (value.toLowerCase() === 'none') { 37 | action = Actions.NONE 38 | } else { 39 | action = Actions.BROWSER 40 | } 41 | return { action, value } 42 | } 43 | 44 | function executeNodeScript (scriptPath, url) { 45 | const extraArgs = process.argv.slice(2) 46 | const child = execa('node', [scriptPath, ...extraArgs, url], { 47 | stdio: 'inherit' 48 | }) 49 | child.on('close', code => { 50 | if (code !== 0) { 51 | console.log() 52 | console.log( 53 | chalk.red( 54 | 'The script specified as BROWSER environment variable failed.' 55 | ) 56 | ) 57 | console.log(chalk.cyan(scriptPath) + ' exited with code ' + code + '.') 58 | console.log() 59 | } 60 | }) 61 | return true 62 | } 63 | 64 | function startBrowserProcess (browser, url) { 65 | // If we're on OS X, the user hasn't specifically 66 | // requested a different browser, we can try opening 67 | // Chrome with AppleScript. This lets us reuse an 68 | // existing tab when possible instead of creating a new one. 69 | const shouldTryOpenChromeWithAppleScript = 70 | process.platform === 'darwin' && 71 | (typeof browser !== 'string' || browser === OSX_CHROME) 72 | 73 | if (shouldTryOpenChromeWithAppleScript) { 74 | try { 75 | // Try our best to reuse existing tab 76 | // on OS X Google Chrome with AppleScript 77 | execSync('ps cax | grep "Google Chrome"') 78 | execSync('osascript openChrome.applescript "' + encodeURI(url) + '"', { 79 | cwd: __dirname, 80 | stdio: 'ignore' 81 | }) 82 | return true 83 | } catch (err) { 84 | // Ignore errors. 85 | } 86 | } 87 | 88 | // Another special case: on OS X, check if BROWSER has been set to "open". 89 | // In this case, instead of passing `open` to `opn` (which won't work), 90 | // just ignore it (thus ensuring the intended behavior, i.e. opening the system browser): 91 | // https://github.com/facebookincubator/create-react-app/pull/1690#issuecomment-283518768 92 | if (process.platform === 'darwin' && browser === 'open') { 93 | browser = undefined 94 | } 95 | 96 | // Fallback to opn 97 | // (It will always open new tab) 98 | try { 99 | var options = { app: browser } 100 | opn(url, options).catch(() => {}) // Prevent `unhandledRejection` error. 101 | return true 102 | } catch (err) { 103 | return false 104 | } 105 | } 106 | 107 | /** 108 | * Reads the BROWSER evironment variable and decides what to do with it. Returns 109 | * true if it opened a browser or ran a node.js script, otherwise false. 110 | */ 111 | exports.openBrowser = function (url) { 112 | const { action, value } = getBrowserEnv() 113 | switch (action) { 114 | case Actions.NONE: 115 | // Special case: BROWSER="none" will prevent opening completely. 116 | return false 117 | case Actions.SCRIPT: 118 | return executeNodeScript(value, url) 119 | case Actions.BROWSER: 120 | return startBrowserProcess(value, url) 121 | default: 122 | throw new Error('Not implemented.') 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /config/lib/openChrome.applescript: -------------------------------------------------------------------------------- 1 | (* 2 | Copyright (c) 2015-present, Facebook, Inc. 3 | This source code is licensed under the MIT license found in the 4 | LICENSE file at 5 | https://github.com/facebookincubator/create-react-app/blob/master/LICENSE 6 | *) 7 | 8 | property targetTab: null 9 | property targetTabIndex: -1 10 | property targetWindow: null 11 | 12 | on run argv 13 | set theURL to item 1 of argv 14 | 15 | tell application "Chrome" 16 | 17 | if (count every window) = 0 then 18 | make new window 19 | end if 20 | 21 | -- 1: Looking for tab running debugger 22 | -- then, Reload debugging tab if found 23 | -- then return 24 | set found to my lookupTabWithUrl(theURL) 25 | if found then 26 | set targetWindow's active tab index to targetTabIndex 27 | tell targetTab to reload 28 | tell targetWindow to activate 29 | set index of targetWindow to 1 30 | return 31 | end if 32 | 33 | -- 2: Looking for Empty tab 34 | -- In case debugging tab was not found 35 | -- We try to find an empty tab instead 36 | set found to my lookupTabWithUrl("chrome://newtab/") 37 | if found then 38 | set targetWindow's active tab index to targetTabIndex 39 | set URL of targetTab to theURL 40 | tell targetWindow to activate 41 | return 42 | end if 43 | 44 | -- 3: Create new tab 45 | -- both debugging and empty tab were not found 46 | -- make a new tab with url 47 | tell window 1 48 | activate 49 | make new tab with properties {URL:theURL} 50 | end tell 51 | end tell 52 | end run 53 | 54 | -- Function: 55 | -- Lookup tab with given url 56 | -- if found, store tab, index, and window in properties 57 | -- (properties were declared on top of file) 58 | on lookupTabWithUrl(lookupUrl) 59 | tell application "Chrome" 60 | -- Find a tab with the given url 61 | set found to false 62 | set theTabIndex to -1 63 | repeat with theWindow in every window 64 | set theTabIndex to 0 65 | repeat with theTab in every tab of theWindow 66 | set theTabIndex to theTabIndex + 1 67 | if (theTab's URL as string) contains lookupUrl then 68 | -- assign tab, tab index, and window to properties 69 | set targetTab to theTab 70 | set targetTabIndex to theTabIndex 71 | set targetWindow to theWindow 72 | set found to true 73 | exit repeat 74 | end if 75 | end repeat 76 | 77 | if found then 78 | exit repeat 79 | end if 80 | end repeat 81 | end tell 82 | return found 83 | end lookupTabWithUrl -------------------------------------------------------------------------------- /config/server.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const Koa = require('koa') 3 | const koaCompress = require('koa-compress') 4 | const compressible = require('compressible') 5 | const koaStatic = require('./koa/static') 6 | const SSR = require('./ssr') 7 | const conf = require('./app') 8 | 9 | const isProd = process.env.NODE_ENV === 'production' 10 | 11 | const app = new Koa() 12 | 13 | app.use(koaCompress({ // 压缩数据 14 | filter: type => !(/event\-stream/i.test(type)) && compressible(type) // eslint-disable-line 15 | })) 16 | 17 | app.use(koaStatic(isProd ? path.resolve(__dirname, '../dist/web') : path.resolve(__dirname, '../public'), { 18 | maxAge: 30 * 24 * 60 * 60 * 1000 19 | })) // 配置静态资源目录及过期时间 20 | 21 | // vue ssr处理,在SSR中处理API 22 | SSR(app).then(server => { 23 | server.listen(conf.app.port, '0.0.0.0', () => { 24 | console.log(`> server is staring...`) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /config/setup-dev-server.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const chalk = require('chalk') 4 | const MFS = require('memory-fs') 5 | const webpack = require('webpack') 6 | const chokidar = require('chokidar') 7 | const apiConfig = require('./webpack.api.config') 8 | const serverConfig = require('./webpack.server.config') 9 | const webConfig = require('./webpack.web.config') 10 | const webpackDevMiddleware = require('./koa/dev') 11 | const webpackHotMiddleware = require('./koa/hot') 12 | const readline = require('readline') 13 | const conf = require('./app') 14 | const { 15 | hasProjectYarn, 16 | openBrowser 17 | } = require('./lib') 18 | 19 | const readFile = (fs, file) => { 20 | try { 21 | return fs.readFileSync(path.join(webConfig.output.path, file), 'utf-8') 22 | } catch (e) {} 23 | } 24 | 25 | module.exports = (app, cb) => { 26 | let apiMain, bundle, template, clientManifest, serverTime, webTime, apiTime 27 | const apiOutDir = apiConfig.output.path 28 | let isFrist = true 29 | 30 | const clearConsole = () => { 31 | if (process.stdout.isTTY) { 32 | // Fill screen with blank lines. Then move to 0 (beginning of visible part) and clear it 33 | const blank = '\n'.repeat(process.stdout.rows) 34 | console.log(blank) 35 | readline.cursorTo(process.stdout, 0, 0) 36 | readline.clearScreenDown(process.stdout) 37 | } 38 | } 39 | 40 | const update = () => { 41 | if (apiMain && bundle && template && clientManifest) { 42 | if (isFrist) { 43 | const url = 'http://' + conf.app.devHost + ':' + conf.app.port 44 | console.log(chalk.bgGreen.black(' DONE ') + ' ' + chalk.green(`Compiled successfully in ${serverTime + webTime + apiTime}ms`)) 45 | console.log() 46 | console.log(` App running at: ${chalk.cyan(url)}`) 47 | console.log() 48 | const buildCommand = hasProjectYarn(process.cwd()) ? `yarn build` : `npm run build` 49 | console.log(` Note that the development build is not optimized.`) 50 | console.log(` To create a production build, run ${chalk.cyan(buildCommand)}.`) 51 | console.log() 52 | if (conf.app.open) openBrowser(url) 53 | isFrist = false 54 | } 55 | cb(bundle, { 56 | template, 57 | clientManifest 58 | }, apiMain, apiOutDir) 59 | } 60 | } 61 | 62 | // server for api 63 | apiConfig.entry.app = ['webpack-hot-middleware/client?path=/__webpack_hmr&timeout=2000&reload=true', apiConfig.entry.app] 64 | apiConfig.plugins.push( 65 | new webpack.HotModuleReplacementPlugin(), 66 | new webpack.NoEmitOnErrorsPlugin() 67 | ) 68 | const apiCompiler = webpack(apiConfig) 69 | const apiMfs = new MFS() 70 | apiCompiler.outputFileSystem = apiMfs 71 | apiCompiler.watch({}, (err, stats) => { 72 | if (err) throw err 73 | stats = stats.toJson() 74 | if (stats.errors.length) return 75 | console.log('api-dev...') 76 | apiMfs.readdir(path.join(__dirname, '../dist/api'), function (err, files) { 77 | if (err) { 78 | return console.error(err) 79 | } 80 | files.forEach(function (file) { 81 | console.info(file) 82 | }) 83 | }) 84 | apiMain = apiMfs.readFileSync(path.join(apiConfig.output.path, 'api.js'), 'utf-8') 85 | update() 86 | }) 87 | apiCompiler.plugin('done', stats => { 88 | stats = stats.toJson() 89 | stats.errors.forEach(err => console.error(err)) 90 | stats.warnings.forEach(err => console.warn(err)) 91 | if (stats.errors.length) return 92 | 93 | apiTime = stats.time 94 | // console.log('web-dev') 95 | // update() 96 | }) 97 | 98 | // web server for ssr 99 | const serverCompiler = webpack(serverConfig) 100 | const mfs = new MFS() 101 | serverCompiler.outputFileSystem = mfs 102 | serverCompiler.watch({}, (err, stats) => { 103 | if (err) throw err 104 | stats = stats.toJson() 105 | if (stats.errors.length) return 106 | // console.log('server-dev...') 107 | bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json')) 108 | update() 109 | }) 110 | serverCompiler.plugin('done', stats => { 111 | stats = stats.toJson() 112 | stats.errors.forEach(err => console.error(err)) 113 | stats.warnings.forEach(err => console.warn(err)) 114 | if (stats.errors.length) return 115 | 116 | serverTime = stats.time 117 | }) 118 | 119 | // web 120 | webConfig.entry.app = ['webpack-hot-middleware/client?path=/__webpack_hmr&timeout=2000&reload=true', webConfig.entry.app] 121 | webConfig.output.filename = '[name].js' 122 | webConfig.plugins.push( 123 | new webpack.HotModuleReplacementPlugin(), 124 | new webpack.NoEmitOnErrorsPlugin() 125 | ) 126 | const clientCompiler = webpack(webConfig) 127 | const devMiddleware = webpackDevMiddleware(clientCompiler, { 128 | // publicPath: webConfig.output.publicPath, 129 | stats: { // or 'errors-only' 130 | colors: true 131 | }, 132 | reporter: (middlewareOptions, options) => { 133 | const { log, state, stats } = options 134 | 135 | if (state) { 136 | const displayStats = (middlewareOptions.stats !== false) 137 | 138 | if (displayStats) { 139 | if (stats.hasErrors()) { 140 | log.error(stats.toString(middlewareOptions.stats)) 141 | } else if (stats.hasWarnings()) { 142 | log.warn(stats.toString(middlewareOptions.stats)) 143 | } else { 144 | log.info(stats.toString(middlewareOptions.stats)) 145 | } 146 | } 147 | 148 | let message = 'Compiled successfully.' 149 | 150 | if (stats.hasErrors()) { 151 | message = 'Failed to compile.' 152 | } else if (stats.hasWarnings()) { 153 | message = 'Compiled with warnings.' 154 | } 155 | log.info(message) 156 | 157 | clearConsole() 158 | 159 | update() 160 | } else { 161 | log.info('Compiling...') 162 | } 163 | }, 164 | noInfo: true, 165 | serverSideRender: false 166 | }) 167 | app.use(devMiddleware) 168 | 169 | const templatePath = path.resolve(__dirname, '../public/index.html') 170 | 171 | // read template from disk and watch 172 | template = fs.readFileSync(templatePath, 'utf-8') 173 | chokidar.watch(templatePath).on('change', () => { 174 | template = fs.readFileSync(templatePath, 'utf-8') 175 | console.log('index.html template updated.') 176 | update() 177 | }) 178 | 179 | clientCompiler.plugin('done', stats => { 180 | stats = stats.toJson() 181 | stats.errors.forEach(err => console.error(err)) 182 | stats.warnings.forEach(err => console.warn(err)) 183 | if (stats.errors.length) return 184 | 185 | clientManifest = JSON.parse(readFile( 186 | devMiddleware.fileSystem, 187 | 'vue-ssr-client-manifest.json' 188 | )) 189 | 190 | webTime = stats.time 191 | }) 192 | app.use(webpackHotMiddleware(clientCompiler)) 193 | } 194 | -------------------------------------------------------------------------------- /config/ssr.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const chalk = require('chalk') 4 | const LRU = require('lru-cache') 5 | const { 6 | createBundleRenderer 7 | } = require('vue-server-renderer') 8 | const isProd = process.env.NODE_ENV === 'production' 9 | const setUpDevServer = require('./setup-dev-server') 10 | const HtmlMinifier = require('html-minifier').minify 11 | 12 | const pathResolve = file => path.resolve(__dirname, file) 13 | 14 | module.exports = app => { 15 | return new Promise((resolve, reject) => { 16 | const createRenderer = (bundle, options) => { 17 | return createBundleRenderer(bundle, Object.assign(options, { 18 | cache: new LRU({ 19 | max: 1000, 20 | maxAge: 1000 * 60 * 15 21 | }), 22 | basedir: pathResolve('../dist/web'), 23 | runInNewContext: false 24 | })) 25 | } 26 | 27 | let renderer = null 28 | if (isProd) { 29 | // prod mode 30 | const template = HtmlMinifier(fs.readFileSync(pathResolve('../public/index.html'), 'utf-8'), { 31 | collapseWhitespace: true, 32 | removeAttributeQuotes: true, 33 | removeComments: false 34 | }) 35 | const bundle = require(pathResolve('../dist/web/vue-ssr-server-bundle.json')) 36 | const clientManifest = require(pathResolve('../dist/web/vue-ssr-client-manifest.json')) 37 | renderer = createRenderer(bundle, { 38 | template, 39 | clientManifest 40 | }) 41 | } else { 42 | // dev mode 43 | setUpDevServer(app, (bundle, options, apiMain, apiOutDir) => { 44 | try { 45 | const API = eval(apiMain).default // eslint-disable-line 46 | const server = API(app) 47 | renderer = createRenderer(bundle, options) 48 | resolve(server) 49 | } catch (e) { 50 | console.log(chalk.red('\nServer error'), e) 51 | } 52 | }) 53 | } 54 | 55 | app.use(async (ctx, next) => { 56 | if (!renderer) { 57 | ctx.type = 'html' 58 | ctx.body = 'waiting for compilation... refresh in a moment.' 59 | next() 60 | return 61 | } 62 | 63 | let status = 200 64 | let html = null 65 | const context = { 66 | url: ctx.url, 67 | title: 'OK' 68 | } 69 | 70 | if (/^\/api/.test(ctx.url)) { // 如果请求以/api开头,则进入api部分进行处理。 71 | next() 72 | return 73 | } 74 | 75 | try { 76 | status = 200 77 | html = await renderer.renderToString(context) 78 | } catch (e) { 79 | if (e.message === '404') { 80 | status = 404 81 | html = '404 | Not Found' 82 | } else { 83 | status = 500 84 | // console.log(e) 85 | console.log(chalk.red('\nError: '), e.message) 86 | html = '500 | Internal Server Error' 87 | } 88 | } 89 | ctx.type = 'html' 90 | ctx.status = status || ctx.status 91 | ctx.body = html 92 | next() 93 | }) 94 | 95 | if (isProd) { 96 | const API = require('../dist/api/api').default 97 | const server = API(app) 98 | resolve(server) 99 | } 100 | }) 101 | } 102 | -------------------------------------------------------------------------------- /config/webpack.api.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin') 4 | const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin') 5 | const { dependencies } = require('../package.json') 6 | 7 | const isProd = process.env.NODE_ENV === 'production' 8 | 9 | module.exports = { 10 | name: 'api', 11 | target: 'node', 12 | devtool: '#cheap-module-source-map', 13 | mode: isProd ? 'production' : 'development', 14 | entry: path.join(__dirname, '../src/api/app.js'), 15 | output: { 16 | libraryTarget: 'commonjs2', 17 | path: path.resolve(__dirname, '../dist/api'), 18 | filename: 'api.js', 19 | publicPath: '/' 20 | }, 21 | resolve: { 22 | alias: { 23 | '@': path.join(__dirname, '../src/web'), 24 | '~': path.join(__dirname, '../src/api') 25 | }, 26 | extensions: ['.js'] 27 | }, 28 | externals: [ 29 | ...Object.keys(dependencies || {}) 30 | ], 31 | module: { 32 | rules: [{ 33 | test: /\.(js)$/, 34 | include: [path.resolve(__dirname, '../src/api')], 35 | exclude: /(node_modules|bower_components)/ 36 | // use: [ 37 | // { 38 | // loader: 'babel-loader', 39 | // options: { 40 | // presets: ['@babel/preset-env'] 41 | // } 42 | // }, 43 | // { 44 | // loader: 'eslint-loader' 45 | // } 46 | // ] 47 | } 48 | ] 49 | }, 50 | plugins: [ 51 | new CaseSensitivePathsPlugin(), 52 | new FriendlyErrorsPlugin(), 53 | new webpack.DefinePlugin({ 54 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 55 | 'process.env.API_ENV': '"server"' 56 | }) 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /config/webpack.base.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 4 | const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin') 5 | const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin') 6 | const CopyWebpackPlugin = require('copy-webpack-plugin') 7 | const { VueLoaderPlugin } = require('vue-loader') 8 | 9 | const isProd = process.env.NODE_ENV === 'production' 10 | 11 | module.exports = { 12 | mode: isProd ? 'production' : 'development', 13 | output: { 14 | path: path.resolve(__dirname, '../dist/web'), 15 | publicPath: '/', 16 | filename: '[name].[chunkhash:8].js', 17 | chunkFilename: '[id].js' 18 | }, 19 | resolve: { 20 | alias: { 21 | '@': path.join(__dirname, '../src/web'), 22 | '~': path.join(__dirname, '../src/api'), 23 | 'vue$': 'vue/dist/vue.esm.js' 24 | }, 25 | extensions: ['.js', '.vue', '.json', '.css'] 26 | }, 27 | module: { 28 | rules: [{ 29 | test: /\.(js|jsx)$/, 30 | include: [path.resolve(__dirname, '../src/web')], 31 | exclude: /(node_modules|bower_components)/, 32 | use: { 33 | loader: 'babel-loader', 34 | options: { 35 | presets: ['@babel/preset-env'], 36 | plugins: [ 37 | 'transform-vue-jsx', 38 | '@babel/plugin-syntax-jsx', 39 | '@babel/plugin-syntax-dynamic-import' 40 | ] 41 | } 42 | } 43 | }, 44 | // { 45 | // test: /\.(js|jsx|vue)$/, 46 | // enforce: 'pre', 47 | // exclude: /node_modules/, 48 | // use: { 49 | // loader: 'eslint-loader' 50 | // } 51 | // }, 52 | { 53 | test: /\.json$/, 54 | use: 'json-loader' 55 | }, 56 | { 57 | test: /\.pug$/, 58 | use: { 59 | loader: 'pug-plain-loader' 60 | } 61 | }, 62 | { 63 | test: /\.css$/, 64 | use: [ 65 | isProd ? MiniCssExtractPlugin.loader : 'vue-style-loader', 66 | 'css-loader' 67 | ] 68 | }, 69 | // { 70 | // test: /\.styl(us)?$/, 71 | // use: [ 72 | // isProd ? MiniCssExtractPlugin.loader : 'vue-style-loader', 73 | // 'css-loader', 74 | // 'stylus-loader' 75 | // ] 76 | // }, 77 | // { 78 | // test: /\.less$/, 79 | // use: [ 80 | // isProd ? MiniCssExtractPlugin.loader : 'vue-style-loader', 81 | // 'css-loader', 82 | // 'less-loader' 83 | // ] 84 | // }, 85 | { 86 | test: /\.html$/, 87 | use: 'vue-html-loader', 88 | exclude: /node_modules/ 89 | }, 90 | { 91 | test: /\.vue$/, 92 | use: [ 93 | { 94 | loader: 'vue-loader', 95 | options: { 96 | preserveWhitespace: false 97 | } 98 | } 99 | ] 100 | }, 101 | { 102 | test: /\.(png|jpe?g|gif|svg|ico)(\?.*)?$/, 103 | use: { 104 | loader: 'url-loader', 105 | query: { 106 | limit: 10000, 107 | name: 'assets/images/[name].[hash:8].[ext]' 108 | } 109 | } 110 | }, 111 | { 112 | test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, 113 | loader: 'url-loader', 114 | options: { 115 | limit: 10000, 116 | name: 'assets/images/[name].[hash:8].[ext]' 117 | } 118 | }, 119 | { 120 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 121 | use: { 122 | loader: 'url-loader', 123 | query: { 124 | limit: 10000, 125 | name: 'assets/font/[name].[hash:8].[ext]' 126 | } 127 | } 128 | } 129 | ] 130 | }, 131 | optimization: { 132 | splitChunks: { 133 | chunks: 'async', 134 | minSize: 30000, 135 | minChunks: 2, 136 | maxAsyncRequests: 5, 137 | maxInitialRequests: 3 138 | // cacheGroups: { 139 | // commons: { 140 | // name: 'manifest', 141 | // chunks: 'initial', 142 | // minChunks: 2 143 | // } 144 | // } 145 | } 146 | }, 147 | performance: { 148 | maxEntrypointSize: 400000, 149 | hints: isProd ? 'warning' : false 150 | }, 151 | plugins: [ 152 | new CaseSensitivePathsPlugin(), 153 | new CopyWebpackPlugin([{ 154 | from: path.join(__dirname, '../public'), 155 | to: path.join(__dirname, '../dist/web'), 156 | ignore: ['.*', 'index.html'] 157 | }]), 158 | new FriendlyErrorsPlugin(), 159 | new VueLoaderPlugin(), 160 | new webpack.optimize.LimitChunkCountPlugin({ 161 | maxChunks: 15 162 | }), 163 | new MiniCssExtractPlugin({ 164 | filename: isProd ? '[name].[hash].css' : '[name].css', 165 | chunkFilename: isProd ? '[id].[hash].css' : '[id].css' 166 | }) 167 | ] 168 | } 169 | -------------------------------------------------------------------------------- /config/webpack.server.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | const merge = require('webpack-merge') 4 | const nodeExternals = require('webpack-node-externals') 5 | const config = require('./webpack.base.config') 6 | const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') 7 | const version = ' V ' + require('../package.json').version 8 | 9 | module.exports = merge(config, { 10 | name: 'server', 11 | target: 'node', 12 | devtool: '#cheap-module-source-map', 13 | mode: 'production', 14 | entry: path.join(__dirname, '../src/web/entry-server.js'), 15 | output: { 16 | libraryTarget: 'commonjs2' 17 | }, 18 | externals: nodeExternals({ 19 | whitelist: [/\.vue$/, /\.css$/, /\.styl(us)$/, /\.pug$/] 20 | }), 21 | plugins: [ 22 | new webpack.DefinePlugin({ 23 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 24 | 'process.env.VUE_ENV': '"server"', 25 | 'process.env.BM_VERSION': "'" + version + "'" 26 | }), 27 | new VueSSRServerPlugin() 28 | ] 29 | }) 30 | -------------------------------------------------------------------------------- /config/webpack.web.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const merge = require('webpack-merge') 3 | const path = require('path') 4 | const base = require('./webpack.base.config') 5 | const isProd = process.env.NODE_ENV === 'production' 6 | const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') 7 | const version = ' V ' + require('../package.json').version 8 | 9 | console.log(version) 10 | 11 | module.exports = merge(base, { 12 | name: 'web', 13 | devtool: '#eval-source-map', 14 | entry: { 15 | app: path.resolve(__dirname, '../src/web/entry-client.js') 16 | }, 17 | mode: isProd ? 'production' : 'development', 18 | plugins: [ 19 | new webpack.DefinePlugin({ 20 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 21 | 'process.env.VUE_ENV': '"client"', 22 | 'process.env.BM_VERSION': "'" + version + "'" 23 | }), 24 | new VueSSRClientPlugin() 25 | ] 26 | }) 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-ssr-koa2-scaffold", 3 | "version": "1.0.0", 4 | "description": "Vue SSR Koa2 Scaffold", 5 | "main": "src/app.js", 6 | "repository": "https://github.com/yi-ge/Vue-SSR-Koa2-Scaffold", 7 | "author": "yige ", 8 | "license": "MIT", 9 | "private": false, 10 | "scripts": { 11 | "serve": "cross-env NODE_ENV=development node config/server.js", 12 | "start": "cross-env NODE_ENV=production node config/server.js", 13 | "build": "rimraf dist && npm run build:web && npm run build:server && npm run build:api", 14 | "build:web": "cross-env NODE_ENV=production webpack --config config/webpack.web.config.js --progress --hide-modules", 15 | "build:server": "cross-env NODE_ENV=production webpack --config config/webpack.server.config.js --progress --hide-modules", 16 | "build:api": "cross-env NODE_ENV=production webpack --config config/webpack.api.config.js --progress --hide-modules" 17 | }, 18 | "dependencies": { 19 | "axios": "0.19.0", 20 | "chokidar": "^2.1.6", 21 | "cross-env": "^5.2.0", 22 | "html-minifier": "^4.0.0", 23 | "koa": "^2.7.0", 24 | "koa-body": "^4.1.0", 25 | "koa-compress": "^3.0.0", 26 | "koa-send": "^5.0.0", 27 | "lru-cache": "^5.1.1", 28 | "memory-fs": "^0.4.1", 29 | "readline": "^1.3.0", 30 | "vue": "^2.6.10", 31 | "vue-router": "^3.0.6", 32 | "vue-server-renderer": "^2.6.10", 33 | "vue-template-compiler": "^2.6.10", 34 | "vuex": "^3.1.1", 35 | "vuex-router-sync": "^5.0.0" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "^7.4.5", 39 | "@babel/plugin-syntax-dynamic-import": "^7.2.0", 40 | "@babel/plugin-syntax-jsx": "^7.2.0", 41 | "@babel/polyfill": "^7.4.4", 42 | "@babel/preset-env": "^7.4.5", 43 | "babel-helper-vue-jsx-merge-props": "^2.0.3", 44 | "babel-loader": "^8.0.6", 45 | "babel-plugin-syntax-jsx": "^6.18.0", 46 | "babel-plugin-transform-vue-jsx": "^3.7.0", 47 | "case-sensitive-paths-webpack-plugin": "^2.2.0", 48 | "chalk": "^2.4.2", 49 | "copy-webpack-plugin": "^5.0.3", 50 | "css-loader": "^2.1.1", 51 | "execa": "^1.0.0", 52 | "file-loader": "^3.0.1", 53 | "friendly-errors-webpack-plugin": "^1.7.0", 54 | "json-loader": "^0.5.7", 55 | "mini-css-extract-plugin": "^0.5.0", 56 | "opn": "^6.0.0", 57 | "url-loader": "^1.1.2", 58 | "vue-html-loader": "^1.2.4", 59 | "vue-loader": "^15.7.0", 60 | "vue-style-loader": "^4.1.2", 61 | "webpack": "^4.32.2", 62 | "webpack-cli": "^3.3.2", 63 | "webpack-dev-middleware": "^3.7.0", 64 | "webpack-hot-middleware": "^2.25.0", 65 | "webpack-merge": "^4.2.1", 66 | "webpack-node-externals": "^1.7.2" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yi-ge/Vue-SSR-Koa2-Scaffold/b67d0e81d6e07ea2aa23d377542f761626dbf130/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title }} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /src/api/app.js: -------------------------------------------------------------------------------- 1 | import KoaBody from 'koa-body' 2 | const env = process.env.NODE_ENV || 'development' // Current mode 3 | 4 | export default app => { 5 | app.proxy = true 6 | 7 | const server = require('http').createServer(app.callback()) 8 | // const io = require('socket.io')(server) // 注释为启用socket.io的方法 9 | 10 | // io.on('connection', function (socket) { 11 | // console.log('a user connected: ' + socket.id) 12 | // socket.on('disconnect', function () { 13 | // console.log('user disconnected:' + socket.id + '-' + socket.code) 14 | // redisClient.del(socket.code) 15 | // }) 16 | // }) 17 | 18 | app 19 | // .use((ctx, next) => { 20 | // ctx.io = io 21 | // return next() 22 | // }) 23 | .use((ctx, next) => { // 跨域处理 24 | ctx.set('Access-Control-Allow-Origin', '*') 25 | ctx.set('Access-Control-Allow-Headers', 'Authorization, DNT, User-Agent, Keep-Alive, Origin, X-Requested-With, Content-Type, Accept, x-clientid') 26 | ctx.set('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS') 27 | if (ctx.method === 'OPTIONS') { 28 | ctx.status = 200 29 | ctx.body = '' 30 | } 31 | return next() 32 | }) 33 | .use(KoaBody({ 34 | multipart: true, // 开启对multipart/form-data的支持 35 | parsedMethods: ['POST', 'PUT', 'PATCH', 'GET', 'HEAD', 'DELETE'], // parse GET, HEAD, DELETE requests 36 | formidable: { // 设置上传参数 37 | // uploadDir: path.join(__dirname, '../assets/uploads/tmpfile') 38 | }, 39 | jsonLimit: '10mb', // application/json 限制,default 1mb 1mb 40 | formLimit: '10mb', // multipart/form-data 限制,default 56kb 41 | textLimit: '10mb' // application/x-www-urlencoded 限制,default 56kb 42 | })) 43 | .use((ctx, next) => { 44 | if (/^\/api/.test(ctx.url)) { 45 | ctx.body = 'World' // 测试用 46 | } 47 | next() 48 | }) 49 | 50 | if (env === 'development') { // logger 51 | app.use((ctx, next) => { 52 | const start = new Date() 53 | return next().then(() => { 54 | const ms = new Date() - start 55 | console.log(`${ctx.method} ${ctx.url} - ${ms}ms`) 56 | }) 57 | }) 58 | } 59 | 60 | return server 61 | } 62 | -------------------------------------------------------------------------------- /src/web/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /src/web/app.js: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill' 2 | import Vue from 'vue' 3 | import App from './App.vue' 4 | import { createStore } from './store' 5 | import { createRouter } from './router' 6 | import { sync } from 'vuex-router-sync' 7 | import titleMixin from './util/title' 8 | import * as filters from './util/filters' 9 | import axios from 'axios' 10 | import conf from '../../config/app' 11 | 12 | Vue.prototype.$request = axios.create({ 13 | baseURL: 'http://' + conf.app.devHost + ':' + conf.app.port, 14 | timeout: 1000 15 | }) 16 | Vue.prototype.$isProd = process.env.NODE_ENV === 'production' 17 | 18 | Vue.mixin(titleMixin) 19 | 20 | Object.keys(filters).forEach(key => { 21 | Vue.filter(key, filters[key]) 22 | }) 23 | 24 | export function createApp () { 25 | const store = createStore() 26 | const router = createRouter() 27 | 28 | // sync the router with the vuex store. 29 | // this registers `store.state.route` 30 | sync(store, router) 31 | 32 | const app = new Vue({ 33 | router, 34 | store, 35 | render: h => h(App) 36 | }) 37 | 38 | return { app, router, store } 39 | } 40 | -------------------------------------------------------------------------------- /src/web/components/ProgressBar.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | 89 | 90 | 104 | -------------------------------------------------------------------------------- /src/web/entry-client.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { createApp } from './app' 3 | import ProgressBar from './components/ProgressBar.vue' 4 | 5 | // global progress bar 6 | const bar = Vue.prototype.$bar = new Vue(ProgressBar).$mount() 7 | document.body.appendChild(bar.$el) 8 | 9 | // a global mixin that calls `asyncData` when a route component's params change 10 | Vue.mixin({ 11 | beforeRouteUpdate (to, from, next) { 12 | const { asyncData } = this.$options 13 | if (asyncData) { 14 | asyncData({ 15 | store: this.$store, 16 | route: to 17 | }).then(next).catch(next) 18 | } else { 19 | next() 20 | } 21 | } 22 | }) 23 | 24 | const { app, router, store } = createApp() 25 | 26 | // prime the store with server-initialized state. 27 | // the state is determined during SSR and inlined in the page markup. 28 | if (window.__INITIAL_STATE__) { 29 | store.replaceState(window.__INITIAL_STATE__) 30 | } 31 | 32 | // wait until router has resolved all async before hooks 33 | // and async components... 34 | router.onReady(() => { 35 | // Add router hook for handling asyncData. 36 | // Doing it after initial route is resolved so that we don't double-fetch 37 | // the data that we already have. Using router.beforeResolve() so that all 38 | // async components are resolved. 39 | router.beforeResolve((to, from, next) => { 40 | const matched = router.getMatchedComponents(to) 41 | const prevMatched = router.getMatchedComponents(from) 42 | let diffed = false 43 | const activated = matched.filter((c, i) => { 44 | return diffed || (diffed = (prevMatched[i] !== c)) 45 | }) 46 | const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _) 47 | if (!asyncDataHooks.length) { 48 | return next() 49 | } 50 | 51 | bar.start() 52 | Promise.all(asyncDataHooks.map(hook => hook({ store, route: to }))) 53 | .then(() => { 54 | bar.finish() 55 | next() 56 | }) 57 | .catch(next) 58 | }) 59 | 60 | // actually mount to DOM 61 | app.$mount('#app') 62 | }) 63 | -------------------------------------------------------------------------------- /src/web/entry-server.js: -------------------------------------------------------------------------------- 1 | import { createApp } 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 | return new Promise((resolve, reject) => { 12 | const s = isDev && Date.now() 13 | const { app, router, store } = createApp() 14 | 15 | const { url } = context 16 | const { fullPath } = router.resolve(url).route 17 | 18 | if (fullPath !== url) { 19 | // console.log(fullPath) 20 | // console.log(url) 21 | return reject(new Error(fullPath)) 22 | } 23 | 24 | // set router's location 25 | router.push(url) 26 | 27 | // wait until router has resolved possible async hooks 28 | router.onReady(() => { 29 | const matchedComponents = router.getMatchedComponents() 30 | // no matched routes 31 | if (!matchedComponents.length) { 32 | return reject(new Error('404')) 33 | } 34 | // Call fetchData hooks on components matched by the route. 35 | // A preFetch hook dispatches a store action and returns a Promise, 36 | // which is resolved when the action is complete and store state has been 37 | // updated. 38 | Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({ 39 | store, 40 | route: router.currentRoute 41 | }))).then(() => { 42 | isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`) 43 | // After all preFetch hooks are resolved, our store is now 44 | // filled with the state needed to render the app. 45 | // Expose the state on the render context, and let the request handler 46 | // inline the state in the HTML response. This allows the client-side 47 | // store to pick-up the server-side state without having to duplicate 48 | // the initial data fetching on the client. 49 | context.state = store.state 50 | resolve(app) 51 | }).catch(reject) 52 | }, reject) 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /src/web/libs/util.js: -------------------------------------------------------------------------------- 1 | // 获取描述对象的值 2 | export const getObjectValue = (obj, des) => { 3 | return eval('obj.' + des) // eslint-disable-line 4 | } 5 | 6 | /** 7 | * 对象比较器 8 | * 使用方法:data.sort(compare("对象名称")) 在对象内部排序,不生成副本 9 | * @param {[type]} propertyName 要排序的对象的子名称(限一级) 10 | * @return {[type]} 排序规则 11 | */ 12 | export const compareObject = propertyName => { 13 | return function (object1, object2) { 14 | var value1 = getObjectValue(object1, propertyName) 15 | var value2 = getObjectValue(object2, propertyName) 16 | if (value2 < value1) { 17 | return -1 18 | } else if (value2 > value1) { 19 | return 1 20 | } else { 21 | return 0 22 | } 23 | } 24 | } 25 | 26 | /** 27 | * 根据含义字符串换算对应的毫秒数 28 | * @param {[type]} str 字符串 29 | * @return {[type]} ms 30 | */ 31 | let getsec = function (str) { 32 | if (/[s|h|d|l]/i.test(str)) { 33 | var str1 = str.substring(0, str.length - 1) 34 | var str2 = str.substring(str.length - 1, str.length) 35 | if (str2 === 's') { 36 | return str1 * 1000 37 | } else if (str2 === 'h') { 38 | return str1 * 60 * 60 * 1000 39 | } else if (str2 === 'd') { 40 | return str1 * 24 * 60 * 60 * 1000 41 | } 42 | } else { 43 | if (str.indexOf('l') === -1) { 44 | return str * 1000 45 | } else { 46 | return 30 * 24 * 60 * 60 * 1000 47 | } 48 | } 49 | } 50 | 51 | // 写 cookies 52 | export const setCookie = function setCookie (name, value, time) { 53 | if (time) { 54 | let strsec = getsec(time) 55 | let exp = new Date() 56 | exp.setTime(exp.getTime() + parseInt(strsec)) 57 | document.cookie = 58 | name + '=' + escape(value) + ';expires=' + exp.toGMTString() 59 | } else { 60 | document.cookie = name + '=' + escape(value) 61 | } 62 | } 63 | 64 | // 读 cookies 65 | export const getCookie = function (name) { 66 | let reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)') 67 | let arr = document.cookie.match(reg) 68 | return arr ? unescape(arr[2]) : null 69 | } 70 | 71 | // 删 cookies 72 | export const delCookie = function (name) { 73 | var exp = new Date() 74 | exp.setTime(exp.getTime() - 1) 75 | var cval = getCookie(name) 76 | if (cval != null) { 77 | document.cookie = name + '=' + cval + ';expires=' + exp.toGMTString() 78 | } 79 | } 80 | 81 | // 获取Token 82 | export const getToken = function () { 83 | if (window.localStorage) { 84 | return window.localStorage.getItem('token') 85 | } 86 | } 87 | 88 | // 设置Token 89 | export const setToken = function (token) { 90 | if (window.localStorage) { 91 | window.localStorage.setItem('token', token) 92 | } else if (window.localStorage) { 93 | window.localStorage.setItem('token', token) 94 | } 95 | } 96 | 97 | // 删除Token 98 | export const delToken = function () { 99 | if (window.localStorage) { 100 | window.localStorage.removeItem('token') 101 | } 102 | } 103 | 104 | /** 105 | * 根据host返回根域名 106 | * @param {[string]} host [window.location.host] 107 | * @return {[string]} [如果不是域名则返回IP] 108 | */ 109 | export const getDomain = host => { 110 | host = host.split(':')[0] 111 | return isNaN(host.substring(host.lastIndexOf('.'))) 112 | ? host.substring( 113 | host.substring(0, host.lastIndexOf('.')).lastIndexOf('.') + 1 114 | ) 115 | : host 116 | } 117 | 118 | /** 119 | * 判断对象是否为空 120 | * @param {[type]} e [对象] 121 | * @return {Boolean} [bool] 122 | */ 123 | export const isEmptyObject = e => { 124 | for (let t in e) { 125 | return !1 126 | } 127 | return !0 128 | } 129 | 130 | /* 131 | * 版本号比较方法 132 | * 传入两个字符串,当前版本号:curV;比较版本号:reqV 133 | * 调用方法举例:compare("1.1","1.2"),将返回false 134 | */ 135 | export const compareVersion = (curV, reqV) => { 136 | if (curV && reqV) { 137 | // 将两个版本号拆成数字 138 | let arr1 = curV.split('.') 139 | let arr2 = reqV.split('.') 140 | var minLength = Math.min(arr1.length, arr2.length) 141 | let position = 0 142 | let diff = 0 143 | // 依次比较版本号每一位大小,当对比得出结果后跳出循环(后文有简单介绍) 144 | while (position < minLength && ((diff = parseInt(arr1[position]) - parseInt(arr2[position])) === 0)) { 145 | position++ 146 | } 147 | diff = (diff !== 0) ? diff : (arr1.length - arr2.length) 148 | // 若curV大于reqV,则返回true 149 | return diff > 0 150 | } else { 151 | // 输入为空 152 | console.log('版本号不能为空') 153 | return false 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/web/page/home.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 27 | -------------------------------------------------------------------------------- /src/web/router/admin.js: -------------------------------------------------------------------------------- 1 | import AuthGuard from '@/router/authGuard' 2 | 3 | import adminLayout from '@/layout/admin' 4 | 5 | import AdminHome from '@/page/admin/home' 6 | import Login from '@/page/admin/login' 7 | 8 | export default { 9 | path: '/admin', 10 | component: adminLayout, 11 | beforeEnter: AuthGuard, 12 | children: [ 13 | { 14 | path: '', 15 | redirect: '/admin/home' 16 | }, 17 | { 18 | path: 'home', 19 | name: 'AdminHome', 20 | component: AdminHome 21 | }, 22 | { 23 | path: 'login', 24 | name: 'Login', 25 | component: Login 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/web/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | 4 | import home from '@/page/home' 5 | 6 | Vue.use(Router) 7 | 8 | export function createRouter () { 9 | return new Router({ 10 | mode: 'history', 11 | fallback: false, 12 | scrollBehavior: () => ({ y: 0 }), 13 | routes: [ 14 | { 15 | path: '/', 16 | name: 'Home', 17 | component: home 18 | } 19 | ] 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/web/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import modules from './modules' 4 | import axios from 'axios' 5 | import conf from '../../../config/app' 6 | 7 | Vuex.Store.prototype.$request = axios.create({ 8 | baseURL: 'http://' + conf.app.devHost + ':' + conf.app.port, 9 | timeout: 1000 10 | }) 11 | 12 | Vue.use(Vuex) 13 | 14 | const debug = process.env.NODE_ENV !== 'production' 15 | 16 | export function createStore () { 17 | return new Vuex.Store({ 18 | modules, 19 | strict: debug 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/web/store/modules/api.js: -------------------------------------------------------------------------------- 1 | // initial state 2 | const state = { 3 | text: '' 4 | } 5 | 6 | // getters 7 | const getters = { 8 | 9 | } 10 | 11 | // actions 12 | const actions = { 13 | async fetchVal ({ commit }) { 14 | try { 15 | const { data } = await this.$request.get('http://127.0.0.1:3000/api') 16 | commit('setVal', data) 17 | } catch (err) { 18 | commit('setVal', 'Error') 19 | } 20 | } 21 | } 22 | 23 | // mutations 24 | const mutations = { 25 | setVal (state, val) { 26 | state.text = val 27 | } 28 | } 29 | 30 | export default { 31 | namespaced: true, 32 | state, 33 | getters, 34 | actions, 35 | mutations 36 | } 37 | -------------------------------------------------------------------------------- /src/web/store/modules/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The file enables `@/store/index.js` to import all vuex modules 3 | * in a one-shot manner. There should not be any reason to edit this file. 4 | */ 5 | 6 | const files = require.context('.', false, /\.js$/) 7 | const modules = {} 8 | 9 | files.keys().forEach(key => { 10 | if (key === './index.js') return 11 | modules[key.replace(/(\.\/|\.js)/g, '')] = files(key).default 12 | }) 13 | 14 | export default modules 15 | -------------------------------------------------------------------------------- /src/web/store/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the first item that pass the test 3 | * by second argument function 4 | * 5 | * @param {Array} list 6 | * @param {Function} f 7 | * @return {*} 8 | */ 9 | export function find (list, f) { 10 | return list.filter(f)[0] 11 | } 12 | 13 | /** 14 | * Deep copy the given object considering circular structure. 15 | * This function caches all nested objects and its copies. 16 | * If it detects circular structure, use cached copy to avoid infinite loop. 17 | * 18 | * @param {*} obj 19 | * @param {Array} cache 20 | * @return {*} 21 | */ 22 | export function deepCopy (obj, cache = []) { 23 | // just return if obj is immutable value 24 | if (obj === null || typeof obj !== 'object') { 25 | return obj 26 | } 27 | 28 | // if obj is hit, it is in circular structure 29 | const hit = find(cache, c => c.original === obj) 30 | if (hit) { 31 | return hit.copy 32 | } 33 | 34 | const copy = Array.isArray(obj) ? [] : {} 35 | // put the copy into cache at first 36 | // because we want to refer it in recursive deepCopy 37 | cache.push({ 38 | original: obj, 39 | copy 40 | }) 41 | 42 | Object.keys(obj).forEach(key => { 43 | copy[key] = deepCopy(obj[key], cache) 44 | }) 45 | 46 | return copy 47 | } 48 | 49 | /** 50 | * forEach for object 51 | */ 52 | export function forEachValue (obj, fn) { 53 | Object.keys(obj).forEach(key => fn(obj[key], key)) 54 | } 55 | 56 | export function isObject (obj) { 57 | return obj !== null && typeof obj === 'object' 58 | } 59 | 60 | export function isPromise (val) { 61 | return val && typeof val.then === 'function' 62 | } 63 | 64 | export function assert (condition, msg) { 65 | if (!condition) throw new Error(`[vuex] ${msg}`) 66 | } 67 | -------------------------------------------------------------------------------- /src/web/util/filters.js: -------------------------------------------------------------------------------- 1 | export function host (url) { 2 | const host = url.replace(/^https?:\/\//, '').replace(/\/.*$/, '') 3 | const parts = host.split('.').slice(-3) 4 | if (parts[0] === 'www') parts.shift() 5 | return parts.join('.') 6 | } 7 | 8 | export function timeAgo (time) { 9 | const between = Date.now() / 1000 - Number(time) 10 | if (between < 3600) { 11 | return pluralize(~~(between / 60), ' minute') 12 | } else if (between < 86400) { 13 | return pluralize(~~(between / 3600), ' hour') 14 | } else { 15 | return pluralize(~~(between / 86400), ' day') 16 | } 17 | } 18 | 19 | function pluralize (time, label) { 20 | if (time === 1) { 21 | return time + label 22 | } 23 | return time + label + 's' 24 | } 25 | -------------------------------------------------------------------------------- /src/web/util/title.js: -------------------------------------------------------------------------------- 1 | function getTitle (vm) { 2 | const { title } = vm.$options 3 | if (title) { 4 | return typeof title === 'function' 5 | ? title.call(vm) 6 | : title 7 | } 8 | } 9 | 10 | const serverTitleMixin = { 11 | created () { 12 | const title = getTitle(this) 13 | if (title) { 14 | this.$ssrContext.title = `Vue HN 2.0 | ${title}` 15 | } 16 | } 17 | } 18 | 19 | const clientTitleMixin = { 20 | mounted () { 21 | const title = getTitle(this) 22 | if (title) { 23 | document.title = `Vue HN 2.0 | ${title}` 24 | } 25 | } 26 | } 27 | 28 | export default process.env.VUE_ENV === 'server' 29 | ? serverTitleMixin 30 | : clientTitleMixin 31 | --------------------------------------------------------------------------------