服务端渲染,预渲染,浏览器渲染
用户请求前的服务器渲染即为「预渲染」。
用户请求后的服务器渲染即为「服务端渲染」。
'预渲染':
+ 预渲染不像服务器渲染那样即时编译HTML,它只在构建时为了特定的路由生成特定的几个静态页面,等于我们可以通过 Webpack 插件将一些特定页面组件 build时就编译为html文件,直接以静态资源的形式输出给搜索引擎。预渲染不执行js的,只适应于纯静态页面
服务端渲染: 解析js + 构建html文件 + 将页面返回浏览器
预渲染: 直接将页面返回给浏览器
服务端渲染
服务端渲染:
+ '优点':
+ 实质时直接返回一个html字符串给浏览器,优化首屏加载时间
+ 有利于seo优化,可以被爬虫爬取到
+ '缺点':
+ 占用更多的CPU和内存
+ 一些常用的浏览器API不可以使用
+ 只在vue的beforeCreate和create两个生命周期生效
服务端渲染初体验
// server.js
const server = require('express')
const vueServerRender = require('vue-server-renderer')
const app = server()
let Vue = require('vue')
let vm = new Vue({
template: `<div>hello</div>`
})
let render = vueServerRender.createRenderer()
app.get('/', (req, res) => {
render.renderToString(vm, (err, html) => {
res.end(`
<!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>
${html}
</body>
</html>
`)
})
})
app.listen(3001)
// node server.js
读取模版
- 一般我们可以将服务端渲染的数据放在模版中,然后读取模版
// index.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>
// server.js
...
const template = fs.readFileSync(path.resolve(__dirname, './index.html'), 'utf-8')
const render = vueServerRender.createRenderer({
template
})
app.get('/', (req, res) => {
render.renderToString(vm, (err, html) => {
res.send(html)
})
})
app.listen(3001)
依然是ok的
构建客户端
- 目录结构
// public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ssr</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
// src/main.js
import Vue from 'vue'
import App from './App.vue'
const vm = new Vue({
el: '#root',
render: h => {
return h(App)
}
})
// webpack.config.js配置基础的webpack配置
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const VueLoaderPlugin = require('vue-loader/lib/plugin');
module.exports = {
entry: path.resolve(__dirname, './src/main.js'),
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
// resolve: {
// alias: {
// 'vue$': 'vue/dist/vue.common.js'
// }
// },
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']
}
]
},
devServer: {
open: true
},
plugins: [
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.resolve(__dirname, 'public/index.html'),
})
]
}
// package.json
...
"scripts": {
"build": "webpack",
"client:dev": "webpack-dev-server"
}
执行npm run client:dev,然后打开localhost:8080
拆分功能,构建server.bundle.js和client.bundle.js
- 为了兼容服务端,要将main.js编程函数形式,每次实例化,得到一个新的配置
// 'main.js'
import Vue from 'vue'
import App from './App.vue'
返回一个工厂函数,专门生产vue的实例
export default () => {
const app = new Vue({ // 创建一个实例函数
render: h => {
return h(App)
}
})
return { app }
}
// 'client-entry.js'
// 客户端入口
import createApp from './main'
const { app } = createApp()
// 客户端需要挂载
console.log(app, 'app')
app.$mount('#root')
// 'server-entry.js'
// 服务器端入口
import createApp from './main'
// 服务端没有挂载,因此返回一个函数,服务端每次调用都产生一个新的app实例
export default () => {
const { app } = createApp()
return app
}
根据server.entry以及client.entry配合webpack打包出两种bundle,
// 'webpack配置'
// webpack.base.config.js
const path = require('path')
const VueLoaderPlugin = require('vue-loader/lib/plugin');
module.exports = {
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, '../dist')
},
// resolve: {
// alias: {
// 'vue$': 'vue/dist/vue.common.js'
// }
// },
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']
}
]
},
devServer: {
open: true
},
plugins: [
new VueLoaderPlugin()
]
}
// webpack.client.config.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const webpackMerge = require('webpack-merge')
const base = require('./webpack.base.config')
module.exports = webpackMerge(base, {
mode: 'development',
entry: {client: path.resolve(__dirname, '../src/client-entry.js')},
plugins: [
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.resolve(__dirname, '../public/index.html'),
})
]
})
// webpack.server.config.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const webpackMerge = require('webpack-merge')
const base = require('./webpack.base.config')
module.exports = webpackMerge(base, {
mode: 'development',
entry: {
server: path.resolve(__dirname, '../src/server-entry.js'),
},
target: 'node', //打包给node用
output: {
libraryTarget: 'commonjs2' // 使用commonjs导出,变成module.exports,而不是一个闭包函数
},
plugins: [
new HtmlWebpackPlugin({
// 只做一个拷贝
filename: 'index.ssr.html',
template: path.resolve(__dirname, '../public/index.ssr.html'),
excludeChunks: ['server'] // 不需要引入server.bundle.js
})
]
})
// package.json
"scripts": {
"client:build": "webpack --config ./config/webpack.client.config.js",
"client:dev": "webpack-dev-server --config ./config/webpack.client.config.js",
"server:build": "webpack --config ./config/webpack.server.config.js",
"server:dev": "webpack-dev-server --config ./config/webpack.server.config.js"
},
分别运行npm run client:build以及npm run server:build进行打包
启动node服务
至此,我们已经进行bundle阶段,接下来我们需要启动一个node服务。将server.bundle使用bundleRenderer方法显然成字符串插入到html模版中去
// node-server.js
const server = require('express')
const vueServerRender = require('vue-server-renderer')
const fs = require('fs')
const path = require('path')
const app = server()
// 拿到打包后的server.bundle结合index.ssr.html,使用bundleRender生成渲成html字符串,返回给浏览器
const serverBundle = fs.readFileSync(path.resolve(__dirname, './dist/server.bundle.js'), 'utf8') // 拿到server.bundle打包后的结果
const template = fs.readFileSync(path.resolve(__dirname, './dist/index.ssr.html'),'utf8')
const render = vueServerRender.createBundleRenderer(serverBundle, {
template
})// 生成渲染函数
app.get('/', (req, res) => {
// 将html字符串返回给浏览器
render.renderToString((err, html) => {
res.send(html)
})
})
app.listen(3001)
nodemon node-server.js,打开localhost:3001,页面基本渲染出来,但是无交互因为我们反会给客户端的仅仅是一个字符串,因此我们需要客户端激活
客户端激活
打包出的client.bundle.js会给app注入一些功能
- 将打包的client.bundle.js在index.ssr.html引入,并激活客户端
// index.ssr.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-->
<script src="client.bundle.js"></script>
</body>
</html>
// node-sever.js
使用静态中间件将dist目录设置为静态目录,默认去这个文件下去找
...省略
app.use(server.static(path.resolve(__dirname, 'dist')))
...
// App.vue//并激活客户端,由于服务端渲染的html字符串没有root这个id,因此我们在App.vue中强制添加一个root
<template>
<div id="root"> // 在App.vue的根添加root
<Bar />
<Foo />
</div>
</template>
此时服务端返回的html页面即可以渲染又赋予了行为,完成了一个初步的服务端渲染,总结,通过服务端和客户端的入口文件打包出对应的bundle,服务端利用serverRender将server的bundle与对应的模版组成字符串返回给浏览器,在模版中注入client的bundle,为模版赋予行为,并且激活客户端。
添加router
// router.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Bar from './components/Bar.vue'
Vue.use(VueRouter)
export default () => {
const router = new VueRouter({ // 创建一个实例函数
mode: 'history',
routes: [{
path: '/',
component: Bar
}, {
path: '/foo',
component: () => import('./components/Foo.vue')
}]
})
return router
}
// main.js
import Vue from 'vue'
import App from './App.vue'
import createRouter from './router.js'
export default () => {
let router = createRouter()
const app = new Vue({ // 创建一个实例函数
router,
render: h => {
return h(App)
}
})
return { app, router }
}
// 客户端绑定在vue实例上,可以直接使用,但服务端不可以
// 在render.renderToString方法中可以传一个上下文,在server.entry.js返回的函数中可以获得这个上下文,从而获得路径
// 'server.entry.js'
// 服务器端入口
import createApp from './main'
// 服务端没有挂载,因此返回一个函数,服务端每次调用都产生一个新的app实例
export default (context) => {
// 服务器端,返回的永远都是index.html,但路由需要跳转到指定路径上,匹配到对应的组件
return new Promise((reslove, reject) => {
const { app, router } = createApp()
router.push(context.url)
router.onReady(() => {
// 有异步加载组件,因此需要等组件加载完成再返回,否则返回一个空页面。
reslove(app)
})
})
}
// node-server.js
... 省略
app.get('/', (req, res) => {
// 将html字符串返回给浏览器
let context = {
url: req.url
}
render.renderToString(context, (err, html) => {
res.send(html) // 这个context就可以在server.entry.js中取到
})
})
...
+ 总结: 添加vuerouter,给从服务端传入url,然后在server-entry.js通过router实例push当前的url
添加vuex
// 'main.js'
...
import createStore from './store.js'
export default () => {
...
const store = createStore()
const app = new Vue({ // 创建一个实例函数
router,
store,
render: h => {
return h(App)
}
})
return { app, router, store }
}
// 'store.js'
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default () => {
const store = new Vuex.Store({ // 创建一个实例函数
state: {
name: 'xx'
},
mutations: {
set_name(state) {
state.name = 'l'
}
},
actions: {
set_name({commit}) {
return new Promise((reslove, reject) => {
setTimeout(() => {
commit('set_name')
reslove()
}, 1000)
})
}
}
})
// store对象在服务端和浏览器端都会被访问,因此我们需要当改变服务端的时候,同步浏览器端
if(typeof window !== 'undefined' && window.__INITIAL_STAT) {
store.state = window.__INITIAL_STAT
}
return store
}
// server-entry.js
...
export default (context) => {
return new Promise((reslove, reject) => {
const { app, router, store } = createApp()
...
router.onReady(() => {
// 有异步加载组件,因此需要等组件加载完成再返回,否则返回一个空页面。
const components = router.getMatchedComponents()
Promise.all(components.map(component => {
// 拿到他的asyncData属性,只在服务器端执行
if (component.asyncData) {
return component.asyncData({store}) // 返回一个promise
}
})).then(() => {
context.state = store.state // 会自动在window上挂载一个属性
reslove(app)
})
})
})
}
// 'Foo.vue'
<template>
<div>
组件A
{{$store.state.name}}
</div>
</template>
<script>
export default {
asyncData({store}) {
// 服务端执行
return store.dispatch('set_name')
},
created() {
// 服务端和客户端都会执行
this.$store.dispatch('set_name')
}
}
</script>
- 匹配到所有的components,让组件的asyncData依次执行,asyncData执行后的结果返回的是一个promise
- context.state = store.state,会自动在window上挂在一个window.INITIAL_STATE={"name":"xx"}
- 总结: 组件的asyncData只会在页面级,并且只在服务端渲染,因此在server-entry.js匹配到对应的组件,执行asyncData函数,将store传入,这样在页面的asyncData就可以取到store,并且将store挂在到全局的context中,这样就会在window生成window.INITIAL_STATE,在服务端改变的时候,同时将客户端的store.state改变
vue-meta插件
// router.js
...
import VueMeta from 'vue-meta'
Vue.use(VueMeta, {
// optional pluginOptions
refreshOnceOnNavigation: true
})
...
// App.vue
...
<script>
import Bar from './components/Bar.vue'
import Foo from './components/Foo.vue'
export default {
metaInfo: {
title: 'myApp',
},
components: {
Bar,
Foo
},
data() {
return {
}
}
}
...
// server-entry.js
import createApp from './main'
export default (context) => {
return new Promise((reslove, reject) => {
const { app, router, store } = createApp()
router.push(context.url)
router.onReady(() => {
...
})).then(() => {
...
context.meta = app.$meta()
reslove(app)
})
})
})
}
</script>
采取json方式
由于打包出的js经常变动,因此我们希望打包成json,这个json是不会变的
// webpack-server-config.js
const ServerPlugin = require('vue-server-renderer/server-plugin') // 会打包出一个json
module.exports = webpackMerge(base, {
...
plugins: [
new ServerPlugin(),
...
]
})
// webpack-client-config.js
const ClientPlugin = require('vue-server-renderer/client-plugin') // 会打包出一个json
module.exports = webpackMerge(base, {
...
plugins: [
new ClientPlugin(),
...
]
})
// node-server.js
...
const serverBundle = require('./dist/vue-ssr-server-bundle.json') // 拿到server.bundle打包后的结果
const template = fs.readFileSync(path.resolve(__dirname, './dist/index.ssr.html'),'utf8')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const render = vueServerRender.createBundleRenderer(serverBundle, {
template,
clientManifest
})
...
这样修改文件,只需要重新打包,不需要重启node服务