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

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

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

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

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

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

第一個 Hello World 頁面

咱們先新建一個目錄,名爲 jt-gmall,而後進入目錄,在目錄下新建 package.json,添加如下內容:react

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

而後咱們須要安裝相關依賴:git

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

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

npm i next react react-dom antd -S

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

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

export default Home;

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

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

咱們第一步已經完成,可是咱們會感受這和咱們平時的寫法差別不大,那麼實現上有什麼差別嗎?後端

在打開控制檯查看差別以前,咱們先思考一個問題,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 的官網教程,並跟着寫一個實際的項目最好,這樣的提高是最大的。

最後祝願你們都可以掌握使用服務端渲染,前端技術日益精進!

本教程源碼

原文地址

相關文章
相關標籤/搜索