React 服務端渲染實戰,Next 最佳實踐

開門見山的說,服務端渲染有兩個特點:

  • 響應快,用戶體驗好,首屏渲染快
  • 對搜索引擎友好,搜索引擎爬蟲可以看到完整的程序源碼,有利於SEO

如果你的站點或者公司未來的站點需要用到服務端渲染,那麼本文將會是非常適合你的一篇入門實戰實踐教學。本文采用 next 框架進行服務器渲染框架的搭建,最終將完成幾個目標:

  1. 項目結構的劃分;
  2. SEO 優化以及首屏加載速度的提升;
  3. 登錄鑑權以及路由的處理;
  4. 對報錯信息的處理;

本文的最終目標是所有人都能跟着這篇教程搭建自己的(第)一個服務端渲染項目,那麼,開始吧。

第一個 Hello World 頁面

我們先新建一個目錄,名爲 jt-gmall,然後進入目錄,在目錄下新建 package.json,添加以下內容:

{
  "scripts": {
    "start": "next",
    "build": "next build",
    "serve": "next start"
  }
}

然後我們需要安裝相關依賴:

antd 是一個 UI 組件庫,爲了讓頁面更加美觀一些。

由於相關依賴比較多,安裝過程可能會比較久,建議切個淘寶鏡像,會快一點。

npm i next react react-dom antd -S

依賴安裝完成後,在目錄下新建 pages 文件夾,同時在該文件夾下創建 index.jsx

const Home = () => <section>Hello Next!</section>

export default Home;

next 中,每個 .js 文件將變成一個路由,自動處理和渲染,當然也可以自定義,這個在後面的內容會講到。

我們運行 npm start 啓動項目並打開 http://localhost:3000,此時可以看到 Hello Next! 被顯示在頁面上了。

我們第一步已經完成,但是我們會感覺這和我們平時的寫法差異不大,那麼實現上有什麼差異嗎?

在打開控制檯查看差異之前,我們先思考一個問題,SEO 的優化是怎麼做到的,我們需要站在爬蟲的角度思考一下,爬蟲爬取的是網絡請求獲取到的 html一般來說(大部分)的爬蟲並不會去執行或者等待 Javascript 的執行,所以說網絡請求拿到的 html 就是他們爬取的 html

我們先打開一個普通的 React 頁面(客戶端渲染),打開控制檯,查看 network 中,對主頁的網絡請求的響應結果如下:

客戶端渲染頁面

我們從圖中可以看出,客戶端渲染的 React 頁面只有一個 id="app"div,它作爲容器承載渲染 react 執行後的結果(虛擬 DOM 樹),而普通的爬蟲只能爬取到一個 id="app" 的空標籤,爬取不到任何內容。

我們再看看由服務端渲染,也就是我們剛纔的 next 頁面返回的內容是什麼:

服務端渲染頁面

這樣看起來就很清楚了,爬蟲從客戶端渲染的頁面中只能爬取到一個無信息的空標籤,而在服務端渲染的頁面中卻可以爬取到有價值的信息內容,這就是服務端渲染對 SEO 的優化。那麼在這裏再提出兩個問題:

  1. 服務端渲染可以對 AJAX 請求的數據也進行 SEO 優化嗎?
  2. 服務端渲染對首屏加載的渲染提升體現在何處?

先解答第一個問題,答案是當然可以,但是需要繼續往下看,所以我們進入後面的章節,對 AJAX 數據的優化以及首屏渲染的優化邏輯。

對 AJAX 異步數據的 SEO 優化

本文的目的不止是教會你如何使用,還希望能夠給大家帶來一些認知上的提升,所以會涉及到一些知識點背後的探討。

我們先回顧第一章的問題,服務端渲染可以對 AJAX 請求的數據也進行 SEO 優化嗎?,答案是可以的,那麼如何實現,我們先捋一捋這個思路。

首先,我們知道要優化 SEO,就是要給爬蟲爬取到有用的信息,而我們不能控制爬蟲等待我們的 AJAX 請求完畢再進行爬取,所以我們需要直接提供給爬蟲一個完整的包含數據的 html 文件,怎麼給?答案已經呼之欲出,對應我們的主題 服務端渲染,我們需要在服務端完成 AJAX 請求,並且將數據填充在 html 中,最後將這個完整的 html 讓爬蟲爬取。

知識點補充: 可以做到執行 js 文件,完成 ajax 請求,並且將內容按照預設邏輯填充在 html 中,需要瀏覽器的 js 引擎,谷歌使用的是 v8 引擎,而 Nodejs 內置也是 v8 引擎,所以其實 next 內部也是利用了 Nodejs 的強大特性(可運行在服務端、可執行 js 代碼)完成了服務端渲染的功能。

