参考视频 手把手教你打造Vue SSR
nodemon 包可监听node.js文件变化,自动重启更新
目录
- 初始化vue项目
- 修改server端和client端webpack打包配置
- 修改server端和client端入口文件|路由|vuex
- asyncData服务器数据预取
- 增加nodejs服务端启动脚本和html模板
- head占位符
初始化新vue项目
- ssr渲染过程一是node端输出html带dom结构,二是客户端请求js激活dom结构挂载vue特性
面向C端的项目一般都要SSR
优势是:
- SEO搜索
- 缩短首屏白屏时间
缺点是:
- 有些库不支持ssr,要改造
- 服务端需要nodejs服务,增加了维护成本
- 写法稍不一样,需要提高技术含量
- 服务端只有beforeCreate和created生命周期
初始化vue新项目
vue create ssrtest
装包
主要是 vue-server-renderer、express
npm install vue-server-renderer express cross-env webpack-node-externals -D
npm install vue-router vuex
注意vue2,3和vue-router版本要匹配
"vue": "^2.6.10",
"vue-router": "^3.2.0",
"vuex": "^3.4.0"
修改server端和client端webpack打包配置
- server端webpack的目的是生成vue-ssr-server-bundle.json,运行在nodejs服务端
- client端webpack的目的是生成vue-ssr-client-manifest.json,是客户端按需加载激活前端dom结构
新建vue.config.js
vue启动的时候@vue/cli-service会自动加载加载vue.config.js配置
const TARGET_NODE = process.env.WEBPACK_TARGET === 'node';
const serverConfig = require('./vue.server.config');
const clientConfig = require('./vue.client.config');
if (TARGET_NODE) {
module.exports = serverConfig;
} else {
module.exports = clientConfig;
}
新建vue.client.config.js
vue官方提供了ssr的插件vue-server-renderer/client-plugin
const VueSSRClientPlugin = require("vue-server-renderer/client-plugin");
module.exports = {
configureWebpack: () => ({
entry: `./src/client-entry.js`,
devtool: "source-map",
target: "web",
plugins: [new VueSSRClientPlugin()],
}),
chainWebpack: (config) => {
config.plugins.delete("html");
config.plugins.delete("preload");
config.plugins.delete("prefetch");
},
};
新建vue.server.config.js
vue官方提供了ssr的插件vue-server-renderer/server-plugin
const nodeExternals = require("webpack-node-externals");
const VueSSRServerPlugin = require("vue-server-renderer/server-plugin");
module.exports = {
css: {
// 不提取 CSS
extract: false,
},
outputDir: "serverDist",
configureWebpack: () => ({
// 服务器入口文件
entry: `./src/server-entry.js`,
devtool: "source-map",
// 构建目标为nodejs环境
target: "node",
output: {
// 构建目标加载模式 commonjs
libraryTarget: "commonjs2",
},
// 跳过 node_mdoules,运行时会自动加载,不需要编译
externals: nodeExternals({
// 允许css文件,方便css module
allowlist: [/\.css$/],
}),
// 关闭代码切割
optimization: {
splitChunks: false,
},
plugins: [new VueSSRServerPlugin()],
}),
};
package.json修改命令
{
"scripts": {
"server": "npm run build && npm run build:server && node server.js",
"dev": "vue-cli-service serve",
"build": "vue-cli-service build",
"build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build --mode server"
},
}
修改server端和client端入口文件|路由|vuex
新建src/server-entry.js
- 从createApp获取新vue、router、vuex对象
- router.push(context.url)返回当前请求路由页面
- getMatchedComponents获得当前页面组件
- 调用asyncData方法获得接口数据存放到store中
- vue-server-renderer库会将context.state中数据放到 window.INITIAL_STATE
import createApp from './createApp';
export default context => {
// 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
// 以便服务器能够等待所有的内容在渲染前就已经准备就绪。
return new Promise((resolve, reject) => {
const { app, router, store } = createApp();
// 设置服务器端 router 的位置
router.push(context.url)
// onReady 等到 router 将可能的异步组件和钩子函数解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
// 匹配不到的路由,执行 reject 函数,并返回 404
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// 对所有匹配的路由组件调用 `asyncData()`
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute
});
}
})).then(() => {
// 状态传递给renderer的上下文,方便后面客户端激活数据
context.state = store.state;
resolve(app)
}).catch(reject);
}, reject);
})
}
新建src/client-entry.js
- 从createApp获取新vue、router、vuex对象
- 根据 window.INITIAL_STATE 加载数据
- 挂载激活客户端dom结构
import Vue from 'vue';
import createApp from './createApp';
Vue.config.productionTip = false
const { app, router, store } = createApp();
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__);
}
router.onReady(() => {
app.$mount('#app', true);
});
新建src/createApp.js
- 获取新router和vuex对象,返回新vue实例
import Vue from "vue";
import App from "./App";
import createRouter from "./createRouter";
import createStore from "./createStore";
export default function createApp(context) {
const router = createRouter();
const store = createStore();
const app = new Vue({
router,
store,
render: (h) => h(App),
});
return {
router,
store,
app,
};
}
新建 src/createRouter.js
- 返回路由创建函数
import Vue from 'vue';
import Router from 'vue-router';
import Home from './Home';
Vue.use(Router)
const routes = [{
path: '/',
name: 'Home',
component: Home
}];
export default function createRouter() {
return new Router({
mode: 'history',
routes
})
}
创建src/createStore.js
返回新vuex实例
import Vue from 'vue';
import Vuex from 'vuex';
import {fetchItem} from './api';
Vue.use(Vuex);
export default function createStore() {
return new Vuex.Store({
state: {
item: {}
},
actions: {
fetchItem({ commit }, id) {
return fetchItem(id).then(item => {
commit('setItem', item);
});
}
},
mutations: {
setItem(state, item) {
state.item = item;
}
}
})
}
修改App.vue
<template>
<div id="app">
<router-link :to="{path: '/'}">Home</router-link>
<router-link :to="{path: '/about'}">About</router-link>
<router-view />
</div>
</template>
组件asyncData预请求数据
新建src/Home.vue
<template>
<div>
<div>id: {{ item.id }}</div>
<div>name: {{ item.name }}</div>
</div>
</template>
<script>
export default {
asyncData({ store, route }) {
// 触发 action 后,会返回 Promise
return store.dispatch("fetchItem", route.query.id || 2);
},
computed: {
// 从 store 的 state 对象中的获取 item。
item() {
return this.$store.state.item;
},
},
};
</script>
新建 src/api.js
export function fetchItem(id) {
const items = [
{ name: 'item1', id: 1 },
{ name: 'item2', id: 2 },
{ name: 'item3', id: 3 }
];
const item = items.find(i => i.id == id);
return Promise.resolve(item);
}
增加nodejs服务端启动脚本和html模板
新建server.js的node启动文件
const express = require('express');
const {createBundleRenderer} = require('vue-server-renderer');
const path = require('path');
const fs = require('fs');
const app = express();
const serverBundle = path.resolve(process.cwd(), 'serverDist', 'vue-ssr-server-bundle.json');
const clientManifestPath = path.resolve(process.cwd(), 'dist', 'vue-ssr-client-manifest.json');
const clientManifest = JSON.parse(fs.readFileSync(clientManifestPath, 'utf-8'));
const template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8');
const renderer = createBundleRenderer(serverBundle, {
runInNewContent: false,
template,
clientManifest
});
app.use(express.static(path.resolve(process.cwd(), 'dist'),{index:false}));
app.get('*', function(req, res) {
const context = {
url: req.url
};
renderer.renderToString(context, (err, html) => {
if (err) {
console.log(err);
res.send('500 server error');
return;
}
res.send(html);
})
});
app.listen(3001, () => {
console.log('listen:3001');
});
新建index.html模板
- 固定 vue-ssr-outlet 占位符
<html>
<head>
<title>SSR</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
运行效果
可以看到运行请求源码中是有html结构的
head占位符
head占位符
context.任意串:讲作为占位符在html模板中使用 {{title}} {{meta}} {{{meta}}}三个括号不转义context.head:(字符串)将会被作为 HTML 注入到页面的头部 (head) 里。context.styles:(字符串)内联 CSS,将以 style 标签的形式注入到页面头部。注意,如过你使用了vue-loader+vue-style-loader来处理组件 CSS,此属性会在构建过程中被自动生成。context.state:(对象)初始 Vuex store 状态,将以window.__INITIAL_STATE__的形式内联到页面