由于公司业务需求,需要我搭建一个SSR(同构渲染)架构。之前CSR(客户端渲染)项目选择的技术栈是Vue.js,那为什么不用Vue社区的Nuxt.js搭建一个开箱即用的SSR环境呢?因为我们的前端项目还有一个BFF(BackEnd For FrontEnd)层,还有些其他的nodejs逻辑在里面,故不能直接使用Nuxt.js,我们需要将SSR糅合进公司之前搭建的BFF层,这又是一个全新的挑战了,所以这边要写一篇关于Vue SSR的文章来记录一下。
由于我们接下来要用到的SSR项目需要运行在BFF层,所以这篇专栏会教你从BFF层搭建到SSR,真正的做到,从零开始搭建Vue SSR。
其实Vue的SSR(其实React也是一样)并不是传统的服务端渲染,而是一种全新的架构:同构渲染。那什么是传统的服务端渲染?又什么是同构渲染?
传统的服务端渲染这里用大白话说就是每一张的页面全部都是真实的。哎?什么是“每一张的页面全部都是真实的”?
这里我们先了解一下SPA(Single Page Application),也就是传统的Vue-cli搭建出来的项目,真正的html文件只有一个,其他页面的所有内容全部都是js来呈现的。
所以传统服务端渲染“每一张的页面全部都是真实的”这里就很好理解了,所有的页面切换都需要请求服务器,服务器吐出真正的html页面交由浏览器呈现。
同构渲染是一种糅合的传统的服务端渲染和SPA呈现方式的全新架构,真正的html页面,其实也是只有一张,当客户端去请求服务器时,服务器开始在本地启动SPA渲染出一张全新的页面,之后交给浏览器呈现。这样就能做到只有一张html但是能产生无数多的“真实页面 ”。但客户端交互怎么办?不要担心,服务端在本地启动SPA渲染时,已经将相关js的引用嵌入到了发送给客户端的html中,所以一旦将页面交给浏览器后,之后全部的操作,又回归到了SPA!
也就是说,只有打开网站的首屏(注意不是首页)是一张真实页面,之后其他的所有页面,又全是交由js呈现的!
由于之前公司的BFF层是由koa2
搭建的,所以我这里也使用koa2
来教大家怎么搭建一个完完全全的Vue同构应用。没学过koa2
的小伙伴赶紧出门右转B站,有一大堆的koa2
教学。
首先哦,新建文件夹!
其次哦,在这个文件夹打开终端!
然后哦,输入npm init -y && npm install koa koa-router -S
(哎呀,这位同学,你用yarn
也可以啦)
新建文件夹和文件server/server.js
,新建文件夹和文件server/config/local.js
。打开server/server.js
,输入:
const Koa = require('koa');
const app = new Koa();
app.listen(3000);
其实端口号不应该写成魔法值(magic value
),而应该放在配置文件中,于是我们打开config/local.js
(本地开发配置),键入:
module.exports = {
port: 3000,
};
相应,更改server.js
:
const Koa = require('koa');
//根据环境动态引入不同的配置文件
const config = require(`./config/${process.env.NODE_ENV}`);
const app = new Koa();
app.listen(config.port);
这里你们应该发现了,我这里会根据不同的process.env.NODE_ENV
来引入不同的配置:
const config = require(`./config/${process.env.NODE_ENV}`);
这个process.env.NODE_ENV
是在哪里定义的?这里我们需要安装一个插件cross-env
,这个插件就是用来注入NODE_ENV
的:
npm i cross-env -S
于是我们就需要在package.json
中编写命令来注入变量啦~
"scripts": {
"local": "cross-env NODE_ENV=local node server/server"
}
这时你就可以启动 npm run local
了啦,服务器就启动了哟~
我们要使用Vue的SSR就需要使用Vue官方的服务端渲染插件vue-server-renderer
,这里我们来安装:
npm i vue-server-renderer -S
插件中有一个createBundleRenderer
方法,需要传递两个参数。
第一个参数是serverBundle
,也就是构建后的服务端代码。
第二个参数是一个对象,对象中有两个属性,第一个属性为template
,也就是唯一的html文件,第二个参数是clientManifest
,客户端构建后的映射文件,该文件可以使用webpack plugin
生成。
接下来我们先不管构建流程,我们先创建一个html模板文件/index.template.html
,放在项目根路径下,它的内容看起来像是这样:
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Documenttitle>
head>
<body>
body>
html>
其中的不能省,这是
vue-server-renderer
嵌入代码的地方。
由于BFF层并不是全部用来SSR功能的,所以我们需要在server
目录下新建一个目录ssr
用来放置所有关于SSR的逻辑。
新建文件夹和文件server/ssr/index.js
。这个文件就用于放置SSR逻辑。
之后打开server/ssr/index.js
写入相关的SSR逻辑:
const fs = require('fs');
const resolve = (path) => require('path').resolve(__dirname, path);
const template = fs.readFileSync(resolve('../../index.template.html'));
module.exports = (server, router) => {
};
这里抛出了一个函数,传入两个参数,一个是服务器实例server
,一个是路由router
。
于是我们就需要更新server/server.js
的代码来传入这两个参数:
const Koa = require('koa');
const router = require('koa-router')();
const config = require(`./config/${process.env.NODE_ENV}`);
const app = new Koa();
const ssrMixin = require('./ssr');
//SSR逻辑
ssrMixin(app, router);
//koa-router中间件
app.use(router.routes()).use(router.allowedMethods());
app.listen(config.port);
之后我们所有的SSR逻辑都可以写在server/ssr/index.js
中啦。
由于我们需要在客户端访问所有服务端路径的时候,都需要先生成一张新的html页面,之后在交由客户端渲染。所以我们这里要用到koa-router
来匹配访问路径。
更新server/ssr/index.js
的代码:
const fs = require('fs');
const resolve = (path) => require('path').resolve(__dirname, path);
const template = fs.readFileSync(resolve('../../index.template.html'));
module.exports = (server, router) => {
router.get('/(.*)', async (ctx) => {
console.log('123');
});
};
这时你可以启动npm run local
来测试一下有没有运行成功~
成功的话我们就继续向下咯~
这时我们就需要使用vue-server-renderer
插件来进行SSR啦!是不是很激动人心捏~
由于我们这里缺失了构建流程,所以我们假设已经拿到了serverBundle
和clientManifest
两个文件,所以这里就可以使用啦,更新server/ssr/index.js
代码:
const fs = require('fs');
const resolve = (path) => require('path').resolve(__dirname, path);
const { createBundleRenderer } = require('vue-server-renderer');
const template = fs.readFileSync(resolve('../../index.template.html'));
//创建一个渲染器
const renderer = createBundleRenderer(serverBundle, {
template,
clientManifest,
});
module.exports = (server, router) => {
router.get('/(.*)', async (ctx) => {
console.log('123');
});
};
这里使用了createBundleRenderer
方法创建了一个渲染器,那这个渲染器何时使用呢?那当然是用户访问任意路由时使用渲染器渲染一个新页面鸭~,渲染器有一个renderToString
方法,有两个参数,第一个参数是上下文对象context
,第二个参数是一个函数,在这个函数中你可以获取到渲染完毕的html字符串,这时你就可以将这个字符串输出到客户端啦。
renderToString
支持promise
风格,这里我使用了promise
的写法:
const fs = require('fs');
const resolve = (path) => require('path').resolve(__dirname, path);
const { createBundleRenderer } = require('vue-server-renderer');
const template = fs.readFileSync(resolve('../../index.template.html'));
const renderer = createBundleRenderer(serverBundle, {
template,
clientManifest,
});
module.exports = (server, router) => {
router.get('/(.*)', async (ctx, next) => {
let context = {};
try {
//渲染html
const html = await renderer.renderToString(context);
ctx.body = html;
} catch (e) {
console.log(e);
await next();
}
});
};
呃,你可能会发现如果发生了错误,我这里只是console.log(e)
,但事实上你要是敢这么写……你的老大可能要搞死你。作为服务器端,任何的错误都需要记录日志,所以这里最好还是使用专业的日志插件log4js
(学过Java的同学应该似曾相识,Java的日志插件是log4j
)
npm i log4js -S
打开你的本地环境配置文件server/config/local.js
,加入以下配置:
const path = require('path');
const localConfig = {
port: 3000,
log: {
appenders: {
fileout: {
type: 'file',
pattern: 'yyyy-MM-dd',
keepFileExt: true,
alwaysIncludePattern: true,
filename: path.join(__dirname, '../logs/local/local.log')
},
consoleout: { type: 'console' }
},
categories: {
default: { appenders: ['fileout', 'consoleout'], level: 'debug' },
anything: { appenders: ['consoleout'], level: 'debug' }
}
}
};
module.exports = localConfig;
配置选项写完之后,我们回到server/ssr/index.js
,使用日志插件,最终,我们的代码应该长这样:
const fs = require('fs');
const log4js = require('log4js');
const logger = log4js.getLogger();
const resolve = (path) => require('path').resolve(__dirname, path);
const { createBundleRenderer } = require('vue-server-renderer');
const template = fs.readFileSync(resolve('../../index.template.html'));
//创建一个服务端渲染器
const renderer = createBundleRenderer(serverBundle, {
template,
clientManifest,
});
module.exports = (server, router) => {
router.get('/(.*)', async (ctx, next) => {
let context = {};
try {
//渲染HTML
const html = await renderer.renderToString(context);
ctx.body = html;
} catch (e) {
//渲染失败
logger.error(e);
await next();
}
});
};
到这,我们的SSR就先告一段落啦,下一节我们将编写最难的构建部分啦~