1. 程式人生 > 其它 >從原理上實現Vue的ssr渲染

從原理上實現Vue的ssr渲染

技術標籤:前端

前言

本篇文章將從一個實戰案例的角度逐步解析vue伺服器端渲染的具體實現方式,整個過程不使用第三方伺服器端渲染框架,以講解底層實現原理為主.

伺服器端渲染(ssr)主要解決了以下兩個問題.

  • 提升了首頁的載入速度,這對於單頁應用而言有明顯的優勢.
  • 服務端渲染優化了seo.使用伺服器端渲染的頁面更容易被搜尋引擎捕獲從而提升網站排名,這一點非常重要.在一些To c的專案中,如果使用者在搜尋引擎裡面輸入關鍵字後,發現網站搜都搜不出來,那就更談不上盈利了.只要是跟經濟效益掛鉤的技術,對於每一個技術人而言都應該重點關注.

在前後端還沒分離的那個時代,像JAVA,PHP這些老牌程式語言.它們一直都在使用伺服器渲染頁面,並且多年的沉澱已經發展出了很多成熟的方案.

如今前後端分離已經覆蓋了整個行業,前端程式設計師慣常使用三大框架vue,reactangular開發頁面.一旦前端使用這些先進的框架開發出了頁面,後臺程式語言是JAVAPHP,它們做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.htmlidappdom元素.

從上面客戶端渲染的流程來看,後端傳送給前臺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程式碼是客戶端和服務端共用的,最好不用絕對路徑寫死.還有一個更優雅的方法,就是對axiosbaseURL進行配置生成帶有域名的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)); //建立資料倉庫
 	
  ***省略   	
   
 })

在建立ctxaxios例項的時候將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取出來賦值給axiosheaders,這樣就確保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屬性就會擁有頁面元件的樣式.最後將這份樣式拼接到htmlhead頭部裡即可.

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,在其中分別設定titlemeta;

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()獲取被渲染頁面元件的metatitle資料,再將它們填充到html字串即可.

這樣一來瀏覽器訪問localhost:3000/list,返回的html檔案的頭部就會包含上面定義的titlemeta資訊.

原始碼

完整程式碼

結尾

上面這一整套流程走下來還是挺複雜的,伺服器端渲染的難點不是在於本身技術存在難度.而是整個流程有些複雜,要處理的細節非常多.但如果真的將這些原理都吃透,那麼不光是vue框架,像reactangular都可以按照同樣的思路去實現伺服器端渲染.