首先按国际惯例来,分析 客户端渲染(SPA) 和 服务端渲染(SSR) 的区别:css
若是只是少些页面须要 ssr 来实现SEO,或许你能够了解下 prerender-spa-plugin,使用 预渲染 来实现。
另外 vue 官网还提供了 nuxt 框架,能够开箱即用,进行 srr 项目开发。
接下来,一步步来独立配置一个 ssr 项目。html
第一步咱们先配置一个经常使用的 SPA 应用,也就是在客户端实现渲染。使用的是 webpack + vue ,这个你们应该比较熟悉:
目录结构:
vue
{
"name": "demo01",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "webpack-dev-server --config config/webpack.config.js --port 3000",
"build": "webpack --config config/webpack.config.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"vue": "^2.6.10"
},
"devDependencies": {
"@babel/core": "^7.4.5",
"autoprefixer": "^9.6.0",
"babel-loader": "^8.0.6",
"@babel/preset-env": "^7.4.5",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^3.0.0",
"file-loader": "^4.0.0",
"html-webpack-plugin": "^3.2.0",
"postcss-loader": "^3.0.0",
"url-loader": "^2.0.0",
"vue-loader": "^15.7.0",
"vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.6.10",
"webpack": "^4.34.0",
"webpack-cli": "^3.3.4",
"webpack-dev-server": "^3.7.1"
}
}
复制代码
webpack配置:(/config/webpack.config.js)node
var path = require('path')
var VueLoaderPlugin = require('vue-loader/lib/plugin')
var HtmlWebpackPlugin = require('html-webpack-plugin')
var CleanWebpackPlugin = require('clean-webpack-plugin').CleanWebpackPlugin
module.exports = {
mode: 'development',
entry: path.resolve(__dirname, '../src/app.js'),
output: {
path: path.resolve(__dirname, '../dist')
},
module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.css$/,
use: ['vue-style-loader', 'css-loader', 'postcss-loader']
},
{
test: /\.(jpg|jpeg|png|gif|svg)$/,
use: {
loader: 'url-loader',
options: {
limit: 10000 // 10Kb
}
}
},
{
test: /\.vue$/,
use: 'vue-loader'
}
]
},
plugins: [
new CleanWebpackPlugin(),
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../src/index.html')
})
]
}
复制代码
.babelrc配置:webpack
{
"presets": [
"@babel/preset-env"
]
}
复制代码
postcss.config.js配置:git
module.exports = {
plugins: [
require('autoprefixer'),
]
}
复制代码
app.js:github
import Vue from 'vue'
import App from './App.vue'
new Vue({
el: '#app',
render: h => h(App)
})
复制代码
App.vue:web
<template>
<section>
<p>vue ssr案例第一步 - 客户端渲染</p>
<home />
<list />
</section>
</template>
<script>
import home from './components/Home.vue'
import list from './components/list.vue'
export default {
name: 'App',
components: {
home,
list
}
}
</script>
复制代码
index.html:vue-router
<!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>客户端渲染 - vue ssr案例第一步</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
复制代码
/src/components/List.vue:vuex
<template>
<section class="list">
list --- list --- list
</section>
</template>
<style>
.list {
background-color:darksalmon;
margin: 20px;
padding: 20px;
}
</style>
复制代码
/src/components/Home.vue:
<template>
<section class="home">
home --- home --- homr 123321
</section>
</template>
<style>
.home {
background-color: aquamarine;
margin: 20px;
padding: 20px;
}
</style>
复制代码
以上就是一个简单的SPA项目。但运行 npm run build 时能够对项目进行一个打包,生成以下文件(可投放于生产):
第二步,咱们来实现一个简单ssr,首先分析下思路,那确定要拿出官网提供原理图了,以下:
从图中能够看到,webpack会从两个入口来进行打包处理,其中经过 Client entry 入口进行客户端的打包,从 Server entry 入口进行服务端打包。
Server entry 打包的文件会在 Node Server (也就是服务端)运行,经过 Bundle Renderer 渲染成了 Html,而后把 HTML 丢给浏览器,浏览器根据获得的 HTML 渲染出页面。
到浏览器端时,此时浏览器已经拿到服务端渲染出来的 HTML ,经过 Client entry 打包出来的 Client Bundle 是用来在浏览器执行(就是 客户端激活 ),用以vue在浏览器端的激活,这样,在浏览器端才能正常执行vue的生命周期以及指令等。
那接下来进行项目的改造。 目录结构:
import { createApp } from './app.js';
const { app } = createApp();
app.$mount('#app');
复制代码
import { createApp } from './app.js';
export default context => {
return new Promise((resolve, reject) => {
const { app } = createApp();
resolve(app);
});
}
复制代码
3.修改app.js。一样也须要返回一个函数,这样每次调用才能产生一个全新的实例。
import Vue from 'vue';
import App from './App.vue';
export function createApp() {
const app = new Vue({
render: h => h(App)
});
return { app };
}
复制代码
4.将webpack的配置分红三部分:公用配置(webpack.base.config.js)、服务端配置(webpack.server.config.js)、客户端配置(webpack.client.config.js)
// webpack.base.config.js
var path = require('path')
var VueLoaderPlugin = require('vue-loader/lib/plugin')
module.exports = {
mode: 'development',
output: {
path: path.resolve(__dirname, '../dist'),
filename: '[name].bundle.js'
},
resolve: {
extensions: ['.js', '.vue']
},
module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.css$/,
use: ['vue-style-loader', 'css-loader', 'postcss-loader']
},
{
test: /\.(jpg|jpeg|png|gif|svg)$/,
use: {
loader: 'url-loader',
options: {
limit: 10000 // 10Kb
}
}
},
{
test: /\.vue$/,
use: 'vue-loader'
}
]
},
plugins: [
new VueLoaderPlugin()
]
}
复制代码
// webpack.client.config.js
const path = require('path');
const merge = require('webpack-merge');
const CleanWebpackPlugin = require('clean-webpack-plugin').CleanWebpackPlugin
var HtmlWebpackPlugin = require('html-webpack-plugin')
const base = require('./webpack.base.config');
module.exports = merge(base, {
entry: {
client: path.resolve(__dirname, '../src/entry-client.js')
},
plugins: [
new CleanWebpackPlugin(),
// 客户端激活
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../src/index.template.html'),
filename: 'index.template.html'
})
]
})
复制代码
// webpack.server.config.js
const path = require('path');
const merge = require('webpack-merge');
const base = require('./webpack.base.config');
module.exports = merge(base, {
// 这容许 webpack 以 Node 适用方式处理动态导入(dynamic import),
// 而且还会在编译 Vue 组件时,告知 `vue-loader` 输送面向服务器代码。
target: 'node',
// 对 bundle renderer 提供 source map 支持
devtool: 'source-map',
entry: {
server: path.resolve(__dirname, '../src/entry-server.js')
},
// 使用 Node 风格导出模块(Node-style exports)
output: {
libraryTarget: 'commonjs2'
}
})
复制代码
5.增长服务配置文件 /bin/www.js ,使用koa来搭建一个服务。
const Koa = require('koa');
const Router = require('koa-router');
const static = require('koa-static');
const path = require('path');
const fs = require('fs');
const app = new Koa()
const router = new Router()
const createBundleRenderer = require('vue-server-renderer').createBundleRenderer
// 服务端执行vue操做
const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.bundle.js'), 'utf-8');
// 客户端激活
const template = fs.readFileSync(path.resolve(__dirname, '../dist/index.template.html'), 'utf-8')
const renderer = createBundleRenderer(bundle, {
template
})
// 资源文件
app.use(static(path.resolve(__dirname, '../dist')));
router.get('/', (ctx, next) => {
// 服务端渲染结果转换成字符串
renderer.renderToString((err, html) => {
if (err) {
console.error(err);
ctx.status = 500;
ctx.body = '服务器内部错误';
} else {
ctx.status = 200;
ctx.body = html; // 将html字符串传到浏览器渲染
}
});
});
// 开启路由
app
.use(router.routes())
.use(router.allowedMethods());
// 应用监听端口
app.listen(3002, () => {
console.log('服务器端渲染地址: http://localhost:3002');
});
复制代码
6.其余文件的代码也贴出来
// App.js
<template>
<section id="app">
<p>服务端渲染(不含 vue-router 和 vuex) - vue ssr案例第二步</p>
<home />
<list />
</section>
</template>
<script>
import home from './components/Home.vue'
import list from './components/list.vue'
export default {
name: 'App',
components: {
home,
list
}
}
</script>
复制代码
// index.template.html
<!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>服务端渲染(不含 vue-router 和 vuex) - vue ssr案例第二步</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
复制代码
// Home.vue
<template>
<section class="home">
home --- home --- homr 123321
</section>
</template>
<style>
.home {
background-color: aquamarine;
margin: 20px;
padding: 20px;
}
</style>
复制代码
// List.vue
<template>
<section class="list">
list --- list --- list
</section>
</template>
<style>
.list {
background-color:darksalmon;
margin: 20px;
padding: 20px;
}
</style>
复制代码
npm run build,打包后产生以下文件:
目录以下:
内置的 source map 支持(在 webpack 配置中使用 devtool: 'source-map')
在开发环境甚至部署过程当中热重载(经过读取更新后的 bundle,而后从新建立 renderer 实例)
关键 CSS(critical CSS) 注入(在使用 *.vue 文件时):自动内联在渲染过程当中用到的组件所需的CSS。更多细节请查看 CSS 章节。
使用 clientManifest 进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk。 1.修改webpack.client.config.js
const path = require('path');
const merge = require('webpack-merge');
const CleanWebpackPlugin = require('clean-webpack-plugin').CleanWebpackPlugin
var HtmlWebpackPlugin = require('html-webpack-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const base = require('./webpack.base.config');
module.exports = merge(base, {
entry: {
client: path.resolve(__dirname, '../src/entry-client.js')
},
plugins: [
new CleanWebpackPlugin(),
new VueSSRClientPlugin(), // 打包成 vue-ssr-client-manifest.json
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../src/index.template.html'),
filename: 'index.template.html'
})
]
})
复制代码
2.修改webpack.server.config.js
const path = require('path');
const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const base = require('./webpack.base.config');
module.exports = merge(base, {
// 这容许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
// 而且还会在编译 Vue 组件时,
// 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
target: 'node',
// 对 bundle renderer 提供 source map 支持
devtool: 'source-map',
// 由于是服务端引用模块,因此不须要打包node_modules中的依赖,直接在代码中require引用就好,生成较小的 bundle 文件。
externals: [nodeExternals({
// 不要外置化 webpack 须要处理的依赖模块。
// 你能够在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
// 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
whitelist: /\.css$/
})],
entry: {
server: path.resolve(__dirname, '../src/entry-server.js')
},
// 使用 Node 风格导出模块(Node-style exports)
output: {
libraryTarget: 'commonjs2'
},
plugins: [
new VueSSRServerPlugin(), // // 打包成 vue-ssr-server-bundle.json
]
})
复制代码
3.新增 /router/index.js。一样的做为一个函数引出,避免在服务器上运行时产生数据交叉污染。
import Vue from 'vue'
import Router from 'vue-router'
import Home from '../components/Home.vue'
import List from '../components/List.vue'
Vue.use(Router)
function createRouter () {
const routes = [
{
path: '/',
component: Home
},
{
path: '/list',
component: List
}
]
const router = new Router({
mode: 'history',
routes
})
return router
}
export default createRouter
复制代码
4.修改app.js。在createApp时带上router
import Vue from 'vue';
import App from './App.vue';
import createRouter from './router/index.js'
export function createApp() {
const router = createRouter()
const app = new Vue({
router,
render: h => h(App)
});
return { app, router };
}
复制代码
5.修改 entry-server.js 。这时须要对路由进行匹配,咱们会从服务端得到当前用户输入的 url 做为 context 参数传进来,而后经过 router.push(context.url) 进行路由跳转,再经过匹配是否能找到该组件来返回对应的状态。
import { createApp } from './app.js';
export default context => {
return new Promise((resolve, reject) => {
const { app, router } = createApp();
// 根据匹配到的路径进行路由跳转
router.push(context.url);
// 在router.onReady的成功回调中,找寻与url所匹配到的组件
router.onReady(() => {
// 查找所匹配到的组件
const matchedComponents = router.getMatchedComponents()
// 未找到组件
if (matchedComponents.length <= 0) {
return reject({
state: 404,
msg: '未找到页面'
})
}
// 成功并返回实例
resolve(app)
}, reject)
});
}
复制代码
6.修改www.js文件。router经过 '*' 来获取全部的请求拦截,并将 ctx.url 获取到的用户当前输入的url做为 renderToString 的参数传,上面第5小步的 'context'也就是这里 renderToString 的一个个参数。
const Koa = require('koa');
const Router = require('koa-router');
const static = require('koa-static');
const path = require('path');
const fs = require('fs');
const app = new Koa()
const router = new Router()
const favicon = require('koa-favicon')
const createBundleRenderer = require('vue-server-renderer').createBundleRenderer
// 记录js文件的内容
const serverBundle = require(path.resolve(__dirname, '../dist/vue-ssr-server-bundle.json'))
// 记录静态资源文件的配置信息
const clientManifest = require(path.resolve(__dirname, '../dist/vue-ssr-client-manifest.json'))
// 客户端激活
const template = fs.readFileSync(path.resolve(__dirname, '../dist/index.template.html'), 'utf-8')
const renderer = createBundleRenderer(serverBundle, {
runInNewContext: false,
template: template,
clientManifest: clientManifest
})
// 资源文件
app.use(static(path.resolve(__dirname, '../dist')))
app.use(favicon(path.resolve(__dirname, '../favicon.ico')))
router.get('*', (ctx, next) => {
let context = {
url: ctx.url
}
// 服务端渲染结果转换成字符串
renderer.renderToString(context, (err, html) => {
if (err) {
console.error(err);
ctx.status = 500;
ctx.body = '服务器内部错误';
} else {
ctx.status = 200;
ctx.body = html; // 将html字符串传到浏览器渲染
}
});
});
// 开启路由
app
.use(router.routes())
.use(router.allowedMethods());
// 应用监听端口
app.listen(3003, () => {
console.log('服务器端渲染地址: http://localhost:3003');
});
复制代码
6.修改App.js
<template>
<section id="app">
<p>实现ssr服务端渲染增长 vue-router 和 vuex - vue ssr案例第三步</p>
<br>
<div>当前的页面路径: <span style="font-size: 20px; color:#f52811;">{{$router.currentRoute.path}}</span></div>
<br>
<router-link to="/">Home</router-link>
<router-link to="/list">List</router-link>
<router-view></router-view>
</section>
</template>
<script>
export default {
name: 'App'
}
</script>
复制代码
执行npm run start,在浏览器打开 http://localhost:3003/
如今 vue-router 也能正常使用了,接下来须要思考一件事,日常咱们都须要从后端交互拿到数据,那在 服务端数据又怎么同步到咱们的组件中呢?
日常咱们多用 created 和 mounted 进行数据的获取,而后将获得数据放在 data 里,最后再到视图中进行数据渲染。可是,在服务端 vue 只进行了 beforeCreate 和 created,而后就会生成html字符串,最后再浏览器端,再浏览器端进行挂载(也就是说 浏览器端vue的生命周期是从 beforeMount 开始,不存在beforeCreate 和 created )。因此在 服务端 vue 的生命周期只有 beforeCreate 和 created 。
到后台请求数据都是异步的,若是在服务端的 beforeCreate 或 created 中去获取数据,可能接口数据还没返回到给咱们,服务端已经把html字符串传到浏览器渲染了,因此数据内容仍是没法显示出来。
在客户端是直接进行挂载,因此客户端生命周期是总beforeMounted开始的,因为爬虫不会等待客户端js执行完,因此在客户端获取数据也是不可取的。
官网推荐使用 vuex,在页面渲染前将获取到的数据存于 store 中,这样在挂载到客户端以前就能够经过 store 获得数据。 大概的思路是:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
function getDataApi () {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('模拟异步获取数据');
}, 1000);
});
}
function createStore () {
const store = new Vuex.Store({
state: {
datas: '' // 数据
},
mutations: {
setData (state, data) {
state.datas = data // 赋值
}
},
actions: {
fetchData ({ commit }) {
return getDataApi().then(res => {
commit('setData', res)
})
}
}
})
return store
}
export default createStore
复制代码
8.app.js
import Vue from 'vue';
import App from './App.vue';
import createRouter from './router/index.js'
import createStore from './store/index.js'
export function createApp() {
const router = createRouter()
const store = createStore()
const app = new Vue({
router,
store,
render: h => h(App)
});
return { app, router, store };
}
复制代码
9.entry-server。若是匹配到路由,在Promise.all里面会筛选出组件里拥有 asyncData 函数的组件,并执行 asyncData 函数。往下面的看 第11 小结源码可知道,asyncData 就是执行 dispatch 去触发 store获取数据和保存数据。这里是关键,只有等Promise.all执行完了,获取到数据,填充好 store 才返回 app实例,服务端才将 html 字符串传到浏览器,数据才能同步。
context.state = store.state 做用是,当服务端 createBundleRenderer 时,若是有template参数,就会把 context.state 的值做为 window.INITIAL_STATE 自动插入到html模板中。
import { createApp } from './app.js';
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp();
// 根据匹配到的路径进行路由跳转
router.push(context.url);
// 在router.onReady的成功回调中,找寻与url所匹配到的组件
router.onReady(() => {
// 查找所匹配到的组件
const matchedComponents = router.getMatchedComponents()
// 未找到组件
if (matchedComponents.length <= 0) {
return reject({
state: 404,
msg: '未找到页面'
})
}
// 对全部匹配的路由组件调用 `asyncData()`
Promise.all(matchedComponents.map(component => {
if (component.asyncData) {
console.log(component.asyncData)
// 匹配的组件存在 asyncData 就将其执行
return component.asyncData({ store, route: router.currentRoute })
}
})).then(res => {
// 在全部预取钩子(preFetch hook) resolve 后,咱们的 store 如今已经填充入渲染应用程序所需的状态。
// 当咱们将状态附加到上下文,而且 `template` 选项用于 renderer 时,状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state
// 成功并返回实例
resolve(app)
}).catch(reject)
}, reject)
});
}
复制代码
10.entry-client。客户端在挂载以前,先经过 store.replaceState(window.INITIAL_STATE) 将服务端获得的 store 数据进行同步,这样客户端 store 初始化的数据就和服务端 store 同步了。
import { createApp } from './app.js';
const { app, router, store } = createApp();
// 客户端在挂载到应用程序以前,同步store状态
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
app.$mount('#app');
复制代码
11.Home.vue组件。asyncData 用于在服务端获取数据,这样 {{$store.state.datas}} 在服务端中就能够实现数据数据读取了。
<template>
<section class="home">
home --- home --- homr 123321
<h2>从服务端去获取的数据 ===> {{$store.state.datas}}</h2>
</section>
</template>
<script>
export default {
name: 'Home',
asyncData ({ store, route }) {
return store.dispatch('fetchData') // 服务端获取异步数据
},
data () {
return {
}
},
mounted () {
// 客户端不存在 created 和 beforeCreated 生命周期
console.log('store', this.$store)
}
}
</script>
<style>
.home {
background-color: aquamarine;
margin: 20px;
padding: 20px;
}
</style>
复制代码
12.www.js。koa 路由拦截里改成 async/await 写法,不然,程序就不等组件渲染好,就直接跑下个 middleware 去了,页面会渲染不出来。
const Koa = require('koa');
const Router = require('koa-router');
const static = require('koa-static');
const path = require('path');
const fs = require('fs');
const app = new Koa()
const router = new Router()
const favicon = require('koa-favicon')
const createBundleRenderer = require('vue-server-renderer').createBundleRenderer
// 记录js文件的内容
const serverBundle = require(path.resolve(__dirname, '../dist/vue-ssr-server-bundle.json'))
// 记录静态资源文件的配置信息
const clientManifest = require(path.resolve(__dirname, '../dist/vue-ssr-client-manifest.json'))
// 客户端激活
const template = fs.readFileSync(path.resolve(__dirname, '../dist/index.template.html'), 'utf-8')
const renderer = createBundleRenderer(serverBundle, {
runInNewContext: false,
template: template,
clientManifest: clientManifest
})
// 资源文件
app.use(static(path.resolve(__dirname, '../dist')))
app.use(favicon(path.resolve(__dirname, '../favicon.ico')))
router.get('*', async (ctx, next) => {
let context = {
url: ctx.url
}
// 服务端渲染结果转换成字符串
await new Promise((resolve, reject) => {
renderer.renderToString(context, (err, html) => {
if (err) {
console.error(err);
ctx.status = 500;
ctx.body = '服务器内部错误';
reject
} else {
ctx.status = 200;
ctx.type = 'html';
ctx.body = html; // 将html字符串传到浏览器渲染
resolve(next())
}
});
})
});
// 开启路由
app
.use(router.routes())
.use(router.allowedMethods());
// 应用监听端口
app.listen(3003, () => {
console.log('服务器端渲染地址: http://localhost:3003');
});
复制代码
// 客户端在挂载到应用程序以前,同步store状态
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
复制代码
查看源代码,以下图: