正如Vue官方所說,SSR配置適合已經熟悉 Vue, webpack 和 Node.js 開發的開發者閱讀。請先移步 ssr.vuejs.org 瞭解手工進行SSR配置的基本內容。
從頭搭建一個服務端渲染的應用是相當複雜的。如果您有SSR需求,對Webpack及Koa不是很熟悉,請直接使用NUXT.js。
本文所述內容示例在 Vue SSR Koa2 腳手架
:github.com/yi-ge/Vue-S…
我們以撰寫本文時的最新版:Vue 2,Webpack 4,Koa 2為例。
特別說明
此文描述的是API與WEB同在一個專案的情況下進行的配置,且API、SSR Server、Static均使用了同一個Koa示例,目的是闡述配置方法,所有的報錯顯示在一個終端,方便除錯。
初始化專案
git init
yarn init
touch .gitignore
複製程式碼
在.gitignore
檔案,將常見的目錄放於其中。
.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*
複製程式碼
根據經驗來預先新增肯定會用到的依賴項:
echo "yarn add cross-env # 跨平臺的環境變數設定工具
koa
koa-body # 可選,推薦
koa-compress # 壓縮資料
compressible # https://github.com/jshttp/compressible
axios # 此專案作為API請求工具
es6-promise
vue
vue-router # vue 路由 注意,SSR必選
vuex # 可選,但推薦使用,本文基於此做Vuex在SSR的優化
vue-template-compiler
vue-server-renderer # 關鍵
lru-cache # 配合上面一個外掛快取資料
vuex-router-sync" | sed `s/#[[:space:]].*//g` | tr `
` ` ` | sed `s/[ ][ ]*/ /g` | bash
echo "yarn add -D webpack
webpack-cli
webpack-dev-middleware # 關鍵
webpack-hot-middleware # 關鍵
webpack-merge # 合併多個Webpack配置檔案的配置
webpack-node-externals # 不打包node_modules裡面的模組
friendly-errors-webpack-plugin # 顯示友好的錯誤提示外掛
case-sensitive-paths-webpack-plugin # 無視路徑大小寫外掛
copy-webpack-plugin # 用於拷貝檔案的Webpack外掛
mini-css-extract-plugin # CSS壓縮外掛
chalk # console著色
@babel/core # 不解釋
babel-loader
@babel/plugin-syntax-dynamic-import # 支援動態import
@babel/plugin-syntax-jsx # 相容JSX寫法
babel-plugin-syntax-jsx # 不重複,必須的
babel-plugin-transform-vue-jsx
babel-helper-vue-jsx-merge-props
@babel/polyfill
@babel/preset-env
file-loader
json-loader
url-loader
css-loader
vue-loader
vue-style-loader
vue-html-loader" | sed `s/#[[:space:]].*//g` | tr `
` ` ` | sed `s/[ ][ ]*/ /g` | bash
複製程式碼
現在的npm模組命名越來越語義化,基本上都是見名知意。關於Eslint以及Stylus、Less等CSS預處理模組我沒有新增,其不是本文研究的重點,況且既然您在閱讀本文,這些配置相信早已不在話下了。
效仿electorn
分離main及renderer,在src
中建立api
及web
目錄。效仿vue-cli
,在根目錄下建立public
目錄用於存放根目錄下的靜態資原始檔。
|-- public # 靜態資源
|-- src
|-- api # 後端程式碼
|-- web # 前端程式碼
複製程式碼
譬如NUXT.js
,前端伺服器代理API進行後端渲染,我們的配置可以選擇進行一層代理,也可以配置減少這層代理,直接返回渲染結果。通常來說,SSR的伺服器端渲染只渲染首屏,因此API伺服器最好和前端伺服器在同一個內網。
配置package.json
的scripts
:
"scripts": {
"serve": "cross-env NODE_ENV=development node config/server.js",
"start": "cross-env NODE_ENV=production node config/server.js"
}
複製程式碼
yarn serve
: 啟動開發除錯
yarn start
: 執行編譯後的程式
config/app.js
匯出一些常見配置:
module.exports = {
app: {
port: 3000, // 監聽的埠
devHost: `localhost`, // 開發環境下開啟的地址,監聽了0.0.0.0,但是不是所有裝置都支援訪問這個地址,用127.0.0.1或localhost代替
open: true // 是否開啟瀏覽器
}
}
複製程式碼
配置SSR
我們以Koa作為除錯和實際執行的伺服器框架,config/server.js
:
const path = require(`path`)
const Koa = req uire(`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...`)
})
})
複製程式碼
上述檔案我們根據是否是開發環境,配置了對應的靜態資源目錄。需要說明的是,我們約定編譯後的API檔案位於dist/api
,前端檔案位於dist/web
。
參考koa-static
實現靜態資源的處理,config/koa/static.js
:
`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
}
}
}
}
複製程式碼
我們可以看到,koa-static
僅僅是對koa-send
進行了簡單封裝(yarn add koa-send
)。接下來就是重頭戲SSR相關的配置了, config/ssr.js
:
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
})
} 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(`
Server 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(`
Error: `), e.message)
html = `500 | Internal Server Error`
}
}
ctx.type = `html`
ctx.status = status || ctx.status
ctx.body = html
next()
})
if (isProd) {
const API = require(`../dist/api/api`).default
const server = API(app)
resolve(server)
}
})
}
複製程式碼
這裡新加入了html-minifier
模組來壓縮生產環境的index.html
檔案(yarn add html-minifier
)。其餘配置和官方給出的差不多,不再贅述。只不過Promise返回的是require(`http`).createServer(app.callback())
(詳見原始碼)。這樣做的目的是為了共用一個koa2例項。此外,這裡攔截了/api
開頭的請求,將請求交由API Server進行處理(因在同一個Koa2例項,這裡直接next()了)。在public
目錄下必須存在index.html
檔案:
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<title>{{ title }}</title>
...
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
複製程式碼
開發環境中,處理資料的核心在config/setup-dev-server.js
檔案:
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 = `
`.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
})
// 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
})
app.use(webpackHotMiddleware(clientCompiler))
}
複製程式碼
由於篇幅限制,koa
及lib
目錄下的檔案參考示例程式碼。其中lib
下的檔案均來自vue-cli
,主要用於判斷使用者是否使用yarn
以及在瀏覽器中開啟URL。
這時,為了適應上述功能的需要,需新增以下模組(可選):
yarn add memory-fs chokidar readline
yarn add -D opn execa
複製程式碼
通過閱讀config/setup-dev-server.js
檔案內容,您將發現此處進行了三個webpack配置的處理。
Server for API // 用於處理`/api`開頭下的API介面,提供非首屏API接入的能力
Web server for SSR // 用於伺服器端對API的代理請求,實現SSR
WEB // 進行常規靜態資源的處理
複製程式碼
Webpack 配置
|-- config
|-- webpack.api.config.js // Server for API
|-- webpack.base.config.js // 基礎Webpack配置
|-- webpack.server.config.js // Web server for SSR
|-- webpack.web.config.js // 常規靜態資源
複製程式碼
由於Webpack的配置較常規Vue專案以及Node.js專案並沒有太大區別,不再一一贅述,具體配置請翻閱原始碼。
值得注意的是,我們為API和WEB指定了別名:
alias: {
`@`: path.join(__dirname, `../src/web`),
`~`: path.join(__dirname, `../src/api`),
`vue$`: `vue/dist/vue.esm.js`
},
複製程式碼
此外,webpack.base.config.js
中設定編譯時拷貝public
目錄下的檔案到dist/web
目錄時並不包含index.html
檔案。
編譯指令碼:
"scripts": {
...
"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"
},
複製程式碼
執行yarn build
進行編譯。編譯後的檔案存於/dist
目錄下。正式環境請儘量分離API及SSR Server。
測試
執行yarn serve
(開發)或yarn start
(編譯後)命令,訪問http://localhost:3000
。
通過檢視原始檔可以看到,首屏渲染結果是這樣的:
➜ ~ curl -s http://localhost:3000/ | grep Hello
<div id="app" data-server-rendered="true"><div>Hello World SSR</div></div>
複製程式碼
至此,Vue SSR配置完成。
原創內容。文章來源:www.wyr.me/post/593