vue-ssr demo 链接 https://github.com/zenghao1998/vue-ssr/tree/main
Vue SSR
什么是 SSR
服务端渲染:凡是是从服务器返回的html页面,均算作是服务端渲染,包括php,jsp,nodejs,
SSR 的优点
-
更好的 SEO
-
更快的内容到达时间
为什么使用 SSR
在传统 vue 单页面应用中,页面的渲染都是由 js 完成,在服务端返回的html文件中,body中只有一个div标签和一个script标签,页面其余的dom结构都将由bundle.js生成,然后挂载到<div id="app"></div>
上。这让搜索引擎爬虫抓取工具无法爬取页面的内容,如果 SEO 对你的站点很重要,则你可能需要服务器端渲染(SSR)解决此问题。
SSR 基本使用
ssr 的本质就服务端返回渲染好的 html 文档。我们先在项目根目录启动一个服务器,然后返回一个html 文档。这里我们使用 express 作为服务端框架。
const server = require('express')()
server.get('*', (req, res) => {
res.end(`<!DOCTYPE html>
<html lang="en">
<head><title>Vue SSR</title></head>
<body>
<div>This is a server render page</div>
</body>
</html>`)
})
server.listen(8088, () => {
console.log('http://127.0.0.1:8088')
})
打开 http://127.0.0.1:8088 右键查看源代码 ,服务端返回的内容如下。
vue 使用 SSR
一个最简单官方示例
const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer()
server.get('*', (req, res) => {
const app = new Vue({
data: {
url: req.url
},
template: `<div>访问的 URL 是:{{ url }}</div>`
})
renderer.renderToString(app, (err, html) => {
if (err) {
res.status(500).end('Internal Server Error')
return
}
res.end(`
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>${html}</body>
</html>
`)
})
})
server.listen(8080)
vue 使用 ssr渲染最核心是:通过 vue-server-renderer 这个库把 vue 对象转换成字符串,返回给客户端。
此时一个最简单的 vue-ssr 已经实现了。
模块化使用 vue-ssr
我们搭建一个本地项目,首先我们需要一个入口文件,app.js。
import Vue from 'vue'
import App from './App.vue'
//这里需要返回一个函数,避免单例状态,我们需要知道 node 服务是一个长期运行的进程,当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。
export function createApp() {
const app = new Vue({
store,
router,
render: h => h(App)
})
return { app }
}
创建 client-entry.js 用于服务端渲染后客户端激活。
import { createApp } from './app.js';
const { app } = createApp();
app.$mount('#app');
它需要做的事情很简单,直接挂载到根标签即可。
创建 server-entry.js 用于服务端渲染
import { createApp } from './app.js';
export default function (context) {
//这个方法服务端渲染会调用 renderer.renderToString() 时调用
return new Promise((reslove, reject) => {
const { app } = createApp();
reslove(app)
})
};
和客户端代码不同的是,这里需要返回一个工厂函数,保证用户每次访问服务端都是一个全新的 vue。
创建 index.template.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
<!--vue-ssr-outlet-->
</div>
</body>
</html>
服务端会把 server-entry.js 里的 vue 对象通过 vue-server-renderer 解析成字符串放在这里 <!--vue-ssr-outlet-->
。
创建 build 文件夹,使用 webpack 打包客户端代码 vue 和 服务端 vue
创建 webpack.base.config.js ( 通用配置 )
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const path = require('path');
module.exports = {
resolve: {
extensions: ['.js', '.vue', '.json'],// 自动补全后缀
alias: {
'@': path.resolve(__dirname, '../src'),// 路径别名
}
},
mode: 'development',
output: {//打包的出口
path: path.resolve(__dirname, '../dist'),
filename: '[name].js'
},
module: {
rules: [
{// 配置 vue-loader 才能正常解析 .vue 文件
test: /\.vue$/,
loader: 'vue-loader'
},
{// vue-style-loader 支持服务端渲染啊 style 样式
test: /\.css$/,
use: ['vue-style-loader', 'css-loader']
},
{// 配置 babel es6 转 es5
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/, // 排除第三方的包
options: {
presets: [
[
'@babel/preset-env',
{
modules: false, // 不转换成 commonjs 模块
useBuiltIns: 'usage', // 按需 polyfill
corejs: 3,
}
]
]
}
}
]
},
plugins: [
new VueLoaderPlugin(), // 这个和 vue-loader 一起的用于解析 .vue 文件
]
};
创建 webpack.client.config.js ( 打包客户端代码 )
const { merge } = require('webpack-merge');
const base = require('./webpack.base.config.js');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = merge(base, {
entry: {
client: path.join(__dirname, '../src/client-entry.js'), // 指定客户端代码入口
},
plugins: [
new HtmlWebpackPlugin({ //使用 index.template.html 模板
template: path.join(__dirname, '../src/index.template.html'),
filename: 'index.ssr.html',
minify: false,
})
]
})
创建 webpack.server.config.js( 打包服务端代码给 node 使用 )
const { merge } = require('webpack-merge');
const base = require('./webpack.base.config.js');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = merge(base, {
target: 'node', // 指定是node环境
entry: { // 指定客户端代码入口
server: path.join(__dirname, '../src/server-entry.js')
},
output: {
libraryTarget: 'commonjs2' // 必须按照 commonjs规范打包才能被服务器调用。
},
})
创建 server.js 服务端渲染
当然,要返回的html字符串可以是由vue模板生成的,这就需要用到vue-server-renderer,它会基于Vue实例生成html字符串,是Vue SSR的核心。server.js中使用
const fs = require('fs');
const path = require('path');
const express = require('express');
const server = express();
//设置静态目录
server.use(express.static('dist'));
//获取到打包后的服务端vue代码
const bundle = fs.readFileSync(path.resolve(__dirname, './dist/server.js'), 'utf-8');
//拿着vue代码和模板生成字符串
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
template: fs.readFileSync(path.resolve(__dirname, './dist/index.ssr.html'), 'utf-8'),
});
server.get('*', (req, res) => {
if (req.url !== "/favicon.ico") {
// 调用 server-entry.js 触发工厂函数,服务端渲染数据
renderer.renderToString().then((html) => {
res.end(html)
})
}
});
server.listen(8011, () => {
console.log('http://127.0.0.1:8011');
});
此时一个简单的模块化SSR已经好了,但是目前还不支持vue-router 和 状态管理 vuex ,现在对上面的代码改造一下。
在 src 下创建 router 和 store。
// router.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import baz from '@/components/baz'
import foo from '@/components/foo'
Vue.use(VueRouter)
// 返回一个函数,避免单例状态
export default function createRouter() {
return new VueRouter({
mode: "history",
routes: [
{
path: "/",
name: 'baz',
component: baz
},
{
path: "/foo",
name: 'foo',
component: foo
}
]
})
}
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
// 返回一个函数,避免单例状态
export default function createStore() {
return new Vuex.Store({
state: {
article: '数据'
},
actions: {
GET_ARTICLE({ commit }) {
return new Promise((r) => {
setTimeout(() => {
commit('SET_ARTICLE', 'vuex数据')
r()
}, 1000);
})
}
},
mutations: {
SET_ARTICLE(state, data) {
state.article = data
}
}
})
}
router 和 vuex 需要给服务端使用,返回一个函数,避免单例状态。
对 app.js 改造
import Vue from 'vue'
import App from './App.vue'
import createStore from './store/index.js';
import createRouter from './router/index.js';
//每次访问都创建一个新的vue 主要用于服务端
export function createApp() {
//创建 store
const store = createStore();
//创建 router
const router = createRouter();
const app = new Vue({
store,
router,
render: h => h(App)
})
return { app, store, router }
}
对 server-entry 改造
import { createApp } from './app.js';
// 服务端渲染会调用此方法
export default function (context) {
return new Promise((reslove, reject) => {
const { app, store, router } = createApp();
//context.url => renderer.renderToString({url: req.url})
//服务端跳转页面
router.push(context.url);
//服务端跳转页面完成
router.onReady(() => {
//获取页面级组件
const matchedComponents = router.getMatchedComponents();
//没有匹配成功返回 404
if (!matchedComponents.length) {
return reject({ code: 404 })
}
//对所有匹配的路由组件调用 `asyncData()`
//拿到页面组件上的asyncData调用
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store,
})
}
})).then(() => {
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state
reslove(app)
})
})
})
};
对 client-entry 改造
import { createApp } from './app.js';
const { app, store } = createApp();
//客户端激活时替换state状态
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__);
};
app.$mount('#app');
对 server.js 改造
const fs = require('fs');
const path = require('path');
const express = require('express');
const server = express();
server.use(express.static('dist'));
//获取到服务端vue代码
const bundle = fs.readFileSync(path.resolve(__dirname, './dist/server.js'), 'utf-8');
//拿着js代码和模板生成字符串
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
template: fs.readFileSync(path.resolve(__dirname, './dist/index.ssr.html'), 'utf-8'), // 服务端渲染数据
});
server.get('*', (req, res) => {
if (req.url !== "/favicon.ico") {
renderer.renderToString({// 这个 url 会传给 server-entry.js 里的 context
url: req.url
}).then((html) => {
res.end(html)
}).catch((error) => {
if (error.code == 404) {
res.writeHead(404, {
"content-type": "text/html;charset=utf8"
})
res.end('找不到页面')
}
})
}
});
server.listen(8011, () => {
console.log('http://127.0.0.1:8011');
});
vue-ssr 本质上就是通过 webpack 打包 client-entry.js 和 server-entry.js 代码,首次进入页面通过 vue-server-renderer 把 server-entry.js 的 vue 生成字符串返回给客户端渲染,后续通过 client-entry.js 进行客户端激活。
所谓客户端激活,指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。
完整代码 https://github.com/zenghao1998/vue-ssr/tree/main