1. 程式人生 > >react精華之next.js getInitialProps自動切換服務端渲染和瀏覽器渲染 而不需要同時使用渲染

react精華之next.js getInitialProps自動切換服務端渲染和瀏覽器渲染 而不需要同時使用渲染

我們已經知道了伺服器端渲染的原理,你只需要搭建一個 Express 伺服器,在伺服器端手工打造『脫水』,在瀏覽器端做『注水』,完成某個頁面的伺服器端渲染並不難。

不過,伺服器端渲染的問題並不這麼簡單,一個最直接的問題,就是怎麼處理多個頁面的『單頁應用』(Single-Page-Application)?

所以單頁應用,就是雖然使用者感覺有多個頁面,但是實現上只有一個頁面,使用者感覺到頁面可以來回切換,但其實只是一個頁面並沒有完全重新整理,只是區域性介面更新而已。

假設一個單頁應用有三個頁面 Home、Prodcut 和 About,分別對應的的路徑是 /home/product

和 /about,而且三個頁面都依賴於 API 呼叫來獲取外部資料。

現在我們要做伺服器端渲染,如果只考慮使用者直接在位址列輸入 /home/product 和 /about 的場景,很容易滿足,按照上面說的套路做就是了。但是,這是一個單頁應用,使用者可以在 Home 頁面點選連結無縫切換到 Product,這時候 Product 要做完全的瀏覽器端渲染。換句話說,每個頁面都需要既支援伺服器端渲染,又支援完全的瀏覽器端渲染,更重要的是,對於開發者來說,肯定不希望為了這個頁面實現兩套程式,所以必須有同時滿足伺服器端渲染和瀏覽器端渲染的程式碼表示方式。

讀者可以思考一下什麼樣的程式碼表示合適,也可以直接往下,看看業界公認最科學的實現方式 Next.js 是如何做的。

快速建立 Next.js 專案

在說明 Next.js 的工作原理之前,我們先看怎麼快速建立 Next.js 專案,這個問題用程式碼來說明會更順暢。

我們也可以手工建立 Next.js 專案,不過更簡單的方式是用自動化工具 create-next-app,這個 create-next-app 類似於 create-react-app,一個命令就建立一個可以執行的應用。

首先安裝 create-next-app。

npm install -g create-next-app

然後,就可以在你專門存放專案的目錄下執行 create-next-app,產生一個使用 Next.js 的 React 應用,下面的命令建立一個叫 next_demo 的應用:

create-next-app next_demo

進入新生成的專案目錄 next_demo 裡檢查一下,可以看到檔案結構非常簡潔,pages 目錄下是頁面檔案,package.json 中差不是下面這樣,沒有繁冗的 webpack 和 babel 依賴包,因為一切都被 Next.js 封裝起來了。

{
  "name": "create-next-example-app",
  "scripts": {
    "dev": "next",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "^6.0.3",
    "react": "^16.5.2",
    "react-dom": "^16.5.2"
  }
}

雖然有不少框架都表示自己的功能很強大,但其中有很多框架的設計並不中立,用這些框架去開發某些特定應用或許還行,如果放到一個更大範圍的應用型別中,就會發現無法滿足要求,這樣的框架通用性不足,開發者一定要謹慎使用。

講良心話,Next.js 真的是一個通用性非常高的框架,因為 Next.js 完全遵從了 React 的技術哲學:一切皆為元件。

在 Next.js 中,創造一個頁面,其實就是創造一個 React 元件,接下來我們看看如何建立一個頁面。

編寫頁面

使用下面的命令啟動 Next.js 應用,進入的是開發者模式,這時候對程式碼的改變,會立刻體現在網頁上。

npm run dev

請注意,這一點上 Next.js 的習慣用法和 create-react-app 產生的應用不一樣。在 create-react-app 產生的應用中, npm run start 啟動是開發者模式,但在 Next.js 應用中,習慣上 npm rum start 以產品模式啟動,所以要先執行 npm run build 然後才能執行 npm run start。

Next.js 遵從『協定優於配置』(convention over configuration)的設計原則,根據『協定』,在 pages 中每個檔案對應一個網頁檔案,檔名對應的就是網頁的路徑名,比如 pages/home.js 檔案對應的就是 /home 路徑的頁面,當然 pages/index.js 比較特殊,對應的是預設根路徑 / 的頁面。

我們修改 pages/index.js,讓它更簡單一些,如下:

import React from 'react'

const Home = (props) => (
  <h1>
    Hello World
  </h1>
)

export default Home

這樣會在頁面上顯示出一個 Hello World,而這個頁面程式碼就是一個普通的 React 元件而已。

頁面都是 React 元件,這就是 Next.js 的哲學。

getInitialProps

我們還是要回到本來的話題,如何優雅地實現伺服器端渲染,上面的 Home 頁面雖然能夠渲染出完整包含 Hello World 的 HTML,但是並沒有呼叫任何外部 API 資源,所以也沒有非同步操作,並不能體現伺服器端渲染的難度。

