详解Vue SSR( Vue2 + Koa2 + Webpack4)配置指南
正如vue官方所说,ssr配置适合已经熟悉 vue, webpack 和 node.js 开发的开发者阅读。请先移步了解手工进行ssr配置的基本内容。
从头搭建一个服务端渲染的应用是相当复杂的。如果您有ssr需求,对webpack及koa不是很熟悉,请直接使用nuxt.js。
本文所述内容示例在 vue ssr koa2 脚手架 : https://github.com/yi-ge/vue-ssr-koa2-scaffold
我们以撰写本文时的最新版: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 '\n' ' ' | 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 '\n' ' ' | 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('\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() }) 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 = '\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 }) // 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配置完成。希望对大家的学习有所帮助,也希望大家多多支持。