引入
为什么要进行服务端渲染? 先来看下面两个例子
如图某音乐网站
我们可以看到有很多分类音乐榜单,我们看看他到底是怎样写的右击查看源码 body内容就下面几行代码,其他的数据都是通过js加载的
代码语言:javascript复制<body>
<main id="app">
<router-view></router-view>
<qr-code ref="qr"></qr-code>
<pay-card></pay-card>
<mini-player></mini-player>
<im-service ref="imservice"></im-service>
<tocode-modal></tocode-modal>
<router-nav></router-nav>
</main>
这就是一个典型的前端渲染的例子 页面内容什么都没有spider来了抓取的也是如上代码,这非常不利于seo。
当然服务端渲染与前端渲染各有优缺点
服务器端渲染:
优点:
- 端耗时少
- 有利于SEO
- 客户端的资源占用少
- 后端生成静态化文件。即生成缓存片段,减少数据库查询的浪费时间。高效。
缺点:
- 不利于前后端分离,开发效率低。
- 占用服务器资源。
客户端渲染
优点:
- 前后端分离。前端专注UI,后端专注api开发。前端有更多选择性,不需要遵循后端特定的模板
- 体验更好。
- 缺点:
- 前端响应较慢,不如服务器渲染的速度快。
- 不利于SEO
所以在开发时,我们要根据不同的业务场景选择不同的渲染方式,这就需要我们对客户端渲染和服务端渲染非常熟练才行。
实现服务端渲染
那么怎样使vue实现服务端渲染? 这里vue官方也有介绍 Vue SSR 指南
接下来我们写一个小项目实现vue服务端渲染 我们用到的技术有vue、node、webpack、ejs 在进行服务端渲染前我们先通过下图将服务端渲染的整个逻辑理清
开发阶段
1.在开发阶段我们会启动一个webpack-dev-server进行前端业务逻辑的高效开发,这个阶段前端还是和以往一样代码该咋写,这个阶段只负责前端的一些逻辑
2.当前端构建完毕后,我们会进行服务端渲染,但是由于webpack-dev-server是一个单独的服务,我们没有办法在webpack-dev-server上面添加服务端渲染的逻辑,所以我们要单独启动一个server服务,这里我们使用node构建Vue官方也推荐node。
3.在node中我们会用到 vue-server-renderer
帮我们在node环境里面渲染出vue代码生成的html代码,这部分代码会直接返回给用户浏览器直接显示 在开发阶段我们两个服务 如果直接访问webpack-dev-server就是前端渲染 如果访问node就是服务端渲染
上面提到 vue-server-renderer
要渲染vue代码生成的html代码以便返回,那么我们服务端怎么拿到前端的vue代码?
在node端我们要做服务端渲染,我们也要打包一个vue应用的代码逻辑,并且打包出来不是运行在客户端端而是服务端,所以 客户端服务端至少有一个webpack配置(webpack.client.config.js
和 webpack.server.config.js
)
webpack.server.config.js
是留给服务端用的,所以我们在 NodeServer
也要运行一个webpack 这里我们就使用 webpack server compiler
启动webpack完成打包. 我们通过 webpack server compiler
生成一个serverbundle.js
文件 这个文件相当于前端的app.js 因为在 webpack dev sever
里面也会打包一个app.js,这两个大部分内容是一样的但有些逻辑不一样。
而NodeServer可以直接访问到webpack打包后的文件( webpack.server.config.js
),node获取到serverbundle.js
后,利用 vue-server-renderer
执行服务端渲染(渲染出html代码)直接返回给用户,但是我们只是返回的html代码,如果要在页面上执行js逻辑,css样式等,我们还是得依赖于webpack-dev-server
给我们打包出来的客户端的app.js以及其他的css文件等静态资源。
这里我们可以通过axios请求 webpack-dev-server
获取资源然后在插入到html中在返回给用户,这样用户才能看到正常页面,用户才能进行各种操作,路由跳转等。 开发阶段的逻辑大概是这样
接下来我们进行开发阶段服务端渲染的构建 本例是在一个小项目上构建的,源码已上传至GitHub这里就不一一介绍文件夹结构了 首先构建用于服务端的webpack配置
webpack配置
代码语言:javascript复制/build/webpack.config.server.js
const path = require('path')
let MiniCssExtractPlugin = require('mini-css-extract-plugin')
const webpack = require('webpack')
let { merge }= require('webpack-merge')
let baseConfig = require('./webpack.config.base')
//获取package.json cross 环境变量
const VueServerPlugin = require('vue-server-renderer/server-plugin')
let config
const isDev = process.env.NODE_ENV === 'development'
config = merge(baseConfig,{
mode:'production',
target:'node',
entry:path.join(__dirname,'../client/server-entry.js'),
devtool:'source-map',
output:{
//通过module.expots导出 在nodehjs中就可以直接引用
libraryTarget:"commonjs2",
filename:'server-entry.js',
path:path.join(__dirname,'../server-build')
},
externals: Object.keys(require('../package.json').dependencies),//排除第三方依赖打包
module:{
rules:[{
test:/.styl/,
use:[
{
loader: MiniCssExtractPlugin.loader,
options: {
publicPath: path.join(__dirname,'../dist')
},
},
'css-loader',
{
loader:'postcss-loader',
options:{
sourceMap:true
}//使用前面的sourcemap
},
'stylus-loader',
]
},
]
},
plugins:[
new MiniCssExtractPlugin({
filename:'main.[chunkhash:8].css'//生成的样式文件名称
}),
//配置环境变量
new webpack.DefinePlugin({
'process.env':{
NODE_ENV:JSON.stringify(process.NODE_ENV || 'development'),
VUE_ENV:'server'
}
}),
new VueServerPlugin()//该配置打包不会输出js文件而是一个json文件,通过这个json文件做一些服务端渲染的操作
]
})
module.exports = config
在server端
- 设置了打包文件的输出方式,方便nodejs引用
- 打包时排除第三方依赖包,因为我们可以直接require node_modules里面的模块,不像浏览器的要打包所有类库
- 单独将css打包成一个单独的文件
- 通过
vue-server-renderer/server-plugin
将打包后的结果输出为一个json文件,这个json文件用于createBundleRenderer
详情参照文档
接下来构建NodeServer 这里我们使用koa框架 构建NodeServer
代码语言:javascript复制npm i koa koa-router -s
代码语言:javascript复制/server/server.js
const Koa = require('koa')
const path = require('path')
const Router = require('koa-router');
const app = new Koa()
const isDev = process.env.NODE_ENV === 'development'
let router = new Router()
//全局异常处理
app.use( async (ctx,next)=>{
try{
console.log(`request with path ${ctx.path}`)
await next()
}catch(err){
console.log(err)
ctx.status = 500
if(isDev){
ctx.body = err.message
}else{
ctx.body = 'please try again later'
}
}
})
//不同环境的渲染方式
let pageRouter
if(isDev){
pageRouter = require('./routers/dev-ssr')
}else{
pageRouter = require('./routers/ssr')
}
app.use(pageRouter.routes()).use(pageRouter.allowedMethods())
const HOST = process.env.HOST || '0.0.0.0'
const PORT = process.env.PORT || '4444'
app.listen(PORT,HOST,()=>{
console.log(`server is run in ${HOST}:${PORT}`)
})
服务端渲染
创建 /server/routers/dev-ssr.js 和 /server/rouuters.ssr.js分别用于开发环境的服务端渲染和生产环境的服务端渲染
创建模板文件 方便渲染html /server/server.template.ejs
代码语言:javascript复制 /server/routers/dev-ssr.js
const Router = require('koa-router')
const axios = require('axios')
const path = require('path')
const fs = require('fs')
const MemoryFS = require('memory-fs')
const webpack = require('webpack')
const VueServerRenderer = require('vue-server-renderer')//服务端渲染模块
const serverConfig = require('../../build/webpack.config.server')//引入配置文件
const serverCompiler = webpack(serverConfig)//编译webpack
const mfs = new MemoryFS()//将文件写到内存对象
serverCompiler.outputFileSystem = mfs//将文件输出到内存
let bundle //记录webpack每次打包生成的文件
serverCompiler.watch({}, (err, stats) => {
//watch时每次在client目录修改时都会重新打包
if (err) throw err
//其他错误捕获
stats = stats.toJson()
stats.errors.forEach(err => console.log(err))
stats.warnings.forEach(warn => console.warn(warn))
//读取bundle文件
const bundlePath = path.join(
serverConfig.output.path,
'vue-ssr-server-bundle.json' //vue-server-renderer/server-plugin生成的json文件
)
bundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8'))//二进制转换字符串读取
console.log('new bundle generated')
})
//服务端渲染 返回html
const handleSSR = async (ctx) => {
if (!bundle) {
ctx.body = '你等一会,别着急......'
return
}
//发送axios获取客户端的js
const clientManifestResp = await axios.get(
'http://127.0.0.1:3000/public/vue-ssr-client-manifest.json'
)
const clientManifest = clientManifestResp.data
//获取模板文件
const template = fs.readFileSync(
path.join(__dirname, '../server.template.ejs'),
'utf-8'
)
//生成调用renderer的function
const renderer = VueServerRenderer
.createBundleRenderer(bundle, {
inject: false,//默认vuer-server-renderer指定template必须按照vue-server-renderer指定的模板形式构建但是限制较大我们设置false
clientManifest//获取 vue-ssr-client-manifest.json 客户端打包成的json文件 包含各种js文件,,不然我们看到的只是html
})
}
在dev-ssr.js里面我们进行开发环境的服务端渲染逻辑
- 启动webpack打包生成vue-ssr-server-bundle.json 该文件用于
createBundleRenderer
- 获取客户端打包的生成的vue-ssr-client-manifest.json 这个文件也用于
createBundleRenderer
通过这个文件我们可以获取前端打包好的js文件 ,这个js文件相当于平时打包的bundle.js
包含了页面交互,样式等 在此之前我们要在/build/webpack.config.client.js进行配置,使其打包一份vue-ssr-client-manifest.json文件
...
const VueClientPlugin = require('vue-server-renderer/client-plugin')
//开发模式生效
if(isDev){
...
plugins:[
...
new VueClientPlugin()//该插件会自动生成vue-ssr-client-manifest.json 方便服务端渲染引用
]
}else{
...
plugins:[
...
new VueClientPlugin()//该插件会自动生成vue-ssr-client-manifest.json 方便服务端渲染引用
]
}
...
3.获取模板文件,生成调用renderer对象
代码语言:javascript复制/server/server.template.ejs
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<%- style %>
</head>
<body>
<div id="root"><%- appString %></div>
<%- scripts %>
</body>
</html>
到此我们将服务端渲染要用到的东西都准备完毕,接下来我们编写服务端渲染的逻辑 我们将其封装到一个单独的js
服务端渲染逻辑
代码语言:javascript复制/server/routers/server-render.js
let ejs = require('ejs')
module.exports = async (ctx, renderer, template) => {
ctx.headers['Content-Type'] = 'text/html'
const context = { url:ctx.path}
/**
* context 用在服务端渲染的时候传入到vue-server-renderer vue-server-renderer拿到context之后渲染完成之后会在在context插入各种属性
* 方面我们渲染html 包含客户端js路径,css路径,将vue组件的样式写入到当前路由style标签,以及title等。。。
*
* */
try {
const appString = await renderer.renderToString(context)
//渲染模板
const html = ejs.render(template, {
appString,
style: context.renderStyles(),//生成<link ...>标签
scripts: context.renderScripts(),//生成<script ...>标签
})
ctx.body = html
} catch (err) {
console.log('render error', err)
throw err
}
}
在dev-ssr.js引用
代码语言:javascript复制...
const VueServerRenderer = require('vue-server-renderer')//服务端渲染
const serverRender = require('../routers/server.render')
await serverRender(ctx, renderer, template)
}
const router = new Router()
router.get(/.*/, handleSSR)//每个请求都经过handSSR处理
module.exports = router
到此服务端渲染的基本配置已经完成
接下来我们配置用于服务端的webpack打包入口文件
入口文件
/client/server-enrty.js
/client/create-app.js 我们将app对象单独抽离出来,这么做的原因主要是,我们每一次服务端渲染都要生成一个新的app,我们不能使用上次渲染过的app对象再次进行下一次渲染,因为这个app对象以及包含了上一次渲染的状态这会影响我们下一次渲染的内容 因此我们前端的router、store加载于之前是有所不同的以store为例
代码语言:javascript复制//传统写法
// export default store
/**服务端渲染的写法 */
export default ()=>{
const store = new Vuex.Store({
state:defalutState,
mutations,
getters,
plugins:[
//每一个方法都是一个插件接收store对象
(store)=>{
}
]
})
return store
}
//在index.js
代码语言:javascript复制/client/create-app.js
//每次渲染都渲染一个新的app,不能用上一次渲染过的
import Vue from 'vue'
import Vuex from 'vuex'
import VueRouter from 'vue-router'
import App from './APP.vue'
import Meta from 'vue-meta'
import createStore from './store/store'
import createRouter from './config/router.js'
import './assets/styles/global.styl'
//
Vue.use(VueRouter)
Vue.use(Vuex)
Vue.use(Meta)//处理页面元信息
//每一次创建新的属性
export default ()=>{
const router = createRouter()
const store = createStore()
const app = new Vue({
router,
store,
render: (h) => h(App)
})
return {app,router,store}
}
代码语言:javascript复制/client/server-entry.js
import createApp from './create-app'
//该函数接收的context 来自 server-render.js里面的 renderer.renderToString(context)
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
router.push(context.url)//在服务端渲染使url与组件匹配
//路由被推进去后,所有的异步操作都完成后执行的回调
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()//获取url匹配的组件
//如果请求的url没有相应的组件
if (!matchedComponents.length) {
return reject(new Error('no component matched'))
}
resolve(app)
})
})
}
1.在server-entry.js 每次进行服务端渲染都会创建一个app对象
2.执行路由匹配请参照编程式导航
3.返回vue对象
此时服务端渲染基本完成
静态资源处理
接下来我们完成静态资源的处理,如果不处理静态资源会抛出如下异常 no component matched
/server/routers/static.js
const static = require('koa-static');
module.exports = function(router,options){
options = options||{};
options.image = options.image||30;
options.script = options.script||1;
options.styles = options.styles||30;
options.html = options.html||30;
options.other = options.othre||7;
router.all(/((.jpg)|(.png)|(.gif))$/i,static('./',{
maxAge:options.image*86400*1000
}));
router.all(/((.js)|(.jsx))$/i,static('./',{
maxAge:options.script*86400*1000
}));
router.all(/((.css))$/i,static('./',{
maxAge:options.styles*86400*1000
}));
router.all(/((.html)|(.htm))$/i,static('./',{
maxAge:options.html*86400*1000
}));
}
我们引入了koa-static该模块主要用于静态资源的管理,我们对每种静态资源都进行了缓存 server.js引用
代码语言:javascript复制...
const staticRouter = require('./routers/static')
...
// favicon.ico 处理
app.use(async (ctx,next)=>{
if(ctx.path==='/favicon.ico'){
await send(ctx,'/favicon.ico',{root:path.join(__dirname,'../')})
}else{
await next()
}
})
//静态资源
staticRouter(router,{
html:365
});
app.use(router.routes());
//页面路由
...
** 服务端渲染处理头信息** 我们要对每个页面进行头信息的处理,如title、desc、meta等,这些每个页面可能都是不相同的。 通过vue-meta
处理 以title为例 1.在create-app.js引入
...
import Meta from 'vue-meta'
Vue.use(Meta)//处理页面元信息
...
2.在组件里面键入meta信息
在APP.vue声明默认信息,当其他页面没有相关信息时会使用APP.vue的信息
代码语言:javascript复制...
export default {
//声明元信息
metaInfo:{
title:'这时首页'
}
...
在其他也页面键入如上信息这里不做展示
接下来我们还要修改webpack-dev-server 的入口文件index.js因为 入口文件并不依赖于create-app.js也就没有办法展示元信息
新键文件client-entry.js
代码语言:javascript复制//client-entry.js
import createApp from './create-app'
const {app,router} = createApp()
router.onReady(()=>{
app.$mount('#app')
})
入口文件修改
代码语言:javascript复制/build/webpack.cofig.base.js
...
entry:path.join(__dirname,'../client/client-entry.js'),
...
代码语言:javascript复制/build/webpack.config.client.js
...
entry:{
app:path.join(__dirname,'../client/client-entry.js')
}
...
到此客户端的元信息配置完成
如果是服务端渲染我们还要自己配置title在传入到模板引擎 在server-entry.js
代码语言:javascript复制...
context.meta = app.$meta
resolve(app)
...
在server-render.js
代码语言:javascript复制...
const appString = await renderer.renderToString(context)
const {title} = context.meta.inject()//查找meta信息
//渲染模板
const html = ejs.render(template, {
appString,
style: context.renderStyles(),//生成<link ...>
scripts: context.renderScripts(),//生成<script ...>
title: '123',//带标签的title信息
initalState: context.renderState()
})
...
更新模板文件
代码语言:javascript复制<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<%- title %>
...
到此开发环境的的服务端渲染完成 在package.json键入配置
代码语言:javascript复制 "dev:client": "cross-env NODE=development webpack-dev-server --config build/webpack.config.client.js",
"dev:server": "nodemon server/server.js",
"dev": "concurrently "npm run dev:client" "npm run dev:server"",
正式环境
接下来我们进行正式环境的服务端渲染配置,相较于开发阶段正式环境简单太多了 在package.json键入如下配置
代码语言:javascript复制"build:client": "cross-env NODE=production webpack --config build/webpack.config.client.js",
"build:server": "cross-env NODE=production webpack --config build/webpack.config.server.js",
"build": "npm run clean && npm run build:client && npm run build:server",
"clean": "rimraf public && rimraf server-build",
运行命令 npm run build
成功打包文件
填充 ssr.js /server/routers/ssr.js
代码语言:javascript复制const Router = require('koa-router')
const path = require('path')
const VueServerRender = require('vue-server-renderer')
const fs = require('fs')
const serverRender = require('./server.render')
const clientManifest = require('../../public/vue-ssr-client-manifest.json')
const renderer = VueServerRender.createBundleRenderer(
path.join(__dirname,'../../server-build/vue-ssr-server-bundle.json'),
{
inject:false,
clientManifest
}
)
const template = fs.readFileSync(
path.join(__dirname,'../server.template.ejs'),
'utf-8'
)
const pageRouter = new Router()
pageRouter.get(/.*/,async (ctx)=>{
await serverRender(ctx,renderer,template)
})
module.exports = pageRouter
在package.json添加配置
代码语言:javascript复制 "start": "cross-env NODE=production node server/server.js",
代码语言:javascript复制npm run start
成功执行服务端渲染
东西挺多的,在来理一下主要的思路
开发阶段
首先要实现服务端渲染,我们要通过vue-server-renderer的createBundleRenderer
方法该方法接收两个参数 第一个参数是bundle
,第二个参数是一些选项配置 第一个参数bundle其实可以简单的理解为对server-enter.js的打包,不过打包后不会生成js文件,而是一个json文件vue-ssr-server-bundle.json
我们要拿到这个json文件创建一个renderer对象,进行服务端渲染的一些操作 在server-entry.js入口文件 我们接收客户端的url,并push进router,使客户端的url匹配相应的组件,这样就实现了不同url跳转不同的页面,这就是服务端渲染的路由操作
...
router.push(context.url)
...
但是我们如何进行路由跳转?因为路由是js写的,所以我们会获取到前端(webpack-dev-server)打包的一些js、css文件并插入到html中这样就有了交互操作,界面美化 createBundleRenderer
的第二个参数就派上用场了,该参数用于设置一些选项配置
const clientManifestResp = await axios.get(
'http://127.0.0.1:3000/public/vue-ssr-client-manifest.json'
)
// return console
const clientManifest = clientManifestResp.data//静态文件地址
const renderer = createBundleRenderer(serverBundle, {
runInNewContext: false, // 推荐
template, // (可选)页面模板
clientManifest // (可选)客户端构建 manifest
})
clientManifest 就是前端打包后的一些静态资源文件的地址,为什么会生成这个地址?
因为我们在webpack.config.client.js
进行了配置
```
...
plugins:[
new VueClientPlugin()//该插件会自动生成资源静态地址方便服务端渲染引用
]
```
拿到这些地址后vue-server-renderer会自动帮我们处理这些地址的依赖关系 在renderTostring()
完成之后我们就可以通过context.renderStyles()
获取样式context.renderScripts()
获取js 获取后通过ejs渲染模板传入到html,这样即完成了服务端渲染。
...
const appString = await renderer.renderToString(context)
const {title} = context.meta.inject()//查找meta信息
const html = ejs.render(template, {
appString,
style: context.renderStyles(),
scripts: context.renderScripts(),
title: title.text(),//带标签的title信息
})
ctx.body = html
正式环境
在正式环境就没有那么麻烦, 因为bundle文件与manifest文件都已经被打包好,我们直接获取这两个文件,进行服务端渲染就可以了
代码语言:javascript复制//ssr.js
const Router = require('koa-router')
const path = require('path')
const VueServerRender = require('vue-server-renderer')
const fs = require('fs')
const serverRender = require('./server.render')
const clientManifest = require('../../public/vue-ssr-client-manifest.json')
const renderer = VueServerRender.createBundleRenderer(
path.join(__dirname,'../../server-build/vue-ssr-server-bundle.json'),
{
inject:false,
clientManifest
}
)
const template = fs.readFileSync(
path.join(__dirname,'../server.template.ejs'),
'utf-8'
)
const pageRouter = new Router()
pageRouter.get(/.*/,async (ctx)=>{
await serverRender(ctx,renderer,template)
})
module.exports = pageRouter