简述
服务端渲染,是指将代码的渲染交给服务器,服务器将渲染后的字符串返回给前端。使用服务端渲染,可以有效减小白屏时间,并且有利于seo优化。
vue-ssr原理
一份项目代码打包为两份,一份给server端使用,一份为client使用;server端通过node解析vm实例,转为字符串返回给前端进行渲染,没有交互能力;当server端的字符串渲染后,client端打包后的js被插入到页面中进行事件交互。
服务端渲染--基础部分
安装模块
- webpack 构建工具
- webpack-cli解析参数
- webpack-dev-server webpack开发环境
- webpack-merge webpack合并文件
- vue-loader 解析vue文件
- vue-template-compiler 解析template
- vue-style-loader 支持服务端渲染style
- css-loader
- style-laoder
- @babel/core @babel/preser-env 转换高级语法为低级语法
- babel-loader 解析js文件
- html-webpack-plugin 打包html插入到页面中
- koa-static
webpack配置:分隔客户端和服务端
webpack.base.js:客户端和服务端公用的公用的webpack配置
const path =require('path')
const VueLoaderPlugin = require('vue-loader/lib/plugin')
module.exports = {
output: {
filename:'[name].bundle.js', // name为entry处定义,根据入口起别名
path: path.resolve(__dirname, '../dist')
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
},
},
exclude: /node_modules/
},
{
test: /\.css$/,
use: ['vue-style-loader', 'css-loader']
},
{
test: /\.vue$/,
use: 'vue-loader', //解析.vue文件,需要配合插件
}
]
},
plugins: [new VueLoaderPlugin()] //解析.vue文件的插件
}
webpack.client.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const {merge} = require('webpack-merge')
const base = require('./webpack.base.js')
module.exports = merge(base, {
mode: 'development',
entry: { // 入口文件
client: path.resolve(__dirname, '../src/client-entry.js')
},
plugins:[
new HtmlWebpackPlugin({ // 将打包后的js文件自动插入到模板html文件中
template: path.resolve(__dirname, '../public/index.client.html'),
filename: 'index.client.html',
minify: false
}),
]
})
- webpack.server.js
const {merge} = require('webpack-merge')
const base = require('./webpack.base.js')
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = merge(base, {
mode: "development",
entry: {
server: path.resolve(__dirname, '../src/server-entry.js')
},
target: 'node',// 打包的服务端给node使用
output: {
libraryTarget: 'commonjs2', // 导出的代码采用commonjs规范,即通过module.exports进行导出
},
plugins:[
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../public/index.server.html'),
filename: 'index.server.html',
minify: false,
excludeChunks:['server'], // 不自动在server.html中自动引入打包后的文件 服务端自动打包后的server.bundle.js文件不是直接引入到index.server.html使用,需要经过处理渲染成字符串插入到html中,所以在打包输出时,在HtmlWebpackPlugin中排除对该模块的引用 所以这里只是将public目录下的index.server.html复制到了dist目录下
}),
]
})
默认webpack的输出是将打包结果放在一个自执行函数中,通过指定 libraryTarget: 'commonjs2'将结果通过module.exports导出
package.json
"scripts": {
"client:build": "webpack --config build/webpack.client.js --watch",
"client:dev": "webpack-dev-server --config build/webpack.client.js",
"server:build": "webpack --config build/webpack.server.js --watch",
"server:dev": "webpack-dev-server --config build/webpack.server.js",
"all": "concurrently \"npm run client:build\" \"npm run server:build\""
}
通过concurrently实现同时执行多个命令
创建html模板
- 客户端在渲染时,将打包的client.bundle.js插入到index.client.html中,js文件中的vue实例会挂载在html文件的app元素上
- 服务端渲染时,是无法将vue实例渲染在app元素上,所以在html文件中,没有div#app
// public/index.client.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
// public/index.sercer.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
在渲染的时候,html片段会被插入到这个注释标记这里
创建组件
// App.vue
<template>
<div id='app'>
<Bar></Bar>
<Foo></Foo>
</div>
</template>
<script>
import Bar from './components/Bar.vue';
import Foo from './components/Foo.vue';
export default {
components: { Bar, Foo }
}
</script>
// src/components/bar.vue
<template>
<div class="bar" @click="handleClick">bar</div>
</template>
<script>
export default {
methods: {
handleClick() {
alert(1)
}
}
}
</script>
// src/components/foo.vue
<template>
<div style='color: red'>foo</div>
</template>
创建vue实例--app.js
在进行服务端渲染时,希望每个用户都是独立的vue实例,所以,这里导出一个函数
import Vue from 'vue';
import App from './App.vue';
export default () => { // 拆成函数,保证在服务端时,每次产生新的实例
let app = new Vue({
render: h => h(App),
})
return {app}
}
创建打包的入口文件--entry.js
- 客户端:获取vue实例,进行手动挂载
- 服务端:返回一个函数,当服务器访问时,会执行这个函数,返回vue实例;因为服务端和客户端是一对多的关系,所以,需要返回一个函数
// client.entry.js
import createApp from './app.js'
const {app} = createApp();
app.$mount('#app'); // 手动进行挂载
// server.entry.js
import createApp from './app.js'
export default () => {
const {app} = createApp();
return app
}
创建开启本地服务的文件--server.js
const Vue = require('vue')
const VueServerRender = require('vue-server-renderer')
const Koa = require('koa')
const Router = require('@koa/router')
const fs = require('fs')
const path = require('path')
let app = new Koa()
let router = new Router()
const static = require('koa-static')
const template = fs.readFileSync('./dist/index.server.html', 'utf8')
const serverBundle = fs.readFileSync('./dist/server.bundle.js', 'utf8') // serverBundle:即vue项目实例
let render = VueServerRender.createBundleRenderer(serverBundle, {
template,
})
router.get('/', async (ctx) => {// 如果渲染内容需要增添样式,需要采用回调方式
ctx.body = await new Promise((resolve, reject) =>{
render.renderToString((err, html) => {
console.log(err, 'err')
resolve(html)
})
})
})
app.use(router.routes())
app.use(static(path.resolve(__dirname, 'dist')))
app.listen(8001)
- 启动本地服务后,通过fs读取服务端js文件的打包结果serverBundle和html文件的打包结果template;
- 通过VueServerRender.createBundleRenderer创建渲染器,传递serverBundle和template;
- 当客户端访问某个路由时,通过render.renderToString将vue实例进行渲染
项目启动
- 执行 npm run all进行客户端和服务端文件打包
- 执行 node server.js开启本地服务
- 浏览器访问localhost:8001/
查看项目源代码可以看到服务端返回的html文件内容
服务端渲染--事件交互
以上配置可以访问到服务端渲染出的页面,但是,没有交互能力,点击事件不生效;是因为服务器将 server.bundle.js渲染的html字符串返回给客户端,事件执行函数是在client.bundle.js中。所以我们需要将client.bundle.js插入到index.server.html中。
webpack配置vue-server-renderer/client-plugin和vue-server-renderer/server-plugin插件
// webpack.client.js
const VueSsrClientPlugin = require('vue-server-renderer/client-plugin')
plugins:[
...
new VueSsrClientPlugin()
]
// webpack.server.js
const VueSsrServerPlugin = require('vue-server-renderer/server-plugin')
plugins:[
...
new VueSsrServerPlugin()
]
重新打包后,生成两个json文件,主要是生成客户端和服务端的对应关系
修改server.js
const static = require('koa-static')
// const serverBundle = fs.readFileSync('./dist/server.bundle.js', 'utf8')
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
let render = VueServerRender.createBundleRenderer(serverBundle, {
template,
clientManifest
})
app.use(static(path.resolve(__dirname, 'dist')))
之前是通过server.bundle.js生成渲染器,这里修改为使用serverBundle的json文件和clientManifest的json文件,为index.server.html自动插入client.bundle.js文件. 这里还需要注意使用koa-static处理服务端静态文件的读取,实现dist下文件直接可以通过连接访问。如果不使用,直接读取的话会在报错:GET http://localhost:8001/client.bundle.js net::ERR_ABORTED 404 (Not Found)
服务端渲染--路由
修改App.vue
前端路由是通过history|hash等实现,但是对于服务端而言,只有首屏是通过服务端渲染,要实现路由渲染页面,需要先跳转首页,再通过前端路由进行跳转
<template>
<div id='app'>
<router-link to='/'>foo</router-link>
<router-link to='/bar'>bar</router-link>
<router-view></router-view>
</div>
</template>
创建路由实例
// createRouter.js
import Vue from 'vue';
import VueRouter from 'vue-router';
Vue.use(VueRouter);
export default () => {
const router = new VueRouter({
mode: 'history',
routes: [{
path: '/', component: () => import('./components/Foo.vue')
}, {
path: '/bar', component: () => import('./components/Bar.vue')
}]
})
return router;
}
vm实例上注入路由,并对外暴露
import createRouter from './createRouter';
export default () => {
let router = createRouter()
let app = new Vue({
router,
...
})
return {app, router}
}
server.js将客户端访问的url传递给服务端
- 在调用renderToString方法时,可以传递上下文对象context。将客户端的访问路径ctx.url保存在context对象上,传递给server-entry.js。在进行服务端渲染时,服务端将路由提前指定,再渲染实例
- 如果访问不存在的路由,先跳转首页,通过客户端进行跳转
- 如果访问不存在的路由出现404code,就直接返回404等(视项目情况稳定)
router.get('/', async (ctx) => {
ctx.body = await new Promise((resolve, reject) =>{
render.renderToString({ // context对象
url: ctx.url,
}, (err, html) => {
resolve(html)
})
})
})
router.get('(.*)', async (ctx) => { // 访问不存在的路由时,跳转首页,通过前端跳转
ctx.body = await new Promise((resolve, reject) =>{
render.renderToString({
url: ctx.url,
},(err, html) => {
if (err && err.code == 404) {
resolve('404')
}
resolve(html)
})
})
})
服务端根据用户访问url进行路由跳转
- 根据context传递的url进行路由跳转。
- 在路由跳转时,如果路由跳转的是异步组件,可能没有跳转完就返回了vm实例,出现渲染异常;所以这里返回一个promise实例,当理由跳转完成router.onReady后,返回匹配的vue实例
- 404页面处理:当访问的路由匹配的实例不存在时,返回404code
import createApp from './app.js'
export default (context) => {
return new Promise((resolve, reject) => {
const {app, router} = createApp();
router.push(context.url);
router.onReady(() => {
const matchedComponents = router.getMatchedComponents(); // 跳转后匹配的组件个数
if(!matchedComponents.length) {
return reject({code: 404})
}
resolve(app)
})
})
}
服务端渲染--vuex
创建store实例
- 创建store实例
- 当服务端将将改变后的state保存在context时,客户端可以通过window.INITIAL_STATE 获取到修改后的state,通过store.replaceState改变客户端store中的state
// createStore.js
import Vue from 'vue';
import Vuex from 'Vuex';
Vue.use(Vuex)
export default () => {
const store = new Vuex.Store({
state: { name: 'xx', age: 10 },
mutations: {
changeName(state) { state.name= 'ss' },
changeAge(state) { state.age = 1 }
},
actions: {
changeName({commit}) {
return new Promise((resolve, reject) =>{
setTimeout(() => {
commit('changeName');
commit('changeAge');
resolve();
}, 1000)
})
}
}
})
if (typeof window != 'undefined' && window.__INITIAL_STATE__) { // 服务器环将store中状态挂载context即window上,所以在此需要替换
store.replaceState(window.__INITIAL_STATE__)
}
return store;
}
vm实例上注入store,并对外暴露
import createStore from './createStore';
export default () => {
...
let store = createStore()
let app = new Vue({
router,
store,
render: h => h(App),
})
return {app, router, store}
}
修改Bar.vue
<template>
<div class="bar" @click="handleClick">
bar {{$store.state.name}}
</div>
</template>
<script>
export default {
asyncData(store) {
return store.dispatch('changeName')
},
mounted() {
this.$store.dispatch('changeName')
}
}
</script>
在组件中定义asyncData方法派发mutation或action;为了在组件渲染时就使用最新数据,可以在mounted中派发action
在服务端执行组件函数改变数据
- 从路由匹配的组件中获取方法并执行,传入store;此时,vuex中的数据仅在服务端发生变化,需要将改变后的state保存在context上;此时,客户端可以通过window.INITIAL_STATE 获取到修改后的state,通过store.replaceState改变客户端store中的state
- 在服务端,需要等到所有的action结束后,再返回vm实例,否则可能导致数据异常
// server-entry.js
const {app, router, store} = createApp();
...
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
...
Promise.all(matchedComponents.map(component => {
if (component.asyncData) {
return component.asyncData(store)
}
})).then(() => {
context.state = store.state //将状态放在context上,state就会放在window.__INITIAL_STATE__上
resolve(app)
})
})