下面開始實戰部分,我們新建文件 ./pages/vegetables/index.jsx,對應的頁面是 http://localhost:3000/vegetables

// ./pages/vegetables/index.jsx
import React, { useState, useEffect } from "react";
import { Table, Avatar } from "antd";

const { Column } = Table;

const Vegetables = () => {
  const [data, setData] = useState([{ _id: 1 }, { _id: 2 }, { _id: 3 }]);

  return <section style={{ padding: 20 }}>
    <Table dataSource={data} pagination={false} >
      <Column render={text => <Avatar src="https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png" />} />
      <Column key="_id" />
    </Table>
  </section>
}

export default Vegetables;

進入 vegetables 頁面後發現我們的組件已經渲染,這也對應了我們開頭所說的 next 路由規則,但是我們發現這個佈局有點崩,這是因爲我們還沒有引入 css 的原因,我們新建 ./pages/_app.jsx

import App from 'next/app'
import React from 'react'
import 'antd/dist/antd.css';

export default class MyApp extends App {
  static async getInitialProps({ Component, router, ctx }) {
    let pageProps = {}

    if (Component.getInitialProps) {
      pageProps = await Component.getInitialProps(ctx)
    }

    return { pageProps }
  }

  render() {
    const { Component, pageProps } = this.props
    return <>
      <Component {...pageProps} />
    </>
  }
}

這個文件是 next 官方指定用來初始化的一個文件,具體的內容我們後面會提到。現在再看看頁面,這個時候你的佈局應該就好看多了。

頁面效果

當然這是靜態數據,打開控制檯也可以看到數據都已經被完整渲染在 html 中,那我們現在就開始獲取異步數據,看看是否還可以正常渲染。此時需要用到 next 提供的一個 API,那就是 getInitialProps,你可以簡單理解爲這是一個在服務端執行生命週期函數,主要用於獲取數據,在 ./pages/_app.jsx 中添加以下內容,最終修改後的結果如下:

由於我們的代碼可能運行在服務端也可能運行在客戶端,但是服務端與不同客戶端環境的不同導致一些 API 的不一致,fetch 就是其中之一,在 Nodejs 中並沒有實現 fetch,所以我們需要安裝一個插件 isomorphic-fetch 以進行自動的兼容處理。

請求數據的格式爲 graphql,有興趣的童鞋可以自己去了解一下,請求數據的地址是我自己的小站,方便大家做測試使用的。

import React, { useState } from "react";
import { Table, Avatar } from "antd";
import fetch from "isomorphic-fetch";

const { Column } = Table;
const Vegetables = ({ vegetableList }) => {
  if (!vegetableList) return null;

  // 設置頁碼信息
  const [pageInfo, setPageInfo] = useState({
    current: vegetableList.page,
    pageSize: vegetableList.pageSize,
    total: vegetableList.total
  });
  // 設置列表信息
  const [data, setData] = useState(() => vegetableList.items);

  return <section style={{ padding: 20 }}>
    <Table rowKey="_id" dataSource={data} pagination={pageInfo} >
      <Column dataIndex="poster" render={text => <Avatar src={text} />} />
      <Column dataIndex="name" />
      <Column dataIndex="price" render={text => <>{text}</>} />
    </Table>
  </section>
}

const fetchVegetable = (page, pageSize) => {
  return fetch("http://dev-api.jt-gmall.com/mall", {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    // graphql 的查詢風格
    body: JSON.stringify({ query: `{ vegetableList (page: ${page}, pageSize: ${pageSize}) { page, pageSize, total, items { _id, name, poster, price } } }` })
  }).then(res => res.json());
}

Vegetables.getInitialProps = async ctx => {
  const result = await fetchVegetable(1, 10);

  // 將查詢結果返回,綁定在 props 上
  return result.data;
}

export default Vegetables;

效果圖如下,數據已經正常顯示

效果圖

下面我們來好好捋一捋這一塊的邏輯,如果你此時打開控制檯刷新頁面會發現在 network 控制檯看不到這個請求的相關信息,這是因爲我們的請求是在服務端發起的,並且在下圖也可以看出,所有的數據也在 html 中被渲染,所以此時的頁面可以正常被爬蟲抓取。

那麼由此就可以解答上面提到的第二個問題,服務端渲染對首屏加載的渲染提升體現在何處?,答案是以下兩點:

  1. html 中直接包含了數據,客戶端可以直接渲染,無需等待異步 ajax 請求導致的白屏/空白時間,一次渲染完畢;
  2. 由於 ajax 在服務端發起,我們可以在前端服務器與後端服務器之間搭建快速通道(如內網通信),大幅度提升通信/請求速度;

