Vue SSR Koa2 Scaffold
This commit is contained in:
commit
699297877a
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
|
||||
# 编译后的文件以下两个目录
|
||||
/dist/web
|
||||
/dist/api
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw*
|
7
config/app.js
Normal file
7
config/app.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
app: {
|
||||
port: 3000, // 监听的端口
|
||||
devHost: 'localhost', // 开发环境下打开的地址,监听了0.0.0.0,但是不是所有设备都支持访问这个地址,用127.0.0.1或localhost代替
|
||||
open: true // 是否打开浏览器
|
||||
}
|
||||
}
|
24
config/koa/dev.js
Normal file
24
config/koa/dev.js
Normal file
@ -0,0 +1,24 @@
|
||||
const devMiddleware = require('webpack-dev-middleware')
|
||||
|
||||
module.exports = (compiler, opts) => {
|
||||
const expressMiddleware = devMiddleware(compiler, opts)
|
||||
|
||||
async function middleware (ctx, next) {
|
||||
await expressMiddleware(ctx.req, {
|
||||
end: (content) => {
|
||||
ctx.body = content
|
||||
},
|
||||
setHeader: (name, value) => {
|
||||
ctx.set(name, value)
|
||||
}
|
||||
}, next)
|
||||
}
|
||||
|
||||
middleware.getFilenameFromUrl = expressMiddleware.getFilenameFromUrl
|
||||
middleware.waitUntilValid = expressMiddleware.waitUntilValid
|
||||
middleware.invalidate = expressMiddleware.invalidate
|
||||
middleware.close = expressMiddleware.close
|
||||
middleware.fileSystem = expressMiddleware.fileSystem
|
||||
|
||||
return middleware
|
||||
}
|
31
config/koa/hot.js
Normal file
31
config/koa/hot.js
Normal file
@ -0,0 +1,31 @@
|
||||
// 参考自 https://github.com/ccqgithub/koa-webpack-hot/blob/master/index.js
|
||||
const hotMiddleware = require('webpack-hot-middleware')
|
||||
const PassThrough = require('stream').PassThrough
|
||||
|
||||
module.exports = (compiler, opts = {}) => {
|
||||
opts.path = opts.path || '/__webpack_hmr'
|
||||
|
||||
const middleware = hotMiddleware(compiler, opts)
|
||||
|
||||
return async (ctx, next) => {
|
||||
if (ctx.request.path !== opts.path) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const stream = new PassThrough()
|
||||
ctx.body = stream
|
||||
|
||||
middleware(ctx.req, {
|
||||
write: stream.write.bind(stream),
|
||||
writeHead: (status, headers) => {
|
||||
ctx.status = status
|
||||
Object.keys(headers).forEach(key => {
|
||||
ctx.set(key, headers[key])
|
||||
})
|
||||
},
|
||||
end: () => {
|
||||
stream.end()
|
||||
}
|
||||
}, next)
|
||||
}
|
||||
}
|
74
config/koa/static.js
Normal file
74
config/koa/static.js
Normal file
@ -0,0 +1,74 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* From koa-static
|
||||
*/
|
||||
|
||||
const { resolve } = require('path')
|
||||
const assert = require('assert')
|
||||
const send = require('koa-send')
|
||||
|
||||
/**
|
||||
* Expose `serve()`.
|
||||
*/
|
||||
|
||||
module.exports = serve
|
||||
|
||||
/**
|
||||
* Serve static files from `root`.
|
||||
*
|
||||
* @param {String} root
|
||||
* @param {Object} [opts]
|
||||
* @return {Function}
|
||||
* @api public
|
||||
*/
|
||||
|
||||
function serve (root, opts) {
|
||||
opts = Object.assign({}, opts)
|
||||
|
||||
assert(root, 'root directory is required to serve files')
|
||||
|
||||
// options
|
||||
opts.root = resolve(root)
|
||||
if (opts.index !== false) opts.index = opts.index || 'index.html'
|
||||
|
||||
if (!opts.defer) {
|
||||
return async function serve (ctx, next) {
|
||||
let done = false
|
||||
|
||||
if (ctx.method === 'HEAD' || ctx.method === 'GET') {
|
||||
if (ctx.path === '/' || ctx.path === '/index.html') { // exclude index.html file
|
||||
await next()
|
||||
return
|
||||
}
|
||||
try {
|
||||
done = await send(ctx, ctx.path, opts)
|
||||
} catch (err) {
|
||||
if (err.status !== 404) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!done) {
|
||||
await next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return async function serve (ctx, next) {
|
||||
await next()
|
||||
|
||||
if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return
|
||||
// response is already handled
|
||||
if (ctx.body != null || ctx.status !== 404) return // eslint-disable-line
|
||||
|
||||
try {
|
||||
await send(ctx, ctx.path, opts)
|
||||
} catch (err) {
|
||||
if (err.status !== 404) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
79
config/lib/env.js
Normal file
79
config/lib/env.js
Normal file
@ -0,0 +1,79 @@
|
||||
// From: https://github.com/vuejs/vue-cli/blob/dev/packages/%40vue/cli-shared-utils/lib/env.js
|
||||
const { execSync } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const LRU = require('lru-cache')
|
||||
|
||||
let _hasYarn
|
||||
const _yarnProjects = new LRU({
|
||||
max: 10,
|
||||
maxAge: 1000
|
||||
})
|
||||
let _hasGit
|
||||
const _gitProjects = new LRU({
|
||||
max: 10,
|
||||
maxAge: 1000
|
||||
})
|
||||
|
||||
// env detection
|
||||
exports.hasYarn = () => {
|
||||
if (process.env.VUE_CLI_TEST) {
|
||||
return true
|
||||
}
|
||||
if (_hasYarn != null) {
|
||||
return _hasYarn
|
||||
}
|
||||
try {
|
||||
execSync('yarnpkg --version', { stdio: 'ignore' })
|
||||
return (_hasYarn = true)
|
||||
} catch (e) {
|
||||
return (_hasYarn = false)
|
||||
}
|
||||
}
|
||||
|
||||
exports.hasProjectYarn = (cwd) => {
|
||||
if (_yarnProjects.has(cwd)) {
|
||||
return checkYarn(_yarnProjects.get(cwd))
|
||||
}
|
||||
|
||||
const lockFile = path.join(cwd, 'yarn.lock')
|
||||
const result = fs.existsSync(lockFile)
|
||||
_yarnProjects.set(cwd, result)
|
||||
return checkYarn(result)
|
||||
}
|
||||
|
||||
function checkYarn (result) {
|
||||
if (result && !exports.hasYarn()) throw new Error(`The project seems to require yarn but it's not installed.`)
|
||||
return result
|
||||
}
|
||||
|
||||
exports.hasGit = () => {
|
||||
if (process.env.VUE_CLI_TEST) {
|
||||
return true
|
||||
}
|
||||
if (_hasGit != null) {
|
||||
return _hasGit
|
||||
}
|
||||
try {
|
||||
execSync('git --version', { stdio: 'ignore' })
|
||||
return (_hasGit = true)
|
||||
} catch (e) {
|
||||
return (_hasGit = false)
|
||||
}
|
||||
}
|
||||
|
||||
exports.hasProjectGit = (cwd) => {
|
||||
if (_gitProjects.has(cwd)) {
|
||||
return _gitProjects.get(cwd)
|
||||
}
|
||||
|
||||
let result
|
||||
try {
|
||||
execSync('git status', { stdio: 'ignore', cwd })
|
||||
result = true
|
||||
} catch (e) {
|
||||
result = false
|
||||
}
|
||||
_gitProjects.set(cwd, result)
|
||||
return result
|
||||
}
|
6
config/lib/index.js
Normal file
6
config/lib/index.js
Normal file
@ -0,0 +1,6 @@
|
||||
[
|
||||
'env',
|
||||
'openBrowser'
|
||||
].forEach(m => {
|
||||
Object.assign(exports, require(`./${m}`))
|
||||
})
|
124
config/lib/openBrowser.js
Normal file
124
config/lib/openBrowser.js
Normal file
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* From: https://github.com/vuejs/vue-cli/blob/dev/packages/%40vue/cli-shared-utils/lib/openBrowser.js
|
||||
*
|
||||
* Copyright (c) 2015-present, Facebook, Inc.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file at
|
||||
* https://github.com/facebookincubator/create-react-app/blob/master/LICENSE
|
||||
*/
|
||||
|
||||
const opn = require('opn')
|
||||
const execa = require('execa')
|
||||
const chalk = require('chalk')
|
||||
const execSync = require('child_process').execSync
|
||||
|
||||
// https://github.com/sindresorhus/opn#app
|
||||
const OSX_CHROME = 'google chrome'
|
||||
|
||||
const Actions = Object.freeze({
|
||||
NONE: 0,
|
||||
BROWSER: 1,
|
||||
SCRIPT: 2
|
||||
})
|
||||
|
||||
function getBrowserEnv () {
|
||||
// Attempt to honor this environment variable.
|
||||
// It is specific to the operating system.
|
||||
// See https://github.com/sindresorhus/opn#app for documentation.
|
||||
const value = process.env.BROWSER
|
||||
let action
|
||||
if (!value) {
|
||||
// Default.
|
||||
action = Actions.BROWSER
|
||||
} else if (value.toLowerCase().endsWith('.js')) {
|
||||
action = Actions.SCRIPT
|
||||
} else if (value.toLowerCase() === 'none') {
|
||||
action = Actions.NONE
|
||||
} else {
|
||||
action = Actions.BROWSER
|
||||
}
|
||||
return { action, value }
|
||||
}
|
||||
|
||||
function executeNodeScript (scriptPath, url) {
|
||||
const extraArgs = process.argv.slice(2)
|
||||
const child = execa('node', [scriptPath, ...extraArgs, url], {
|
||||
stdio: 'inherit'
|
||||
})
|
||||
child.on('close', code => {
|
||||
if (code !== 0) {
|
||||
console.log()
|
||||
console.log(
|
||||
chalk.red(
|
||||
'The script specified as BROWSER environment variable failed.'
|
||||
)
|
||||
)
|
||||
console.log(chalk.cyan(scriptPath) + ' exited with code ' + code + '.')
|
||||
console.log()
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function startBrowserProcess (browser, url) {
|
||||
// If we're on OS X, the user hasn't specifically
|
||||
// requested a different browser, we can try opening
|
||||
// Chrome with AppleScript. This lets us reuse an
|
||||
// existing tab when possible instead of creating a new one.
|
||||
const shouldTryOpenChromeWithAppleScript =
|
||||
process.platform === 'darwin' &&
|
||||
(typeof browser !== 'string' || browser === OSX_CHROME)
|
||||
|
||||
if (shouldTryOpenChromeWithAppleScript) {
|
||||
try {
|
||||
// Try our best to reuse existing tab
|
||||
// on OS X Google Chrome with AppleScript
|
||||
execSync('ps cax | grep "Google Chrome"')
|
||||
execSync('osascript openChrome.applescript "' + encodeURI(url) + '"', {
|
||||
cwd: __dirname,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
// Ignore errors.
|
||||
}
|
||||
}
|
||||
|
||||
// Another special case: on OS X, check if BROWSER has been set to "open".
|
||||
// In this case, instead of passing `open` to `opn` (which won't work),
|
||||
// just ignore it (thus ensuring the intended behavior, i.e. opening the system browser):
|
||||
// https://github.com/facebookincubator/create-react-app/pull/1690#issuecomment-283518768
|
||||
if (process.platform === 'darwin' && browser === 'open') {
|
||||
browser = undefined
|
||||
}
|
||||
|
||||
// Fallback to opn
|
||||
// (It will always open new tab)
|
||||
try {
|
||||
var options = { app: browser }
|
||||
opn(url, options).catch(() => {}) // Prevent `unhandledRejection` error.
|
||||
return true
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the BROWSER evironment variable and decides what to do with it. Returns
|
||||
* true if it opened a browser or ran a node.js script, otherwise false.
|
||||
*/
|
||||
exports.openBrowser = function (url) {
|
||||
const { action, value } = getBrowserEnv()
|
||||
switch (action) {
|
||||
case Actions.NONE:
|
||||
// Special case: BROWSER="none" will prevent opening completely.
|
||||
return false
|
||||
case Actions.SCRIPT:
|
||||
return executeNodeScript(value, url)
|
||||
case Actions.BROWSER:
|
||||
return startBrowserProcess(value, url)
|
||||
default:
|
||||
throw new Error('Not implemented.')
|
||||
}
|
||||
}
|
83
config/lib/openChrome.applescript
Normal file
83
config/lib/openChrome.applescript
Normal file
@ -0,0 +1,83 @@
|
||||
(*
|
||||
Copyright (c) 2015-present, Facebook, Inc.
|
||||
This source code is licensed under the MIT license found in the
|
||||
LICENSE file at
|
||||
https://github.com/facebookincubator/create-react-app/blob/master/LICENSE
|
||||
*)
|
||||
|
||||
property targetTab: null
|
||||
property targetTabIndex: -1
|
||||
property targetWindow: null
|
||||
|
||||
on run argv
|
||||
set theURL to item 1 of argv
|
||||
|
||||
tell application "Chrome"
|
||||
|
||||
if (count every window) = 0 then
|
||||
make new window
|
||||
end if
|
||||
|
||||
-- 1: Looking for tab running debugger
|
||||
-- then, Reload debugging tab if found
|
||||
-- then return
|
||||
set found to my lookupTabWithUrl(theURL)
|
||||
if found then
|
||||
set targetWindow's active tab index to targetTabIndex
|
||||
tell targetTab to reload
|
||||
tell targetWindow to activate
|
||||
set index of targetWindow to 1
|
||||
return
|
||||
end if
|
||||
|
||||
-- 2: Looking for Empty tab
|
||||
-- In case debugging tab was not found
|
||||
-- We try to find an empty tab instead
|
||||
set found to my lookupTabWithUrl("chrome://newtab/")
|
||||
if found then
|
||||
set targetWindow's active tab index to targetTabIndex
|
||||
set URL of targetTab to theURL
|
||||
tell targetWindow to activate
|
||||
return
|
||||
end if
|
||||
|
||||
-- 3: Create new tab
|
||||
-- both debugging and empty tab were not found
|
||||
-- make a new tab with url
|
||||
tell window 1
|
||||
activate
|
||||
make new tab with properties {URL:theURL}
|
||||
end tell
|
||||
end tell
|
||||
end run
|
||||
|
||||
-- Function:
|
||||
-- Lookup tab with given url
|
||||
-- if found, store tab, index, and window in properties
|
||||
-- (properties were declared on top of file)
|
||||
on lookupTabWithUrl(lookupUrl)
|
||||
tell application "Chrome"
|
||||
-- Find a tab with the given url
|
||||
set found to false
|
||||
set theTabIndex to -1
|
||||
repeat with theWindow in every window
|
||||
set theTabIndex to 0
|
||||
repeat with theTab in every tab of theWindow
|
||||
set theTabIndex to theTabIndex + 1
|
||||
if (theTab's URL as string) contains lookupUrl then
|
||||
-- assign tab, tab index, and window to properties
|
||||
set targetTab to theTab
|
||||
set targetTabIndex to theTabIndex
|
||||
set targetWindow to theWindow
|
||||
set found to true
|
||||
exit repeat
|
||||
end if
|
||||
end repeat
|
||||
|
||||
if found then
|
||||
exit repeat
|
||||
end if
|
||||
end repeat
|
||||
end tell
|
||||
return found
|
||||
end lookupTabWithUrl
|
26
config/server.js
Normal file
26
config/server.js
Normal file
@ -0,0 +1,26 @@
|
||||
const path = require('path')
|
||||
const Koa = require('koa')
|
||||
const koaCompress = require('koa-compress')
|
||||
const compressible = require('compressible')
|
||||
const koaStatic = require('./koa/static')
|
||||
const SSR = require('./ssr')
|
||||
const conf = require('./app')
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
|
||||
const app = new Koa()
|
||||
|
||||
app.use(koaCompress({ // 压缩数据
|
||||
filter: type => !(/event\-stream/i.test(type)) && compressible(type) // eslint-disable-line
|
||||
}))
|
||||
|
||||
app.use(koaStatic(isProd ? path.resolve(__dirname, '../dist/web') : path.resolve(__dirname, '../public'), {
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000
|
||||
})) // 配置静态资源目录及过期时间
|
||||
|
||||
// vue ssr处理,在SSR中处理API
|
||||
SSR(app).then(server => {
|
||||
server.listen(conf.app.port, '0.0.0.0', () => {
|
||||
console.log(`> server is staring...`)
|
||||
})
|
||||
})
|
197
config/setup-dev-server.js
Normal file
197
config/setup-dev-server.js
Normal file
@ -0,0 +1,197 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const chalk = require('chalk')
|
||||
const MFS = require('memory-fs')
|
||||
const webpack = require('webpack')
|
||||
const chokidar = require('chokidar')
|
||||
const apiConfig = require('./webpack.api.config')
|
||||
const serverConfig = require('./webpack.server.config')
|
||||
const webConfig = require('./webpack.web.config')
|
||||
const webpackDevMiddleware = require('./koa/dev')
|
||||
const webpackHotMiddleware = require('./koa/hot')
|
||||
const readline = require('readline')
|
||||
const conf = require('./app')
|
||||
const {
|
||||
hasProjectYarn,
|
||||
openBrowser
|
||||
} = require('./lib')
|
||||
|
||||
const readFile = (fs, file) => {
|
||||
try {
|
||||
return fs.readFileSync(path.join(webConfig.output.path, file), 'utf-8')
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
module.exports = (app, cb) => {
|
||||
let apiMain, bundle, template, clientManifest, serverTime, webTime, apiTime
|
||||
const apiOutDir = apiConfig.output.path
|
||||
let isFrist = true
|
||||
|
||||
const clearConsole = () => {
|
||||
if (process.stdout.isTTY) {
|
||||
// Fill screen with blank lines. Then move to 0 (beginning of visible part) and clear it
|
||||
const blank = '\n'.repeat(process.stdout.rows)
|
||||
console.log(blank)
|
||||
readline.cursorTo(process.stdout, 0, 0)
|
||||
readline.clearScreenDown(process.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
if (apiMain && bundle && template && clientManifest) {
|
||||
if (isFrist) {
|
||||
const url = 'http://' + conf.app.devHost + ':' + conf.app.port
|
||||
console.log(chalk.bgGreen.black(' DONE ') + ' ' + chalk.green(`Compiled successfully in ${serverTime + webTime + apiTime}ms`))
|
||||
console.log()
|
||||
console.log(` App running at: ${chalk.cyan(url)}`)
|
||||
console.log()
|
||||
const buildCommand = hasProjectYarn(process.cwd()) ? `yarn build` : `npm run build`
|
||||
console.log(` Note that the development build is not optimized.`)
|
||||
console.log(` To create a production build, run ${chalk.cyan(buildCommand)}.`)
|
||||
console.log()
|
||||
if (conf.app.open) openBrowser(url)
|
||||
isFrist = false
|
||||
}
|
||||
cb(bundle, {
|
||||
template,
|
||||
clientManifest
|
||||
}, apiMain, apiOutDir)
|
||||
}
|
||||
}
|
||||
|
||||
// server for api
|
||||
apiConfig.entry.app = ['webpack-hot-middleware/client?path=/__webpack_hmr&timeout=2000&reload=true', apiConfig.entry.app]
|
||||
apiConfig.plugins.push(
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
new webpack.NoEmitOnErrorsPlugin()
|
||||
)
|
||||
const apiCompiler = webpack(apiConfig)
|
||||
const apiMfs = new MFS()
|
||||
apiCompiler.outputFileSystem = apiMfs
|
||||
apiCompiler.watch({}, (err, stats) => {
|
||||
if (err) throw err
|
||||
stats = stats.toJson()
|
||||
if (stats.errors.length) return
|
||||
console.log('api-dev...')
|
||||
apiMfs.readdir(path.join(__dirname, '../dist/api'), function (err, files) {
|
||||
if (err) {
|
||||
return console.error(err)
|
||||
}
|
||||
files.forEach(function (file) {
|
||||
console.info(file)
|
||||
})
|
||||
})
|
||||
apiMain = apiMfs.readFileSync(path.join(apiConfig.output.path, 'api.js'), 'utf-8')
|
||||
update()
|
||||
})
|
||||
apiCompiler.plugin('done', stats => {
|
||||
stats = stats.toJson()
|
||||
stats.errors.forEach(err => console.error(err))
|
||||
stats.warnings.forEach(err => console.warn(err))
|
||||
if (stats.errors.length) return
|
||||
|
||||
apiTime = stats.time
|
||||
// console.log('web-dev')
|
||||
// update()
|
||||
})
|
||||
|
||||
// web server for ssr
|
||||
const serverCompiler = webpack(serverConfig)
|
||||
const mfs = new MFS()
|
||||
serverCompiler.outputFileSystem = mfs
|
||||
serverCompiler.watch({}, (err, stats) => {
|
||||
if (err) throw err
|
||||
stats = stats.toJson()
|
||||
if (stats.errors.length) return
|
||||
// console.log('server-dev...')
|
||||
bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
|
||||
update()
|
||||
})
|
||||
serverCompiler.plugin('done', stats => {
|
||||
stats = stats.toJson()
|
||||
stats.errors.forEach(err => console.error(err))
|
||||
stats.warnings.forEach(err => console.warn(err))
|
||||
if (stats.errors.length) return
|
||||
|
||||
serverTime = stats.time
|
||||
// console.log('web-dev')
|
||||
// update()
|
||||
})
|
||||
|
||||
// web
|
||||
webConfig.entry.app = ['webpack-hot-middleware/client?path=/__webpack_hmr&timeout=2000&reload=true', webConfig.entry.app]
|
||||
webConfig.output.filename = '[name].js'
|
||||
webConfig.plugins.push(
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
new webpack.NoEmitOnErrorsPlugin()
|
||||
)
|
||||
const clientCompiler = webpack(webConfig)
|
||||
const devMiddleware = webpackDevMiddleware(clientCompiler, {
|
||||
// publicPath: webConfig.output.publicPath,
|
||||
stats: { // or 'errors-only'
|
||||
colors: true
|
||||
},
|
||||
reporter: (middlewareOptions, options) => {
|
||||
const { log, state, stats } = options
|
||||
|
||||
if (state) {
|
||||
const displayStats = (middlewareOptions.stats !== false)
|
||||
|
||||
if (displayStats) {
|
||||
if (stats.hasErrors()) {
|
||||
log.error(stats.toString(middlewareOptions.stats))
|
||||
} else if (stats.hasWarnings()) {
|
||||
log.warn(stats.toString(middlewareOptions.stats))
|
||||
} else {
|
||||
log.info(stats.toString(middlewareOptions.stats))
|
||||
}
|
||||
}
|
||||
|
||||
let message = 'Compiled successfully.'
|
||||
|
||||
if (stats.hasErrors()) {
|
||||
message = 'Failed to compile.'
|
||||
} else if (stats.hasWarnings()) {
|
||||
message = 'Compiled with warnings.'
|
||||
}
|
||||
log.info(message)
|
||||
|
||||
clearConsole()
|
||||
|
||||
update()
|
||||
} else {
|
||||
log.info('Compiling...')
|
||||
}
|
||||
},
|
||||
noInfo: true,
|
||||
serverSideRender: false
|
||||
})
|
||||
app.use(devMiddleware)
|
||||
|
||||
const templatePath = path.resolve(__dirname, '../public/index.html')
|
||||
|
||||
// read template from disk and watch
|
||||
template = fs.readFileSync(templatePath, 'utf-8')
|
||||
chokidar.watch(templatePath).on('change', () => {
|
||||
template = fs.readFileSync(templatePath, 'utf-8')
|
||||
console.log('index.html template updated.')
|
||||
update()
|
||||
})
|
||||
|
||||
clientCompiler.plugin('done', stats => {
|
||||
stats = stats.toJson()
|
||||
stats.errors.forEach(err => console.error(err))
|
||||
stats.warnings.forEach(err => console.warn(err))
|
||||
if (stats.errors.length) return
|
||||
|
||||
clientManifest = JSON.parse(readFile(
|
||||
devMiddleware.fileSystem,
|
||||
'vue-ssr-client-manifest.json'
|
||||
))
|
||||
|
||||
webTime = stats.time
|
||||
// console.log('web-dev')
|
||||
// update()
|
||||
})
|
||||
app.use(webpackHotMiddleware(clientCompiler))
|
||||
}
|
96
config/ssr.js
Normal file
96
config/ssr.js
Normal file
@ -0,0 +1,96 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const chalk = require('chalk')
|
||||
const LRU = require('lru-cache')
|
||||
const {
|
||||
createBundleRenderer
|
||||
} = require('vue-server-renderer')
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
const setUpDevServer = require('./setup-dev-server')
|
||||
const HtmlMinifier = require('html-minifier').minify
|
||||
|
||||
const pathResolve = file => path.resolve(__dirname, file)
|
||||
|
||||
module.exports = app => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const createRenderer = (bundle, options) => {
|
||||
return createBundleRenderer(bundle, Object.assign(options, {
|
||||
cache: LRU({
|
||||
max: 1000,
|
||||
maxAge: 1000 * 60 * 15
|
||||
}),
|
||||
basedir: pathResolve('../dist/web'),
|
||||
runInNewContext: false
|
||||
}))
|
||||
}
|
||||
|
||||
let renderer = null
|
||||
if (isProd) {
|
||||
// prod mode
|
||||
const template = HtmlMinifier(fs.readFileSync(pathResolve('../public/index.html'), 'utf-8'), {
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: false
|
||||
})
|
||||
const bundle = require(pathResolve('../dist/web/vue-ssr-server-bundle.json'))
|
||||
const clientManifest = require(pathResolve('../dist/web/vue-ssr-client-manifest.json'))
|
||||
renderer = createRenderer(bundle, {
|
||||
template,
|
||||
clientManifest
|
||||
})
|
||||
const API = require('../dist/api/api').default
|
||||
const server = API(app)
|
||||
resolve(server)
|
||||
} else {
|
||||
// dev mode
|
||||
setUpDevServer(app, (bundle, options, apiMain, apiOutDir) => {
|
||||
try {
|
||||
const API = eval(apiMain).default // eslint-disable-line
|
||||
const server = API(app)
|
||||
renderer = createRenderer(bundle, options)
|
||||
resolve(server)
|
||||
} catch (e) {
|
||||
console.log(chalk.red('\nServer error'), e)
|
||||
}
|
||||
})
|
||||
}
|
||||
app.use(async (ctx, next) => {
|
||||
if (!renderer) {
|
||||
ctx.type = 'html'
|
||||
ctx.body = 'waiting for compilation... refresh in a moment.'
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
let status = 200
|
||||
let html = null
|
||||
const context = {
|
||||
url: ctx.url,
|
||||
title: 'OK'
|
||||
}
|
||||
|
||||
if (/^\/api/.test(ctx.url)) { // 如果请求以/api开头,则进入api部分进行处理。
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
status = 200
|
||||
html = await renderer.renderToString(context)
|
||||
} catch (e) {
|
||||
if (e.message === '404') {
|
||||
status = 404
|
||||
html = '404 | Not Found'
|
||||
} else {
|
||||
status = 500
|
||||
console.log(chalk.red('\nError: '), e.message)
|
||||
html = '500 | Internal Server Error'
|
||||
}
|
||||
}
|
||||
ctx.type = 'html'
|
||||
ctx.status = status || ctx.status
|
||||
ctx.body = html
|
||||
next()
|
||||
})
|
||||
})
|
||||
}
|
58
config/webpack.api.config.js
Normal file
58
config/webpack.api.config.js
Normal file
@ -0,0 +1,58 @@
|
||||
const webpack = require('webpack')
|
||||
const path = require('path')
|
||||
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
|
||||
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')
|
||||
const { dependencies } = require('../package.json')
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
|
||||
module.exports = {
|
||||
name: 'api',
|
||||
target: 'node',
|
||||
devtool: '#cheap-module-source-map',
|
||||
mode: isProd ? 'production' : 'development',
|
||||
entry: path.join(__dirname, '../src/api/app.js'),
|
||||
output: {
|
||||
libraryTarget: 'commonjs2',
|
||||
path: path.resolve(__dirname, '../dist/api'),
|
||||
filename: 'api.js',
|
||||
publicPath: '/'
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.join(__dirname, '../src/web'),
|
||||
'~': path.join(__dirname, '../src/api')
|
||||
},
|
||||
extensions: ['.js']
|
||||
},
|
||||
externals: [
|
||||
...Object.keys(dependencies || {})
|
||||
],
|
||||
module: {
|
||||
rules: [{
|
||||
test: /\.(js)$/,
|
||||
include: [path.resolve(__dirname, '../src/api')],
|
||||
exclude: /(node_modules|bower_components)/
|
||||
// use: [
|
||||
// {
|
||||
// loader: 'babel-loader',
|
||||
// options: {
|
||||
// presets: ['@babel/preset-env']
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// loader: 'eslint-loader'
|
||||
// }
|
||||
// ]
|
||||
}
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new CaseSensitivePathsPlugin(),
|
||||
new FriendlyErrorsPlugin(),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
|
||||
'process.env.API_ENV': '"server"'
|
||||
})
|
||||
]
|
||||
}
|
168
config/webpack.base.config.js
Normal file
168
config/webpack.base.config.js
Normal file
@ -0,0 +1,168 @@
|
||||
const path = require('path')
|
||||
const webpack = require('webpack')
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
|
||||
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
|
||||
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin')
|
||||
const { VueLoaderPlugin } = require('vue-loader')
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
|
||||
module.exports = {
|
||||
mode: isProd ? 'production' : 'development',
|
||||
output: {
|
||||
path: path.resolve(__dirname, '../dist/web'),
|
||||
publicPath: '/',
|
||||
filename: '[name].[chunkhash:8].js',
|
||||
chunkFilename: '[id].js'
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.join(__dirname, '../src/web'),
|
||||
'~': path.join(__dirname, '../src/api'),
|
||||
'vue$': 'vue/dist/vue.esm.js'
|
||||
},
|
||||
extensions: ['.js', '.vue', '.json', '.css']
|
||||
},
|
||||
module: {
|
||||
rules: [{
|
||||
test: /\.(js|jsx)$/,
|
||||
include: [path.resolve(__dirname, '../src/web')],
|
||||
exclude: /(node_modules|bower_components)/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
presets: ['@babel/preset-env'],
|
||||
plugins: [
|
||||
'transform-vue-jsx',
|
||||
'@babel/plugin-syntax-jsx',
|
||||
'@babel/plugin-syntax-dynamic-import'
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
// {
|
||||
// test: /\.(js|jsx|vue)$/,
|
||||
// enforce: 'pre',
|
||||
// exclude: /node_modules/,
|
||||
// use: {
|
||||
// loader: 'eslint-loader'
|
||||
// }
|
||||
// },
|
||||
{
|
||||
test: /\.json$/,
|
||||
use: 'json-loader'
|
||||
},
|
||||
{
|
||||
test: /\.pug$/,
|
||||
use: {
|
||||
loader: 'pug-plain-loader'
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
isProd ? MiniCssExtractPlugin.loader : 'vue-style-loader',
|
||||
'css-loader'
|
||||
]
|
||||
},
|
||||
// {
|
||||
// test: /\.styl(us)?$/,
|
||||
// use: [
|
||||
// isProd ? MiniCssExtractPlugin.loader : 'vue-style-loader',
|
||||
// 'css-loader',
|
||||
// 'stylus-loader'
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// test: /\.less$/,
|
||||
// use: [
|
||||
// isProd ? MiniCssExtractPlugin.loader : 'vue-style-loader',
|
||||
// 'css-loader',
|
||||
// 'less-loader'
|
||||
// ]
|
||||
// },
|
||||
{
|
||||
test: /\.html$/,
|
||||
use: 'vue-html-loader',
|
||||
exclude: /node_modules/
|
||||
},
|
||||
{
|
||||
test: /\.vue$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'vue-loader',
|
||||
options: {
|
||||
preserveWhitespace: false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpe?g|gif|svg|ico)(\?.*)?$/,
|
||||
use: {
|
||||
loader: 'url-loader',
|
||||
query: {
|
||||
limit: 10000,
|
||||
name: 'assets/images/[name].[hash:8].[ext]'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
|
||||
loader: 'url-loader',
|
||||
options: {
|
||||
limit: 10000,
|
||||
name: 'assets/images/[name].[hash:8].[ext]'
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
|
||||
use: {
|
||||
loader: 'url-loader',
|
||||
query: {
|
||||
limit: 10000,
|
||||
name: 'assets/font/[name].[hash:8].[ext]'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
optimization: {
|
||||
splitChunks: {
|
||||
chunks: 'async',
|
||||
minSize: 30000,
|
||||
minChunks: 2,
|
||||
maxAsyncRequests: 5,
|
||||
maxInitialRequests: 3
|
||||
// cacheGroups: {
|
||||
// commons: {
|
||||
// name: 'manifest',
|
||||
// chunks: 'initial',
|
||||
// minChunks: 2
|
||||
// }
|
||||
// }
|
||||
}
|
||||
},
|
||||
performance: {
|
||||
maxEntrypointSize: 400000,
|
||||
hints: isProd ? 'warning' : false
|
||||
},
|
||||
plugins: [
|
||||
new CaseSensitivePathsPlugin(),
|
||||
new CopyWebpackPlugin([{
|
||||
from: path.join(__dirname, '../public'),
|
||||
to: path.join(__dirname, '../dist/web'),
|
||||
ignore: ['.*', 'index.html']
|
||||
}]),
|
||||
new FriendlyErrorsPlugin(),
|
||||
new VueLoaderPlugin(),
|
||||
new webpack.optimize.LimitChunkCountPlugin({
|
||||
maxChunks: 15
|
||||
}),
|
||||
new MiniCssExtractPlugin({
|
||||
filename: isProd ? '[name].[hash].css' : '[name].css',
|
||||
chunkFilename: isProd ? '[id].[hash].css' : '[id].css'
|
||||
})
|
||||
]
|
||||
}
|
29
config/webpack.server.config.js
Normal file
29
config/webpack.server.config.js
Normal file
@ -0,0 +1,29 @@
|
||||
const webpack = require('webpack')
|
||||
const path = require('path')
|
||||
const merge = require('webpack-merge')
|
||||
const nodeExternals = require('webpack-node-externals')
|
||||
const config = require('./webpack.base.config')
|
||||
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
|
||||
const version = ' V ' + require('../package.json').version
|
||||
|
||||
module.exports = merge(config, {
|
||||
name: 'server',
|
||||
target: 'node',
|
||||
devtool: '#cheap-module-source-map',
|
||||
mode: 'production',
|
||||
entry: path.join(__dirname, '../src/web/entry-server.js'),
|
||||
output: {
|
||||
libraryTarget: 'commonjs2'
|
||||
},
|
||||
externals: nodeExternals({
|
||||
whitelist: [/\.vue$/, /\.css$/, /\.styl(us)$/, /\.pug$/]
|
||||
}),
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
|
||||
'process.env.VUE_ENV': '"server"',
|
||||
'process.env.BM_VERSION': "'" + version + "'"
|
||||
}),
|
||||
new VueSSRServerPlugin()
|
||||
]
|
||||
})
|
26
config/webpack.web.config.js
Normal file
26
config/webpack.web.config.js
Normal file
@ -0,0 +1,26 @@
|
||||
const webpack = require('webpack')
|
||||
const merge = require('webpack-merge')
|
||||
const path = require('path')
|
||||
const base = require('./webpack.base.config')
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
|
||||
const version = ' V ' + require('../package.json').version
|
||||
|
||||
console.log(version)
|
||||
|
||||
module.exports = merge(base, {
|
||||
name: 'web',
|
||||
devtool: '#eval-source-map',
|
||||
entry: {
|
||||
app: path.resolve(__dirname, '../src/web/entry-client.js')
|
||||
},
|
||||
mode: isProd ? 'production' : 'development',
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
|
||||
'process.env.VUE_ENV': '"client"',
|
||||
'process.env.BM_VERSION': "'" + version + "'"
|
||||
}),
|
||||
new VueSSRClientPlugin()
|
||||
]
|
||||
})
|
68
package.json
Normal file
68
package.json
Normal file
@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "vue-ssr-koa2-scaffold",
|
||||
"version": "0.0.1",
|
||||
"description": "Vue SSR Koa2 Scaffold",
|
||||
"main": "src/app.js",
|
||||
"repository": "https://github.com/yi-ge/Vue-SSR-Koa2-Scaffold",
|
||||
"author": "yige <a@wyr.me>",
|
||||
"license": "MIT",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"serve": "cross-env NODE_ENV=development node config/server.js",
|
||||
"start": "cross-env NODE_ENV=production node config/server.js",
|
||||
"build": "rimraf dist && npm run build:web && npm run build:server && npm run build:api",
|
||||
"build:web": "cross-env NODE_ENV=production webpack --config config/webpack.web.config.js --progress --hide-modules",
|
||||
"build:server": "cross-env NODE_ENV=production webpack --config config/webpack.server.config.js --progress --hide-modules",
|
||||
"build:api": "cross-env NODE_ENV=production webpack --config config/webpack.api.config.js --progress --hide-modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.18.0",
|
||||
"chokidar": "^2.0.4",
|
||||
"cross-env": "^5.2.0",
|
||||
"html-minifier": "^3.5.21",
|
||||
"koa": "^2.6.2",
|
||||
"koa-body": "^4.0.4",
|
||||
"koa-compress": "^3.0.0",
|
||||
"koa-send": "^5.0.0",
|
||||
"lru-cache": "^4.1.3",
|
||||
"memory-fs": "^0.4.1",
|
||||
"readline": "^1.3.0",
|
||||
"vue": "^2.5.17",
|
||||
"vue-router": "^3.0.1",
|
||||
"vue-server-renderer": "^2.5.17",
|
||||
"vue-template-compiler": "^2.5.17",
|
||||
"vuex": "^3.0.1",
|
||||
"vuex-router-sync": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.1.5",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
|
||||
"@babel/plugin-syntax-jsx": "^7.0.0",
|
||||
"@babel/polyfill": "^7.0.0",
|
||||
"@babel/preset-env": "^7.1.5",
|
||||
"babel-helper-vue-jsx-merge-props": "^2.0.3",
|
||||
"babel-loader": "^8.0.4",
|
||||
"babel-plugin-syntax-jsx": "^6.18.0",
|
||||
"babel-plugin-transform-vue-jsx": "^3.7.0",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.1.2",
|
||||
"chalk": "^2.4.1",
|
||||
"copy-webpack-plugin": "^4.6.0",
|
||||
"css-loader": "^1.0.1",
|
||||
"execa": "^1.0.0",
|
||||
"file-loader": "^2.0.0",
|
||||
"friendly-errors-webpack-plugin": "^1.7.0",
|
||||
"json-loader": "^0.5.7",
|
||||
"mini-css-extract-plugin": "^0.4.4",
|
||||
"opn": "^5.4.0",
|
||||
"url-loader": "^1.1.2",
|
||||
"vue-html-loader": "^1.2.4",
|
||||
"vue-loader": "^15.4.2",
|
||||
"vue-style-loader": "^4.1.2",
|
||||
"webpack": "^4.25.1",
|
||||
"webpack-cli": "^3.1.2",
|
||||
"webpack-dev-middleware": "^3.4.0",
|
||||
"webpack-hot-middleware": "^2.24.3",
|
||||
"webpack-merge": "^4.1.4",
|
||||
"webpack-node-externals": "^1.7.2"
|
||||
}
|
||||
}
|
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
27
public/index.html
Normal file
27
public/index.html
Normal file
@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-cn">
|
||||
<head>
|
||||
<title>{{ title }}</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<meta name="description" content="网站描述"/>
|
||||
<meta name="renderer" content="webkit"/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1"/>
|
||||
<meta http-equiv="cache-control" content="max-age=0"/>
|
||||
<meta http-equiv="cache-control" content="no-cache"/>
|
||||
<meta http-equiv="pragma" content="no-cache"/>
|
||||
<meta http-equiv="expires" content="0"/>
|
||||
<meta name="format-detection" content="telephone=no"/>
|
||||
<meta name="format-detection" content="address=no"/>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<style>
|
||||
#skip a { position:absolute; left:-10000px; top:auto; width:1px; height:1px; overflow:hidden; }
|
||||
#skip a:focus { position:static; width:auto; height:auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="skip"><a href="#app">skip to content</a></div>
|
||||
<!--vue-ssr-outlet-->
|
||||
</body>
|
||||
</html>
|
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
61
src/api/app.js
Normal file
61
src/api/app.js
Normal file
@ -0,0 +1,61 @@
|
||||
import KoaBody from 'koa-body'
|
||||
const env = process.env.NODE_ENV || 'development' // Current mode
|
||||
|
||||
export default app => {
|
||||
app.proxy = true
|
||||
|
||||
const server = require('http').createServer(app.callback())
|
||||
// const io = require('socket.io')(server)
|
||||
|
||||
// io.on('connection', function (socket) {
|
||||
// console.log('a user connected: ' + socket.id)
|
||||
// socket.on('disconnect', function () {
|
||||
// console.log('user disconnected:' + socket.id + '-' + socket.code)
|
||||
// redisClient.del(socket.code)
|
||||
// })
|
||||
// })
|
||||
|
||||
app
|
||||
// .use((ctx, next) => {
|
||||
// ctx.io = io
|
||||
// return next()
|
||||
// })
|
||||
.use((ctx, next) => { // 跨域处理
|
||||
ctx.set('Access-Control-Allow-Origin', '*')
|
||||
ctx.set('Access-Control-Allow-Headers', 'Authorization, DNT, User-Agent, Keep-Alive, Origin, X-Requested-With, Content-Type, Accept, x-clientid')
|
||||
ctx.set('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS')
|
||||
if (ctx.method === 'OPTIONS') {
|
||||
ctx.status = 200
|
||||
ctx.body = ''
|
||||
}
|
||||
return next()
|
||||
})
|
||||
.use(KoaBody({
|
||||
multipart: true, // 开启对multipart/form-data的支持
|
||||
strict: false, // 取消严格模式,parse GET, HEAD, DELETE requests
|
||||
formidable: { // 设置上传参数
|
||||
// uploadDir: path.join(__dirname, '../assets/uploads/tmpfile')
|
||||
},
|
||||
jsonLimit: '10mb', // application/json 限制,default 1mb 1mb
|
||||
formLimit: '10mb', // multipart/form-data 限制,default 56kb
|
||||
textLimit: '10mb' // application/x-www-urlencoded 限制,default 56kb
|
||||
}))
|
||||
.use((ctx, next) => {
|
||||
if (/^\/api/.test(ctx.url)) {
|
||||
ctx.body = 'World' // 测试用
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
if (env === 'development') { // logger
|
||||
app.use((ctx, next) => {
|
||||
const start = new Date()
|
||||
return next().then(() => {
|
||||
const ms = new Date() - start
|
||||
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
6
src/web/App.vue
Normal file
6
src/web/App.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
39
src/web/app.js
Normal file
39
src/web/app.js
Normal file
@ -0,0 +1,39 @@
|
||||
import '@babel/polyfill'
|
||||
import Vue from 'vue'
|
||||
import App from './App.vue'
|
||||
import { createStore } from './store'
|
||||
import { createRouter } from './router'
|
||||
import { sync } from 'vuex-router-sync'
|
||||
import titleMixin from './util/title'
|
||||
import * as filters from './util/filters'
|
||||
import axios from 'axios'
|
||||
import conf from '../../config/app'
|
||||
|
||||
Vue.prototype.$request = axios.create({
|
||||
baseURL: 'http://' + conf.app.devHost + ':' + conf.app.port,
|
||||
timeout: 1000
|
||||
})
|
||||
Vue.prototype.$isProd = process.env.NODE_ENV === 'production'
|
||||
|
||||
Vue.mixin(titleMixin)
|
||||
|
||||
Object.keys(filters).forEach(key => {
|
||||
Vue.filter(key, filters[key])
|
||||
})
|
||||
|
||||
export function createApp () {
|
||||
const store = createStore()
|
||||
const router = createRouter()
|
||||
|
||||
// sync the router with the vuex store.
|
||||
// this registers `store.state.route`
|
||||
sync(store, router)
|
||||
|
||||
const app = new Vue({
|
||||
router,
|
||||
store,
|
||||
render: h => h(App)
|
||||
})
|
||||
|
||||
return { app, router, store }
|
||||
}
|
103
src/web/components/ProgressBar.vue
Normal file
103
src/web/components/ProgressBar.vue
Normal file
@ -0,0 +1,103 @@
|
||||
<!-- borrowed from Nuxt! -->
|
||||
|
||||
<template>
|
||||
<div class="progress"
|
||||
:style="{
|
||||
'width': percent+'%',
|
||||
'height': height,
|
||||
'background-color': canSuccess? color : failedColor,
|
||||
'opacity': show ? 1 : 0
|
||||
}"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
percent: 0,
|
||||
show: false,
|
||||
canSuccess: true,
|
||||
duration: 3000,
|
||||
height: '2px',
|
||||
color: '#ffca2b',
|
||||
failedColor: '#ff0000'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
start () {
|
||||
this.show = true
|
||||
this.canSuccess = true
|
||||
if (this._timer) {
|
||||
clearInterval(this._timer)
|
||||
this.percent = 0
|
||||
}
|
||||
this._cut = 10000 / Math.floor(this.duration)
|
||||
this._timer = setInterval(() => {
|
||||
this.increase(this._cut * Math.random())
|
||||
if (this.percent > 95) {
|
||||
this.finish()
|
||||
}
|
||||
}, 100)
|
||||
return this
|
||||
},
|
||||
set (num) {
|
||||
this.show = true
|
||||
this.canSuccess = true
|
||||
this.percent = Math.floor(num)
|
||||
return this
|
||||
},
|
||||
get () {
|
||||
return Math.floor(this.percent)
|
||||
},
|
||||
increase (num) {
|
||||
this.percent = this.percent + Math.floor(num)
|
||||
return this
|
||||
},
|
||||
decrease (num) {
|
||||
this.percent = this.percent - Math.floor(num)
|
||||
return this
|
||||
},
|
||||
finish () {
|
||||
this.percent = 100
|
||||
this.hide()
|
||||
return this
|
||||
},
|
||||
pause () {
|
||||
clearInterval(this._timer)
|
||||
return this
|
||||
},
|
||||
hide () {
|
||||
clearInterval(this._timer)
|
||||
this._timer = null
|
||||
setTimeout(() => {
|
||||
this.show = false
|
||||
this.$nextTick(() => {
|
||||
setTimeout(() => {
|
||||
this.percent = 0
|
||||
}, 200)
|
||||
})
|
||||
}, 500)
|
||||
return this
|
||||
},
|
||||
fail () {
|
||||
this.canSuccess = false
|
||||
return this
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.progress {
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
height: 2px;
|
||||
width: 0%;
|
||||
transition: width 0.2s, opacity 0.4s;
|
||||
opacity: 1;
|
||||
background-color: #efc14e;
|
||||
z-index: 999999;
|
||||
}
|
||||
</style>
|
62
src/web/entry-client.js
Normal file
62
src/web/entry-client.js
Normal file
@ -0,0 +1,62 @@
|
||||
import Vue from 'vue'
|
||||
import { createApp } from './app'
|
||||
import ProgressBar from './components/ProgressBar.vue'
|
||||
|
||||
// global progress bar
|
||||
const bar = Vue.prototype.$bar = new Vue(ProgressBar).$mount()
|
||||
document.body.appendChild(bar.$el)
|
||||
|
||||
// a global mixin that calls `asyncData` when a route component's params change
|
||||
Vue.mixin({
|
||||
beforeRouteUpdate (to, from, next) {
|
||||
const { asyncData } = this.$options
|
||||
if (asyncData) {
|
||||
asyncData({
|
||||
store: this.$store,
|
||||
route: to
|
||||
}).then(next).catch(next)
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { app, router, store } = createApp()
|
||||
|
||||
// prime the store with server-initialized state.
|
||||
// the state is determined during SSR and inlined in the page markup.
|
||||
if (window.__INITIAL_STATE__) {
|
||||
store.replaceState(window.__INITIAL_STATE__)
|
||||
}
|
||||
|
||||
// wait until router has resolved all async before hooks
|
||||
// and async components...
|
||||
router.onReady(() => {
|
||||
// Add router hook for handling asyncData.
|
||||
// Doing it after initial route is resolved so that we don't double-fetch
|
||||
// the data that we already have. Using router.beforeResolve() so that all
|
||||
// async components are resolved.
|
||||
router.beforeResolve((to, from, next) => {
|
||||
const matched = router.getMatchedComponents(to)
|
||||
const prevMatched = router.getMatchedComponents(from)
|
||||
let diffed = false
|
||||
const activated = matched.filter((c, i) => {
|
||||
return diffed || (diffed = (prevMatched[i] !== c))
|
||||
})
|
||||
const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
|
||||
if (!asyncDataHooks.length) {
|
||||
return next()
|
||||
}
|
||||
|
||||
bar.start()
|
||||
Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
|
||||
.then(() => {
|
||||
bar.finish()
|
||||
next()
|
||||
})
|
||||
.catch(next)
|
||||
})
|
||||
|
||||
// actually mount to DOM
|
||||
app.$mount('#app')
|
||||
})
|
54
src/web/entry-server.js
Normal file
54
src/web/entry-server.js
Normal file
@ -0,0 +1,54 @@
|
||||
import { createApp } from './app'
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production'
|
||||
|
||||
// This exported function will be called by `bundleRenderer`.
|
||||
// This is where we perform data-prefetching to determine the
|
||||
// state of our application before actually rendering it.
|
||||
// Since data fetching is async, this function is expected to
|
||||
// return a Promise that resolves to the app instance.
|
||||
export default context => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = isDev && Date.now()
|
||||
const { app, router, store } = createApp()
|
||||
|
||||
const { url } = context
|
||||
const { fullPath } = router.resolve(url).route
|
||||
|
||||
if (fullPath !== url) {
|
||||
// console.log(fullPath)
|
||||
// console.log(url)
|
||||
return reject(new Error(fullPath))
|
||||
}
|
||||
|
||||
// set router's location
|
||||
router.push(url)
|
||||
|
||||
// wait until router has resolved possible async hooks
|
||||
router.onReady(() => {
|
||||
const matchedComponents = router.getMatchedComponents()
|
||||
// no matched routes
|
||||
if (!matchedComponents.length) {
|
||||
return reject(new Error('404'))
|
||||
}
|
||||
// Call fetchData hooks on components matched by the route.
|
||||
// A preFetch hook dispatches a store action and returns a Promise,
|
||||
// which is resolved when the action is complete and store state has been
|
||||
// updated.
|
||||
Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
|
||||
store,
|
||||
route: router.currentRoute
|
||||
}))).then(() => {
|
||||
isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
|
||||
// After all preFetch hooks are resolved, our store is now
|
||||
// filled with the state needed to render the app.
|
||||
// Expose the state on the render context, and let the request handler
|
||||
// inline the state in the HTML response. This allows the client-side
|
||||
// store to pick-up the server-side state without having to duplicate
|
||||
// the initial data fetching on the client.
|
||||
context.state = store.state
|
||||
resolve(app)
|
||||
}).catch(reject)
|
||||
}, reject)
|
||||
})
|
||||
}
|
155
src/web/libs/util.js
Normal file
155
src/web/libs/util.js
Normal file
@ -0,0 +1,155 @@
|
||||
// 获取描述对象的值
|
||||
export const getObjectValue = (obj, des) => {
|
||||
return eval('obj.' + des) // eslint-disable-line
|
||||
}
|
||||
|
||||
/**
|
||||
* 对象比较器
|
||||
* 使用方法:data.sort(compare("对象名称")) 在对象内部排序,不生成副本
|
||||
* @param {[type]} propertyName 要排序的对象的子名称(限一级)
|
||||
* @return {[type]} 排序规则
|
||||
*/
|
||||
export const compareObject = propertyName => {
|
||||
return function (object1, object2) {
|
||||
var value1 = getObjectValue(object1, propertyName)
|
||||
var value2 = getObjectValue(object2, propertyName)
|
||||
if (value2 < value1) {
|
||||
return -1
|
||||
} else if (value2 > value1) {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据含义字符串换算对应的毫秒数
|
||||
* @param {[type]} str 字符串
|
||||
* @return {[type]} ms
|
||||
*/
|
||||
let getsec = function (str) {
|
||||
if (/[s|h|d|l]/i.test(str)) {
|
||||
var str1 = str.substring(0, str.length - 1)
|
||||
var str2 = str.substring(str.length - 1, str.length)
|
||||
if (str2 === 's') {
|
||||
return str1 * 1000
|
||||
} else if (str2 === 'h') {
|
||||
return str1 * 60 * 60 * 1000
|
||||
} else if (str2 === 'd') {
|
||||
return str1 * 24 * 60 * 60 * 1000
|
||||
}
|
||||
} else {
|
||||
if (str.indexOf('l') === -1) {
|
||||
return str * 1000
|
||||
} else {
|
||||
return 30 * 24 * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 写 cookies
|
||||
export const setCookie = function setCookie (name, value, time) {
|
||||
if (time) {
|
||||
let strsec = getsec(time)
|
||||
let exp = new Date()
|
||||
exp.setTime(exp.getTime() + parseInt(strsec))
|
||||
document.cookie =
|
||||
name + '=' + escape(value) + ';expires=' + exp.toGMTString()
|
||||
} else {
|
||||
document.cookie = name + '=' + escape(value)
|
||||
}
|
||||
}
|
||||
|
||||
// 读 cookies
|
||||
export const getCookie = function (name) {
|
||||
let reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)')
|
||||
let arr = document.cookie.match(reg)
|
||||
return arr ? unescape(arr[2]) : null
|
||||
}
|
||||
|
||||
// 删 cookies
|
||||
export const delCookie = function (name) {
|
||||
var exp = new Date()
|
||||
exp.setTime(exp.getTime() - 1)
|
||||
var cval = getCookie(name)
|
||||
if (cval != null) {
|
||||
document.cookie = name + '=' + cval + ';expires=' + exp.toGMTString()
|
||||
}
|
||||
}
|
||||
|
||||
// 获取Token
|
||||
export const getToken = function () {
|
||||
if (window.localStorage) {
|
||||
return window.localStorage.getItem('token')
|
||||
}
|
||||
}
|
||||
|
||||
// 设置Token
|
||||
export const setToken = function (token) {
|
||||
if (window.localStorage) {
|
||||
window.localStorage.setItem('token', token)
|
||||
} else if (window.localStorage) {
|
||||
window.localStorage.setItem('token', token)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除Token
|
||||
export const delToken = function () {
|
||||
if (window.localStorage) {
|
||||
window.localStorage.removeItem('token')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据host返回根域名
|
||||
* @param {[string]} host [window.location.host]
|
||||
* @return {[string]} [如果不是域名则返回IP]
|
||||
*/
|
||||
export const getDomain = host => {
|
||||
host = host.split(':')[0]
|
||||
return isNaN(host.substring(host.lastIndexOf('.')))
|
||||
? host.substring(
|
||||
host.substring(0, host.lastIndexOf('.')).lastIndexOf('.') + 1
|
||||
)
|
||||
: host
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断对象是否为空
|
||||
* @param {[type]} e [对象]
|
||||
* @return {Boolean} [bool]
|
||||
*/
|
||||
export const isEmptyObject = e => {
|
||||
for (let t in e) {
|
||||
return !1
|
||||
}
|
||||
return !0
|
||||
}
|
||||
|
||||
/*
|
||||
* 版本号比较方法
|
||||
* 传入两个字符串,当前版本号:curV;比较版本号:reqV
|
||||
* 调用方法举例:compare("1.1","1.2"),将返回false
|
||||
*/
|
||||
export const compareVersion = (curV, reqV) => {
|
||||
if (curV && reqV) {
|
||||
// 将两个版本号拆成数字
|
||||
let arr1 = curV.split('.')
|
||||
let arr2 = reqV.split('.')
|
||||
var minLength = Math.min(arr1.length, arr2.length)
|
||||
let position = 0
|
||||
let diff = 0
|
||||
// 依次比较版本号每一位大小,当对比得出结果后跳出循环(后文有简单介绍)
|
||||
while (position < minLength && ((diff = parseInt(arr1[position]) - parseInt(arr2[position])) === 0)) {
|
||||
position++
|
||||
}
|
||||
diff = (diff !== 0) ? diff : (arr1.length - arr2.length)
|
||||
// 若curV大于reqV,则返回true
|
||||
return diff > 0
|
||||
} else {
|
||||
// 输入为空
|
||||
console.log('版本号不能为空')
|
||||
return false
|
||||
}
|
||||
}
|
26
src/web/page/home.vue
Normal file
26
src/web/page/home.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div>Hello {{ world }} {{ text }}</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
text: null
|
||||
}
|
||||
},
|
||||
asyncData ({ store, route }) {
|
||||
// 触发 action 后,会返回 Promise
|
||||
return store.dispatch('api/fetchVal', route.params.id)
|
||||
},
|
||||
computed: {
|
||||
// 从 store 的 state 对象中的获取从API拿到的数据
|
||||
world () {
|
||||
return this.$store.state.api.text
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.text = 'SSR'
|
||||
}
|
||||
}
|
||||
</script>
|
28
src/web/router/admin.js
Normal file
28
src/web/router/admin.js
Normal file
@ -0,0 +1,28 @@
|
||||
import AuthGuard from '@/router/authGuard'
|
||||
|
||||
import adminLayout from '@/layout/admin'
|
||||
|
||||
import AdminHome from '@/page/admin/home'
|
||||
import Login from '@/page/admin/login'
|
||||
|
||||
export default {
|
||||
path: '/admin',
|
||||
component: adminLayout,
|
||||
beforeEnter: AuthGuard,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: '/admin/home'
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
name: 'AdminHome',
|
||||
component: AdminHome
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
name: 'Login',
|
||||
component: Login
|
||||
}
|
||||
]
|
||||
}
|
21
src/web/router/index.js
Normal file
21
src/web/router/index.js
Normal file
@ -0,0 +1,21 @@
|
||||
import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
|
||||
import home from '@/page/home'
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
export function createRouter () {
|
||||
return new Router({
|
||||
mode: 'history',
|
||||
fallback: false,
|
||||
scrollBehavior: () => ({ y: 0 }),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: home
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
21
src/web/store/index.js
Normal file
21
src/web/store/index.js
Normal file
@ -0,0 +1,21 @@
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import modules from './modules'
|
||||
import axios from 'axios'
|
||||
import conf from '../../../config/app'
|
||||
|
||||
Vuex.Store.prototype.$request = axios.create({
|
||||
baseURL: 'http://' + conf.app.devHost + ':' + conf.app.port,
|
||||
timeout: 1000
|
||||
})
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
const debug = process.env.NODE_ENV !== 'production'
|
||||
|
||||
export function createStore () {
|
||||
return new Vuex.Store({
|
||||
modules,
|
||||
strict: debug
|
||||
})
|
||||
}
|
32
src/web/store/modules/api.js
Normal file
32
src/web/store/modules/api.js
Normal file
@ -0,0 +1,32 @@
|
||||
// initial state
|
||||
const state = {
|
||||
text: ''
|
||||
}
|
||||
|
||||
// getters
|
||||
const getters = {
|
||||
|
||||
}
|
||||
|
||||
// actions
|
||||
const actions = {
|
||||
async fetchVal ({ commit }) {
|
||||
const { data } = await this.$request.get('/api')
|
||||
commit('setVal', data)
|
||||
}
|
||||
}
|
||||
|
||||
// mutations
|
||||
const mutations = {
|
||||
setVal (state, val) {
|
||||
state.text = val
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations
|
||||
}
|
14
src/web/store/modules/index.js
Normal file
14
src/web/store/modules/index.js
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* The file enables `@/store/index.js` to import all vuex modules
|
||||
* in a one-shot manner. There should not be any reason to edit this file.
|
||||
*/
|
||||
|
||||
const files = require.context('.', false, /\.js$/)
|
||||
const modules = {}
|
||||
|
||||
files.keys().forEach(key => {
|
||||
if (key === './index.js') return
|
||||
modules[key.replace(/(\.\/|\.js)/g, '')] = files(key).default
|
||||
})
|
||||
|
||||
export default modules
|
66
src/web/store/util.js
Normal file
66
src/web/store/util.js
Normal file
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Get the first item that pass the test
|
||||
* by second argument function
|
||||
*
|
||||
* @param {Array} list
|
||||
* @param {Function} f
|
||||
* @return {*}
|
||||
*/
|
||||
export function find (list, f) {
|
||||
return list.filter(f)[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep copy the given object considering circular structure.
|
||||
* This function caches all nested objects and its copies.
|
||||
* If it detects circular structure, use cached copy to avoid infinite loop.
|
||||
*
|
||||
* @param {*} obj
|
||||
* @param {Array<Object>} cache
|
||||
* @return {*}
|
||||
*/
|
||||
export function deepCopy (obj, cache = []) {
|
||||
// just return if obj is immutable value
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
// if obj is hit, it is in circular structure
|
||||
const hit = find(cache, c => c.original === obj)
|
||||
if (hit) {
|
||||
return hit.copy
|
||||
}
|
||||
|
||||
const copy = Array.isArray(obj) ? [] : {}
|
||||
// put the copy into cache at first
|
||||
// because we want to refer it in recursive deepCopy
|
||||
cache.push({
|
||||
original: obj,
|
||||
copy
|
||||
})
|
||||
|
||||
Object.keys(obj).forEach(key => {
|
||||
copy[key] = deepCopy(obj[key], cache)
|
||||
})
|
||||
|
||||
return copy
|
||||
}
|
||||
|
||||
/**
|
||||
* forEach for object
|
||||
*/
|
||||
export function forEachValue (obj, fn) {
|
||||
Object.keys(obj).forEach(key => fn(obj[key], key))
|
||||
}
|
||||
|
||||
export function isObject (obj) {
|
||||
return obj !== null && typeof obj === 'object'
|
||||
}
|
||||
|
||||
export function isPromise (val) {
|
||||
return val && typeof val.then === 'function'
|
||||
}
|
||||
|
||||
export function assert (condition, msg) {
|
||||
if (!condition) throw new Error(`[vuex] ${msg}`)
|
||||
}
|
24
src/web/util/filters.js
Normal file
24
src/web/util/filters.js
Normal file
@ -0,0 +1,24 @@
|
||||
export function host (url) {
|
||||
const host = url.replace(/^https?:\/\//, '').replace(/\/.*$/, '')
|
||||
const parts = host.split('.').slice(-3)
|
||||
if (parts[0] === 'www') parts.shift()
|
||||
return parts.join('.')
|
||||
}
|
||||
|
||||
export function timeAgo (time) {
|
||||
const between = Date.now() / 1000 - Number(time)
|
||||
if (between < 3600) {
|
||||
return pluralize(~~(between / 60), ' minute')
|
||||
} else if (between < 86400) {
|
||||
return pluralize(~~(between / 3600), ' hour')
|
||||
} else {
|
||||
return pluralize(~~(between / 86400), ' day')
|
||||
}
|
||||
}
|
||||
|
||||
function pluralize (time, label) {
|
||||
if (time === 1) {
|
||||
return time + label
|
||||
}
|
||||
return time + label + 's'
|
||||
}
|
30
src/web/util/title.js
Normal file
30
src/web/util/title.js
Normal file
@ -0,0 +1,30 @@
|
||||
function getTitle (vm) {
|
||||
const { title } = vm.$options
|
||||
if (title) {
|
||||
return typeof title === 'function'
|
||||
? title.call(vm)
|
||||
: title
|
||||
}
|
||||
}
|
||||
|
||||
const serverTitleMixin = {
|
||||
created () {
|
||||
const title = getTitle(this)
|
||||
if (title) {
|
||||
this.$ssrContext.title = `Vue HN 2.0 | ${title}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clientTitleMixin = {
|
||||
mounted () {
|
||||
const title = getTitle(this)
|
||||
if (title) {
|
||||
document.title = `Vue HN 2.0 | ${title}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default process.env.VUE_ENV === 'server'
|
||||
? serverTitleMixin
|
||||
: clientTitleMixin
|
Loading…
Reference in New Issue
Block a user