服務端將Vue組件渲染爲HTML 字符串,並將html字符串直接發送到瀏覽器,最後將這些靜態標記"激活"爲客戶端上徹底可交互的應用程序。javascript
|—— components // 子組件
| |—— Foo.vue
| |—— Bar.vue
|
|—— App.vue // 根組件
|—— index.js // 入口文件
|—— webpack.config.js
複製代碼
代碼很簡單就是一個很普通的vue項目(包括一些點擊事件,數據綁定), 典型的客戶端渲染。php
剛開始接觸web開發,都是以html頁面爲模板,把後端數據塞到模板中,像.php、.jsp文件。還有與node 結合使用的artTemplate,ejs等。css
而Vue 的服務端渲染也分爲兩步:html
官方提供一個插件 vue-server-renderer 能夠直接將vue 實例渲染成 Dom 標記vue
const Vue = require('vue')
const app = new Vue({
template: `<div>Hello World</div>`
})
// 第 2 步:建立一個 renderer
const renderer = require('vue-server-renderer').createRenderer()
// 第 3 步:將 Vue 實例渲染爲 HTML
renderer.renderToString(app, (err, html) => {
if (err) throw err
console.log(html)
// => <div data-server-rendered="true">Hello World</div>
})
// 在 2.5.0+,若是沒有傳入回調函數,則會返回 Promise:
renderer.renderToString(app).then(html => {
console.log(html)
}).catch(err => {
console.error(err)
})
複製代碼
與服務端結合, 經過請求返回html 頁面java
const Vue = require('vue')
const Koa = require('koa');
const Router = require('koa-router');
const renderer = require('vue-server-renderer').createRenderer()
const app = new Koa();
const router = new Router();
router.get('*', async (ctx, next) => {
const app = new Vue({
data: {
url: ctx.request.url
},
template: `<div>訪問的 URL 是: {{ url }}</div>`
})
renderer.renderToString(app, (err, html) => {
if (err) {
ctx.status(500).end('Internal Server Error')
return
}
ctx.body = ` <!DOCTYPE html> <html lang="en"> <head><title>Hello</title></head> <body>${html}</body> </html> `
})
})
app
.use(router.routes())
.use(router.allowedMethods());
app.listen(8080, () => {
console.log('listen 8080')
})
複製代碼
從demo1 能夠看出vue-server-renderer 方法返回的是一個html 片斷 官方叫標記(markup), 並非完整的html 頁面。 咱們必須像demo2中那樣用一個額外的 HTML 頁面包裹容器,來包裹生成的 HTML 標記。node
咱們能夠提供一個模板頁面。例如webpack
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
複製代碼
注意 <!--vue-ssr-outlet-->
註釋這裏將是應用程序 HTML 標記注入的地方。 這是插件提供的,若是不用 <!--vue-ssr-outlet-->
也是能夠的,那就要本身去簡單處理一下了。好比demo3git
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>
{injectHere}
</body>
</html>
複製代碼
const template = require('fs').readFileSync(path.resolve(__dirname, './index.template.html'), 'utf-8')
ctx.body = template.replace('{injectHere}', html)
複製代碼
既然在客戶端和服務端上都能運行,那應該有兩個入口文件。一些 Dom, Bom 的操做在服務端確定是不行的.github
一般 Vue 應用程序是由 webpack 和 vue-loader 構建,而且許多 webpack 特定功能不能直接在 Node.js 中運行(例如經過 file-loader 導入文件,經過 css-loader 導入 CSS)
app.js
import Vue from 'vue'
import App from './App.vue'
export function createApp() {
const app = new Vue({
render: h => h(App)
})
return { app }
}
複製代碼
enter-client.js
import { createApp } from './app.js'
const { app } = createApp()
// App.vue 模板中根元素具備 `id="app"`
app.$mount('#app')
複製代碼
enter-server.js
import { createApp } from './app.js';
export default context => { // koa 的 context
const { app } = createApp()
return app
}
複製代碼
<!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>服務端渲染</title>
</head>
<body>
<!--vue-ssr-outlet-->
<!-- 引入客戶端打包後的js文件(client.bundle.js) -->
<script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script>
</body>
</html>
複製代碼
webpack.server.config.js
const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-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',
entry: {
server: path.resolve(__dirname, '../entry-server.js')
},
output: {
// 此處告知 server bundle 使用 Node 風格導出模塊(Node-style exports)
libraryTarget: 'commonjs2'
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../../index.ssr.html'),
filename: 'index.ssr.html',
files: {
js: 'client.bundle.js' // index.ssr.html 中引入的js文件是客戶端打包出來的client.bundle.js。這是由於 Vue 須要在瀏覽器端接管由服務端發送的靜態 HTML,使其變爲由 Vue 管理的動態 DOM。這個過程官方稱爲客戶端激活
},
excludeChunks: ['server']
})
]
});
複製代碼
webpack.client.config.js
const path = require('path')
const merge = require('webpack-merge')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const base = require('./webpack.base.config')
module.exports = merge(base, {
entry: {
client: path.resolve(__dirname, '../entry-client.js')
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../../index.html'),
filename: 'index.html'
})
]
})
複製代碼
這是比較完整的 客戶端接管由服務端渲染Vue 實例發送的靜態 HTML,並由 Vue 管理的動態Dom 的例子。完整代碼 03
Vue 項目的路由管理由vue-router 來負責,和 02 項目同樣, 服務端返回渲染後的html, 剩下的就交給Vue了。
router.js
import Vue from 'vue'
import Router from 'vue-router'
import Bar from "./components/Bar.vue";
import Foo from "./components/Foo.vue";
const routes = [
{ path: '/foo', component: Foo },
{ path: '/bar', component: Bar }
]
Vue.use(Router)
export function createRouter() {
// 建立 router 實例,而後傳 `routes` 配置
// 你還能夠傳別的配置參數, 不過先這麼簡單着吧。
return new Router({
mode: 'history',
routes
})
}
複製代碼
app.js 引入router
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
// 導出一個工廠函數,用於建立新的
// 應用程序、router 和 store 實例
export function createApp() {
// 建立 router 實例
const router = createRouter()
const app = new Vue({
// 注入 router 到根 Vue 實例
router,
// 根實例簡單的渲染應用程序組件。
render: h => h(App)
})
return { app, router }
}
複製代碼
這樣就能夠了嗎, 顯然還不夠,Vue 優化上,咱們通常會選擇惰性加載組件,而不是一會兒所有加載。那咱們就須要簡單修改一下entry-server.js 和 router.js 文件了。
router.js
import Vue from 'vue'
import Router from 'vue-router'
const routes = [
// webpack.base.config.js 中須要配置 @babel/plugin-syntax-dynamic-import
{ path: '/foo', component: () => import('./components/Foo.vue') },
{ path: '/bar', component: () => import('./components/Bar.vue') }
]
Vue.use(Router)
export function createRouter() {
// 建立 router 實例,而後傳 `routes` 配置
// 你還能夠傳別的配置參數, 不過先這麼簡單着吧。
return new Router({
mode: 'history',
routes
})
}
複製代碼
因爲加入了異步路由鉤子函數或組件,因此咱們將返回一個 Promise,以便服務器可以等待全部的內容在渲染前,就已經準備就緒。 咱們如今的entry-server.js 更新成這樣
entry-server.js
import { createApp } from './app.js';
export default context => {
// 由於有可能會是異步路由鉤子函數或組件,因此咱們將返回一個 Promise,
// 以便服務器可以等待全部的內容在渲染前,
// 就已經準備就緒。
return new Promise((resolve, reject) => {
const { app, router } = createApp()
if (context.url.indexOf('.') === -1) { // 防止匹配 favicon.ico *.js 文件
router.push(context.url)
}
// 設置服務器端 router 的位置
console.log(context.url, '******')
// 等到 router 將可能的異步組件和鉤子函數解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// 匹配不到的路由,執行 reject 函數,並返回 404
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// Promise 應該 resolve 應用程序實例,以便它能夠渲染
resolve(app)
}, reject)
})
}
複製代碼
entry.client.js
import { createApp } from './app.js'
const { app, router } = createApp()
router.onReady(() => {
// 這裏假定 App.vue 模板中根元素具備 `id="app"`
app.$mount('#app')
})
複製代碼
因爲用到了,異步路由這個時候,打包的bundle.js不包括異步組件的js文件。還按照上面直接引入 server.bundle.js 的話,會報錯找不到相關的異步組件的js文件。
因此這裏咱們用vue-server-renderer下的插件vue-server-renderer/server-plugin把server.entry.js文件打包成一個json 文件, 而json 文件中會把全部的異步組件和相關的js一一map。
從上面幾個例子能夠看到,在服務器端渲染(SSR)期間,咱們本質上是在渲染一個靜態文件,後續的交互仍是交給了客戶端的vue,因此若是應用程序依賴於一些須要初始化的異步數據,那麼在開始渲染過程以前,須要先預取和解析好這些數據。
還有一個問題是在掛載 (mount) 到客戶端應用程序以前,須要獲取到與服務器端應用程序徹底相同的數據 - 不然,客戶端應用程序會由於使用與服務器端應用程序不一樣的狀態,而後致使混合失敗。
爲了解決這個問題,獲取的數據須要位於視圖組件以外,即放置在專門的數據預取存儲容器(data store)或"狀態容器(state container))"中。首先,在服務器端,咱們能夠在渲染以前預取數據,並將數據填充到 store 中。此外,咱們將在 HTML 中序列化(serialize)和內聯預置(inline)狀態。這樣,在掛載(mount)到客戶端應用程序以前,能夠直接從 store 獲取到內聯預置(inline)狀態。
即在全部預取鉤子(preFetch hook) resolve 後,咱們的 store 已經填充入渲染應用程序所需的狀態。當咱們將狀態附加到上下文,而且 template
選項用於 renderer 時,狀態將自動序列化爲 window.__INITIAL_STATE__
,並注入 HTML。在客戶端咱們就能夠經過全局變量window.__INITIAL_STATE__拿到數據。
咱們用官方的狀態管理庫 的VueX 。
store.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
// 一個能夠返回 Promise 的 API
import { fetchItem } from './api'
export function createStore () {
return new Vuex.Store({
state: {
items: {}
},
actions: {
fetchItem ({ commit }, id) {
// `store.dispatch()` 會返回 Promise,
// 以便咱們可以知道數據在什麼時候更新
return fetchItem(id).then(item => {
commit('setItem', { id, item })
})
}
},
mutations: {
setItem (state, { id, item }) {
Vue.set(state.items, id, item)
}
}
})
}
複製代碼
app.js
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'
// 導出一個工廠函數,用於建立新的
// 應用程序、router 和 store 實例
export function createApp() {
const router = createRouter()
const store = createStore()
const app = new Vue({
router,
store,
// 根實例簡單的渲染應用程序組件。
render: h => h(App)
})
return { app, router, store }
}
複製代碼
那麼,咱們在哪裏放置「dispatch 數據預取 action」的代碼?
咱們須要經過訪問路由,來決定獲取哪部分數據 - 這也決定了哪些組件須要渲染。事實上,給定路由所需的數據,也是在該路由上渲染組件時所需的數據。因此在路由組件中放置數據預取邏輯,是很天然的事情。
咱們將在路由組件上暴露出一個自定義靜態函數 asyncData。注意,因爲此函數會在組件實例化以前調用,因此它沒法訪問 this。須要將 store 和路由信息做爲參數傳遞進去, 因此如今咱們的 entry-server.js 如今變成這樣
entry-server.js
import { createApp } from './app.js';
export default context => {
// 由於有可能會是異步路由鉤子函數或組件,因此咱們將返回一個 Promise,
// 以便服務器可以等待全部的內容在渲染前,
// 就已經準備就緒。
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
if (context.url.indexOf('.') === -1) {
// 設置服務器端 router 的位置
router.push(context.url)
}
console.log(context.url, '******')
// 等到 router 將可能的異步組件和鉤子函數解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// 匹配不到的路由,執行 reject 函數,並返回 404
if (!matchedComponents.length) {
router.push('/foo') // 能夠加個默認頁面, 或者是404頁面
// return reject({ code: 404 })
}
Promise.all(matchedComponents.map(component => {
if (component.asyncData) {
return component.asyncData(
{
store,
route: router.currentRoute
})
}
})).then(() => {
// 當使用 template 時,context.state 將做爲 window.__INITIAL_STATE__ 狀態,
//自動嵌入到最終的 HTML 中。而在客戶端,在掛載到應用程序以前,store 就應該獲取到狀態
context.state = store.state
// Promise 應該 resolve 應用程序實例,以便它能夠渲染
resolve(app)
}).catch(reject)
}, reject)
})
}
複製代碼