我們現在來完成第二章的最後內容,分頁數據的加載。服務端渲染的初始頁面數據由服務端執行請求,而後續的請求(如交互類)都是由客戶端繼續完成。

我們希望能實現分頁效果,那麼只需要添加事件監聽,然後處理事件即可,代碼實現如下:

// ...

const Vegetables = ({ vegetableList }) => {
  if (!vegetableList) return null;

  const fetchHandler = async page => {
    if (page !== pageInfo.current) {
      const result = await fetchVegetable(page, 10);
      const { vegetableList } = result.data;
      setData(() => vegetableList.items);
      setPageInfo(() => ({
        current: vegetableList.page,
        pageSize: vegetableList.pageSize,
        total: vegetableList.total,
        onChange: fetchHandler
      }));
    }
  }
  // 設置頁碼信息
  const [pageInfo, setPageInfo] = useState({
    current: vegetableList.page,
    pageSize: vegetableList.pageSize,
    total: vegetableList.total,
    onChange: fetchHandler
  });

  //...
}

html

數據翻頁

到這裏,大家應該對 next 和服務端渲染已經有了一個初步的瞭解。服務端渲染簡單點說就是在服務端執行 js,將 html 填充完畢之後再將完整的 html 響應給客戶端,所以服務端由 Nodejs 來做再合適不過,Nodejs 天生就有執行 js 的能力。

我們下一章將講解如何使用 next 搭建一個需要鑑權的頁面以及鑑權失敗後的自動跳轉問題。

路由攔截以及鑑權處理

我們在工作中經常會遇到路由攔截和鑑權問題的處理,在客戶端渲染時,我們一般都是將鑑權信息存儲在 cookie、localStorage 進行本地持久化,而服務端中沒有 window 對象,在 next 中我們又該如何處理這個問題呢?

我們先來規劃一下我們的目錄,我們會有三個路由,分別是:

  • 不需要鑑權的 vegetables 路由,裏面包含了一些所有人都可以訪問的實時菜價信息;
  • 不需要鑑權的 login 路由,登錄後記錄用戶的登錄信息;
  • 需要鑑權的 user 路由,裏面包含了登錄用戶的個人信息,如頭像、姓名等,如果未登錄跳轉到 user 路由則觸發自動跳轉到 login 路由;

我們先對 ./pages/_app.jsx 進行一些改動,加上一個導航欄,用於跳轉到對應的這幾個頁面,添加以下內容:

//...
import { Menu } from 'antd';
import Link from 'next/link';

export default class MyApp extends App {
  //...

  render() {
    const { Component, pageProps } = this.props
    return <>
      <Menu mode="horizontal">
          <Menu.Item key="vegetables"><Link href="/vegetables"><a>實時菜價</a></Link></Menu.Item>
          <Menu.Item key="user"><Link href="/user"><a>個人中心</a></Link></Menu.Item>
      </Menu>
      <Component {...pageProps} />
    </>
  }
}

數據翻頁

加上導航欄以後,效果如上圖。如果這時候你點擊個人中心會出現 404 的情況,那是因爲我們還沒有創建這個頁面,我們現在來創建 ./pages/user/index.jsx

// ./pages/user/index.jsx

import React from "react";
import { Descriptions, Avatar } from 'antd';
import fetch from "isomorphic-fetch";

const User = ({ userInfo }) => {
  if (!userInfo) return null;

  const { nickname, avatarUrl, gender, city } = userInfo;
  return (
    <section style={{ padding: 20 }}>
      <Descriptions title={`歡迎你 ${nickname}`}>
        <Descriptions.Item label="用戶頭像"><Avatar src={avatarUrl} /></Descriptions.Item>
        <Descriptions.Item label="用戶暱稱">{nickname}</Descriptions.Item>
        <Descriptions.Item label="用戶性別">{gender ? "男" : "女"}</Descriptions.Item>
        <Descriptions.Item label="所在地">{city}</Descriptions.Item>
      </Descriptions>
    </section>
  )
}

// 獲取用戶信息
const getUserInfo = async (ctx) => {
  return fetch("http://dev-api.jt-gmall.com/member", {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    // graphql 的查詢風格
    body: JSON.stringify({ query: `{ getUserInfo { nickname avatarUrl city gender } }` })
  }).then(res => res.json());
}

User.getInitialProps = async ctx => {
  const result = await getUserInfo(ctx);
  // 將 result 打印出來,因爲未登錄,所以首次進入這裏肯定是包含錯誤信息的
  console.log(result);

  return {};
}

