React 服務端渲染如此輕鬆 從零開始構建前後端應用

LucasHC發表於2017-07-19

參加或留意了最近舉行的JSConf CN 2017的同學,想必對 Next.js 不再陌生, Next.js 的作者之一到場進行了精彩的演講。其實在更早些時候,由 Facebook 舉辦的 React Conf 2017,他就到場並有近40分鐘的分享。但兩次分享帶來的 demo 都是 hacker news。我觀察 Next.js 時間較長,看著它從1.x 版本一直到了今天的 3.x,終於決定寫一篇入門級的新手指導文章。而這篇文章試圖通過一個全新的例子,來讓大家瞭解 Next.js 到底是如何與 React 配合,達到服務端渲染的。

“React universal” 是社群上形容基於 React 構建 web 應用,並採用“服務端渲染”方式的一個詞語。也許很多人對 “isomorphic” 這個單詞更加熟悉,其實這兩個詞語想要表達的概念類似。今天這篇文章顯然不是討論這兩個詞語的,我們要嘗試使用最新版 Next.js,構件一個簡單的服務端渲染 React 應用。

最終專案地址可以點選這裡檢視。

為何要開發 Universal 應用?

React app 實現了虛擬 DOM,來實現對真實 DOM 的抽象。這樣的設計迅速引領了前端開發浪潮。但是 “Every great thing comes with a price”,虛擬 DOM 同樣帶來了一些弊端,比如在前後端分離的開發模式下,SEO就成了問題;同樣首屏載入時間變長,各種 loading 消磨人的耐心。就像下面截圖所展現的那樣:

頁面
頁面

檢視網頁原始碼
檢視網頁原始碼

使用 Next.js 實現 Universal

Universal 應用架構可以簡單粗暴先而片面的理解成應用將在客戶端和服務端共同完成渲染。這樣取代了完全由客戶端渲染(前後端分離方式)模式。在 React 場景下,我們可以使用 React 自身的 renderToString 完成服務端初次渲染。但是如果我們每次手動來完成這些過程,手動實現服務端繁瑣配置,難免令人頭大心煩。

Next.js 的出現,就是為你解決這種惱人的問題。我們先來認識一下它的幾個原則和思想:

  • 不需要除 Next 之外,多餘的配置和安裝(比如 webpack,babel);
  • 使用 Glamor 處理樣式;
  • 自動編譯和打包;
  • 熱更新;
  • 方便的靜態資源管理;
  • 成熟靈活的路由配置,包括路由級別 prefetching;

Demo:英超聯賽積分榜

其實關於更多的 Next.js 設計理念我不想再贅述了,讀者都可以在其官網找到豐富的內容。下面,我將使用 Football Data API 來簡單開發一個基於 Next.js 的應用,這個應用將展現英超聯賽的實時積分榜。同時包含了簡單的路由開發和頁面跳轉。

小試牛刀

相信所有的開發者都厭惡超長時間的安裝和各種依賴、外掛配置。不要擔心,Next.js 作為一個獨立的 npm package 最大限度的替你完成了很多耗時且無趣的工作。我們首先需要進行安裝:

# Start a new project
npm init
# Install Next.js
npm install next --save複製程式碼

安裝結束後,我們就可以開啟指令碼:

"scripts": {
   "start": "next"
 },複製程式碼

Next 安裝的同時,也會安裝 React,所以無需自己費心。接下來所需要做的很簡單,就是在根目錄下建立一個 pages 資料夾,並在其下新建一個 index.js 檔案:

// ./pages/index.js

// Import React
import React from 'react'

// Export an anonymous arrow function
// which returns the template
export default () => (
  <h1>This is just so easy!</h1>
)複製程式碼

好了,現在就可以直接看到結果:

# Start your app
npm start複製程式碼

頁面
頁面

驗證一下它來自服務端渲染:

檢視網頁原始碼
檢視網頁原始碼

就是這麼簡單,清新。如果我們自己手段實現這一切的話,除了 NodeJS 的種種繁瑣不說,webpack 配置,node_modules 依賴,babel外掛等等就夠折騰半天的了。

新增 Page Head

在 ./pages/index.js 檔案內,我們可以新增頁面 head 標籤、meta 資訊、樣式資源等等:

// ./pages/index.js
import React from 'react'
// Import the Head Component
import Head from 'next/head'

export default () => (
  <div>
    <Head>
        <title>League Table</title>
        <meta name="viewport" content="initial-scale=1.0, width=device-width" />
        <link rel="stylesheet" href="https://unpkg.com/purecss@0.6.1/build/pure-min.css" />
    </Head>
    <h1>This is just so easy!</h1>
  </div>
)複製程式碼

這個 head 當然不是指真實的 DOM,千萬別忘了 React 虛擬 DOM 的概念。其實這是 Next 提供的 Head 元件,不過最終一定還是被渲染成為真實的 head 標籤。

傳送 Ajax 請求

Next 還提供了 getInitialProps 方法,這個方法支援非同步選項,並且是服務端/客戶端同構的。我們可以使用 async/await 方式,處理非同步請求。請看下面的示例:

import React from 'react'
import Head from 'next/head'
import axios from 'axios';

export default class extends React.Component {
    // Async operation with getInitialProps
    static async getInitialProps () {
        // res is assigned the response once the axios
        // async get is completed
        const res = await axios.get('http://api.football-data.org/v1/competitions/426/leagueTable');
        // Return properties
        return {data: res.data}
      }
 }複製程式碼

我們使用了 axios 類庫來傳送 HTTP 請求。網路請求是非同步的,因此我們需要在未來某個合適的時候(請求結果返回時)接收資料。這裡使用先進的 async/await,以同步的方式處理,從而避免了回撥巢狀和 promises 鏈。

我們將非同步獲得的資料返回,它將自動掛載在 props 上(注意 getInitialProps 方法名,顧名思義),render 方法裡便可以通過 this.props.data 獲取:

import React from 'react'
import Head from 'next/head'
import axios from 'axios';

export default class extends React.Component {
  static async getInitialProps () {
    const res = await axios.get('http://api.football-data.org/v1/competitions/426/leagueTable');
    return {data: res.data}
  }
  render () {
    return (
      <div>
        <Head>
            ......
        </Head>
        <div className="pure-g">
            <div className="pure-u-1-3"></div>
            <div className="pure-u-1-3">
              <h1>Barclays Premier League</h1>
              <table className="pure-table">
                <thead>
                  <tr>
                    ......
                  </tr>
                </thead>
                <tbody>
                {this.props.data.standing.map((standing, i) => {
                  const oddOrNot = i % 2 == 1 ? "pure-table-odd" : "";
                  return (
                      <tr key={i} className={oddOrNot}>
                        <td>{standing.position}</td>
                        <td><img className="pure-img logo" src={standing.crestURI}/></td>
                        <td>{standing.points}</td>
                        <td>{standing.goals}</td>
                        <td>{standing.wins}</td>
                        <td>{standing.draws}</td>
                        <td>{standing.losses}</td>
                      </tr>
                    );
                })}
                </tbody>
              </table>
            </div>
            <div className="pure-u-1-3"></div>
        </div>
      </div>
    );
  }
}複製程式碼

這樣,再訪問我們的頁面,就有了:

頁面
頁面

路由和頁面跳轉

也許你已經有所感知:我們已經有了最基本的一個路由。Next 不需要任何額外的路由配置資訊,你只需要在 pages 資料夾下新建檔案,每一個檔案都將是一個獨立的頁面。

讓我們來新建一個 team 頁面吧!新建 ./pages/details.js 檔案:

// ./pages/details.js
import React from 'react'
export default () => (
  <p>Coming soon. . .!</p>
)複製程式碼

我們使用 Next 已經準備好的元件 來進行頁面跳轉:

// ./pages/details.js
import React from 'react'

// Import Link from next
import Link from 'next/link'

export default () => (
  <div>
      <p>Coming soon. . .!</p>
      <Link href="/"><a>Go Home</a></Link>
  </div>
)複製程式碼

這個頁面不能總是 “Coming soon. . .!” 的資訊,我們來進行完善以展示更多內容,通過頁面 URL 的 query id 變數,我們來請求並展現當前相應隊伍的資訊:

import React from 'react'
import Head from 'next/head'
import Link from 'next/link'
import axios from 'axios';

export default class extends React.Component {
    static async getInitialProps ({query}) {
        // Get id from query
        const id = query.id;
        if(!process.browser) {
            // Still on the server so make a request
            const res = await axios.get('http://api.football-data.org/v1/competitions/426/leagueTable')
            return {
                data: res.data,
                // Filter and return data based on query
                standing: res.data.standing.filter(s => s.position == id)
            }
        } else {
            // Not on the server just navigating so use
            // the cache
            const bplData = JSON.parse(sessionStorage.getItem('bpl'));
            // Filter and return data based on query
            return {standing: bplData.standing.filter(s => s.position == id)}
        }
    }

