共计 10909 个字符,预计需要花费 28 分钟才能阅读完成。
为何须要 ssr
- 解决单页利用首屏渲染慢的问题。
- 解决 SEO 爬虫抓取时无奈获取单页利用齐全渲染的页面问题。
残缺样例
vue-ssr
根本用法
服务端 ssr 渲染最根底的性能就是将 Vue 组件渲染成 HTML 并返回到浏览器进行展现。
此处须要用到两个依赖包:
npm install vue vue-server-renderer –save
依赖增加实现后,在我的项目目录下增加 index.js 文件,该文件作为 node 启动程序的入口。
const Vue = require('vue')
const {createRenderer} = require('vue-server-renderer')
const app = new Vue({template: `<div>Hello World</div>`})
const renderer = createRenderer()
renderer.renderToString(app, (err, html) => {if (err) throw err
console.log(html)
})
利用 renderer 提供的 renderToString 办法能够将 vue 组件渲染成 html 字符串,渲染后的后果如下:
能够看到,渲染实现的 html 节点增加了 data-server-rendered,该属性在后续将用于辨别是服务端渲染还是失常的客户端渲染。
实现将 vue 实例渲染成 html 字符串之后,还须要搭建一个服务,用于响应用户的申请,并将 html 字符串返回浏览器用于展现。
此处应用 node express
npm install express –save
将下面的渲染代码进行革新,增加服务:
const Vue = require('vue')
const express = require('express')
const {createRenderer} = require('vue-server-renderer')
const {request, response} = require('express')
const app = new Vue({template: `<div>Hello World</div>`})
const renderer = createRenderer()
// 创立服务
const server = express()
server.get('*', async (request, response) => {
try {
// 反对中文
response.setHeader('Content-type', 'text/html;charset=utf-8')
const html = await renderer.renderToString(app)
response.end(`
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>${html}</body>
</html>
`)
} catch{response.status(500).end('Internal Server Error')
}
})
// 启动服务
server.listen(3000)
此时拜访 http://localhost:3000/
就能够失常看到内容被渲染输入到浏览器上了。
应用模版
在根底用例中,咱们将 vue 实例渲染成 html 字符串,如果须要浏览器可能失常显示,必须用 HTML 页面包裹这个 html 字符串。
response.end(`
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>${html}</body>
</html>
`)
此处显著应用 html 模版更为不便,在 createRenderer 创立 renderer 的时候反对传入模版index.template.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello</title>
</head>
<body>
<!--html 字符串注入入口 -->
<!--vue-ssr-outlet-->
</body>
</html>
在模版中必须蕴含<!--vue-ssr-outlet-->
,renderer 在生成 html 字符串之后,会插入到这个中央。
在生成 renderer 的中央,增加页面模版参数:
const renderer = require('vue-server-renderer').createRenderer({template:require('fs').readFileSync('./index.template.html', 'utf-8')
})
模版插值表达式
在 html 模版中,title,description,meta 信息反对用户自定义的,能够在模版中应用插值表达式。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}}</title>
<!-- 三个大括号示意不须要转译,间接输入 -->
{{{meta}}}
</head>
<body>
<!--html 字符串注入入口 -->
<!--vue-ssr-outlet-->
</body>
</html>
在调用 renderToString 办法的时候能够传入渲染上下文对象,对象中蕴含响应插值表达式信息:
const html = await renderer.renderToString(app, {
title: 'Hello SSR',
meta: `<meta name="description" content="搭建 ssr">`
})
残缺构造
在下面的例子中,尽管实现了简略的服务端渲染输入并在浏览器上展现,然而当咱们为 vue 实例增加事件等,会发现页面中相应事件并没有执行胜利。
失常的一个 ssr 零碎应该是服务端渲染首评,客户端接管接下来的页面使其成为单页利用。在咱们的例子中,通过查看页面中的源码发现服务端返回的 html 页面并没有加载任何用于客户端接管的 js 代码,因而呈现下面说的注册事件然而没有胜利执行。
官网提出的解决方案是辨别服务端和客户端入口文件,别离打包,而后利用 renderer 将打包的后果联合到一起。
因而咱们在我的项目中增加如下构造的文件:
build
├── webpack.base.config.js
├── webpack.client.config.js
├── webpack.server.config.js
src
├── App.vue
├── app.js # 通用入口
├── entry-client.js # 客户端入口
└── entry-server.js # 服务端入口
App.vue
<template>
<div id="app">Vue SSR</div>
</template>
<script>
export default {name: 'App',}
</script>
<style>
</style>
app.js
app.js 是利用的通用入口,在此文件中导出 vue 实例。在客户端,将 vue 实例挂载到 Dom 上;在服务端,将其渲染为 html 字符串。
import Vue from 'vue'
import App from './App.vue'
// 导出创立 app 的工具函数,避免服务端多实例之间相互影响。export function createApp() {
const app = new Vue({render: h => h(App)
})
return {app}
}
entry-client.js
此文件是客户端入口,当客户端执行的时候,vue 外部会依据根节点是否蕴含 data-server-rendered
属性,如果蕴含那么就阐明页面曾经在服务端渲染过,此时客户端只是单纯接管页面。否则就挂载 Dom。
import createApp from './app'
// 创立 vue 实例
const {app} = createApp()
// 挂载
app.$mount('#app')
entry-server.js
此文件服务端入口,在此文件中只是导出一个函数,此函数用于创立 vue 实例。
import {createApp} from './app'
export default context => {const { app} = createApp()
return app
}
webpack 构建
此我的项目应用 webpack 进行打包解决,webpack 打包须要三个配置文件:
- webpack.base.config.js
- webpack.client.config.js
- webpack.server.config.js
此三个文件具体内容可参考:配置文件
装置依赖包
npm i -D webpack webpack-cli webpack-merge webpack-node-externals @babel/core @babel/plugin-transform-runtime @babel/preset-env babel-loader css-loader url-loader file-loader rimraf vue-loader vue-template-compiler friendly-errors-webpack-plugin
npm i cross-env
增加 npm scripts:
"build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js",
"build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js",
"build": "rimraf dist && npm run build:client && npm run build:server",
在命令行中执行npm run build
,能够看到在 dist 目录中构建了 2 个 json 文件:
这两个文件用于后续渲染 html 字符串。
server.js
因为此时我的项目打包生成了相应的 json 文件,所以创立 renderer 和渲染 html 的形式产生些许变动。
在我的项目中增加 server.js 用于服务端 node 启动文件:
const express = require('express')
const fs = require('fs')
const {createBundleRenderer} = require('vue-server-renderer')
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const template = fs.readFileSync('./index.template.html', 'utf-8')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const renderer = createBundleRenderer(serverBundle, {
template,
clientManifest
})
// 创立服务
const server = express()
server.use('/dist', express.static('./dist'))
server.get('/', async (request, response) => {
try {
// 反对中文
response.setHeader('Content-type', 'text/html;charset=utf-8')
const html = await renderer.renderToString({
title: 'Hello SSR',
meta: `<meta name="description" content="搭建 ssr">`
})
response.end(html)
} catch(e){response.status(500).end('Internal Server Error')
}
})
// 启动服务
server.listen(3000, () => {console.log('server running at port 3000.')
})
在命令行中执行 node server.js
,此时可能失常启动站点,并且在浏览器中拜访时可能失常展现页面。
开发模式优化
在执行 node server.js
之后,尽管站点是可能失常拜访,然而每次批改之后都须要从新打包,重新启动 web 服务,这样十分麻烦,也不利于进步开发效率,此时咱们心愿可能优化开发构建模式,当代码发生变化的时候可能主动构建、主动重启 web 服务、主动刷新浏览器。
首先在 build 文件夹下增加 setup-dev-server.js
文件,该文件用于监控文件变动,当文件发生变化之后,执行用户传入的回调函数:
const fs = require('fs')
const path = require('path')
const chokidar = require('chokidar')
const webpack = require('webpack')
const devMiddleware = require('webpack-dev-middleware')
const hotMiddleware = require('webpack-hot-middleware')
module.exports = function (server, callback) {
let ready
const onReady = new Promise(resolve => ready = resolve)
let serverBundle
let clientManifest
let template
const update = () => {if (template && serverBundle && clientManifest) {
// 构建实现
ready()
callback(serverBundle, template, clientManifest)
}
}
// 监听模版文件变动
const templatePath = path.resolve(__dirname, '../index.template.html')
template = fs.readFileSync(templatePath, 'utf-8')
update()
chokidar.watch(templatePath).on('change', () => {template = fs.readFileSync(templatePath, 'utf-8')
update()})
// 构建监控
const serverConfig = require('./webpack.server.config')
const serverCompiler = webpack(serverConfig)
const serverDevMiddleware = devMiddleware(serverCompiler, {logLevel: 'silent' // 敞开日志输入,由 FriendlyErrorsWebpackPlugin 解决})
serverCompiler.hooks.done.tap('server', () => {
serverBundle = JSON.parse(
// 读取内存中的数据
serverDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
)
update()})
const clientConfig = require('./webpack.client.config')
// 增加 HRM plugin
clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())
clientConfig.entry.app = [
'webpack-hot-middleware/client?quiet=true&reload=true', // 和服务端交互解决热更新一个客户端脚本
clientConfig.entry.app
]
clientConfig.output.filename = '[name].js' // 热更新模式下确保统一的 hash
const clientCompiler = webpack(clientConfig)
const clientDevMiddleware = devMiddleware(clientCompiler, {
publicPath: clientConfig.output.publicPath,
logLevel: 'silent' // 敞开日志输入,由 FriendlyErrorsWebpackPlugin 解决
})
clientCompiler.hooks.done.tap('client', () => {
clientManifest = JSON.parse(clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
)
update()})
server.use(hotMiddleware(clientCompiler, {log: false // 敞开它自身的日志输入}))
server.use(clientDevMiddleware)
return onReady
}
当增加完监控代码后,须要批改 server.js,将开发模式和生产模式拆散:
const express = require('express')
const fs = require('fs')
const setupDevServer = require('./build/setup-dev-server')
const {createBundleRenderer} = require('vue-server-renderer')
const isProd = process.env.NODE_ENV === 'production'
// 创立服务
const server = express()
let renderer
// 获取构建监控 Promise
let onReady
if (isProd) {const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const template = fs.readFileSync('./index.template.html', 'utf-8')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createBundleRenderer(serverBundle, {
template,
clientManifest
})
} else {onReady = setupDevServer(server, (serverBundle, template, clientManifest) => {
renderer = createBundleRenderer(serverBundle, {
template,
clientManifest
})
})
}
server.use('/dist', express.static('./dist'))
async function render(request, response) {
try {
// 反对中文
response.setHeader('Content-type', 'text/html;charset=utf-8')
const html = await renderer.renderToString({
title: 'Hello SSR',
meta: `<meta name="description" content="搭建 ssr">`
})
response.end(html)
} catch (e) {response.status(500).end('Internal Server Error')
}
}
server.get('*', isProd ?
render :
async (req, res) => {
// 开发模式下,须要期待构建实现之后再执行
await onReady
render(req, res)
})
// 启动服务
server.listen(3000, () => {console.log('server running at port 3000.')
})
路由
Vue 我的项目基本上离不开路由零碎,vue ssr 反对 vue-router,只须要在少许中央作出批改,就能兼容单页利用中的路由写法。
筹备工作:
- 增加两个路由页面 Home, About。
- 增加路由注册 js
- 在 App.vue 中增加路由进口 <router-view/>
批改 app.js,将路由增加到组件实例上:
import Vue from 'vue'
import App from './App.vue'
import {createRouter} from './router/index'
// 导出创立 app 的工具函数,避免服务端多实例之间相互影响。export function createApp() {const router = createRouter()
const app = new Vue({
router,
render: h => h(App)
})
return {app, router}
}
批改 entry-server.js,当首屏渲染的时候,在服务端通过 router.push 跳转到相应路由页面
import {createApp} from './app'
export default async context => {const { app, router} = createApp()
console.log(context.url)
router.push(context.url)
// 当路由齐全解析之后再执行渲染
await new Promise(router.onReady.bind(router))
return app
}
在 server.js 中,获取以后申请的 url,并增加到渲染上下文中:
const html = await renderer.renderToString({
url: request.url,
title: 'Hello SSR',
meta: `<meta name="description" content="搭建 ssr">`
})
页面治理
咱们冀望在每个路由页面中可能自定义页面 title 和 meta 信息,此处能够采纳第三方解决方案 vue-meta。
在通用入口 app.js 中增加 vue-meta 引入
import VueMeta from 'vue-meta'
Vue.use(VueMeta)
在路由页面 Home.vue 中增加 meta 信息:
{
metaInfo: {title: '首页'}
}
在服务端入口 entry-server.js 中获取 meta 信息增加到渲染上下文中:
const meta = app.$meta()
router.push(context.url)
context.meta = meta
批改页面模版 index.template.html:
<head>
{{{meta.inject().title.text()}}}
{{{meta.inject().meta.text()}}}
</head>
数据预取
在服务端渲染过程中,只反对 beforeCreate 和 created 申明周期,然而服务端渲染不会期待其外部的异步数据拜访,并且获取的数据也不是响应式的,所以通常在生命周期中获取数据并更新页面的形式无奈在服务端渲染过程中应用。
服务端给出的解决方案就是在服务端渲染期间获取到的数据存储到 Vuex 中,而后把容器中的数据同步到客户端。
创立 store:
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
Vue.use(Vuex)
export const createStore = () => {
return new Vuex.Store({
state: {posts: [] // 文章列表
},
mutations: {
// 批改容器状态
setPosts(state, data) {state.posts = data}
},
actions: {async getPosts({ commit}) {const { data} = await axios({
method: 'GET',
url: 'https://cnodejs.org/api/v1/topics'
})
commit('setPosts', data.data)
}
}
})
}
在 app.js 中引入 store:
export function createApp() {const router = createRouter()
const store = createStore()
const app = new Vue({
router,
store,
render: h => h(App)
})
return {app, router, store}
}
在组件中引入相应 state 并增加预定义方法 serverPrefetch:
computed: {...mapState(['posts'])
},
serverPrefetch () {return this.getPosts()
},
methods: {...mapActions(['getPosts'])
}
在服务端入口文件 entry-server.js 中为渲染上下文增加:
context.rendered = () => {
// Renderer 会把 context.state 数据对象内联到页面模板中
// 最终发送给客户端的页面中会蕴含一段脚本:window.__INITIAL_STATE__ = context.state
// 客户端就要把页面中的 window.__INITIAL_STATE__ 拿进去填充到客户端 store 容器中
context.state = store.state
}
客户端入口 entry-client.js 中将内联的 window.__INITIAL_STATE__数据更新到 state 中:
const {app, router, store} = createApp()
if (window.__INITIAL_STATE__) {store.replaceState(window.__INITIAL_STATE__)
}