首先按國際慣例來,分析 客戶端渲染(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__)
}
複製代碼
查看源代碼,以下圖: