A Simple Pre-Rendered Web App Using Vue + Nuxt

Michael Lynch

使用 Vue + Nuxt 打造簡單的預先渲染網頁應用程式

原文由 Michael Lynch 發布,訂閱此部落格

在這篇文章中,我會示範如何使用 Vue 和 Nuxt 預先渲染頁面。這個方法結合了 Vue 便利的開發體驗,同時又不會犧牲社群分享或搜尋引擎最佳化等關鍵功能。

這篇教學假設你完全沒有 Vue 或 Nuxt 的經驗,過程中我會一一解說。

Vue 的問題

跟 Angular 和 React 一樣,Vue 是一個用來打造單頁式應用程式(SPA)的框架。傳統網站每當使用者點擊站內連結時,都會迫使瀏覽器重新下載整個頁面,而 SPA 則是把所有內容都維持在同一個頁面上。當使用者在你的網站中瀏覽時,JavaScript 只是重新繪製新的頁面,而不需要再從伺服器把所有東西重新抓下來。這省去了使用者瀏覽器與你的網頁伺服器之間緩慢的網路請求,讓使用者體驗感覺快速又流暢。

Vue 帶來快速反應的代價,是你對頁面初始 HTML 的控制變少了。當瀏覽器從伺服器取得 SPA 時,它收到的 HTML 大概會長這樣:

<html>
<head>
  <title>My Awesome Website</title>
</head>
<body>
  <div id="app"></div>
  <!-- app.js populates the rest of the page after the browser executes the script. -->
  <script type="text/javascript" src="app.js">
</body>
</html>

因為是單頁式應用程式,這個 HTML 空殼在你網站上的每個頁面都是一樣的。換句話說,不管使用者造訪的是 yoursite.com/about 還是 yoursite.com/contact,伺服器傳給他們的都是同一個 HTML 空殼。JavaScript 負責在使用者瀏覽器中執行後,判斷路徑並繪製出對應的頁面。

動態頁面渲染是一項讓網站導覽變快的巧妙創新,但當你把網站串接到社群網路或搜尋引擎時,就會產生問題。

SPA 問題一:社群分享

當我在 Twitter 上分享我的部落格文章時,看起來會像這樣:

豐富的 Twitter 卡片範例

透過 Open Graph 標籤讓 Twitter 為我的文章產生豐富卡片。

Twitter 是根據頁面中遵循 Open Graph 標準的 HTML 標籤來產生這種卡片。舉例來說,為了指定卡片中的圖片,我會加入一個長這樣的標籤:

<meta property="og:image" content="https://mtlynch.io/post-42/cover.jpg" />

如果你的網站是 SPA,那麼所有頁面都會共用同一個 HTML 骨架,也因此共用同一組 Open Graph 標籤。像 Twitter 和 Facebook 這類主流社群網路,都要求 Open Graph 標籤必須在任何 JavaScript 執行之前就已存在。結果就是,你無法為網站上的不同頁面建立各自獨立的 Twitter 卡片或 Facebook 卡片。

SPA 問題二:搜尋引擎最佳化(SEO)

跟社群網站不同,搜尋引擎確實會用 JavaScript 來渲染網站。問題在於,它們無法完美做到這一點

許多網站會在使用者瀏覽時,用 JavaScript 持續更新頁面內容。從 Google 的角度來看,頁面什麼時候才算「渲染完成」、可以開始索引?對於一般的 SPA 來說,Google 會嘗試索引你的頁面,但你無法保證它一定會正確索引。

靠 Nuxt 來救援

在現代的網路上,社群網路和 SEO 都相當重要,所以如果用了 Vue 就代表你的應用程式無法與這些服務完整整合,那會非常可惜。

Nuxt.js 標誌

Nuxt 就是用來解決這個問題的框架。它在 Vue 之上多加了一層,把一部分原本由瀏覽器處理的工作搬回伺服器端。Nuxt 不會只傳下一個空的 HTML 空殼、再等待客戶端的 JavaScript 來渲染所有內容,而是會在伺服器端先預先處理頁面,產生更完整的 HTML。

伺服器端渲染的問題

大多數人都是在網頁伺服器上執行 Nuxt,這就叫做「伺服器端渲染」。當使用者向伺服器請求頁面時,Nuxt 會在伺服器端即時建立好頁面,再傳送到使用者的瀏覽器。

伺服器端渲染可以減少應用程式初始載入的時間,因為你的伺服器分擔了一部分瀏覽器的工作。但如果你只是想為了社群分享和 SEO 多填幾個 HTML 標籤,就為了這樣在技術架構中加入 Nuxt 和一整台 Node.js 伺服器,實在有點小題大作。

SPA 最大的優勢之一,就是它只是靜態的 HTML、CSS 和 JavaScript,完全不需要應用程式伺服器。像 Google Cloud Storage 和 Amazon S3 這類簡單的檔案託管服務就能託管標準的 SPA。如果你使用伺服器端渲染,就必須從靜態檔案託管升級到完整的應用程式伺服器,成本更高、也更複雜。

幸好,除了伺服器端渲染之外還有另一個選擇:預先渲染。Nuxt 不會在收到 HTTP 請求時才即時渲染頁面,而是會事先把你網站上的每個頁面都渲染好。這個過程會產生靜態檔案,所以你依然可以把應用程式託管在任何能託管標準 SPA 的地方。

你該使用預先渲染嗎?

預先渲染並不適合所有情況。你需要判斷你的應用程式需要什麼,到底是需要預先渲染、伺服器端渲染,還是維持使用單純的 Vue 就好。下面我列出了一些優缺點,幫助你決定何時該採用預先渲染。

預先渲染的優點

  • 讓你能為網站上的每個頁面設定獨立的社群分享卡片
  • 提升頁面載入速度
    • 標準的 SPA 必須等到瀏覽器下載並執行完 JavaScript 後才開始渲染頁面。採用預先渲染時,使用者在瀏覽器執行任何 JavaScript 之前就能看到你的頁面。

預先渲染的缺點

  • 比標準的 Vue 更複雜
    • 雖然預先渲染比在 Node 伺服器上執行 Nuxt 來得單純,但仍比執行完全在客戶端運行的 Vue 應用程式複雜。
    • 使用預先渲染時,你必須在腦中釐清程式碼是在伺服器端還是客戶端執行,以及執行當下有哪些可用的執行環境。
  • 無法支援使用者產生的頁面
    • 預先渲染要求你在建置頁面時就知道所有的頁面路由。它明確不支援動態路由
    • 如果你的網站包含使用者產生的內容,例如你希望使用者加入後能擁有自己的網址(例如 yoursite.com/users/michael123),用預先渲染就無法做到。
    • 你可以把部分路由改成 URL 查詢參數來變通(例如 yoursite.com/users?id=michael123),然後在客戶端再抓取動態資料,但你仍然無法為這些頁面產生各自獨立的社群分享標籤。

預先渲染的「Hello, world」

為了示範預先渲染,我會用僅僅三個檔案來展示一個基本的、預先渲染的「Hello, world!」應用程式。

唯一的先決條件是 Node.js。我使用的是 Node v12.13.1,也就是撰寫本文當時的最新穩定版本。

pages/index.vue

第一個檔案定義了網頁應用程式中的一個頁面。pages/ 資料夾對 Nuxt 有特殊的意義。它會為在 pages/ 資料夾中找到的每個 .vue 檔案預先渲染成各自獨立的頁面。名稱 index.vue 代表這是根頁面,也就是使用者在沒有指定任何路徑時會看到的頁面。

index.vue 會產生一個簡單的「Hello, world!」頁面,顯示歡迎訊息和一個按鈕。為了展示 Vue 的一些客戶端功能,這個按鈕會在使用者每次點擊時更新文字。

