主要库包版本:
"vue": "^2.6.11",
"vue-server-renderer": "^2.6.11",
"vuex": "^3.5.1"
"@babel/core": "^7.10.4",
"@babel/plugin-transform-runtime": "^7.10.4",
"@babel/preset-env": "^7.10.4",
"babel-loader": "^8.1.0",
"css-loader": "^3.6.0",
"file-loader": "^6.0.0",
"vue-template-compiler": "^2.6.11",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.12"
1.1、初始化项目:
npm install vue vue-server-renderer --save
此处有坑:vue与vue-server-renderer的版本必须一致,而vue-server-renderer当前最新版本为2.6.14,必须使用vue2.x,而vue2.x最高版本只到2.6.11,因此两个版本均为2.6.11保持一致,才可开发;
1.2、实例:
创建vue实例,使用vue-ssr渲染成html,启动服务访问;
抽离模板为index.template.html,测试插值:使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation),使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation)
解决问题:
const Vue = require('vue');
const server = require('express')();
const fs = require('fs');
const renderer = require('vue-server-renderer').createRenderer({
template: fs.readFileSync('./index.template.html', 'utf-8')
});
server.get('*', (req, res) => {
const app = new Vue({
data: {
url: req.url
},
template: `<div>url:{{url}}</div>`
})
renderer.renderToString(app, {title: 'hhh', meta: '<meta charset="UTF-8">'}).then(html => {
// res.setHeader('Content-type', 'text/html;charset=utf8'); // 解决乱码,需指明给浏览器
res.end(html)
}).catch(error => {
res.status(500).end('Internal Server Error' + error.toString())
})
})
server.listen(8080) // 3000为更优,开发习惯
先看源码结构是因为有vue ssr工作的原理图,有助于理解原理
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1uhIbT6n-1635214413157)(C:\Users\v_mjvzhang\AppData\Roaming\Typora\typora-user-images\1632812586651.png)]
通用APP代码通过app.js文件集成前后端入口文件(server),分别处理前后端渲染特定逻辑(如:数据预取、设置服务端router等),经由webpack配置打包,分别生成前后端bundle文件,服务器需要「服务器 bundle」然后用于服务器端渲染(SSR),而「客户端 bundle」会发送给浏览器,用于混合静态标记。
提点:
src
├── components
│ ├── Foo.vue
│ ├── Bar.vue
│ └── Baz.vue
├── App.vue
├── app.js # 通用 entry(universal entry) 简单导出一个creatApp
├── entry-client.js # 仅运行于浏览器 创建模板,挂载app.$mount('#app')
└── entry-server.js # 仅运行于服务器 创建返回程序实例app,执行服务器端路由匹配和数据预取逻辑 。
客户端激活
data-server-rendered
特殊属性,让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载。注意,这里并没有添加 id="app"
,而是添加 data-server-rendered
属性:你需要自行添加 ID 或其他能够选取到应用程序根元素的选择器,否则应用程序将无法正常激活。
在没有 data-server-rendered
属性的元素上,app.$mount('#app', true)
来强制使用激活模式
开发模式下,vue推断客户端生成的virtual DOM tree是否与服务器渲染的DOM结构匹配,不匹配则退出混合模式丢弃现有DOM从头渲染;生成模式下,避免性能损耗会跳过此检测步骤;因此需要注意一些浏览器会帮助修改不符合标准的一些HTML结构,如tbody等会导致dom结构不一致。
配置分为三个文件:base, client 和 server。基本配置 (base config) 包含在两个环境共享的配置,例如,输出路径 (output path),别名 (alias) 和 loader。服务器配置 (server config) 和客户端配置 (client config),可以通过使用 webpack-merge 来简单地扩展基本配置。
根据官网默认配置,运行采坑:
<script src="/dist/app[hash].js" defer=""></script>
,拉取资源会有500报错。因为这是静态资源,需要再服务器下注册一下//server.js
server.use('/dist', express.static('./dist'))
[vue-server-renderer-webpack-plugin] webpack config output.libraryTarget should be “commonjs2”.
Error: Entry “main” not found. Did you specify the correct entry option?
暂降低版本为webpack4;
报错:@vitejs/plugin-vue requires vue (>=3.2.13) or @vue/compiler-sfc to be present in the dependency tree;
不去升级vue到3版本,因为vue-server-renderer版本只到2.x,而需要保持一致;安装@vue/compiler-sfc
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Wj204pXx-1635214413159)(C:\Users\v_mjvzhang\AppData\Roaming\Typora\typora-user-images\1632822793949.png)]
Internal Server ErrorError: Cannot find module ‘vue/server-renderer’
这个错误一直很懵逼,几乎调整了所有npm包的版本 ???
bundle renderer
devtool: 'source-map'
)*.vue
文件时):自动内联在渲染过程中用到的组件所需的CSS。更多细节请查看 CSS 章节。nodemon监控代码变更,自动重新启动服务
区分环境开发,开发模式下自动监听打包(bundle renderer依赖的文件template、bundle、mainfest)
综上,代码调整如下:
// server.js
const Vue = require('vue');
const express = require('express');
const server = express();
const fs = require('fs');
const {createBundleRenderer} = require('vue-server-renderer');
// 物理磁盘中的资源
server.use('/dist', express.static('./dist'))
const setupDevServer = require('./build/setup-dev-server')
let renderer
let onReady // 拿到返回的promise状态
const isProd = process.env.NODE_ENV === 'production';
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{
// 监视打包构建 -> 重新生成Renderer渲染器
onReady = setupDevServer(server, (template, serverBundle, clientManifest) => {
renderer = createBundleRenderer(serverBundle, {
template, clientManifest
});
}); // 传递server,挂载中间件
}
// 匹配路由之后渲染的render
const render = (req, res) => {
// app实例自动去找, renderToString第一个参数即为context对象
renderer.renderToString({
url: req.url
}).then(html => {
// console.log('html ---> ', html);
// res.setHeader('Content-type', 'text/html;charset=utf8');
res.end(html)
}).catch(error => {
console.log('error ---> ', error);
res.status(500).end('Internal Server Error' + error.toString())
})
}
const renderDev = async (req, res) => {
// 等待有render之后再渲染
await onReady
render(req, res)
}
server.get( '*', isProd ? render: renderDev)
server.listen(8080)
const fs = require('fs');
const path = require('path');
const chokidar = require('chokidar');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const hotMiddleware = require('webpack-hot-middleware');
const fileResolve = file => path.resolve(__dirname, file);
module.exports = (server, callback) => {
let ready
const onReady = new Promise(r => ready = r); // ready = resovler,等待调用
let serverBundle
let template
let clientManifest
const update = () => {
if(template && serverBundle && clientManifest){
ready();
callback(template, serverBundle, clientManifest)
}
}
// 监视构建 -> 更新update -> 更新render
// 1、监视template
const templatePath = fileResolve('../index.template.html')
template = fs.readFileSync(templatePath, 'utf-8')
update()
// fs.watch fs.watchFile
// chokidar 监视包
chokidar.watch(templatePath).on('change', () => {
template = fs.readFileSync(templatePath, 'utf-8')
// console.log('template ---> ', template);
update()
})
// 2、监视serverBundle
const serverConf = require('./webpack.server.config')
const serverCompiler = webpack(serverConf);
// webpack打包结果存在磁盘,开发模式下内存会快些; <Custom File Systems>- memfs包\ webpack-dev-middleware
const serverDevMid = webpackDevMiddleware(serverCompiler, {
logLevel: 'silent', // 关闭日志输出,由FriendlyErrorsWebpackPlugin统一管理日志
})
serverCompiler.hooks.done.tap('server', () => {
serverBundle = JSON.parse(serverDevMid.fileSystem.readFileSync(fileResolve('../dist/vue-ssr-server-bundle.json'), 'utf-8'))
// console.log('serverBundle ---> ', serverBundle);
update()
})
// serverCompiler.watch({}, (err, stats) => {
// if(err) throw err; // webpack的错误
// if(stats.hasErrors()) return // 代码自身错误
// serverBundle = JSON.parse(fs.readFileSync(fileResolve('../dist/vue-ssr-server-bundle.json'), 'utf-8'))
// console.log('serverBundle ---> ', serverBundle);
// update()
// })
// 3、监视clientManifest
const clientConf = require('./webpack.client.config')
clientConf.plugins.push(new webpack.HotModuleReplacementPlugin())
clientConf.entry.app = [
'webpack-hot-middleware/client?quiet=true&reload=true', // 和服务端交互处理热更新一个客户端脚本
clientConf.entry.app
]
clientConf.output.filename = '[name].js' // 热更新下不启动hash
const clientCompiler = webpack(clientConf);
const clientDevMid = webpackDevMiddleware(clientCompiler, {
publicPath: clientConf.output.publicPath,
logLevel: 'silent',
})
clientCompiler.hooks.done.tap('client', () => {
clientManifest = JSON.parse(clientDevMid.fileSystem.readFileSync(fileResolve('../dist/vue-ssr-client-manifest.json'), 'utf-8'))
// console.log('clientManifest ---> ', clientManifest);
// console.log('client change');
update()
})
server.use(hotMiddleware(clientCompiler, {
log: false // 关日志
}))
// 将 clientDevMid 挂载到express服务中,提供对其内部内存中数据的访问
server.use(clientDevMid);
return onReady
}
// package.json scripts
"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",
"start": "cross-env NODE_ENV=production node server.js",
"dev": "node server.js",
"nmdev": "nodemon server.js"
},
提点:
server中匹配’*’,获取url传递到vue程序,对客户、服务端复用相同的路由配置
mode: ‘history’, // 服务端不支持hash
服务端要等到routrer将可能的异步组件和钩子函数解析完router.onReady才可进行返回,客户端也是才能挂载app
异步组件,实现惰性加载(代码分割),有助于减少浏览器在初始渲染中下载的资源体积,可以极大地改善大体积 bundle 的可交互时间(TTI - time-to-interactive)。这里的关键在于,对初始首屏而言,“只加载所需”。
pretch预加载资源
客户端尽早接管服务端的内容,减少服务器的损耗;
<link rel="preload" href="/dist/app.js" as="script">
<link rel="prefetch" href="/dist/2.js">
link 预加载,不执行代码,不影响渲染;提高加载渲染的速度
preload当前页面一定用到的资源
prefetch可能下页要用到的,浏览器空闲的时候加载(不一定加载到)
通常页面都是数据驱动的,且客户、服务端状态一致才能渲染成功,
所以ssr渲染之前,需先预取和解析好这些数据;另为使得客户、服务端状态相同混合成功,需要获取到服务端渲染完全相同的数据。
需要中间容器(缓存数据的地方),选取vuex,服务端serverPrefetch()生命周期函数获取之后存入,在html页面通过window对象内联inline给客户端,客户端再存入vuex中进行正常vue状态管理
// entry-server.js
import { createApp } from './app'
export default async context => {
// 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
// 以便服务器能够等待所有的内容在渲染前,
// 就已经准备就绪。
const { app, router, store } = createApp()
// 设置服务器端 router 的位置
router.push(context.url)
await new Promise(router.onReady.bind(router))
context.rendered = () => { // 服务端渲染完毕调用
// Renderer 会把 context.state 数据对象内联到页面模板中
// 最终发送给客户端的页面中会包含一段脚本:window.__INITIAL_STATE__ = context.state
// 客户端就要把页面中的 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中
context.state = store.state
}
return app
}
import { createApp } from './app'
// 客户端特定引导逻辑……
const { app, router, store } = createApp()
if(window.__INITIAL_STATE__){
store.replaceState(window.__INITIAL_STATE__)
}
// 这里假定 App.vue 模板中根元素具有 `id="app"`
router.onReady(() => {
app.$mount('#app')
})
返回代码效果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gYdDDDvP-1635214413162)(C:\Users\V_MJVZ~1\AppData\Local\Temp\企业微信截图_16328118502426.png)]
其他思考:这样岂非便利了爬虫?
head管理
使用vueMeta包
// app.js
import VueMeta from 'vue-meta'
Vue.use(VueMeta)
Vue.mixin({
metaInfo: {
titleTemplate: '%s - dudu'
}
})
// entry-server.js
const meta = app.$meta()
context.meta = meta
缓存
对一些静态或不易更改的页面进行一定时间的缓存配置,或可加上时间戳增大可缓存文件的范围
流式渲染
依赖生命周期填充数据,不建议使用。虽然在流式渲染模式下,当 renderer 遍历虚拟 DOM 树 (virtual DOM tree) 时,会尽快发送数据。这意味着我们可以尽快获得"第一个 chunk",并开始更快地将其发送给客户端。
然而,当第一个数据 chunk 被发出时,子组件甚至可能不被实例化,它们的生命周期钩子也不会被调用。这意味着,如果子组件需要在其生命周期钩子函数中,将数据附加到渲染上下文 (render context),当流 (stream) 启动时,这些数据将不可用。
非nodejs环境中使用
提点:
beforeCreate
和 created
会在服务器端渲染 (SSR) 过程中被调用。应该避免在 beforeCreate
和 created
生命周期时产生全局副作用的代码,例如在其中使用 setInterval
设置 timer,移到beforeMount
或 mounted
生命周期中directives
选项所提供"服务器端版本(server-side version)"。与传统 SPA (单页应用程序 (Single-Page Application)) 相比,服务器端渲染 (SSR) 的优势主要在于:
使用服务器端渲染 (SSR) 时还需要有一些权衡之处:
ssr vs 前端同构应用,概念对比
vue ssr官网有说,服务器渲染的 Vue.js 应用程序也可以被认为是"同构"或"通用",因为应用程序的大部分代码都可以在服务器和客户端上运行。意即:同构即为服务客户端渲染同一套代码,各自完成对应的任务(服务端直出html,客户端处理交互及后续路由切换)
**备注:**实际的业务应用开发才是所有技术服务的重点,需要谨记开发规范;