export default User;

組件編寫完畢後,我們進入 http://localhost:3000/user。此時發現頁面是空白的,是因爲進入了 if (!userInfo) return null; 這一步的邏輯。我們需要看看控制檯的輸出,發現內容如下:

因爲請求發生在服務端的 getInitialProps,此時的輸出是在命令行輸出的,並不會在瀏覽器控制檯輸出,寫服務端渲染的項目這一點要習慣。

{ 
  errors:
   [ 
     { message: '401: No Auth', locations: [Array], path: [Array] } 
    ],
  data: { getUserInfo: null } 
}

拿到報錯信息之後,我們只需要處理報錯信息,然後在出現 401 登錄未授權時跳轉到登錄界面即可,所以在 getInitialProps 函數中再加入以下邏輯:

import Router from "next/router";

// 重定向函數
const redirect = ({ req, res }, path) => {
  // 如果包含 req 信息則表示代碼運行在服務端
  if (req) {
    res.writeHead(302, { Location: path });
    res.end();
  } else {
    // 客戶端跳轉方式
    Router.push(path);
  }
};

User.getInitialProps = async ctx => {
  const result = await getUserInfo(ctx);
  const { errors, data } = result;
  // 判斷是否爲鑑權失敗錯誤
  if (errors && errors.length > 0 && errors[0].message.startsWith("401")) {
    return redirect(ctx, '/login');
  }

  return { userInfo: data.getUserInfo };
}

這裏格外需要注意的一點就是,你的代碼可能運行在服務端也可能運行在客戶端,所以在很多地方需要進行判斷,執行對應的函數,這樣纔是一個具有健壯性的服務端渲染項目。在上面的例子中,重定向函數就對環境進行了判斷,從而執行對應的跳轉方法,防止頁面出錯。

現在刷新頁面,我們應該跳轉到了登錄頁面,那麼我們現在就來把登錄頁面實現一下,鑑於方便實現,我們登錄界面只放一個登錄按鈕,完成登錄功能,實現如下:

// ./pages/login/index.jsx
import React from "react";
import { Button } from "antd";
import Router from "next/router";
import fetch from "isomorphic-fetch";

const Login = () => {
  const login = async () => {
    const result = await fetch("http://dev-api.jt-gmall.com/member", {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ query: `{ loginQuickly { token } }` })
    }).then(res => res.json());

    // 打印登錄結果
    console.log(result);
  }
  
  return (
    <section style={{ padding: 20 }}>
      <Button type="primary" onClick={login}>一鍵登錄</Button>
    </section>
  )
}

export default Login;

代碼寫到這裏就可以先停一下,思考一下問題了,打開頁面,點擊一鍵登錄按鈕,這時候控制會輸出響應結果如下:

{
  "data": {
    "loginQuickly": {
      "token": "7cdbd84e994f7be693b6e578549777869e086b9db634363635e2f29b136df1a1"
    }
  }
}

登錄拿到了一個 token 信息,現在我們的問題就變成了,如何存儲 token,保持登錄態的持久化處理。我們需要用到兩個插件,分別是 js-cookienext-cookies,前者用於在客戶端存儲 cookie,而後者用於在服務端和客戶端獲取 cookie,我們先用 npm 進行安裝:

npm i js-cookie next-cookies -S

隨後我們修改 ./pages/login/index.jsx,在登錄成功後將 token 信息存儲到 cookie 之中,同時我們也需要修改 ./pages/user/index.jsx,將 token 作爲請求頭髮送給 api 服務端,代碼實現如下:

// ./pages/login/index.jsx
//...
import cookie from 'js-cookie';

const login = async () => {
    //...

    const { token } = result.data.loginQuickly;
    cookie.set("token", token);
    // 存儲 token 後跳轉到個人信息界面
    Router.push("/user");
  }
// ./pages/login/index.jsx
//...
import nextCookie from 'next-cookies';

//...
// 獲取用戶信息
const getUserInfo = async (ctx) => {
  // 在 cookie 中獲取 token 信息
  const { token } = nextCookie(ctx);
  return fetch("http://dev-api.jt-gmall.com/member", {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      // 在首部帶上身份認證信息 token
      'x-auth-token': token
    },
    // graphql 的查詢風格
    body: JSON.stringify({ query: `{ getUserInfo { nickname avatarUrl city gender } }` })
  }).then(res => res.json());
}

//...

之所以使用 cookie 存儲 token 是利用了 cookie 會隨着請求發送給服務端,服務端就有能力獲取到客戶端存儲的 cookie,而 localStorage 並沒有該特性。

我們在 user 頁面刷新,查看控制檯(下圖),會發現 html 文件的請求頭中有 cookie 信息,服務端獲取 cookie 的原理就是在請求頭中獲取客戶端傳輸過來的 cookie,這也是服務端渲染和客戶端渲染的一大區別。

請求頭信息

到這一步,關於登錄鑑權路由控制的問題已經解決。這裏的話,再拋出一個問題,我們的請求是在服務端發起的,如果發生了錯誤,html 無法正常填充,我們應該怎麼處理?帶着這個問題,進入下一章吧。

對報錯信息的處理

我們的代碼在大部分時候都是可控可預測的,一般來說只有網絡請求是不好預測的,所以我們從下面這段函數來思考如何處理網絡請求錯誤:

// ./pages/vegetables/index.jsx
// ...
const fetchVegetable = (page, pageSize) => {
  return fetch("http://dev-api.jt-gmall.com/mall", {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    // graphql 的查詢風格
    body: JSON.stringify({ query: `{ vegetableList (page: ${page}, pageSize: ${pageSize}) { page, pageSize, total, items { _id, name, poster, price } } }` })
  }).then(res => res.json());
}

Vegetables.getInitialProps = async ctx => {
  const result = await fetchVegetable(1, 10);

  // 將查詢結果返回,綁定在 props 上
  return result.data;
}

我們修改一行代碼,把請求信息中的 ... vegetableList (page ... 修改成 ... vegetableListError (page ... 來測試一下會發現什麼。

修改過後打開頁面進行刷新,發現界面變成空白,這是因爲 if (!vegetableList) return null; 這行代碼導致的,我們在 getInitialProps 中輸出一下請求結果 result,會發現返回的對象中包含 errors 信息。那麼問題就很簡單了,我們只需要把錯誤信息傳遞給組件的 props,然後交由組件處理下一步邏輯就好了,具體實現如下:

const Vegetables = ({ errors, vegetableList }) => {
  if (errors) return <section>{JSON.stringify(errors)}</section>

  //...
}

Vegetables.getInitialProps = async ctx => {
  const result = await fetchVegetable(1, 10);

  if (result.errors) {
    return { errors: result.errors };
  }

  // 將查詢結果返回,綁定在 props 上
  return result.data;
}

錯誤信息

到這裏你應該就明白了,我們可以在開發環境將詳細錯誤信息直接呈現,方便我們調試,而正式環境我們可以返回一個指定的錯誤頁面,例如 500 服務器開小差

到這裏就結束了嗎?當然沒有,這樣的話我們就需要在每個頁面加入這個錯誤處理,這樣的操作非常繁瑣而且缺乏健壯性,所以我們需要寫一個高階組件來進行錯誤的處理,我們新建文件 ./components/withError.jsx

// ./components/withError.jsx
import React from "react";

const WithError = () => WrappedComponent => {
  return class Error extends React.PureComponent {
    static async getInitialProps(ctx) {
      const result = WrappedComponent.getInitialProps && (await WrappedComponent.getInitialProps(ctx));

      // 這裏從業務上來說與直接返回 result 並無區別
      // 這裏只是強調對發生錯誤時的特殊處理
      if (result.errors) {
        return { errors: result.errors };
      }
      return result.data;
    }

    render() {
      const { errors } = this.props;
      if (errors && errors.length > 0) return <section>Error: {JSON.stringify(errors)}</section>
      return <WrappedComponent {...this.props} />;
    }
  }
}

export default WithError;

同時我們也需要修改 ./pages/vegetables/index.jsx 中的 getInitialProps 函數,將對響應結果的處理延遲到組合類,同時刪除之前添加的所有錯誤處理函數,修改後如下:

const Vegetables = ({ vegetableList }) => {
  if (!vegetableList) return null;
  //...
}

Vegetables.getInitialProps = async ctx => {
  const result = await fetchVegetable(1, 10);

  // 將查詢結果返回,綁定在 props 上
  return result;
}

export default WithError()(Vegetables);

此時刷新頁面,會發現結果和剛纔是一樣的,只不過我們只需要在導出組件的時候進行 WithError()(Vegetables) 操作即可。這個函數其實也可以放在根組件,這個就交由大家自己去探究了。

結語

此時此刻,React 服務端渲染入門教程已經結束了,相信大家對服務端渲染也有了更加深刻的理解。如果想要了解更多,可以看看 next 的官網教程,並跟着寫一個實際的項目最好,這樣的提升是最大的。

最後祝願大家都能夠掌握使用服務端渲染,前端技術日益精進!

本教程源碼

原文地址

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章