從原理上實現Vue的ssr渲染
技術標籤:前端
前言
本篇文章將從一個實戰案例的角度逐步解析vue
伺服器端渲染的具體實現方式,整個過程不使用第三方伺服器端渲染框架,以講解底層實現原理為主.
伺服器端渲染(ssr
)主要解決了以下兩個問題.
- 提升了首頁的載入速度,這對於單頁應用而言有明顯的優勢.
- 服務端渲染優化了
seo
.使用伺服器端渲染的頁面更容易被搜尋引擎捕獲從而提升網站排名,這一點非常重要.在一些To c
的專案中,如果使用者在搜尋引擎裡面輸入關鍵字後,發現網站搜都搜不出來,那就更談不上盈利了.只要是跟經濟效益掛鉤的技術,對於每一個技術人而言都應該重點關注.
在前後端還沒分離的那個時代,像JAVA
,PHP
這些老牌程式語言.它們一直都在使用伺服器渲染頁面,並且多年的沉澱已經發展出了很多成熟的方案.
如今前後端分離已經覆蓋了整個行業,前端程式設計師慣常使用三大框架vue
,react
和angular
開發頁面.一旦前端使用這些先進的框架開發出了頁面,後臺程式語言是JAVA
或PHP
,它們做ssr
就有點束手無力了.老牌程式語言的ssr
只能在自己的生態下做,所以這部分工作就落到了前端同學的頭上.
前端一旦接手了ssr
,可以讓頁面的開發模式和之前保持一致.之前是怎麼開發單頁面應用的現在依舊怎麼開發,只不過是在原來的基礎上增加了一些額外的配置.這樣在成本花費很低的情況下既讓前端程式設計師保留了過往的開發習慣又讓應用支援了srr
.
ssr到底在做什麼事
伺服器端渲染(srr)
,顧名思義,頁面在後臺渲染好後再發給前端展示.這要和客戶端渲染對照來講,看如下程式碼.
//index.js import Vue from 'vue'; import App from '../App.vue'; new Vue({ render: (h) => h(App), }).$mount('#app'); //index.html <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> </head> <body> <div id="app"></div> <!-- built files will be auto injected --> <script src="http://www.xxx.com/main.js"></script> </body> </html>
前端在開發單頁應用的時候會經常碰到上述程式碼.客戶端渲染最明顯的特徵就是後端傳送過來的index.html
裡面的app
節點裡面內容是空的.那整個客戶端渲染流程很容易打通.
- 瀏覽器輸入網址請求伺服器
- 後端將一個不含有頁面內容的
html
傳送給瀏覽器 - 瀏覽器接收到
html
開始載入,當讀到後面script
處就開始向伺服器請求js
資源.此時html
是不含有內容的空模板 - 後端收到請求便把
js
傳送給瀏覽器,瀏覽器收到後開始載入執行js
程式碼. - 這個時候
vue
開始接管了整個應用,它便開始載入App
元件,但發現App
元件裡面有個非同步請求的程式碼.瀏覽器便開始向後臺發起ajax
請求獲取資料,資料得到後便開始渲染App
元件的模板. App
元件所有工作都做完後,vue
便把App
元件的內容插入到index.html
裡id
為app
的dom
元素.
從上面客戶端渲染的流程來看,後端傳送給前臺index.html
是不包含頁面內容的空模板,頁面內容的渲染過程都是瀏覽器這邊完成的,所以這種方式稱為客戶端渲染
.
srr
和客戶端渲染最大區別就是上面第二步,後端直接將一個把內容都填充好的html
發給瀏覽器渲染.
如此瀏覽器收到了html
直接渲染就可以了,不需要自己再額外發送請求獲取資料渲染模板,正因為這部分工作給省掉了,所以頁面的載入速度會變得很流暢.其次由於傳送過來的html
本身就是有內容的,搜尋引擎就能通過這些內容判端網站的型別和用處,這樣便優化了seo
.
小試牛刀
下面來通過一個非常簡單的案例從巨集觀上感受一下伺服器端渲染的過程.
import Koa2 from 'koa';
import { createRenderer } from 'vue-server-renderer';
import Vue from 'vue';
const renderer = createRenderer();
const app = new Koa2();
/**
* 應用接管路由
*/
app.use(async function(ctx) {
const vm = new Vue({
template:"<div>hello world</div>"
});
ctx.set('Content-Type', 'text/html;charset=utf-8');
const htmlString = await renderer.renderToString(vm);
ctx.body = `<html>
<head>
</head>
<body>
${htmlString}
</body>
</html>`;
});
app.listen(3000);
上面程式碼的邏輯十分簡單,使用koa2
搭建了一個web
服務端,監聽3000
埠.
瀏覽器輸入網址localhost:3000
發起請求,請求便會進入到app.use
裡面的函式方法.在該函式裡,首先定義了一個非常簡單vue
例項,隨後設定一下響應的資料格式,告訴瀏覽器返回的資料是一段html
.
重點來了,我們現在在伺服器端建立了一個vue
例項vm
.
vm
是什麼?它是一個數據物件.
熟悉前後臺互動的同學應該都清楚,前後端通訊都是通過傳送字串作為資料格式的,比如API
伺服器通常採用的json
字串.vm
它是一個物件,物件是不能直接傳送給瀏覽器的,傳送前必須要把vm
轉化成字串.
怎麼把一個vue
例項轉化成字串呢?這個過程不能亂轉化,因為在建立vue
的例項過程中,可不光只有template
這一個屬性,我們還可以給它新增響應式資料data
,我們還可以給它新增事件方法method
.
十分慶幸vue
官網提供了一個外掛vue-server-renderer
,它的作用就是把一個vue
例項轉化成字串,使用這個包要先用npm
安裝.
通過renderer.renderToString
這個方法,將vm
作為引數傳遞進去執行,便很輕鬆的返回了vm
轉化後的字串,如下.
<div data-server-rendered="true">hello world</div>
得到了內容字串後,把它插入到html
字串中,最後傳送給前端就大功告成了.此時頁面上就會顯示hello world
.
從上面的案例,可以從巨集觀上把握伺服器端渲染的整個脈絡.
- 首先是要獲取到當前這個請求路徑是想請求哪個
vue
元件 - 將元件資料內容填充好轉化成字串
- 最後把字串拼接成
html
傳送給前端.
上面的vm
是一個非常簡單的vue
例項,它只有一個template
屬性.現實業務中的vm
要複雜一些,因為隨著業務的增長會給vm
整合路由
和vuex
.接下來一一講解.
路由整合
一般而言專案不可能只有一個頁面,整合路由的目的就是為了讓一個請求路徑匹配一個vue
頁面元件,方便專案管理.
在實現srr
的任務裡,主要工作是為了在客戶端傳送請求後能找出當前的請求路徑是匹配哪個vue
元件.
建立一個route.js
,填寫以下程式碼.
import Vue from 'vue';
import Router from 'vue-router';
import List from './pages/List';
import Search from './pages/Search';
//route.js
Vue.use(Router);
export const createRouter = () => {
return new Router({
mode: 'history',
routes: [
{
path: '/list',
component: List,
},
{
path: '/search',
component: Search,
},
{
path: '/',
component: List,
},
],
});
};
在route.js
中定義好路由和頁面元件,這和之前前端定義路由的方式一樣.如果前端訪問根路徑,預設載入List
元件.
App
元件也和之前一樣,裡面只放了一個視口<router-view></router-view>
展現內容.
回到伺服器端的入口檔案index.js
中,引入上面定義的createRouter
方法.
import Koa2 from 'koa';
import { createRenderer } from 'vue-server-renderer';
import Vue from 'vue';
import App from './App.vue';
import { createRouter, routerReady } from './route';
const renderer = createRenderer();
const app = new Koa2();
/**
* 應用接管路由
*/
app.use(async function(ctx) {
const req = ctx.request;
const router = createRouter(); //建立路由
const vm = new Vue({
router,
render: (h) => h(App),
});
router.push(req.url);
// 等到 router 鉤子函式解析完
await routerReady(router);
const matchedComponents = router.getMatchedComponents();//獲取匹配的頁面元件
if (!matchedComponents.length) {
ctx.body = '沒有找到該網頁,404';
return;
}
ctx.set('Content-Type', 'text/html;charset=utf-8');
const htmlString = await renderer.renderToString(vm);
ctx.body = `<html>
<head>
</head>
<body>
${htmlString}
</body>
</html>`;
});
app.listen(3000);
- 使用
createRouter()
方法建立一個路由例項物件router
,把它注入到Vue
例項中. - 隨後執行
router.push(req.url)
,這一步非常關鍵.相當於告訴Vue
例項,當前的請求路徑已經傳給你了,你快點根據路徑尋找要渲染的頁面元件. await routerReady(router);
執行完畢後,就已經可以得到當前請求路徑匹配的頁面元件了.matchedComponents.length
如果等於0
,說明當前的請求路徑和我們定義的路由沒有一個匹配上,那麼這裡應該要定製一個精美的404
頁面返回給瀏覽器.matchedComponents.length
不等於0
,說明當前的vm
已經根據請求路徑讓匹配的頁面元件佔據了視口.接下來只需要將vm
轉化成字串傳送給瀏覽器就可以了.
瀏覽器輸入localhost:3000
,經過上面的流程就能將List
頁面內容渲染出來.
vuex整合
同構
路由整合後雖然能夠根據路徑渲染指定的頁面元件,但是伺服器渲染也存在侷限性.
比如你在頁面元件模板上加一個v-click
事件,結果會發現頁面在瀏覽器上渲染完畢後事件無法響應,這樣肯定會違揹我們的初衷.
怎麼解決這樣的棘手的問題呢?我們還是要回到伺服器端渲染的本質上來,它做的主要的事情就是返回一個填充滿頁面內容html
給客戶端,至於後面怎麼樣它就不管了.
事件繫結,點選連結跳轉這些都是瀏覽器賦予的能力.因此可以藉助客戶端渲染來幫助我們走出困境.
整個流程可以設計如下.
- 瀏覽器輸入連結請求伺服器,伺服器端將包含頁面內容的
html
返回,但是在html
檔案下要加上客戶端渲染的js
指令碼. html
開始在瀏覽器上載入,頁面上已經呈現出靜態內容了.當執行緒走到html
檔案下的script
標籤,開始請求客戶端渲染的指令碼並執行.- 此時客戶端腳本里面的
vue
例項開始接管了整個應用,它開始賦予原本後端返回的靜態html
各種能力,比如讓標籤上的事件繫結開始生效.
這樣就將客戶端渲染和ssr
聯合了起來.ssr
只負責返回靜態的html
檔案內容,目的是為了讓頁面快點展現出來.而客戶端的vue
例項在靜態頁面渲染後開始接管整個應用,賦予頁面各種各樣的能力,這種協作的方式就稱為同構
.
下面通過程式碼演示一遍上述流程加深理解.
為了實現同構
,需要增加客戶端渲染的程式碼.新建client/index.js
作為webpack
構建客戶端指令碼的入口.
import Vue from 'vue';
import App from '../App.vue';
import { createRouter } from '../route';
const router = createRouter(); //建立路由
new Vue({
router,
render: (h) => h(App),
}).$mount('#app', true);
webpack
跑完上面的客戶端程式碼,會把它們打包生成一個bundle.js
.這裡的程式碼和之前唯一有點區別的就是$mount('#app', true)
後面多了一個true
引數.
加上這個true
的原因也好理解,由於ssr
把渲染好的靜態html
發給瀏覽器渲染後,客戶端開始接管應用.
但是當前這個路徑所訪問的頁面已經被後臺渲染好了,不需要客戶端vue
例項再渲染一遍.加個true
引數就讓客戶端的vue
例項只對當前的模板內容新增一些事件繫結和功能支援就行了.
在ssr
的入口檔案index.js
裡,需要新增如下程式碼.
import Koa2 from 'koa';
import { createRenderer } from 'vue-server-renderer';
import Vue from 'vue';
import staticFiles from 'koa-static';
import App from './App.vue';
import { createRouter, routerReady } from './route';
const renderer = createRenderer();
const app = new Koa2();
/**
* 靜態資源直接返回
*/
app.use(staticFiles('public'));
/**
* 應用接管路由
*/
app.use(async function(ctx) {
... //省略
ctx.body = `<html>
<head>
</head>
<body>
${htmlString}
<script src="/bundle.js"></script>
</body>
</html>`;
});
app.listen(3000);
從上面修改的內容可以看出來僅僅只是在原來的基礎上做了一點小修改,在ctx.body
返回的html
加了一個script
標籤讓瀏覽器執行客戶端渲染的配置.
為了讓瀏覽器能夠順利請求到這個bundle.js
,需要執行app.use(staticFiles('public'))
.這句程式碼的含義就是如果請求路徑是靜態資源,直接將public
資料夾下的資源返回給客戶端.
通過上面這一輪配置,就能使被ssr
渲染的靜態頁面在客戶端賦予各種能力.一旦html
檔案在瀏覽器上載入完畢後,ssr
的使命就完成了,後面的所有事情比如頁面跳轉,互動操作都是客戶端js
指令碼的vue
例項在接管,到了此時就和前端之前熟悉的場景沒有區別了.
vuex的配置
現在假設List.vue
的模板內容如下.
<template>
<div class="list">
<p>當前頁:列表頁</p>
<a @click="jumpSearch()">go搜尋頁</a>
<ul>
<li v-for="item in list" :key="item.id">
<p>城市: {{item.name}}</p>
</li>
</ul>
</div>
</template>
從上可以看出模板並不全都是靜態的標籤內容,它下面要渲染一個城市列表.而城市列表資料list
是放在遠端一個JAVA
伺服器上.
這時就出現了問題.首先是客戶端輸入連結localhost:3000/list
請求node
伺服器,node
攔截請求後根據/list
路徑找到了當前要渲染的頁面是List.vue
,於是就開始載入元件的內容.
結果在這個元件內部發現它需要渲染的資料在遠端伺服器上,那麼當前的node
伺服器必須要先去請求遠端伺服器把資料取回來,取回來後才能渲染List.vue
,最後再把生成的字串返回給瀏覽器.
為了順利實現上面的流程,需要藉助vuex
的能力.
- 在專案根目錄下建立
vuex/store.js
.
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
export function createStore() {
return new Vuex.Store({
state: {
list: [],
name: 'kay',
},
actions: {
getList({ commit }, params) {
return new Promise((resolve)=>{
commit("setList",[{
name:"廣州"
},{
name:"深圳"
}]);
resolve();
},2000)
},
},
mutations: {
setList(state, data) {
state.list = data || [];
},
},
});
}
這份vuex
的配置和前端之前的做法沒區別.定義了一個action
方法getList
獲取城市列表.在getList
方法裡使用定時器模擬遠端請求延時返回資料.
- 客戶端整合
vuex
import Vue from 'vue';
import App from '../App.vue';
import { createRouter } from '../route';
import { createStore } from '../vuex/store';
const router = createRouter(); //建立路由
const store = createStore();
new Vue({
router,
store,
render: (h) => h(App),
}).$mount('#app', true);
List.vue
檔案增加非同步獲取資料的方法.
<template>
<div class="list">
<p>當前頁:列表頁</p>
<a @click="jumpSearch()">go搜尋頁</a>
<ul>
<li v-for="item in list" :key="item.name">
<p>城市: {{item.name}}</p>
</li>
</ul>
</div>
</template>
<script>
export default {
asyncData({ store, route }) {
return store.dispatch("getList");
},
computed: {
list() {
return this.$store.state.list;
},
},
methods: {
jumpSearch() {
this.$router.push({
path: "search",
});
},
},
};
</script>
在元件上增加一個asyncData
方法,獲取遠端資料.
ssr
整合vuex
.在伺服器端渲染入口檔案index.js
新增store
.
import { sync } from 'vuex-router-sync';
...省略
/**
* 應用接管路由
*/
app.use(async function(ctx) {
const req = ctx.request;
const router = createRouter(); //建立路由
const store = createStore(); //建立資料倉庫
// 同步路由狀態(route state)到 store
sync(store, router);
const vm = new Vue({
router,
store,
render: (h) => h(App),
});
router.push(req.url);
...省略
const matchedComponents = router.getMatchedComponents();//獲取當前路由匹配的頁面元件
await Promise.all(
matchedComponents.map((Component) => {
if (Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute,
});
}
})
);
const htmlString = await renderer.renderToString(vm);
...省略
})
首先先建立一個store
倉庫,然後要使用sync
將路由狀態同步一下,注入到vue
例項中,最後還需要使用Promise.all
將頁面元件的asyncData
執行一遍.
將上面這幾個步驟再梳理一遍.現在頁面元件模板List.vue
的資料放在遠端伺服器,需要載入了資料才能渲染.
首先給List.vue
增加一個asyncData
函式,這個函式一旦觸發就會啟動vuex
裡面的action
請求遠端資料.
現在瀏覽器開啟連結localhost:3000/list
.伺服器端渲染入口檔案index.js
接管了這個請求後,發現要渲染的頁面的元件是List.vue
.
於是node伺服器
開始執行Promise.all
後面程式碼檢查一下List.vue
下有沒有定義asyncData
函式,如果定義了就趕緊執行這個函式去請求遠端資料.資料返回後同步到store
倉庫中,緊接著整個vue
例項會因為vuex
的資料變化重新渲染,List.vue
將遠端資料填充在模板上,最後將vue
例項轉化成html
字串返回給瀏覽器.
脫水
現在ssr
和客戶端都配置了vuex
,但區別是服務端的store
裡面放著List.vue
需要的遠端請求的資料,而客戶端的store
是空的.
這樣就會造成一個問題.頁面本來很好的在瀏覽器展現,突然閃爍一下,List.vue
頁面模板的城市列表的資料消失了.
為什麼會這樣呢?srr
返回的靜態html
是帶著城市列表的,一旦客戶端的vue
接管了整個應用就會展開各種各樣的初始化操作.客戶端也要配置vuex
,由於它的資料倉庫是空的所以重新引發了頁面渲染.致使原本來含有城市列表的頁面部分消失了.
為了解決這個問題,就要想辦法讓ssr
遠端請求來的資料也給客戶端的store
發一份.這樣客戶端即使接管了應用,但發現此時store
儲存的城市列表資料和頁面保持一致也不會造成閃爍問題.
在ssr
的入口檔案加上如下程式碼.
ctx.body = `<html>
<head>
</head>
<body>
${htmlString}
<script>
var context = {
state: ${JSON.stringify(store.state)}
}
</script>
<script src="/index.js"></script>
</body>
</html>`;
其實就是將伺服器端store
裡面的資料轉化成字串放到js
的變數裡再一起返回給瀏覽器.
這樣的好處就是客戶端的指令碼就可以訪問context.state
拿到遠端請求的資料.
將資料從伺服器端注入到客戶端js
的過程就稱之為脫水
.
注水
伺服器端將資料放入了js
腳本里,客戶端此時就可以輕鬆拿到這份資料.
import Vue from 'vue';
import App from '../App.vue';
import { createRouter } from '../route';
import { createStore } from '../vuex/store';
const router = createRouter(); //建立路由
const store = createStore();
if (window.context && window.context.state) {
store.replaceState(window.context.state);
}
new Vue({
router,
store,
render: (h) => h(App),
}).$mount('#app', true);
在客戶端入口檔案里加上store.replaceState(window.context.state);
.如果發現window.context.state
存在,就把這部分資料作為vuex
的初始資料,這個過程稱之為注水
.
裝載真實資料
上面在vuex
裡是使用定時器模擬的請求資料,接下來利用網上的一些開放API
接入真實的資料.
對vuex
裡的action
方法做如下修改.
actions: {
getList({ commit }, params) {
const url = '/api/v2/city/lookup?location=guangzhou&key=9423bb18dff042d4b1716d084b7e2fe0';
return axios.get(url).then((res)=>{
commit("setList",res.data.location);
})
}
}
現在重新把流程捋一捋,服務端根據請求路徑要載入List.vue
,發現了裡面有非同步呼叫的方法asyncData
,便開始執行這個方法.
asyncData
一執行就會走到上面actions
裡面的getList
,它就會對上面那個url
地址發起請求.但仔細觀察發現這個url
是沒有寫域名的,這樣訪問肯定會報錯.
那把遠端域名給它加上去行不行呢?如果這樣硬加是會出現問題的.有一種場景就是客戶端接管應用它也可以呼叫getList
方法,我們寫的這部分vuex
程式碼可是服務端和客戶端共用的.那如果客戶端直接訪問帶有遠端域名的路徑就會引起跨域.
那如何解決這一問題呢?這裡的url
最好不要加域名,以/
開頭.那樣客戶端訪問這個路徑就會引向node
伺服器.此時只要加一個介面代理轉發就搞定了.
import proxy from 'koa-server-http-proxy';
export const proxyHanlder = (app)=>{
app.use(proxy('/api', {
target: 'https://geoapi.qweather.com', //網上尋找的開放API介面,支援返回地理資料.
pathRewrite: { '^/api': '' },
changeOrigin: true
}));
}
定義一箇中間件函式,在執行伺服器端渲染前新增到koa2
上.
這樣node
伺服器只要看到以/api
開頭的請求路徑就會轉發到遠端地址上獲取資料,不會再走後面伺服器端渲染的邏輯.
伺服器端路徑請求的問題
使用上面的代理轉發之後又會帶來新的問題,設想一種場景.如果瀏覽器輸入localhost:3000/list
後,node
解析請求發現要載入List.vue
這個頁面元件,而這個元件又有一個asyncData
非同步方法,因此就執行非同步方法獲取資料.
actions: {
getList({ commit }, params) {
const url = '/api/v2/city/lookup?location=guangzhou&key=9423bb18dff042d4b1716d084b7e2fe0';
return axios.get(url).then((res)=>{
commit("setList",res.data.location);
})
}
}
這個非同步方法就是getList
,注意此時執行這段指令碼的是node
伺服器,不是客戶端的瀏覽器.
瀏覽器如果請求以/
開頭的url
,請求會發給node
伺服器.node
伺服器現在需要自己請求自己,只要請求了自己設定的代理就能把請求轉發給遠端伺服器,而如今node
伺服器請求以/
開頭的路徑是絕對無法請求到自己的,這個時候只能用絕對路徑.
我們上面提到這部分的vuex
程式碼是客戶端和服務端共用的,最好不用絕對路徑寫死.還有一個更優雅的方法,就是對axios
的baseURL
進行配置生成帶有域名的axios
例項來請求.那這部分程式碼就可以改成如下.
export function createStore(_axios) {
return new Vuex.Store({
state: {
list: [],
name: 'kay',
},
actions: {
getList({ commit }, params) {
const url = '/api/v2/city/lookup?location=guangzhou&key=9423bb18dff042d4b1716d084b7e2fe0';
return _axios.get(url).then((res)=>{
commit("setList",res.data.location);
})
},
},
mutations: {
setList(state, data) {
state.list = data || [];
},
},
});
}
_axios
是配置基礎域名後的例項物件,客戶端會生成一個_axios
,服務端也會生成一個,只不過客戶端是不用配置baseURL
的.
import axios from "axios";
//util/getAxios.js
/**
* 獲取客戶端axios例項
*/
export const getClientAxios = ()=>{
const instance = axios.create({
timeout: 3000,
});
return instance;
}
/**
* 獲取伺服器端axios例項
*/
export const getServerAxios = (ctx)=>{
const instance = axios.create({
timeout: 3000,
baseURL: 'http://localhost:3000'
});
return instance;
}
通過生成兩份axios
例項既保持了vuex
程式碼的統一性,另外還解決了node
伺服器自己訪問不了自己的問題.
cookie如何處理
使用了介面代理之後,怎麼確保每次介面轉發都能把cookie
也一併傳給遠端的伺服器.可以按如下配置.
在ssr
的入口檔案裡.
***省略
**
* 應用接管路由,伺服器端渲染程式碼
*/
app.use(async function(ctx) {
const req = ctx.request;
//圖示直接返回
if (req.path === '/favicon.ico') {
ctx.body = '';
return false;
}
const router = createRouter(); //建立路由
const store = createStore(getServerAxios(ctx)); //建立資料倉庫
***省略
})
在建立ctx
和axios
例項的時候將ctx
傳遞進去.
/**
* 獲取伺服器端axios例項
*/
export const getServerAxios = (ctx)=>{
const instance = axios.create({
timeout: 3000,
headers:{
cookie:ctx.req.headers.cookie || ""
},
baseURL: 'http://localhost:3000'
});
return instance;
}
將ctx
中的cookie
取出來賦值給axios
的headers
,這樣就確保cookie
被攜帶上了.
樣式處理
.vue
頁面的檔案通常把程式碼分成三個標籤<template>
,<script>
和<style>
.
<style scoped lang="scss"></style>
上還可以新增一些屬性.
和客戶端渲染相比,實現ssr
的過程要多處理一步.即將<style>
裡面的樣式內容提取出來,再渲染到html
的<head>
裡面.
在ssr
入口檔案index.js
新增如下程式碼.
...省略
const context = {}; //建立一個上下文物件
htmlString = await renderer.renderToString(vm, context);
ctx.body = `<html>
<head>
${context.styles ? context.styles : ''}
</head>
<body>
${htmlString}
<script>
var context = {
state: ${JSON.stringify(store.state)}
}
</script>
<script src="./bundle.js"></script>
</body>
</html>`;
服務端提取樣式的過程非常簡單,定義一個上下文物件context
.
renderer.renderToString
函式的第二個引數裡傳入context
,該函式執行完畢後,context
物件的styles
屬性就會擁有頁面元件的樣式.最後將這份樣式拼接到html
的head
頭部裡即可.
Head資訊處理
常規的html
檔案的head
裡面不僅包含樣式,它可能還需要設定<title>
和<meta />
.如何針對每個頁面設定個性化的頭部資訊,可以利用vue-meta
外掛.
現在需要給List.vue
頁面元件新增一些頭資訊,可以按如下設定.
<script>
export default {
metaInfo: {
title: "列表頁",
meta: [
{ charset: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
],
},
asyncData({ store, route }) {
return store.dispatch("getList");
}
...省略
}
在匯出的物件上新增一個屬性metaInfo
,在其中分別設定title
和meta
;
在ssr
的入口檔案處加入如下程式碼.
import Koa2 from 'koa';
import Vue from 'vue';
import App from './App.vue';
import VueMeta from 'vue-meta';
Vue.use(VueMeta);
/**
* 應用接管路由
*/
app.use(async function(ctx) {
...省略
const vm = new Vue({
router,
store,
render: (h) => h(App),
});
const meta_obj = vm.$meta(); // 生成的頭資訊
router.push(req.url);
...省略
htmlString = await renderer.renderToString(vm, context);
const result = meta_obj.inject();
const { title, meta } = result;
ctx.body = `<html>
<head>
${title ? title.text() : ''}
${meta ? meta.text() : ''}
${context.styles ? context.styles : ''}
</head>
<body>
${htmlString}
<script>
var context = {
state: ${JSON.stringify(store.state)}
}
</script>
<script src="./index.js"></script>
</body>
</html>`;
});
app.listen(3000);
通過 vm.$meta()
生成頭資訊meta_obj
,待到vue
例項載入完畢後,執行meta_obj.inject()
獲取被渲染頁面元件的meta
和title
資料,再將它們填充到html
字串即可.
這樣一來瀏覽器訪問localhost:3000/list
,返回的html
檔案的頭部就會包含上面定義的title
和meta
資訊.
原始碼
結尾
上面這一整套流程走下來還是挺複雜的,伺服器端渲染的難點不是在於本身技術存在難度.而是整個流程有些複雜,要處理的細節非常多.但如果真的將這些原理都吃透,那麼不光是vue
框架,像react
和angular
都可以按照同樣的思路去實現伺服器端渲染.