我們用一個函式來實現非同步操作,以此模擬呼叫 API 的延遲效果,如下:

const timeout = (ms, result) => {
  return new Promise(resolve => setTimeout(() => resolve(result), ms));
};

然後,我們利用這個 timeout 來獲得展示網頁所需的資料。比如說,獲取使用者名稱,那麼我們的 Home 元件就要換一個寫法,像下面那樣,增加 getInitialProps 的定義:

const Home = (props) => (
  <h1>
    Hello {props.userName}
  </h1>
)

Home.getInitialProps = async () => {
  return await timeout(200, {userName: 'Morgan'});
};

這個 getiInitialProps 是 Next.js 最偉大的發明,它確定了一個規範,一個頁面元件只要把訪問 API 外部資源的程式碼放在 getInitialProps 中就足夠,其餘的不用管,Next.js 自然會在伺服器端或者瀏覽器端呼叫 getInitialProps 來獲取外部資源,並把外部資源以 props 的方式傳遞給頁面元件。

注意 getInitialProps 是頁面元件的靜態成員函式,可以用下面的方法定義:

Home.getInitialProps = async () = {...};

也可以在元件類中加上 static 關鍵字定義:

class Home extends React.Component {
  static async getInitialProps() {
     ...
  }
}

通過上面的程式碼,我麼也可以注意到,getInitialProps 是一個 async 函式,所以,在 getInitialProps 函式中可以使用 await 關鍵字,用同步的方式編寫非同步邏輯。

我們可以這樣來看待 getInitialProps,它就是 Next.js 對代表頁面的 React 元件生命週期的擴充。React 元件的生命週期函式缺乏對非同步操作的支援,所以 Next.js 乾脆定義出一個新的生命週期函式 getInitialProps,在呼叫 React 原生的所有生命週期函式之前,Next.js 會呼叫 getInitialProps 來獲取資料,然後把獲得資料作為 props 來啟動 React 元件的原生生命週期過程。

這個生命週期函式的擴充十分巧妙,因為:

  1. 沒有侵入 React 原生生命週期函式,以前的 React 元件該怎麼寫還是怎麼寫;
  2. getInitialProps 只負責獲取資料的過程,開發者不用操心什麼時候呼叫 getInitialProps,依然是 React 哲學的宣告式程式設計方式;
  3. getInitialProps 是 async 函式,可以利用 JavaScript 語言的新特性,用同步的方式實現非同步功能。

Next.js 的“脫水”和“注水”

我們說過伺服器端渲染的關鍵是如何“脫水”和“注水”,如果你對 Next.js 如何實現這兩個關鍵點好奇(實際上你確實應該感到好奇),那麼在瀏覽器中使用“顯示網頁原始碼”就可以讓你一目瞭然。

在網頁的 HTML 中,可以看到類似下面的內容:

<script>
  __NEXT_DATA__ = {
    "props":{
      "pageProps": {"userName":"Morgan"}},
      "page":"/","pathname":"/","query":{},"buildId":"-","assetPrefix":"","nextExport":false,"err":null,"chunks":[]}
</script>

Next.js 在做伺服器端渲染的時候,頁面對應的 React 元件的 getInitialProps 函式被呼叫,非同步結果就是“脫水”資料的重要部分,除了傳給頁面 React 元件完成渲染,還放在內嵌 script 的 __NEXT_DATA__ 中,這樣,在瀏覽器端渲染的時候,是不會去呼叫 getInitialProps 的,直接通過 __NEXT_DATA__ 中的“脫水”資料來啟動頁面 React 元件的渲染。

這樣一來,如果 getInitialProps 中有呼叫 API 的非同步操作,只在伺服器端做一次,瀏覽器端就不用做了。

那麼,getInitialProps 什麼時候會在瀏覽器端呼叫呢?

當在單頁應用中做頁面切換的時候,比如從 Home 頁切換到 Product 頁,這時候完全和伺服器端沒關係,只能靠瀏覽器端自己了,Product頁面的 getInitialProps 函式就會在瀏覽器端被呼叫,得到的資料用來開啟頁面的 React 原生生命週期過程。

關鍵點是,瀏覽器可能會直接訪問 /home 或者 /product,也可能通過網頁切換訪問這兩個頁面,也就是說 Home 或者 Product 都可能被伺服器端渲染,也可能完全只有瀏覽器端渲染,不過,這對應用開發者來說無所謂,應用開發者只要寫好 getInitialProps,至於呼叫 getInitialProps 的時機,交給 Next.js 處理就好了。

你可以發明自己的伺服器端框架,但很可能最後你發現,如果要做得通用性好,最後都會做到和 Next.js 一樣的模式上來。

值得一提的是,getInitialProps 返回的應該是“純資料”,也就是不要返回一個定製類的例項。比如,有一個類 Foo 有一個成員函式 bar,不要在 getInitialProps 返回一個 Foo 例項。不然,經過“脫水”和“注水”過程,網頁元件獲得的那個“Foo 例項”不再是你想的那個 Foo 例項了,它變成了一個純粹的資料,不會包含成員函式 bar的。