<template>
  <div>
    <h1>Hello, world!</h1>
    <p>I'm an example of a pre-rendered Vue webpage.</p>
    <button v-on:click="count++">I have been clicked {{ count }} times</button>
  </div>
</template>

<script>
  export default {
    data: function () {
      return {
        count: 0,
      };
    },
  };
</script>

package.json

package.json 檔案會告訴 Node.js 如何建置這個應用程式:

{
  "name": "hello-world-vue-pre-rendered",
  "dependencies": {
    "nuxt": "latest"
  },
  "scripts": {
    "dev": "nuxt --port 3600",
    "generate": "nuxt generate"
  }
}

nuxt.config.js

最後,Nuxt 需要一個設定檔,即使內容是空的也一樣:

// Even though we have no Nuxt settings, this file is required.

執行「Hello, world」

你可以在 Codesandbox 上執行這個應用程式:

或者,你也可以用以下指令在本機上執行這個應用程式:

git clone https://github.com/mtlynch/hello-world-vue-pre-rendered.git
cd hello-world-vue-pre-rendered
git checkout step-1

npm install
npm run dev

應用程式會在 http://localhost:3600 上執行。

預先渲染你的應用程式

當你執行 npm run dev 時,你使用的是伺服器端渲染。Node 會執行一個本地開發伺服器,並在你請求頁面時即時產生頁面。

但我答應要給你的是預先渲染的頁面。對於預先渲染的頁面,你甚至不需要網頁伺服器,因為它就是一組靜態檔案。

要預先渲染你的應用程式,請執行以下指令:

npm run generate

如果你查看 dist/ 資料夾,會看到 Nuxt 已經為你預先渲染好頁面:

$ find ./dist/ -type f
./dist/.nojekyll
./dist/200.html
./dist/index.html
./dist/_nuxt/7cef7880379068a94897.js
./dist/_nuxt/b10d0692e6306468ee9f.js
./dist/_nuxt/cae55ee8b1125819f113.js
./dist/_nuxt/ee10340617a3beab9da2.js
./dist/_nuxt/LICENSES

你可以透過一個簡單的 HTTP 伺服器來檢視這些檔案,例如 Python2 的 SimpleHTTPServer:

cd dist
python -m SimpleHTTPServer 8123

接著 Python 會啟動一個網頁伺服器,讓你可以在 http://localhost:8123 上檢視預先渲染好的應用程式。稍後我會示範如何將這個應用程式發布到靜態檔案託管服務。

新增 About 頁面

為了讓內容更有趣一點,我會為這個應用程式再新增第二個頁面。

pages/about.vue

這個頁面使用 Vue 的 hook 來顯示頁面是如何被渲染的相關資訊。我會在下方更詳細地解釋這段程式碼。

<template>
  <div>
    <h1>About this Build</h1>
    <p v-if="buildTime">
      Nuxt pre-rendered this page at
      <b>{{ buildTime }}</b> (before the browser ever saw it).
    </p>
    <template v-else>
      <p>
        Vue generated this page client-side because you navigated here from
        another route on the same site.
      </p>
      <p>
        <a href="/about">Refresh the page</a> to see the pre-rendered version.
      </p>
    </template>
    <p>
      The browser loaded this page at
      <b>{{ loadTime }}</b>.
    </p>
    <p><nuxt-link to="/">Home</nuxt-link></p>
  </div>
</template>

<script>
  export default {
    asyncData() {
      // Don't re-evaluate buildTime when the client loads this page in the
      // browser.
      if (!process.client) {
        return {
          buildTime: new Date().toUTCString(),
        };
      }
    },
    // Vue evaluates data variables at page render time and again every time the
    // browser loads this page.
    data: function () {
      return {
        loadTime: new Date().toUTCString(),
      };
    },
  };
</script>

這是 About 頁面的線上版本

了解 About 頁面的兩種版本

About 頁面展示了 Nuxt 和 Vue 如何協作來建立預先渲染的頁面。根據你瀏覽網站的方式,你會看到兩種不同版本的頁面。

About 頁面不同版本的截圖

