为何须要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.jssrc├── 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-pluginnpm 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// 获取构建监控Promiselet onReadyif (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__)}