    componentDidMount () {
        // Cache data in localStorage if
        // not already cached
        if(!sessionStorage.getItem('bpl')) sessionStorage.setItem('bpl', JSON.stringify(this.props.data))
    }

    // . . . render method truncated
 }複製程式碼

這個頁面根據 query 變數,動態展現出球隊資訊。具體來看,getInitialProps 方法獲取 URL query id,根據 id 篩選出(filter 方法)展示資訊。有意思的是,因為一直球隊的資訊比較穩定,所以在客戶端使用了 sessionStorage 進行儲存。

完整的 render 方法:

// . . . truncated

export default class extends React.Component {
    // . . . truncated
    render() {

        const detailStyle = {
            ul: {
                marginTop: '100px'
            }
        }

        return  (
             <div>
                <Head>
                    <title>League Table</title>
                    <meta name="viewport" content="initial-scale=1.0, width=device-width" />
                    <link rel="stylesheet" href="https://unpkg.com/purecss@0.6.1/build/pure-min.css" />
                </Head>

                <div className="pure-g">
                    <div className="pure-u-8-24"></div>
                    <div className="pure-u-4-24">
                        <h2>{this.props.standing[0].teamName}</h2>
                        <img src={this.props.standing[0].crestURI} className="pure-img"/>
                        <h3>Points: {this.props.standing[0].points}</h3>
                    </div>
                    <div className="pure-u-12-24">
                        <ul style={detailStyle.ul}>
                            <li><strong>Goals</strong>: {this.props.standing[0].goals}</li>
                            <li><strong>Wins</strong>: {this.props.standing[0].wins}</li>
                            <li><strong>Losses</strong>: {this.props.standing[0].losses}</li>
                            <li><strong>Draws</strong>: {this.props.standing[0].draws}</li>
                            <li><strong>Goals Against</strong>: {this.props.standing[0].goalsAgainst}</li>
                            <li><strong>Goal Difference</strong>: {this.props.standing[0].goalDifference}</li>
                            <li><strong>Played</strong>: {this.props.standing[0].playedGames}</li>
                        </ul>
                        <Link href="/">Home</Link>
                    </div>
                </div>
             </div>
            )
    }
}複製程式碼

注意下面截圖中,同一頁面不同 query 值,分別展示了冠軍?切爾西和曼聯的資訊。

切爾西
切爾西

曼聯
曼聯

別忘了我們的主頁(排行榜頁面)index.js 中,也要使用相應的 sessionStorage 邏輯。同時,在 render 方法里加入一條連結到詳情頁的 :

<td><Link href={`/details?id=${standing.position}`}>More...</Link></td>複製程式碼

錯誤頁面

在 Next 中,我們同樣可以通過 error.js 檔案定義錯誤頁面。在 ./pages 下新建 error.js:

// ./pages/_error.js
import React from 'react'

export default class Error extends React.Component {
  static getInitialProps ({ res, xhr }) {
    const statusCode = res ? res.statusCode : (xhr ? xhr.status : null)
    return { statusCode }
  }

  render () {
    return (
      <p>{
        this.props.statusCode
        ? `An error ${this.props.statusCode} occurred on server`
        : 'An error occurred on client'
      }</p>
    )
  }
}複製程式碼

當傳統情況下頁面404時,得到:

404頁面
404頁面

在我們設定 _ error.js 之後,便有:

自定義錯誤頁面
自定義錯誤頁面

總結

這篇文章實現了一個簡易 demo,只是介紹了最基本的 Next.JS 搭建 React 同構應用的基本步驟。

想想你是否厭煩了 webpack 惱人的配置?是否對於 Babel 各種外掛雲裡霧裡?
使用 Next.js,簡單、清新而又設計良好。這也是它在推出短短時間以來,便迅速走紅的原因之一。

除此之外,Next 還有非常多的功能,非常多的先進理念可以應用。

  • 比如 搭配 prefetch,預先請求資源;
  • 再如動態載入元件(Next.js 支援 TC39 dynamic import proposal),從而減少首次 bundle size;
  • 雖然它替我們封裝好了 Webpack、Babel 等工具,但是我們又能 customizing,根據需要自定義。

最後,對於這些本文章沒有演示到的功能是否有些手癢?感興趣的讀者可以關注本文 demo 的Github專案地址,自己手動嘗試起來吧~

本文意譯了Chris Nwamba的:React Universal with Next.js: Server-side React 一文,並對原文進行了升級,相容了最新的 Next 設計。

我的其他關於 React 文章:

Happy Coding!

PS:
作者Github倉庫知乎問答連結
歡迎各種形式交流。

相關文章