概念:放在浏览器进行就是浏览器渲染,放在服务器进行就是服务器渲染。
- 客户端渲染不利于 SEO 搜索引擎优化
- 服务端渲染是可以被爬虫抓取到的,客户端异步渲染是很难被爬虫抓取到的
- SSR直接将HTML字符串传递给浏览器。大大加快了首屏加载时间。
- SSR占用更多的CPU和内存资源
- 一些常用的浏览器API可能无法正常使用
- 在vue中只支持beforeCreate和created两个生命周期
Vue2之SSR
- https://v2.ssr.vuejs.org/zh/
ssr运行过程
- 只是首屏做ssr,服务端渲染
- 后续的切换逻辑,执行的都是客户端渲染(前端路由来切换)
安装
yarn add vue-server-renderer vue
yarn add koa koa-router
-
createRenderer,创建一个渲染函数 renderToString, 渲染出一个字符串
-
server.js
// server.js
const Vue = require('vue');
const render = require('vue-server-renderer');
const Koa = require('koa');
const Router = require('koa-router');
const app = new Koa();
const router = new Router();
const vm = new Vue({
data(){
return {msg:"hello world"}
},
template:`<div>{{msg}}</div>`
});
router.get('/',async (ctx)=>{
// 把vue实例渲染为html字符串
let r = await render.createRenderer().renderToString(vm);
ctx.body = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
${r}
</body>
</html>
`
});
app.use(router.routes());
app.listen(4000);
采用模板渲染
-
在index.html中添加一个
<!--vue-ssr-outlet-->
<!DOCTYPE html> <html lang="en"> <head><title>Hello</title></head> <body> <!--vue-ssr-outlet--> </body> </html>
-
在server.js中使用模板,进行渲染
- 传入template 替换掉注释标签
// server.js const Vue = require('vue'); const render = require('vue-server-renderer'); const Koa = require('koa'); const Router = require('koa-router'); const app = new Koa(); const router = new Router(); const vm = new Vue({ data(){ return {msg:"hello world"} }, template:`<div>{{msg}}</div>` }); // 读取模板 const template = require('fs').readFileSync('./index.html','utf8'); router.get('/',async (ctx)=>{ // vm中的内容插入模板中 let r = await render.createRenderer({ template }).renderToString(vm); ctx.body = r; }); app.use(router.routes()); app.listen(4000);
通过webpack实现编译vue项目
安装插件
vue-style-loader基于style-loader,支持服务端渲染
yarn add webpack webpack-cli webpack-dev-server vue-loader vue-style-loader css-loader html-webpack-plugin @babel/core @babel/preset-env babel-loader vue-template-compiler webpack-merge
app.js
// app.js
app.js
import Vue from "vue";
import App from "./App.vue";
export default () => { // 为了保证实例的唯一性所以导出一个创建实例的函数
const app = new Vue({
render: h => h(App)
});
return { app };
};
client-entry.js
// client-entry.js
import createApp from "./app";
const { app } = createApp();
app.$mount("#app"); // 客户端渲染手动挂载到dom元素上
server-entry.js
// server-entry.js
import createApp from "./app";
export default () => {
const { app } = createApp();
return app; // 服务端渲染只需将渲染的实例导出即可
};
webpack.config.js
// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const resolve = (dir)=>{
return path.resolve(__dirname,dir)
}
module.exports = {
entry: resolve('./src/client-entry.js'),
output:{
filename:'[name].bundle.js',
path:resolve('dist')
},
module:{
rules:[
{
test:/\.css$/,
use:['vue-style-loader','css-loader']
},
{
test:/\.js$/,
use:{
loader:'babel-loader',
options:{
presets:['@babel/preset-env']
}
},
exclude:/node_modules/
},
{
test:/.vue$/,
use:'vue-loader'
}
]
},
plugins:[
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
template:'./index.html'
})
]
}
配置客户端打包和服务端打包
webpack.base.js
-
webpack.base.js
let path = require('path'); let VueLoaderPlugin = require('vue-loader/lib/plugin') module.exports = { output:{ filename:'[name].bundle.js', path:path.resolve(__dirname,'../dist') }, module:{ rules:[ {test:/\.css/,use:['vue-style-loader','css-loader']}, { test:/\.js/, use:{ loader:'babel-loader', options:{ presets:['@babel/preset-env'] }, }, exclude:/node_modules/, }, {test:/\.vue/,use:'vue-loader'} ] }, plugins:[ new VueLoaderPlugin() ] }
webpack.client.js(客户端)
-
webpack.client.js(客户端)
const {merge} = require("webpack-merge"); const path = require("path"); const HtmlWebpackPlugin = require("html-webpack-plugin"); const base = require("./webpack.base"); const resolve = filepath => { return path.resolve(__dirname, filepath); }; module.exports = merge(base, { entry: { client: resolve("../src/client-entry.js") }, plugins: [ new HtmlWebpackPlugin({ template: resolve("../template/index.client.html") }) ] });
webpack.server.js(服务端)
webpack打包服务端代码,是不需要引入打包后的js的,只是引入前端的打包的结果
-
webpack.server.js(服务端)
const {merge} = require("webpack-merge"); const path = require("path"); const HtmlWebpackPlugin = require("html-webpack-plugin"); const base = require("./webpack.base"); const resolve = filepath => { return path.resolve(__dirname, filepath); }; module.exports = merge(base, { entry: { server: resolve("../src/server-entry.js") }, target: "node", output: { libraryTarget: "commonjs2" // 导出供服务端渲染来使用 }, plugins: [ new HtmlWebpackPlugin({ filename: "index.ssr.html", // 不压缩 minify:false, template: resolve("../template/index.ssr.html"), // 排除引入文件 excludeChunks: ["server"] }) ] });
-
index.ssr.html
<!DOCTYPE html> <html lang="en"> <head><title>Hello</title></head> <body> <!--vue-ssr-outlet--> </body> </html>
配置运行脚本
"scripts": {
"client:dev": "webpack-dev-server --config ./build/webpack.client.js", // 客户端开发环境
"client:build": "webpack --config ./build/webpack.client.js", // 客户端打包环境
"server:build": "webpack --config ./build/webpack.server.js" // 服务端打包环境
},
并行执行多个命令
使用concurrently
- https://www.npmjs.com/package/concurrently
- 安装
npm i concurrently
"scripts": {
"client:dev": "webpack-dev-server --config ./build/webpack.client.js", // 客户端开发环境
"client:build": "webpack --config ./build/webpack.client.js", // 客户端打包环境
"server:build": "webpack --config ./build/webpack.server.js", // 服务端打包环境
"run:all": "concurrently \"npm run client:build\" \"npm run server:build\"" // 并行打包环境
},
服务端配置
- 在App.vue上增加id="app"可以保证元素被正常激活
server.js
// server.js
const Koa = require("koa");
const Router = require("koa-router");
const static = require("koa-static");
const path = require("path");
const app = new Koa();
const router = new Router();
const VueServerRenderer = require("vue-server-renderer");
const fs = require("fs");
// 服务端打包的结果
const serverBundle = fs.readFileSync("./dist/server.bundle.js", "utf8");
const template = fs.readFileSync("./dist/index.ssr.html", "utf8");
const render = VueServerRenderer.createBundleRenderer(serverBundle, {
template
});
router.get("/", async ctx => {
ctx.body = await new Promise((resolve, reject) => {
render.renderToString((err, html) => {
// 必须写成回调函数的方式否则样式不生效
resolve(html);
});
});
});
app.use(router.routes());
app.use(static(path.resolve(__dirname, "dist")));
app.listen(3000);
在index.ssr.html中需要手动引入客户端打包后的结果,也就是引入
dist/client.bundle.js
通过json配置createBundleRenderer方法
-
实现热更新,自动增加preload和prefetch,以及可以使用sourceMap
-
在
webpack.client.js
中引入vue-server-renderer/client-plugin
并在plugins
中使用 -
在
webpack.server.js
中引入vue-server-renderer/server-plugin
并在plugins
中使用 -
配置之后打会产生对应的json文件
webpack.client.js
// webpack.client.js
const {merge} = require("webpack-merge");
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const base = require("./webpack.base");
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const resolve = filepath => {
return path.resolve(__dirname, filepath);
};
module.exports = merge(base, {
entry: {
client: resolve("../src/client-entry.js")
},
plugins: [
new VueSSRClientPlugin(),
new HtmlWebpackPlugin({
template: resolve("../template/index.client.html")
})
]
});
webpack.server.js
// webpack.server.js
const {merge} = require("webpack-merge");
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const base = require("./webpack.base");
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const resolve = filepath => {
return path.resolve(__dirname, filepath);
};
module.exports = merge(base, {
entry: {
server: resolve("../src/server-entry.js")
},
target: "node",
output: {
libraryTarget: "commonjs2" // 导出供服务端渲染来使用
},
plugins: [
new VueSSRServerPlugin(),
new HtmlWebpackPlugin({
filename: "index.ssr.html",
// 不压缩
minify:false,
template: resolve("../template/index.ssr.html"),
// 排除引入文件
excludeChunks: ["server"]
})
]
});
server.js
// server.js
const Vue = require('vue');
const VueServerRenderer = require('vue-server-renderer');
const Koa = require('koa');
const Router = require('@koa/router');
const fs = require('fs');
const static = require('koa-static');
const path = require('path')
const app = new Koa();
const router = new Router();
// const serverBundle = fs.readFileSync(path.resolve(__dirname, 'dist/server.bundle.js'), 'utf8');
// const template = fs.readFileSync(path.resolve(__dirname, 'dist/index.ssr.html'), 'utf8');
// const render = VueServerRenderer.createBundleRenderer(serverBundle, {
// template
// })
const serverBundle = require('./dist/vue-ssr-server-bundle.json');
const template = fs.readFileSync(path.resolve(__dirname, 'dist/index.ssr.html'), 'utf8');
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const render = VueServerRenderer.createBundleRenderer(serverBundle,{
template,
clientManifest // 通过后端注入前端的js脚本
})
router.get("/(.*)", async (ctx) => {
// 客户端 = template + 编译的结果 = 组成的html
// 在我们渲染页面的时候 需要让服务器根据当前路径渲染对应的路由
try{
ctx.body = await render.renderToString({url:ctx.url});
}catch(e){
console.log(e);
if(e.code == 404){
ctx.body = 'page not found'
}
}
});
// 先匹配静态资源 资源找不到在找对应的api
// 数据获取可以使用axios
app.use(static(path.resolve(__dirname,'dist'))); // 使用静态服务插件
app.use(router.routes());
app.listen(4000);
集成VueRouter
history api 默认刷新会变成404
-
安装
yarn add vue-router
-
create-router.js
// create-router.js import Vue from "vue"; import VueRouter from "vue-router"; import Foo from "./components/Foo.vue"; Vue.use(VueRouter); export default () => { const router = new VueRouter({ mode: "history", routes: [ { path: "/", component: Foo }, { path: "/bar", component: () => import("./components/Bar.vue") } ] }); return router; };
导出路由配置
配置入口文件app.js
app.js
import Vue from "vue";
import App from "./App.vue";
import createRouter from "./router";
export default () => {
const router = createRouter();
const app = new Vue({
router,
render: h => h(App)
});
return { app, router };
};
配置组件信息
App.vue
<template>
<div id="app">
<router-link to="/"> foo</router-link>
<router-link to="/bar"> bar</router-link>
<router-view></router-view>
</div>
</template>
防止刷新页面不存在
配置 router.get(“/(.*)”
server.js
const Vue = require('vue');
const VueServerRenderer = require('vue-server-renderer');
const Koa = require('koa');
const Router = require('@koa/router');
const fs = require('fs');
const static = require('koa-static');
const path = require('path')
const app = new Koa();
const router = new Router();
// const serverBundle = fs.readFileSync(path.resolve(__dirname, 'dist/server.bundle.js'), 'utf8');
// const template = fs.readFileSync(path.resolve(__dirname, 'dist/index.ssr.html'), 'utf8');
// const render = VueServerRenderer.createBundleRenderer(serverBundle, {
// template
// })
const serverBundle = require('./dist/vue-ssr-server-bundle.json');
const template = fs.readFileSync(path.resolve(__dirname, 'dist/index.ssr.html'), 'utf8');
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const render = VueServerRenderer.createBundleRenderer(serverBundle,{
template,
clientManifest // 通过后端注入前端的js脚本
})
router.get("/(.*)", async (ctx) => {
// 客户端 = template + 编译的结果 = 组成的html
// 在我们渲染页面的时候 需要让服务器根据当前路径渲染对应的路由
try{
ctx.body = await render.renderToString({url:ctx.url});
}catch(e){
console.log(e);
if(e.code == 404){
ctx.body = 'page not found'
}
}
});
// 先匹配静态资源 资源找不到在找对应的api
// 数据获取可以使用axios
// 静态资源要放在路由之前
app.use(static(path.resolve(__dirname,'dist'))); // 使用静态服务插件
app.use(router.routes());
app.listen(4000);
保证异步路由加载完成
ssr切换流程:服务端会先拿到路径,先在服务端渲染好对应的页面,把结果返回。当客户端切换的时候,就由客户端路由接管了
server-entry.js
// server-entry.js
import createApp from './app';
// 此方法是服务端运行的
export default (context)=>{ // context.url 这里包含着当前访问服务端的路径
return new Promise((resolve,reject)=>{
const {app,router,store} = createApp();
router.push(context.url); // 默认跳转到路径里,有异步组件
router.onReady(()=>{
const matchComponents = router.getMatchedComponents(); // 获取匹配到的组件
if(matchComponents.length > 0){ // 匹配到路由了
// 调用组件对应的asyncData
Promise.all(matchComponents.map(component=>{
// 需要所有的asyncdata方法执行完毕后 才会响应结果
if(component.asyncData){
// 返回的是promise
return component.asyncData(store);
}
})).then(()=>{
context.state = store.state;// 将状态放到上下文中
resolve(app)// 每次都是一个新的 只是产生一个实例 服务端根据实例 创建字符串
},reject)
}else{
reject({code:404}); // 没有匹配到路由
}
},reject)
// return app;
})
}
集成vuex配置
-
安装
yarn add vuex
-
create-store.js
import Vue from 'vue'; import Vuex from 'vuex'; Vue.use(Vuex); export default ()=>{ let store = new Vuex.Store({ state:{ username:'song' }, mutations:{ changeName(state){ state.username = 'hello'; } }, actions:{ changeName({commit}){ return new Promise((resolve,reject)=>{ setTimeout(() => { commit('changeName'); resolve(); }, 1000); }) } } }); return store }
-
app.js
// app.js // 引用vuex import createRouter from './router'; import createStore from './store' export default ()=>{ let router = createRouter(); let store = createStore(); let app = new Vue({ router, store, render:(h)=>h(App) }) return {app,router,store} }
在后端更新vuex
server-entry.js
// server-entry.js
import createApp from './app';
export default (context)=>{
return new Promise((resolve)=>{
let {app,router,store} = createApp();
router.push(context.url); // 默认访问到/a就跳转到/a
router.onReady(()=>{
let matchComponents = router.getMatchedComponents(); // 获取路由匹配到的组件
Promise.all(matchComponents.map(component=>{
if(component.asyncData){
return component.asyncData(store);
}
})).then(()=>{
context.state = store.state; // 将store挂载在window.__INITIAL_STATE__
resolve(app);
});
})
})
}
在浏览器运行时替换store
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
export default () => {
let store = new Vuex.Store({
state: {
name: 'jw'
},
mutations: {
changeName(state, payload) {
state.name = payload;
}
},
actions: {
changeName({ commit }, payload) {
return new Promise((resolve,reject)=>{
setTimeout(() => {
commit('changeName', payload);
resolve();
}, 1000);
})
}
}
});
// 前端运行时会执行此方法 ,用服务端的状态替换掉前端的状态
if(typeof window !=='undefined' && window.__INITIAL_STATE__){
store.replaceState(window.__INITIAL_STATE__)
}
return store; // 导出容器
}
需要执行的钩子函数
export default {
mounted() {
return this.$store.dispatch("changeName");
},
// 只有页面级组件才有这个方法
asyncData(store) {
return store.dispatch("changeName");
}
};