项目源码地址 : https://github.com/qifutian/learngit/tree/main/vue-ssr
server.js
const Vue = require('vue')
const renderer = require('vue-server-renderer')
const app = new({
template:`
{
{message}}
`,
data:{
message: "ssr text"
}
})
renderer.renderToString(app,(err,heml)=>{
if(err) throw err
console.log(html)
})
目的:是将渲染结果发送给客户端浏览器
const Vue = require('vue')
const renderer = require('vue-server-renderer').createRenderer()
const express = require('express')
const server = express()
server.get('/',(req,res)=>{
const app = new Vue({
template:`
{
{message}}
`,
data:{
message: "ssr text"
}
})
renderer.renderToString(app,(err,html)=>{
if(err){
return res.status(500).end("Server ERROR")}
res.setHeader('Content-Type','text/html;charset=utf8')
// res.end(html)
res.end(
`
Document
${
html}
`
)
})
})
server.listen(3000,()=>{
console.log('server running at port 3000');
})
页面的模板可以存放到一个单独的文件中,对他进行管理和维护
创建index.template.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
修改server.js
const Vue = require('vue')
const fs = require('fs')
const renderer = require('vue-server-renderer').createRenderer({
template: fs.readFileSync('./index.template.html','utf-8')
})
const express = require('express')
const server = express()
server.get('/',(req,res)=>{
const app = new Vue({
template:`
{
{message}}
`,
data:{
message: "ssr text"
}
})
renderer.renderToString(app,(err, html)=>{
if(err){
return res.status(500).end("Server ERROR")}
res.setHeader('Content-Type','text/html;charset=utf8')
res.end(html)
})
})
server.listen(3000,()=>{
console.log('server running at port 3000');
})
修改renderToString方法
renderer.renderToString(app,{
title:"ssr",
meta: `
`
},(err, html)=>{
if(err){
return res.status(500).end("Server ERROR")}
res.setHeader('Content-Type','text/html;charset=utf8')
res.end(html)
})
建议使用官网推荐的结构
https://ssr.vuejs.org/zh/guide/structure.html#%E4%BD%BF%E7%94%A8-webpack-%E7%9A%84%E6%BA%90%E7%A0%81%E7%BB%93%E6%9E%84
cross-env 作用 :通过npm scripts设置扩平台环境变量 webpack-cli: webpack 的命令行工具
webpack-merge: webpack 配置信息合并工具 webpack-node-externals: 排除 webpack 中的
Node 模块 rimraf: 基于 Node 封装的一个跨平台 rm -rf 工具
friendly-errors-webpack-plugin: 友好的 webpack 错误提示 Babel 相关工具 vue-loader
vue-template-compiler 处理
.vue 资源 file-loader 处理字体资源
css-loader 处理 CSS资源
url-loader 处理图片资源
可根据package.json进行配置
{
"name": "vue-ssr",
"version": "1.0.0",
"description": "",
"main": "index.js",
"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",
},
"dependencies": {
"axios": "^0.19.2",
"chokidar": "^3.4.0",
"cross-env": "^7.0.2",
"express": "^4.17.1",
"vue": "^2.6.11",
"vue-meta": "^2.4.0",
"vue-router": "^3.3.4",
"vue-server-renderer": "^2.6.11",
"vuex": "^3.5.1"
},
"devDependencies": {
"@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",
"friendly-errors-webpack-plugin": "^1.7.0",
"rimraf": "^3.0.2",
"url-loader": "^4.1.0",
"vue-loader": "^15.9.3",
"vue-template-compiler": "^2.6.11",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.12",
"webpack-dev-middleware": "^3.7.2",
"webpack-hot-middleware": "^2.25.0",
"webpack-merge": "^5.0.9",
"webpack-node-externals": "^2.5.0"
}
}
const Vue = require('vue')
const fs = require('fs')
const serverBundle = require("./dist/vue-ssr-server-bundle.json")
const clientManifest = require("./dist/vue-ssr-client-manifest.json") // 打包需要的资源清单
const template = fs.readFileSync('./index.template.html','utf-8')
const renderer = require('vue-server-renderer').createBundleRenderer(serverBundle,{
template,
clientManifest
})
const express = require('express')
const server = express()
server.get('/',(req,res)=>{
// 会找到entry-server.js 找到对应的渲染实例
renderer.renderToString({
title:"ssr",
meta: `
`
},(err, html)=>{
if(err){
return res.status(500).end("Server ERROR")}
res.setHeader('Content-Type','text/html;charset=utf8')
res.end(html)
})
})
server.listen(3000,()=>{
console.log('server running at port 3000');
})
使用 node server 运行,浏览器会报错
可以通过node.static处理静态资源,
浏览器的事件正常处理
服务端流程
客户端加载
实现自动构建,自动重启,自动刷新浏览器等
解决server.js中的renderer是关键内容
开发模式下需要更新,需要根据源代码改变修改打包之后的资源文件
在package.json中增加对应的命令
"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"
},
在server.js中拿到环境变量执行server.js,判断是生产模式还是开发模式
const Vue = require('vue')
const fs = require('fs')
const express = require('express')
let renderer
// 根据环境变量执行对应代码
const isProd = process.env.NODE_ENV === "production"
if(isProd){
const serverBundle = require("./dist/vue-ssr-server-bundle.json")
const clientManifest = require("./dist/vue-ssr-client-manifest.json") // 打包需要的资源清单
const template = fs.readFileSync('./index.template.html','utf-8')
renderer = require('vue-server-renderer').createBundleRenderer(serverBundle,{
template,
clientManifest
})
} else {
// 开发模式 -> 监视源代码改变,进行打包构建 -> 重新生成 Renderer渲染器
}
const server = express()
server.use('/dist',express.static('./dist'))
const render = (req,res)=>{
// 会找到entry-server.js 找到对应的渲染实例
renderer.renderToString({
title:"ssr",
meta: `
`
},(err, html)=>{
if(err){
return res.status(500).end("Server ERROR")}
res.setHeader('Content-Type','text/html;charset=utf8')
res.end(html)
})
}
server.get('/', isProd ? render : (req, res) => {
// 等待有了 Renderer 渲染器以后,调用render 进行渲染
render()
})
server.listen(3000,()=>{
console.log('server running at port 3000');
})
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')
const resolve = file => path.resolve(__dirname, file)
module.exports = (server, callback) => {
let ready
const onReady = new Promise(r => ready = r)
// 监视构建 -> 更新 Renderer
let template
let serverBundle
let clientManifest
const update = () => {
if (template && serverBundle && clientManifest) {
ready()
callback(serverBundle, template, clientManifest)
}
}
// 监视构建 template -> 调用 update -> 更新 Renderer 渲染器
const templatePath = path.resolve(__dirname, '../index.template.html')
template = fs.readFileSync(templatePath, 'utf-8')
update()
// fs.watch、fs.watchFile
chokidar.watch(templatePath).on('change', () => {
template = fs.readFileSync(templatePath, 'utf-8')
update()
})
// 监视构建 serverBundle -> 调用 update -> 更新 Renderer 渲染器
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()
})
// 监视构建 clientManifest -> 调用 update -> 更新 Renderer 渲染器
const clientConfig = require('./webpack.client.config')
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 // 关闭它本身的日志输出
}))
// 重要!!!将 clientDevMiddleware 挂载到 Express 服务中,提供对其内部内存中数据的访问
server.use(clientDevMiddleware)
return onReady
}
使用 chokidar 第三方库加载监视的变化
是封装了 fs.watch 和fs.watchFile,更加方便使用
chokidar.watch(templatePath).on('change', () => {
在此处科员监听对应的文件变化
})
服务端监听打包
通过webpack 创建对应的编译器
重新构建,需要先删除之前生成的,创建新的,放到内存中
热更新
使用 webpack-hot-middleware
在打包之后自动刷新浏览器
使用: npm i --save-dev webpack-hot-middleware
在plugins中引入
const clientConfig = require('./webpack.client.config')
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 // 关闭它本身的日志输出
}))
编写通用应用的注意项
使用vue-router使用方式和 客户端使用方法基本相同
用法
新建router文件夹,新建index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '@/pages/Home'
Vue.use(VueRouter)
export const createRouter = () => {
const router = new VueRouter({
mode: 'history', // 兼容前后端
routes: [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/about',
name: 'about',
component: () => import('@/pages/About')
},
{
path: '/posts',
name: 'post-list',
component: () => import('@/pages/Posts')
},
{
path: '*',
name: 'error404',
component: () => import('@/pages/404')
}
]
})
return router
}
// entry-server.js
import {
createApp } from './app'
export default async context => {
// 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
// 以便服务器能够等待所有的内容在渲染前,
// 就已经准备就绪。
const {
app, router, store } = createApp()
const meta = app.$meta()
// 设置服务器端 router 的位置
router.push(context.url)
context.meta = meta
// 等到 router 将可能的异步组件和钩子函数解析完
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
}
服务端适配
修改server.js中的get监听路由
// 服务端路由设置为 *,意味着所有的路由都会进入这里
server.get('*', isProd
? render
: async (req, res) => {
// 等待有了 Renderer 渲染器以后,调用 render 进行渲染
await onReady
render(req, res)
}
)
不同页面定义自己的head部分
Vue ssr指南上有专门的head管理
也可以使用vue-meta插件,使用对应的metaInfo
npm i vue-meta
在对应入口文件注册
Vue.use(VueMeta)
服务端渲染接口数据
基于vuex创建容器
npm i vuex
创建对应的store文件夹下index.js
use方式引入vuex
vuex中请求成功在将数据保存到客户端,解决数据刷新丢失问题
在entry-server.js中,使用context对象设置rendered将数据填充到客户端
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: {
// 在服务端渲染期间务必让 action 返回一个 Promise
async getPosts ({
commit }) {
// return new Promise()
const {
data } = await axios.get('https://cnodejs.org/api/v1/topics')
commit('setPosts', data.data)
}
}
})
}
context.rendered = () => {
// Renderer 会把 context.state 数据对象内联到页面模板中
// 最终发送给客户端的页面中会包含一段脚本:window.__INITIAL_STATE__ = context.state
// 客户端就要把页面中的 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中
context.state = store.state
}