About 頁面會根據你的到達方式顯示不同的資訊。

如果你直接從 /about 頁面開始瀏覽,應該會看到左邊的版本。如果你從根頁面開始,再點擊「about page」連結,應該會看到右邊的版本。

為什麼會看到兩種不同版本的頁面?答案就在 asyncData hook 裡。這個函式會在兩個時間點執行:

  1. (伺服器端)當 Nuxt 預先渲染頁面時
  2. (客戶端)當瀏覽器從站內其他地方導覽到這個頁面時

定義如下:

asyncData() {
  // Don't re-evaluate buildTime when the client loads this page in the
  // browser.
  if (!process.client) {
    return {
      buildTime: new Date().toUTCString(),
    };
  }
},

當 Nuxt 預先渲染網站時,伺服器會執行 asyncData 方法。在伺服器環境中,process.client 是 null,因此它會把 buildTime 設為當下的時間,並在預先渲染頁面的 HTML 時使用這個變數。

當你從站內不同頁面導覽到 /about 路徑時,瀏覽器會在載入頁面時執行 asyncData 方法。此時 process.client 因為程式碼是在客戶端執行而不再是 null,所以這個方法不會定義 buildTime,而 Vue 就會渲染 buildTime 未定義時的頁面樣板:

<p v-if="buildTime">...</p>
<template v-else>
  <p>
    Vue generated this page client-side because you navigated here from another
    route on the same site.
  </p>
  <p><a href="/about">Refresh the page</a> to see the pre-rendered version.</p>
</template>

預先渲染只針對第一個頁面

About 頁面展示了預先渲染的一個細微之處:Nuxt 只會預先渲染使用者造訪的第一個頁面。之後,Vue 就會像一般的 SPA 那樣,每當使用者在站內導覽時都在客戶端重新繪製頁面。這其實是好事,代表你的應用程式既能保留 Vue 瞬間切換頁面的導覽體驗,又不會犧牲與需要伺服器端渲染的服務的相容性。

在本機執行 About 頁面

想實際操作 About 頁面的話,請執行以下指令

git clone https://github.com/mtlynch/hello-world-vue-pre-rendered.git
cd hello-world-vue-pre-rendered

npm install
npm run dev

你會發現,當你瀏覽到 https://localhost:3600/about 時,建置時間和載入時間大致相同。這是因為當你執行 npm run dev 時,Nuxt 是使用伺服器端渲染來即時建立頁面的。

以伺服器端渲染方式呈現的 About 頁面截圖

npm run dev 會在使用者請求時才渲染頁面,因此建置時間和載入時間會一致。

跟只產生一次頁面並持續提供同一份頁面的預先渲染不同,伺服器端渲染會在使用者每次造訪時都產生一份全新的頁面。

發布你的應用程式

使用預先渲染時,你不需要 Node.js 伺服器來託管應用程式。你只需要一個支援靜態檔案託管的託管服務即可。

以下是幾個熱門平台發布靜態檔案的操作說明:

原始碼

這個範例的所有程式碼都已在 GitHub 上以 MIT 授權釋出:

功能更豐富的範例

如果你要用 Vue 和 Nuxt 打造實際可用的應用程式,你會需要比兩個預先渲染頁面更多的功能。我建立了一個樣板專案 pre-vue,裡面包含了 SEO 和社群分享所需的所有樣板程式碼:

它具備以下功能:

  • 產生 robots.txt 檔案
  • 產生 sitemap
  • 為每個頁面支援獨立的 <title> 標籤及其他與 SEO 相關的 <meta> 標籤
  • 為每個頁面加入獨立的 Open Graph 標籤
  • 加入 Google Analytics 支援
  • 加入 favicon
  • 處理 404 錯誤

我使用 pre-vue 樣板重寫了 Zestful 展示網站,它原本是一個 Angular SPA。README 中說明了如何使用 pre-vue,如果大家有興趣,我會再發布一篇詳細解說細節的部落格文章。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言